lsh Write a Shell in C 用 C 语言编写简单 Unix shell

原作者博文 or 原作者Github

译文可能会跟原文有些不大一样,有些成段的对代码的解释我会直接加到代码里做成注释,个人感觉这样读代码会更通畅易懂。

下面给的代码都会默认遵守在main()前面声明函数,在main()后定义。

Shell的基础生命周期

shell在其生命周期中主要做三件事:

  • 初始化(Initialize):
    一个典型的 shell 将读取并执行其配置文件,这会改变 shell 行为的各个方面。
  • 解释(Interpret):
    shell 从标准输入stdin(它可以是交互式输入或文件)读取命令并执行它们。
  • 结束(Terminate):
    执行完命令之后,shell执行关闭命令,释放所有内存并终止shell。

我们只是做一个简单的shell,所以不弄配置文件和关闭命令,只会调用一个主循环函数然后终止。

#include<stdio.h>
#include<stdlib.h>
int main(int argc, char **argv)
{
  //命令主循环函数
  lsh_loop();

  return EXIT_SUCCESS;
}

主循环函数 lsh_loop(),将在这里循环解释命令。

一个shell的基本循环

shell基本的程序逻辑:在其循环过程中一个处理命令的简单方式:

  • 读取(Read)
    从黑窗口(标准输入)读取命令
  • 分析(Parse)
    将命令字符串分割成程序和参数。
  • 执行(Execute)
    运行分析后的命令
void lsh_loop()
{
  char *line;//读取的原始命令字符串
  char **args;//line分割后的一些参数
  int status;//状态变量

  do {
    printf("> ");
    line = lsh_read_line();//读取一行
    args = lsh_split_line(line);//将读取的一行拆分成一些参数
    status = lsh_execute(args);//执行这些参数

    //释放之前创建且用过的内存,防止内存溢出
    free(line);
    free(args);

  } while (status);
}

do-while循环更方便检查状态变量,因为在检查它的值之前会执行一遍。

获取命令字符串

从标准输入读取一行听起来很简单,但在C语言中却可能很麻烦。你事先不知道用户会在shell中输入多少文本,所以不能简单地分配一个块。
C语言中常见的策略则是:先创建一定大小的内存,如果输入超过了,就重新分配更多的空间。
我们将使用它来实现lsh_read_line()。

#define LSH_RL_BUFSIZE 1024
char *lsh_read_line()
{
  int bufsize = LSH_RL_BUFSIZE;                  // 事先假定的接收命令字符串大小
  char *buffer = malloc(sizeof(char) * bufsize); // 开辟内存空间
  if (!buffer)                                   // 如果分配失败
  {
    fprintf(stderr, "lsh: lsh_read_line中malloc失败: %m\n");
    exit(EXIT_FAILURE);
  }
  int position = 0; // 记录数组有效的长度,初始化为0,表示当前位置
  int c;            // 接收读取的一个字符

  while (1)
  {
    // 读取一个字符
    c = getchar();

    // 如果遇到EOF,用空字符替换它并返回
    if (c == EOF || c == '\n')
    {
      buffer[position] = '\0';
      return buffer;
    }
    else
    {
      buffer[position++] = c;
    }

    // 如果输入的命令字符串长度大于了最初假定的大小,就重新分配更大的空间
    if (position >= bufsize)
    {
      bufsize += LSH_RL_BUFSIZE;
      buffer = realloc(buffer, bufsize);
      if (!buffer)
      {
        fprintf(stderr, "lsh: lsh_read_line中realloc error\n");
        exit(EXIT_FAILURE);
      }
    }
  }
}

EOF是一个特殊的标志,表示没有更多的输入可供读取。在终端中,你可以通过输入Ctrl+D来模拟EOF,这会告诉程序已经输入完毕。
为什么用int类型接收每个字符:EOF是一个整数,不是字符,如果你想检查它,你需要使用一个整数。

对于那些对较新版本的C库非常熟悉的人可能会注意到stdio.h中有一个名为getline()的函数,它可以完成我们刚刚实现的大部分工作。老实说,在我写完这段代码之后我才知道它的存在。这个函数是GNU对C库的一个扩展,直到2008年才被添加到规范中,因此大多数现代的Unix系统现在应该都有它。我会保留我的现有代码,并鼓励人们在使用getline之前先以这种方式学习。如果不这样做,你会失去一个学习的机会!无论如何,有了getline,函数变得更容易,但是用getchar并不会比用getline更琐碎,因为都需要在读取时检查EOF或错误:

