【Linux实践系列】:用c语言实现一个shell外壳程序

发布于:2025-03-04 ⋅ 阅读:(10) ⋅ 点赞:(0)

🔥本文专栏:Linux Linux实践项目
🌸博主主页:努力努力再努力wz

在这里插入图片描述
在这里插入图片描述

那么今天我们就要进入Linux的实践环节,那么我们之前学习了进程控制相关的几个知识点,比如进程的终止以及进程的等待和进程的替换,那么我们接下来就要结合前面所讲的进程控制相关的接口比如fork以及waitpid和execl等来自己实现一个命令行解释器,那么废话不多说,让我们进入正文

★★★ 本文前置知识
进程的替换
进程的终止与等待
进程的概念


shell实现的框架

那么在用c语言真正上手实操我们的shell外壳程序时,那么我们脑海里得有一个大体的实现框架,也就是所谓的一个整体思路,在有了整体思路后我们再去谈具体每个模块的细节,那么我们首先就先从shell本身的工作原理作为切入口入手

那么我们的shell也就是我们的命令行解释器,那么它的工作就是获取用户输入的指令,然后来执行用户输入的指令,那么我们知道用户输入的指令的本质上就是一个字符串,所以shell首先就得读取到用户输入的字符串,然后保存在一个字符数组中,读取到用户输入的字符串之后,那么紧接着下一步便是解析用户输入的字符串,那么我们用户输入的指令无非可以分成两大部分,分别是指令部分以及参数部分,那么这里我们就需要定义一个字符指针数组,那么数组的每一个元素就是一个指针,那么指针指向的就是一个字符串,那么我们用户输入的字符串的指令部分就保存在字符指针数组的第一个元素也就是下标为0的位置,那么参数部分则依次保存在之后的位置,比如我们用户输入的指令是ls -l -a,那么此时我们就要解析为三部分,分别是是指令部分的“ls”字符串以及两个参数部分的字符串"-l”和“-a”,将这三个字符串则是依次保存到我们的字符指针数组下标为0和1和2的位置当中

而具体的解析这三部分字符串则需要用到我们c语言的strtok函数,那么具体细节我们下文再说,那么这里我们讨论的是大的框架与思路,所以我们可以专门定义一个函数来完成这个字符串解析的模块,它的工作就是解析用户输入的字符串将其指令部分以及参数部分的各个字符串分别保存到字符指针数组不同位置中,并且返回命令行的个数,比如用户输入的是ls -l,那么将其保存在字符指针数组char* argv[]并返回的个数就是2,,而如果是pwd,将其保存在字符指针数组char* argv[]并返回的个数就是1

那么接下来解析完用户输入的字符串之后,那么我们就可以来执行用户输入的指令了,那么这里我们知道我们用户输入的各种指令本质上就是在特定路径下保存的一个可执行文件,那么指令的执行本质上就是创建一个进程,那么我们shell执行这些指令就得利用fork函数来创建一个子进程,然后我们利用fork函数的返回值,将父子进程分成不同的执行流,那么在子进程的执行流代码片段中,我们就可以利用进程的替换,那么将我们的子进程的内容替换为我们要执行指令所对应的进程的上下文,那么我们父进程的执行流代码片段则是等待我们子进程的退出结果,那么我们就需要用waitpid函数来获取子进程的退出码

最后获取完子进程的退出码,如果子进程没有正常终止,那么就得将情况返回给用户,也就是将错误信息打印到终端,如果子进程正常终止然后下一步就是重复我们之前上文的环节,那么重复也就意味着我们实现的时候最后这些逻辑的代码都要封装到一个死循环当中。

那么这就是我们实现shell外壳程序的一个大框将,那么我们可以简单将其分为几个模块,分别是获取用户输入->解析用户输入->创建子进程->子进程的替换->父进程等待获取子进程的退出情况->重复上述步骤

那么看到这些模块,想必你一定还有一些疑问,那么接下来我就会在下文补充每个模块的代码实现以及注意的一些细节,和其他的模块的补充,那么有了大框架之后,那么接下来就让我们具体实现每一个模块了
在这里插入图片描述

shell各个模块的实现

1.获取用户输入

