本文参考自Write a Shell in C
Shell的基本生命周期
让我们自顶向下的思考一下 Shell。一个 Shell 在它的生命周期里主要做了三件事。
初始化:Shell 会在初始化时读入和执行配置文件。这会改变 Shell 接下来各方面的行为。
解释:Shell 在解释阶段(也就是等待用户输入的阶段)读入标准输入的命令并解释执行。
终结:在用户输入 shutdown 命令后,Shell 会释放掉占用的内存并终结自己。
这些步骤是很通用的,它们可以应用在任何程序中,我们将在我们的 Shell 中利用它们作为基础。我们的 Shell会足够简单,以至于没有任何配置文件,也不会有任何 shutdown 命令。因此,我们仅仅调用循环函数然后结束它。但需要注意的是,在程序的生命周期中循环只是一个基础的组成部分,真正的架构往往会复杂的多。
1 | int main(int argc, char **argv) |
可以在上面的代码中看到,我只使用了一个函数,lsh_loop()。它将会循环执行并解释命令。我们将在接下来的部分看到它的具体实现。
Shell 的基本循环
我们已经思考过 Shell 程序是如何启动的。现在,考虑基本的程序逻辑:Shell 在执行循环的时候做了什么?
答案是以下三点:
- 读入:从标准输入中读入命令
- 解析:将执行的命令和其参数解析出来输入程序执行
- 执行:根据命令和参数执行程序
将以上三点表述成代码放入 lsh_loop():
1 | void lsh_loop(void) |
让我们看看这段代码。开头是几行声明语句。后面的 do-while 循环在检查变量的状态方面更加地方便,因为在检查之前就已经执行了一次了。在循环里,我们打印了一个提示符,调用了一个函数来读入一行,再调用了一个函数来划分读入行的参数,然后执行这些参数。最后我们释放了 line 和 arguments 变量。注意我们使用了一个状态变量 status (由 lsh_execute()返回)来决定何时终止循环。
读入一行
从标准输入中读入一行听起来简单,但用 C 实现起来还是有点麻烦的。难受的事情是,你并不知道在这一段时间里用户会往 shell 中输入多少文本。你不能简单地就分配一个块并期望输入不会溢出。而是要在启动的时候分配一个块,然后在溢出的时候重新分配更多的空间。这在 C 里是一个普遍的策略,我们将会在 lsh_read_line()中实现。
1 | #define LSH_RL_BUFSIZE 1024 |
第一部分有很多的声明。函数的主要内容在while(1)
循环中(显然是无限循环)。在循环中,我们读入一个字符并把它存储为 int
而不是 char
,这很重要!EOF是一个 Integer,而不是一个 Character。如果你想在条件语句中检查它,就得把它声明为 int
。这是一个 C 语言初学者普遍会犯的错误。如果读入的是一个新行或者 EOF,我们会终止当前的读入并返回。否则我们将读到的字符添加到缓存字符串中。
接下来,我们会判断新的字符是否会超过当前的缓存大小。如实是的话,我们在继续读入前重新分配我们的缓存大小(同时检查分配错误)。这些都是值得做的。
对较新版本 C 函数库比较熟悉的人可能会察觉到在 stdio.h
中的 getline()
可以完成大部分我们实现的功能。但是尝试着自己实现一下 C 标准库的函数也没什么不好。使用 getline
的代码如下:
1 | char *lsh_read_line(void) |
解析一行
现在我们再看最初的循环,我们已经实现了 lsh_read_line(),所以可以获取读入的行了。之后我们需要把读入的行解析为一系列参数。这里我将做一些简化,不允许在命令行参数中出现引号和反斜杠,而是简单地通过空格来分隔每一个参数。因此命令echo "this message"
将不会通过一个参数调用echo
,而是通过两个参数"this"
和message"
调用。
在这些简化后,我们需要做的就是使用空格作为分隔符来对输入字符串做词法分析。这意味着我们可以调用标准库函数 strtok 来为我们做一些麻烦的事。
1 | #define LSH_TOK_BUFSIZE 64 |
这段代码看起来和之前的 lsh_read_line()
很类似,我们使用了同样的缓存和动态拓展策略。但这一次我们使用了 null 终结的指针数组而不是 null 终结的字符数组。
在一开始,我们调用了strtok
来划分词元。它返回第一个词元的指针。strtok()
事实上做的是返回你给的字符串内部的指针,并将每一个词元的末尾(分割符所在地址)置\0
(C语言中表示字符串结束的标志)。我们将每一个字符指针保存在 tokens 中。
最后,我们在需要的时候重新分配内存。这个过程将会重复进行直到所有的词元都被strtok
返回,然后将 null 放在 tokens 末尾。
现在我们有了一组词元了,可以准备开始执行了。
Shell 启动进程
现在我们来到了编写 Shell 的关键。启动线程是 Shell 的主要功能。所以编写一个 shell 意味着你需要明确地知道线程里发生了什么以及它们如何启动。因此在正式开写之前谈一谈 Unix 中的线程是很有必要的。
在 Unix 中仅有两种启动线程的方式。第一种是 Init 进程。当 unix 电脑启动时,它的内核会被加载。一旦内核被加载并初始化后,内核将会启动唯一的一个进程,Init 进程。Init 进程会在电脑启动后一直运行,并管理加载剩下的你所需要的进程。
因为大部分程序都不是 Init 进程,那么就只有一种常用的方式来启动进程了:fork()
系统调用。当这个函数被调用后,操作系统会构建一个该进程的副本并启动它。原进程称为父进程,新进程称为子进程。fork()
对子进程返回0,对父进程返回子进程的进程ID数(PID)。这意味着启动新进程的唯一方式是通过一个已存在的进程复制它自己并启动。
这存在一个问题。就是当你想要运行一个新程序的时候,你并不想要当前进程的复制——你就是想要运行一个不同的程序。这就是 exec()
系统调用做的事情。它用一个全新的程序替代了当前进程。这意味着当你调用exec()
时,操作系统将会终止当前进程,加载新的程序,然后在当前位置启动新的程序。一个程序不会从exec()
中获得返回值(除非它报错)。
有了这两个系统调用,我们就有了在 Unix 系统中启动新进程的砖头。首先,一个已存的进程 fork 它本身得到两个一样的进程。然后子进程执行 exec()
来用新程序替换掉它自己。父进程可以继续做其它事情,或者使用wait()
系统调用来等待子进程执行完。
通过上述的背景知识的补充,下面的用于启动一个新程序的代码将会变得很好理解:
1 | int lsh_launch(char **args) |
这个函数将我们之前得到的参数数组作为参数。之后调用fork()
,保存它的返回值。当fork()
返回时,我们实际上有两个正在运行的进程。子进程将会执行第一个 if 条件语句(where pid == 0)。
在子进程中,我们想要运行用户输入的命令。所以,我们使用了exec()
的变种之一execvp()
。execvp()
略微有一些不同。它将希望运行的程序名和一个字符串数组(也被成为’vector’,这就是其中’v’的由来)作为参数。其中的’p’表示不需要提供运行程序的完整路径,只需要给出它的名字,操作系统将会在系统路径中自动搜索它。
如果 exec
命令返回-1,我们就知道产生了一个 error。所以我们使用perror
来打印系统的错误信息,和我们的 Shell 名一样,这样我们就知道这个 error 来自哪里了。之后,我们退出函数,使得 Shell 能继续运行。
第二个条件(pid < 0)检查了是否 fork()
出现了error。如果是,我们打印 error 并继续运行 Shell——这里并没有提示用户出现了 error 并让他们决定是否需要退出。
第三个条件意味着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 里的命令是 cd,exit 和 help。它们的实现如下:
1 | /* |
这段代码包含三个部分。第一个部分是内置函数的前向声明。一个前向声明意味着你声明了一个函数(并没有定义),因此你能在定义它之前使用它的函数名。这样做的原因是lsh_help()
使用了内置函数名数组,数组中包含了lsh_help()
。打破这个依赖循环的最清晰的方式就是前向声明。
下一个部分是一个包含了内建命令名的数组以及一个包含命令名对应函数的数组。也就是说,在将来想要添加内建命令的时候只要修改这些数组就好了,不需要在代码的某处地方编辑一个非常复杂的 switch 语句。如果你对builtin_func
的声明感到困惑,那就对了!这是一个包含函数指针的数组,它将字符串数组作为输入并返回一个整型数。在 C 语言里,任何涉及函数指针的声明都是很复杂的,这样做的好处是,当你想要调用一系列函数时可以直接通过数组名加索引调用,而不是用硬编码的方式直接调用函数本身。
执行命令
最后要做的就是实现 lsh_execute()
,这个函数将会启动新线程或者调用内置方法。
1 | int lsh_execute(char **args) |
这段代码做的就是检查输入命令是否是内置命令,如果是的话就运行它。如果不是则调用lsh_launch()
启动一个新进程。
整合代码
到这里一个简易 Shell 所需要的所有代码就都实现了。将上面所有的代码片段复制到 main.c 文件里。在将下列头文件包含在文件起始位置。
1
#include <sys/wait.h>
waitpid()
and associated macros
1
#include <unistd.h>
chdir()
fork()
exec()
pid_t
1
#include <stdlib.h>
malloc()
realloc()
free()
exit()
execvp()
EXIT_SUCCESS
,EXIT_FAILURE
1
#include <stdio.h>
fprintf()
printf()
stderr
getchar()
perror()
1
#include <string.h>
strcmp()
strtok()
保存退出后,执行
gcc -o main main.c
进行编译,然后执行./main
运行,lsh就启动了!