C语言编写Linux的Shell外壳

时间:2024-04-10 10:35:20

目录

一、输出命令行

1.1 了解环境变量

1.2 获取用户名、主机名、当前路径

1.3 缓冲区改进MakeCommandLine

二、获取用户命令

2.1 读取函数的选择

2.2 细节优化

2.3 返回值

三、指令和选项分割

3.1 strtok 函数

3.2 分割实现 

四、执行命令

4.1 fork 方法

4.2 进程等待

4.3 进程替换

4.4 程序编写

五、程序优化

5.1 执行次数

5.2 检测命令是否为内建命令

5.3 子进程执行失败

5.4 命令行路径更改

六、完整代码


在学习之前我们要认识到,Shell外壳中的命令行以及我们输入的指令都是字符串!

首先我们要创建两个文件:MyShell.cmakefile ,一个存储我们的 Shell 外壳,一个方便操作

其次,我们看一下 MyShell.c 中需要包含的头文件:

  1 #include <stdio.h>
  2 #include <unistd.h>     //进程创建接口
  3 #include <sys/types.h>  //进程等待
  4 #include <sys/wait.h>   //进程等待     

一、输出命令行


首先我们先来认识一下命令行,其中, Flash 是当前用户名、@ 后紧跟的是当前主机名、主机名空格后紧跟的是当前路径。所以由此我们知道,如果想打印出我们的命令行,我们至少要知道三个信息:1.用户名 2.主机名 3.当前路径

1.1 了解环境变量

这三个信息如何拿去呢?我们的操作系统中有自带的环境变量,在环境变量中存放着我们需要的三个信息。我们如何查我们的环境变量呢?

有三种方法:

1.命令行参数 -> [env] ,当我们在命令行输入指令,系统就会显示出一大串信息(这里有省略),其中就有我们需要的 [USER] [PWD] [HOSTNAME]

2.ENVIRON 等第三方提供的接口,这里不做详细介绍

3.使用C语言提供的接口函数,如 getenv() ,我们这里使用该方式,下面会详细介绍。

1.2 获取用户名、主机名、当前路径

getenv 函数 —— stdlib.h C语言标准库

其中,传参 [const char* name] 表示的是我们需要从环境变量中拿取的变量名称,我们这里需要拿到的就是上述使用命令行参数 [env] 读取到的 [USER] [HOSTNAME] [PWD],它的返回值就是我们需要的一个字符串。

现在我们就可以来编写我们的函数了。

char* GetUserName()
{
  char* User = getenv("USER");
  if (!User) return "None";
  return User;
}
char* GetHostName()
{
  char* Host = getenv("HOSTNAME");
  if (!Host) return "None";
  return Host;
}
char* GetCwd()
{
  char* PWD = getenv("PWD");
  if (!PWD) return "None";
  return PWD;
}
void MakeCommandLine()
{
  char* UserName = GetUserName();
  char* HostName = GetHostName();
  char* Cwd = GetCwd();
  printf("[%s@%s %s]>\n", UserName, HostName, Cwd);
}
int main()
{
  MakeCommandLine();
  return 0;
}

我们还可以继续改进一下,使用缓冲区的概念

1.3 缓冲区改进MakeCommandLine

首先我们先宏定义一个缓冲区的大小,这里我设置为 256 。

#define SIZE 256
char Line[SIZE];//自定义缓冲区

其次我们再来学习一个函数 snprintf

按照参数的顺序,依次是 s : 需要写入的目的地 n : 写入字符的数量 format : 标准格式

使用 snprintf 就可以像我们自定义的缓冲区里写啦!

snprintf(Line, sizeof(Line), "[%s@%s %s]>", UserName, HostName, Cwd);
printf("%s\n", Line);

成品: 

#define SIZE 256
//只有MakeCommandLine函数变化,只展示该函数
void MakeCommandLine()
{
  char Line[SIZE];
  char* UserName = GetUserName();
  char* HostName = GetHostName();
  char* Cwd = GetCwd();
  snprintf(Line, sizeof(Line), "[%s@%s %s]>", UserName, HostName, Cwd);
  printf("%s\n", Line);
}

