尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从0到1手写Shell:实现Linux命令行解释器的核心机制

从0到1手写Shell:实现Linux命令行解释器的核心机制 看到“手搓 Shell”这个标题估计不少人的第一反应是这玩意儿有什么好写的天天都在用不就是一个能敲命令的界面吗但真等你自己动手把一门 Linux 系统编程的课程作业做成一个能跑管道、能处理重定向、能响应 CtrlC 的完整命令行解释器时你会发现这件事远比想象中复杂也远比想象中有价值。我在专门研究 Linux 系统编程时就认认真真从 0 到 1 写过一版 Shell。今天这篇东西就是把我当时踩过的坑、想通的细节、最后沉淀下来的代码结构原原本本分享出来。适合正在学系统编程的学生也适合那些天天用 Linux、却想搞清楚 Shell 内部逻辑的开发者。1. Shell到底是个什么东西——动手前先看透本质1.1 Shell的本质不是“程序”而是“循环”很多人以为 Shell 是一个固定的程序启动起来之后等命令、执行命令、结束。其实它的核心是一个交互式循环专业点叫 REPLRead-Eval-Print Loop。你从键盘上读一行解析这行内容执行它把结果打印回终端然后再读下一行。整个过程的行为模式和 Python 的交互式解释器、Node.js 的 REPL 没有本质差异只不过 Shell 解析的是命令语法执行的是系统调用。明白这一点很重要因为一旦你意识到 Shell 是个“循环”你的代码结构就会自然分成两半一半负责“一次命令”的处理逻辑另一半负责“无限循环”的驱动逻辑。我当时初学的时候一上来就拼命写解析函数、执行函数最后全部挤在 main 里循环里又嵌套循环代码一团乱麻。后来重构时才想明白主循环应该简洁到令人发指——就是读取、解析、执行、清理四步走其他所有复杂逻辑都拆到函数里去。1.2 一个完整Shell的核心模块划分在动手写第一行代码之前建议先把整个项目的模块边界画清楚。一个可用的 Shell哪怕是最小版本也至少需要这些模块模块职责关键函数/行为输入模块从终端读取用户输入支持交互式与非交互式两种模式解析模块把字符串拆成动词参数处理引号和转义生成 argv 结构命令分发模块判断是内建命令还是外部程序builtin 表 PATH 查找执行模块fork、exec、wait管理子进程支撑前台/后台运行重定向模块处理 、、 符号打开文件、dup2 替换 fd管道模块处理 符号连接多个命令信号模块处理 CtrlC、Ctrl\、SIGCHLD防止子进程变僵尸我当时被导师按着脑袋画完这张表以后写代码的路线就清晰了很多。你不是在“写一个 Shell”你是在“实现七个模块”每个模块单独能测最后再拼起来难度一下子从“无从下手”降到了“逐个击破”。这也是我想特别强调的一点任何看似复杂的系统程序第一步永远不是写代码而是拆模块。2. 环境准备与初始设计——先把地基打牢2.1 项目结构与工具链选型我选择用 C 语言来实现理由很简单Linux 系统编程的经典接口比如 fork、exec、pipe、dup2、signal都是 C 接口用 C 写可以获得最直接的系统调用体验。如果你用 Python 写一个玩具 Shell那基本是在调库完全体会不到系统编程的精髓。项目结构我建议这样规划myshell/ ├── main.c # 主循环、初始化、退出清理 ├── shell.h # 公共头文件数据结构定义 ├── parse.c # 命令行解析 ├── parse.h ├── execute.c # 命令执行、管道、重定向 ├── execute.h ├── builtin.c # 内建命令实现 ├── builtin.h └── Makefile这个结构不是随便分的它对应了刚才那张模块表。每个 .c 文件只干一件事头文件暴露必要的接口主循环只调用 parse 和 execute。这样调试的时候你只需要对着出错的模块去查不需要在 1000 行代码里翻来翻去。编译工具用 gcc调试工具必须配 gdb。另外强烈建议开-Wall -Werror别觉得烦C 语言写系统程序编译警告往往就是运行期 bug 的前兆。我的 Makefile 长这样CC gcc CFLAGS -Wall -Werror -g SRCS main.c parse.c execute.c builtin.c OBJS $(SRCS:.c.o) TARGET myshell $(TARGET): $(OBJS) $(CC) $(CFLAGS) -o $(TARGET) $(OBJS) %.o: %.c shell.h $(CC) $(CFLAGS) -c $ -o $ clean: rm -f $(OBJS) $(TARGET)-g选项一定留着后面排查段错误、僵尸进程、管道阻塞全靠 gdb 和 core dump。2.2 启动流程与主循环骨架Shell 启动后的第一件事不是吹响号角而是先检查自己是“交互式”还是“非交互式”。怎么判断看标准输入是不是终端。用isatty(STDIN_FILENO)就能区分。交互式模式要打印提示符myshell非交互模式不需要因为可能是在跑脚本或者被别的程序调用。主循环我最终写成了这个样子简洁到不能再简洁int main(int argc, char **argv) { char *line NULL; size_t bufsize 0; ssize_t nread; init_signal_handlers(); while (1) { if (isatty(STDIN_FILENO)) { printf(myshell ); fflush(stdout); } nread getline(line, bufsize, stdin); if (nread -1) { break; // EOF 或出错退出主循环 } if (strcmp(line, \n) 0) { continue; // 空行直接跳过 } Command *cmd parse_line(line); if (cmd NULL) { continue; // 解析失败例如只有管道符没有命令 } execute_command(cmd); free_command(cmd); } free(line); printf(\n); return 0; }这个循环里全是对外的函数调用没有任何业务逻辑纠结在中间。每加一个功能就是新写一个函数然后把调用点补到对应位置。我在实际开发中有一个很深的感觉主循环干净项目的整体气质就干净主循环乱后面所有功能都会被连带拖入泥潭。3. 第一步命令行读取与解析——从字符串到 argv3.1 读入一行命令getline vs fgets读取终端输入教科书上常写fgets但实际我更推荐getline。getline是 GNU 扩展在 glibc 环境下直接可用它能自动扩容缓冲区不像fgets那样必须预先指定一个上限一旦输入超长就截断命令行长参数多的时候容易出问题。getline的返回值很关键返回 -1 表示读到 EOF 或出错。在交互模式下用户按 CtrlD 会触发 EOFShell 应该礼貌地退出同时换一行否则终端上会出现一个奇怪的残行。这也是我上面那段代码最后printf(\n)的原因。还有一个细节很容易忽略getline会把换行符\n也读进来所以解析前要去掉末尾的换行符。我在解析函数里直接用line[strcspn(line, \n)] \0;strcspn返回从开头到第一个\n之前的字符数把它替换成\0既删掉了换行又不会越界。3.2 分词把一行命令拆成 argv 数组拿到一行字符串后第一件事是分词tokenize。最简单的实现方式是strtok但它有两个著名问题一是会修改原字符串二是不可重入。多线程下或者你想保留原始命令行做调试时strtok就会反噬你。所以我用的是手写扫描的方式。基本思路遍历字符串跳过空白字符遇到非空白就开始记一个 token 的起始位置直到再遇到空白或结束符。把 token 拷贝进一个动态数组里。这个过程没有任何魔法核心就是一个状态机当前在“空白”状态还是“单词”状态。static char **split_tokens(char *line, int *count) { int capacity 8; char **tokens malloc(sizeof(char *) * capacity); int n 0; char *p line; while (*p) { while (*p isspace((unsigned char)*p)) p; if (*p \0) break; char *start p; while (*p !isspace((unsigned char)*p)) p; int len p - start; tokens[n] strndup(start, len); n; if (n capacity) { capacity * 2; tokens realloc(tokens, sizeof(char *) * capacity); } } *count n; return tokens; }这里有个初学者经常犯的错拿到char**后没意识到每个 token 都是独立 malloc 出来的内存最后释放时只 free 了指针数组本身导致每个 token 的内存泄漏。在 C 语言里谁分配谁释放这是铁律。3.3 引号与转义解析里最容易被低估的部分引号处理是解析模块里最容易翻车的环节。如果一个命令是echo hello world分词后应该是两个 tokenecho和hello world而不是三个。同理echo hello\ world也应该得到hello world一个 token。把引号和转义支持进分词器需要额外维护两个状态是否在单引号内、是否在双引号内。单引号内一切字符都按字面处理双引号内仍然处理\转义但只对、\、$、反引号这几个特殊字符生效——为省事我当时只实现了一个简化版双引号内允许转义和\其余原样保留。这个简化版足够应付 90% 的日常命令如果你要做一个完整的 Shell再去补参数展开、命令替换这些高阶功能。我的经验是解析器千万不要一上来就做全功能先处理单引号、双引号和反斜杠这三种标准情况剩下的留给后续迭代。很多系统编程作业要求“支持引号”其实能处理好这三种情况就已经超过 80% 的同学了。4. 第二步内建命令——Shell 的“自留地”4.1 为什么内建命令不能走外部程序执行有一个问题很多初学者会困惑为什么cd不能直接 fork 一个子进程来执行原因很简单——cd需要改变当前 Shell 进程自己的工作目录而 fork 出来的子进程工作目录和父进程是独立的子进程里改了对父进程毫无影响。等于白干。所以 Shell 必须有一批内建命令builtin它们在当前 Shell 进程内直接执行不走 fork。常见的包括cd、exit、export、unset、pwd、echo部分 Shell 是内建、jobs、fg、bg。当解析出来的命令匹配到内建表时直接在父进程调用对应函数否则才走 fork exec 的外部命令路径。4.2 核心内建命令的实现要点内建命令的实现套路非常固定一个函数对应一个命令入参是 argv 和 argc返回布尔值告诉主循环是否退出。cd的实现需要注意两个细节。第一cd不带参数时要回到$HOME第二cd -要到上一个目录。第二个功能需要用一个全局变量记录上次的工作目录static char *prev_dir NULL; int builtin_cd(char **argv) { char *target NULL; if (argv[1] NULL) { target getenv(HOME); if (target NULL) { fprintf(stderr, cd: HOME not set\n); return 0; } } else if (strcmp(argv[1], -) 0) { target prev_dir; if (target NULL) { fprintf(stderr, cd: OLDPWD not set\n); return 0; } } else { target argv[1]; } char *old getcwd(NULL, 0); if (chdir(target) ! 0) { perror(cd); } else { free(prev_dir); prev_dir old; } return 0; }还有一个我自己踩过的坑getcwd(NULL, 0)这个用法是 GNU 扩展它会自动 malloc 一块足够大的缓冲区用完后要 free。如果传一个固定大小的缓冲区比如char buf[256]遇到超长路径就会静默截断后续提示符显示的工作目录就会是错的。exit的实现更简单但要注意需要先释放所有动态分配的资源包括之前解析出来但没有执行的命令、全局 token 数组等。虽然操作系统会在进程退出后回收所有内存但养成好的释放习惯对于后续做更大型系统编程项目的帮助是不可估量的。export和unset直接包装setenv/unsetenv就能实现但要记住一点export是在当前 Shell 进程里调用它影响的是 Shell 及其未来子进程的环境变量不能反过来影响 Shell 父进程——这正好是“Shell 进程是用户会话环境边界”的体现。5. 第三步外部程序执行——fork、exec 与 wait5.1 fork execve子进程的诞生与交接外部命令的执行路径核心就三个系统调用fork、execve、waitpid。我把它们比作一个完整的“交接仪式”fork()把当前进程复制一份子进程从 fork 返回点开始继续执行父子进程拿到不同的返回值父进程拿子进程 PID子进程拿 0。execve()在子进程里把当前进程的代码段、数据段、堆栈全部替换成目标程序执行目标程序的 main。waitpid()父进程阻塞等待子进程退出回收子进程资源。这里有一个系统工程思维的亮点fork出来之后子进程先是“父亲的影子”但 exec 的一瞬间就“脱胎换骨”了。所以 exec 出错时比如命令不存在子进程不能继续往下执行 Shell 主循环逻辑否则两个进程都在读输入画面会非常混乱。标准做法是 exec 失败后子进程自己_exit(127)把错误码交还给父进程。pid_t pid fork(); if (pid 0) { perror(fork); return -1; } if (pid 0) { // 子进程 execvp(argv[0], argv); fprintf(stderr, %s: command not found\n, argv[0]); _exit(127); } // 父进程等待 int status; waitpid(pid, status, 0);注意子进程里用的是_exit()而不是exit()。exit()会刷新并关闭所有 stdio 缓冲区而 fork 的时候子进程继承了父进程的缓冲区内容如果两边都调用exit()同一个缓冲区会被 flush 两次产生重复输出。这种 bug 极其隐蔽不查个半天根本发现不了。5.2 PATH查找与可执行文件定位execvp的p就代表 “PATH”它会帮你在PATH环境变量指定的目录列表里查找可执行文件。这是省力的方式但如果你要自己实现execve就需要手写 PATH 查找。PATH 查找的规则是从左到右遍历PATH里的每个目录把目录/命令名拼接起来检查这个文件是否存在且可执行。如果命令名本身包含/比如./a.out或/bin/ls就直接按相对或绝对路径处理不走 PATH 查找。手写时可以用stat或access判断文件存在性和执行权限。我个人推荐先stat判断类型再access(X_OK)判断执行权限。因为access只检查权限位遇到目录会误判目录的 X 权限表示可进入不是可执行。5.3 进程回收与僵尸进程父进程不wait子进程会导致什么后果子进程退出后变成僵尸进程zombie占据进程表条目直到父进程调用wait/waitpid回收。如果父进程一直不回收僵尸进程会越积越多最终可能因为进程表满了导致系统无法创建新进程。waitpid(pid, status, 0)是阻塞等待指定子进程退出。但你马上会遇到一个问题如果 Shell 支持后台执行命令末尾加父进程就不能阻塞等待后台子进程否则 Shell 卡在那里没法输入新命令。解决办法是用signal(SIGCHLD, handler)或sigaction注册 SIGCHLD 信号处理函数在子进程退出时异步回收。这个细节我会在信号处理那一节展开。6. 第四步管道与重定向——Shell 的灵魂6.1 重定向文件描述符的“偷换”重定向的本质是把进程标准输入fd 0、标准输出fd 1、标准错误fd 2指向一个文件而不是终端。实现的系统调用是dup2。举个例子ls out.txtShell 要先open(out.txt, O_WRONLY | O_CREAT | O_TRUNC, 0644)得到一个文件描述符 fd然后dup2(fd, STDOUT_FILENO)把标准输出“偷换”成这个文件描述符。接下来的ls不管怎么printf内容都进了文件。实现的时候要注意几点。第一open的 flag 必须区分和前者要O_TRUNC后者要O_APPEND。第二对应O_RDONLY必须先确认文件存在否则 open 失败需要报错。第三重定向的 fd 只在子进程里改就行——父进程 Shell 不受影响这样处理最干净。所以重定向逻辑应该放在 fork 之后、exec 之前正好是子进程自己的小空间。我最初犯过的错是把重定向放在 fork 之前结果 Shell 自己的 fd 也被改了想再看屏幕输出都不行只能重启 Shell。记住一个原则父进程是 Shell 的本体任何影响 fd 的操作都必须在子进程里做除非你想永久改变 Shell 自己的状态。6.2 管道两个进程之间的“传话筒”管道pipe的核心系统调用是pipe(int fd[2])它返回两个文件描述符fd[0]用于读fd[1]用于写。实现cmd1 | cmd2的基本思路是创建一个管道拿到读端和写端。fork 第一个子进程它的标准输出重定向到管道写端dup2(fd[1], STDOUT_FILENO)然后 execcmd1。fork 第二个子进程它的标准输入重定向到管道读端dup2(fd[0], STDIN_FILENO)然后 execcmd2。父进程关闭管道两端等待两个子进程退出。这里有一个非常关键的工程细节谁什么时候关闭管道 fd。管道在读端全部关闭后写端写入会收到 SIGPIPE在写端全部关闭后读端读取会返回 EOF。所以两个子进程必须在 exec 前把自己不需要的一端关掉父进程也要在 fork 完成后关掉两端否则管道读端一直开着第二个子进程读数据时永远不会遇到 EOF会一直阻塞等待——这是死锁最常见的来源。用代码表示核心逻辑如下int pipefd[2]; pipe(pipefd); pid_t p1 fork(); if (p1 0) { dup2(pipefd[1], STDOUT_FILENO); close(pipefd[0]); close(pipefd[1]); execvp(cmd1[0], cmd1); _exit(127); } pid_t p2 fork(); if (p2 0) { dup2(pipefd[0], STDIN_FILENO); close(pipefd[0]); close(pipefd[1]); execvp(cmd2[0], cmd2); _exit(127); } close(pipefd[0]); close(pipefd[1]); waitpid(p1, NULL, 0); waitpid(p2, NULL, 0);6.3 多级管道的递归实现ls | grep .c | wc -l这种三级管道处理方式可以有两种迭代或递归。迭代的写法是维护一个“上一个管道的读端”每遇到一个|就新建管道把上一个读端接到当前命令的标准输入循环往复。递归的写法更直观把cmd1 | 剩余部分看作先执行 cmd1 并接到一个管道然后递归处理剩余部分让剩余部分的入口标准输入就是这个管道的读端。我建议初学者先写递归版本因为它天然贴合命令的层次结构。但需要注意递归深度极端情况下用户输入 100 个管道递归栈可能爆掉。行业里成熟 Shell 通常用迭代方案或显式 pipeline 结构。我在作业里写了递归版本注释写清楚“这是一个演示用版本生产环境要改迭代”不至于被导师问住。7. 第五步信号处理与交互体验——让 Shell 活起来7.1 拦截 CtrlC不要在错误的地方退出默认情况下用户按 CtrlC 会向整个前台进程组发送 SIGINTShell 和正在运行的子进程都会收到。如果你不处理Shell 自己也会被 SIGINT 杀掉这显然不符合预期。正确的行为是前台子进程运行时Shell 进程要忽略 SIGINT把信号留给子进程处理。没有子进程在跑用户在提示符下按 CtrlC 时Shell 要丢弃当前输入、换行、重新打印提示符而不是退出。实现方式是在启动时设置信号处理函数。signal函数简单但不推荐跨平台使用sigaction是 POSIX 标准接口控制粒度更细。我给 Shell 进程注册的 SIGINT 处理函数长这样void sigint_handler(int sig) { (void)sig; printf(\n); printf(myshell ); fflush(stdout); }但如果前台子进程正在运行Shell 主循环处于waitpid阻塞状态这个处理函数会在 waitpid 返回之前被调用。注意waitpid 被信号打断后会返回 -1并设置 errno 为 EINTR。所以 waitpid 的循环要处理这种情况要么重新调用 waitpid要么提前检查子进程是否退出。很多裸奔的 Shell 在这一步就会表现得很奇怪按下 CtrlC 后Shell 直接退出了或者命令结果还挂在屏幕上。解决方案是waitpid的返回值判断里加上 EINTR 检查和 WIFSIGNALED 宏while (waitpid(pid, status, 0) 0) { if (errno EINTR) { continue; } perror(waitpid); break; }7.2 SIGCHLD回收后台进程的关键当你支持sleep 10 这种后台命令时Shell 不能阻塞等待它。但 Shell 退出后如果后台进程还顶着它会被 init 收养这不是大的问题更大的问题是后台进程退出后状态码没人收变成僵尸。解决办法就是监听 SIGCHLD。子进程状态改变退出、停止时内核会向父进程发送 SIGCHLD 信号。在信号处理函数里可以循环调用waitpid(-1, NULL, WNOHANG)把能回收的子进程全部回收干净。这里有个线程安全相关的坑信号处理函数里不能调用非异步信号安全async-signal-safe的函数。printf、malloc都是不安全的。正确做法是信号处理函数里只做一件事情——用一个全局标记位记录“有子进程退出了”然后在主循环安全的位置检查标记位并调用 waitpid。这个模式叫 “self-pipe trick” 或 “signalfd”成熟 Shell 都这么干。我初版图省事直接在处理函数里 waitpid结果偶尔会崩溃后来查 gdb 才明白是信号打断了 malloc 的内部状态。7.3 提示符与工作目录展示一个体验良好的 Shell 提示符应显示当前用户、主机名和当前目录。在 C 里可以用getenv(USER)、gethostname()、getcwd()拼出来颜色转义序列可以玩 ANSI escape code。比如给目录名加一个蓝色看起来比白花花一片舒服很多printf(\033[1;32m%s%s\033[0m:\033[1;34m%s\033[0m$ , user, host, cwd);提示符有一个隐藏需求如果当前目录是用户主目录业界习惯显示为~。不处理也不致命但既然要做终端神器这种细节越贴近真实 Shell成就感越高。还有一个性能相关的点每次打印提示符都调用getcwd需要一次系统调用代价很低完全不需要缓存。8. 常见问题排查与调试实录8.1 子进程变僵尸或变成孤儿这个问题几乎每个人都会撞上。表现形式终端里执行sleep 10 后用ps能看到一个defunct状态的进程。排查思路如下先确认父进程有没有调用waitpid回收。如果父进程在某个分支里提前 return 了后续子进程没人管就会出现僵尸。如果用了信号处理检查处理函数里是否真的调用了waitpid(-1, NULL, WNOHANG)以及是否捕获了 SIGCHLD。我用 gdb 排查这类问题时会先ps -o pid,ppid,state,cmd -p pid看看子进程和父进程的状态再在父进程断点查看 waitpid 返回。有一次发现子进程僵尸了但父进程确实调了 waitpid最后查出来是父进程 waitpid 的第一个参数写错了写成了子进程 fork 前的变量值导致等待了一个不存在的 PID。8.2 管道阻塞终端卡住不动这是管道实现里最经典的问题。症状执行两个命令后第一个命令不输出第二个命令也一直等待。99% 的原因是某个进程持有管道写端没关闭。排查技巧用lsof -p pid查看这个进程打开的文件描述符如果看到pipe相关的 fd 号不对或者父进程/子进程没有关闭多余的端问题就明确了。我在调试时有一个笨但有效的方法在每个 close 调用前后加日志把 fd 号打出来。比如printf(p1 child: close pipefd[0] %d\n, pipefd[0]);然后对比实际行为很快就能看出谁没关。另一个相关的坑管道写端在没有任何读端时被写入进程会收到 SIGPIPE 信号默认行为是终止进程。如果你的程序没有收到任何报错就突然消失多半是这个原因。可以在启动时忽略 SIGPIPE但更好的做法是让子进程在 exec 前默认恢复 SIGPIPE 的默认行为避免误杀。8.3 解析引号的边界情况引号解析的边界问题通常在输入echo 、echo a、echo it\s这几种情况时暴露。我建议在测试阶段准备一份“诡异命令清单”专门用来炸自己的解析器输入期望输出常见错误echo 一个空行被当成echo没有参数echo a ba b被拆成两个参数echo it\sit\s转义状态错乱ls out.txt追加写入被当成覆盖cat nofilewc报错且管道不执行这些测试用例写成一个脚本每改一次解析逻辑就跑一遍能省下大量手工敲命令的时间。我当时还做过一个鲁棒性测试随机往命令行里插引号和空格看解析器会不会崩。C 程序一旦崩溃就是段错误防御性编程在这种场景下特别重要——所有 malloc 后必须检查返回值所有数组访问前必须确认下标范围。8.4 内存泄漏valgrind 是最好用的照妖镜C 语言的 Shell 项目内存泄漏是重灾区。我最开始写的那版跑完 50 条命令后内存增长了几十 KB。用valgrind --leak-checkfull ./myshell一跑泄漏点清清楚楚列出来全是分词函数里 malloc 的 token 没有完全释放。治理内存泄漏的经验总结成三条每条命令执行完必须释放 token 数组、命令行结构体、重定向和管道相关的临时内存。每个函数在 return 之前沿着所有分支检查一遍是否有资源没释放。尤其是出错处理分支最容易漏。信号处理函数里不做复杂逻辑避免引入不可控的临时分配。valgrind 跑起来会比较慢但它能把“飘忽不定”的段错误和“悄无声息”的内存泄漏一起暴露出来是系统编程阶段最值得依赖的工具之一。最后再分享一个实际调试的小技巧很多人遇到段错误就去翻代码逐行看效率极低。我的习惯是先开gdb ./myshell在 main 里设断点然后执行出错的那条命令程序崩溃后bt看堆栈。C 语言最奇妙的地方是问题往往不在你看到的那一行而在之前某个函数里埋下的定时炸弹。比如有一次我 debug 一个偶发的崩溃崩溃点在一个解析函数里但真正的问题在分词函数里多写了一个字符越界把返回地址给覆盖了。一个趁手的 gdb 命令列表能救急rrun、bbreakpoint、nnext、sstep、pprint、btbacktrace、fframe、xexamine memory。每次遇到段错误先想到bt再想别的。做完这个项目之后我最大的感受是Shell 就像一个系统编程的“期末大作业”它把进程管理、文件描述符、信号处理、字符串解析、内存管理几大主题全串起来了。你会自然地理解为什么父进程要 wait 子进程、为什么管道要关多余端、为什么信号处理函数不能随便调用函数。这些东西靠看书背不下来只有真正写一遍、踩一遍坑、用 gdb 和 valgrind 排查一遍才会真正长在你自己手里。如果你也在做类似的 Shell 项目希望这篇实战记录能帮你少走一些弯路。
返回列表