手写简易Shell:内建命令与exec程序替换全解析

发布时间:2026/10/5 17:41:07
手写简易Shell:内建命令与exec程序替换全解析 最近项目组要在一个受限环境里搭一个轻量交互进程原本想直接调系统命令结果业务场景对命令解析、退出码、环境变量切换的要求特别细最后干脆自己动手写了一个简易Shell。这篇复盘把整个过程中最核心的两块——内建命令和exec程序替换——拆开讲清楚也是给自己留一份可回看的笔记。说句实话写Shell这件事很多同学觉得是操作系统课程的作业题其实它是一面很有价值的镜子你会在里面看到进程、文件描述符、环境变量、信号处理这些概念在真实代码里是怎么协作的。如果你正在学C语言、准备系统编程面试或者工作中要跟subprocess、进程管理打交道这篇内容可以直接拿来对照参考。全文不涉及太花哨的框架就是纯C实现把主干逻辑走通。2. fork与exec这对组合程序替换的底层逻辑2.1 进程不是凭空出现的要理解Shell先要理解一个事实在Unix世界里创建一个新进程从来不是直接启动某个程序而是分两步走。第一步是fork()它把当前进程复制一份复制出来的子进程拥有和父进程几乎一样的地址空间、文件描述符、环境变量。第二步是exec系列函数它在当前进程里加载一个新的可执行文件把原来的代码段、数据段、堆栈全部替换掉然后从新程序的main()开始执行。这两个步骤合在一起才是我们平时说的运行一个程序。打个比方fork()就像你用复印机复印了一份文件复印纸上原本写的是旧内容exec则是把复印纸上的内容全部擦掉重新写上你要的新内容。不先复印就直接擦写那会把你自己手里唯一的一份文件也毁掉。所以Shell必须先fork()一个子进程再由子进程去exec这样即使新程序崩溃也不会影响Shell本身。这也是为什么主流的Shell都是这样实现外部命令的父进程负责等待和回收子进程负责干活。2.2 exec族函数怎么选exec不是只有一个函数而是一族函数C标准库提供了六个函数名路径来源参数传递方式环境变量来源execl显式路径变长参数列表继承当前环境execv显式路径argv数组继承当前环境execle显式路径变长参数列表显式envpexecve显式路径argv数组显式envpexeclp自动搜索PATH变长参数列表继承当前环境execvp自动搜索PATHargv数组继承当前环境很多初学者看到这一堆函数就头大其实记忆方法很简单。后缀里带llist表示参数一个一个列出来带vvector表示参数放在一个字符串数组里带ppath表示函数会自己搜索PATH环境变量找到可执行文件不带p的就要你给出文件路径带eenvironment表示你可以自己指定环境变量。我实现简易Shell时首选的是execvp()原因有二。第一它自动搜索PATH这恰好符合我们平时在Shell里直接敲ls、grep就能运行的习惯。第二参数用数组传递结构上和后面要做的命令解析天然契合——我解析完命令行已经得到了一个char *argv[]数组直接传给execvp就行。注意execl这类变长参数形式看着方便但参数个数固定写死的话后面想扩展命令参数就痛苦了。实际项目中能用execv系的就别用exec系。2.3 一个最小可运行的exec演示先看一段最简代码确认这个机制跑得通#include stdio.h #include unistd.h #include sys/wait.h int main(void) { pid_t pid fork(); if (pid 0) { // 子进程 // 用 execvp 去执行 ls参数数组以 NULL 结尾 char *argv[] {ls, -l, NULL}; execvp(ls, argv); // 如果 execvp 失败了下面这行才会执行 perror(execvp failed); _exit(127); // 127 是“找不到命令”的惯例退出码 } else if (pid 0) { // 父进程等待子进程结束 int status; waitpid(pid, status, 0); printf(子进程退出码: %d\n, WEXITSTATUS(status)); } else { perror(fork failed); } return 0; }这段代码的逻辑很清晰子进程里调用execvp成功的话ls的输出会直接打到终端上失败的话才走到perror和_exit(127)。这里有一个细节很多人会忽略——为什么失败后要用_exit而不是return或exit因为子进程是从父进程的fork复制出来的如果exec失败后你使用exit()退出它会刷新stdio缓冲区把父进程缓冲尚未输出的数据也一并冲出去可能导致输出重复更危险的是它还会执行父进程注册的atexit钩子。正确的做法是使用_exit()它直接进入内核退出路径不做任何用户态清理这才是子进程exec失败的规范姿势。2.4 为什么要先fork再exec这个问题我在带新人时被问过很多次。有人觉得直接在当前Shell进程里exec不就行了反正Shell已经完成了对命令的解析执行完替换掉自己也没啥问题。问题在于如果Shell在exec时被替换了它就没法回到原来的命令行循环了。你敲一条命令Shell进程就变成那条命令的进程命令执行完整个终端也死了不可能再给你第二次输入提示符。所以必须有fork这层复制把执行命令这件事隔离到子进程里父进程稳定地留在外面等待。这句话是理解进程模型的关键也直接引出后面的wait机制。3. 内建命令为什么Shell必须“亲自下场”3.1 cd暴露出的父子进程问题你写一个最简单的Shell解析用户输入forkexec执行。跑起来后发现一个尴尬的问题——cd命令失效了。你的Shell执行cd /tmp调用execvp(cd, ...)系统提示找不到cd这个程序或者在某些系统上能运行但你的Shell当前目录根本没变。原因是cd并不是一个外部程序它其实是改变了Shell进程自身的当前工作目录。chdir()这个系统调用只能影响调用它的进程。如果你fork一个子进程去chdir子进程的工作目录确实变了可子进程干完活就退出了父进程的Shell还是留在原来的目录。这就像你想搬办公室却让快递员帮你搬——快递员到了新办公室你自己还在原地。所以cd必须由Shell进程自己执行不能派发给子进程。这类由Shell自己实现、不依赖外部可执行文件的命令就叫内建命令builtin command。3.2 内建命令和外部命令的本质区别这个问题是面试高频题。区别可以概括为外部命令通过fork()exec()在子进程中运行命令结束后Shell不受任何影响比如ls、grep、cat。内建命令Shell进程自己调用对应函数处理不创建子进程因此可以直接修改Shell的状态比如cd、exit、export。为什么要区分因为有些操作只能在当前进程内完成任何子进程都不行。影响当前Shell的状态的东西必然是内建命令。顺带说一句很多人以为echo一定是内建命令其实不然。Bash里echo既有内建版本也有一个位于/bin/echo的外部版本具体用哪个要看调用方式。在bash里直接敲echo用的是内建版本但如果你写/bin/echo hello那就是外部程序。这一类“二合一”命令挺容易踩坑的后面我会单独说。3.3 一个最小内建命令集我把实现一个可用Shell需要的最少内建命令列了出来并标注了每个命令的核心职责命令为什么必须内建核心动作cd改变当前目录只能影响当前进程chdir()exit终止Shell进程本身设置退出码后exit()export设置环境变量必须影响当前Shell及其后代setenv()unset删除环境变量unsetenv()set查看/设置Shell变量变量表操作echo虽可不内建但内建避免依赖外部程序printf输出实际写一个入门级Shell至少把cd、exit、export做掉否则这个Shell用户连切换目录和退出都做不到。3.4 内建命令的一种优雅实现内建命令的实现思路很简单在执行派发之前先用strcmp()比对命令名命中内建列表就直接在当前进程调用对应函数。int builtin_cmd(char **args) { if (args[0] NULL) return 0; if (strcmp(args[0], cd) 0) { const char *path args[1] ? args[1] : /root; if (chdir(path) ! 0) { perror(cd failed); return 1; } return 1; } if (strcmp(args[0], exit) 0) { int code args[1] ? atoi(args[1]) : 0; exit(code); } if (strcmp(args[0], export) 0) { if (args[1]) { char *eq strchr(args[1], ); if (eq) { *eq \0; setenv(args[1], eq 1, 1); } } return 1; } return 0; }这里我特别强调返回值的设计builtin_cmd返回1表示“这个命令已经被我处理了”返回0表示“这不是内建命令你再去走forkexec流程”。这个约定的好处是主循环代码很干净不用每加一个内建命令就改一次主循环结构。3.5 误把内建当外置的经典翻车现场我在给别人做代码评审时见过一个很典型的错误有人用system(cd /tmp)来实现自己的Shell的cd命令。system()内部其实也是forkexec它启动了一个新的Shell去执行cd /tmp当你回到你自己的Shell进程时目录还是原来的。这个坑和fork子进程去chdir完全一样只是多了一层system的封装更容易迷惑新手。还有一个不太容易发现的坑如果你的Shell接管了用户的PATH那当你执行export PATH/custom/path之后再去外部命令ls会发现找不到命令了。所以写export内建命令时一定要考虑它对外部命令搜索的影响。成熟的Shell会用安全路径兜底不会让你把自己锁死。4. 手写Shell的主循环实现从字符流到命令执行4.1 整体架构读入-解析-执行一个Shell的运行骨架本质上是一个不断循环的“读取-解析-执行”过程。我用一张流程拆解图来描述逻辑顺序Shell启动后打印提示符然后用getline()读入一行字符串接着把这行字符串拆分成一个个单词构成argv数组最后根据argv[0]判断走内建命令还是外部命令。这个设计不是我想出来的它源自Unix经典工具哲学一个程序只做一件事。所以我把它拆成了三个独立函数shell_readline()、shell_parse()、shell_execute()每个函数职责单一调试时也方便定位问题。4.2 命令解析的细节解析阶段看起来简单就是把一行字符串按空白字符拆开但细节处有讲究。我用strtok()是因为它把连续的分隔符自动折叠输入里多几个空格也不会产生空参数但它会修改原字符串所以我会先复制一份再拆。char **shell_parse(char *line) { int bufsize 64, position 0; char **tokens malloc(bufsize * sizeof(char *)); char *token strtok(line, \t\r\n); while (token ! NULL) { tokens[position] strdup(token); if (position bufsize) { bufsize 64; tokens realloc(tokens, bufsize * sizeof(char *)); } token strtok(NULL, \t\r\n); } tokens[position] NULL; return tokens; }注意两个点第一strtok的分隔符集合要包含\t、\r、\n否则用户敲完命令按回车\n会黏在最后一个单词上导致命令名匹配失败。第二每次拆分出来的token要用strdup()复制一份否则后面释放line时这些指针就悬空了。4.3 执行派发逻辑执行函数是核心它只有两个分支int shell_execute(char **args) { if (args[0] NULL) return 0; // 空命令直接略过 if (builtin_cmd(args)) { return 1; } pid_t pid fork(); if (pid 0) { execvp(args[0], args); perror(myshell: command not found); _exit(127); } else if (pid 0) { perror(fork error); return 1; } int status; waitpid(pid, status, 0); return 1; }派发顺序很关键先检查内建命令再走外部命令。如果反过来你敲exit时会先fork一个子进程子进程里执行exit退出了父进程还在等结果是Shell永远退不出去。这个顺序问题经常有人搞反。4.4 主循环与退出码回收主循环部分反而最简单int main(void) { char *line; char **args; int status 1; while (status) { printf(myshell ); fflush(stdout); line shell_readline(); args shell_parse(line); status shell_execute(args); free(args); free(line); } return 0; }这里有个容易被忽略的细节打印提示符之后要fflush(stdout)。因为stdout在终端下通常是行缓冲但如果你重定向到文件或管道就变成全缓冲了不刷新的的话提示符可能延迟出现。实测很多初学者写的Shell在管道环境下看不到提示符就是栽在这。4.5 边缘情况的处理我在项目里还处理了几个容易让人懵的场景用户直接按回车解析后args[0]是NULLshell_execute直接返回1Shell继续循环不能报错退出。命令行末尾带注释符号简易版可以直接忽略这需要解析阶段处理#。exit带参数比如exit 3要能用atoi转成退出码否则用户无法控制脚本的返回状态。子进程被信号杀掉waitpid返回后WIFSIGNALED(status)能检查出来进阶版可以打印“被信号X终止”。这五个边缘场景加起来不过二十行代码但对稳健性的提升非常明显。很多人写完Shell跑几个正常命令就觉得完事了结果一遇空输入就段错误一遇信号就exit不了全是在这些细节上失守。5. 实测复盘我踩过的坑与排查思路5.1 僵尸进程是怎么产生的写Shell绕不开waitpid。我第一次写完主循环后跑了几个命令然后去开另外一个终端执行ps aux | grep myshell发现一堆defunct状态的僵尸进程。原因很直接父进程没有及时回收子进程。我一开始为了“简化”fork之后没调用waitpid以为子进程执行完就自动消失。实际上子进程退出时内核会把它的退出状态保留在进程表里直到父进程调用wait()或waitpid()取走状态为止。父进程之前一直不调用wait那些子进程就会一直占着进程表项数量多了还会达到进程数上限。修复方式是每个外部命令执行完后同步waitpid这也符合简易Shell的语义——用户输了一条命令Shell等它执行完再显示下一条提示符。如果你只是想演示异步行为那就要用SIGCHLD信号加非阻塞wait那是后续扩展的范畴当前阶段我把同步等待作为默认行为。5.2 exec失败后直接返回到父进程代码是个大坑这是我调试时最凶险的一次。我起初在子进程里这样写pid_t pid fork(); if (pid 0) { execvp(args[0], args); // 某个错误情况你没写 _exit perror(exec failed); return -1; // 这个return是致命的 }表面看没什么问题exec失败打一行错误返回-1。但注意这里的return返回的并不是“子进程的main函数返回”而是返回到shell_execute()函数的调用栈里因为fork出来的子进程和父进程共享这份代码子进程在execvp失败后回到shell_execute继续往下走就会执行后面的waitpid逻辑而子进程此刻已经是父进程的执行流最终可能导致整个Shell逻辑错乱甚至出现两个进程同时打印提示符。修正后的黄金法则是子进程里exec如果失败必须用_exit()结束自己永远不允许返回到公共代码路径。这一点怎么强调都不为过。5.3 echo、cd、exit这三个“伪内置”最容易让人迷惑我在实际使用中遇到一个很有意思的case。同事说他的Shell能执行cd但每次cd完再运行pwd目录确实变过来了可他手贱敲了一个/bin/cd /tmp结果目录没变他百思不得其解。其实我猜他的Shell在实现时就踩了一个坑它让cd走了外部命令路径——系统里碰巧存在/bin/cd这个程序的话fork子进程去运行它子进程chdir成功了父进程目录不变。所以他直接敲cd没变目录只是被程序“看似运行成功”骗了而我写的Shell则把cd强行放到了builtin_cmd迷宫里同样遇到这种情况时才有基准可以对比验证。还有一个容易踩的坑是echo。如果你把echo做成外部调用在子进程里执行/bin/echo hello输出虽然能显示但没法处理echo -n不换行这类带选项的参数而且每次都要fork性能也差。所以我的项目里把echo也做了内建直接用printf处理。5.4 交互模式下的缓冲乱序这是终端编程里很经典的问题Shell打印提示符“myshell ”然后执行外部命令ls输出的文件列表应该显示在提示符后面。但实测发现有时候文件列表跑到提示符前面去了顺序完全颠倒。问题出在提示符和ls的输出缓冲方式不一样。我的提示符用的是printfstdout在终端下是行缓冲而ls的输出是直接写到stdout文件描述符的。如果我打印提示符后没有fflush(stdout)提示符可能还躺在缓冲区里ls的输出已经先到了终端。修复方法很简单就是我在主循环里写的那个fflush(stdout)这个细节在书本上经常被一笔带过实际编码时却能把人折腾半天。5.5 子进程继承的“隐形遗产”最后说一个代码之外的问题。子进程通过fork继承父进程的所有文件描述符等资源如果你的Shell之前打开过一个文件但没关闭那么子进程执行grep时也会持有这个fd一旦外部命令和这个文件有交互可能造成文件锁冲突或者资源泄漏。在我自己的项目里我一开始没注意这个后来跑一个长时间日志分析脚本发现打开的文件数量爆炸排查到最后发现是Shell每个外部命令的fork都继承了那个日志fd。解决方案是设计Shell时保持文件描述符的整洁该关闭的就关闭或者必要时在子进程里显式关闭无关fd。这也是一个能体现你系统编程功力的小细节。6. 下一步扩展重定向、管道与真正的Shell还差什么6.1 重定向的本质是文件描述符写完基础的forkexec之后我自然而然地想加和重定向。一直以为重定向很神奇其实它的本质就是文件描述符的重新绑定。比如ls out.txt意思是把ls的标准输出从终端换成文件out.txt。实现时在子进程执行exec之前先用open()打开目标文件再用dup2(fd, STDOUT_FILENO)把文件描述符复制到1号位置然后关闭原fd最后再execvp。这样新程序看到的stdout已经指向文件了。if (redir_out ! NULL) { int fd open(redir_out, O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, STDOUT_FILENO); close(fd); } execvp(args[0], args);这个方法同样适用于输入重定向只需把dup2的目标换成STDIN_FILENO。学到了这个思路就觉得重定向一点神秘感都没有了。6.2 管道是重定向的进阶管道ls | grep xxx的实现比单纯重定向多了一个pipe()系统调用。pipe()会创建一对文件描述符一个读端、一个写端。实现“左命令的输出作为右命令的输入”本质就是让左命令的stdout指向管道写端让右命令的stdin指向管道读端。int fd[2]; pipe(fd); if (pid_left 0) { dup2(fd[1], STDOUT_FILENO); close(fd[0]); close(fd[1]); execvp(left_args[0], left_args); } if (pid_right 0) { dup2(fd[0], STDIN_FILENO); close(fd[1]); close(fd[0]); execvp(right_args[0], right_args); }这里有个必须注意的坑管道两端必须在fork之前创建这样两个子进程都能继承到这对fd而每个子进程里要立刻关闭自己不需要的那一端否则会因为还有进程持有写端导致读端无法读到EOF命令会卡住。6.3 作业控制是更深的江湖再往后如果你想让Shell支持fg、bg这样的作业控制光靠fork和exec就不够了还得引入进程组、会话、信号处理这些机制。作业控制的核心是把每个管道中的子进程放进一个独立的进程组然后用tcsetpgrp()把终端前台进程组切换过去这样用户按CtrlC时信号只会发给当前前台进程组不会殃及Shell自己。我目前的简易Shell还没有完整支持作业控制但每次写完一个扩展都让我更理解终端驱动和进程管理的关系。这个升级路径是清晰的解析阶段多识别一个执行阶段把同步wait换成waitpid加WNOHANG再加上一个作业表维护后台进程就搭起一个可以继续深入的框架。6.4 从迷你Shell到主流Shell还差哪些东西把基础版和真正的Bash对比一下你会发现差距主要集中在这几块丰富的语法比如if、for、while需要实现ast级别的语法解析。命令别名和函数定义需要在Shell内部维护一个符号表。历史记录、自动补全需要读入历史文件并做交互优化。脚本执行能力需要把整段脚本解析成AST后逐条执行这已经上升到解释器层面了。我自己完成简易Shell之后最大的体会是写一个“能跑”的Shell只需要一小段代码写一个“像样”的Shell则可以一直写下去。很多人学完fork和exec觉得没什么了不起但当你真的亲手把提示符、内建命令、程序替换、退出码这些串成一个闭环之后你会对“命令行是一个进程还是多个进程”“Shell为什么能改变环境”“管道为什么能让命令协作”这些之前只停留在纸面上的问题产生完全不一样的理解。最后分享一个我在实际开发中的小技巧调试Shell进程时不要只盯着printf输出多开一个终端用ps -ef --forest观察进程树看看它的父子关系是不是符合预期。很多诡异的Shell行为看一眼进程树就能当场破案。这个习惯我用了很久每次排查进程相关问题都特别高效。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询