第 2 章 · 07 给 Shell 加上脚本能力


文档摘要

第 2 章 · 07 给 Shell 加上脚本能力 本节摘要:到目前为止,你的 Shell 已经能跑外部命令、内建命令、管道、重定向、后台作业——它是一个好用的「交互式命令执行器」。但它还不是一门「语言」。这一节是本章的收尾,你要给 Shell 加上三样东西:变量( 、 )、退出码查询( )、条件运算符( 、 )。这三样让 Shell 从「执行器」升级为「可编程脚本语言」——你可以写 ,让 Shell 根据条件做不同的事。本节导读变量存储(Shell 内部的一张名字→值的表)、变量展开(在第 02 节 tokenizer 之后、第 04 节 fork 之前插入的「展开阶段」)、退出码传播(每条命令执行完都更新 )、以及 / 的短路求值。

第 2 章 · 07 给 Shell 加上脚本能力

本节摘要:到目前为止,你的 Shell 已经能跑外部命令、内建命令、管道、重定向、后台作业——它是一个好用的「交互式命令执行器」。但它还不是一门「语言」。这一节是本章的收尾,你要给 Shell 加上三样东西:变量(x=5$x)、退出码查询($?)、条件运算符(&&||)。这三样让 Shell 从「执行器」升级为「可编程脚本语言」——你可以写 test -f config && build || echo "no config",让 Shell 根据条件做不同的事。本节导读变量存储(Shell 内部的一张名字→值的表)、变量展开(在第 02 节 tokenizer 之后、第 04 节 fork 之前插入的「展开阶段」)、退出码传播(每条命令执行完都更新 $?)、以及 &&/|| 的短路求值。呼应《Linux 命令》第 9 章——那章是「用 Shell 脚本」,这章是「造 Shell 的脚本能力」。

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

学习目标

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

  1. 实现变量赋值(x=5)与变量引用($x):Shell 内部维护一张变量表,赋值是改表,引用是查表。
  2. 区分「Shell 变量」与「环境变量」:export 把 Shell 变量提升为环境变量,子进程能继承;普通变量只在 Shell 进程内可见。
  3. 实现 $? 退出码查询:每条命令(内建或外部)执行完,把退出码存入这个特殊变量。
  4. 实现 &&/|| 短路求值:A && B 表示「A 成功才跑 B」,A || B 表示「A 失败才跑 B」。
  5. 把变量展开插到解析流程的正确位置:在第 02 节 tokenizer 之后、fork 之前,扫一遍 token 把 $xxx 替换成变量值。
  6. 支持从脚本文件执行(mysh script.sh):把脚本文件逐行读入、当作交互输入处理,Shell 从「交互模式」切到「脚本模式」。
  7. 说清 Shell 脚本语言与 Python/Lua 的同构性——都是「读 → 解析 → 展开 → 执行 → 循环」,本节是本书第 9 章(造编译器)的热身。

一、学习价值:从执行器到编程语言

到目前为止,你的 Shell 每敲一行,执行一行——它没有「状态延续」,前一行算的结果,后一行用不上。这在交互式用法下没问题,但一旦你想把一连串命令固化成「脚本」,立刻遇到障碍:

  • 你想记录一个路径供后续多条命令用:PREFIX=/opt/myapp,后面写 cd $PREFIX/bincp x $PREFIX/lib
  • 你想根据上一步的结果决定下一步:make && ./test(编译成功才跑测试)、ping host || echo "host down"
  • 你想批量重复:for f in *.txt; do wc -l $f; done
  • 你想把一段流程写成文件,下次直接 bash deploy.sh 一键跑——而不是每次重新敲二十行命令。

这三类需求——变量、条件、循环——正是「编程语言」的核心特征。把它们加进 Shell,它就从「敲一条跑一条的命令执行器」,升级成「可编程的脚本语言」。这正是《Linux 命令》第 9 章的主题——只不过那章教你怎么 Shell 脚本,这章教你怎么 Shell 的脚本能力。

