用C语言手写一个简易Shell:从进程模型到核心实现

发布时间:2026/10/3 14:39:47
用C语言手写一个简易Shell:从进程模型到核心实现
拿到这个作业的时候很多人其实不是不会写C而是没搞明白Shell到底该干什么。我当年也一样对着“用C语言实现一个简单的shell终端”这个题目愣了半天第一反应是“Shell不就是那个黑框框吗怎么用C写”后来才想明白这个作业真正考察的是你对Linux进程模型、文件描述符、字符串处理这几个基本功的理解有多深。说白了就是让你亲手实现一个能接收命令、创建进程、执行程序的命令行解释器。这个项目适合谁两类人。一类是正在被这门作业折磨的学生你需要的是把原理讲透、把代码掰开揉碎让你能自己写出来而不是抄一份交差另一类是学完Linux操作但一直停留在“会用命令”阶段、想往底层看一眼的开发者。你不需要很牛才能读懂这篇文章但需要你愿意动手敲代码。我不会给你一份完整代码让你直接复制那样没意思。我会把整个项目从原理到设计再到核心代码怎么写、坑在哪里一条线讲清楚。你跟着思路走完一遍自己从头写一遍这个作业你就真的吃透了。1. 动手之前理解Shell的完整工作流程Shell在Linux里是个什么角色你可以把它理解成一个翻译加调度员。坐在终端前敲命令的人是你内核负责干活Shell夹在中间——它把你的输入变成内核能理解的进程调用再把结果回给你。所以Shell的本质就是一个循环程序等待输入、解析输入、执行命令、等待下一个输入。1.1 终端、Shell和进程之间的关系先厘清几个容易混的概念。终端是一个硬件设备的概念现在是软件模拟的比如你开的终端窗口。Shell是跑在终端里的那个程序常见的有bash、zsh、sh。你写一个简单的shell本质上是写一个普通的用户态进程只不过这个进程的特殊之处在于它要能读取用户的键盘输入还要能创建其他子进程去执行命令。关键点来了当你在Shell里敲ls -l然后回车Shell并不是自己去列文件而是先去找到一个叫ls的可执行程序然后创建一个子进程让这个子进程去执行lsShell自己则等待子进程结束再去读下一条命令。这是整个Linux进程模型最核心的一个场景一切命令的执行都离不开fork和execShell则借助waitpid来同步子进程结束的时机。1.2 简单Shell的完整生命周期一个最简单的Shell它的生命周期就是下面这个循环打印提示符比如mysh$。从标准输入读取一行字符串。把字符串按空格拆成命令和参数比如ls -l /home拆成ls、-l、/home。判断这个命令是不是内建命令比如cd、exit是的话直接处理不是的话进入下一步。调用fork创建子进程在子进程里调用execvp执行命令父进程用waitpid等待子进程结束。回到第1步继续循环。注意我提到“内建命令”这个词。为什么必须有这个东西比如cd它改变的是Shell自身的工作目录如果咱们也用fork一个子进程去执行cd那改变的只是子进程的工作目录子进程跑完就没了Shell自己的工作目录纹丝不动。所以像cd、exit、export这类影响Shell自身状态的命令必须在Shell内部自己实现这就是“内建命令”存在的原因。1.3 你是要做哪个版本的Shell“简单的shell终端”这句话其实很模糊我建议你在动笔之前先问清楚你自己要做哪个版本。我见过很多同学上来就写写了一周发现越写越复杂最后崩了原因就是没有提前定义功能边界。我做这个作业时给自己定的版本是支持任意带参数的外部命令支持cd、exit、pwd三个内建命令支持PATH环境变量查找支持空格和Tab分隔参数。这是基础版。如果你还想拿高分可以考虑加上重定向、和管道|但这属于进阶版我会在后面的章节单独讲。你先把基础版跑通再考虑升级千万别一上来就想把bash的语法糖全部实现那不是这个作业的要求也会把自己劝退。2. 架构设计先画好功能边界再动手这个作业的C语言代码量大概在300到600行之间不算大但如果你不先想清楚结构写起来会非常混乱。我建议分四层来设计主循环、解析器、执行器、内建命令模块。每一层职责单一后续调试也方便。2.1 数据结构的定义命令怎么表达在C语言里execvp需要的是一个char *argv[]数组数组第一个元素是程序名最后一个元素必须是NULL。比如执行ls -l你需要构造出{ls, -l, NULL}这个东西。这个约束从头到尾贯穿项目所以你的解析器输出就应该是这样一个字符串数组。我当年定义的Command结构体是这样的#define MAX_ARGS 64 #define MAX_LINE 1024 typedef struct { char *argv[MAX_ARGS]; int argc; } Command;其实你会发现Command结构体有点多余直接用char *argv[]就行但封装一下有个好处以后想扩展比如加一个redirect_fd字段表示重定向到哪个文件直接往结构体里加字段就行不会动到其他代码。2.2 主循环怎么写读一答一主循环我推荐写成这样while (1) { print_prompt(); char *line read_line(); Command cmd parse_line(line); execute_command(cmd); }三个函数各管一件事read_line负责读入一行并去掉换行符parse_line负责把字符串拆成参数数组execute_command负责判断和执行。把逻辑拆开好处是每段代码都很短出问题了单步调试就能定位。这里有个细节容易被忽略打印提示符的时候应该输出到标准错误而不是标准输出。为什么因为如果用户执行了ls file.txt标准输出被重定向到了文件如果提示符也写到标准输出它会跑到文件里污染命令输出结果。写到标准错误stderr是Shell常见做法bash也是这么干的。我见过不少同学程序测试时一切正常一加重定向功能就发现文件里多出一堆$就是这个原因。2.3 功能边界要做什么坚决不做什么很多人在实现时被bash的各种能力带偏了写了一大堆跟作业无关的功能还浪费时间。我先帮你划几条清晰的边界基础版必须要做的读到一行命令拆成参数数组能执行普通外部命令能传入参数给命令支持cd、exit、pwd内建命令能正确处理无命令输入直接回车的情况建议不要急着做的复杂的语法分析比如if、for、while这些控制结构那是写解释器不是写Shell命令别名、历史记录、Tab补全这些是体验功能对原理理解没有帮助后台执行和作业控制涉及信号和进程组太复杂先把基础循环跑通。如果连ls -l都不能正常执行谈再多新特性都是空中楼阁。3. 核心代码实现一行一行来现在进入正题。我会逐段讲关键代码你不用照抄理解后自己敲一遍。我会把每个选择背后的原因也讲清楚。3.1 读取输入老实的fgets最靠谱读输入最直接的方式是fgets。注意不能用scanf(%s)因为遇到空格就断了也不能用gets那个函数在C11标准里已经被移除因为它无法限制读取长度缓冲区溢出风险极大。正确做法char buf[MAX_LINE]; if (fgets(buf, sizeof(buf), stdin) NULL) { // 读到EOF比如用户按了CtrlD exit(EXIT_SUCCESS); } buf[strcspn(buf, \n)] \0; // 去掉末尾换行符这里用strcspn是标准做法它会返回字符串中第一个匹配到目标字符的下标把那个位置置为\0就干净地去掉了换行符。注意如果用户输入的行超过MAX_LINE - 1fgets会截断后面我讲常见问题时会展开讲这个坑。还有一个细节如果用户直接按了回车读到的是一行空字符串解析后argc 0你应该什么也不做继续下一次循环而不是尝试执行一个空命令。3.2 解析命令strtok好用但有个大坑解析命令我们第一反应是用strtok它按分隔符把字符串拆成子串用起来极爽。但有一个大坑strtok会把字符串本身破坏掉把分隔符替换为\0并且它是静态存储的不可重入。在这个项目里问题不大后文我会讲怎么处理。我建议你用手写的方式解析一方面是绕开strtok的坑另一方面是你能自由扩展比如后面想支持引号拼接参数手写解析更方便。基础版的手写解析思路如下char *parse_args(char *line, char *argv[]) { int argc 0; char *p line; while (*p ! \0) { // 跳过开头的空白字符空格、Tab while (*p || *p \t) p; if (*p \0) break; // 此时p指向一个参数的开头记录起始位置 argv[argc] p; // 跳过这个参数直到遇到下一个空白字符 while (*p ! *p ! \t *p ! \0) p; if (*p ! \0) { *p \0; p; } } argv[argc] NULL; return argv[argc]; }这段代码做的事情很朴素遇到空白就跳遇到非空白就当作参数开始一直走到下一个空白结束用\0替换空白。整个过程把一行字符串切成了以\0结尾的参数序列。这段代码能处理连续多个空格比如echo hello world因为每轮循环开头都跳过了所有空白。这里我提一个进阶诉求如果你将来想支持引号比如echo hello world拼成一个参数你就在跳过参数时加判断如果遇到或就一直读到下一个同样的引号为止并且跳过引号本身。这个不复杂基础版可以先不加。3.3 执行外部命令fork execvp waitpid 三件套这是整个Shell的核心理解了这一段你就理解了Linux进程管理大部分的精髓。第一步先判断内建命令。如果是cd、exit、pwd直接调用对应的函数处理不创建子进程。检测方法就是把argv[0]和字符串strcmp比较。第二步fork创建子进程。pid_t pid fork(); if (pid 0) { perror(fork); return; }fork执行成功后会出现一个和父进程几乎一样的子进程两者的区别是fork返回值不同父进程中返回子进程PID子进程中返回0。所以下面是分支逻辑if (pid 0) { // 子进程去执行命令 execvp(cmd-argv[0], cmd-argv); // 如果执行成功execvp不会返回能走到这里说明执行失败了 fprintf(stderr, mysh: %s: command not found\n, cmd-argv[0]); exit(EXIT_FAILURE); } else { // 父进程等待子进程结束 int status; waitpid(pid, status, 0); }execvp里的v表示参数用数组传p表示会去PATH环境变量指定的目录里查找可执行文件。所以你在Shell里敲ls它能自动在/bin、/usr/bin找到你你不需要自己拼绝对路径。还有一点很多人会踩坑子进程里execvp失败后一定要exit并且要打印错误信息到stderr。如果漏了exit子进程会继续往下跑去读下一条命令导致出现“你的Shell被复制了一份”的诡异现象这是经典的僵尸循环问题。为什么用waitpid而不是waitwaitpid可以指定等待特定子进程还能配合WNOHANG做非阻塞等待以后你如果做后台任务会用到。这里先写成阻塞等待符合基础版需求。3.4 内建命令为什么必须单独伺候内建命令的处理不需要fork直接在Shell进程里执行函数。我列出三个核心内建命令cd命令用chdir(argv[1])改变当前工作目录。如果没有参数记得chdir(getenv(HOME))回到用户主目录这是bash的默认行为。注意要处理chdir失败的情况比如cd /nonexistent要打印错误到stderrShell不能崩。exit命令直接break出主循环或者调用exit(EXIT_SUCCESS)结束进程。注意如果有参数exit 1应该用exit(atoi(argv[1]))设置退出码。这虽然基础但有些脚本执行会依赖退出码。pwd命令调用getcwd获取当前工作目录并打印相当于getcwd(buf, sizeof(buf))然后printf(%s\n, buf)。这三个命令的代码非常简单但如果你把这些逻辑不单独实现而是也走fork子进程去执行就会遇到我前面说的致命问题——cd改不了Shell自己的工作目录。这是Shell设计一个很重要的分水岭凡是会影响Shell自身状态的命令都必须是内建命令。3.5 进阶扩展重定向和管道如果基础版你已经跑通了接下来你可能会想挑战重定向和管道。这两个功能是Linux下最常用的两个特性也是在面试和评分里最容易加分的点。我给思路但注意做好心理准备这部分不需要在做基础版时一步到位。重定向的实现思路在fork之前或子进程里exec之前用open打开目标文件然后用dup2把文件描述符复制到STDOUT_FILENO或STDIN_FILENO再关闭原始fd。核心代码是// 在子进程、exec之前 int fd open(output.txt, O_WRONLY|O_CREAT|O_TRUNC, 0644); dup2(fd, STDOUT_FILENO); close(fd); execvp(...);这里的顺序很关键先dup2把fd复制给标准输出此时标准输出就指向了文件然后close(fd)关闭原fd就安全了因为dup2之后有两个fd指向同一个文件表项关掉一个还有一个。管道的实现思路管道用pipe(fds)创建一对fdfds[0]是读端fds[1]是写端。执行cmd1 | cmd2时要创建两个子进程把cmd1的标准输出重定向到fds[1]把cmd2的标准输入重定向到fds[0]关掉管道两端等待两个子进程这个逻辑比重定向复杂核心难点在于你需要在父进程里关闭管道两端否则会因为fd引用计数不为零导致读端读到EOF却一直阻塞今天先理解思路真正实现时要有耐心调试。4. 测试与验收怎么判断你这个Shell写好了代码写完了怎么证明它能用把这个作业当成一个软件项目你得自己设计一套测试用例。4.1 基础功能测试清单我建议你准备一张测试表逐项验证测试项输入预期结果简单命令ls正常输出当前目录文件带参数命令ls -l /tmp输出/tmp下文件的详细信息内建cdcd /tmp再执行pwd打印出/tmp内建exitexit 3Shell退出echo $?能看到3多空格分割echo hello world输出hello world空命令直接按回车无任何输出提示符继续等待不存在的命令nosuchcmd打印command not foundShell继续运行命令路径/bin/ls能正常执行因为execvp支持含路径的命令我当年写测试时发现一个很典型的问题用echo hello world测试多空格如果输出是hello world那就是对但如果你用printf hello %s\n $var这种方式测试那是bash的行为这个Shell不需要支持变量展开。4.2 自动化测试把测试脚本跑起来为了偷懒我当时写了个简单脚本批量输进去对比预期输出。测试命令可以写到文件里然后重定向你的Shell输入echo ls -l echo cd /tmp echo pwd echo exit然后用./mysh test_input.txt来看输出对不对。但要注意重定向之后你的提示符也会被输出捕捉到这是正常的因为提示符写到了stderr而测试输入重定向的是stdin。这条逻辑我前文埋了个伏笔这里就通了提示符写stderr的另一个好处就是自动化测试时不会污染标准输出你只需要对比stdout就能验证命令执行结果。建议你用脚本把“预期输出”和“实际输出”做一次diff这个虽然简陋但是能让你快速定位回归。养成这个习惯以后做更大的项目也有用。5. 常见问题与排查技巧实录这个作业我踩过不少坑也帮不少同学排查过问题。这里整理几个出现频率最高的问题附带排查思路希望你能少走弯路。5.1 提示符不显示按回车没反应最常见的原因fgets把换行符\n留在了字符串里你比较命令时strcmp(cmd, cd)永远不相等于是cd被当成外部命令去forkshell看着像“卡住了”但其实它在等待子进程结束。排查方法很简单把读到的字符串打印出来看看或者用strcspn去掉\n。我见过更隐蔽的版本用户按回车Shell就退出原因是解析时空字符串没有正确处理argv[0]是空串execvp()失败子进程打印一条错误还退出了。解决方法是解析后判断argc 0就直接continue。5.2 出现大量“僵尸进程”Zombie如果你打开另一个终端执行ps aux看到一堆defunct状态的进程说明父进程没有回收子进程。这个项目里最大的可能是你 fork 之后没有调用waitpid或者内建命令分支忘了处理外部命令的等待逻辑。waitpid必须和fork对齐每个子进程创建后父进程都必须等待它结束。顺便提一个技巧如果你以后想做支持后台运行的Shell就需要给父进程注册SIGCHLD信号处理函数用waitpid(-1, status, WNOHANG)去回收所有结束的子进程而不是只等特定一个。这是进阶做法现阶段用阻塞waitpid就够了。5.3 输入超长命令就崩了MAX_LINE设成 1024这个值不会有问题但如果用户输入超过1024个字符fgets只读取一部分剩下的留在缓冲区里下次循环又会被读进来导致莫名其妙的参数。稳妥的做法是检测fgets读取的那行末尾是否还是\n如果没有换行符说明缓冲区满了继续循环清空或者直接报错。这个边界情况课程测试不一定会覆盖但养成处理它的意识是好的。5.4 CtrlC 把Shell也杀了CtrlC 会向整个前台进程组发送SIGINT信号你的Shell和它fork出来的子进程都在同一个进程组所以按 CtrlC 时你的Shell和正在执行的命令都会收到信号。bash的做法是让Shell忽略SIGINT只让前台子进程负责处理。实现方式是signal(SIGINT, SIG_IGN);不过这个是进阶话题你基础版可以先不管但你要知道直接用默认行为CtrlC 会把你的Shell和子进程一起终止。如果你做完基础版还有余力把SIGINT忽略加进去体验会好很多。这里注意你的Shell不要自己退出而是要等子进程结束后继续循环。5.5 常见问题速查表现象可能原因排查方向命令执行后多出一行错误exec失败后没有exit子进程沿父路径继续跑检查子进程分支最后是否有exit所有命令都报 not foundexecvp 查找不到可能你忘了让参数数组以NULL结尾打印argv检查最后一个元素是否为NULLcd 没有效果cd 用fork子进程执行了检查内建命令是否在fork之前处理提示符跑到重定向文件里提示符写到了stdout改成写stderr输出没有换行argv[0]里带着\n检查是否做过换行符清理按回车Shell就退出空命令没有跳过解析后判断argc06. 我做完这个项目后的几点体会第一这个作业花了我最长时间的不是写代码而是理解“父进程到底在干什么”。一开始我总觉得Shell是个高高在上的程序后来发现自己写一遍才发现它就是个典型的“队长角色”负责接收指令、组建小分队fork、发任务exec、等队员归队waitpid。你对这个流程理解得越透后面学多线程、学网络编程里的多进程模型都会顺手很多。第二调试思路比代码本身重要。我建议你在开发时多打印、多观察ps aux和pwd的变化。比如怀疑fork出了问题就在分为前打印一条标记在子进程里也打印一条看执行顺序就能定位问题。千万别蒙头改代码先用信息确认你的假设。第三这个项目最大的隐藏价值在于等你写完再看bash那些“自动补全”“历史记录”“作业控制”你会觉得它不仅仅是个工具而是一个精心设计的程序。再看Linux系统调用文档时你对fork、exec、waitpid的理解就不再是死记硬背了。如果你学完这个还想继续深挖我建议你下一步去实现“多个管道串联”或者给Shell加上简单的引号处理和通配符展开。这些改动有一半是字符串处理另一半还是进程管理但你会发现自己对Shell的理解又会上一层。这个作业做完以后留着等以后学了信号处理再回头看会发现它其实是一扇门门后是Linux系统编程的一大片后花园。