如果你在 Linux 下写过几年代码大概率会对 Shell 产生一种“天天用、却说不清它到底怎么工作”的感觉。它不是普通的应用程序也不是内核的一部分而是介于用户和操作系统之间的一层解释器把你敲进去的字符串解析成命令然后通过进程控制相关的系统调用去创建子进程、等待结束、传递数据。这篇文章从进程控制的角度完整拆解自主实现一个 Shell 命令行解释器的思路、设计、代码结构和踩坑记录。从学习路径上看手写一个 Shell 特别适合作为进程控制系列的综合练习。它不像写业务代码那样依赖框架也不像调内核模块那样难以复现核心就围绕 fork、exec、wait 三大系统调用展开再叠加字符串解析、文件描述符重定向、管道通信这些细节。做完之后你对“进程到底是怎么被创建和控制的”“文件描述符在父子进程之间是怎么继承的”“管道两端为什么会阻塞”这些概念会有完全不一样的理解。1. 从“进程控制”角度看Shell 的职责是什么1.1 一个命令从敲下回车到结束内核层发生了什么很多人把 Shell 理解成“一个能执行命令的程序”这句话没错但不够精确。实际上Shell 自己几乎不执行任何外部命令它只负责三件事读入命令行、解析命令行、创建子进程去执行。真正运行 ls、grep、cat 的是 fork 出来的子进程Shell 只负责在父进程里调用 wait 等待子进程结束。所以从进程控制的角度看Shell 是一个典型的“父进程调度器”。你敲下 ./a.out 回车内核里发生的事情大致如下Shell 进程调用 read 或 getline从标准输入读入一行字符串。Shell 按照空格、引号、管道符等规则解析这一行拆出命令名、参数、输入输出目标。Shell 调用 fork() 创建子进程子进程里用 execvp() 加载新的程序映像。父进程调用 waitpid() 阻塞等待子进程运行完毕或异常退出后父进程继续读下一行。这四步就是整个 Shell 的核心循环。所谓“自主 Shell 命令行解释器”本质上就是把计算机系统课程里进程控制的知识组合成一个能交互运行的程序。1.2 为什么推荐把“手写 Shell”作为进程控制系列的收尾项目进程控制本身的知识点是零散的fork 怎么复制进程、exec 怎么替换进程映像、wait 怎么回收子进程、pipe 怎么让两个进程通信、dup2 怎么重定向文件描述符。单独学每个接口都不难难的是把串起来。手写 Shell 正好把这些点全部串在一起。比如你写一个支持管道的 Shell就一定会用到 pipe fork dup2 wait这四个系统调用缺一不可你写一个支持重定向的 Shell就一定会用到 open dup2 close还要思考文件描述符在 fork 之后为什么会共享。这个过程比单纯看系统调用文档要深刻得多。另外Shell 本身是一个交互式程序涉及输入解析、异常处理、子进程回收、信号屏蔽等。这些场景在实际工程里经常遇到但在普通练习项目里很难一次性覆盖。所以我把手写 Shell 当作进程控制系列的实践收尾做完这一篇前面学的那些接口才真正变成自己的。2. 整体架构先画出主循环再动手2.1 主循环的四个步骤动手写代码之前先把架构定下来。Shell 的主体就是一个 while 循环不需要设计复杂的面向对象结构C 语言完全够用。while (1) { // 1. 打印提示符 // 2. 读取一行输入 // 3. 解析命令行得到命令结构体 // 4. 执行命令 }这个循环看起来简单但每一步都有讲究。第一步打印提示符要注意如果输入不是终端而是管道比如 echo ls | ./myshell就不应该打印提示符否则输出会混入奇怪的内容。第二步读取输入我建议用 getline 而不是 fgetsgetline 内部自动分配缓冲区不用担心长度不够。第三步解析是后面单独讲的重点。第四步执行分两种情况内建命令直接在当前进程处理外部命令走 fork exec。我习惯用一个全局变量记录 Shell 是否退出比如 running 设置为 0 时退出循环。这样处理 exit 命令时只需要设置退出标志内部命令解析完成后循环自然结束。2.2 数据流设计与模块划分模块划分会影响后续维护和调试的体验。我现在这个版本没有整得太复杂分了五个文件main.c主循环、提示符、输入读取。parse.c命令行解析输入字符串输出 cmd 结构体数组。execute.c命令执行包括内建命令、外部命令、重定向、管道。builtin.c内建命令的具体实现。utils.c通用的错误处理、字符串工具函数。数据流是单向的main 负责拿到原始字符串交给 parse 拆成结构体再交给 execute 执行。结构体定义是整个程序的骨架我使用了最传统的“命令数组”模型每一条管道段对应一个命令结构体#define MAX_ARGS 64 #define MAX_CMDS 16 struct command { char *args[MAX_ARGS]; // 参数列表args[0] 是命令名 int in_fd; // 输入文件描述符-1 表示没指定 int out_fd; // 输出文件描述符-1 表示没指定 }; struct pipeline { struct command cmds[MAX_CMDS]; int cmd_count; };这种设计把解析和执行解耦了解析器只负责把字符串填充到结构体里执行器不关心字符串原来的样子只看结构体里的内容。in_fd 和 out_fd 字段是给重定向准备的如果一个命令带有符号解析时就把重定向文件名打开并把 out_fd 设置成对应的文件描述符执行时发现 out_fd 不是 -1就调用 dup2 把标准输出替换掉。2.3 第一个版本应该做哪些功能很多初学者一上来就想写一个功能完整的 Bash结果写了一半就被各种边界情况劝退。我的建议是分版本迭代第一个版本只支持最基础的功能跑通之后再逐步添加版本功能范围关键要解决的问题v0.1无管道、无重定向只执行简单外部命令fork、execvp、waitpid 的基本配合v0.2支持 cd、exit 等内建命令理解为什么内建命令不能 forkv0.3支持输入输出重定向open、dup2、close 的组合v0.4支持单管道pipe 文件描述符的分配与关闭v0.5支持多管道、变量展开、引号解析子进程的创建顺序和回收顺序我在实际开发中花了大量时间在 v0.4 到 v0.5 的过渡上多管道需要动态创建多个 pipe还要注意中间进程同时持有两个管道的一端文件描述符处理不当就会导致子进程阻塞。后面第七章专门讲这个问题。3. 命令行解析从字符串到可执行参数3.1 拆分参数为什么用 strtok_r 而不是 strtok解析器第一个任务是按空格拆命令C 标准库里最直接的函数是 strtok但我不建议直接用它。原因有两个strtok 内部使用静态缓冲区保存拆分位置不是线程安全的更重要的是它不能在嵌套调用中保存各自的上下文。比如你的解析函数里既要按管道符号分割又要对每一段按空格拆分如果两处分隔符都调 strtok状态会互相干扰程序就乱了。strtok_r 是 strtok 的可重入版本多一个 save_ptr 参数保存内部状态可以在嵌套解析场景下安全使用。char *save_ptr NULL; char *token strtok_r(input_str, |, save_ptr); while (token ! NULL) { // 处理一段命令 token strtok_r(NULL, |, save_ptr); }在每段命令内部继续按空格拆分时需要另外建一个 save_ptr 变量两层解析并行不悖。我踩过最早的坑就是没注意嵌套状态导致管道符号拆分完之后所有空格全部失效。3.2 引号与转义真实 Shell 里的边界情况如果只按空格拆分echo hello world会被拆成 echo、hello、world 三个参数这显然不对。真实 Shell 遇到引号时会把引号内的内容作为一个整体保留下来即使中间有空格。处理引号的简单方法是状态机遍历字符串时维护一个 in_quote 标志遇到双引号或单引号时切换状态只有不在引号内时才允许把空格作为分隔符。遇到反斜杠转义时跳过下一个字符并把它原样输出。例如对输入echo hello world test解析过程识别出 echo 作为第一个参数。遇到双引号进入引号状态连续积累 hello world去掉引号。遇到空格时因为处于引号状态不切割。遇到结束引号退出引号状态。下一个空格不是引号内作为分隔符test 成为下一个参数。写这部分代码时要特别注意引号是否成对出现。如果用户输入echo abc没有闭合引号比较严谨的处理是报错并放弃执行而不是把引号原样传给命令。我目前用了一个简单办法解析完成后判断 in_quote 是否仍然为真如果是返回解析失败。3.3 变量展开的简单实现变量展开也就是把$PATH这类字符串替换成环境变量的值看起来复杂其实核心就一个函数getenv。解析阶段每拿到一个参数就扫描字符串中的$字符遇到$后读取后面的变量名直到遇到空格、引号或字符串结束然后调用 getenv 获取值替换到参数中。如果变量不存在按标准 Shell 的行为替换成空字符串。shell 热词里有linux shell ${}和$() 区别这里简单提一句${}是变量展开语法$()是命令替换把括号内命令的输出作为结果。自主 Shell 第一版可以不实现命令替换单做变量展开就够了。变量展开写完之后你会发现echo $HOME输出的路径和真实 Shell 完全一致那种感觉是很有成就感的。不过要提醒一个细节双引号内做变量展开单引号内不做。这是 Bash 的规则真实 Shell 里单引号内的字符全部原样保留。我的实现里用一个字段标记当前参数是否来自单引号如果来自单引号就跳过变量展开逻辑上并不复杂。4. 进程控制fork、exec、wait 三件套的配合4.1 一条简单命令的执行流程这条命令没有管道和重定向就是最普通的ls -l或./a.out arg1。执行函数的核心逻辑void execute_simple(struct command *cmd) { pid_t pid fork(); if (pid 0) { perror(fork); return; } if (pid 0) { // 子进程 execvp(cmd-args[0], cmd-args); // 如果 execvp 返回说明出错了 fprintf(stderr, %s: command not found\n, cmd-args[0]); exit(127); } else { // 父进程 int status; waitpid(pid, status, 0); } }这里最关键的理解点是fork 之后子进程和父进程执行的是同一份代码唯一的区别是 fork 的返回值。子进程进入 if 分支后调用 execvpexecvp 成功的话子进程的进程映像完全被替换之后的代码不再执行execvp 失败才会继续往下走所以“command not found”的提示必须写在 execvp 之后并且打印完要 exit(127)否则子进程会跑回主循环出现一个 Shell 里面嵌套另一个 Shell 的诡异情况。4.2 PATH 搜索与 execvp 的隐藏逻辑为什么 shell 推荐用 execvp 而不是 execl 或 execv因为 execvp 会按照 PATH 环境变量去搜索可执行文件。echo $PATH输出的那些目录execvp 会依次尝试找到第一个匹配的就执行。用 execv 的话你必须自己把完整路径拼出来比如/bin/ls。用户敲ls就找不到文件必须敲/bin/ls才能执行。这在交互式 Shell 里是完全不可用的体验。关于 PATH 搜索有个细节需要注意如果在参数里已经包含斜杠比如./a.out、/usr/bin/python3execvp 会直接把它当路径执行不再搜索 PATH。这是 Shell 的一个习惯带斜杠就按路径来不带斜杠就按命令名搜索。这个逻辑可以写进博文解析层也可以直接依赖 execvp 的实现第一版可以偷懒。4.3 内建命令为什么 cd 不能 fork如果你照搬外部命令的执行流程处理 cd会发现子进程的当前目录变了但父进程 Shell 的当前目录没变。这涉及 fork 的语义子进程获得父进程地址空间、文件描述符表、当前工作目录的一份完整副本之后父进程和子进程各改各的互不影响。cd的本质是修改当前进程的工作目录所以它必须在 Shell 进程内直接调用 chdir()不能 fork 子进程去执行。同理exit是让 Shell 进程退出export是修改当前进程的环境变量表这些都是典型的“内建命令”必须在父进程里直接处理。我在 execute 函数前面加了一个判断先把命令名提取出来跟内建命令表比较命中的话直接调用对应处理函数if (strcmp(cmd-args[0], cd) 0) { return builtin_cd(cmd); } if (strcmp(cmd-args[0], exit) 0) { running 0; return; }4.4 等待子进程与退出码传递waitpid 的作用不只是“等待子进程结束”它还承担一个非常重要的职责回收子进程的资源防止它变成僵尸进程。僵尸进程是已经终止但父进程没有调用 wait 回收还在进程表里占着一个条目的进程。如果你开着 Shell 不断执行命令却不 wait用 ps 能看到一堆defunct状态进程时间久了进程表会被撑满。退出码的传递同样依赖 wait 系列函数。子进程调用 exit(127) 后父进程从 status 里取出退出码。完整解析方式int status; waitpid(pid, status, 0); if (WIFEXITED(status)) { int code WEXITSTATUS(status); printf(exit code: %d\n, code); }$?在真实 Shell 里能拿到上一条命令的退出码本质上就是从 waitpid 捞到的 WEXITSTATUS。自主 Shell 里你可以维护一个全局变量把每次 wait 到的退出码存下来以后做脚本的时候就可以支持echo $?了。5. 重定向与管道让数据按你想的方向流动5.1 重定向的本质是“替换标准文件描述符”和在 Shell 里看起来是命令的特殊参数本质上只是文件描述符的替换。echo hello file.txt的意思是先打开 file.txt 获得一个文件描述符然后把这个描述符复制到标准输出的位置fd 1之后往标准输出写的内容实际写到了文件里。实现上靠 dup2 完成复制经典的代码片段int fd open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd 0) { perror(open); return; } dup2(fd, STDOUT_FILENO); close(fd);这里有一个经常被忽略的点为什么 dup2 之后要 close(fd)因为 dup2 把 fd 复制到 STDOUT_FILENO 之后两个描述符都指向同一个文件表项。如果不关掉原来的 fd程序在后台还有另一个打开的描述符虽然现在不出问题但一旦以后加管道逻辑多出来的描述符可能会让管道的读端一直不关闭导致对端阻塞。记住一条黄金规则用完就关只留你需要的那个描述符。输入重定向方向相反open 用 O_RDONLYdup2 到 STDIN_FILENO。这里建议注意打开文件失败时的处理如果文件不存在Shell 应该报错并停止执行而不是继续往下跑命令。5.2 管道的本质是“一个读端一个写端的文件”管道是两个进程之间传输数据的机制它本质上是一个内存中的环形缓冲区。pipe() 一次给你两个文件描述符fd[0] 是读端fd[1] 是写端。数据从写端进去从读端出来先进先出。ls | wc -l这条命令ls 进程的标准输出要指向管道的写端wc 进程的标准输入要指向管道的读端。核心执行逻辑int pipefd[2]; pipe(pipefd); pid_t pid1 fork(); if (pid1 0) { // 第一个子进程ls dup2(pipefd[1], STDOUT_FILENO); close(pipefd[0]); close(pipefd[1]); execvp(ls, args); } pid_t pid2 fork(); if (pid2 0) { // 第二个子进程wc dup2(pipefd[0], STDIN_FILENO); close(pipefd[0]); close(pipefd[1]); execvp(wc, args); } // 父进程关闭管道两端 close(pipefd[0]); close(pipefd[1]); waitpid(pid1, NULL, 0); waitpid(pid2, NULL, 0);为什么父进程要关闭管道两端这个细节关键到没法再关键了。每个进程都持有 stdin、stdout、pipefd 等描述符如果父进程不关闭写端那么即使 ls 进程退出了wc 的读端依然能检测到“还有进程持有写端”会一直阻塞等待数据。只有唯一持有写端的进程全部退出后读端读到 EOF 才会返回。父进程关闭两端就是为了让引用计数正确地降下去。5.3 管道与重定向同时出现时顺序决定结果真实命令行里经常会写cat input.txt | grep abc output.txt。这种组合要求解析层和处理层都要足够灵活。我的做法是在解析阶段每条命令的 in_fd、out_fd 分别处理。执行到某条命令时先处理输入重定向再处理输出重定向最后是管道连接。处理的优先级需要统一约定我采用的是“重定向优先于管道”的方案如果命令同时有和管道的下一级那么指定的文件覆盖管道输出的目标。这与 Bash 的行为一致。实际执行时会在子进程内部先根据 in_fd 和 out_fd 做 dup2然后再处理管道描述符。注意 dup2 操作有一个常见坑如果重定向的目标 fd 和管道 fd 之间有冲突比如管道写端恰好是 2而你要 dup2 到 2顺序错了就会互相覆盖。稳妥做法是先把所有管道 fd 存下来在子进程里统一处理重定向最后再管管道。6. 交互细节提示符、信号、退出与历史6.1 提示符与交互模式交互体验是 Shell 给人的第一印象。提示符不只是好看它其实在告诉你“Shell 准备好接收命令了”。我的实现里默认用mysh$作为提示符如果想模拟真实 Shell 的体验可以把当前用户、主机名、当前目录拼进去。判断是否打印提示符有一个细节用 isatty(STDIN_FILENO) 检查标准输入是不是终端。如果输入来自管道或文件比如echo ls | ./myshell这时打印提示符只会污染输出。所以只在 isatty 为真时才打印提示符。另外提示符打印后要立即调用 fflush(stdout)。因为标准输出在终端下是行缓冲的正常情况下遇到换行才会刷新但提示符没有换行如果不主动刷新它可能一直停在缓冲区里不出来。这个坑很隐蔽但排查起来也很快。6.2 CtrlC 为什么不能让 Shell 退出真实 Shell 里乱按 CtrlC正在运行的进程会被终止但 Shell 本身不会退出。这是因为 Shell 对 SIGINT 信号做了处理设置一个信号处理器忽略或捕获这个信号。自主 Shell 不处理的话CtrlC 会直接杀掉整个进程组包括 Shell 自己。在 C 语言里最简单的处理方式是把 SIGINT 设为忽略signal(SIGINT, SIG_IGN);这个调用要放在主循环之前因为子进程会继承信号处理方式。设置为忽略后fork 出来的子进程也忽略了 SIGINT这会导致子进程无法用 CtrlC 终止。实际工程中更合理的做法是在子进程 exec 之前把 SIGINT 恢复为默认行为这样前台子进程能被 CtrlC 中断Shell 自身不受影响。这个需要在 fork 的子进程分支里设置// 子进程分支 signal(SIGINT, SIG_DFL); execvp(...);6.3 EOF 处理和逐条命令退出码交互式 Shell 里用户在提示符下按 CtrlD 表示输入结束。如果你的 Shell 什么都不做getline 返回 -1循环会一直空转。正确做法是返回 -1 时判断退出标志然后 break 出主循环关闭 Shell。退出码方面真实 Shell 里每条命令结束后的$?会保留到下一轮循环。我维护了一个全局变量 last_status每次 waitpid 后更新。执行内建命令时比如 cd 失败把 last_status 设为 1成功设为 0。整体体验就非常接近真实 Shell。6.4 简单命令历史的实现历史记录不是必须的但实用价值很高。最简单的实现是用一个字符串数组保存最近 20 条输入在读取输入之前打印最近一条或者 Capture 方向键上下翻动。不过方向键处理需要原生终端模式代码量会上来不少。第一版可以先只支持在文件里追加历史每次启动时读取。思路是main 循环开始前读~/.mysh_history文件循环结束前把新增历史写回去。注意写文件时不要无限追加超过 1000 行就截断。7. 常见问题与排查技巧7.1 僵尸进程一堆问题出在哪里表现运行几次命令后用 ps 看到大量defunct进程。原因只有一个父进程没有调用 wait 或 waitpid 回收子进程。可能出现在支持管道之后fork 了多个子进程但父进程只 wait 了一个或者 wait 的顺序不对。排查看两个地方一是所有 fork 的子进程是否都有对应的 waitpid二是 waitpid 的 pid 参数是否正确指定。多管道情况下最好把所有子进程 pid 存到数组里最后统一 wait 一遍。如果某一个子进程是后台执行的结尾带 标准 Shell 不会立即等待它但那属于作业控制的范畴第一版可以先不支持后台执行。7.2 命令找不到、退出码为 127怎么排查排错经验表现象可能原因排查方法输入 ls输出 command not foundexecvp 没找到命令先确认/bin/ls存在再确认 PATH 环境变量是否为空输出错误信息后 Shell 直接退出子进程没调用 exit检查 execvp 失败后的分支是否 exit 了带路径的命令可以执行不带路径不行PATH 没有被正确继承fork 后子进程会继承环境变量检查父进程有没有调用 putenv 清空退出码始终是 0没正确取 WEXITSTATUSwait 后用 WIFEXITED WEXITSTATUS 解析7.3 管道命令卡死等待时机与文件描述符泄漏管道命令卡死是最让人头大的问题。症状是执行ls | wc -l之后程序一直不出结果。原因大概率不是子进程逻辑错了而是文件描述符泄漏某个进程中多余的写端没有被关闭导致读端永远等不到 EOF。排查方法特别实用在子进程 exec 前把所有不需要的管道文件描述符全部 close然后逐个进程检查引用计数。你可以先写一个最简单的单管道版本ls 改成 echo hello把管道两端关闭逻辑跑通再加多管道。另一个等待时机的问题是 waitpid 的顺序。在多管道环境下如果父进程先等待下游进程可能因为上游还握着写端导致下游进程阻塞。正确做法是先在父进程里关掉自己的管道副本再依次 wait。关闭自己的副本是关键这个动作要放在所有 fork 之后。7.4 调试技巧与个人心得最后分享几个我在这类项目里积累的调试经验。第一在关键位置加 fprintf(stderr, ...) 日志不要用 gdb 一上来就断点。日志里至少要打印出“当前命令名、子进程 pid、要 dup2 的 fd、当前文件描述符状态”这样能快速定位是解析问题还是执行问题。第二写一个小的辅助函数 dump_cmd 打印 command 结构体每解析完一行就调用一次。这个函数帮我发现了大量引号处理问题和管道切分问题。第三遇到卡死不要慌先用 ps pstree 看进程状态再用 strace 跟踪一下哪个系统调用阻塞了。如果你也想动手写一个我建议从最简单的“读取-解析-执行”三件套开始先不管重定向和管道把 fork 和 execvp 跑通这一步能让你立刻理解进程控制的核心。之后再逐步加重定向、管道、内建命令每加一个功能就回归测试一遍之前的行为。手写 Shell 没有想象中那么难但做完之后再看那些复杂的脚本命令你会发现自己终于能看清它们背后究竟发生了什么。