资讯详情

资讯详情

用C语言手写迷你Shell:从fork到管道重定向的完整实现

每天在终端敲命令的人很多真正想过自己动手写一个 shell 的人不多。我最早冒出这个念头是在一次面试里——对方让我讲讲在 bash 里输入 ls 然后回车这中间到底发生了什么。当时我说得稀碎回来之后花了一个周末用 C 语言写了一个不到 300 行的迷你 shell把 fork、exec、wait、pipe、dup2 这些平时散落的知识点一次性全部串了起来。这篇就把整个实现过程整理出来从架构设计到核心代码再到实测排坑适合正在学 Linux 系统编程的同学也适合那些天天用 shell 却从没搞明白它凭什么能干活的人。这个项目叫简易 shell说白了就是自己实现一个简化版的 bash。它能解析你输入的命令支持内建命令、外部程序执行、管道、输入输出重定向、CtrlC 信号处理基本把 Linux 进程模型里最有代表性的几个系统调用都覆盖了。整个项目下来你对命令行到底是怎么跑起来的会有一种从里到外的掌控感面试时再被问到 fork 和 exec 也不至于支支吾吾。1. 为什么每个 Linux 学习曲线里都绕不开自己写一个 shell1.1 一个命令的完整旅程拆解ls 回车背后发生了什么先做一道简单的心理题在终端输入ls -l回车之后操作系统层面到底发生了什么真实流程大概是这样shell 打印提示符等输入 → 读取你的一行字符串 → 把字符串拆成ls和-l两个部分 → 在 PATH 环境变量指定的目录里找到/bin/ls这个可执行文件 → 调用 fork 复制出一个子进程 → 在子进程里用 exec 把自身替换成 ls 程序 → 子进程执行、输出结果 → shell 用 wait 等待子进程结束 → 重新回到提示符等你下一条命令。这里面每一步都是简易 shell 项目里要自己动手实现的部分。平时你在终端用的 bash 把这个流程封装得极其顺滑以至于你感知不到它的存在但只要你换到脚本编程、进程管理、CI runner 这些场景理解这套机制就成了基本功。1.2 这个项目的真正收益面试、系统编程、脚本认知三层加码我一直觉得写简易 shell 是学习 Linux 系统编程性价比最高的实践项目没有之一。很多教程让你照着写一个文件复制程序或者 socket 聊天室但那些项目覆盖的系统调用太单一。shell 不同它天然就是操作系统进程管理的集大成者。从面试角度看这几乎是一个万能项目。面试官想看你对进程的理解你可以讲 fork 和 exec 的配合想看你对文件描述符的理解你可以讲管道和重定向想看你对异常处理的理解你可以讲信号和僵尸进程。一段代码能同时覆盖四五类核心考点比堆十个玩具项目有用得多。从知识串联角度看它能把零散的概念拧成一根线。我之前对管道、文件描述符、缓冲区的理解都是孤立的写完之后突然发现它们全都在讲同一件事——数据怎么从一个进程流到另一个进程。这种通了的感觉是看多少文档都换不来的。另外还有个非常实际的好处用 shell 写脚本的人往往被各种诡异现象坑过。比如为什么cd在一个脚本里不管用、为什么管道里某条命令的变量赋值传不出去、为什么子 shell 的环境变量和父 shell 不一样。这些坑写完简易 shell 之后基本都能在原理层面想明白因为你亲眼看到了什么叫子进程。1.3 先定义边界我们做一个怎样的 shell动手之前先把需求说清楚。简易 shell 的目标不是复刻 bash而是在一个周末内实现一个能正常干活的交互式命令解释器。我给自己定的功能清单是这样的功能类别具体能力备注内建命令cd、exit、pwd、export必须内建原因后面讲外部命令任意 PATH 下可执行文件通过 fork execvp 实现管道支持 cmd1cmd2支持连续多段管道重定向open dup2 实现信号处理CtrlC 不会杀掉 shell 本体前台子进程照常退出交互体验提示符、EOF 退出读到 CtrlD 自动退出循环明确不做的引号解析ls a b.txt这种暂时不支持、通配符展开*.c不支持、命令历史、作业控制后台、jobs、||连接符。这些属于 bash 的豪华配置先把骨架搭起来再谈锦上添花。2. 架构先行REPL 主循环与命令解析器设计2.1 一切从 while(1) 开始REPL 模式是所有 shell 的地基所有 shell 的核心结构都叫 REPL意思是 Read读取输入、Evaluate解析执行、Print打印结果、Loop循环回去。不管 bash 还是 zsh心脏都是一个无限循环。用代码表达就是while (1) { print_prompt(); // 打印 my-shell$ char *line read_line(); // 读取一行 if (!line) break; // EOF退出 Command cmd parse_line(line); // 解析 execute_command(cmd); // 执行 }设计要点在于shell 永远不会自己退出除非你明确敲exit或者按 CtrlD。这个循环就是整个程序的内核其他所有功能都是在这个循环的某个环节里插进去的。第一次写的时候我栽过一个非常隐蔽的坑提示符printf(mysh$ )之后没有加fflush(stdout)。结果程序运行起来提示符迟迟不出现光标一直闪得先敲个回车它才蹦出来。原因是标准输出默认是行缓冲不是行结尾的$符号迟迟没有被冲刷到终端。这个细节虽然小但直接决定了交互体验也让我记住了写交互式程序时提示符后必须主动刷新缓冲这条铁律。2.2 解析器怎么把一行散乱的字符串切成可执行的命令拿到用户输入的字符串之后第一步是把这行文字拆成命令和参数。一个最简单的做法是先按|管道符号切分成多个命令段再在每个段里按空格和 Tab 切分成参数数组。// 先按管道符号拆分 char *cmds[MAX_PIPE]; int cmd_count split_by_pipe(line, cmds); // 每段再按空白字符拆分得到 argv 数组 char *args[MAX_ARG]; int argc split_tokens(cmds[i], args); args[argc] NULL; // argv 必须以 NULL 结尾execvp 靠它判断参数终点这里有一个很多人容易忽略的细节C 语言的 exec 系列函数要求参数数组必须以 NULL 结尾。也就是说你在解析器里 split 完之后必须手动往数组最后一个位置塞一个 NULL。如果没有这一步execvp 会直接越界读取内存程序崩溃得毫无预兆。另外提醒一下strtok会把源字符串的换行符也当成定界符处理正好替我们清掉末尾的\n。如果你用strtok记得最后一个参数传 \t\n而不是只传空格。2.3 为什么执行外部命令必须 fork而不是直接调 system很多初学者会问Linux 不是提供了system()函数吗直接system(ls -l)多省事确实省事但有两致命问题。第一system()内部是先把命令交给/bin/sh -c去处理的等于你让别人的 shell 代工那就完全失去了自己实现 shell的意义。第二system()在执行外部命令时会阻塞shell 的并发能力全没了而且你无法精确控制子进程的文件描述符和环境也没法获取细粒度的退出状态。正确姿势是三件套fork()创建子进程execvp()在子进程里加载外部程序waitpid()等待子进程结束。pid_t pid fork(); if (pid 0) { // 子进程执行用户命令 execvp(cmd-argv[0], cmd-argv); perror(execvp); // 走到这里说明 exec 失败了 exit(127); // 127 是命令不存在的惯例退出码 } else if (pid 0) { waitpid(pid, status, 0); // 父进程阻塞等待 } else { perror(fork); }理解这段代码的关键在 fork 的返回值。fork 调用一次但返回两次——父进程里返回子进程的 PID子进程里返回 0。代码里靠这个差异让父子进程走向不同的分支。子进程负责干活父进程负责收尾。有一个新手常犯的错误execvp 失败之后子进程直接继续往下跑又去执行父进程的后续代码最后两个进程同时跑错乱的逻辑。所以 execvp 后面必须紧跟perror exit无论如何不能让子进程再回头看父进程的代码。3. 核心实现从内建命令到管道重定向逐个打通3.1 内建命令cd 和 exit 为什么必须在 shell 自己进程里做写完外部命令执行之后我兴冲冲地敲了个cd /tmp然后发现目录纹丝不动。那一刻我才真正理解了内建命令的必要性外部命令是 fork 到子进程里执行的子进程再怎么 cd 也只是改自己进程的工作目录父进程不受影响。cd 必须直接操作 shell 进程自身这是硬性约束。类似的还有 exit 和 export。exit 自不必说如果 fork 一个子进程去执行 exit退出的只是子进程shell 本体继续活蹦乱跳。export 要设置的是 shell 进程的环境变量如果放到子进程里改父进程完全感知不到。实现思路很简单在解析出命令名之后先查一张内建命令表命中就调用自己的实现。比如cd就走chdir()系统调用pwd就调getcwd()然后打印。int is_builtin(char *name) { return strcmp(name, cd) 0 || strcmp(name, exit) 0 || strcmp(name, pwd) 0 || strcmp(name, export) 0; } void run_builtin(Command *cmd) { if (strcmp(cmd-argv[0], cd) 0) { const char *path cmd-argv[1] ? cmd-argv[1] : getenv(HOME); if (chdir(path) ! 0) perror(cd); } else if (strcmp(cmd-argv[0], exit) 0) { exit(0); } // ... }3.2 外部命令PATH 查找与 execvp 的配合逻辑外部命令的分发相对直接不是内建命令就交给 fork execvp。execvp 的 v 和 p 两个字母是有讲究的v 代表按参数向量传入p 代表自动搜索 PATH 环境变量。也就是说你不需要自己拼/bin/ls这种路径execvp 会在 PATH 指定的几个目录里逐个找。找不到就会返回 -1错误码是 ENOENT对应终端里那句经典的 command not found。如果你想进一步理解 PATH 查找的底层逻辑也可以自己提前做一遍用getenv(PATH)拿到路径串按冒号切成多个目录再在每个目录下用access()检查可执行权限。这个练习很有趣会让你知道为什么输入 ls 能执行输入 abcdefg 会报错。但实际项目中直接用 execvp 就够了。只有遇到一个坑需要警惕如果用户敲的命令第一个字符就是路径比如/usr/bin/lsexecvp 同样可以处理它会直接尝试执行这个路径不再搜索 PATH。这个行为和我们预期完全一致。3.3 管道把上一个命令的 stdout 接到下一个命令的 stdin管道是整个项目最有意思的部分。shell 里cmd1 | cmd2 | cmd3的语义是cmd1 的输出当做 cmd2 的输入cmd2 的输出再给 cmd3 的输入三者的数据像流水线一样推进。底层靠两类系统调用实现。第一是pipe()它创建一个内存里的管道缓冲区返回两个文件描述符——fds[0] 是读端fds[1] 是写端。第二是dup2()它能把一个文件描述符复制到另一个编号上——比如把 fds[1] 复制到编号 1stdout上那么进程里所有写到标准输出的东西实际都流进了管道。单段管道的核心流程是这样int fds[2]; pipe(fds); pid_t pid1 fork(); if (pid1 0) { // 第一个命令标准输出重定向到管道写端 dup2(fds[1], STDOUT_FILENO); close(fds[0]); close(fds[1]); execvp(cmd1-argv[0], cmd1-argv); exit(127); } pid_t pid2 fork(); if (pid2 0) { // 第二个命令标准输入重定向到管道读端 dup2(fds[0], STDIN_FILENO); close(fds[0]); close(fds[1]); execvp(cmd2-argv[0], cmd2-argv); exit(127); } // 父进程收尾 close(fds[0]); close(fds[1]); waitpid(pid1, NULL, 0); waitpid(pid2, NULL, 0);这里最容易被问到的知识点是为什么父进程和两个子进程都要 close 掉不需要的管道端。这其实牵扯到管道的一个底层机制——读端 read 只有在所有写端都关闭之后才会收到 EOF。父进程如果不关 fds[1]它自己也握着一个写端管道就永远不会结束下游进程的 read 就会一直卡死在那。多段管道比单段复杂一些需要把上一段的读端保存下来传给下一段。具体做法是在循环里维护一个prev_fd每一轮先把prev_fddup2 到 stdin再把新建的写端 dup2 到 stdout。这段代码我放在后面完整示例里展示。3.4 重定向 的本质就是换一个数据出口管道解决的是进程到进程的数据流动重定向解决的是进程到文件的数据流动。理解重定向之前先记住一个核心理念Linux 一切皆文件标准输入输出本质就是文件描述符 0 和 1。ls out.txt的意思就是把原本输出到 stdoutfd 1的内容改成输出到 out.txt 这个文件。实现上只需要三步open()打开目标文件获得一个 fddup2()把这个 fd 复制到 fd 1 上再关闭原 fd。之后就什么都不用管了ls 程序自己往 stdout 写时内核自动把数据导向了文件。和的差别主要在 open 的参数上。对应O_WRONLY | O_CREAT | O_TRUNC意思是清空原文件再写对应O_WRONLY | O_CREAT | O_APPEND意思是追加到文件末尾。更简单打开文件后 dup2 到 fd 0 即可。我写的时候在代码里加了一个小结构体字段来记录重定向信息解析时看到就把文件名存进去执行前统一处理。这样思路清爽不会把 open 的时机和管道逻辑搅在一起。if (cmd-out_file) { int flags O_WRONLY | O_CREAT | (cmd-append ? O_APPEND : O_TRUNC); int fd open(cmd-out_file, flags, 0644); if (fd 0) { perror(open); exit(1); } dup2(fd, STDOUT_FILENO); close(fd); }有个细节值得提创建文件时权限 0644 必须写在 open 的第三个参数位置而且只有带上O_CREAT标志这个参数才生效。如果你只写两个参数文件创建出来后权限会不可预期出现明明程序写入了但文件权限怪怪的这种问题。3.5 信号处理别让 CtrlC 把 shell 和子进程一起带走写完基本功能我开开心心跑了个sleep 5然后习惯性按了下 CtrlC。结果不只是 sleep 退了整个 shell 也跟着退了直接回到登录提示符。这就是没做信号处理的恶果。操作系统默认行为是进程收到 SIGINTCtrlC 对应的信号就立即终止。前台运行的 sleep 是 shell 的子进程它收到没问题但 shell 自己同时也收到了这个信号默认行为同样是被杀掉。正确的处理方式在 fork 之前就定好shell 进程忽略 SIGINT子进程在 exec 之前恢复默认处理。这样 CtrlC 只杀前台子进程shell 安然无恙地回到循环。// main 函数开头 signal(SIGINT, SIG_IGN); // shell 自身忽略 // fork 出的子进程里 signal(SIGINT, SIG_DFL); // 子进程恢复默认保证前台程序能正常被杀这个机制的妙处在于信号的处理动作可以继承exec 之后如果不显式恢复继承下来的也是忽略。所以那句SIG_DFL必须写在子进程里。少了它你会发现 CtrlC 没有任何反应sleep 永远睡死过去。4. 完整参考代码与关键模块讲解4.1 主干循环一个可直接运行的骨架下面给出一个整合了上述思路的参考实现。考虑到篇幅我做了一定精简保留了全部核心逻辑目标是你在本地改改就能跑。#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 MAX_PIPE 16 typedef struct { char *argv[MAX_ARGS]; char *in_file; char *out_file; int append; } Command; int split_by_pipe(char *line, char *cmds[]) { int n 0; char *saveptr NULL; char *token strtok_r(line, |, saveptr); while (token n MAX_PIPE) { cmds[n] token; token strtok_r(NULL, |, saveptr); } return n; } int parse_command(char *str, Command *cmd) { memset(cmd, 0, sizeof(Command)); int argc 0; char *saveptr NULL; char *token strtok_r(str, \t\n, saveptr); while (token argc MAX_ARGS - 1) { if (strcmp(token, ) 0 || strcmp(token, ) 0) { cmd-append (token[1] ); token strtok_r(NULL, \t\n, saveptr); cmd-out_file token; } else if (strcmp(token, ) 0) { token strtok_r(NULL, \t\n, saveptr); cmd-in_file token; } else { cmd-argv[argc] token; } token strtok_r(NULL, \t\n, saveptr); } cmd-argv[argc] NULL; return argc; } void handle_redirect(Command *cmd) { if (cmd-in_file) { int fd open(cmd-in_file, O_RDONLY); if (fd 0) { perror(open); exit(1); } dup2(fd, STDIN_FILENO); close(fd); } if (cmd-out_file) { int flags O_WRONLY | O_CREAT | (cmd-append ? O_APPEND : O_TRUNC); int fd open(cmd-out_file, flags, 0644); if (fd 0) { perror(open); exit(1); } dup2(fd, STDOUT_FILENO); close(fd); } } int is_builtin(char *name) { return !strcmp(name, cd) || !strcmp(name, exit) || !strcmp(name, pwd) || !strcmp(name, export); } void run_builtin(Command *cmd) { if (!strcmp(cmd-argv[0], cd)) { const char *path cmd-argv[1] ? cmd-argv[1] : getenv(HOME); if (chdir(path) ! 0) perror(cd); } else if (!strcmp(cmd-argv[0], exit)) { exit(0); } else if (!strcmp(cmd-argv[0], pwd)) { char buf[512]; if (getcwd(buf, sizeof(buf))) puts(buf); } else if (!strcmp(cmd-argv[0], export)) { if (cmd-argv[1] strchr(cmd-argv[1], )) putenv(cmd-argv[1]); } } void exec_command(Command *cmd) { if (!cmd-argv[0]) return; if (is_builtin(cmd-argv[0])) { run_builtin(cmd); return; } pid_t pid fork(); if (pid 0) { signal(SIGINT, SIG_DFL); handle_redirect(cmd); execvp(cmd-argv[0], cmd-argv); fprintf(stderr, mysh: %s: command not found\n, cmd-argv[0]); exit(127); } else if (pid 0) { int status; waitpid(pid, status, 0); } else { perror(fork); } } void run_pipeline(char *cmds[], int n) { int prev_fd -1; for (int i 0; i n; i) { int fds[2]; if (i n - 1) pipe(fds); pid_t pid fork(); if (pid 0) { signal(SIGINT, SIG_DFL); if (prev_fd ! -1) { dup2(prev_fd, STDIN_FILENO); close(prev_fd); } if (i n - 1) { close(fds[0]); dup2(fds[1], STDOUT_FILENO); close(fds[1]); } Command cmd; parse_command(cmds[i], cmd); handle_redirect(cmd); if (is_builtin(cmd.argv[0])) exit(0); execvp(cmd.argv[0], cmd.argv); fprintf(stderr, mysh: %s: command not found\n, cmd.argv[0]); exit(127); } if (prev_fd ! -1) close(prev_fd); if (i n - 1) { close(fds[1]); prev_fd fds[0]; } waitpid(pid, NULL, 0); } }4.2 主干函数的组织方式main 函数代码大家都容易写但几个细节决定了程序能不能丝滑运行int main() { signal(SIGINT, SIG_IGN); while (1) { printf(mysh$ ); fflush(stdout); char line[MAX_LINE]; if (!fgets(line, sizeof(line), stdin)) { printf(\n); break; // CtrlD 退出 } char *cmds[MAX_PIPE]; int n split_by_pipe(line, cmds); if (n 1) { run_pipeline(cmds, n); } else { Command cmd; parse_command(cmds[0], cmd); exec_command(cmd); } } return 0; }注意fgets读回来字符串末尾带着换行符解析函数里 strtok 的分隔符包含了\n所以不会出问题。但如果你自己写字符扫描逻辑一定记得处理换行。4.3 多段管道的 fd 生命周期值得反复盘管道这种写法对文件描述符的生命周期要求非常严格我建议你认真过一遍编号逻辑第一轮i0prev_fd-1新建 fds[0] 和 fds[1]。子进程 0 把 fds[1] 接到 stdout父进程关闭 fds[1]把 fds[0] 保存为 prev_fd。第二轮i1prev_fdfds[0]新建新的管道。子进程 1 把 prev_fd 接到 stdin再把新管道的写端接到 stdout。父进程关闭旧的 prev_fd关闭新管道的写端保存新读端。这样子进程 0 的输出自然流入子进程 1 的输入。关键是每轮循环都有三处 close少一处都可能造成管道写端引用计数不为零的悬案让整条命令链卡死。5. 实测与排坑十次调试九次栽在文件描述符上5.1 测试方法拿真实 ls、grep、wc 来验收写完不是结束要能经得起命令的实战检验。我建议从简单到复杂做一轮系统测试基础功能ls、pwd、echo hello内建命令cd /tmp后pwd验证目录确实变了外部命令带参数ls -l /etc/hosts、grep root /etc/passwd管道测试ls /etc | grep conf、cat /etc/passwd | grep root | wc -l重定向测试ls out.txt后cat out.txt、echo hi out.txt验证追加信号测试sleep 10后按 CtrlC确认 shell 还在异常测试敲一个不存在的命令确认打印 command not found 且不崩溃测试时要和系统 bash 结果做对比。比如ls /etc | grep conf的输出数量如果和 bash 不一致说明管道传数据有 bug。这类对比测试是排查问题最直接的手段。5.2 常见问题速查表我踩过的坑你直接避开现象原因解决办法提示符不显示要按回车才出现没加 fflush(stdout)提示符打印后立即 flush执行 ls 后没有任何输出程序卡住管道的写端没关干净读端收不到 EOF所有不用 fd 一律 close按 CtrlC 后整个 shell 退出没做 SIGINT 忽略main 里 signal(SIGINT, SIG_IGN)cd 后目录没变cd 被 fork 到子进程执行了用内建命令直接 chdir命令不存在时程序崩溃execvp 失败没 exitperror 后 exit(127)exit 命令退不出来exit 也是在子进程里跑的内建命令直接调 exit(0)输入重定向后程序读不到数据没把文件 fd 复制到 stdindup2(fd, STDIN_FILENO)5.3 几个值得单列出来的细节提醒第一个是 fork 之后父进程要尽快 wait。子进程如果结束了但父进程没去收尸它就会变成僵尸进程。虽然一个两个不致命但如果你在一个循环里 fork 几百个子进程再统一 wait内存里全是僵尸非常难看。正确的做法就是每条命令执行完立刻 waitpid这也是 shell 的常规节奏。第二个是 redirect 和管道同时出现时顺序容易乱。我的处理顺序是先接管道再处理重定向。因为对于cat in.txt | grep x out.txt这种命令两个 fd 修改互不冲突但如果你在子进程里先 open 了in.txt再 dup2 到 stdin此时管道读端还没接进去输入源就被覆盖了。所以要么在同一个子进程里分两步先 dup2 管道读端再处理重定向文件两个逻辑不打架。第三个是 perror 不一定要写到 stdout。错误信息走 stderr 是 Unix 惯例方便你在管道重定向时日志和正常输出依然分开。我实现时统统用perror它天然走 stderr比 printf 更合适。6. 还能往哪扩展这个版本只是起点6.1 几个高性价比的改进方向按收益排序写完基础版你完全可以继续往上叠功能。我个人建议按这个顺序来引号解析能让你处理带空格的文件名这是日常使用最痛的缺失和||连接符逻辑其实不复杂前面命令的退出状态决定后面跑不跑后台执行需要配合waitpid(..., WNOHANG)和作业表来跟踪子进程通配符展开把*.c在 shell 层展开成多个参数需要调glob()函数。还有更进阶的命令历史可以用 GNU readline 库上下箭头切换历史命令这个库直接提供readline()函数比自己搞行编辑省力得多。脚本文件执行也不难从source myscript.sh开始把逐行读取的逻辑抽出来复用即可。6.2 一点个人经验收尾整个项目做完之后我最大的感受是以前写 shell 脚本遇到各种问题总觉得是语法太玄学现在回头看绝大多数都是进程模型和文件描述符的问题。你对这几块的理解每深一层脚本的调试效率就高一大截。最后分享一个小技巧调试管道问题时先用strace -f跟踪系统调用能直接看到每个进程 open、dup2、close 的顺序。十次里有九次fd 有没有关干净一眼就看得出来。这个工具比我无数次打印调试信息都管用强烈建议你也装上。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →