第 2 章 · 03 内建命令与外部命令的差别 本节摘要:解析完成后,Shell 立刻要做一个判断——这条命令是「自己做」,还是「fork 出子进程做」? 、 这种是外部命令,fork 出去跑;但 、 、 、 这些是内建命令,必须 Shell 自己在当前进程里做。为什么?因为这些命令改变的是 Shell 自己的进程状态(当前目录、环境变量、别名表),fork 出子进程改了也没用——子进程一退出,改动就丢了。本节导读这个核心分叉,以及那个经典问题「为什么 cd 不能是外部程序」——它是理解内建命令存在意义的关键。理解这点,你就理解了为什么 Shell 既是一个「命令执行器」,又是一个「带状态的解释器」。
本节摘要:解析完成后,Shell 立刻要做一个判断——这条命令是「自己做」,还是「fork 出子进程做」?
ls、grep这种是外部命令,fork 出去跑;但cd、exit、export、alias这些是内建命令,必须 Shell 自己在当前进程里做。为什么?因为这些命令改变的是 Shell 自己的进程状态(当前目录、环境变量、别名表),fork 出子进程改了也没用——子进程一退出,改动就丢了。本节导读这个核心分叉,以及那个经典问题「为什么 cd 不能是外部程序」——它是理解内建命令存在意义的关键。理解这点,你就理解了为什么 Shell 既是一个「命令执行器」,又是一个「带状态的解释器」。
内容来源:基于原索引「Build your own X」Shell 域相关条目整理的导读,原始教程为外部资源(如「Tutorial - Write a Shell in C」「Writing a UNIX Shell」等)。
阅读完本节,你应当能够:
cd 不能是外部程序」:它改变的是 Shell 自己的当前目录,fork 子进程改了也会随子进程退出而丢失。cd(改目录)、exit(退出)、export(环境变量)、alias(别名)、echo/pwd(为性能也可内建)。echo、pwd 既可以是内建又可以是外部程序(/bin/echo),以及 bash 为什么要把它做成内建。$?,与外部命令保持一致——对用户而言两者应当无差别。任何一个学 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 状态 | 为什么不能 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 把它们做成内建,出于两个理由:
echo、pwd、true、false、test 这些命令调用极其频繁。如果每次都 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 进程内存里,外部程序看不到。
这一类到第 07 节(脚本能力)才用得上,但概念上属于内建:
if/then/fi、for/do/done、while、case——这些不是命令,是 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 /不存在 都返回非零退出码,行为一致。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 那条路。这个顺序是本节的核心要点。
当用户敲一个命令名(比如 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 的某些「怪行为」。
对外部命令,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 自己的解析或执行行为。最典型的例子是 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 自己的进程里逐行跑脚本」,这样脚本里的 cd、export、变量赋值才会真正影响 Shell。如果你用 bash script.sh 跑脚本,那是 fork 一个子 Shell,脚本里的 cd 改的是子 Shell 的目录,父 Shell 不受影响——这是 source 和 bash 执行脚本的唯一本质差别。
关键概念:
source(或.)让脚本「在当前 Shell 进程里执行」,所以脚本的副作用(目录、变量、函数)会保留;而bash script.sh是「fork 子 Shell 执行」,副作用随子 Shell 退出消失。这就是为什么.bashrc必须用source ~/.bashrc重新加载,而不是bash ~/.bashrc——后者改的环境变量不会被当前 Shell 看到。
cd 和 exit这一节的最小目标,是在你的 Shell 里加上两个最基础的内建:cd 和 exit。
第一步:实现内建命令表和查找。定义 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完全不生效,提示符里的目录纹丝不动。这个实验能把「为什么必须内建」这个概念刻进你的骨头里,胜过读十遍理论。
cd(目录)、exit(退出)、export(环境)、alias(别名)都属于此类。cd 不能是外部程序的经典解释:外部命令在 fork 出的子进程里跑,子进程的目录改动随退出丢失,Shell 父进程目录不变。argv、都返回退出码、退出码都存入 $?,对用户透明。echo)既可内建又可外部:bash 出于性能和一致性把它做成内建,这就是 type echo 显示「shell builtin」的原因。cd /tmp | echo hi 不改变 Shell 目录——这是反直觉但正确的边界行为。builtin_cd:chdir 改 Shell 自己的目录。验证后,做一个「故意在子进程里 chdir」的对比实验,亲眼看到失效。builtin_exit:让 Shell 干净退出,而不是 fork 出退出的子进程。cd 之后提示符目录立即变化,这是「Shell 是带状态解释器」的可视证据。下一节(fork + exec 执行外部命令)处理上一节分叉的另一条路——当一条命令不是内建时(比如 ls、grep),Shell 必须 fork 出一个子进程,在子进程里 exec 那个程序,然后父进程 wait 它结束。这是 Unix 进程模型的灵魂原语,也是 Shell 执行外部命令的全部核心。