但是还有一个细节,当我们想让程序慢一点结束时,使用 sleep 函数,就会发生神奇的一幕:

为什么没有立刻打印呢?原因是 stdout 的缓冲问题。printf 是行缓冲的,通常情况下,当遇到换行符('\n')时,缓冲区的内容会被送往 stdout 进行输出。然而在某些情况下,如果在调用 sleep 之前没有刷新缓冲区,那么输出可能会延迟直到缓冲区被刷新。

我们的解决方法是使用 fflush 函数!这将刷新(即清空并发送)包含 printf 输出的缓冲区,从而确保立即看到输出:

snprintf(Line, sizeof(Line), "[%s@%s %s]>", UserName, HostName, Cwd);
printf("%s\n", Line);
fflush(stdout);
sleep(5);

二、获取用户命令

首先,我们在输出命令行时,为了方便阅读,用 printf 输出 Line 时加了 '\n' ,我们现在要保证命令行和命令在同一行,所以现在我们要删除掉 '\n' 。

2.1 读取函数的选择

我们可以继续使用 scanf 函数来读取命令吗?按照我们输入的命令,如 [ls -a -l] ,他们都是以空格为分隔符,显然与我们的 sacnf 发生了冲突,而且无法控制每次输入命令的空格数量,所以我们不能使用 scanf 读取输入。

我们的命令都是用行读取,C语言也提供了这样的函数 fgets 。

其中,返回指向读取到字符开头的指针,传参依次为读取字符后存放的位置,读取字符的长度,读取字符的位置。 

 int GetUserCommand(char Command[], size_t n)
 {                                               
    char* s = fgets(Command, n, stdin);
    if (!s) return -1;                                                    
 }     
 int main()                                                         
 {                                                                  
    //输出命令行                                                     
    MakeCommandLine();                                               
    //读取用户命令                                                   
    char UserCommand[SIZE];                                          
    GetUserCommand(UserCommand, sizeof(UserCommand));  
    printf("echo : %s\n", UserCommand); //打印验证一下是否被读取                                            
    return 0;
 }

2.2 细节优化

现在又来了一个细节问题:

fgets 按行读取,如果我们想执行就必须按 [回车] ,假设我们输入的是"Hello World",那么其读取到的就是"Hello World\n",再加上我们自己写的 printf 中的 '\n' 就变成了两行,下面我们进一步优化一下,只需要把命令的最后一个字符改成 '\0' 即可:

 int GetUserCommand(char Command[], size_t n)
 {                                               
    char* s = fgets(Command, n, stdin);
    if (!s) return -1;
    Command[strlen(Command) - 1] = '\0';                                                    
 } 

2.3 返回值

关于函数的返回值,为了与 [return -1] 区分开,也为了更好地执行命令,我们可以返回一下读取到的命令长度,这样当命令 [> 0] 时,我们再继续执行,否则直接退出:

 int GetUserCommand(char Command[], size_t n)
 {                                               
    char* s = fgets(Command, n, stdin);
    if (!s) return -1;
    Command[strlen(Command) - 1] = '\0';    
    return strlen(Command);                                                
 } 
 int main()                                                         
 {                                                                  
    //输出命令行                                                     
    MakeCommandLine();                                               
    //读取用户命令                                                   
    char UserCommand[SIZE];                                          
    int n = GetUserCommand(UserCommand, sizeof(UserCommand));  
    (void)n;//暂时不搞,先强转一下,防止警告                                         
    return 0;
 }

三、指令和选项分割

像我们上面使用的 []ls -a -l] ,它们是由指令和选项构成,所以我们如果要执行,肯定也要把读取用户传入的字符串分割成下面的形式, [ls] [-a] [-l] 。

3.1 strtok 函数

那么如何分割呢?这又要使用到C语言中的字符串函数 strtok 。

其中,返回值是分割后的小字符串,传入参数 str 是要分割的字符串, delimiters 是分隔符

如果首次分割是已经传入参数 str ,那么再次调用时把 str 设置为 NULL ,strtok就会默认继续分割上次的字符串。