更重要的是,理解 Shell 的脚本能力是怎么搭出来的,你会对「脚本语言的本质」有更深的洞察:所谓脚本语言,无非是「在原本的命令执行循环里,塞进变量存储、展开阶段、控制结构」。一旦你把这套机制看穿,你会发现 Python、Lua、JavaScript 的解释器,骨架和 Shell 高度同构——都是「读 → 解析 → 展开 → 执行 → 循环」。本书第 9 章(造编译器)会更深入地展开这个主题,本节是它的「热身」。

关键概念:Shell 升级为脚本语言,靠的不是某个魔法特性,而是「在执行循环里多塞两个阶段」——展开阶段(把 $x 替换成变量值)和控制阶段(根据 $? 决定下一条跑不跑)。这两个阶段都很短小,但它们让 Shell 获得了「可编程」的全部力量。

二、子系统拆解:变量、展开、退出码、条件

变量存储:Shell 内部的一张表

变量的实现极其朴素——Shell 进程内存里维护一张「名字 → 值」的哈希表(或链表)。赋值 x=5 就是往表里插一条 x → 5(或更新已有的);引用 $x 就是查表取出值。没有任何魔法。

伪代码:变量表 struct Var { string name; string value; bool exported; }; vars = [] // Shell 的变量表 builtin_assign(name, value): if 已存在 name: 更新它的 value else: 新增一条 // 注意: 赋值本身是内建操作, 不 fork

注意几点:

  • 赋值是内建操作:x=5 改的是 Shell 自己的变量表,不能 fork(呼应第 03 节——内建改 Shell 自己状态)。
  • 赋值不经过解析的「命令」路径:x=5 不是「命令 + 参数」,你的解析器要识别「名字=值」这种特殊形态,直接走赋值路径。具体做法:在解析时,如果第一个 token 匹配「^[A-Za-z_][A-Za-z0-9_]*=.*」(变量名后紧跟等号),就识别为赋值。
  • 变量表是 Shell 进程的内存数据:子进程 fork 时能继承环境变量(见下),但 Shell 变量本身只在 Shell 进程可见。

变量名遵循常规编程语言的命名规则:字母/下划线开头,后跟字母/数字/下划线。xPATHMY_VAR_1 都合法,1x(数字开头)、my-var(含连字符)不合法。

Shell 变量 vs 环境变量

这是新手最容易混淆的概念,务必讲清:

  • Shell 变量:只存在于 Shell 进程内存里。子进程看不到。x=5 默认就是 Shell 变量。
  • 环境变量:存在于 Shell 进程的「环境」里(environ 指针指向的一张表),子进程 fork 时自动继承(fork 复制进程,环境表也复制一份;exec 不替换环境表)。外部命令通过 getenv 读到的就是环境变量。

export 命令的作用,是把一个 Shell 变量提升为环境变量——之后 fork 出去的子进程就能继承它了。这就是为什么第 03 节里 export 必须是内建:它改的是 Shell 自己的环境表,fork 出去改没用。

x=5 // Shell 变量, 子进程看不到 export x // 现在提升为环境变量, 子进程能继承 ./myprogram // myprogram 里 getenv("x") 能拿到 "5"
┌─────────────────────────────────┐ │ Shell 进程 (PID=100) │ │ │ │ 变量表 (Shell 内存): │ │ x = "5" (exported=true) │ <── export 后, 同时进入环境表 │ y = "hi" (exported=false) │ │ │ │ 环境表 (environ 指针): │ │ x = "5" │ <── 子进程 fork 时复制这张表 │ PATH = "/usr/bin:..." │ │ HOME = "/home/user" │ └─────────────────────────────────┘ │ fork ▼ ┌─────────────────────────────────┐ │ 子进程 (PID=101) │ │ │ │ 环境表 (从父进程继承的副本): │ │ x = "5" ✓ 能读到 │ │ PATH = "..." ✓ │ │ HOME = "..." ✓ │ │ (y 不在! Shell 变量没继承) │ └─────────────────────────────────┘

💡 学习建议:做完本节后,做一个实验来固化这个概念——x=5 不 export,然后跑 bash -c 'echo $x'(在子 Shell 里读),会看到空;再 export x 后跑同样的命令,会看到 5。这个对实验比读十遍文档都管用。

变量展开:插在解析之后的「替换阶段」

变量引用 $x 怎么工作?关键在于展开发生的时机:它必须在第 02 节的 tokenizer 之后(因为变量名要在引号外才识别),但在第 04 节的 fork 之前(因为子进程要在展开后的最终命令上 exec)。