char *lsh_read_line(void)
{
  char *line = NULL;
  ssize_t bufsize = 0; // have getline allocate a buffer for us

  if (getline(&line, &bufsize, stdin) == -1){
    if (feof(stdin)) {
      exit(EXIT_SUCCESS);  // We recieved an EOF
    } else  {
      perror("readline");
      exit(EXIT_FAILURE);
    }
  }

  return line;
}

解析获取到的命令字符串

我们已经实现了lsh_read_line()函数,并且得到了输入的一行内容。现在,我们需要将这行内容解析成一个参数列表。
我将进行一个明显的简化,即不允许在命令行参数中使用引号或反斜杠进行转义,将会简单地使用空格来将参数分开。
例如:命令echo "this message"不会将echo调用为一个参数"this message",而是将其调用为两个参数:"this"和"message"。
简化后只需要使用空格作为分隔符来“标记化”这个字符串。这意味着我们可以使用经典的库函数strtok来帮助我们完成一些繁琐的工作。

点这里看strtok知识点

#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
char **lsh_split_line(char *line)
{
  int bufsize = LSH_TOK_BUFSIZE;
  int position = 0;
  char *token;                                      // 用于接收每次分割出来的单个参数
  char **tokens = malloc(bufsize * sizeof(char *)); // 存储分割好的参数
  if (!tokens)
  {
    fprintf(stderr, "lsh: lsh_split_line中malloc error\n");
    exit(EXIT_FAILURE);
  }

  token = strtok(line, LSH_TOK_DELIM);
  while (token != NULL)
  {
    tokens[position] = token;
    position++;
    // 这里是对参数多于假定个数时进行的扩容处理
    if (position >= bufsize)
    {
      bufsize += LSH_TOK_BUFSIZE;
      tokens = realloc(tokens, bufsize * sizeof(char *));
      if (!tokens)
      {
        fprintf(stderr, "lsh: lsh_split_line中realloc error\n");
        exit(EXIT_FAILURE);
      }
    }
    // 接着分割取下一个参数
    token = strtok(NULL, LSH_TOK_DELIM);
  }
  // 在最末尾将指针置空(加上空结束符)
  tokens[position] = NULL;
  // 将最终结果返回出去
  return tokens;
}

在函数的开始,我们通过调用strtok开始标记化。它返回指向第一个标记的指针。实际上,strtok()的操作是返回在你给出的字符串内部的指针,并在每个标记的末尾放置\0字节。我们将每个指针存储在一个字符指针的数组(缓冲区)中。

一切准备就绪,我们得到了一个参数数组,可以进行下一步的执行操作。这就引出了一个问题,我们该如何执行这些标记呢?

Shell如何启动进程

现在真正涉及到了shell的核心功能:启动进程。这意味着你需要准确了解进程的运行方式以及它们是如何启动的。让我们稍微偏离一下话题,讨论一下类Unix操作系统中的进程:

在Unix上,启动进程只有两种方式:

  • 第一种方式(几乎不算)是通过init进程,当Unix计算机启动时它的内核会被加载,一旦加载并初始化完成,内核只启动一个进程,称为init。这个进程在计算机运行期间一直存在,并负责加载其他的进程。
  • 由于大多数程序都不是init进程,那么启动进程的实际方法只剩下一种:fork()系统调用。当调用这个函数时,操作系统会创建进程的副本并同时启动它们。原始进程称为“父进程”,新进程称为“子进程”。fork()会向子进程返回0,而父进程则会得到它的子进程的进程ID号(PID)。实际上,这意味着启动新进程的唯一方法是通过现有进程复制自身。

通常,当你想运行一个新进程时,你不只是想要同一个程序的另一个副本,而是想要运行一个不同的程序。这就是exec()系统调用的作用。它会用全新的程序替换当前运行的程序。这意味着当你调用exec时,操作系统会停止当前进程,加载新程序,并以新程序代替原来的程序运行,进程在执行exec()调用后不会返回(除非出现错误)。

通过这两个系统调用,我们了解到大多数Unix程序是如何运行的基本方法:首先,一个现有进程fork成两个独立的进程;然后,子进程使用exec()来用新程序替换自身。

