第 2 章 · 03 内建命令与外部命令的差别


文档摘要

第 2 章 · 03 内建命令与外部命令的差别 本节摘要:解析完成后,Shell 立刻要做一个判断——这条命令是「自己做」,还是「fork 出子进程做」? 、 这种是外部命令,fork 出去跑;但 、 、 、 这些是内建命令,必须 Shell 自己在当前进程里做。为什么?因为这些命令改变的是 Shell 自己的进程状态(当前目录、环境变量、别名表),fork 出子进程改了也没用——子进程一退出,改动就丢了。本节导读这个核心分叉,以及那个经典问题「为什么 cd 不能是外部程序」——它是理解内建命令存在意义的关键。理解这点,你就理解了为什么 Shell 既是一个「命令执行器」,又是一个「带状态的解释器」。

第 2 章 · 03 内建命令与外部命令的差别

本节摘要:解析完成后,Shell 立刻要做一个判断——这条命令是「自己做」,还是「fork 出子进程做」?lsgrep 这种是外部命令,fork 出去跑;但 cdexitexportalias 这些是内建命令,必须 Shell 自己在当前进程里做。为什么?因为这些命令改变的是 Shell 自己的进程状态(当前目录、环境变量、别名表),fork 出子进程改了也没用——子进程一退出,改动就丢了。本节导读这个核心分叉,以及那个经典问题「为什么 cd 不能是外部程序」——它是理解内建命令存在意义的关键。理解这点,你就理解了为什么 Shell 既是一个「命令执行器」,又是一个「带状态的解释器」。

内容来源:基于原索引「Build your own X」Shell 域相关条目整理的导读,原始教程为外部资源(如「Tutorial - Write a Shell in C」「Writing a UNIX Shell」等)。

学习目标

阅读完本节,你应当能够:

  1. 定义「内建命令」与「外部命令」:内建是 Shell 进程内的函数,外部是磁盘上的独立可执行文件。
  2. 解释「为什么 cd 不能是外部程序」:它改变的是 Shell 自己的当前目录,fork 子进程改了也会随子进程退出而丢失。
  3. 列出常见内建命令及其分类:cd(改目录)、exit(退出)、export(环境变量)、alias(别名)、echo/pwd(为性能也可内建)。
  4. 在 Shell 的 Eval 段加入「内建优先」判断:先查内建表,匹配则直接调用对应函数;不匹配才走 fork+exec。
  5. 解释为什么 echopwd 既可以是内建又可以是外部程序(/bin/echo),以及 bash 为什么要把它做成内建。
  6. 理解「内建命令的退出码」也要存入 $?,与外部命令保持一致——对用户而言两者应当无差别。
  7. 说清内建判断必须发生在 fork 之前——这是新手最容易踩的「在子进程里做内建」错误。

一、学习价值:这一节回答 Shell 的经典谜题

任何一个学 Shell 用了一阵子的人,都会撞上这么一个谜题:

如果 Unix 的哲学是「一切皆文件」「命令都是独立的可执行程序」,那 cd 为什么这么特殊?它难道不能也写成 /bin/cd 这样的外部程序吗?

答案藏在你对 fork 的理解里。回顾第 04 节:Shell 执行外部命令的方式是 fork 出一个子进程,在子进程里 exec 那个程序。这意味着,外部命令对进程状态的所有改动,都发生在子进程里——子进程一退出,改动就消失了

现在设想 cd 是一个外部程序 /bin/cd。你敲 cd /tmp,Shell fork 出子进程,子进程调用 chdir("/tmp")自己的当前目录改成 /tmp——然后子进程退出。Shell 父进程的当前目录纹丝不动,还在原地。你下一条敲 pwd,Shell fork 出新子进程跑 pwd,它的当前目录还是 Shell 的目录——cd 完全失效了。

设想 cd 是外部程序的悲剧: [Shell, cwd=/home/user] │ fork ▼ [子进程, cwd=/home/user] ── chdir("/tmp") ──> [子进程, cwd=/tmp] │ │ │ 子进程退出 │ 子进程退出 ▼ ▼ [Shell, cwd=/home/user] <─ 纹丝不动! (改动随子进程消失)

