如何用 C 写一个 Shell

2023年 9月 1日 64.8k 0

你很容易认为自己“不是一个纯正的程序员”。

有一些程序所有人都用,它们的开发者很容易被捧上神坛。虽然开发大型软件项目并不是那么容易,但很多时候这种软件的基本思想都很简单。

自己实现这样的软件是一种证明自己可以是纯正程序员的有趣方式。因此这篇文章介绍了我是如何用 C 写一个自己的简易 Unix Shell 的。我希望其它人也能感受到这种有趣的方式。

在这篇文章中介绍的 Shell(其实它叫做 lsh),可以在 GitHub (https://github.com/brenns10/lsh)上获取它的源代码。

学校里的学生请注意! 

许多课程都有要求你编写一个 Shell 的作业,而且有些教师都知道这样的教程和代码。如果你是此类课程上的学生,请你不要在简单的复制(或复制加修改)这里的代码。

我强烈反对重度依赖本教程的行为。

Shell 的基本生命周期

我们自顶向下地观察一个 Shell。一个 Shell 在它的生命周期中主要做这3件事:

  • 初始化:在这一步中,Shell 一般会加载并执行它的配置文件。这些配置会改变 Shell 的默认行为。

  • 解释执行:接着,Shell 会从标准输入(可能是交互式输入,也可能是一个文件)读取命令并执行这些命令。

  • 终止:当命令全部执行完毕后,Shell 会执行关闭命令,释放所有内存,然后正常终止。

这三个步骤过于宽泛,其实可以适用于任何程序,但我们可以将其用于本篇文章的 Shell 的基础。

我们的 Shell 会很简单,不需要任何配置文件,也没有任何关闭命令。所以,我们只需要调用循环函数,然后终止即可。不过对于架构而言,请各位需要记住,程序的生命周期并不仅仅是循环。

1234567891011
int main(int argc, char **argv){  // 如果有配置文件,则加载。  // 运行命令循环  lsh_loop();  // 做一些关闭和清理工作。  return EXIT_SUCCESS;}

在这里可以看到,我只是写了一个函数:lsh_loop()。这个函数会开始循环,并解释执行一条条命令。

我们接下来会看到这个循环体是如何实现的。

Shell 的基本循环

我们已经知道了 Shell 程序如何启动。

现在思考程序的基本逻辑:Shell 在它的循环中会做什么?处理命令的一个简单的方式为采用以下三步:

  • 读取:从标准输入读取一个命令。

  • 分析:将命令字符串分割为程序名和参数。

  • 执行:运行分析后的命令。

下面,我将这些思路转化为 lsh_loop() 的代码:

12345678910111213141516
void lsh_loop(void){  char *line;  char **args;  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 循环在检查状态变量时会更方便,它会在检查变量的值之前至少执行一次。

在循环体内部,我们打印了一个提示符,调用函数来读取一行输入、将一行分割为参数以及执行这些参数。最后,我们释放之前为 line 和 args 申请的内存空间。

注意到我们使用 lsh_execute() 返回的状态变量决定何时退出循环。

读取一行输入

从标准输入读取一行听起来似乎很简单,但用 C 语言做起来可能有一定难度。一个坏消息是,你没法预先知道用户会在 Shell 中键入多长的文本。

因此你不能简单地分配一块空间,希望能装得下用户的输入,而应该先暂时分配一定长度的空间,当确实装不下用户的输入时,再重新分配更多的空间。这是 C 语言中的一个常见策略,我们也会用这个方法来实现 lsh_read_line()

12345678910111213141516171819202122232425262728293031323334353637
#define LSH_RL_BUFSIZE 1024char *lsh_read_line(void){  int bufsize = LSH_RL_BUFSIZE;  int position = 0;  char *buffer = malloc(sizeof(char) * bufsize);  int c;  if (!buffer) {    fprintf(stderr, "lsh: allocation errorn");    exit(EXIT_FAILURE);  }  while (1) {    // 读取一个字符    c = getchar();    // 如果我们到达了末尾 EOF, 就将其替换为 '' 并返回。    if (c == EOF || c == 'n') {      buffer[position] = '';      return buffer;    } else {      buffer[position] = c;    }    position++;    // 如果我们超出了 buffer 的大小,则重新分配。    if (position >= bufsize) {      bufsize += LSH_RL_BUFSIZE;      buffer = realloc(buffer, bufsize);      if (!buffer) {        fprintf(stderr, "lsh: allocation errorn");        exit(EXIT_FAILURE);      }    }  }}

第一部分是很多的声明。也许你没有发现,我倾向于使用古典的 C 语言风格,将变量的声明放在其它代码前面。这个函数的重点在(显然是无限的)while (1) 循环中。

在这个循环中,我们读取了一个字符(并将它保存为 int 类型,而不是 char 类型,这很重要!EOF 是一个整型值而不是字符型值。如果你想将它的值作为判断条件,需要使用 int 类型。这是 C 语言初学者常犯的错误。)。如果这个字符是换行符或者 EOF,我们将当前字符串用空字符结尾,并返回它。否则,我们将这个字符添加到当前的字符串中。

下一步,我们检查下一个字符是否会超出当前的缓冲区大小。如果会超出,我们就先重新分配缓冲区(并检查内存分配是否成功)。就是这样。

如果你对新版的 C 标准库很熟悉,会注意到 stdio.h 中有一个 getline() 函数,和我们刚才实现的功能几乎一样。实话说,我在写完上面这段代码之后才知道这个函数的存在。

这个函数一直是 C 标准库的 GNU 扩展,直到 2008 年才加入规约中,大多数现代的 Unix 系统应该都已经有了这个函数。我会保持我已写的代码,我也鼓励你们先用这种方式学习,然后再使用 getline。否则,你会失去一次学习的机会!不管怎样,有了 getline 之后,这个函数就不重要了:

1234567
char *lsh_read_line(void){  char *line = NULL;  ssize_t bufsize = 0; // 利用 getline 帮助我们分配缓冲区  getline(&line, &bufsize, stdin);  return line;}

分析一行输入

好,那我们回到最初的那个循环。我们目前实现了 lsh_read_line(),得到了一行输入。现在,我们需要将这一行解析为参数的列表。

我在这里将会做一个巨大的简化,假设我们的命令行参数中不允许使用引号和反斜杠转义,而是简单地使用空白字符作为参数间的分隔。这样的话,命令 echo "this message" 就不是使用单个参数 this message 调用 echo,而是有两个参数: "this 和 message"

有了这些简化,我们需要做的只是使用空白符作为分隔符标记字符串。这意味着我们可以使用传统的库函数 strtok 来为我们干些苦力活。

1234567891011121314151617181920212223242526272829303132
#define LSH_TOK_BUFSIZE 64#define LSH_TOK_DELIM " trna"char **lsh_split_line(char *line){  int bufsize = LSH_TOK_BUFSIZE, position = 0;  char **tokens = malloc(bufsize * sizeof(char*));  char *token;  if (!tokens) {    fprintf(stderr, "lsh: allocation errorn");    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: allocation errorn");        exit(EXIT_FAILURE);      }    }    token = strtok(NULL, LSH_TOK_DELIM);  }  tokens[position] = NULL;  return tokens;}

这段代码看起来和 lsh_read_line() 极其相似。这是因为它们就是很相似!我们使用了相同的策略 —— 使用一个缓冲区,并且将其动态地扩展。不过这里我们使用的是以空指针结尾的指针数组,而不是以空字符结尾的字符数组。

在函数的开始处,我们开始调用 strtok 来分割 token。这个函数会返回指向第一个 token 的指针。strtok() 实际上做的是返回指向你传入的字符串内部的指针,并在每个 token 的结尾处放置字节 。我们将每个返回的指针放在一个字符指针的数组(缓冲区)中。

最后,我们在必要时重新分配指针数组。这样的处理过程一直重复,直到 strtok 不再返回 token 为止。此时,我们将 token 列表的尾部设为空指针。

这样,我们的工作完成了,我们得到了 token 的数组。接下来我们就可以执行命令。那么问题来了,我们怎么去执行命令呢?

Shell 如何启动进程

现在,我们真正来到了 shell 的核心位置。Shell 的主要功能就是启动进程。所以写一个 shell 意味着你要很清楚进程中发生了什么,以及进程是如何启动的。因此这里我要暂时岔开话题,聊一聊 Unix 中的进程。

在 Unix 中,启动进程只有两种方式。第一种(其实不能算一种方式)是成为 Init 进程。当 Unix 机器启动时,它的内核会被加载。内核加载并初始化完成后,会启动单独一个进程,叫做 Init 进程。这个进程在机器开启的时间中会一直运行,负责管理启动其他的你需要的进程,这样机器才能正常使用。

既然大部分的程序都不是 Init,那么实际上就只有一种方式启动进程:使用 fork() 系统调用。当调用该函数时,操作系统会将当前进程复制一份,并让两者同时运行。原有的进程叫做“父进程”,而新的进程叫做“子进程”。fork() 会在子进程中返回 0,在父进程中返回子进程的进程 ID 号(PID)。本质上,这意味着新进程启动的唯一方法是复制一个已有的进程。

这看上去好像有点问题。特别是,当你想运行一个新的进程时,你肯定不希望再运行一遍相同的程序 —— 你想运行的是另一个程序。这就是 exec() 系统调用所做的事情。它会将当前运行的程序替换为一个全新的程序。这意味着每当你调用 exec,操作系统都会停下你的进程,加载新的程序,然后在原处启动新的程序。一个进程从来不会从 exec() 调用中返回(除非出现错误)。

有了这两个系统调用,我们就有了大多数程序在 Unix 上运行的基本要素。首先,一个已有的进程将自己分叉(fork)为两个不同的进程。然后,子进程使用 exec() 将自己正在执行的程序替换为一个新的程序。父进程可以继续做其他的事情,甚至也可以使用系统调用 wait() 继续关注子进程。

啊!我们讲了这么多。但是有了这些作为背景,下面启动程序的代码才是说得通的:

123456789101112131415161718192021222324
int lsh_launch(char **args){  pid_t pid, wpid;  int status;  pid = fork();  if (pid == 0) {    // 子进程    if (execvp(args[0], args) == -1) {      perror("lsh");    }    exit(EXIT_FAILURE);  } else if (pid 0) {    // Fork 出错    perror("lsh");  } else {    // 父进程    do {      wpid = waitpid(pid, &status, WUNTRACED);    } while (!WIFEXITED(status) && !WIFSIGNALED(status));  }  return 1;}

这个函数使用了我们之前创建的参数列表。然后,它 fork 当前的进程,并保存返回值。当 fork() 返回时,我们实际上有了两个并发运行的进程。子进程会进入第一个 if 分支(pid == 0)。

在子进程中,我们想要运行用户提供的命令。所以,我们使用 exec 系统调用的多个变体之一:execvpexec 的不同变体做的事情稍有不同。一些接受变长的字符串参数,一些接受字符串的列表,还有一些允许你设定进程运行的环境。execvp 这个变体接受一个程序名和一个字符串参数的数组(也叫做向量(vector),因此是‘v’)(数组的第一个元素应当是程序名)。‘p’ 表示我们不需要提供程序的文件路径,只需要提供文件名,让操作系统搜索程序文件的路径。

如果 exec 命令返回 -1(或者说,如果它返回了),我们就知道有地方出错了。那么,我们使用 perror 打印系统的错误消息以及我们的程序名,让用户知道是哪里出了错。然后,我们让 shell 继续运行。

第二个 if 条件(pid )检查 fork() 是否出错。如果出错,我们打印错误,然后继续执行 —— 除了告知用户,我们不会进行更多的错误处理。我们让用户决定是否需要退出。

第三个 if 条件表明 fork() 成功执行。父进程会运行到这里。我们知道子进程会执行命令的进程,所以父进程需要等待命令运行结束。我们使用 waitpid() 来等待一个进程改变状态。不幸的是,waitpid() 有很多选项(就像 exec() 一样)。进程可以以很多种方式改变其状态,并不是所有的状态都表示进程结束。一个进程可能退出(正常退出,或者返回一个错误码),也可能被一个信号终止。所以,我们需要使用 waitpid() 提供的宏来等待进程退出或被终止。函数最终返回 1,提示上层函数需要继续提示用户输入了。

Shell 内置函数

你可能发现了,lsh_loop() 函数调用了 lsh_execute()。但上面我们写的函数却叫做 lsh_launch()。这是有意为之的。虽然 shell 执行的命令大部分是程序,但有一些不是。一些命令是 shell 内置的。

这里的原因其实很简单。如果你想改变当前目录,你需要使用函数 chdir()。问题是,当前目录是进程的一个属性。那么,如果你写了一个叫 cd 的程序来改变当前目录,它只会改变自己当前的目录,然后终止。它的父进程的当前目录不会改变。所以应当是 shell 进程自己执行 chdir(),才能更新自己的当前目录。然后,当它启动子进程时,子进程也会继承这个新的目录。

类似的,如果有一个程序叫做 exit,它也没有办法使调用它的 shell 退出。这个命令也必须内置在 shell 中。还有,多数 shell 通过运行配置脚本(如 ~/.bashrc)来进行配置。这些脚本使用一些改变 shell 行为的命令。这些命令如果由 shell 自己实现的话,同样只会改变 shell 自己的行为。

因此,我们需要向 shell 本身添加一些命令是有道理的。我添加进我的 shell 的命令是 cdexit 和 help。下面是他们的函数实现:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
/*  内置 shell 命令的函数声明: */int lsh_cd(char **args);int lsh_help(char **args);int lsh_exit(char **args);/*  内置命令列表,以及它们对应的函数。 */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: expected argument to "cd"n");  } else {    if (chdir(args[1]) != 0) {      perror("lsh");    }  }  return 1;}int lsh_help(char **args){  int i;  printf("Stephen Brennan's LSHn");  printf("Type program names and arguments, and hit enter.n");  printf("The following are built in:n");  for (i = 0; i     printf("  %sn", builtin_str[i]);  }  printf("Use the man command for information on other programs.n");  return 1;}int lsh_exit(char **args){  return 0;}

这段代码有三个部分。第一部分包括我的函数的前置声明。前置声明是当你声明了(但还未定义)某个符号,就可以在它的定义之前使用。我这么做是因为 lsh_help() 使用了内置命令的数组,而这个数组中又包括 lsh_help()。打破这个依赖循环的最好方式是使用前置声明。

第二个部分是内置命令名字的数组,然后是它们对应的函数的数组。这样做是为了,在未来可以简单地通过修改这些数组来添加内置命令,而不是修改代码中某处一个庞大的“switch”语句。如果你不理解 builtin_func 的声明,这很正常!我也不理解。这是一个函数指针(一个接受字符串数组作为参数,返回整型的函数)的数组。C 语言中任何有关函数指针的声明都会很复杂。我自己仍然需要查一下函数指针是怎么声明的!

最后,我实现了每个函数。lsh_cd() 函数首先检查它的第二个参数是否存在,不存在的话打印错误消息。然后,它调用 chdir(),检查是否出错,并返回。帮助函数会打印漂亮的消息,以及所有内置函数的名字。退出函数返回 0,这是让命令循环退出的信号。

组合内置命令与进程

我们的程序最后缺失的一部分就是实现 lsh_execute() 了。这个函数要么启动一个内置命令,要么启动一个进程。如果你一路读到了这里,你会知道我们只剩下一个非常简单的函数需要实现了:

1234567891011121314151617
int lsh_execute(char **args){  int i;  if (args[0] == NULL) {    // 用户输入了一个空命令    return 1;  }  for (i = 0; i     if (strcmp(args[0], builtin_str[i]) == 0) {      return (*builtin_func[i])(args);    }  }  return lsh_launch(args);}

这个函数所做的不过是检查命令是否和各个内置命令相同,如果相同的话就运行内置命令。如果没有匹配到一个内置命令,我们会调用 lsh_launch() 来启动进程。需要注意的是,有可能用户输入了一个空字符串或字符串只有空白符,此时 args 只包含空指针。所以,我们需要在一开始检查这种情况。

全部组合在一起

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

  • #include

    • waitpid() 及其相关的宏

  • #include

    • chdir()

    • fork()

    • exec()

    • pid_t

  • #include

    • malloc()

    • realloc()

    • free()

    • exit()

    • execvp()

    • EXIT_SUCCESSEXIT_FAILURE

  • #include

    • fprintf()

    • printf()

    • stderr

    • getchar()

    • perror()

  • #include

    • strcmp()

    • strtok()

当你准备好了代码和头文件,简单地运行 gcc -o main main.c 进行编译,然后 ./main 来运行即可。

或者,你可以从 GitHub 上获取代码。代码地址:

https://github.com/brenns10/lsh/tree/407938170e8b40d231781576e05282a41634848c

这个链接直接跳转到我写这篇文章时的代码当前版本 —— 未来我可能会更新此代码,增加一些新的功能。

如果代码更新了,我会尽量在本文中更新代码的细节和实现思路。

结语

如果你读了这篇文章,想知道我到底是怎么知道如何使用这些系统调用的。

答案很简单:通过手册页(man pages)。在 man 3p 中有对每个系统调用的详尽文档。如果你知道你要找什么,只是想知道如何使用它,那么手册页是你最好的朋友。如果你不知道 C 标准库和 Unix 为你提供了什么样的接口,我推荐你阅读 POSIX 规范,特别是第 13 章,“头文件”。你可以找到每个头文件,以及其中需要定义哪些内容。

显然,这个 shell 的功能不够丰富。一些明显的遗漏有:

  • 只用了空白符分割参数,没有考虑到引号和反斜杠转义。

  • 没有管道和重定向。

  • 内置命令太少。

  • 没有通配符。

实现这几个功能其实非常有趣,但已经远不是我这样一篇文章可以容纳的了的了。如果我开始实现其中任何一项,我一定会写一篇关于它的后续文章。不过我鼓励读者们都尝试自己实现这些功能。

最后,感谢阅读这篇教程(如果有人读了的话)。我写得很开心,也希望你能读得开心。在评论区让我知道你的想法!

更新: 在本文的较早版本中,我在 lsh_split_line() 中遇到了一些讨厌的 bug,它们恰好相互抵消了。感谢 Reddit 的 /u/munmap(以及其他评论者)找到了这些 bug!在这里看看我究竟做错了什么。

更新二: 感谢 GitHub 用户 ghswa 贡献了我忘记的一些 malloc() 的空指针检查。他还指出 getline 的手册页规定了第一个参数所占用的内存空间应当可以被释放,所以我的使用 getline() 的 lsh_read_line() 实现中,line 应当初始化为 NULL

编译:万能的大雄来源:https://brennan.io/2015/01/16/write-a-shell-in-c/

本文为 @ 场长 创作并授权发布,未经许可,不得转载。

内容授权事宜请您联系 info@21cto.com或关注 21CTO 公众号。

该文观点仅代表作者本人,21CTO 平台仅提供信息存储空间服务。

相关文章

塑造我成为 CTO 之路的“秘诀”
“人工智能教母”的公司估值达 10 亿美金
教授吐槽:985 高校成高级蓝翔!研究生基本废了,只为房子、票子……
Windows 蓝屏中断提醒开发者:Rust 比 C/C++ 更好
Claude 3.5 Sonnet 在伽利略幻觉指数中名列前茅
上海新增 11 款已完成登记生成式 AI 服务

发布评论