父进程可以继续执行其他任务,甚至可以使用wait()系统调用来监视它的子进程。

点这里看进程知识点

呼~有了这些背景知识,下面启动程序的代码就会更加容易理解:

#include <unistd.h>
#include <sys/wait.h>
int lsh_launch(char **args)
{
  pid_t pid; // 接收fork返回值
  pid_t wpid;
  int status;

  pid = fork();
  if (pid == 0) // fork成功:子进程执行的代码部分
  {
    if (execvp(args[0], args) == -1)
    {
      perror("lsh: lsh_launch中execvp error - ");
    }
    // execvp失败就会到这里,退出子进程
    exit(EXIT_FAILURE);
  }
  else if (pid < 0) // fork失败(仅告知用户,不对错误进行处理)
  {
    perror("lsh: lsh_launch中fork error - ");
  }
  else // fork成功:当前进程(父进程)执行的代码部分
  {
    // 父进程需要等待子进程的命令执行完成
    do
    {
      // 等待进程状态改变
      wpid = waitpid(pid, &status, WUNTRACED);
    }
    // 循环直到进程要么退出,要么被杀死
    while (!WIFEXITED(status) && !WIFSIGNALED(status));
  }
  // 最终返回1,告诉调用函数应该再次提示用户输入命令
  return 1;
}

这个函数接收之前解析好的参数数组,创建子进程并保存返回值。fork()过后,我们实际上有两个并发运行的进程,子进程将进入第一个if条件(其中pid == 0)。

子进程中想要运行用户输入的命令,就需要使用exec系统调用的多种变体之一:execvp。不同的exec变体稍有不同,有的接收可变数量的字符串参数,有的接受字符串列表,还有的让你指定进程运行的环境。这个特定的变体第一个参数:一个程序名,第二个参数:一个字符串参数数组(也称为vector,因此有个‘v’)。‘p’表示可以不提供要运行的程序的完整文件路径,只用提供程序名,让操作系统搜索该程序路径。

Shell内建命令

你可能注意到lsh_loop() 函数调用了 lsh_execute(),但上面我们将函数命名为 lsh_launch(),这是有意为之的!你会发现,大多数shell执行的都是程序,但并非全部。有些命令是直接内建在shell中的。

原因很简单,如果你想改变目录,你需要使用 chdir() 函数。问题是,当前目录是进程的一个属性。所以,如果你编写了一个名为 cd 的程序来改变目录,它只会改变它自己的当前目录,然后终止。它的父进程的当前目录将保持不变。但我们想让shell进程本身需要执行 chdir(),以便更新它自己的当前目录,当它启动子进程时,它们也会继承这个目录。

同理输入exit 退出程序,会无法退出调用它的shell,这个命令也需要内建在shell中。此外,大多数shell通过运行配置脚本来配置,比如 ~/.bashrc。这些脚本使用的命令会改变shell的运行方式,这些命令只有在内建在shell进程本身中时,才能改变shell的操作。

因此我们需要向shell中添加一些命令,我添加到我的shell中的命令有 cdexithelp。以下是它们的函数实现:

// 内置命令列表
char *builtin_str[] = {
    "cd",
    "help",
    "exit"};
//与列表相对应的函数指针列表
int (*builtin_func[])(char **) = {
    &lsh_cd,
    &lsh_help,
    &lsh_exit};
//计算 内置命令列表 中的元素数量
int lsh_num_builtins()
{
  return sizeof(builtin_str) / sizeof(char *);
}
//内建函数实现
int lsh_cd(char **args)
{
  if (args[1] == NULL)
  {
    fprintf(stderr, "lsh: lsh_cd 中 参数缺失\n");
  }
  else
  {
    //更改当前工作目录到参数args[1]中的路径
    //返回非0值表示失败
    if (chdir(args[1]) != 0)
    {
      perror("lsh");
    }
  }
  return 1;
}

int lsh_help(char **args)
{
  printf("LSH: 一个简单的Unix Shell\n");
  printf("输入程序名称和参数,然后按回车键\n");
  printf("以下是已经内置的程序:\n");

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

  printf("使用 man 命令获取其他程序的信息\n");
  return 1;
}

int lsh_exit(char **args)
{
  //0为终止shell循环的信号
  return 0;
}