具体做法:在解析器输出 token 序列后,再扫一遍,把每个 token 里的 $xxx 替换成变量表里 xxx 的值。这个步骤叫「展开」。

伪代码:变量展开 expand_tokens(tokens): for each token t in tokens: t = 替换 t 里所有 $name 为 vars.lookup(name) 的值 // 注意: 单引号内的 $name 不展开(第 02 节的引号语义) return tokens 整体流程变为: line = Read() tokens = tokenize(line) // 第 02 节 tokens = expand_tokens(tokens) // <— 本节新增的展开阶段 segments = parse(tokens) // 第 02 节 execute(segments) // 第 03-06 节

注意单双引号的语义差别在这里生效:单引号内的 $name 不展开(字面量),双引号内的 $name 会展开。这要求你的展开器知道每个 token 当初是哪种引号上下文里的——一种简化做法是在 tokenizer 阶段给每个 token 标记它是否经过单引号,展开时跳过这些标记。

展开除了变量,还有几种常见形式(本节可选实现):

  • $$:当前 Shell 的 PID。
  • $?:上一条命令的退出码(见下)。
  • $#/$1/$2:脚本的位置参数(脚本模式才用得上)。
  • $(cmd):命令替换——先跑 cmd,把它的输出替换进来(进阶,本节可不实现)。

⚠️ 难点预警:展开的顺序很容易出错。真实 bash 的展开有七八种(花括号展开、波浪号展开、变量展开、命令替换、算术展开、单词切分、路径展开……),顺序还有讲究——比如变量展开后如果值里有空格,还要做「单词切分」重新切成多个 token。本节你只需实现「变量展开」一种,但理解「展开是一个独立阶段」这个架构,为后续进阶打下基础。一个常见的坑:x="a b"; echo $x 会打印成两行(ab),因为展开后做了单词切分——而 echo "$x" 打印一行。这就是为什么 bash 文档总强调「变量引用要加双引号」。

退出码 $?

$? 是个特殊变量,它存「上一条命令的退出码」。实现极其简单——Shell 维护一个全局变量 last_exit_code,每条命令(无论内建还是外部)执行完,都把它的退出码存进去。

伪代码:退出码传播 execute(segment): exit_code = ... (内建返回值 或 waitpid 取到的子进程退出码) last_exit_code = exit_code // <— 更新 $? // 用户敲 $? 时, 展开器把 $? 替换成 last_exit_code 的字符串形式

这就解释了第 04 节为什么强调「父进程要把 wait 到的退出码存起来」——它就是为 $? 用的。同时也解释了为什么 echo $? 能告诉你「上条命令成没成功」——上条命令的退出码被存进 last_exit_code,echo 通过展开 $? 读到它。

退出码的约定俗成(可沿用 GNU 的规范):

  • 0:成功。
  • 1:一般性失败。
  • 2:用法错误(参数不对)。
  • 126:命令不可执行(没权限)。
  • 127:命令找不到(第 04 节 exec 失败)。
  • 128 + N:被信号 N 杀死(如 137 = 被 SIGKILL 杀,130 = 被 SIGINT/Ctrl+C 杀)。