这就是「为什么 cd 必须是内建」:它改变的是 Shell 自己的状态,而外部命令改不了 Shell 自己。任何「需要改变 Shell 进程状态」的操作,都必须是内建的。这是 fork+exec 模型的一个天然副作用——也是理解 Unix 进程模型的一个绝佳切入点。

关键概念:内建命令的本质是「它必须改变 Shell 自己的进程状态」。cd 改当前目录、export 改环境变量、exit 让 Shell 退出——这些操作 fork 出去都没用,只能 Shell 自己做。

这一节的价值不止于「回答一个谜题」。它真正教给你的,是 fork 模型对程序架构的深远影响:一旦你选了「用子进程执行命令」这条路,就注定有些事情只能在父进程做、有些只能在子进程做——这种「父子进程的职责划分」会贯穿整个 Shell 的设计。理解了这点,你才能在后续章节里判断「这个功能该放哪儿」——第 06 节的信号处理、第 07 节的变量存储,都会回到这个「内建 vs 外部」的划分原则。

二、子系统拆解:哪些必须内建,为什么

第一类:必须内建的——改 Shell 自己的状态

这一类是「真正的内建」,没法用外部程序替代:

内建命令 改变的 Shell 状态 为什么不能 fork
cd 当前目录(chdir) 子进程改的目录随退出丢失
exit 让 Shell 进程退出 子进程退出 ≠ Shell 退出
export Shell 的环境变量表 子进程改的环境随退出丢失
unset 删除环境变量 同上
alias Shell 的别名表 别名表在 Shell 进程内存里
unalias 删除别名 同上
umask Shell 的文件创建掩码 子进程改的随退出丢失
trap Shell 的信号处理表 信号处理表是进程属性
set Shell 的选项与位置参数 这些是 Shell 自己的状态
source/. 在 Shell 进程里执行脚本 必须在当前进程跑, 改 Shell 状态
history Shell 的历史记录 历史在 Shell 进程内存里

判别准则:问自己「这条命令是改 Shell 进程自己,还是只是产出一个输出?」——前者必须内建,后者可以外部。

💡 学习建议:把这张表记下来。它是你判断「新功能该做成内建还是外部」的决策清单。比如你想给 Shell 加 history(历史命令),立刻意识到「历史是 Shell 内存里的数据」,所以 history 必须内建——外部程序根本访问不到 Shell 的历史数组。再比如你想加 kill(发信号),虽然它操作别的进程,但 bash 也把它做成内建——因为内建版能直接访问 Shell 的「后台作业表」,知道 %1 指的是哪个作业。

第二类:可为内建也可为外部的——出于性能或一致性

有些命令「原则上可以是外部程序」,但 bash 把它们做成内建,出于两个理由:

  • 性能:echopwdtruefalsetest 这些命令调用极其频繁。如果每次都 fork+exec,开销惊人——fork 要复制进程地址空间(尽管有写时复制优化),exec 要重新加载可执行文件、动态链接库。做成内建,直接在 Shell 进程里算完返回,省掉了这一切开销。
  • 一致性:echo 内建后,行为可以和 Shell 的其他部分(变量展开、退出码)紧密结合,避免与 /bin/echo 行为不一致(实际上历史上 /bin/echo 和 bash 内建 echo 在处理 -e/-n 选项时确实有细微差异)。