int (*builtin_func[])(char **):

  • builtin_func[] 表示 builtin_func 是一个数组

  • * 表示数组中的元素是指针

  • int (*...)(char **) 表示指针指向的函数:

    • 该函数返回 int 类型,并接受一个 char ** 类型的参数

将内置命令和进程整合在一起

拼凑起最后一块拼图是实现 lsh_execute() 函数,该函数将负责执行内置命令或者启动一个进程。如果你读到这里,你会知道我们已经为编写一个非常简单的函数做好了准备:

int lsh_execute(char **args)
{
  if (args[0] == NULL)
  {
    // 输入了一个空的命令
    return 1;
  }
  //先检查是否是内建命令
  for (int i = 0; i < lsh_num_builtins(); i++)
  {
    if (strcmp(args[0], builtin_str[i]) == 0)
    {
      //返回 运行内建命令函数 后的返回值
      return (*builtin_func[i])(args);
    }
  }
  //返回 运行非内建命令函数(shell启动子进程) 后的返回值
  return lsh_launch(args);
}

将上述代码片段全部整合到一起

这就是构成 shell 的所有代码。如果你一路阅读下来,你应该完全理解了 shell 是如何工作的。要尝试运行它(在 Linux 机器上),你需要将这些代码段复制到一个文件中(main.c),然后编译它。确保只包含一个 lsh_read_line() 的实现。你需要在文件顶部包含以下头文件。我已经添加了注释,以便你知道每个函数的来源。

  • #include <sys/wait.h>
    • waitpid() 及其相关宏
  • #include <unistd.h>
    • chdir()
    • fork()
    • exec()
    • pid_t
  • #include <stdlib.h>
    • malloc()
    • realloc()
    • free()
    • exit()
    • execvp()
    • EXIT_SUCCESSEXIT_FAILURE
  • #include <stdio.h>
    • fprintf()
    • printf()
    • stderr
    • getchar()
    • perror()
  • #include <string.h>
    • strcmp()
    • strtok()

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#define LSH_RL_BUFSIZE 1024
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"

// 内置命令列表
char *builtin_str[] = {
    "cd",
    "help",
    "exit"};

// 程序主循环
void lsh_loop();
// 读取一行命令字符串
char *lsh_read_line();
// 对读取到的命令字符串解析成参数数组
char **lsh_split_line(char *line);
// shell启动子进程
int lsh_launch(char **args);
// 计算内置命令的数量
int lsh_num_builtins();
// shell内建命令函数
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);
// 将内置命令和进程整合在一起
int lsh_execute(char **args);

// 与列表相对应的函数指针列表
int (*builtin_func[])(char **) = {
    &lsh_cd,
    &lsh_help,
    &lsh_exit};

int main(int argc, char **argv)
{
  // 命令主循环函数
  lsh_loop();

  return EXIT_SUCCESS;
}

void lsh_loop()
{
  char *line;  // 读取的原始命令字符串
  char **args; // line分割后的一些参数
  int status;  // 状态变量

  do
  {
    printf("> ");
    line = lsh_read_line();      // 读取一行
    args = lsh_split_line(line); // 将读取的一行拆分成一些参数
    status = lsh_execute(args);  // 执行这些参数

    // 释放之前创建且用过的内存,防止内存溢出
    free(line);
    free(args);

  } while (status);
}

char *lsh_read_line()
{
  int bufsize = LSH_RL_BUFSIZE;                  // 事先假定的接收命令字符串大小
  char *buffer = malloc(sizeof(char) * bufsize); // 开辟内存空间
  if (!buffer)                                   // 如果分配失败
  {
    fprintf(stderr, "lsh: lsh_read_line中malloc失败: %m\n");
    exit(EXIT_FAILURE);
  }
  int position = 0; // 记录数组有效的长度,初始化为0,表示当前位置
  int c;            // 接收读取的一个字符

  while (1)
  {
    // 读取一个字符
    c = getchar();

    // 如果遇到EOF,用空字符替换它并返回
    if (c == EOF || c == '\n')
    {
      buffer[position] = '\0';
      return buffer;
    }
    else
    {
      buffer[position++] = c;
    }

    // 如果输入的命令字符串长度大于了最初假定的大小,就重新分配更大的空间
    if (position >= bufsize)
    {
      bufsize += LSH_RL_BUFSIZE;
      buffer = realloc(buffer, bufsize);
      if (!buffer)
      {
        fprintf(stderr, "lsh: lsh_read_line中realloc error\n");
        exit(EXIT_FAILURE);
      }
    }
  }
}

