ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

手写Shell:内置命令的注册表设计与核心实现

2026/9/8 18:13:29 拓冰建站 浏览量
手写Shell:内置命令的注册表设计与核心实现 内置命令这种东西你光看名字会觉得没什么好讲的不就是 shell 自己实现的那几条命令吗但如果你真的在写一个命令行解释器或者跟着操作系统课程的实验在实现一个小型 shell你大概率会卡在这个看起来最不起眼的环节。很多人的第一个版本能把ls、pwd这类外部命令跑起来一输入cd /tmp就彻底没反应接着敲exit也没反应整个程序像个木头一样杵在那。这不是你 fork 的姿势不对而是你对“哪些命令必须由 shell 自己执行”还没有形成一个清晰的模型。这个系列第一节已经把读取命令、拆分成参数、用fork exec跑外部程序的主循环搭好了。这一节就在这个基础上补上内置命令机制我会先把内置命令为什么存在的底层原因讲清楚再给出一套能直接抄走的注册表式实现最后逐条落地cd、exit、pwd、echo、export、unset这些内置命令。如果你正在写自己的 shell、想做一个类似 bash 的解释器或是单纯想搞清楚type cd为什么回答 “cd is a shell builtin”这篇内容应该能帮到你。1. 为什么内置命令是绕不开的第一道坎1.1 先分清两种命令的执行模型命令分两种一种是需要去磁盘上找可执行文件、启动一个独立进程来运行的外部命令比如ls、ps、grep另一种是 shell 自己在进程内实现的命令比如cd、exit。两者最直观的区别是“有没有产生新的进程”。外部命令的执行过程通常是这样shell 先 fork 出一个子进程子进程再去 exec 对应的可执行文件父进程负责等待子进程结束。这种设计的好处是安全、隔离坏处是子进程永远无法直接改变父进程的内存状态。就好比你让秘书去帮你搬家秘书搬完自己换了套新家具你的房子还是原来的样子。绝大多数命令不关心这种隔离因为它们只是把结果打印出来就行但有一类命令必须操作 shell 自己的状态比如改变当前工作目录、修改环境变量、退出当前 shell。这一类如果放到子进程里执行你做的所有修改都会随着子进程消失用户看到的自然就是“命令执行了但什么都没发生”。1.2 进程模型决定了“哪些事必须亲自干”拿我这一节的例子说最典型的反例是cd。Unix 系统里有一个叫chdir()的系统调用它可以改变进程的当前工作目录。问题是每个进程都有自己的当前目录这个目录是进程属性的一部分不是全局共享的东西。你在子进程里调用chdir(/tmp)改的是子进程的目录父进程依然停留在原来的目录。如果你用 fork 去执行一条所谓的“外部 cd 命令”那它改变不了任何东西。exit就更明显了。如果 shell 生成了一个子进程让子进程去执行 exit那退出的是子进程shell 自己还在循环里等着下一条命令。别的能修改 shell 内部状态的命令比如设置环境变量的export、删除变量的unset、查看/设置外壳选项的命令也都有同样的问题。所以这就不难理解为什么“内置命令”不是一个可选题而是任何正常 shell 都绕不开的基础设施。1.3 这一节要实现的命令范围我给自己写的迷你 shell 起名叫nsh。第一节做完之后它已经有一个主循环会打印提示符、读取一行输入、按空白拆参数然后把第一个参数当作外部命令去执行。到了内置命令这一步我不打算一口气实现 bash 的全部内建命令而是先实现一组能覆盖不同状态修改维度的小集合命令作用为什么需要内建pwd打印当前工作目录不强制内建但作为 shell 自身状态的一部分很方便cd切换当前工作目录必须内建chdir 要作用在 shell 进程上exit退出当前 shell必须内建要让主循环结束echo打印参数减少额外进程开销且便于实现大量 shell 专属输出选项export设置/导出一组环境变量必须内建要修改 shell 进程环境unset删除环境变量同上type判断命令是内建还是外部需要访问内置命令注册表help列出注册表中的命令用来验证注册表机制选择这组命令的另一个原因是它们都很适合用来验证“注册表”机制是否可靠。真正复杂的 shell 有几十上百个内建命令如果把每条命令都用一个if (strcmp(name, cd) 0)分支来实现代码会迅速膨胀到没法维护。所以这一节里我更想强调的是“内置命令怎么组织、怎么查找、怎么执行”这套机制本身而不是单纯堆一堆命令出来。2. 命令分发架构把 if/else 换成一张表2.1 最直观的实现以及它为什么撑不到十个命令初学者写多命令程序时最容易想到的实现方式就是在主循环里这么写if (strcmp(argv[0], cd) 0) { do_cd(argc, argv); } else if (strcmp(argv[0], exit) 0) { do_exit(argc, argv); } else if (strcmp(argv[0], echo) 0) { do_echo(argc, argv); } else { run_external(argc, argv); }只写两三条命令时这种分支确实够用而且逻辑非常直白。但随着命令数量增长问题会一个个冒出来一是strcmp分支越来越长找到某个命令对应的处理函数需要反复滚动二是新增一条命令时你必须小心翼翼选择一个合适的位置插入新的else if漏掉一个就只能在运行时踩坑三是每个分支都在做“名字-函数”的映射这本来是可以集中处理的重复劳动四是单元测试很难做因为命令分发的逻辑和命令本身的实现搅在一起。我见过有同学把factor、look这类偶尔用一次的命令也写死进 if/else最后文件足足多出四五百行而且只要动一个分支就可能影响到外部命令的 fallback 路径。这套方案的问题不在于“能不能用”而在于它没有把“命令选择机制”和“命令实现”分开。改到第十个命令时你会发现自己的耐心也在跟着流失。2.2 用结构体数组做“内置命令注册表”在 C 语言里“注册表”最朴素也最好用的形态就是一张结构体数组。每个结构体至少保存两样东西命令名字、指向处理函数的指针。处理函数的签名统一你就能写一个通用的查找函数去遍历这张表。typedef struct shell NSH; typedef struct { const char *name; int (*func)(NSH *sh, int argc, char **argv); const char *usage; } Builtin; static Builtin builtins[] { { pwd, builtin_pwd, pwd }, { cd, builtin_cd, cd [dir] }, { exit, builtin_exit, exit [status] }, { echo, builtin_echo, echo [-n] [-e] args... }, { export, builtin_export, export [NAMEVALUE]... }, { unset, builtin_unset, unset NAME... }, { type, builtin_type, type NAME }, { help, builtin_help, help }, }; static size_t builtins_len(void) { return sizeof(builtins) / sizeof(builtins[0]); } Builtin *lookup_builtin(const char *name) { for (size_t i 0; i builtins_len(); i) { if (strcmp(builtins[i].name, name) 0) { return builtins[i]; } } return NULL; }函数指针可能让部分新手觉得抽象但其实它表达的语义非常直接int (*func)(NSH *, int, char **)就是“这里存了一个函数地址如果你想执行这条命令带上 shell 状态和参数去调用这个地址就行”。这样一来从命令名到逻辑的映射就变成了一行表项新增一条内置命令只需要往下追加一行再写一个符合签名的函数就好。注册表里的usage字段也不只是给人看的后面做help命令时可以直接遍历这张表输出帮助信息省得在帮助文本里再维护一份列表。2.3 为什么函数签名必须背上一整个 Shell 状态内置命令的执行不是孤立的它们要么依赖 shell 状态要么修改 shell 状态。因此我建议把处理函数设计成“接收一个 shell 结构体指针”而不是在函数内部使用全局变量。比如cd要修改当前目录export要修改环境变量exit要通知主循环退出。如果这些状态全都用全局变量保存代码写起来确实省事但会带来一个实际问题它会让程序很难嵌入到别的地方。万一以后你想给这个 shell 加测试或同时运行多个 shell 实例全局变量就会互相打架。我定义一个很小的 shell 状态结构体这个阶段只需要放和“内置命令”密切相关的字段#define PATH_MAX 4096 typedef struct shell { char cwd[PATH_MAX]; char oldpwd[PATH_MAX]; int last_status; int exit_code; int exit_requested; } NSH;cwd保存当前目录oldpwd保存切换前的目录方便实现cd -。last_status用来保存上一条命令的退出状态这是 shell 脚本里$?的基础exit_code和exit_requested的组合则是为了处理exit命令先让 exit 命令把退出状态记下来再把exit_requested标记为 1最后回到主循环统一收尾退出。这个设计你不用急着背下来关键是体会它的思路内置命令不是一个“游离的函数”而是一个“能操作 shell 当前状态的动作”。把状态显式传入函数比藏进全局变量更值得推荐。从这一节的经验来看哪怕你只是在写一个小项目养成这种习惯也能少踩很多坑。3. 核心内置命令的逐一实现与避坑记录3.1 pwd 与 cd当前目录的所有权问题先说pwd它是最容易的一环。真实 shell 里有的版本会直接使用PWD环境变量来返回“逻辑路径”但在我们这个小 shell 里为了减少干扰我选择直接向系统询问物理当前目录。static int builtin_pwd(NSH *sh, int argc, char **argv) { (void)sh; (void)argc; (void)argv; char buf[PATH_MAX]; if (getcwd(buf, sizeof(buf)) NULL) { perror(pwd); return 1; } printf(%s\n, buf); return 0; }这一条基本不涉及状态修改最大的意义是让你先确认getcwd()的调用方式同时为cd之后的目录变化提供验证工具。cd则是内置命令里最有代表性的一个。它的核心是调用chdir()而且调用它的进程必须是 shell 自己不能是某条子进程。我在这里把流程拆成四步判断参数数量没有参数时默认去$HOME参数是-时打开oldpwd保存的目录调用chdir()切换目录切换成功后用getcwd()重新读取路径并更新oldpwd和当前 shell 状态。static int builtin_cd(NSH *sh, int argc, char **argv) { const char *target NULL; if (argc 2) { fprintf(stderr, nsh: cd: too many arguments\n); return 1; } if (argc 1) { target getenv(HOME); if (target NULL) { fprintf(stderr, nsh: cd: HOME not set\n); return 1; } } else if (strcmp(argv[1], -) 0) { target sh-oldpwd; if (target[0] \0) { fprintf(stderr, nsh: cd: OLDPWD not set\n); return 1; } } else { target argv[1]; } if (chdir(target) ! 0) { fprintf(stderr, nsh: cd: %s: %s\n, target, strerror(errno)); return 1; } if (getcwd(sh-cwd, sizeof(sh-cwd)) NULL) { perror(cd: getcwd); return 1; } sh-exit_code 0; return 0; }一个小细节不要写出“先调用chdir()失败后什么都不做”的代码。你要记住chdir()返回值是 0 才表示成功失败的场景可能包括目录不存在、权限不够、路径组件不是目录等。失败时把原始错误信息用strerror(errno)打印出来用户才能知道发生了什么。另外oldpwd的更新时机也很重要只有chdir()成功之后才把原来的cwd复制到oldpwd否则一次失败的cd会把上次的目录状态搞丢。3.2 echo 的选项处理从“能跑”到“不丢参数”echo看起来简单把所有参数打印出来中间加空格最后换个行。但真实 shell 的echo往往还支持-n表示不换行、-e表示解析转义序列。既然是内置命令我们没必要把 stdout 改成文件描述符等复杂形式直接处理标准输出就够了。static void print_escaped(const char *s) { for (; *s; s) { if (*s \\ (s[1] n || s[1] t || s[1] \\)) { s; putchar(*s n ? \n : *s t ? \t : \\); } else { putchar(*s); } } } static int builtin_echo(NSH *sh, int argc, char **argv) { (void)sh; int no_newline 0; int do_esc 0; int i 1; while (i argc argv[i][0] - argv[i][1] ! \0) { if (strcmp(argv[i], -n) 0) no_newline 1; else if (strcmp(argv[i], -e) 0) do_esc 1; else if (strcmp(argv[i], -E) 0) do_esc 0; else break; i; } for (; i argc; i) { if (do_esc) print_escaped(argv[i]); else fputs(argv[i], stdout); if (i 1 argc) putchar( ); } if (!no_newline) putchar(\n); return 0; }这里有个很容易忽略的问题处理选项时不能让echo -abc这种参数被当成立即终止的标志。我上面用了一个while循环逐个判断“像选项的参数”一旦遇到不以-开头的参数就停止解析后面所有内容都当作普通参数原样输出。这样echo -n hello会输出hello且不换行echo -- -n则会原样输出两个横杠和-n和 bash 的直觉习惯更接近。真实世界中实现echo还有一个非常隐蔽的坑很多发行版的/bin/echo与 shell 自带的 echo 行为并不完全一致。如果我们用 fork 去执行外部/bin/echo细节会因系统而异而把echo做成内置命令你就能完全控制-n、-e、-E这些选项的定义这对 shell 整体行为的确定性是有帮助的。3.3 exit 不调用 exit()给主循环留出收尾的机会exit的最直接实现是在命令函数里调用exit(status)这样整个进程立刻结束shell 主循环自然也不会再继续。这个方案在交互式 shell 里勉强能用却有一个比较大的架构问题真正退出之前的资源清理、历史记录保存、缓冲区刷新等收尾代码全部失去了执行机会。所以我在这条命令上做了一个看似绕了一圈、实际更合理的做法内置命令函数只负责“提交退出请求”真正的exit动作留给主循环来做。static int builtin_exit(NSH *sh, int argc, char **argv) { int status 0; if (argc 1) { char *end NULL; long n strtol(argv[1], end, 10); if (end argv[1] || *end ! \0) { fprintf(stderr, nsh: exit: %s: numeric argument required\n, argv[1]); status 2; } else if (n 255 || n 0) { fprintf(stderr, nsh: exit: invalid status: %s\n, argv[1]); return 1; } else { status (int)n; } } if (argc 2) { fprintf(stderr, nsh: exit: too many arguments\n); return 1; } sh-exit_code status; sh-exit_requested 1; return status; }主循环在拿到exit_requested 1后会跳出循环再做一些清理动作最后用exit_code作为进程的最终返回码。这个方法也方便单元测试你可以初始化一个测试用的 NSH 结构调用builtin_exit然后断言exit_requested和exit_code的值。如果你真的在命令函数里直接exit()测试框架会因为进程退出而无法继续。3.4 export/unset对外部程序可见的那份状态如果你已经理解内置命令的关键是“修改 shell 自己的状态”那export和unset就很好解释了。外部程序启动时会继承 shell 的环境变量这个“继承”发生在 fork 的时刻。要让孩子进程看到新的变量shell 必须先把变量写进自己的环境然后再 fork。static int valid_name(const char *s) { if (!((s[0] a s[0] z) || (s[0] A s[0] Z) || s[0] _)) return 0; for (s; *s; s) { if (!((*s a *s z) || (*s A *s Z) || (*s 0 *s 9) || *s _)) return 0; } return 1; } static int builtin_export(NSH *sh, int argc, char **argv) { (void)sh; if (argc 1) { extern char **environ; for (int i 0; environ[i] ! NULL; i) printf(%s\n, environ[i]); return 0; } for (int i 1; i argc; i) { char *eq strchr(argv[i], ); if (eq ! NULL) { char *name strndup(argv[i], (size_t)(eq - argv[i])); if (name NULL || !valid_name(name)) { fprintf(stderr, nsh: export: not a valid identifier: %s\n, argv[i]); free(name); return 1; } setenv(name, eq 1, 1); free(name); } else { if (!valid_name(argv[i])) { fprintf(stderr, nsh: export: not a valid identifier: %s\n, argv[i]); return 1; } // 当前版本没有独立的本地变量表所以只 export 一个名字时 // 如果它已经存在于环境中就保留原值否则不引入空变量。 if (getenv(argv[i]) NULL) { // 这里选择静默忽略bash 里有 local 变量机制时会不一样。 } } } return 0; }unset则是export的逆向操作调用unsetenv(name)就可以把环境变量删掉。真正完整的 shell 里export和本地变量管理的耦合会更深因为FOObar这种赋值可以只创建本地变量export FOO才把它提升为环境变量。在这一个小节里我先把export NAMEVALUE这种最常用的形式做好本地变量表可以放到后面章节再做。需要注意环境变量是进程级资源setenv()修改的是当前进程的 environment后续 fork 出的子进程会自然继承。如果你自己维护了一个固定的envp数组并手动传给execve反而要小心不要让内置命令的修改和传参之间出现不一致。3.5 type/help注册表本身就是干这个用的type命令是用来判断一个名字到底是内置命令还是外部命令的。实现它需要访问我们刚建好的注册表static int builtin_type(NSH *sh, int argc, char **argv) { (void)sh; if (argc ! 2) { fprintf(stderr, usage: type NAME\n); return 1; } if (lookup_builtin(argv[1]) ! NULL) { printf(%s is a shell builtin\n, argv[1]); return 0; } printf(%s is %s\n, argv[1], argv[1]); return 0; }严格来说type还需要去 PATH 里查找外部程序并区分别名、函数等形态但在这个阶段它已经能帮你验证真正的核心逻辑注册表有没有生效。类似的help命令只需要遍历builtins[]数组逐行打印名字和 usage 字段不需要单独维护第二份命令清单。这就是注册表模式带来的直接好处命令的“元信息”和命令的执行函数待在一起维护一份数据就能同时支持查找、帮助和类型判断。4. 把内置命令接入主循环4.1 dispatch在 fork 之前先问注册表空有一张命令表还不够主循环必须在正确的时间点查表。这个“正确的时间点”可以说是在 fork 之前。我建议把“一条命令应该怎么执行”的逻辑单独抽成一个函数主循环只负责持续读取和调用它。int run_command(NSH *sh, char **argv) { if (argv[0] NULL) return 0; Builtin *b lookup_builtin(argv[0]); if (b ! NULL) return b-func(sh, argc_of(argv), argv); return run_external(sh, argv); }这条路径非常关键如果lookup_builtin命中了就直接在当前 shell 进程内调用处理函数如果没有命中才进入外部命令的fork exec流程。很多人一上来把整个执行流程统一写成“先 fork”就会遇到cd不生效的问题因为它们的代码里缺少这条“先询问注册表”的分支。这时你也能体会到为什么我前面说内置命令必须排在外部分支之前外部命令意味着新进程而内置命令意味着修改当前进程状态。先查表再决定是要“创建子进程”还是“亲自处理”这是一个非常干净的架构。4.2 外部命令的执行与退出状态归一化run_external是实现内置命令接入时的另外半块拼图。它要负责 fork 子进程、执行外部程序、等待子进程结束并把子进程的退出状态转换成当前 shell 能理解的整数。int run_external(NSH *sh, char **argv) { (void)sh; fflush(stdout); pid_t pid fork(); if (pid 0) { perror(nsh: fork); return 1; } if (pid 0) { execvp(argv[0], argv); fprintf(stderr, nsh: %s: %s\n, argv[0], strerror(errno)); _exit(errno ENOENT ? 127 : 126); } int status 0; while (waitpid(pid, status, 0) 0 errno EINTR) { // 被信号打断就继续等 } if (WIFEXITED(status)) return WEXITSTATUS(status); if (WIFSIGNALED(status)) return 128 WTERMSIG(status); return 1; }父进程里在 fork 之前调用fflush(stdout)这一点来自一个真实的经验坑C 语言的 stdio 缓冲区是用户态的fork 时子进程会复制一份父进程的缓冲区内容。如果父进程在调用 fork 前打印过提示符但没有 flush这些数据可能被子进程重复输出也可能在 exec 时被直接丢在缓冲区里导致显示异常。外部命令执行前先刷新一次 stdout是很多大型项目都会做的防御动作。子进程调用execvp后如果命令不存在errno通常是ENOENTshell 的惯例是返回 127如果文件存在但没有执行权限errno是EACCES返回 126。这些都是 shell 编程里约定俗成的状态码可以先用最简单的方式做出来。4.3 内置命令遇到重定向和管道怎么办这一节的内置命令只有在“简单命令”场景下才具备直接修改当前进程状态的能力。如果你继续给 shell 增加重定向和管道就要小心一个边界ls | cd /tmp这条命令的真实现象是什么bash 会把管道左右两边都放到不同的子进程里去执行所以这个cd是在子进程里生效的不会影响你当前所在的目录。换句话说一旦命令被放进管道内置命令会退化为“子进程内的内置函数”它的状态修改作用范围也随之缩小。因此一个正确、可扩展的架构不应该把“内置命令”和“永远不 fork”捆绑在一起而应该把“简单命令的执行单元”抽象出来。对于管道左侧或右侧的简单命令它们都应该在子进程里运行这时候无论你是内置函数还是外部程序运行结果对父 shell 都不可见。你在自己实现到这个阶段时最好提前想清楚“是否允许管道中使用内置命令”以及“如果允许它改的是哪个进程的状态”否则很容易做出一个在while true; do cd /tmp; done里无效、在管道里行为又诡异的 shell。5. 常见问题与排查技巧实录真实动手写内置命令时遇到的大部分问题其实都和《进程模型》这一课有关。我把高频问题整理成了下面的速查表你可以直接对照排查。现象大概率原因处理建议cd /tmp后 shell 没有反应再执行pwd还是旧目录代码把 cd 放到了 fork 后的子进程里执行在主循环得到 argv 后先查内置命令表命中则直接在父进程调用exit命令执行后程序不仅没退出反而还停在那里只让子进程退出或者 exit 内置函数没有通知主循环用一个exit_requested标记命令函数返回后由主循环跳出echo -n hello输出了-necho 的选项解析太简单把所有参数都当成普通文本在处理 echo 时用一个循环识别前导选项遇到非选项就停止export FOObar后运行外部程序仍然看不到 FOO修改环境变量后没有及时同步给 fork 出来的子进程在 fork 前用setenv()修改环境外部程序通过 exec 继承即可外部命令输出顺序和预期不一样甚至莫名重复输出fork 前 stdout 缓冲区没有刷新在run_external()的 fork 之前调用fflush(stdout)新增内置命令后help列表里不显示忘记往注册表数组里加表项注册表里追加一行结构化数据并实现对应的处理函数cd -第一次使用时报 OLDPWD 为空oldpwd 初始没有设置在启动 shell 时把初始目录写入 oldpwd5.1 排查“内置命令为什么没生效”的三个手段先说我自己的习惯。遇到cd或export不生效时我喜欢用strace直接观察系统调用执行过程这一招非常快strace -f -e tracechdir,execve,wait4 ./nsh在这个输出里你会清楚地看到有没有chdir()被调用是在哪个进程里被调用后面跟的路径是什么。如果chdir()