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复制文件描述符实现 |
| 单管道(`cmd1 | cmd2`) | 建议做 |
后台执行(&) | 建议做 | 涉及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 /tmp,shell 要做的事情是把它拆成三个字符串: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秒后子进程结束才回到主循环。后台模式则直接跳过 wait,shell 继续干自己的事,但此时子进程成了“孤儿”,由 init 进程收养——这就是为什么后台任务的回收逻辑要单独写。
至此,第一个可运行版本已经成型。现在的myshell能执行绝大部分外部命令,比如ls、date、grep,但还没法cd,因为光靠 exec 改变不了父进程的目录。
4. 内建命令:为什么 cd 必须自己实现
内建命令是本项目中学到“进程模型”精髓的地方。前面提过,cd如果是子进程执行,目录切换不会影响到父进程。下面把常见的三个内建命令逐个过一遍。
4.1 cd 与 chdir
int 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启动的所有子进程都能看到它。实现上其实就是调用setenv:
int builtin_export(char **argv) { if (argv[1] == NULL) { // 打印当前环境变量 extern char **environ; for (char **e = environ; *e; e++) { printf("%s\n", *e); } return 0; } // 支持 export NAME=value 和 export NAME if (strchr(argv[1], '=') != NULL) { putenv(argv[1]); // 注意:putenv不复制字符串,argv[1]不能是局部变量 } else { setenv(argv[1], "", 1); } return 0; }高能预警:putenv有一个陷阱——它不复制传入的字符串,而是直接把字符串地址放进环境表。如果你传入的是一个将要被覆盖、释放或修改的缓冲区,环境表里就会变成一个悬空指针。用setenv就没有这个问题,因为它内部会做复制。
为了说明 export 的作用,我建议你在这个版本里做个实验:先执行export FOO=bar,再执行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)`,关闭两个管道的fd,exec cmd1; 3. fork 第二个子进程,在子进程里 `dup2(pfd[0], STDIN_FILENO)`,关闭两个管道的fd,exec cmd2; 4. 父进程关闭两个管道的fd,waitpid 两个子进程。 这里有一个值得反复强调的准则是:**管道必须在 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:执行cmd1,stdout接到管道写端 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:执行cmd2,stdin接到管道读端 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 的功能已经超过很多“作业级”实现。但还有个细节容易被忽略:你按下 Ctrl+C 的时候,信号是发给整个前台进程组的。
7.1 为子进程单独分配进程组
默认情况下,fork 出来的子进程和 shell 在同一个进程组。你在终端按 Ctrl+C,shell 和子进程都会收到 SIGINT。对shell来说这是灾难——用户按 Ctrl+C 是想终止正在运行的前台命令,而不是想把shell整个杀掉。
解决方式是在子进程里调用setpgid(0, 0),让它自己成为一个新进程组的组长。这样终端的前台进程组就可能是子进程,shell 本身收不到 SIGINT。另外可以在主循环里忽略 SIGINT,需要的时候再恢复默认行为:
// 主循环开始前 signal(SIGINT, SIG_IGN);然后在 fork 的子进程里恢复默认处理:
signal(SIGINT, SIG_DFL); setpgid(0, 0);这个组合拳的目的很明确:让 Ctrl+C 只作用于正在执行的前台子进程,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的理由”。到那个程度,再去面试聊进程、文件描述符、信号,你讲出来的细节就有说服力了——因为那是你一行一行踩出来的经验,不是背出来的概念。