
1. 为什么值得自己动手写一个shell先聊点实际的。你每天敲的ls、cd、grep背后到底发生了什么大多数人不关心但在Linux下做开发迟早会撞上“命令是怎么被找到的”“管道怎么把两个进程连起来”“环境变量怎么传给子进程”这类问题。与其零散地翻书看概念不如动手写一个迷你版shell——把这些问题一次性打通。这个项目我之前带过好几个方向测试的同学拿它理解进程模型做运维的拿它梳理作业控制应届生面试的时候聊这个项目也很加分。它确实“简单”但简单不等于浅薄一个精简但功能完整的shell牵扯到fork、exec、wait、文件描述符复制、字符串解析、信号处理几乎把Linux系统编程的地基都踩了一遍。本文实现的版本定位是“可用但不臃肿”支持外部命令执行、cd/export/exit这类内建命令、标准输入输出重定向、单管道以及简单的后台运行和信号清理。代码量控制在几百行适合打印出来慢慢看也适合一行一行自己敲。2. 动手前的设计决策做什么、不做什么写shell最怕一上来就铺开写最后变成一辆“有方向盘却没刹车”的破车。我建议先划定边界明确哪些必须支持、哪些先不做。这样后续每加一个功能改动的范围是可控的。2.1 功能清单与优先级功能优先级说明执行外部命令带参数必做shell的核心能力依赖fork execvp内建命令cd、exit、export必做不需要fork直接在shell进程内完成输入输出重定向、必做用dup2复制文件描述符实现单管道cmd1cmd2建议做后台执行建议做涉及waitpid和孤儿进程概念通配符扩展、引号解析、多管道先不做会显著增加解析复杂度不是本文重点注意环境变量展开$PATH、$HOME在完整shell里很重要但这个精简版本先砍掉等主流程跑通后再加不迟。为什么cd必须做成内建命令因为cd要改变shell进程自己的当前工作目录。如果通过fork子进程去执行系统的cd程序子进程的目录变了父进程shell纹丝不动——这是初学者最容易踩的坑。理解这一点就理解了内建命令存在的根本原因。2.2 整体架构一个读过无数次书的循环shell的本质是一个循环读取一行输入 解析为命令和参数 根据命令类型执行内建 or 外部 回到读取状态这个“读取-解析-执行”的主循环是所有交互式shell的骨架。交互式的核心特征不是“界面长什么样”而是“每执行完一条命令进程不能退出”。如果你对进程的父子关系不熟用这个项目过一遍感觉非常直观。后续所有功能都是在主循环的某个环节做文章输入重定向解析出 file后在exec之前把文件描述符0指向该文件管道解析出|后创建pipe让前一个进程的 stdout 写到写端后一个进程的 stdin 从读端读后台执行检测到fork后不等待子进程结束直接打印 pid 回到主循环。3. 第一个可运行版本解析命令并fork执行先写一个能跑的骨架。别追求一步到位能跑起来再逐步加特性调试的心情会好很多。3.1 字符串拆分用什么切怎么切用户输入ls -l /tmpshell 要做的事情是把它拆成三个字符串ls、-l、/tmp。C 标准库提供了strtok但这是个有争议的函数——它会修改原字符串而且不能同时处理多个分隔符状态。对于单线程的简易shell用起来没问题我个人建议自己写一个简单的拆分函数static int parse_tokens(char *line, char **argv, int max_args) { int argc 0; char *p line; while (*p argc max_args - 1) { while (*p || *p \t) p; // 跳过空白 if (*p \0) break; argv[argc] p; // 记录当前token起点 while (*p *p ! *p ! \t) p; if (*p) *p \0; // 截断当前token } argv[argc] NULL; // execvp要求指针数组以NULL结尾 return argc; }核心思想扫描字符串跳过空白符找到一个token的起点记录下来继续扫描到空白符或结束符在空白符位置写入\0完成截断。这里有几个容易忽略的细节参数数组必须以NULL结尾execvp靠这个判断参数是否结束传入max_args是为了防止参数数量太多导致数组越界虽然简易实现可以不设上限但养成写防御性代码的习惯不亏这段代码没有处理引号所以echo hello world会被拆成echo、hello、world引号问题留到后面扩展。3.2 fork、execvp、waitpid 三件套命令拆好了执行就是经典的 fork exec 模式int run_external(char **argv, int background) { pid_t pid fork(); if (pid 0) { perror(fork); return -1; } if (pid 0) { // 子进程 execvp(argv[0], argv); fprintf(stderr, myshell: command not found: %s\n, argv[0]); exit(127); } // 父进程 if (!background) { int status; waitpid(pid, status, 0); } else { printf([%d] %s\n, pid, argv[0]); } return 0; }为什么一定是fork之后立刻exec因为fork会复制一份当前进程的地址空间而exec系列函数用新的程序替换当前进程的地址空间。合在一起的效果就是创建一个新的子进程然后让这个子进程变成你要执行的程序。有个非常关键的点必须说明子进程里execvp失败后一定要调用exit返回错误码而不是继续往下走。因为这时候子进程还拿着父进程的内存副本如果不退出子进程会像shell一样继续读入下一行命令——到时候屏幕上出现两个接收输入的进程行为就很诡异了。这属于“不写不知道写了踩一脚”的经典问题。再说waitpid的第三个参数是 0表示阻塞等待子进程终止。比如执行sleep 10父进程会停在这里直到10秒后子进程结束才回到主循环。后台模式则直接跳过 waitshell 继续干自己的事但此时子进程成了“孤儿”由 init 进程收养——这就是为什么后台任务的回收逻辑要单独写。至此第一个可运行版本已经成型。现在的myshell能执行绝大部分外部命令比如ls、date、grep但还没法cd因为光靠 exec 改变不了父进程的目录。4. 内建命令为什么 cd 必须自己实现内建命令是本项目中学到“进程模型”精髓的地方。前面提过cd如果是子进程执行目录切换不会影响到父进程。下面把常见的三个内建命令逐个过一遍。4.1 cd 与 chdirint builtin_cd(const char *path) { if (path NULL) { fprintf(stderr, myshell: cd: missing argument\n); return 0; } if (chdir(path) ! 0) { perror(myshell: cd); } return 0; }chdir是一个系统调用作用是修改当前进程的工作目录。因为是在shell进程内直接调用所以之后你再敲pwd看到的就是新目录。这里不返回状态码给调用方是因为shell没必要跟谁汇报只负责把错误打到 stderr 上。如果要做cd ~或cd -需要自己解析~为getenv(HOME)解析-为getenv(OLDPWD)。这个版本先不展开但接口已经预留好了。4.2 export 与环境变量传递export的作用是把一个变量加入环境并让后续由本shell启动的所有子进程都能看到它。实现上其实就是调用setenvint builtin_export(char **argv) { if (argv[1] NULL) { // 打印当前环境变量 extern char **environ; for (char **e environ; *e; e) { printf(%s\n, *e); } return 0; } // 支持 export NAMEvalue 和 export NAME if (strchr(argv[1], ) ! NULL) { putenv(argv[1]); // 注意putenv不复制字符串argv[1]不能是局部变量 } else { setenv(argv[1], , 1); } return 0; }高能预警putenv有一个陷阱——它不复制传入的字符串而是直接把字符串地址放进环境表。如果你传入的是一个将要被覆盖、释放或修改的缓冲区环境表里就会变成一个悬空指针。用setenv就没有这个问题因为它内部会做复制。为了说明 export 的作用我建议你在这个版本里做个实验先执行export FOObar再执行env你会看到 FOO 出现在子进程的环境变量列表里。这比空谈“环境变量会被继承”更有说服力。4.3 exit 与返回值约定int builtin_exit(char **argv, int *should_quit) { *should_quit 1; return 0; // 简易版本退出码固定为0扩展时可用 argv[1] 指定 }主循环里检查should_quit为真就 break 出循环并返回。这里不做栈撕裂之类的事。一个细节是在退出前可以考虑打印一行 “exit”模拟真实shell的行为但打印与否不影响功能。至此myshell 已经能cd到任何目录能设置环境变量能正常退出。接下来是真正拉开“玩具”与“小工具”差距的部分——重定向和管道。5. 重定向把文件描述符玩明白重定向的本质是让进程在 exec 之前把标准输入或标准输出的文件描述符指向某个文件。命令不关心也不应该关心自己是从终端读还是从文件读、输出到终端还是到文件。5.1 dup2 的使用姿势假设用户输入ls out.txt我们先解析出命令是ls重定向目标是out.txt方向是“覆盖写”。然后int fd open(out.txt, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd 0) { perror(open); return; } dup2(fd, STDOUT_FILENO); // 把stdout指向fd对应的文件 close(fd); // 原fd不再需要 execvp(argv[0], argv);dup2的含义是“让第二个参数所代表的文件描述符指向第一个参数指向的那个打开文件描述”。所以执行完dup2(fd, STDOUT_FILENO)后描述符1就跟fd指向同一个打开文件描述之后任何往描述符1写的数据都会进入out.txt。关闭fd完全不影响因为描述符1仍然有效。为什么必须在fork之后的子进程里做这个操作如果放在父进程里做shell自己的 stdout 就永久被改了之后的命令输出全部进文件而不是终端运行就乱套了。放在子进程里影响只在子进程内部父进程不受干扰。5.2 重定向的解析顺序重定向的语法涉及多个可能同时出现的情况cmd in.txt out.txt。解析的时候需要注意一点重定向符号和文件名不一定紧挨着命令它们可以出现在参数列表的任何位置。我的实现顺序是三步走扫描整行找出所有的和及其后面的文件名用strtok思路把重定向符号和文件名从参数列表里剔除剩下的就是真正的命令和参数在子进程中按“输入先、输出后”的顺序依次执行 open 和 dup2。这一步做完cat input.txt output.txt 就能正常工作了。 ## 6. 管道两个进程之间的数据接力 管道是shell项目里最有意思的部分。它把前一个进程的标准输出接到后一个进程的标准输入中间靠 pipe() 创建的一对文件描述符作为载体。进程间数据搬运完全由内核完成不需要用户态的数据拷贝。 ### 6.1 pipe 与 fork 的顺序硬规则 实现 cmd1 | cmd2 的流程有一步错整个管道就废 1. 先调用 pipe(pfd)拿到两个文件描述符pfd[0] 读端、pfd[1] 写端 2. fork 第一个子进程在子进程里 dup2(pfd[1], STDOUT_FILENO)关闭两个管道的fdexec cmd1 3. fork 第二个子进程在子进程里 dup2(pfd[0], STDIN_FILENO)关闭两个管道的fdexec cmd2 4. 父进程关闭两个管道的fdwaitpid 两个子进程。 这里有一个值得反复强调的准则是**管道必须在 fork 之前创建**。因为 fork 会复制文件描述符表子进程才能拿到父进程里的管道fd。如果 fork 之后再建管道那这个管道只有当前进程能访问没法传递给兄弟进程。 ### 6.2 为什么父子进程都要关闭多余的fd 管道有四个持有者父进程两个、子进程1两个其中一个被dup2改指到stdout、子进程2两个。 看一个实际问题假设父进程不关闭 pfd[0] 和 pfd[1]直接等待两个子进程结束。问题是如果 cmd1 一直往管道写数据cmd2 读完数据后退出一旦cmd2退出管道的读端在cmd2那边就关了但父进程还握着 pfd[0]。这时候 cmd1 继续写会收到 SIGPIPE 信号直接被终止——cmd1 可能才写到一半数据丢失。为了避免这种情况父进程必须关闭写端让“只要所有读端关闭写端就会收到 SIGPIPE”这个逻辑成立。 同理父进程也得关闭读端否则cmd2读完所有输入后管道读端不会“EOF”因为父进程的读端还开着cmd1 写完后也不会收到“写入失败”的反馈导致cmd2卡在读管道上永远等不到EOF。 这块是管道实现里最容易出问题的地方排查时的典型现象就是“cmd2 没有输出”或者“整个shell卡在管道上”。如果你真的遇到这种问题可以用 strace 看看进程阻塞在哪里。 c // 伪代码管道处理 int pfd[2]; pipe(pfd); pid_t p1 fork(); if (p1 0) { // 子进程1执行cmd1stdout接到管道写端 dup2(pfd[1], STDOUT_FILENO); close(pfd[0]); close(pfd[1]); execvp(cmd1_argv[0], cmd1_argv); } pid_t p2 fork(); if (p2 0) { // 子进程2执行cmd2stdin接到管道读端 dup2(pfd[0], STDIN_FILENO); close(pfd[0]); close(pfd[1]); execvp(cmd2_argv[0], cmd2_argv); } // 父进程关闭管道fd等待两个子进程 close(pfd[0]); close(pfd[1]); waitpid(p1, NULL, 0); waitpid(p2, NULL, 0);看着简单但每一行都有它存在的理由。建议你亲手注释掉某个 close 看看会发生什么比任何讲解都深刻。7. 后台执行、信号处理与孤儿进程做到这一步myshell 的功能已经超过很多“作业级”实现。但还有个细节容易被忽略你按下 CtrlC 的时候信号是发给整个前台进程组的。7.1 为子进程单独分配进程组默认情况下fork 出来的子进程和 shell 在同一个进程组。你在终端按 CtrlCshell 和子进程都会收到 SIGINT。对shell来说这是灾难——用户按 CtrlC 是想终止正在运行的前台命令而不是想把shell整个杀掉。解决方式是在子进程里调用setpgid(0, 0)让它自己成为一个新进程组的组长。这样终端的前台进程组就可能是子进程shell 本身收不到 SIGINT。另外可以在主循环里忽略 SIGINT需要的时候再恢复默认行为// 主循环开始前 signal(SIGINT, SIG_IGN);然后在 fork 的子进程里恢复默认处理signal(SIGINT, SIG_DFL); setpgid(0, 0);这个组合拳的目的很明确让 CtrlC 只作用于正在执行的前台子进程shell 不受干扰。7.2 waitpid 回收后台任务后台任务用waitpid(pid, status, WNOHANG)进行非阻塞检查。一般策略是在主循环每次读取新命令之前循环回收所有已结束的后台子进程打印退出状态或信号终止信息。这能避免产生一堆僵尸进程。僵尸进程是个什么概念子进程先退出父进程没有调用 wait 收尸那么子进程的进程表项不会被清除它就成了僵尸。如果父进程长期不 wait僵尸会越积越多严重时可能导致进程号耗尽。在shell场景里每条前台命令执行完父进程都会 waitpid所以前台不会产生僵尸后台任务产生僵尸的概率更大所以必须有回收逻辑。void reap_background() { int status; pid_t pid; while ((pid waitpid(-1, status, WNOHANG)) 0) { if (WIFEXITED(status)) { printf([%d] exit %d\n, pid, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf([%d] killed by signal %d\n, pid, WTERMSIG(status)); } } }waitpid(-1, ...)表示等待任何一个子进程配合WNOHANG就是“看看有没有已经退出的没有就立刻返回不阻塞”。注意它和前台 waitpid 的区别前台是精确指定 pid且会阻塞后台是任意 pid且立即返回。8. 从“能跑”到“能扛”遇到过的坑和最终样的完整代码说几个实际调试中遇到过、也让我印象深刻的坑供你少走弯路。8.1 坑一execvp 失败后子进程没退出的诡异现象没写过这几行代码的人很难想象这个坑多隐蔽。我第一次实现时子进程 exec 失败后没有调用 exit结果那条不存在的命令执行完子进程居然继续像shell一样读输入、执行命令看起来就像是 shell “分身”了两个进程抢同一份输入。原因前文说过子进程是父进程的副本它也在主循环的读取等待中。加了exit(127)之后世界清静了。这里必须用127这个退出码因为bash约定“command not found”返回127很多依赖退出码判断结果的调用链会用到。8.2 坑二重定向符号的残留参数解析ls out.txt时如果只找到重定向符号就把剩下的都当成命令参数execvp会把和out.txt当成ls的参数传给系统结果ls会报“无法访问 ”这样的错误。后面在拼接参数时要把重定向符号及文件名踢出去重新构造 argv 数组。很多教学代码用strtok直接按空格切完就完事这在实际场景里不够用。8.3 坑三刷新 stdout 缓冲区还有一个容易被忽略的坑如果你的 shell 有printf输出提示符子进程 exec 之后这个缓冲区的数据可能会被复制到子进程里导致同一个提示符被打印两次。原因在于 fork 会把整个进程地址空间复制一份包括 stdio 缓冲区。当 stdout 是行缓冲时还好如果重定向到文件或管道变成全缓冲缓冲区里的内容就会在子进程执行时被刷新输出两次。解决方法是在 fork 之前调用fflush(stdout)把缓冲区清空确保子进程拿到的副本里没有残留数据。8.4 最终代码结构完整代码接近300行核心结构如下#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/wait.h #include sys/types.h #include fcntl.h #include signal.h #define MAX_LINE 1024 #define MAX_ARGS 64 #define TOK_BUFSIZE 64 // 解析整行命令返回参数个数 static int parse_tokens(char *line, char **argv, int max_args); // 内建命令 int builtin_cd(char **argv); int builtin_exit(char **argv, int *should_quit); int builtin_export(char **argv); // 重定向信息 typedef struct { char *in_file; char *out_file; } RedirectInfo; // 解析重定向 void parse_redirects(char **argv, RedirectInfo *redir); // 执行外部命令 int run_external(char **argv, int background, RedirectInfo *redir); // 处理管道简化版只支持单个管道 int run_pipeline(char **left, char **right); // 回收后台任务 void reap_background(); void shell_loop() { char line[MAX_LINE]; char *argv[MAX_ARGS]; int should_quit 0; while (!should_quit) { reap_background(); printf(myshell ); fflush(stdout); if (fgets(line, MAX_LINE, stdin) NULL) { printf(\n); break; } // 去掉结尾换行 line[strcspn(line, \n)] \0; if (strlen(line) 0) continue; // 检测后台标记 int background 0; size_t len strlen(line); if (len 0 line[len - 1] ) { background 1; line[len - 1] \0; } // 检测管道 char *pipe_pos strchr(line, |); if (pipe_pos ! NULL) { *pipe_pos \0; char *left_argv[MAX_ARGS]; char *right_argv[MAX_ARGS]; parse_tokens(line, left_argv, MAX_ARGS); parse_tokens(pipe_pos 1, right_argv, MAX_ARGS); run_pipeline(left_argv, right_argv); continue; } parse_tokens(line, argv, MAX_ARGS); if (argv[0] NULL) continue; if (strcmp(argv[0], cd) 0) { builtin_cd(argv[1]); } else if (strcmp(argv[0], exit) 0) { builtin_exit(argv, should_quit); } else if (strcmp(argv[0], export) 0) { builtin_export(argv); } else { RedirectInfo redir {0}; parse_redirects(argv, redir); run_external(argv, background, redir); } } } int main() { signal(SIGINT, SIG_IGN); shell_loop(); return 0; }9. 调试经验分享与继续扩展的路径到这一步你有没有发现每一行代码背后都站着一个当初踩过的坑这其实是系统编程学习的常态——概念看十遍不如亲手把代码跑起来、再拆掉几个 debug 手段。调试这个项目时我强烈建议搭配两个工具strace看系统调用确认执行顺序pstree看进程树验证 fork 之后的父子关系。遇到卡死的现象先确认不是“父进程没有关管道fd”这类问题。这个版本还留了一堆可以继续扩展的口子引号解析、环境变量展开$HOME、双管道cmd1 | cmd2 | cmd3、作业控制jobs、fg、bg、setenv/unsetenv内建命令、tab补全、历史记录。推荐从环境变量展开开始加因为难度适中又不涉及复杂的解析。个人觉得做这种练习型项目最忌讳的就是“网上找个代码跑一遍”。真正把它变成你自己的是需要做到“闭着眼能画出来整体结构能说出每个fork和每个close的理由”。到那个程度再去面试聊进程、文件描述符、信号你讲出来的细节就有说服力了——因为那是你一行一行踩出来的经验不是背出来的概念。