第 2 章 · 02 命令解析:词法与管道识别


文档摘要

第 2 章 · 02 命令解析:词法与管道识别 本节摘要:上一节你的 Shell 能读一行字符串了,但那行字符串还是一坨原始文本( )——Eval 段要做的第一件事,是把它「拆」开。这一节是本章的难点之一:你要把一行字符串切成「命令 + 参数」,并识别其中的特殊符号( 、 、 、 、 ),最终把它拆成「管道段」和「重定向目标」的有序结构,为后续的 fork(每段一个子进程)与 dup2(每个重定向一次接线)做准备。本节导读词法切分(空格、引号、转义)、特殊符号识别、引号语义(单引号不展开、双引号展开)这三个层次,以及为什么「解析」看似枯燥却是 Shell 一切能力的入口——解析错了,fork 和 dup2 再正确也白搭。呼应《Linux 命令》第 1 章的「Shell 展开」。

第 2 章 · 02 命令解析:词法与管道识别

本节摘要:上一节你的 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」等)。

学习目标

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

  1. 说清「词法切分」与「语法识别」的差别:前者按空格/引号切出 token,后者在 token 序列里识别 |> 等控制符。
  2. 写出一个能处理空格、单引号、双引号、反斜杠转义的 tokenizer。
  3. 解释单引号与双引号的语义差别:单引号内全为字面量,双引号内仍会做变量展开(为第 07 节铺垫)。
  4. 把 token 序列按 | 切成「管道段」,每段是一个独立的「命令 + 参数 + 重定向」结构。
  5. 识别 ><>> 并把它们与紧随其后的「目标文件名」绑定,供后续 dup2 使用。
  6. 处理 &(后台)与 ;(顺序分隔)这两个「命令级」控制符,它们影响的是「怎么调度命令」而非「命令内部怎么接线」。
  7. 说清解析器与执行器之间的接口约定:解析输出的结构化数据,正是第 04、05 节消费的输入。

一、学习价值:解析是 Shell 一切能力的入口

很多新手觉得「解析」是最枯燥的部分——不就是把字符串按空格切一下嘛?但一旦你动手写,会发现它藏着大量细节:引号里的空格不能切、反斜杠能让下一个字符变字面量、>>> 是两个不同运算符、管道符 | 两边的空格可有可无。这些细节处理不好,你的 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 节(脚本能力)会在它之上加「展开翻译」。

二、子系统拆解:词法、语法、结构化

第一层:词法切分(tokenizer)

词法切分的任务是:把一行字符串,切成一个 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 章(造编译器)会把这个状态机扩展成完整的词法分析器,这里是它的入门版。

第二层:单引号 vs 双引号(为展开铺垫)

单引号和双引号在 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 在这种情况下会显示一个 > 续行提示符,等用户补完引号——这是更高级的交互,入门时直接报错即可。

第三层:语法识别(在 token 序列里找控制符)

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 || CA | B && C | D……这些组合的语义靠运算符优先级决定。但入门时,你只需支持「一条管道 + 可选重定向 + 可选 &」这一最简单形态:

命令行 = 管道段 ('|' 管道段)* 重定向* ('&')? 管道段 = (参数)+ 重定向 = ('>' | '>>' | '<') 文件名

这个文法(用 BNF 写)覆盖了 ls -la | grep foo | wc -l > out.txt & 这类日常命令。等你把这层做扎实了,再考虑 &&/||(那是第 07 节脚本能力的事)。

一个容易踩的细节:控制符两边的空格是可选的ls|grepls | grepls| 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 到结构化解析

本节的实现建议分四步递进,每步都有清晰的验证点:

第一步:写 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 | | grepls >),你的解析器应当报错而不是崩溃或产生垃圾结构。这步让你的 Shell 从「能跑对输入」升级到「能抗住瞎敲」。

💡 学习建议:第一步的 tokenizer 是本节最难、也最有价值的部分。如果你卡住了,可以参考「Write a shell in C」这类教程里的 split_line 函数——但务必理解它的状态机逻辑,而不是照抄。自己写一遍,这个技能会迁移到本书后续所有涉及字符串解析的章节(第 9 章造编译器尤其依赖)。

⚠️ 难点预警:本节最常见的 bug 是「重定向目标混进 argv」和「引号不闭合吞掉后续输入」。前者的症状是程序收到莫名其妙的文件名参数,后者的症状是 Shell 突然「卡住」不响应。遇到这两种症状,第一时间检查解析器。

本节要点回顾

  1. 解析分两层:词法(tokenizer 切 token)和语法(在 token 序列里识别控制符、组装结构)。
  2. 不能用 strtok:引号内的空格、反斜杠转义,必须手写状态机 tokenizer 处理。
  3. 单引号不展开、双引号会展开:本节只需记住语义差别,展开实现在第 07 节。
  4. 重定向运算符的下一个 token 是文件名:必须单独取出绑定,不能让它混进 argv
  5. &; 是命令级控制符:& 标记整条管道后台运行(第 06 节用),; 分隔顺序命令。
  6. 解析的输出是一个「管道段列表」结构:第 04 节按它 fork,第 05 节按它的 redir 字段 dup2——定义好这个接口是本节的关键产出。
  7. 引号不闭合要报错:tokenizer 结束时检查状态机是否回到 NORMAL,否则吞掉后续输入。

推荐上手顺序

  1. 先写 tokenizer:状态机处理空格、单双引号、反斜杠。验证引号内空格不切分、转义字符生效。
  2. 加上控制符识别:在 token 序列上区分参数与 |/>/</&/;
  3. 组装成结构:输出「管道段 + 重定向 + 后台标记」的结构体,供后续节消费。
  4. 测试边界 case:空行、只有空格的行、引号不闭合、> 后没有文件名等非法输入。
  5. 进入下一节(内建 vs 外部):解析有了,下一步要决定「这条命令是 Shell 自己做(fork 前),还是 fork 出子进程做」。

下一节(内建命令与外部命令的差别)处理解析完成后立刻要做的「分叉」:cdexitexport 这些命令,不能 fork 出子进程去做——因为它们改变的是 Shell 自己的状态。理解「为什么 cd 不能是外部程序」是本节解析输出的「第一类消费者」:解析完成后,Shell 要先判断这条命令是不是内建的。


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