第 2 章 · 02 命令解析:词法与管道识别 本节摘要:上一节你的 Shell 能读一行字符串了,但那行字符串还是一坨原始文本( )——Eval 段要做的第一件事,是把它「拆」开。这一节是本章的难点之一:你要把一行字符串切成「命令 + 参数」,并识别其中的特殊符号( 、 、 、 、 ),最终把它拆成「管道段」和「重定向目标」的有序结构,为后续的 fork(每段一个子进程)与 dup2(每个重定向一次接线)做准备。本节导读词法切分(空格、引号、转义)、特殊符号识别、引号语义(单引号不展开、双引号展开)这三个层次,以及为什么「解析」看似枯燥却是 Shell 一切能力的入口——解析错了,fork 和 dup2 再正确也白搭。呼应《Linux 命令》第 1 章的「Shell 展开」。
本节摘要:上一节你的 Shell 能读一行字符串了,但那行字符串还是一坨原始文本(
ls -la | grep foo > out.txt)——Eval 段要做的第一件事,是把它「拆」开。这一节是本章的难点之一:你要把一行字符串切成「命令 + 参数」,并识别其中的特殊符号(|、>、<、&、;),最终把它拆成「管道段」和「重定向目标」的有序结构,为后续的 fork(每段一个子进程)与 dup2(每个重定向一次接线)做准备。本节导读词法切分(空格、引号、转义)、特殊符号识别、引号语义(单引号不展开、双引号展开)这三个层次,以及为什么「解析」看似枯燥却是 Shell 一切能力的入口——解析错了,fork 和 dup2 再正确也白搭。呼应《Linux 命令》第 1 章的「Shell 展开」。
内容来源:基于原索引「Build your own X」Shell 域相关条目整理的导读,原始教程为外部资源(如「Tutorial - Write a Shell in C」「Write a shell in C」等)。
阅读完本节,你应当能够:
|、> 等控制符。| 切成「管道段」,每段是一个独立的「命令 + 参数 + 重定向」结构。>、<、>> 并把它们与紧随其后的「目标文件名」绑定,供后续 dup2 使用。&(后台)与 ;(顺序分隔)这两个「命令级」控制符,它们影响的是「怎么调度命令」而非「命令内部怎么接线」。很多新手觉得「解析」是最枯燥的部分——不就是把字符串按空格切一下嘛?但一旦你动手写,会发现它藏着大量细节:引号里的空格不能切、反斜杠能让下一个字符变字面量、> 和 >> 是两个不同运算符、管道符 | 两边的空格可有可无。这些细节处理不好,你的 Shell 就会在「带空格的文件名」「echo "a | b"」这些边界 case 上崩溃。
更重要的是,解析的输出结构,直接决定了后续 fork 和 dup2 怎么做。举个例子:
ls -la | grep foo > out.txt
这一行,经过解析,必须变成这样的结构:
管道段 1: 命令=ls, 参数=[-la], stdin=终端, stdout=管道写端 管道段 2: 命令=grep, 参数=[foo], stdin=管道读端, stdout=重定向到 out.txt
只有解析成这种结构,第 04 节的 fork 才知道「要 fork 两个子进程」,第 05 节的 dup2 才知道「第二个子进程的 fd 1 要接到 out.txt」。解析是 Shell 一切能力的入口——解析错了,fork 再多、dup2 再准,跑出来的也是错的。
这也是为什么真实 Shell(bash、zsh)的解析器极其复杂——它们要处理变量展开、命令替换、通配符、算术展开、花括号展开……这些统称「Shell 展开」,正是《Linux 命令》第 1 章讲的内容。本节我们只做「最小子集」:词法切分 + 控制符识别 + 重定向目标提取。但理解了这个最小子集,你就掌握了 Shell 解析的骨架,后续那些花哨的展开都是在它之上叠加的。
关键概念:解析器是 Shell 的「翻译官」——它把人类写的命令行文本,翻译成执行器能直接消费的结构化数据。这个翻译质量决定了 Shell 的能力上限。本节做的是「骨架翻译」,第 07 节(脚本能力)会在它之上加「展开翻译」。
词法切分的任务是:把一行字符串,切成一个 token(单词)序列。最朴素的实现是 strtok(buf, " \t\n")——按空格、Tab、换行切。但它立刻会在两个地方崩溃:
echo "hello world" 应该切成 ["echo", "hello world"],而不是 ["echo", "\"hello", "world\""]。echo a\ b 应该切成 ["echo", "a b"],反斜杠让空格变成字面量。所以你必须手写一个状态机 tokenizer,而不是用 strtok。状态机的核心逻辑:
伪代码:tokenize(一行字符串) -> token 列表 state = NORMAL current_token = "" for each char c in 字符串: if state == NORMAL: if c 是空格/Tab: if current_token 非空: 把它存入 token 列表, 清空 current_token elif c == '\'': state = IN_SINGLE_QUOTE // 进入单引号模式 elif c == '"': state = IN_DOUBLE_QUOTE // 进入双引号模式 elif c == '\\': 读下一个字符 c', 把 c' 原样追加到 current_token (转义) else: current_token += c elif state == IN_SINGLE_QUOTE: if c == '\'': state = NORMAL // 单引号结束 else: current_token += c // 单引号内一切字面量 elif state == IN_DOUBLE_QUOTE: if c == '"': state = NORMAL elif c == '\\': 读下一个字符 c', 按规则处理 (双引号内只转义 $ ` " \ 四个字符) else: current_token += c // 双引号内变量展开留到第 07 节 行末把 current_token 存入列表
这个状态机只有三个状态(NORMAL、IN_SINGLE_QUOTE、IN_DOUBLE_QUOTE),但它能正确处理 90% 的日常命令。状态转换可以用下面这张图理解:
空格/Tab (结束当前 token) ┌─────────────────────────────┐ ▼ │ ┌─────────────┐ 遇到 ' ┌───────────────┐ │ NORMAL │ ────────> │ IN_SINGLE_QUO │ │ │ <──────── │ │ └─────────────┘ 遇到 ' └───────────────┘ │ │ │ 遇到 " │ ▼ │ ┌─────────────┐ 遇到 " ┌───────────────┐ │ IN_DOUBLE_ │ <──────── │ │ │ QUOTE │ ────────> │ (回 NORMAL) │ └─────────────┘ └───────────────┘
💡 学习建议:这个状态机一定要自己手写一遍。它看似麻烦,但代码不超过五十行。写完它,你对「字符串处理」的感觉会提升一个层次——这是系统编程的基本功。本书第 9 章(造编译器)会把这个状态机扩展成完整的词法分析器,这里是它的入门版。
单引号和双引号在 Shell 里语义不同,这是新手最容易混淆的点:
'...':里面的所有字符都是字面量。'$HOME' 就是 5 个字符 $HOME,'$(date)' 就是 7 个字符。单引号内没有任何展开。"...":里面的字符大部分是字面量,但变量 $var、命令替换 $(cmd)、反斜杠转义仍然会展开。"$HOME" 会变成 /home/user。这就是为什么第 07 节(脚本能力)里,推荐「变量引用总是加双引号」——既能展开变量,又能防止变量值里的空格被错误切分。本节你只需记住:单引号内不展开、双引号内会展开(展开的实现在第 07 节)。
对比一张表:
| 输入 | 单引号结果 | 双引号结果 |
|---|---|---|
echo '$HOME' | $HOME(字面量) |
(不适用) | |
echo "$HOME" |
(不适用) | /home/user(展开) |
echo "hello world" |
(不适用) | hello world(一个 token) |
echo 'a\rb' |
a\rb(字面量) |
(不适用) |
⚠️ 难点预警:引号不闭合是解析最常见的 bug。比如用户敲
echo "hello(忘了关引号),你的 tokenizer 必须能检测到「到达行末仍在 IN_DOUBLE_QUOTE 状态」,并报错「未闭合的引号」,否则会吞掉后续所有输入。记得在循环结束后检查 state 是不是 NORMAL,不是就报错。 真实 bash 在这种情况下会显示一个>续行提示符,等用户补完引号——这是更高级的交互,入门时直接报错即可。
tokenizer 输出的是一串 token。但 |、>、<、>>、&、; 这些控制符,目前还混在 token 序列里(它们被当成了普通 token)。语法识别的任务,是把它们识别出来,并把 token 序列组织成「结构」。
token 序列: ["ls", "-la", "|", "grep", "foo", ">", "out.txt"] 经过语法识别, 组织成: 管道段 1: argv = ["ls", "-la"] 管道段 2: argv = ["grep", "foo"], stdout_redir = "out.txt"
具体做法:遍历 token 序列,维护一个「当前管道段」的缓冲。每遇到一个控制符,就根据它做不同动作:
伪代码:parse(token 列表) -> 管道段列表 segments = [] current = { argv=[], stdin_redir=NULL, stdout_redir=NULL, append=false } for each token t: if t == "|": 把 current 存入 segments, 新开一个 current elif t == ">": 读下一个 token 作为 stdout_redir 目标文件 elif t == ">>": 读下一个 token 作为 stdout_redir 目标, append=true elif t == "<": 读下一个 token 作为 stdin_redir 来源文件 elif t == "&": 标记「整条管道要后台运行」(留给第 06 节用) elif t == ";": 把 current 存入 segments, 把 segments 存入「命令列表」 新开一段(分号意味着顺序执行两条独立命令) else: current.argv.append(t) 把最后一个 current 存入 segments return segments
关键概念:
>、<、>>是重定向运算符,它们后面紧跟的 token 不是参数,而是「目标文件名」。解析时要把这个 token 单独取出来绑定,不要让它混进argv——否则程序会收到一个莫名其妙的「out.txt」参数。这是新手常犯的错误:ls > out.txt跑出来,ls报错「找不到 out.txt 文件」——就是因为out.txt被当成参数塞给了ls,而不是被当成重定向目标。
真实 Shell 的解析器还要处理优先级:A && B || C、A | B && C | D……这些组合的语义靠运算符优先级决定。但入门时,你只需支持「一条管道 + 可选重定向 + 可选 &」这一最简单形态:
命令行 = 管道段 ('|' 管道段)* 重定向* ('&')? 管道段 = (参数)+ 重定向 = ('>' | '>>' | '<') 文件名
这个文法(用 BNF 写)覆盖了 ls -la | grep foo | wc -l > out.txt & 这类日常命令。等你把这层做扎实了,再考虑 &&/||(那是第 07 节脚本能力的事)。
一个容易踩的细节:控制符两边的空格是可选的。ls|grep、ls | grep、ls| grep 都是合法的。你的 tokenizer 要么把控制符单独切成一个 token(推荐做法,后续语法识别最简单),要么在语法识别阶段做更复杂的字符串匹配。前者更清晰,推荐。
无论你用结构体还是哈希表,解析的最终输出应当是这样的结构(供第 04、05 节消费):
CommandPipeline { segments: [ Segment { argv: ["ls", "-la"], redir_in: NULL, redir_out: NULL }, Segment { argv: ["grep", "foo"], redir_in: NULL, redir_out: "out.txt", append: false } ] background: false // 有没有 & 后台标记 }
第 04 节会遍历 segments,每段 fork 一个子进程;第 05 节会读 redir_in/redir_out,在子进程里用 dup2 接线。解析器和执行器的接口,就是这个结构——把它定义清楚,后续两节才能顺畅接入。
整体数据流: 用户输入 ──> [第 01 节] Read 一行 │ ▼ [第 02 节] tokenize ──> parse ──> CommandPipeline 结构 │ ▼ [第 03 节] 第一段是内建? 是 ──> 直接调用内建函数 │ 否 ▼ [第 04 节] 遍历 segments, 每段 fork + exec │ ▼ [第 05 节] 在每个子进程里, 按 redir 字段 dup2 接线 │ ▼ [第 06 节] 若 background=true, 父进程不 wait
这张图是整章的「地图」。本节的任务,就是把「CommandPipeline 结构」这条数据通道打通。
本节的实现建议分四步递进,每步都有清晰的验证点:
第一步:写 tokenizer。手写状态机,支持空格切分、单双引号、反斜杠转义。验证:echo "hello world" 切成 ["echo", "hello world"];echo a\ b 切成 ["echo", "a b"];引号不闭合时报错。
第二步:识别控制符。在 token 序列上遍历,把 |、>、<、>>、&、; 识别出来。先只做识别,不做结构化——验证你能正确区分「参数」和「控制符」。
第三步:组装成结构。把 token 序列组织成「管道段 + 重定向目标 + 后台标记」的结构。验证:ls -la | grep foo > out.txt 能被正确拆成两段,第二段的 stdout 重定向到 out.txt。
第四步(进阶):错误恢复。用户敲了非法语法(ls | | grep、ls >),你的解析器应当报错而不是崩溃或产生垃圾结构。这步让你的 Shell 从「能跑对输入」升级到「能抗住瞎敲」。
💡 学习建议:第一步的 tokenizer 是本节最难、也最有价值的部分。如果你卡住了,可以参考「Write a shell in C」这类教程里的
split_line函数——但务必理解它的状态机逻辑,而不是照抄。自己写一遍,这个技能会迁移到本书后续所有涉及字符串解析的章节(第 9 章造编译器尤其依赖)。
⚠️ 难点预警:本节最常见的 bug 是「重定向目标混进 argv」和「引号不闭合吞掉后续输入」。前者的症状是程序收到莫名其妙的文件名参数,后者的症状是 Shell 突然「卡住」不响应。遇到这两种症状,第一时间检查解析器。
strtok:引号内的空格、反斜杠转义,必须手写状态机 tokenizer 处理。argv。& 和 ; 是命令级控制符:& 标记整条管道后台运行(第 06 节用),; 分隔顺序命令。|/>/</&/;。> 后没有文件名等非法输入。下一节(内建命令与外部命令的差别)处理解析完成后立刻要做的「分叉」:cd、exit、export 这些命令,不能 fork 出子进程去做——因为它们改变的是 Shell 自己的状态。理解「为什么 cd 不能是外部程序」是本节解析输出的「第一类消费者」:解析完成后,Shell 要先判断这条命令是不是内建的。