一说到分隔符,我们这里的分隔符显而易见的就是空格啦!由于我们的分割是把一个字符串分割成若干小字符串,所以我们就可以直接定义一个全局的数组,用来挨个存放这些小字符串:

但是要注意, strtok 函数使用的分隔符都是字符串,我们的空格不能设置为 ' ' ,而应该为 " "

 #define SEP " "    

3.2 分割实现 

void SplitCommand(char Command[], size_t n)
{
  (void)n;
  argv[0] = strtok(Command, SEP);
  int index = 1;
  while ((argv[index++] = strtok(NULL, SEP)));//strtok如果无法分割,则返回NULL,此时argv最后一个元组直接被赋值为NULL且while循环结束
}

我们还可以通过打印来验证是否正确:

 int i = 0;                                                               
 for (; argv[i]; i++)
 {
     printf("%s ", argv[i]);
 }
 printf("\n");

四、执行命令

我们之前讲过 Bash 会创建子进程,为了保证安全,都是子进程在执行我们的命令,而我们今天要实现的也是使用子进程来帮助我们执行命令。

4.1 fork 方法

fork 函数并不是我们的C语言函数,而是操作系统提供的,所以我们可以用 man 2 fork 查询

这里不做过多说明,实在不知道可以移步到Linux进程概念(1)-CSDN博客

如果执行的是子进程,那么返回值为0,如果是父进程,会返回子进程的 pid

4.2 进程等待

我们之前说过,如果父进程先于子进程退出,子进程就会变成僵尸进程,为了避免这种影响,父进程可以通过进程等待的方式,回收子进程资源,获取子进程退出信息。

下面我们来介绍几个进程等待的方法:

wait 方法:

    返回值:等待成功返回被等待子进程的 pid ,失败返回 -1

    参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL

waitpid 方法:

    返回值:

       当正常返回的时候 waitpid 返回收集到的子进程的进程ID;如果设置了选项 WNOHANG,而调用中 waitpid 发现没有已退出的子进程可收集,则返回 0;如果调用中出错,则返回 -1,这时 errno 会被设置成相应的值以指示错误所在;

    参数:

    pid:
       pid=-1,等待任一个子进程。与wait等效。
       pid>0.等待其进程ID与pid相等的子进程。
    status:
       WIFEXITED: 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
       WEXITSTATUS: 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
    options:
       WNOHANG: 若 pid 指定的子进程没有结束,则 waitpid() 函数返回0,不予以等待。

       若正常结束,则返回该子进程的ID。

4.3 进程替换

我们用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。

有六种以 exec 开头的函数,统称为 exec 函数:

#include <unistd.h>

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);

这些函数都可以用 man execl 查询:

4.4 程序编写

void ExecuteCommand(char Command[], size_t n)
{
  pid_t id = fork();
  if (id == 0)//子进程,执行命令
  {
   execvp(argv[0],argv);
  }
  else if (id > 0)//父进程
  {
    int status = 0;
    waitpid(id, &status, 0);
  }
  else//fork创建失败,直接退出
  {
    exit(1);
  }
}

下面我们来看一下完整代码以及效果图:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <unistd.h>     //进程创建接口
#include <sys/types.h>  //进程等待
#include <sys/wait.h>   //进程等待

#define SIZE 256
#define NUM 16
#define SEP " "

char* argv[NUM];