那么我们的shell首先得获取用户输入的字符串,那么我们知道在c语言中,我们获取用户输入的字符串常见就是使用我们的scanf函数来获取用户的输入,但是scanf函数有一个缺陷就是一旦读取到空格的时候,那么scanf便停止读取输入,而我们用户在输入字符串的时候,会手动用空格隔开指令部分与参数部分,所以我们就不能采取scanf函数来获取输入,所以这里我们需要用fgets函数,那么fgets函数则是将从标准输入流中读取用户的输入,遇到换行符停止,那么我们可以指定其在输入流中读取的字符串的长度也就是fgets的第二个参数,那么将其保存到一个临时字符数组中,如果读取失败,那么fgets则会返回NULL,读取成功fgets则会返回保存数组的地址

  • fgets

    头文件:<string.h>

  • 功能:获取用户输入的字符串,末尾自动添加\0

    :函数原型

char *fgets(char *str, int n, FILE *stream);

而我们知道用户在输入之前,我们终端都会显示一个命令行提示符,会显示我们当前登录的用户名以及所处的工作目录和运行的主机名称,所以我们在获取用户输入之前,我们得先打印一个字符串也就是命令行提示符,而切记,我们的shell命令行解释器本质也是一个进程,所以这命令行提示符的每一个信息就保存在我们当前进程的环境变量中,我们需要通过我们的系统调用接口getenv来获取其中特定字段的环境变量,这里就需要获取到我们的USER以及HOSTNMAE以及PWD这三个字段,那么我们只需要向getenv函数传递这三个字符串的指针,那么他会依次匹配各个字段的名称所对应的字符串并返回对应的值,也就是字符串的起始地址

代码实现:

  printf("[%s@%s %s]$",getenv("USER"),getenv("HOSTNAME"),getenv("PWD"));
	  	  if(fgets(temp,sizeof(temp),stdin)==NULL)
	  	  {
	  	  	  perror("fgets");
	  	  	  continue;
			}

2.解析命令行

那么现在我们获取了我们的用户输入的字符串之后,那么我们是将用户输入的字符串保存在一个临时字符数组里,那么接下来我们就要将这个字符串给分割,将其指令部分以及参数部分的各个字符串给分割保存到我们的字符指针数组当中,那么我们这里就专门可以定义一个函数来完成字符串解析模块,并返回命令行参数的个数,那么我们知道我们用户输入的字符串会手动以空格分割,那么这里我们就需要调用我们的字符串函数也就是strtok函数来分割我们的字符串按照空格作为分隔符。但是在分割之前,我们又得注意一个细节,也就是我们用户输入完一个字符串,那么它会敲一个回车键来表示输入的结束,而回车则是对应的一个换行符\n,他会被我们的fegts给读取到,那么意味着在我们的字符串的末尾可能会有一个回车换行符

而回车换行符并不是我们一个有效的字符信息,所以我们在解析之前得去掉这个换行符,所以我们就利用我们的strlen函数首先获取到我们这个字符串包括空格以及换行符的总长度,那么如果我判断用户输入的字符串的len-1位置处的字符处是换行符,那么我们就将len-1位置用\0来覆盖,而\0是标记字符串结尾的标志,那么这样我们就可以消去末尾的回车换行符,这里是其中一个关键的实现细节

那么第二个细节就是我们的strtok函数的使用,那么我们strtok函数第一次调用的时候要传递我们要分割的字符串的首元素的地址,那么strtok内部会访问到一个静态的全局变量,这个静态变量是用来保存下一次分割的位置,那么我们每次调用strtok函数的时候,会从分割的起始位置处往后扫描直到遇到分隔符,然后将分隔符的位置修改为\0,然后返回该分割起始位置的指针,而我们知道\0是标记字符串的结尾,所以返回分割起始位置的指针就达到了一个分割子串的一个效果
而下一次调用strtok函数的时候,那么我们就不用传要分割的字符串的首元素的地址,因为上文说过strtok内部能访问到一个记录下一次分割位置的全局变量,那么之后的调用我们只需要传递一个NULL即可,它内部会继续从这个全局变量记录的位置开始扫描到下一个分隔符,将其修改为\0,最后如果我们开始的分割的位置是\0,也就是字符串末尾,没有更多的子串来分割时候,那么strtok就返回一个NULL


  • strtok
    头文件:<string.h>
    -功能:分割字符串

    :函数原型

 char *strtok(char *str, const char *delim);

在这个函数中我们就定义一个int类型的argc变量来跟踪命令行的个数,初始化为0,而我们将分割的字符串保存在对应的字符数组的下标就是argc的值,保存之后接着递增argc,最后返回的该argc就是我们的命令行的个数

代码实现:

int getString(char temp[],char* argv[])
{
       int len=strlen(temp);
       if(len>0&&temp[len-1]=='\n')
       {
           temp[len-1]='\0';
           len--;
       }
       int argc=0;
       char* toke=strtok(temp," ");
       while(toke!=NULL&&argc<length-1)
       {
              argv[argc++]=toke;
             toke=strtok(NULL," ");
       }
       argv[argc]=NULL;
       return argc;
}

