你很容易认为自己“不是一个纯正的程序员”。
有一些程序所有人都用,它们的开发者很容易被捧上神坛。虽然开发大型软件项目并不是那么容易,但很多时候这种软件的基本思想都很简单。
自己实现这样的软件是一种证明自己可以是纯正程序员的有趣方式。因此这篇文章介绍了我是如何用 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 的结尾处放置字节