1. 为什么我要亲手写一个 Shell:它不是玩具,而是一台操作系统的显微镜
很多人把 Shell 当成一个黑盒子,点开终端敲命令,程序跑起来,一切都显得理所当然。直到某天我想给终端加一个自定义命令,才发现自己对操作系统究竟干了什么一无所知。于是我用 C 语言写了一个自制 Shell 命令行解释器,从读入一行文本开始,到 fork、exec、waitpid、管道、重定向、信号处理全走了一遍。做完以后再去翻操作系统教材的进程管理章节,感觉像换了一本书。
这个项目能做什么?运行之后会出现一个像 bash 一样的提示符,可以输入 ls -l、cd、echo hello | wc -l、cat < a.txt > b.txt 这类命令,程序会负责拆解字符串、创建进程、接通管道、等到子进程结束。如果你是在校生,这几乎是最适合作为课程设计的系统编程题目之一;如果你是转行求职的开发者,这个项目比背一百道进程线程面试题更能让人真正理解进程模型。
1.1 一条命令背后到底发生了什么
在真实 shell 里敲下 ls -l 并按下回车,背后至少发生了这些事:先由终端驱动把整行文本交给 shell 进程的标准输入;shell 对字符串做切分得到 ls 和 -l;调用 fork 复制出一个几乎一模一样的子进程;子进程调用 exec 系列函数把新的程序映像加载进自己的地址空间;最后父进程调用 wait 系列函数等待子进程退出。整个过程涉及进程创建、程序替换、生命周期管理三个核心概念,而这恰恰是操作系统课程里最难真正理解的部分——因为教材只会给你一堆状态图和术语表,而写一个 shell 会让你亲眼看到每个环节。
1.2 我给项目划定的功能边界
第一版我给自己定了四个必须完成的目标:支持外部命令和简单的内建命令;支持单个管道 |;支持 >、>>、< 三种重定向;正确处理 Ctrl+C 信号。至于语法分析器、作业控制、历史记录、通配符展开、脚本文件执行,全部明确列为“以后再说”。划定边界非常重要——很多人做这个项目失败不是因为难,而是因为想第一版就实现全部功能,结果在解析器上花了大量时间,最后连最基本的 ls 都跑不稳。
1.3 前置知识不是硬门槛
坦白说,你需要 C 语言的基础语法和指针知识,但不需要先修完了操作系统课程。我当时的策略是边做边补:先跑通最小闭环,再对照系统调用的 man page 理解每一行的含义。如果你有 Python 的使用经验也完全没问题,C 只是让你更贴近系统调用的原始面孔。后面我也会按这个思路走:先给你一个能跑的最小版本,再逐步拆解为什么这么写、会在哪里翻车。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开工前的准备:工具链选型、调试手段、模块划分
2.1 为什么选 C,而且只用标准库
现在让你写一个 shell,语言选择很多:Python 有 os.fork、os.exec、subprocess,Go 也有 os/exec,甚至用 shell 写一个 shell 都能成为梗。但我还是推荐 C。原因是 shell 的本质就是对系统调用的一层浅封装,C 和系统调用之间几乎没有翻译损耗;你写的 fork() 就是内核提供的 fork(),不经过任何运行时包装。虽然标准库不提供 execvp 之外的封装,但你恰恰需要这份“裸露感”。
编译器用 gcc 就够了,编译命令我会带 -Wall -g:-Wall 把警告打开,-g 保留调试信息。项目不大,我不想引入复杂构建系统,一个 Makefile 三行搞定,后面觉得不方便再换。
2.2 三个调试工具,比任何教科书都好用
第一个是 gdb。你可能会在 fork 之后的子进程里加断点,这时要注意 gdb 默认只跟随父进程,需要在 gdb 里执行 set follow-fork-mode child 才能进入子进程的上下文。
第二个是 strace,这是排查 shell 类项目的核武器。strace -f -o /tmp/mysh.trace ./mysh 会把你的 shell 每次产生的系统调用全部记录下来,包括 fork、execve、pipe、dup2、wait4。当你怀疑死锁或者文件描述符泄漏时,直接看 trace 文件比猜快得多。
第三个是 valgrind,主要用于检查内存泄漏。valgrind --leak-check=full ./mysh 可以在退出时报告所有泄漏点。项目本身不大,内存问题大多出在 getline 的动态分配和参数数组的释放上,跑一遍就能定位。
2.3 代码模块怎么切
我花了半小时先把模块切清楚,避免写成一大坨 main。核心是一个命令结构体,加上四个模块:读取、解析、执行、内建命令。
c复制#define MAX_ARGS 64
typedef struct {
char *args[MAX_ARGS];
int argc;
char *input_file;
char *output_file;
int append;
int bg;
} cmd_t;
cmd_t 描述一条“命令”:args 是拆分后的参数向量,argv[0] 是命令名;input_file 和 output_file 分别记录重定向文件名;append 记录是不是 >>;bg 表示是否需要后台运行。读取模块负责从 stdin 拿到一行;解析模块把一行文本变成 cmd_t 数组;执行模块拿到 cmd_t 数组后负责 fork、dup2、exec、wait。这样分层之后,每个文件只有一两个职责,后面加功能时不需要改动其他模块的接口。
3. 最小闭环:读一行、拆参数、创建子进程、等着收尸
3.1 读入:别用 scanf,用 getline
读取输入最简单可靠的方式是 getline(&line, &len, stdin)。它会自动扩容,并把换行符保留在字符串末尾。我见过新人用 scanf("%s", buf) 读命令,结果遇到 ls -l 这种带空格的一行只能读到 ls,整个 shell 立刻变成残废。正确做法是读完一行后,把末尾的 \n 和 \r 手动换成 \0,然后跳过空行。
c复制ssize_t nread;
char *line = NULL;
size_t len = 0;
while ((nread = getline(&line, &len, stdin)) != -1) {
line[strcspn(line, "\r\n")] = 0;
if (strlen(line) == 0)
continue;
// 交给解析模块
}
free(line);
3.2 拆分参数:strtok_r 与它的两个警告
拆参数最简单的办法是 strtok(line, " "),但我建议直接用 strtok_r,并提前讲清楚两个坑。第一个:strtok 系列会在原字符串里写入 \0,所以最好在复制的内存上做拆分,否则原 line 变量会被切得面目全非。第二个:strtok 不是线程安全的,虽然单线程用没问题,但直接养成用 strtok_r 的习惯,后面如果加多线程或管道并行处理,至少不会莫名其妙踩雷。
c复制char *rest = NULL;
char *token = strtok_r(cmd_line, " ", &rest);
while (token != NULL && cmd->argc < MAX_ARGS - 1) {
cmd->args[cmd->argc++] = token;
token = strtok_r(NULL, " ", &rest);
}
cmd->args[cmd->argc] = NULL;
这里要先说明一点:第一版我用空格做分隔符,所以 echo "hello world" 这种带引号的命令会被错误地拆成三个参数。我把它明确列为已知限制,等版本跑稳后再换手写扫描器。宁可先有缺陷地跑起来,也不要一开始就被词法分析拖住。
3.3 fork、execvp、waitpid:系统编程的老三样
这是整个项目的心脏。执行外部命令时,shell 要复制自己、替换新程序、等待退出,对应的三个系统调用是 fork、execvp、waitpid。
fork 的返回值要分开看:父进程里拿到的是子进程 PID,子进程里拿到的是 0。借用一个办公室里的类比——fork 相当于复印了一份一模一样的文件,然后两个人在两个办公室里同时继续往下读同一页代码,唯一的区别就是靠返回值分辨谁是谁。execvp 则是把当前进程的地址空间整个换成新程序,换完之后 PID 不变,文件描述符也不变,只是执行的内容换了。
c复制#include <sys/wait.h>
#include <unistd.h>
int execute_external(cmd_t *cmd) {
pid_t pid = fork();
if (pid == 0) {
// 子进程:去执行命令
execvp(cmd->args[0], cmd->args);
// execvp 只有失败才走到这里
fprintf(stderr, "%s: command not found\n", cmd->args[0]);
_exit(127);
} else if (pid < 0) {
perror("fork");
return -1;
}
int status;
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
return -1;
}
return 0;
}
3.4 为什么 execvp 失败必须 _exit,而不是 exit
这一步是很多初版 shell 出乱子的根源。execvp 只有失败才会返回,子进程继续往下执行。如果不做任何处理,子进程会顺着代码继续走,进入 shell 主循环的下一次迭代——它会再打印一次提示符、再尝试读一行输入,导致屏幕上出现一堆幽灵提示符,行为完全失控。正确的做法是失败后打一条报错,然后立刻 _exit(127)。
这里刻意不用标准库的 exit(127),是因为 exit 会刷新 stdio 缓冲区,而子进程缓冲区里可能残留着父进程复制过来的数据,flush 出去会让输出重复;_exit 不做任何清理,直接结束进程,更符合“子进程任务失败终止”的语义。
4. 内建命令与子进程回收:为什么 cd 必须自己来
4.1 chdir 的适用范围:命令只能改自己的当前目录
如果你天真地把 cd 作为外部命令执行,会发现敲完 cd /tmp 之后当前目录纹丝不动。原因在于 chdir 系统调用只能修改“调用者进程自己”的当前工作目录。外部命令 cd 其实是子进程在调用 chdir,它确实进入 /tmp 了,但那是子进程的工作目录,子进程一退出,一切回到原样,父 shell 的 cwd 没有被影响。所以凡是改环境状态的操作,都必须是内建命令:cd 改目录,export 改环境变量,exit 结束当前 shell 进程。这个道理会了之后,对“父子进程到底共享什么、不共享什么”会有非常具体的认识。
4.2 用一张表把内建命令挂起来
简单做法是执行前先拿 args[0] 和表里的名字逐个比较,命中就调用对应函数。函数指针表写起来很直观:
c复制int builtin_cd(char **args);
int builtin_exit(char **args);
int builtin_echo(char **args);
struct builtin {
const char *name;
int (*func)(char **args);
};
struct builtin builtins[] = {
{"cd", builtin_cd},
{"exit", builtin_exit},
{"echo", builtin_echo},
{NULL, NULL},
};
int run_builtin(cmd_t *cmd) {
for (int i = 0; builtins[i].name; i++) {
if (strcmp(builtins[i].name, cmd->args[0]) == 0) {
return builtins[i].func(cmd->args);
}
}
return -1; // 未命中
}
cd 的实现只是判断参数、调用 chdir(args[1]),失败就打印原因。exit 直接调用 exit(EXIT_SUCCESS) 或者解析参数里的数字作为退出码。echo 可以自己循环输出参数,也可以通过外部命令 /bin/echo 完成,但内建的好处是执行更快、不依赖 fork。从这几条开始,你会发现“为什么 shell 的某些命令比较快”的答案就藏在内建命令表里。
4.3 僵尸进程与 waitpid 的状态读取
当子进程结束的时候,如果没有父进程来收尸,它会变成僵尸进程,在进程表里占着一个条目。waitpid 做的就是收尸:把子进程的退出状态捞走,释放进程表条目。自己的 shell 如果不调用 waitpid,每次执行完外部命令都会留下一个僵尸,连续跑几十条命令之后,进程表里会挂着一串 <defunct>,这一般就是 shell 写得有问题的典型症状。
waitpid 返回的状态值是个 bit 包装,很多人第一次接触会看不懂,直接查这些宏就行:
| 宏 | 含义 |
|---|---|
| WIFEXITED(status) | 子进程是否正常退出 |
| WEXITSTATUS(status) | 正常退出时的退出码 |
| WIFSIGNALED(status) | 子进程是否被信号杀死 |
| WTERMSIG(status) | 杀掉它的信号编号 |
我们在执行外部命令时等待的就是这个状态,拿到之后才能真正感知“这条命令跑成功了没有”。
5. 管道与重定向:文件描述符就是门牌号
5.1 管道的本质,是一对门牌号指向同一个信箱
pipe(int fd[2]) 会在内核里创建一个缓冲区,返回两个文件描述符:fd[0] 是读端,fd[1] 是写端。你可以把内核缓冲区想象成一个小信箱,fd[0] 是取信口的门牌,fd[1] 是投信口的门牌。写入 fd[1] 的数据会缓存到内核空间,从 fd[0] 读出来。关键点在于,fork 之后两个进程都继承了这一对 fd,只要写端没被全部关闭,读端就永远等不到 EOF。这个细节正是管道实现时最大的死锁来源。
5.2 单管道的标准实现序列
实现 cmd1 | cmd2 的思路:先建管道,fork 两个子进程;左子进程把自己的 stdout 重定向到管道写端,右子进程把自己的 stdin 重定向到管道读端;父进程不需要保留管道的任何一端,立刻全部关闭。顺序可以简化成这样:
c复制int pipefd[2];
if (pipe(pipefd) == -1) {
perror("pipe");
return;
}
pid_t left = fork();
if (left == 0) {
// 左边:标准输出接到管道写端
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[0]);
close(pipefd[1]);
execvp(cmds[0].args[0], cmds[0].args);
_exit(127);
}
pid_t right = fork();
if (right == 0) {
// 右边:标准输入接到管道读端
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[0]);
close(pipefd[1]);
execvp(cmds[1].args[0], cmds[1].args);
_exit(127);
}
close(pipefd[0]);
close(pipefd[1]);
waitpid(left, NULL, 0);
waitpid(right, NULL, 0);
有一点很重要:dup2 之后立刻关闭原来的 fd,否则 fd 会握着写端不放,读端就读不到 EOF。等你把 ls | grep x 跑起来发现卡死,第一反应就该是检查所有进程各自还剩哪些 fd 没关——十有八九问题都在“多余的写端还开着”。
5.3 多级管道:递归是更简单的写法
支持三个以上命令组成的管道时,内存布局就变成一个命令序列,不再是一锤子买卖。一个简洁的思路是递归处理:先拿第一段命令和剩余命令分开,左侧进程直接 exec 当前命令,右侧递归调用处理剩余部分;每层递归先建管道,dup2 之后关掉不用的 fd,最后统一等待两侧退出。
这种写法比显式维护一个 fd 数组直观得多,缺点是会多几个轻量级的等待进程,但对学习项目完全值得。真正的优化版本可以改成循环加进程数组,但前提是你已经把递归版本调通——因为递归版本出错时,栈上每一层的 fd 状态更容易顺着调用关系复盘。
5.4 重定向:open 与 dup2 的顺序陷阱
重定向比管道简单,但有一个顺序陷阱。>、>>、< 的实现都是先 open 目标文件拿到一个 fd,再用 dup2 把它复制到 STDOUT_FILENO 或 STDIN_FILENO。open 的 flag 不一样:> 对应 O_WRONLY|O_CREAT|O_TRUNC,>> 要把 O_TRUNC 换成 O_APPEND,< 对应 O_RDONLY。
顺序陷阱在于:如果你想同时做输入和输出重定向,比如 cmd < in.txt > out.txt,必须先把两个文件都打开,再逐一 dup2。如果先处理了 < 再打开 >,open 出来的 fd 会占用还没有被释放的低编号位置,导致文件描述符错位。我最初的版本就有这个问题:< 占用 fd 3,> 打开后又抢占 fd 3,然后 dup2 的时候全乱套。安全的写法是先把所有需要的文件 open 完,保存好这些 fd,最后统一 dup2、统一 close。
c复制int in_fd = -1, out_fd = -1;
if (cmd->input_file)
in_fd = open(cmd->input_file, O_RDONLY);
if (cmd->output_file)
out_fd = open(cmd->output_file,
O_WRONLY | O_CREAT | (cmd->append ? O_APPEND : O_TRUNC),
0644);
if (in_fd != -1) dup2(in_fd, STDIN_FILENO);
if (out_fd != -1) dup2(out_fd, STDOUT_FILENO);
if (in_fd != -1) close(in_fd);
if (out_fd != -1) close(out_fd);
// 然后 exec
顺便说一句,重定向和管道是可以叠加的。当一条命令同时带了管道的两侧时,处理顺序是:先建管道,再 open 文件,然后 dup2 管道,接着 dup2 文件,最后关闭多余 fd。利用“后 dup2 覆盖先 dup2”的特性,文件重定向应该在管道 dup2 之后执行,这样文件会覆盖掉管道位置。很多初版 shell 在这里纠缠不清,我的建议是把两条路径分开处理,而不是在一个函数里塞太多 if。
6. 信号处理:让 Ctrl+C 只杀正在跑的命令,不杀 shell
6.1 Ctrl+C 到底发生了什么
终端里按下 Ctrl+C,内核会向前台进程组发送 SIGINT。如果 shell 不处理这个信号,shell 进程会被立刻杀掉——要知道执行 sleep 100 的时候你正焦急地想中断这个命令,结果没等命令退出,先把 shell 也带走了,你之前开的别名、设的变量全没了,非常狼狈。所以一个能用的 shell 必须做两件事:自己对 SIGINT 免疫,让子进程去死。
6.2 最简单也最稳的方案:shell 忽略,子进程恢复默认
在 main 的开头调用 signal(SIGINT, SIG_IGN),把 shell 对 SIGINT 的处理设为忽略。fork 出来的子进程在 exec 之前,再把 SIGINT 恢复成默认行为 signal(SIGINT, SIG_DFL)。这样 Ctrl+C 的信号发给整个前台进程组,只有子进程会响应并终止自己,shell 则当没看见,继续进入下一轮读命令。
c复制// shell 启动阶段
signal(SIGINT, SIG_IGN);
// 子进程分支内、exec 之前
signal(SIGINT, SIG_DFL);
signal(SIGQUIT, SIG_DFL);
为什么要在子进程里恢复默认?因为 signal 的处理方式会被 fork 复制,子进程如果不恢复,也会忽略 SIGINT,那你后面任何命令都无法用 Ctrl+C 中断。很多人会漏掉子进程里这一句,写完发现 Ctrl+C 完全失效,然后百思不得其解。
6.3 SIGCHLD 与后台任务
如果你暂时不做后台任务,waitpid 阻塞等待即可,父进程会在 waitpid 处睡觉,状态更简单。一旦引入 & 后台执行,父进程就不能每次都等命令跑完,这时需要把 waitpid 改成非阻塞模式,同时借助 SIGCHLD 信号通知父进程“某个子进程结束了”。
处理 SIGCHLD 的稳妥模式是:处理器里只设置一个 volatile sig_atomic_t 标志位,主循环检测到标志后,再调用 waitpid(-1, &status, WNOHANG) 循环收割。虽然不少老代码直接在处理器里 waitpid,但为了可移植性,把复杂逻辑留在主循环外更安全。这个细节在入门项目里很容易被忽略,但后续扩展作业控制迟早会碰到。
6.4 为什么真实 shell 非要进程组那一套
主流 shell 处理 Ctrl+C 更精细:fork 之后调用 setpgid(0, 0) 把每个子命令放进新的进程组,再用 tcsetpgrp 把终端前台组切换过去,命令结束后切换回来。这样即使一条命令自己 fork 出一堆孙子进程,Ctrl+C 也能精准地杀掉整组,shell 永远不惊不扰。对于第一个版本,忽略 SIGINT + 子进程恢复默认已经足够可靠,不会出现把自己偶发掉线的丑态。我把进程组方案列为进阶方向,是因为它涉及会话、控制终端的很多概念,一下子全写在第一版里容易让人失去耐心。
7. 实测中的三个坑与完整排查链路
7.1 坑一:echo hello | wc -l 挂死,死因是多余的 fd
现象非常好复现:写完单管道版本,高高兴兴敲 echo hello | wc -l,结果屏幕永远停在光标闪烁。我用 strace 挂上去一看,发现右边 wc 进程一直在 read(0, ...) 阻塞,永远等不到输入结束。再细看,右进程 wc 等待的其实是 EOF,而管道只有在所有写端全部关闭后才会给出 EOF。父进程创建完左右两个子进程后,自己也还持有 pipefd[1] 写端没关闭,于是管道始终有一个活着的写者,wc 的 read 永远返回不了 0。另外,左子进程还持有读端 fd 没关,这是第二个脏点,暂时不会导致挂死,但会破坏正确性。
修复方法就是我们前面反复强调的:所有进程要把自己不用的 fd 立刻 close。父进程关闭 pipefd 两端;左子进程关闭读端;右子进程关闭写端。改完这四处,命令立刻通了。这个坑几乎每个手写 shell 都会踩,而且它是理解“文件描述符继承关系”的最佳教学案例。
7.2 坑二:执行不存在的命令后,屏幕上冒出一堆幽灵提示符
第二个坑出现在 execvp 失败的处理上。我第一次版本用的是 return -1; 从 execute_external 返回,而不是在子进程里 _exit(127)。结果敲 notexist 之后,shell 没有再回到提示符,而是像断线重连一样连续打印了几个提示符,并且每个提示符后面都跟着奇怪的输入输出。排查的时候我在 strace 输出里看到子进程竟然一路执行到了主循环的 getline 调用,它还在尝试从同一个 stdin 读数据,而父进程又在 waitpid 等它,两个进程搅在一起,整个终端行为完全失控。
修复就是让失败路径彻底终止子进程,用 _exit(127) 结束它。后面我把“exec 失败后立即 _exit”写进了自己的检查清单,每次新加一条命令执行路径都会回头对着看一遍。
7.3 坑三:欢迎语和命令输出顺序错位
第三个坑和缓冲区有关。我的 shell 启动时会打印一行欢迎语,结果发现欢迎语经常跑到命令输出之后,或者一条命令执行完后欢迎语出现两次。这里的问题不是 wait 没等对,而是 stdio 缓冲区的复制。stdout 在非交互管道下是全缓冲的,fork 时缓冲区原件被复制给了子进程;子进程 exec 后,新程序映像会把这部分用户态缓冲丢得干干净净,所以正常 exec 不会重复输出。但是如果某条 exec 失败路径用了 exit() 而不是 _exit(),exit() 会去刷新从父进程复制来的 stdio 缓冲区,残留的半截欢迎语就被刷了出来。而且如果 fork 前没有 fflush,父进程的缓冲区里也可能存着旧数据。
修复方式是:fork 之前统一调用 fflush(NULL) 把当前所有 stdio 缓冲区清干净,所有 exec 失败路径统一用 _exit 终止。做到这两点之后,输出顺序才稳定下来。这个坑很隐蔽,它证明了“父子进程共享的是文件描述符映射,而不是用户态缓冲区”这个概念在实际中会如何坑人。
7.4 用对照测试替代盲目试错
调试 shell 时最好的参照物是系统自带的 bash。遇到疑难的输入,直接两边各跑一遍,比较行为、比较 strace 的记录,通常能立刻看出差异在哪一层。我自己写了一个 20 条左右的小测试用例集,每跑完一版就自动对比输出和退出码,这比手动敲命令靠谱得多。测试集里一定要包含空命令、未知命令、exit 带退出码、管道带空输入、重定向到不存在的目录、Ctrl+C 打断 sleep 这类边界场景。
8. 后续扩展尝试与边界:当基础版跑稳之后的思路
8.1 && 与 ||:返回码驱动的控制流
管道解决的是数据传输,逻辑连接解决的是“跑完上一条再决定要不要跑下一条”。实现 && 非常简单:先执行左边,拿到退出码,如果等于 0 就继续执行右边;|| 刚好相反。难的部分不在实现,而在于解析:一行里可能既有管道又有 &&,优先级也要处理好。我的建议是把解析器从“按空格拆”升级成“按最小语法单元拆”,先按 &&、|| 分成段,段内再按管道拆,段内命令再按空格拆。分层之后,每层职责单一,扩展起来很轻松。
8.2 环境变量展开与引号解析
真实 shell 里 echo $HOME 会把变量展开,echo "hello world" 会把引号内的空格保留为单个参数。这两件事最终都要在词法分析阶段解决:先识别引号和变量引用,再生成参数列表。我第一版用 strtok_r 无法处理这些,到后面自己写了一个逐字符扫描的分词器,统一处理空白、单双引号、$VAR、转义字符。这里没必要用正则表达式库,状态机式的手写扫描器 100 行左右就能跑。
8.3 后台任务与作业控制
& 的实现本质上是把 waitpid 从阻塞改成非阻塞轮询,并为每个任务记录 PID、命令行和运行状态。想做得更接近真实 shell,就开始涉及作业控制:任务表、停止/继续信号、前台切换、SIGCHLD 分发。这是一个大坑,但也是从“玩具 shell”走向“真的能日常使用的 shell”的关键一步。我的建议是先不做,等管道、重定向、信号处理都稳定以后,再单独开一个分支慢慢做。
8.4 一些硬性边界:参数上限、exec 失败之外的限制
execvp 最终调用的是 execve,内核会限制单条命令参数的总字节数,也就是 ARG_MAX(Linux 上通常可以到 2MB 级别)。如果你从终端粘贴了成千上万个参数,exec 会直接返回 E2BIG。自己的 shell 还需要注意 getline 读到超长行时内存的释放,避免每次循环泄漏。做这个项目让我学会一件事:shell 看似轻巧,实质上一头连着终端、进程、文件描述符,另一头连着命令语法、控制流、作业管理,任何一个方向深挖下去都能写成一篇很长的笔记,所以别急着“一步到位”,先跑稳最小闭环,再慢慢加东西。