test 命令(或 [)是退出码机制的最大消费者——它不输出任何东西,只通过退出码告诉你「条件成不成立」:test -f file 文件存在返回 0、不存在返回 1。这就是 &&/|| 能做条件判断的根基——test 把「判断」转成「退出码」,&&/|| 再根据退出码决定跑不跑下一条。整条链路是:条件 → test 的退出码 → $?&&/|| 的分支。

短路求值的实战模式

理解了 &&/|| 的短路语义,你会发现 Shell 脚本里大量模式都是它的组合:

模式 含义
mkdir build && cd build 建目录成功才进去(失败则停在原地, 不会 cd 到不存在的目录)
`test -f config
ping -c1 host && deploy 通才部署
`cmd
cd $DIR && rm -rf * | 进了目录才删(经典安全写法, 防 $DIR 为空时删错地方)
make 2>&1 | grep error || echo "clean build" 有错报错, 没错报「干净」

最后一条展示了 &&/|| 与管道的混合用法——管道(|)是「命令内部」的数据流连接,&&/|| 是「命令之间」的控制流连接,两者优先级不同:管道先结合,所以 A | B && C 等价于 (A | B) && C。这也是第 02 节解析器要正确处理「控制符优先级」的原因。

脚本模式与 shebang

当你的 Shell 支持从文件读入执行后,就可以把一组命令固化成「脚本」。脚本文件第一行通常是 shebang:

#!/usr/bin/env mysh

这一行告诉内核:用 mysh 这个解释器来跑本文件。内核在 execve 脚本时,会读这行、把解释器路径和脚本路径拼成 execve("/usr/bin/env", ["mysh", "script.sh"])。你的 Shell 收到脚本路径后,逐行读入执行——和交互模式唯一的区别是不打印提示符(第 01 节讲的脚本模式)。

入门时你可以先不解析 shebang(直接当成注释跳过),但理解它的存在有助于解释「为什么 ./script.sh 能直接跑」——靠的就是内核读 shebang、自动调用解释器。

条件运算符 && / ||

有了 $?,条件运算符就好理解了。A && B 的语义是:先跑 A,如果 A 成功(退出码 0),再跑 B;否则跳过 BA || B 反过来:A 失败才跑 B。这就是编程语言里「短路求值」的标准定义。

伪代码:条件执行 execute_conditional(segments_and_ops): // segments_and_ops 形如 [segA, "&&", segB, "||", segC] last = execute(segA) // 跑 A, 拿退出码 for each (op, seg) in 后续: if op == "&&": if last == 0: // A 成功才跑 last = execute(seg) // 否则跳过, last 保持不变 elif op == "||": if last != 0: // A 失败才跑 last = execute(seg) // 否则跳过

这要求你的解析器(第 02 节)识别出 &&/|| 是「命令级」运算符(和 | 管道符不同——| 是「命令内部」连接,&& 是「命令之间」连接),并把命令行组织成「段 + 运算符」的序列,交给执行器按短路规则跑。

💡 学习建议:&&| 的差别是新手的大坑。记住:|数据流连接(A 的输出喂给 B 的输入),&&控制流连接(A 成功才决定跑不跑 B)。两者完全不同,不能混用。一个验证对比:ls | grep foo(ls 的输出喂给 grep)vs ls && grep foo file(ls 成功后跑 grep,grep 读的是 file 不是 ls 的输出)。

实战中,&&/|| 的组合极其常用,比如:

  • mkdir build && cd build(建目录成功才进去)
  • test -f config || echo "config missing"(没有就报)
  • ping -c1 host && deploy || echo "host down"(通的才部署,否则报警)
  • cd $DIR && rm -rf *(进了目录才删——防 $DIR 为空时删错地方,这是经典的安全写法)

这些「条件式命令链」让 Shell 从「执行器」变成「能写小逻辑的语言」。

(进阶)循环与函数

ifforwhile、函数定义,属于脚本能力的进阶部分,本节不必全部实现。但理解它们的实现路径很重要:它们都是 Shell 的内建关键字,在解析阶段被识别为「语法结构」,在执行阶段按结构控制流程。比如 for x in a b c; do ...; done,解析器要识别出这是一个循环结构,执行器要遍历 a b c、每次给 x 赋值、跑一次循环体。这已经接近「写一个完整的解释器」,本书第 9 章(造编译器)会深入这个主题。

入门时,你可以先只实现 &&/|| 这种「行内条件」,跑通常见的链式命令(make && make testping host || echo down)。等到这点稳固了,再挑战 if/for 这些块结构——那需要你的解析器支持多行结构(读到 do 要继续读到 done),复杂度上一个台阶。

三、上手第一步:从变量到条件,逐层叠加

本节的能力建议分四步递进,每步都是独立可用的功能:

第一步:实现变量赋值与 $x 展开。在变量表里存,在展开阶段替换。验证:x=hello; echo $x 打印 hello;echo "value is $x"(双引号)展开、echo 'value is $x'(单引号)不展开。

第二步:实现 $? 退出码。维护 last_exit_code,每条命令后更新。验证:ls /不存在; echo $? 打印非零;true; echo $? 打印 0false; echo $? 打印 1

第三步:实现 &&/||。解析器识别这两个运算符,执行器按短路规则跑。验证:true && echo yes 打印 yesfalse && echo yes 什么都不打印、false || echo no 打印 notrue || echo no 什么都不打印。

第四步:支持脚本文件执行。Shell 启动时检查 argv[1] 是不是文件名,是就打开它,逐行当作交互输入处理(但不打印提示符——这就是第 01 节讲的「脚本模式」)。验证:mysh build.sh 能跑一个包含多条命令和条件的脚本。脚本文件第一行的 #!/bin/bash 叫 shebang,内核靠它决定用哪个解释器跑这个文件——你的 Shell 也可以读取并忽略它(或识别为自己的 shebang)。

第五步(进阶):实现 export 与环境变量继承。把 Shell 变量提升为环境变量(setenv),fork 出的子进程能继承。验证:export FOO=bar; ./myprogram(myprogram 里读 FOO)能拿到 bar

💡 学习建议:这一节的每一步都建立在前几节的基础上——变量表是 Shell 进程的内存数据(第 01 节的 Shell 进程概念)、展开插在解析之后(第 02 节)、$? 来自退出码(第 04 节的 wait)、export 是内建(第 03 节)。当你把这些能力一层层叠加上去,会发现它们自然咬合,没有任何「硬塞」的感觉——这就是 Shell 架构的优雅之处。

⚠️ 难点预警:脚本模式下,一行命令可能跨多行(比如 for 循环体),也可能一行里写多条命令(a; b; c)。你的解析器要处理这两种情况。入门时可以先只支持「一行一条命令」,把多行结构和分号留到进阶。

本节要点回顾

  1. 变量是一张 Shell 进程内的表:x=5 是内建赋值操作(改 Shell 自己状态,不 fork),$x 是查表读取。
  2. Shell 变量 vs 环境变量:前者只在 Shell 进程内可见,后者通过 export 提升后子进程能继承(因为 fork 复制环境表)。
  3. 展开是一个独立阶段:插在 tokenizer 之后、fork 之前,把 $xxx 替换成变量值;单引号内不展开、双引号内展开。
  4. $? 是特殊变量:存上一条命令的退出码,每条命令执行完都更新——这就是第 04 节 wait 到的退出码的最终归宿。
  5. &&/|| 是控制流运算符:与 |(数据流连接)完全不同;&& 成功才跑下一条,|| 失败才跑下一条,标准短路求值。
  6. 脚本模式:从文件逐行读入、不打印提示符、跑完退出;mysh script.sh 让 Shell 从交互工具变成脚本解释器。
  7. 本节的架构洞见:Shell 升级为脚本语言,靠的是「在执行循环里塞进展开阶段和控制阶段」——这套骨架和 Python/Lua 等脚本语言同构,是本书第 9 章(造编译器)的热身。

推荐上手顺序

  1. 先实现变量表与 $x 展开:赋值是内建,展开插在解析后。验证单双引号语义差别。
  2. 实现 $?:维护 last_exit_code,每条命令后更新。
  3. 实现 &&/||:解析器识别运算符,执行器按短路规则跑。
  4. 支持脚本文件执行:从文件读入逐行处理,切入「脚本模式」。
  5. (进阶)实现 export 与环境变量继承:让子进程能读到 Shell 设的变量。
  6. (可选)挑战 if/for/while:需要解析器支持多行结构,这是迈向「完整脚本语言」的一步,也为本书第 9 章做铺垫。

至此,第 2 章「造一个 Shell」的七个小节全部完成。回顾整章路径:你从一个空壳的 REPL(第 01 节)起步,加上解析(第 02 节)、区分内建与外部(第 03 节)、实现 fork+exec(第 04 节)、接上管道与重定向(第 05 节)、加上作业控制(第 06 节)、最后给它脚本能力(第 07 节)——一个能用、好用、甚至能编程的小 Shell 就在你手里成型了。这七节构成的架构,正是 bash、zsh、fish 这些工业级 Shell 的共同骨架。带着这套理解,你可以进入第 3 章(造命令行工具),那是 Shell 的近亲——CLI 工具用到的参数解析、stdin/stdout、退出码,都是你在这章亲手实现过的东西。


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