你会发现系统里同时存在 /bin/echo 和 bash 的内建 echo——敲 echo 时 bash 优先用自己的内建,敲 /bin/echo 时强制走外部。这就是 type 命令能告诉你「`echo is a shell builtin」的原因。

$ type cd -> cd is a shell builtin (内建) $ type ls -> ls is /bin/ls (外部) $ type echo -> echo is a shell builtin (bash 内建优先) $ type /bin/echo -> /bin/echo is /bin/echo (强制走外部) $ type type -> type is a shell builtin (type 自己也是内建!)

type 本身也是内建——这并不矛盾,因为 type 需要查询 Shell 的内建表,这个表在 Shell 进程内存里,外部程序看不到。

第三类:结构性内建——影响 Shell 的控制流

这一类到第 07 节(脚本能力)才用得上,但概念上属于内建:

  • if/then/fifor/do/donewhilecase——这些不是命令,是 Shell 的语法关键字,只能内建(它们操作的是 Shell 自己的解析和执行流)。
  • &&||;——这些是运算符,靠 Shell 在 Eval 段自己处理。
  • [(等同于 test)——这是个看似奇怪的「命令」,实际 bash 把它做成内建以提升性能(历史上的 [/bin/[,现在大多被内建取代)。

本节你只需理解前两类,第三类留到第 07 节展开。

内建命令的实现机制

实现内建命令,本质是在 Shell 进程里维护一张「名字 → C 函数」的表。Eval 段拿到解析后的命令,先在这张表里查;查到了就直接调用对应函数,函数返回后整个命令就执行完了(没有 fork、没有 exec)。

伪代码:内建命令表 struct Builtin { string name; int (*func)(char **argv); // 返回退出码 }; builtins[] = { { "cd", builtin_cd }, { "exit", builtin_exit }, { "export", builtin_export }, { "alias", builtin_alias }, { "pwd", builtin_pwd }, // 可选, 为性能内建 { "echo", builtin_echo }, // 可选 { "type", builtin_type }, ... }; eval(segment): // 内建优先: 先查表 for b in builtins: if segment.argv[0] == b.name: return b.func(segment.argv) // 直接调用, 不 fork // 没匹配到内建, 才走 fork+exec return run_external(segment)

注意几个细节:

  • 内建函数接收和外部命令一样的 argv——这样 builtin_cd 也能读 argv[1] 拿目标路径,接口统一。用户敲 cd /tmp,builtin_cd 收到 argv = ["cd", "/tmp"],读 argv[1] 就是 /tmp
  • 内建函数返回退出码——和外部命令一样,存入 $?(第 07 节会用)。对用户而言,cd /不存在ls /不存在 都返回非零退出码,行为一致。
  • 内建在 fork 之前判断——这是关键:如果是内建,Shell 完全不 fork,直接在主进程里改状态。

内建 cd 的实现细节

cd 为例,它的实现极其简短:

伪代码:builtin_cd int builtin_cd(char **argv): target = argv[1] // 目标路径 if target == NULL: target = getenv("HOME") // 裸 cd 回家 if chdir(target) != 0: perror("cd") // 失败打印错误 return 1 // 退出码 1 // 可选: 更新 OLDPWD 环境变量 (cd - 用得上) setenv("OLDPWD", 旧的cwd, 1) return 0 // 成功

短短几行,但它必须在 Shell 自己的进程里跑——因为 chdir 改的是调用它的进程的当前目录。如果在子进程里跑 chdir,改的只是子进程的目录,子进程退出后 Shell 的目录不变,前功尽弃。

bash 的 cd 还有一些额外逻辑:cd - 切到上一次的目录(靠 OLDPWD 环境变量)、cd 不带参数回家(靠 HOME)、cd ~user 去某用户家目录(靠 getpwnam)。这些都是细节,核心还是 chdir

⚠️ 难点预警:有些新手会犯一个微妙错误——把内建判断放在 fork 之后(先 fork,再在子进程里判断是不是内建)。这是错的,因为内建命令存在的全部意义就是「不 fork」——一旦 fork 了,改的就是子进程,内建就失去了意义。内建判断必须在 fork 之前:先查表,匹配就调用、不 fork;不匹配才走到 fork+exec 那条路。这个顺序是本节的核心要点。

内建的查找优先级:内建 > 函数 > 别名 > PATH

当用户敲一个命令名(比如 test),Shell 按什么顺序查找它?bash 的查找优先级是固定的:

1. 特殊内建 (如 export, exit, set) <- 优先级最高, 不可覆盖 2. Shell 函数 (用户定义的 myfunc) 3. 内建命令 (如 echo, cd, test) 4. 别名 (alias 定义, 如 ll='ls -l') 5. PATH 查找 (扫描 /usr/bin 等目录) 6. 找不到 -> 报错 "command not found", 退出码 127

这个优先级解释了几个现象:

  • 你定义了 alias echo='echo "[DEBUG]"',但 echo 仍然是内建——别名在展开阶段把 echo 替换成 echo [DEBUG],然后再走查找。
  • 你敲 type test 看到 test is a shell builtin,但 /usr/bin/[ 也存在——内建优先,所以 bash 用自己的版本。
  • 你敲 /bin/echo hi 强制走外部(因为带了路径,跳过查找直接 exec)。

入门时你的 Shell 只需实现「内建表 > PATH 查找」这两级优先级,但理解完整的查找链有助于解释 bash 的某些「怪行为」。

PATH 查找:外部命令是怎么找到的

对外部命令,Shell 不是去某个固定位置找——它扫描 PATH 环境变量里列出的目录,挨个看有没有同名可执行文件。PATH 是用冒号分隔的目录列表:

$ echo $PATH /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin

用户敲 ls,Shell 依次查找 /usr/local/bin/ls/usr/bin/ls/bin/ls……第一个找到的就是要跑的。第 04 节会用到 execvp——这个函数自动帮你做 PATH 扫描(传 ls 它会自己找全路径);如果用 execv,你得自己拼全路径。

bash 还有一个优化叫 hash 缓存:第一次找到 /usr/bin/ls 后,记在内存里,下次直接用,不再扫 PATH。这就是 hash 命令显示的那张表——它解释了为什么「新装了个 /usr/local/bin/ls 但 bash 还在用老的 /usr/bin/ls」(hash 缓存没刷新,hash -r 可清掉)。这个细节本节不必实现,但理解它能帮你诊断「明明装了新命令却跑老的」这类诡异问题。

💡 学习建议:在你的 Shell 里实现「找不到命令就报错、退出码 127」这个标准行为。execvp 失败时(返回 -1,errno == ENOENT),打印 命令名: command not found,然后子进程 exit(127)。这个退出码是约定俗成的——后续脚本里 $? 等于 127 就知道是「命令找不到」。

内建与管道/重定向的交互

一个微妙的问题:cd > out.txt 这种内建命令带重定向时怎么办?bash 的做法是:重定向仍然生效(创建 out.txt),但 cd 的状态改动发生在 Shell 主进程里。具体实现稍复杂(Shell 要临时重定向自己的 fd、改完目录再恢复),入门时你可以先不支持「内建命令加重定向」这种罕见组合,等主体逻辑跑通再处理。

另一个微妙问题:管道里的内建命令(比如 cd /tmp | echo hi)。bash 的处理是:管道的每一段都会 fork 子进程,即使是内建——因为管道要求每段是独立进程。但这意味着「cd 在子进程里执行,改的目录随子进程消失」,所以 cd /tmp | echo hi 之后 Shell 的目录并没变。这是个反直觉的边界,入门时不必深究,但理解「管道强制 fork」这点能帮你解释很多怪现象。

内建命令的「特殊性」:它们能改 Shell 的解析行为

除了「改状态」这一类,内建命令还有一个隐蔽的特性:有些内建会改变 Shell 自己的解析或执行行为。最典型的例子是 alias:

alias ll='ls -la' # 之后敲 ll, Shell 在解析阶段就展开成 ls -la alias grep='grep --color=auto' # 给 grep 默认带上颜色参数

alias 不是「运行时改状态」,而是改了 Shell 解析命令的方式——下次用户敲 ll,Shell 在展开阶段先查别名表,发现 ll 是别名,替换成 ls -la,然后再走正常的解析+执行流程。这就要求别名表在 Shell 进程内存里(外部程序访问不到),所以 alias 必须内建。这也是为什么第 07 节的「展开阶段」要在解析之后、执行之前——别名展开就是那阶段要做的事之一。

类似地,source/. 命令(在当前 Shell 进程里执行一个脚本文件)也必须内建——因为它的全部意义就是「不 fork,在 Shell 自己的进程里逐行跑脚本」,这样脚本里的 cdexport、变量赋值才会真正影响 Shell。如果你用 bash script.sh 跑脚本,那是 fork 一个子 Shell,脚本里的 cd 改的是子 Shell 的目录,父 Shell 不受影响——这是 sourcebash 执行脚本的唯一本质差别。

关键概念:source(或 .)让脚本「在当前 Shell 进程里执行」,所以脚本的副作用(目录、变量、函数)会保留;而 bash script.sh 是「fork 子 Shell 执行」,副作用随子 Shell 退出消失。这就是为什么 .bashrc 必须用 source ~/.bashrc 重新加载,而不是 bash ~/.bashrc——后者改的环境变量不会被当前 Shell 看到。

三、上手第一步:先实现 cdexit

这一节的最小目标,是在你的 Shell 里加上两个最基础的内建:cdexit

第一步:实现内建命令表和查找。定义 builtins[] 数组,在 Eval 段先查这张表。先放一个空的占位,跑通「先查内建、没查到再走 fork」这个分叉逻辑。验证:敲 cd 时走内建分支、敲 ls 时走外部分支(用调试打印确认)。

第二步:实现 builtin_cd。调用 chdir(argv[1]),失败打印错误。验证:cd /tmp 之后,下一条 pwd(即使是外部的)会显示 /tmp——这就证明 cd 真的改了 Shell 自己的目录。

第三步:实现 builtin_exit。调用 exit(atoi(argv[1])) 或直接 exit(0),让 Shell 干净退出。验证:exit 能让 Shell 退出,而不是 fork 出一个马上退出的子进程(后者会让你看到「敲 exit 后 Shell 还在,只是多跑了个马上退出的命令」的怪现象)。

第四步:接上提示符动态字段。上一节(第 01 节)讲过提示符里显示当前目录——现在 cd 内建实现了,你能验证:cd /tmp 之后提示符里的目录立即变成 /tmp,因为每一轮循环都用 getcwd 重新拼提示符。这是「Shell 是带状态解释器」的最直观证据。

第五步(进阶):实现 export。调用 setenv(name, value, 1) 改 Shell 自己的环境变量表。验证:export FOO=bar 之后,下一条 echo $FOO(第 07 节展开变量)或外部程序读 FOO 能拿到 bar——因为子进程继承 Shell 的环境。

💡 学习建议:实现完 cd 后,做一个对比实验——故意写一个「假 cd」,把它放在 fork 后的子进程里跑 chdir。你会亲眼看到:cd 完全不生效,提示符里的目录纹丝不动。这个实验能把「为什么必须内建」这个概念刻进你的骨头里,胜过读十遍理论。

本节要点回顾

  1. 内建命令是 Shell 进程内的函数,外部命令是磁盘上的独立可执行文件——两者对用户无差别,但实现机制完全不同。
  2. 必须内建的判别准则:命令是否改变 Shell 自己的进程状态——cd(目录)、exit(退出)、export(环境)、alias(别名)都属于此类。
  3. cd 不能是外部程序的经典解释:外部命令在 fork 出的子进程里跑,子进程的目录改动随退出丢失,Shell 父进程目录不变。
  4. 内建判断必须在 fork 之前:先查内建表,匹配就调用、不 fork;不匹配才 fork+exec——这是 Eval 段的核心分叉。
  5. 内建和外部命令接口统一:都接收 argv、都返回退出码、退出码都存入 $?,对用户透明。
  6. 有些命令(如 echo)既可内建又可外部:bash 出于性能和一致性把它做成内建,这就是 type echo 显示「shell builtin」的原因。
  7. 管道里的内建会强制 fork:所以 cd /tmp | echo hi 不改变 Shell 目录——这是反直觉但正确的边界行为。

推荐上手顺序

  1. 先实现内建表和查找逻辑:Eval 段先查内建表、没查到再 fork,跑通这个分叉。
  2. 实现 builtin_cd:chdir 改 Shell 自己的目录。验证后,做一个「故意在子进程里 chdir」的对比实验,亲眼看到失效。
  3. 实现 builtin_exit:让 Shell 干净退出,而不是 fork 出退出的子进程。
  4. 接上提示符动态字段(第 01 节遗留):cd 之后提示符目录立即变化,这是「Shell 是带状态解释器」的可视证据。
  5. 进入下一节(fork+exec):内建判断的分叉有了,下一步把不匹配内建的命令,正确地 fork+exec 出去跑。

下一节(fork + exec 执行外部命令)处理上一节分叉的另一条路——当一条命令不是内建时(比如 lsgrep),Shell 必须 fork 出一个子进程,在子进程里 exec 那个程序,然后父进程 wait 它结束。这是 Unix 进程模型的灵魂原语,也是 Shell 执行外部命令的全部核心。


发布者: 作者: 灏天文库 转发
评论区 (0)
U