3.指令判断

那么这里在我们上文介绍实现我们的shell外壳程序的框架的时候模块的时候,其实我们故意漏了一个模块,那就是指令的判断,那么想必你一定会有所疑问,那么就是我们获取解析完用户输入的指令之后,我们为什么还要进行指令的判断呢?直接通通交给子进程去执行不就完了吗,我们父进程也就是shell外壳程序的本职工作不就是获取用户的输入吗

那么这里我们就要注意的就是,我们用户其中输入的指令,比如cd指令,也就是更改我们进程所处的工作目录,那么它针对的对象其实是我们的父进程也就是我们的shell外壳程序,那么如果我们把这个指令交给了子进程去完成,将子进程替换为cd指令所对应的上下文,那么子进程的执行是不会影响父进程的,那么子进程执行结束退出之后,我们shell进程所处的工作目录没有进行更改,那么所以我们对于有些指令,也就是针对当前父进程shell的运行环境的指令,比如cd,比如PWD指令,那么它就不能交给子进程来执行,而是得交给父进程来自己完成,那么这些指令也就是我们的内置指令

那么内置指令那么就不再是一个编写好的可执行文件,那么它是通常是一个实现好的库函数或者直接嵌套在我们的shell进程所对应的代码中,所以我们自己用c语言实现的时候,那么我们就首先准备定义一个全局属性的字符指针数组,然后该数组里面记录了我们所有的内置命令所对应的字符串,那么当解析完用户的指令之后,解析完保存的字符指针数组的第一个位置就是对应用户输入的指令部分的字符串,所以下一步我们依次匹配保存的所有内置命令对应的字符串,如果匹配成功,那么意味着是内置指令,就直接交给我们父进程执行,匹配失败则说明该指令不是内置命令,就交给子进程来执行,那么我们匹配的过程以及内置命令的执行的过程都可以定义两个函数来分别实现这两个模块,那么其中字符串的匹配就需要用到strcmp函数来实现

而所谓的内置命令,他的底层实现的时候本质其实就是依赖用c编写的库函数或者系统调用,比如cd内置命令,那么它就是用chdir库函数来实现的,那么这个库函数的作用就是能够访问到当前进程的环境变量中的工作目录字段,然后修改当前所处的工作目录,而pwd内置命令的本质其实也就是依赖getcwd库函数,那么该库函数会访问到该进程中环境变量记录当前所处也就是工作目录的字段PWD,将其值记录保存到一个数组当中,并且返回指向该数组的指针

  • chdir
    头文件:<unistd.h>

    :函数原型

  int chdir(const char *path);
  
  • getcwd
    头文件:<unistd.h>

    :函数原型

    char *getcwd(char *buf, size_t size);
  
  

那么这里我在实现的时候,就只判断了cd以及pwd这两种内置命令,那么我们可以下来直接去添加更多的内置命令,然后查询对应实现所依赖的库函数或者系统调用接口

代码实现:

bool check(char* argv[])//指令的判断
{
    for(int i=0;order[i]!=NULL;i++)
    {
         if(strcmp(argv[0],order[i])==0)//如果该指令是内置命令就返回true
         {
             return true;
         }
    }
      return false;
}
void ordercomplete(int argc,char* argv[])//内置命令的执行
{
       if(strcmp(argv[0],"cd")==0)
       {
                if(argc==2)//cd指令最多只能两个参数,其中第二个参数就是跳转的工作目录
                {
               if (chdir(argv[1]) != 0) {
                perror("chdir");
            }
                }else
                {
                    printf("error: expected argument for 'cd'\n");
                }
       }
       if(strcmp(argv[0],"pwd")==0)
       {
              char cwd[length];  // 定义一个字符数组错误的来保存我们的当前所处的工作目录

    if (getcwd(cwd, sizeof(cwd)) != NULL) {
        printf("Current working directory: %s\n", cwd);
    } else {
        perror("getcwd failed");  // 输出错误信息
    }
       }
}

5.子进程执行指令