char* GetUserName()
{
  char* User = getenv("USER");
  if (!User) return "None";
  return User;
}
char* GetHostName()
{
  char* Host = getenv("HOSTNAME");
  if (!Host) return "None";
  return Host;
}
char* GetCwd()
{
  char* PWD = getenv("PWD");
  if (!PWD) return "None";
  return PWD;
}
void MakeCommandLine()
{
  char Line[SIZE];
  char* UserName = GetUserName();
  char* HostName = GetHostName();
  char* Cwd = GetCwd();
  snprintf(Line, sizeof(Line), "[%s@%s %s]>", UserName, HostName, Cwd);
  printf("%s", Line);
  fflush(stdout);
}
int GetUserCommand(char Command[], size_t n)
{
  char* s = fgets(Command, n, stdin);
  if (!s) return -1;
  Command[strlen(Command) - 1] = '\0';
  return strlen(Command);
}
void SplitCommand(char Command[], size_t n)
{
  (void)n;
  argv[0] = strtok(Command, SEP);
  int index = 1;
  while ((argv[index++] = strtok(NULL, SEP)));
      //strtok如果无法分割,则返回NULL,此时argv最后一个元组直接被赋值为NULL且while循环结束
}
void ExecuteCommand(char Command[], size_t n)
{
  pid_t id = fork();
  if (id == 0)//子进程,执行命令
  {
   execvp(argv[0],argv);
  }
  else if (id > 0)//父进程
  {
    int status = 0;
    waitpid(id, &status, 0);
  }
  else//fork创建失败,直接退出
  {
    exit(1);
  }

}
int main()
{
  //输出命令行
  MakeCommandLine();
  //读取用户命令
  char UserCommand[SIZE];
  int n = GetUserCommand(UserCommand, sizeof(UserCommand));
  (void)n;
  printf("echo : %s\n", UserCommand);
  //指令和选项分割
  SplitCommand(UserCommand, sizeof(UserCommand));
  //执行命令
  ExecuteCommand(UserCommand, sizeof(UserCommand));
  return 0;
}

五、程序优化

5.1 执行次数

虽然我们的 Shell 已经完成的有一点雏形了,但是怎么这个外壳只能使用一次呀?我们是不是要让他多执行几次呢?所以我们就要把这几个步骤都放到一个 while 循环中。

int main()
{
  while(1)
  {
    //输出命令行
    MakeCommandLine();
    //读取用户命令
    char UserCommand[SIZE];
    int n = GetUserCommand(UserCommand, sizeof(UserCommand));
    (void)n;
    //指令和选项分割
    SplitCommand(UserCommand, sizeof(UserCommand));
    //执行命令
    ExecuteCommand(UserCommand, sizeof(UserCommand));
   }
  return 0;
}

5.2 检测命令是否为内建命令

我们在我们的 Shell 中使用 cd 命令,但是我们的命令行无法进入某目录,这是为什么呢?
因为我们上面创建了子进程,我们的 cd 命令是让子进程执行的,和我们真正的 bash 没有关系,我们正确的做法是让父进程执行!

什么是内建命令呢?

下面我们来看看如何检测是否为内建命令。

虽然我们的 [cd] 已经可以使用,但是我们的命令行路径怎么不回退呢?
因为我们还要更改我们的环境变量!

更改环境变量要用到系统调用,需要获取当前路径,并写入环境变量中。

获取当前路径:

man 2 getcwd

写入环境变量:

man snprintf

man putenv

所以我们此时在对我们的 cd 函数做修改:

void ExecuteCd()
{
  const char* path = argv[1];
  if (path == NULL) path = GetHome();
  else chdir(path);

  char tmp[SIZE];
  getcwd(tmp, sizeof(tmp));
  snprintf(cwd, sizeof(cwd), "PWD=%s", tmp);
  putenv(cwd);
}

当然还有其他内建命令,方法都诸如此类。

5.3 子进程执行失败

我们直接在全局定义一个退出码 int lastcode ,然后在父进程中左对应的修改:

void ExecuteCommand(char Command[], size_t n)
{
  pid_t id = fork();
  if (id == 0)//子进程,执行命令
  {
   execvp(argv[0],argv);
  }
  else if (id > 0)//父进程
  {
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0)
    {
      lastcode = WEXITSTATUS(status);
      if (lastcode != 0) printf("%s:%s:%d\n", argv[0], strerror(lastcode), lastcode);
    }
  }
  else//fork创建失败,直接退出
  {
    exit(1);
  }
}

5.4 命令行路径更改

在我们的 XShell 中提供的 Shell 外壳,其命令行的路径都是相对路径,我们的 Shell 也可以改成这样,如下:

我们这里采用了宏函数,也可以使用正常函数,在使用宏函数是时,若程序是代码块,建议放在 [do while(0)] 中,如下:

#define SkipPath(p) do{ p += (strlen(p)-1); while(*p != '/') p--; }while(0)

同时在输出命令行中也调用该函数,如下:

void MakeCommandLine()
{
  char Line[SIZE];
  char* UserName = GetUserName();
  char* HostName = GetHostName();
  char* Cwd = GetCwd();
  
  SkipPath(Cwd);
  snprintf(Line, sizeof(Line), "[%s@%s %s]>", UserName, HostName, Cwd);
  printf("%s", Line);
  fflush(stdout);
}

六、完整代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#include <unistd.h>     //进程创建接口
#include <sys/types.h>  //进程等待
#include <sys/wait.h>   //进程等待

#define SIZE 256
#define NUM 16
#define SEP " "
#define SkipPath(p) do{ p += (strlen(p)-1); while(*p != '/') p--; }while(0)

char* argv[NUM];
char cwd[SIZE];
int lastcode = 0;

char* GetUserName()
{
  char* User = getenv("USER");
  if (!User) return "None";
  return User;
}
char* GetHostName()
{
  char* Host = getenv("HOSTNAME");
  if (!Host) return "None";
  return Host;
}
char* GetCwd()
{
  char* PWD = getenv("PWD");
  if (!PWD) return "None";
  return PWD;
}
void MakeCommandLine()
{
  char Line[SIZE];
  char* UserName = GetUserName();
  char* HostName = GetHostName();
  char* Cwd = GetCwd();
  
  SkipPath(Cwd);
  snprintf(Line, sizeof(Line), "[%s@%s %s]>", UserName, HostName, Cwd);
  printf("%s", Line);
  fflush(stdout);
}
int GetUserCommand(char Command[], size_t n)
{
  char* s = fgets(Command, n, stdin);
  if (!s) return -1;
  Command[strlen(Command) - 1] = '\0';
  return strlen(Command);
}
void SplitCommand(char Command[], size_t n)
{
  (void)n;
  argv[0] = strtok(Command, SEP);
  int index = 1;
  while ((argv[index++] = strtok(NULL, SEP)));
      //strtok如果无法分割,则返回NULL,此时argv最后一个元组直接被赋值为NULL且while循环结束
}
const char* GetHome()
{
  const char* home = getenv("HOME");
  if (home == NULL) return "/";
  return home;
}
void ExecuteCd()
{
  const char* path = argv[1];
  if (path == NULL) path = GetHome();
  else chdir(path);

  char tmp[SIZE];
  getcwd(tmp, sizeof(tmp));
  snprintf(cwd, sizeof(cwd), "PWD=%s", tmp);
  putenv(cwd);
}
int CheckBuiltin()
{
  int ch = 0;//默认非内建命令
  const char* command = argv[0];
  if (strcmp(command, "cd") == 0)
  {
    ch = 1;
    ExecuteCd();
  }
  return ch;
}
void ExecuteCommand(char Command[], size_t n)
{
  pid_t id = fork();
  if (id == 0)//子进程,执行命令
  {
   execvp(argv[0],argv);
  }
  else if (id > 0)//父进程
  {
    int status = 0;
    pid_t rid = waitpid(id, &status, 0);
    if (rid > 0)
    {
      lastcode = WEXITSTATUS(status);
      if (lastcode != 0) printf("%s:%s:%d\n", argv[0], strerror(lastcode), lastcode);
    }
  }
  else//fork创建失败,直接退出
  {
    exit(1);
  }

}

int main()
{
  while(1)
  {
    //输出命令行
    MakeCommandLine();
    //读取用户命令
    char UserCommand[SIZE];
    int n = GetUserCommand(UserCommand, sizeof(UserCommand));
    (void)n;
    //指令和选项分割
    SplitCommand(UserCommand, sizeof(UserCommand));
    //检查是否为内建命令
    int ch = CheckBuiltin();
    if (ch) continue;
    //执行命令
    ExecuteCommand(UserCommand, sizeof(UserCommand));
   }
  return 0;
}