揭秘:C 语言构建 Shell,这些关键要点被你忽视了 对于很多人来说常常会不自觉地认为自己“算不上真正的程序员”。想想看市面上那些大家日常都在使用的程序功能强大又复杂而开发它们的程序员仿佛自带光环让人觉得高不可攀。但实际上别看大型软件项目开发起来困难重重很多时候其背后的基本原理并没有那么神秘是相当简单易懂的。自己动手去实现这些软件可是一种超棒的方式能让你切实感受到自己也具备成为真正程序员的实力而且这个过程充满乐趣哦接下来我就详细地给大家讲讲我是如何用C语言编写一个简单的Unix shell的希望通过我的分享能让大家也有同样的体会发现编程的乐趣和自己的潜力。需要源代码的请私聊我特别要提醒大学生朋友们在很多课程里都会布置编写shell的作业而且有些老师对这篇教程以及相关代码是有所了解的。如果你正好在学习这样的课程可一定要注意没有得到老师许可的情况下千万别抄袭这段代码也不要抄袭后稍作修改就交上去。最好还是自己多思考、多尝试这样才能真正学到东西。Shell的基本生命周期咱们先来从整体上认识一下shell。一个shell在它的整个运行过程中主要会做三件事。1.初始化在这个阶段一般的shell会读取并执行它的配置文件。这些配置文件就像是给shell制定的规则会改变shell的各种行为表现。2.解释初始化完成后shell就开始从标准输入获取命令啦。这里的标准输入可以是你在交互式界面中手动输入的命令也可以是从某个文件里读取的命令然后shell会执行这些命令。3.终止当所有命令都执行完毕后shell会执行一些关闭相关的命令把之前运行过程中占用的内存都释放掉最后结束自己的运行。这些步骤其实很通用不少程序都遵循类似的流程。不过我们要构建的这个shell非常简单它不会有配置文件也没有专门的关闭命令。所以我们只需要调用一个循环函数等这个循环结束程序也就可以终止了。但从程序架构的角度来看大家要清楚地知道程序的生命周期可不只是一个简单的循环过程它包含了从开始到结束的各个阶段。在代码里你可以看到我定义了一个函数lsh_loop()这个函数的作用就是不断循环负责解释和处理用户输入的命令。后面我们就会看到它具体是怎么实现的。Shell的基本循环我们已经弄清楚了程序该如何启动接下来看看程序的基本逻辑也就是shell在循环过程中具体都做些什么。有一种很简单的处理命令的方法主要分为三个步骤1.读取从标准输入读取用户输入的命令。这就好比你在命令行界面中敲下各种指令shell得先把这些指令读取进来才能知道你想让它做什么。2.解析把读取到的命令字符串拆分成程序名和参数。比如说你输入“ls -l”这里“ls”就是程序名“-l”就是参数。shell要把它们区分开来才能正确地执行命令。3.执行根据解析出来的程序名和参数运行相应的命令。下面我把这些思路写成lsh_loop()函数的代码咱们来逐行分析一下这段代码。开头几行都是变量声明就像是在准备一些小盒子用来装后面要用到的数据。这里用的do - while循环有个好处它会先把循环体执行一次然后再去检查status变量的值。在循环里面首先会打印一个提示符“ ”这就像是在告诉你shell已经准备好接收你的命令了。接着调用lsh_read_line()函数读取你输入的一行命令再调用lsh_split_line()函数把这行命令拆分成参数然后通过lsh_execute()函数去执行这些参数。最后别忘了把之前创建的用来存储命令行和参数的内存空间释放掉避免内存浪费。这里要注意我们是通过lsh_execute()返回的status变量来判断什么时候该退出循环的。读取一行输入从标准输入读取一行内容听起来好像没什么难度但在C语言里实际做起来可有点小麻烦。为什么呢因为你没办法提前知道用户到底会在shell里输入多长的文本。你不能随便分配一块固定大小的内存就指望用户输入的内容不会超出这块内存的容量。那该怎么办呢正确的做法是先分配一块内存要是用户输入的内容超出了这块内存的大小就重新分配一块更大的内存空间。这是C语言里处理这类问题的常见办法我们就用这个方法来实现lsh_read_line()函数。函数一开始声明了好多变量。可能你也发现了我比较喜欢用老式的C语言风格先把所有变量都声明好再写其他代码。函数最关键的部分就在这个while (1)循环里这个循环看起来好像是个死循环但实际上它有自己的退出条件。在循环里我们用getchar()函数读取一个字符注意哦这里把读取到的字符存成int类型而不是char类型这一点特别重要因为EOF是一个整数不是字符如果想用getchar()读取到的字符和EOF比较就必须用int类型来存储读取到的字符这可是C语言初学者很容易犯的错误。要是读取到的字符是换行符或者EOF那就说明用户输入结束了我们在当前字符串的末尾加上空字符然后把这个字符串返回。要是读取到的不是这两个特殊字符就把这个字符存到我们之前分配的字符串buffer里。接着我们检查下一个要存储的字符会不会超出当前缓冲区的大小如果超出了就重新分配一块更大的内存空间给buffer当然重新分配内存的时候要检查一下分配是否成功要是失败了就得打印错误信息然后结束程序。基本上这个函数就是这么工作的。对较新版本C库比较熟悉的同学可能会发现stdio.h头文件里有个getline()函数它能完成我们刚才实现的大部分功能。说实在的我在写完这段代码之后才知道有这个函数。在2008年之前getline()函数是GNU对C库的扩展后来才被纳入标准规范所以现在大多数现代的Unix系统都支持它。不过我还是保留了原来自己写的代码并且建议大家在学习的时候先掌握自己实现读取输入的方法再去用getline()函数。因为只有自己动手实现一遍才能真正理解其中的原理要是一开始就用现成的函数就会错过这个宝贵的学习机会。当然用getline()函数之后代码会变得更简单别看这段代码好像短了不少但它也不是完全没有需要注意的地方。在读取输入的时候我们还是得检查是不是到达了文件末尾或者有没有出现错误。EOF文件结束符出现的情况有两种一种是从文本文件里读取命令读到了文件末尾另一种是用户在键盘上输入了Ctrl-D这也表示文件结束。不管是哪种情况都说明程序可以正常退出了。要是出现了其他错误就得打印错误信息然后退出程序。解析输入行回顾一下前面的循环部分我们已经实现了lsh_read_line()函数成功获取了用户输入的命令行。现在我们要把这行输入解析成参数列表。这里我做了一个简化处理在我们这个简单的shell里命令行参数不支持使用引号或者反斜杠转义我们就简单地用空格来分隔参数。比如说你输入“echo this message”按照我们的规则这个命令不会把“this message”当成一个参数传递给echo而是会把echo的参数拆分成两个分别是“this”和“message”。基于这样的简化我们要做的就是以空格为分隔符把输入的字符串进行“分词”。这时候经典的库函数strtok就能派上用场了它可以帮我们完成一些比较繁琐的工作。这段代码是不是看起来和lsh_read_line()函数有点像没错它们采用了类似的策略都是先分配一个缓冲区然后根据实际情况动态扩展缓冲区的大小。不过这次我们处理的不是字符数组而是一个以空字符结尾的指针数组。在函数开始的时候先调用strtok函数对输入的字符串进行分词它会返回指向第一个分词的指针。strtok()函数的工作原理是它会在你传入的字符串里找到分隔符然后在分隔符的位置放上\0字节把字符串拆分成一个个的分词并且返回指向第一个分词的指针。我们把这个指针存到tokens数组里。接下来只要strtok还能返回分词我们就不断地把分词指针存到tokens数组里。在这个过程中如果tokens数组快存满了也就是position快要达到bufsize了我们就重新分配一块更大的内存空间给tokens数组当然和之前一样要检查内存分配是否成功。当strtok返回NULL的时候就说明分词结束了我们在tokens数组的末尾加上一个NULL指针这样就形成了一个以空指针结尾的分词数组最后把这个数组返回。好了现在我们已经把输入的命令行解析成了分词数组接下来的问题就是怎么用这个数组来执行命令呢Shell如何启动进程现在我们真正深入到shell的核心功能部分了。启动进程可是shell的主要任务。所以说要编写一个shell你必须清楚地了解进程是怎么工作的以及它们是如何启动的。接下来我就给大家简单介绍一下类Unix操作系统里进程的相关知识。在Unix系统里启动进程只有两种方式。第一种方式几乎可以忽略不计是通过Init进程启动。你想想当Unix计算机启动的时候首先会加载内核。内核加载并初始化完成后只会启动一个进程这个进程就是Init进程。Init进程很特殊只要计算机开着它就一直运行而且计算机上其他那些让系统正常工作所需要的进程都是由Init进程负责加载的。不过对于我们编写的程序来说绝大多数都不是Init进程。所以对于进程实际的启动方式只有一种可行的办法那就是使用fork()系统调用。当你调用fork()函数的时候操作系统会复制当前正在运行的进程这样就有两个进程同时运行了。原来的那个进程叫“父进程”新创建的进程叫“子进程”。fork()函数有个特点它会给子进程返回0给父进程返回子进程的进程ID号PID。这就意味着新进程只能通过现有的进程复制自身来启动。这可能会让你产生疑问通常我们想要运行一个新进程可不是想要一个和当前程序一模一样的副本而是想运行一个不同的程序呀。这时候exec()系统调用就发挥作用了。exec()系统调用会用一个全新的程序替换掉当前正在运行的程序。也就是说当你调用exec的时候操作系统会先停止当前进程然后加载你指定的新程序最后启动这个新程序。注意哦一个进程调用exec()之后正常情况下除非出现错误是不会再返回的。有了fork()和exec()这两个系统调用我们就有了在Unix系统上运行大多数程序的基本工具。一般的流程是这样的首先一个现有的进程调用fork()复制出一个子进程这样就有了两个独立的进程。然后子进程调用exec()用新程序替换掉自己。而父进程呢可以继续做其他事情甚至还能用wait()系统调用来跟踪子进程的状态。说了这么多下面这段用于启动程序的代码就好理解了这个函数接受我们之前解析好的参数列表args。它首先调用fork()创建子进程并且把返回值存到pid变量里。fork()返回之后实际上就有两个进程在同时运行了。子进程会进入if (pid 0)这个条件分支。在子进程里我们要运行用户输入的命令。这里用到了exec系统调用的一个变体execvp。exec有好几种变体它们的功能稍微有点不一样。有些变体可以接受可变数量的字符串参数有些接受字符串列表还有些能让你指定进程运行的环境。execvp这个变体有点特别它需要一个程序名和一个字符串参数数组这个数组也叫向量所以名字里有个v而且数组的第一个元素必须是程序名。名字里的p表示什么呢它表示我们不需要提供要运行程序的完整文件路径只要给出程序名就行操作系统会在它的路径里去搜索这个程序。如果execvp命令返回-1那就说明出现错误了。这时候我们用perror函数打印系统的错误信息并且把我们自己程序的名字“lsh”也带上这样用户就能清楚地知道错误是从哪里来的。打印完错误信息子进程就退出这样shell才能继续运行等待用户输入下一个命令。if (pid 0)这个条件分支是检查fork()函数有没有出错。要是fork()出错了我们就打印错误信息然后程序继续运行。因为这种情况下除了告诉用户出错了让他们决定是否要退出也没有其他特别好的处理办法。else分支对应的是fork()执行成功的情况此时运行到这里的是父进程。我们知道子进程会去执行用户输入的命令所以父进程需要等待子进程执行完毕。这里使用waitpid()函数来等待子进程的状态发生改变。不过waitpid()函数有很多选项就如同exec()函数一样。进程的状态改变方式多种多样并非所有的状态改变都意味着进程已经结束。进程既可以正常退出可能带有错误码也可能被某个信号终止。因此我们利用waitpid()函数提供的宏WIFEXITED和WIFSIGNALED来判断直到子进程退出或者被终止。最后这个函数返回1向调用它的函数发出信号表示我们应该再次提示用户输入新的命令。Shell内置命令你可能已经留意到了lsh_loop()函数调用的是lsh_execute()但前面我们定义的用于启动进程的函数是lsh_launch()。这其实是有意设计的你看shell执行的命令大部分是独立的程序但并非全部如此。有一些命令是直接内置在shell里面的。这其中的原因很简单。比如说当你想要切换目录的时候得使用chdir()函数。问题在于当前目录是进程的一个属性。假如你编写一个名为cd的程序来切换目录它只能改变自身这个进程的当前目录一旦这个程序运行结束它的父进程也就是shell进程的当前目录并不会改变。所以为了让shell进程的当前目录得到更新并且让后续启动的子进程也能继承这个新的目录就需要shell进程自身去执行chdir()函数。同样的道理如果有一个名为exit的程序它无法直接退出调用它的shell。这个退出命令也得内置在shell中才行。另外大多数shell是通过运行配置脚本例如~/.bashrc来进行配置的。这些脚本里使用的命令会改变shell的操作方式。只有这些命令在shell进程内部实现才能真正改变shell的运行状态。所以在我们的shell中添加一些内置命令是很有必要的。我在自己的shell里添加了cd、exit和help这几个内置命令。下面就是它们的函数实现这段代码可以分成三个部分来看。第一部分是对函数的前置声明。所谓前置声明就是在定义函数之前先把函数的名字、参数类型和返回值类型等信息声明一下。这样一来在后面的代码中就可以在函数正式定义之前使用这个函数了。我之所以这么做是因为lsh_help()函数会用到内置命令数组builtin_str而这个数组里面又包含了lsh_help()函数对应的指针。通过前置声明这种方式能够很简洁地打破这种依赖循环。接下来的部分定义了一个内置命令名字的数组builtin_str后面紧跟着一个对应的函数指针数组builtin_func。这么做的好处是以后如果想要添加新的内置命令只需要在这两个数组里进行修改就可以了而不用在代码里某个复杂的地方去编辑一个庞大的switch语句。要是你对builtin_func这个函数指针数组的声明感到困惑别担心这很正常它是一个数组数组里面的每个元素都是一个函数指针这些函数接受一个字符串数组作为参数并且返回一个整数。C语言里任何涉及函数指针的声明往往都比较复杂我自己每次用到的时候也经常得去查阅相关资料呢最后一部分就是具体的函数实现了。lsh_cd()函数首先检查它的第二个参数是否存在如果不存在就打印一条错误信息提示用户cd命令缺少参数。如果参数存在就调用chdir()函数去切换目录并且检查是否执行成功如果失败就打印错误信息。不管切换目录成功与否函数最后都返回1。lsh_help()函数的作用是打印一些帮助信息它会先打印这个shell的名称然后提示用户如何输入命令接着列出所有的内置命令最后告诉用户可以使用man命令获取其他程序的详细信息这个函数执行完也返回1。lsh_exit()函数最简单它直接返回0这个返回值就像是给命令循环发送了一个终止信号告诉循环该结束了。整合内置命令和进程现在就差最后一块拼图了那就是实现lsh_execute()函数。这个函数的作用是根据用户输入的命令判断是要启动一个内置命令还是启动一个外部进程。如果你一直跟着读到这里就会发现我们已经为实现这个函数做好了充分的准备它的代码其实很简单这个函数的工作流程是这样的首先检查用户输入的命令是否为空如果args[0]是NULL那就说明用户输入了一个空命令这种情况下函数直接返回1。然后通过一个循环把用户输入的命令和我们定义的内置命令数组builtin_str中的每个命令进行比较。如果找到了匹配的内置命令就调用对应的函数指针(*builtin_func[i])并且把参数args传递进去执行相应的内置命令函数返回执行结果。要是循环结束后没有找到匹配的内置命令那就调用lsh_launch()函数把args参数传递过去启动一个外部进程。整合所有部分到这里构建这个简单shell所需的全部代码就介绍完了。如果你一直认真地跟着读完相信你已经完全理解这个shell是如何工作的了。要是你想在Linux机器上实际运行一下这个shell需要把上面这些代码段复制到一个名为main.c的文件里然后进行编译。注意哦lsh_read_line()函数的实现你只能选择一种保留在代码里。另外你还需要在main.c文件的顶部包含以下头文件我给每个头文件都添加了注释方便你知道里面的函数都是用来做什么的。当你把代码和头文件都准备好了编译就很容易了。在命令行里运行gcc -o main main.c这个命令就能完成编译编译成功后会生成一个名为main的可执行文件。然后运行./main命令就可以启动我们编写的这个shell啦。或者你也可以直接从GitHub上获取代码。我提供的这个链接指向的是我写这篇文章时代码的当前版本。以后我可能会对代码进行更新添加一些新功能。要是我这么做了我会尽量在这篇文章里更新相关的细节内容以及新功能的实现思路方便大家学习。总结如果你读了这篇文章心里可能会好奇我是怎么知道该如何使用那些系统调用的呢答案很简单就是借助手册页man pages。在man 3p这个手册部分里对每个系统调用都有非常详细的文档说明。要是你知道自己想要找什么只是想了解某个系统调用具体该怎么使用那手册页绝对是你最好的帮手。如果你对C库和Unix系统提供的接口不太熟悉我建议你参考POSIX规范特别是其中的第13节“头文件”。在那里你可以找到每个头文件的详细介绍以及这个头文件必须定义的所有内容。很明显我们编写的这个shell功能还不是特别丰富存在一些比较明显的不足之处• 它只支持用空格来分隔参数不支持使用引号或者反斜杠进行转义。这就意味着一些复杂的参数格式它处理不了。• 不支持管道|和重定向、等操作。在实际使用中管道和重定向功能可以让我们更灵活地处理命令的输入和输出。• 标准内置命令很少。像一些常用的shell会有更多的内置命令来方便用户操作。• 不支持通配符扩展。通配符扩展可以让我们更方便地匹配文件名等内容。参考文献Tutorial - Write a Shell in C