- 原文地址:Tutorial - Write a Shell in C
- 原文做者:Stephen Brennan
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:nettee
- 校对者:kasheemlew,JackEggie
你很容易认为本身“不是一个真正的程序员”。有一些程序全部人都用,它们的开发者很容易被捧上神坛。虽然开发大型软件项目并不容易,但不少时候这种软件的基本思想都很简单。本身实现这样的软件是一种证实本身能够是真正的程序员的有趣方式。因此,这篇文章介绍了我是如何用 C 语言写一个本身的简易 Unix shell 的。我但愿其余人也能感觉到这种有趣的方式。html
这篇文章中介绍的 shell(叫作 lsh
),能够在 GitHub 上获取它的源代码。前端
学校里的学生请注意! 许多课程都有要求你编写一个 shell 的做业,并且有些教师都知道这样的教程和代码。若是你是此类课程上的学生,请不要在未经容许的状况下复制(或复制加修改)这里的代码。我建议反对重度依赖本教程的行为。android
让咱们自顶向下地观察一个 shell。一个 shell 在它的生命周期中主要作三件事。ios
这三个步骤过于宽泛,其实能够适用于任何程序,但咱们能够将其用于咱们的 shell 的基础。咱们的 shell 会很简单,不须要任何配置文件,也没有任何关闭命令。那么,咱们只须要调用循环函数,而后终止。不过对于架构而言,咱们须要记住,程序的生命周期并不只仅是循环。git
int main(int argc, char **argv) {
// 若是有配置文件,则加载。
// 运行命令循环
lsh_loop();
// 作一些关闭和清理工做。
return EXIT_SUCCESS;
}
复制代码
这里你能够看到,我只是写了一个函数:lsh_loop()
。这个函数会循环,并解释执行一条条命令。咱们接下来会看到这个循环如何实现。程序员
咱们已经知道了 shell 程序如何启动。如今考虑程序的基本逻辑:shell 在它的循环中会作什么?处理命令的一个简单的方式是采用这三步:github
下面,我将这些思路转化为 lsh_loop()
的代码:shell
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()
。数组
#define LSH_RL_BUFSIZE 1024
char *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 error\n");
exit(EXIT_FAILURE);
}
while (1) {
// 读一个字符
c = getchar();
// 若是咱们到达了 EOF, 就将其替换为 '\0' 并返回。
if (c == EOF || c == '\n') {
buffer[position] = '\0';
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 error\n");
exit(EXIT_FAILURE);
}
}
}
}
复制代码
第一部分是不少的声明。也许你没有发现,我倾向于使用古老的 C 语言风格,将变量的声明放在其余代码前面。这个函数的重点在(显然是无限的)while (1)
循环中。在这个循环中,咱们读取了一个字符(并将它保存为 int
类型,而不是 char
类型,这很重要!EOF 是一个整型值而不是字符型值。若是你想将它的值做为判断条件,须要使用 int
类型。这是 C 语言初学者常犯的错误。)。若是这个字符是换行符或者 EOF,咱们将当前字符串用空字符结尾,并返回它。不然,咱们将这个字符添加到当前的字符串中。
下一步,咱们检查下一个字符是否会超出当前的缓冲区大小。若是会超出,咱们就先从新分配缓冲区(并检查内存分配是否成功)。就是这样。
若是你对新版的 C 标准库很熟悉,会注意到 stdio.h
中有一个 getline()
函数,和咱们刚才实现的功能几乎同样。实话说,我在写完上面这段代码以后才知道这个函数的存在。这个函数一直是 C 标准库的 GNU 扩展,直到 2008 年才加入规约中,大多数现代的 Unix 系统应该都已经有了这个函数。我会保持我已写的代码,我也鼓励大家先用这种方式学习,而后再使用 getline
。不然,你会失去一次学习的机会!无论怎样,有了 getline
以后,这个函数就不重要了:
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
来为咱们干些苦力活。
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
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 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: allocation error\n");
exit(EXIT_FAILURE);
}
}
token = strtok(NULL, LSH_TOK_DELIM);
}
tokens[position] = NULL;
return tokens;
}
复制代码
这段代码看起来和 lsh_read_line()
极其类似。这是由于它们就是很类似!咱们使用了相同的策略 —— 使用一个缓冲区,而且将其动态地扩展。不过这里咱们使用的是以空指针结尾的指针数组,而不是以空字符结尾的字符数组。
在函数的开始处,咱们开始调用 strtok
来分割 token。这个函数会返回指向第一个 token 的指针。strtok()
实际上作的是返回指向你传入的字符串内部的指针,并在每一个 token 的结尾处放置字节 \0
。咱们将每一个返回的指针放在一个字符指针的数组(缓冲区)中。
最后,咱们在必要时从新分配指针数组。这样的处理过程一直重复,直到 strtok
再也不返回 token 为止。此时,咱们将 token 列表的尾部设为空指针。
这样,咱们的工做完成了,咱们获得了 token 的数组。接下来咱们就能够执行命令。那么问题来了,咱们怎么去执行命令呢?
如今,咱们真正来到了 shell 的核心位置。Shell 的主要功能就是启动进程。因此写一个 shell 意味着你要很清楚进程中发生了什么,以及进程是如何启动的。所以这里我要暂时岔开话题,聊一聊 Unix 中的进程。
在 Unix 中,启动进程只有两种方式。第一种(其实不能算一种方式)是成为 Init 进程。当 Unix 机器启动时,它的内核会被加载。内核加载并初始化完成后,会启动单独一个进程,叫作 Init 进程。这个进程在机器开启的时间中会一直运行,负责管理启动其余的你须要的进程,这样机器才能正常使用。
既然大部分的程序都不是 Init,那么实际上就只有一种方式启动进程:使用 fork()
系统调用。当调用该函数时,操做系统会将当前进程复制一份,并让二者同时运行。原有的进程叫作“父进程”,而新的进程叫作“子进程”。fork()
会在子进程中返回 0,在父进程中返回子进程的进程 ID 号(PID)。本质上,这意味着新进程启动的惟一方法是复制一个已有的进程。
这看上去好像有点问题。特别是,当你想运行一个新的进程时,你确定不但愿再运行一遍相同的程序 —— 你想运行的是另外一个程序。这就是 exec()
系统调用所作的事情。它会将当前运行的程序替换为一个全新的程序。这意味着每当你调用 exec
,操做系统都会停下你的进程,加载新的程序,而后在原处启动新的程序。一个进程历来不会从 exec()
调用中返回(除非出现错误)。
有了这两个系统调用,咱们就有了大多数程序在 Unix 上运行的基本要素。首先,一个已有的进程将本身分叉(fork)为两个不一样的进程。而后,子进程使用 exec()
将本身正在执行的程序替换为一个新的程序。父进程能够继续作其余的事情,甚至也可使用系统调用 wait()
继续关注子进程。
啊!咱们讲了这么多。可是有了这些做为背景,下面启动程序的代码才是说得通的:
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
系统调用的多个变体之一:execvp
。exec
的不一样变体作的事情稍有不一样。一些接受变长的字符串参数,一些接受字符串的列表,还有一些容许你设定进程运行的环境。execvp
这个变体接受一个程序名和一个字符串参数的数组(也叫作向量(vector),所以是‘v’)(数组的第一个元素应当是程序名)。‘p’ 表示咱们不须要提供程序的文件路径,只须要提供文件名,让操做系统搜索程序文件的路径。
若是 exec 命令返回 -1(或者说,若是它返回了),咱们就知道有地方出错了。那么,咱们使用 perror
打印系统的错误消息以及咱们的程序名,让用户知道是哪里出了错。而后,咱们让 shell 继续运行。
第二个 if 条件(pid < 0
)检查 fork()
是否出错。若是出错,咱们打印错误,而后继续执行 —— 除了告知用户,咱们不会进行更多的错误处理。咱们让用户决定是否须要退出。
第三个 if 条件代表 fork()
成功执行。父进程会运行到这里。咱们知道子进程会执行命令的进程,因此父进程须要等待命令运行结束。咱们使用 waitpid()
来等待一个进程改变状态。不幸的是,waitpid()
有不少选项(就像 exec()
同样)。进程能够以不少种方式改变其状态,并非全部的状态都表示进程结束。一个进程可能退出(正常退出,或者返回一个错误码),也可能被一个信号终止。因此,咱们须要使用 waitpid()
提供的宏来等待进程退出或被终止。函数最终返回 1,提示上层函数须要继续提示用户输入了。
你可能发现了,lsh_loop()
函数调用了 lsh_execute()
。但上面咱们写的函数却叫作 lsh_launch()
。这是有意为之的。虽然 shell 执行的命令大部分是程序,但有一些不是。一些命令是 shell 内置的。
这里的缘由其实很简单。若是你想改变当前目录,你须要使用函数 chdir()
。问题是,当前目录是进程的一个属性。那么,若是你写了一个叫 cd
的程序来改变当前目录,它只会改变本身当前的目录,而后终止。它的父进程的当前目录不会改变。因此应当是 shell 进程本身执行 chdir()
,才能更新本身的当前目录。而后,当它启动子进程时,子进程也会继承这个新的目录。
相似的,若是有一个程序叫作 exit
,它也没有办法使调用它的 shell 退出。这个命令也必须内置在 shell 中。还有,多数 shell 经过运行配置脚本(如 ~/.bashrc
)来进行配置。这些脚本使用一些改变 shell 行为的命令。这些命令若是由 shell 本身实现的话,一样只会改变 shell 本身的行为。
所以,咱们须要向 shell 自己添加一些命令是有道理的。我添加进个人 shell 的命令是 cd
、exit
和 help
。下面是他们的函数实现:
/* 内置 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 LSH\n");
printf("Type program names and arguments, and hit enter.\n");
printf("The following are built in:\n");
for (i = 0; i < lsh_num_builtins(); i++) {
printf(" %s\n", 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()
了。这个函数要么启动一个内置命令,要么启动一个进程。若是你一路读到了这里,你会知道咱们只剩下一个很是简单的函数须要实现了:
int lsh_execute(char **args) {
int i;
if (args[0] == NULL) {
// 用户输入了一个空命令
return 1;
}
for (i = 0; i < lsh_num_builtins(); 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 <sys/wait.h>
waitpid()
及其相关的宏#include <unistd.h>
chdir()
fork()
exec()
pid_t
#include <stdlib.h>
malloc()
realloc()
free()
exit()
execvp()
EXIT_SUCCESS
, EXIT_FAILURE
#include <stdio.h>
fprintf()
printf()
stderr
getchar()
perror()
#include <string.h>
strcmp()
strtok()
当你准备好了代码和头文件,简单地运行 gcc -o main main.c
进行编译,而后 ./main
来运行便可。
或者,你能够从 GitHub 上获取代码。这个连接直接跳转到我写这篇文章时的代码当前版本 —— 将来我可能会更新代码,增长一些新的功能。若是代码更新了,我会尽可能在本文中更新代码的细节和实现思路。
若是你读了这篇文章,想知道我究竟是怎么知道如何使用这些系统调用的。答案很简单:经过手册页(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
。
若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。