[译]教程 - 用C实现一个Shell
文章目录
原文链接:Tutorial - Write a Shell in C,作者:Stepthen Brennan 16 January 2015
我们很容易自视为”不是一个真正的程序员“。存在那些人尽皆知的程序,使得其中相关的研发者很容易地被推上神坛。尽管研发大型软件项目并不容易,但很多时候这些软件的基本思想是十分简单的。亲自去实现是学习如何成为一个真正的程序员的有趣方法。本文就是关于我如何用C实现简易的Unix Shell的演练,希望其他人也能从中体会到同样的乐趣。
本文所描述的Shell名为:lsh, 可以在Github上找到它。
**大学生注意告示!**很多课程会要求你们实现一个Shell,一些教师也知道本文及其代码。如果你是这些课程的其中一员,你不应未经允许地复制这些代码(或复制后修改)。即使如此,我强烈建议不要过度依赖本教程。(关于本段,参考作者的另一篇博文,译者注)
Shell的基本生命周期
让我们宏观地了解下Shell。一个Shell在它的生命周期中主要做三件事情。
- 初始化:在这一步,Shell会读取并执行自身的配置文件。这些配置文件改变了Shell的操作特性。
- 解释:接下来,Shell从标准输入读取命令(可以是交互命令,或文件)并执行之。
- 终止:在其命令执行后,Shell执行任意关机命令,释放内存并终止。
上述流程是如此普遍以至于可以应用到很多程序中去,我们将在自己的Shell中实现这些流程。我们的Shell是十分简单的,以至于它不需要任何的配置文件,也不会有任何的关机命令。所以,我们只需要调用循环函数然后终止就好。但在架构方面,需要时刻注意程序的生命周期远不止循环。
|
|
在这你可以看到我刚刚用到的函数,lsh_loop()
,将会开始循环,解释命令。接下来我们将看到其具体实现。
Shell的基本循环
我们已经知道到程序应该如何启动了。现在,就程序基本逻辑来说:shell在循环中该怎么做?简单来说,处理命令有三个步骤:
- 读取:从标准输入中读取命令。
- 解析:将命令字符串分隔为程序名和参数。
- 执行:运行解析后的命令。
将上述步骤写成lsh_loop()
代码:
|
|
让我们过一遍代码。前几行是声明。do-while循环更适合用于检查status
,因为它在检查前会执行一次命令。在本循环中,我们打印一个命令提示符,调用函数读取输入,调用函数将输入分割进args
, 并执行args
。最后释放原先创建的参数空间。注意,我们使用由lsh_execute()
返回的变量status
来决定何时退出程序。
读取输入
读取标准输入听上去似乎很简单,但在C中有点麻烦。棘手的是,你无法提前知道用户会在Shell中输入多少内容。你不能只是简单地分配一块空间,然后希望用户不要超量使用它。因此,你需要从一块空间开始,一旦用户使用量超过了它,立马重新分配更多的空间。这在C中是一个常用策略,我们依据这个想法来实现lsh_read_line()
。
|
|
第一部分是一堆声明。如果你没注意到的话,我建议在接下来的代码中保留旧版C标准风格声明变量的方式。函数主体是一个(无限循环的)while(1)
循环。在循环中,我们每次读取一个字符(并将其存储为int
,而不是char
, 这非常重要!EOF是整数型,而不是字符型,如果你想要将读取的字符与EOF做对比检查,你应该使用int
。这是C新手常犯的一个错误。)。如果它是换行符,或EOF,我们终止当前字符串并返回它。不然,就将其添加到已存在的字符串中。
接下来,我们检查下一个输入字符是否超出了当前的缓冲区。如果是,我们在继续之前重新分配缓冲区(检查分配错误)。整个流程就是如此了。
对熟悉新版C标准库的人来说,可能会注意到在stdio.h
头文件中有一个函数getline()
,它可以提供大部分我们才刚刚实现的功能。坦诚来说,我在完成这份代码之前并不知道有这个函数的存在。这个GUN拓展函数在2008年时被添加到C标准库规范之中,所以大多数的现代Unix系统应该都已配备了。我将保留原来的代码实现,并且鼓励你们应首先学习这样的实现而不是一开始就使用getline
。若不如此的话,你就是剥夺了自己一次学习的机会了。不管怎样,使用getline
的话,lsh_read_line()
函数可改写为如下:
|
|
解析输入
好了,如果我们回顾一下loop循环,可以看到我们已经实现了lsh_read_line()
,也有了输入流。现在,我们需要将输入解析为参数列表。在这里我要做一个简化,在我们的命令行参数中并不支持引号或反斜杠。作为替代,我们使用空格来作为参数间的分割符。所以形如命令 :echo "this message"
并不会直接将参数this message
给传递到echo
中,而是分为两个参数:"this"
和"message"
。
在这样的简化下,我们所需要做的就是使用空格作为分隔符来”解析标记“输入字符串。如此一来我们就可以利用库函数strtok
来完成一些繁杂的工作。
|
|
如果你觉得这段代码看起来和lsh_read_line()
十分相似的话,那是因为正是如此!我们采取了同样的策略,使用并动态拓展缓冲区。但这一次,我们用一个以null结尾的指针数组来代替以null结尾的字符数组。
在函数开头,我们调用strtok
来进行解析token
。它返回一个指向第一个token
的指针。strtok()
实际上做的是返回指向你提供给它的字符串的指针,并在每一个token
的后面添加\0
字符。我们将每个指针存储在字符指针数组(缓冲区)中。
最后,如果有必要的话,我们重新分配指针数组。这个过程反复进行直到strtok
函数不再返回token
,此时我们终止tokens
列表。
好了,一旦准备周全后,我们有了一个tokens
数组,准备开始执行了。那么问题来了,我们应该怎么做?
Shell如何启动进程
目前,我们开始深入到了Shell工作原理的核心。开启进程是Shell的主要功能。所以编写一个Shell意味着你必须清楚地知道进程中发生了什么,以及它们是如何启动的。正因此,我要稍稍岔开话题,单独地来谈谈Unix中的进程。
在Unix中启动进程只有两种方法。第一种(基本可以不考虑)是通过初始化。可以想见,当Unix系统启动时,它的内核开始加载。一旦其加载和初始化完成,内核只会开始运行一个进程,即为Init
进程。此进程在系统的整个生命周期一直运行,它管理加载你计算机所需要的其他进程。
既然大多数程序不会是Init
进程,对进程启动来说就只剩下唯一一个实际的途径:fork()
系统调用。当此函数被调用时,操作系统会复制进程并保持它们同时运行。原先的进程被称为”父进程“,复制出的新进程被称为”子进程“。fork()
向子进程返回0,向父进程返回子进程的进程ID(PID)。本质上来说,开启新进程的唯一方法就是从已有的进程上复制一份。
这听起来似乎有个问题。技术上来说,当你想要启动一个新进程时,你并不是想要一个当前程序的复制品——你想要运行另一个程序。这就是exec()
系统调用所要做的事情。(为方便理解,此句与原文有出入,出自Wikipedia——译者注)用其他程序覆盖自身:停止执行自己之前的程序并执行其他程序。这就意味着当你调用exec()
后, 操作系统停止你的进程,加载新的程序,并启动它。一个进程永远不会从exec()
调用返回(除非出现了错误)。
通过这两个系统调用,我们大概可以知道在Unix系统上大部分程序的运行方式了。首先,一个已存在的进程调用fork
来创建一个自身的副本。然后,子进程调用exec()
,用一个新的程序来替代自己。父进程可以继续运行下去,甚至可以通过系统调用wait()
,来保持对子进程的跟踪。
呼!了解了这么多信息,但是根据这些背景知识,也使得接下来这个启动程序的代码是顺理成章的了:
|
|
好了,此函数接收我们先前创建出的参数列表。然后,它分支出进程,并保存返回值。一旦fork()
函数调用返回,我们就有了两个同时运行的进程了。第一个即为子进程如果满足情况(pid
== 0)的话。
在子进程中,我们希望运行用户提供的命令。所以,我们使用系统调用exec
的众多变种之一,execvp
。exec
的不同变种提供了不同的功能,有些需要可变数量的字符串参数,有些则需要字符串列表。更有一些让你特别选定进程运行的环境。这一个(指execvp)特别的变种(第一个参数必须是程序名),需要一个程序名和一个字符串参数数组(也称为矢量,‘v')。’p‘意味着我们选择提供程序名,并让操作系统寻找环境路径(PATH)中的程序,而不是为了运行程序而提供完整的文件路径。
如果execvp
命令返回 -1(或者它有返回状态的话),我们就知道有错误出现了。所以,我们使用perror
来打印程序名和系统错误信息,这样用户就可以知道是哪儿出了错。然后,程序退出留待Shell继续运行。
第二种情况(pid < 0)检查fork()
是否出现错误。如果出现了,我们将其打印出来并继续——除了告知用户此错误并由他们来决定是否需要退出以外,我们别无他法。
第三种情况意味着fork()
成功执行。父进程将保持不变。我们知道子进程将要开始执行,所以父进程需要等待命令完成运行。我们使用waitped()
来等待进程的状态改变。棘手的是,waitpid()
有许多选项(正如exec()
一样)。进程可能会以多种方式改变状态,并不是所有的方式都表示进程已经结束了。一个进程可能是退出了(普通情况下,或伴随一个错误码),也可能是被信号终止。所以,我们用waitpid()
提供的宏来等待进程退出或是终止。然后,函数最终返回1,作为调用此函数的信号,我们应该再次提示输入。
Shell内置函数
你可能已经注意到了,lsh_loop()
函数循环中调用的是lsh_execute()
函数,而我们却以lsh_launch()
命名我们上述实现的函数。这是有意为之的!可以看到,Shell执行的大部分命令都是(外部)程序,但不是全部。其中有一些是Shell的内置函数。
这样做的理由很简单。(举例来说)如何你想要改变目录,你需要使用chdir()
函数。然而,当前所在目录是进程的一个属性。所以,如果你写了一个名为cd
的更改目录的程序,它只会改变它自己的目录,然后便终止了。它的父进程的目录并不会改变。然而,shell进程本身需要执行chdir(),以便更新自己的当前目录。这样,当它启动子进程时,子进程也可以继承该目录了。
类似的,如果有一个名为exit
的程序,它理应不会使得调用它的Shell退出。同样的,对大多数的Shell来说,是通过运行类似~/.bashrc
的配置脚本来对自身进行配置的。这些脚本使用改变Shell操作方式的命令。要做到这一点,只有在这些命令是Shell的内部实现的前提下才有可能。
如此一来,添加一些Shell的内置命令也就变得顺理成章了。我为自己的Shell选择的是cd
,exit
,和help
。下面是它们的函数实现:
|
|
代码分为三个部分。第一部分包含函数的前置声明。前置声明使得你可以在定义它(而尚未实现时)以前使用它的名字。我这样做的原因是因为lsh_help()
函数需要用到内置函数数组,而这个数组包含lsh_help()
的函数名。解决这一依赖循环的最简洁办法就是使用前置声明了。
下一部分是一个包含内置命令名称的数组,及包含其相应函数的数组。这样实现的原因是,在未来,可以简单地通过修改此数组来添加内置命令,而不必通过编辑代码中某处一个大型的”switch“语句段。如果你对builtin_func
的声明感到疑惑的话,没关系,我也如此!它是一个函数指针数组(接收字符串并返回一个int
)。在C中,任何涉及到函数指针的声明都会引起困惑。我自己仍然在学习函数指针是如何声明的!
最后,我实现了每一个函数,lsh_cd()
首先检查它接收的第二个参数是否存在,若否的话则打印错误信息。然后,它调用chdir()
,检查是否有错误,返回。help函数打印帮助信息以及所有内置函数的名字。exit函数返回0,作为loop
循环命令终止的信号。
将内置函数和进程整合起来
最后一片迷失的拼图就是实现lsh_execute()
函数了,它将会启动一个内置函数,或者一个进程。如果你已经读到了这儿的话,你会发现这个函数的实现是十分简单的:
|
|
它所做的全部事情就是,检查输入命令是否是内置命令,如果是的话,运行它。否则,调用lsh_launch()
启动程序进程。值得考虑的一点是,如果用户输入了空字符串,或者空格符,输入参数args
就有可能为NULL。所以,我们需要在开头就检查这些情况。
组织代码
以上就是Shell中的全部代码了。如果你读到了这里,你应该已经完全明白Shell的工作原理了。为了尝试运行它(在一台Linux机器上),你应该将这些代码整合到一个文件中(main.c
),并且编译它。要注意其中只应包含一个lsh_read_line()
函数的实现。你需要在文件顶部包含以下头文件。我添加了注释来帮助你了解每个函数的出处。
-
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
来运行就好了。
或者,你也可以在Github上找到这些代码。该链接直接指向编写本文时的当前修订版的代码——在未来的某一天我可能会考虑更新它并加上一些新功能。如果我这么做了,我会尽我所能地将这些细节和实现思路更新在文章中的。
总结
如果你已经读到了这里,并且好奇我究竟是怎么知道如何使用这些系统调用函数的,答案十分简单:man
手册。在man 3p
里,每个系统调用都有详尽的文档。如果你清楚你的需求,并且想知道如果去实现它,man
手册将是你的绝佳助手。如果你不清楚C标准库和Unix系统给你提供了哪些接口,我向你推荐POSIX 规范,特别是13章节,”Headers“。你可以在那里找到每一个拥有详尽定义的头文件文档。
当然,这个Shell并不是功能完备的。明显的一些缺陷就包括:
- 仅允许使用空格分隔参数,不允许使用引用或是反斜杠转义。
- 没有管道或重定向。
- 仅有少量的内置函数。
- 不支持通配符。
上述功能的实现是十分有趣的事情,但那样已经远远超出我这篇文章所能容纳的内容范围了。如果我有机会去实现其中之一的话,我会写一篇后续文章的。但是我鼓励我的读者们去亲自实现这些功能。如果你成功了的话,请在评论区留言,我十分乐于见到这些代码。
最后,感谢阅读本篇教程(如果有人这么做了的话)。我享受写作本文的过程,希望你们能同样享受阅读它的过程。在评论区留下你们的想法吧!
**后记:**在文章的早期版本中,我在lsh_split_line()
中遇到了一些烦人的错误,这些错误恰好导致了函数间相互抵消。感谢Reddit上的/u/munmap
(还有其他一些评论用户)找到了这些错误!可在diff中查看我犯了什么错误。
**后记2:**感谢Github用户ghswa为函数malloc()
所做出的null检查贡献。他/她同样指出了get_line()
函数的manpage上表明其第一个参数应是可释放的,故而在我使用了getline()
实现的lsh_read_line()
函数中,line
应被初始化为NULL
。