那么剩下几个模块的细节和实现就很简单了,接下来这个模块就调用fork函数来创建一个子进程,然后利用fork函数的返回值,让父子进程有着不同的执行流,然后我们在子进程对应的执行流代码片段中,调用进程的替换的系统接口,而这里我们调用的exec族函数,一定是不能带有l的比如execl以及execlp等,因为我们不知道用户输入的命令行个数,所以不能用可变参数列表的进程替换接口,这里要注意
而我们用户输入的字符串都解析在了一个字符指针数组中,所以我们传的参数肯定就是一个数组,所以这里我们选择进程替换的函数就是execvp,那么它可以默认在环境变量的PATH中去匹配我们用户输入的指令所对应的可执行文件
那么我们用execvp函数来将子进程替换为指令所对应的进程的上下文,但是我们知道我们进程替换会出现调用失败的情况,那么调用失败的结果则是会执行进程替换接口之后的代码,那么我们就在execvp函数后面打印一个错误信息并且返回一个特殊的退出码

6.父进程的等待

那么我们父进程对应的执行流代码片段则是等待我们子进程的退出情况,所以我们需要调用waitpid函数来获取子进程的退出码,那么waitpid我们的等待方式则是设置为阻塞式等待,那么它的返回值就分别对应两种情况,要么等待成功并且获取到子进程的退出码,对应的返回值就是子进程的pid,而等待失败则是返回-1,我们对于等待失败则是要打印错误信息以及子进程的退出码

完整实现

那么将我们上面的6个模块所对应代码融合就是我们的shell的外壳程序,那么其实我们在实现shell外壳程序的时候,其实shell的整体实现难度不大,主要考察你对shell的工作原理的理解程度和几个系统调用接口的熟悉程度,shell实现的真正的难点其实在它各个模块实现的细节上,很容易出错,其中就考察我们对于一些c语言的库函数的掌握情况,那么接下来我就给出完成的shell的c语言代码的实现

#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<stdbool.h>
#define length 1000
#define EXIT_FAIL 40
const char* order[]={"cd","pwd",NULL};

int getString(char temp[],char* argv[])
{
       int len=strlen(temp);
       if(len>0&&temp[len-1]=='\n')
       {
           temp[len-1]='\0';
           len--;
       }
       int argc=0;
       char* toke=strtok(temp," ");
       while(toke!=NULL&&argc<length-1)
       {
              argv[argc++]=toke;
             toke=strtok(NULL," ");
       }
       argv[argc]=NULL;
       return argc;
}
bool check(char* argv[])
{
    for(int i=0;order[i]!=NULL;i++)
    {
         if(strcmp(argv[0],order[i])==0)
         {
             return true;
         }
    }
      return false;
}
void ordercomplete(int argc,char* argv[])
{
       if(strcmp(argv[0],"cd")==0)
       {
                if(argc==2)
                {
               if (chdir(argv[1]) != 0) {
                perror("chdir");
            }
                }else
                {
                    printf("error: expected argument for 'cd'\n");
                }
       }
       if(strcmp(argv[0],"pwd")==0)
       {
              char cwd[length];  // 定义一个足够大的缓冲区来存储路径

    if (getcwd(cwd, sizeof(cwd)) != NULL) {
        printf("Current working directory: %s\n", cwd);
    } else {
        perror("getcwd failed");  // 输出错误信息
    }
       }
}
int main()
{
      int argc;
      char* argv[length];
      char temp[length];
      
      while(1)
      {
            printf("[%s@%s %s]$",getenv("USER"),getenv("HOSTNAME"),getenv("PWD"));
            if(fgets(temp,sizeof(temp),stdin)==NULL)
            {
                  perror("fgets");
                  continue;
            }
            argc=getString(temp,argv);
            if(argc==0)
            {
                continue;
            }
            if(check(argv))
            {
                ordercomplete(argc,argv);
                continue;
            }
            int id=fork();
            if(id==0)
            {
                execvp(argv[0],argv);
                perror("execvp");
                exit(EXIT_FAIL);
            }else
            {
                int status;
                int m=waitpid(id,&status,0);
                if(m<0)
                {
                    perror("waitpid");
                }else
                {
                     if(WIFEXITED(status))
                     {
                           if(WEXITSTATUS(status)==40)
                           {
                                printf("error\n");
                           }
                     }
                }
                
            }
      }
      return 0;
}

在Linux上的运行截图:
在这里插入图片描述

结语

那么这就是用c语言实现shell外壳程序的所有内容啦,那么它也是我第一个学习Linux所完成的一个小项目,那么它这个小项目的教学价值以及学习意义其实非常高,因为它不仅可以帮组你了解shell外壳程序的工作原理,更重要的是帮组你更能熟练掌握运用那几个关于进程控制十分重要的系统调用接口其中比如fork以及waitpid等,那么我的下一篇Linux文章就正式进入文件系统啦,我会持续更新,希望大家多多关注,那么如果本篇文章对你有所帮组的话,那么还请多多三连加关注哦,你的支持就是我最大的动力!

在这里插入图片描述