char **lsh_split_line(char *line)
{
  int bufsize = LSH_TOK_BUFSIZE;
  int position = 0;
  char *token;                                      // 用于接收每次分割出来的单个参数
  char **tokens = malloc(bufsize * sizeof(char *)); // 存储分割好的参数
  if (!tokens)
  {
    fprintf(stderr, "lsh: lsh_split_line中malloc error\n");
    exit(EXIT_FAILURE);
  }

  token = strtok(line, LSH_TOK_DELIM);
  while (token != NULL)
  {
    tokens[position] = token;
    position++;
    // 这里是对参数多于假定个数时进行的扩容处理
    if (position >= bufsize)
    {
      bufsize += LSH_TOK_BUFSIZE;
      tokens = realloc(tokens, bufsize * sizeof(char *));
      if (!tokens)
      {
        fprintf(stderr, "lsh: lsh_split_line中realloc error\n");
        exit(EXIT_FAILURE);
      }
    }
    // 接着分割取下一个参数
    token = strtok(NULL, LSH_TOK_DELIM);
  }
  // 在最末尾将指针置空(加上空结束符)
  tokens[position] = NULL;
  // 将最终结果返回出去
  return tokens;
}

int lsh_launch(char **args)
{
  pid_t pid; // 接收fork返回值
  pid_t wpid;
  int status;

  pid = fork();
  if (pid == 0) // fork成功:子进程执行的代码部分
  {
    if (execvp(args[0], args) == -1)
    {
      perror("lsh: lsh_launch中execvp error - ");
    }
    // execvp失败就会到这里,退出子进程
    exit(EXIT_FAILURE);
  }
  else if (pid < 0) // fork失败(仅告知用户,不对错误进行处理)
  {
    perror("lsh: lsh_launch中fork error - ");
  }
  else // fork成功:当前进程(父进程)执行的代码部分
  {
    // 父进程需要等待子进程的命令执行完成
    do
    {
      // 等待进程状态改变
      wpid = waitpid(pid, &status, WUNTRACED);
    }
    // 循环直到进程要么退出,要么被杀死
    while (!WIFEXITED(status) && !WIFSIGNALED(status));
  }
  // 最终返回1,告诉调用函数应该再次提示用户输入命令
  return 1;
}

int lsh_num_builtins()
{
  return sizeof(builtin_str) / sizeof(char *);
}

int lsh_cd(char **args)
{
  if (args[1] == NULL)
  {
    fprintf(stderr, "lsh: lsh_cd 中 参数缺失\n");
  }
  else
  {
    // 更改当前工作目录到参数args[1]中的路径
    // 返回非0值表示失败
    if (chdir(args[1]) != 0)
    {
      perror("lsh");
    }
  }
  return 1;
}

int lsh_help(char **args)
{
  printf("LSH: 一个简单的Unix Shell\n");
  printf("输入程序名称和参数,然后按回车键\n");
  printf("以下是已经内置的程序:\n");

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

  printf("使用 man 命令获取其他程序的信息\n");
  return 1;
}

int lsh_exit(char **args)
{
  // 0为终止shell循环的信号
  return 0;
}

int lsh_execute(char **args)
{
  if (args[0] == NULL)
  {
    // 输入了一个空的命令
    return 1;
  }
  // 先检查是否是内建命令
  for (int i = 0; i < lsh_num_builtins(); i++)
  {
    if (strcmp(args[0], builtin_str[i]) == 0)
    {
      // 返回 运行内建命令函数 后的返回值
      return (*builtin_func[i])(args);
    }
  }
  // 返回 运行非内建命令函数(shell启动子进程) 后的返回值
  return lsh_launch(args);
}

简单的测试结果 

 

你也可以从 GitHub上获取代码。那个链接直接指向本文撰写时的代码的当前修订版——我可能会选择在未来某一天更新它并添加新功能。如果我这样做,我会尽力更新这篇文章,包括详细的实现和想法。 

总结

这个shell并不具备丰富的功能,它有明显的缺点:

  • 仅使用空白字符来分隔参数,不支持引号或反斜杠转义
  • 不支持管道(piping)或重定向(redirection)
  • 标准内置命令较少
  • 不支持文件名模式匹配(globbing)

具体更新信息可到原博主的博客查看

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值