另外,可参考
http://akaedu.github.io/book/ch30s05.html
https://www.cs.purdue.edu/homes/grr/SystemsProgrammingBook/Book/Chapter5-WritingYourOwnShell.pdf
[译] 教程 - 用 C 写一个 Shell
发表于 2019-02-25 | 0 Comments
这是我在掘金翻译计划中的译文。 译文链接:[译] 教程 - 用 C 写一个 Shell
- 原文地址:Tutorial - Write a Shell in C
- 原文作者:Stephen Brennan
- 译文出自:掘金翻译计划
- 本文永久链接:https://github.com/xitu/gold-miner/blob/master/TODO1/tutorial-write-a-shell-in-c.md
- 译者:nettee
- 校对者:kasheemlew,JackEggie
你很容易认为自己“不是一个真正的程序员”。有一些程序所有人都用,它们的开发者很容易被捧上神坛。虽然开发大型软件项目并不容易,但很多时候这种软件的基本思想都很简单。自己实现这样的软件是一种证明自己可以是真正的程序员的有趣方式。所以,这篇文章介绍了我是如何用 C 语言写一个自己的简易 Unix shell 的。我希望其他人也能感受到这种有趣的方式。
这篇文章中介绍的 shell(叫做 lsh
),可以在 GitHub 上获取它的源代码。
学校里的学生请注意! 许多课程都有要求你编写一个 shell 的作业,而且有些教师都知道这样的教程和代码。如果你是此类课程上的学生,请不要在未经允许的情况下复制(或复制加修改)这里的代码。我建议反对重度依赖本教程的行为。
Shell 的基本生命周期
让我们自顶向下地观察一个 shell。一个 shell 在它的生命周期中主要做三件事。
- 初始化:在这一步中,shell 一般会加载并执行它的配置文件。这些配置会改变 shell 的行为。
- 解释执行:接着,shell 会从标准输入(可能是交互式输入,也可能是一个文件)读取命令,并执行这些命令。
- 终止:当命令全部执行完毕,shell 会执行关闭命令,释放所有内存,然后终止。
这三个步骤过于宽泛,其实可以适用于任何程序,但我们可以将其用于我们的 shell 的基础。我们的 shell 会很简单,不需要任何配置文件,也没有任何关闭命令。那么,我们只需要调用循环函数,然后终止。不过对于架构而言,我们需要记住,程序的生命周期并不仅仅是循环。
1
2
3
4
5
6
7
8
9
10
11
int main(int argc, char **argv)
{
// 如果有配置文件,则加载。
// 运行命令循环
lsh_loop();
// 做一些关闭和清理工作。
return EXIT_SUCCESS;
}
这里你可以看到,我只是写了一个函数:lsh_loop()
。这个函数会循环,并解释执行一条条命令。我们接下来会看到这个循环如何实现。
Shell 的基本循环
我们已经知道了 shell 程序如何启动。现在考虑程序的基本逻辑:shell 在它的循环中会做什么?处理命令的一个简单的方式是采用这三步:
- 读取:从标准输入读取一个命令。
- 分析:将命令字符串分割为程序名和参数。
- 执行:运行分析后的命令。
下面,我将这些思路转化为 lsh_loop()
的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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()
。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#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 ");
exit(EXIT_FAILURE);
}
while (1) {
// 读一个字符
c = getchar();
// 如果我们到达了 EOF, 就将其替换为 ' ' 并返回。
if (c == EOF || c == ' ') {
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 error ");
exit(EXIT_FAILURE);
}
}
}
}
第一部分是很多的声明。也许你没有发现,我倾向于使用古老的 C 语言风格,将变量的声明放在其他代码前面。这个函数的重点在(显然是无限的)while (1)
循环中。在这个循环中,我们读取了一个字符(并将它保存为 int
类型,而不是 char
类型,这很重要!EOF 是一个整型值而不是字符型值。如果你想将它的值作为判断条件,需要使用 int
类型。这是 C 语言初学者常犯的错误。)。如果这个字符是换行符或者 EOF,我们将当前字符串用空字符结尾,并返回它。否则,我们将这个字符添加到当前的字符串中。
下一步,我们检查下一个字符是否会超出当前的缓冲区大小。如果会超出,我们就先重新分配缓冲区(并检查内存分配是否成功)。就是这样。
如果你对新版的 C 标准库很熟悉,会注意到 stdio.h
中有一个 getline()
函数,和我们刚才实现的功能几乎一样。实话说,我在写完上面这段代码之后才知道这个函数的存在。这个函数一直是 C 标准库的 GNU 扩展,直到 2008 年才加入规约中,大多数现代的 Unix 系统应该都已经有了这个函数。我会保持我已写的代码,我也鼓励你们先用这种方式学习,然后再使用 getline
。否则,你会失去一次学习的机会!不管怎样,有了 getline
之后,这个函数就不重要了:
1
2
3
4
5
6
7
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
来为我们干些苦力活。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " 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 ");
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 ");
exit(EXIT_FAILURE);
}
}
token = strtok(NULL, LSH_TOK_DELIM);
}
tokens[position] = NULL;
return tokens;
}
这段代码看起来和 lsh_read_line()
极其相似。这是因为它们就是很相似!我们使用了相同的策略 —— 使用一个缓冲区,并且将其动态地扩展。不过这里我们使用的是以空指针结尾的指针数组,而不是以空字符结尾的字符数组。
在函数的开始处,我们开始调用 strtok
来分割 token。这个函数会返回指向第一个 token 的指针。strtok()
实际上做的是返回指向你传入的字符串内部的指针,并在每个 token 的结尾处放置字节