我们要把编译器处理 2 + 3 * 4 的全过程拆解到“原子级”的精细度。
第一步:词法分析 (Lexical Analysis)
任务:将字符流切分成 Token(词法单元)。
- 输入 (Input):字符串
"2 + 3 * 4" - 推导过程:
- 指针指向
2:识别为数字,生成Token(NUM, 2)。 - 指针指向
+:识别为加号,生成Token(PLUS, '+')。 - 指针指向
3:识别为数字,生成Token(NUM, 3)。 - 指针指向
*:识别为乘号,生成Token(STAR, '*')。 - 指针指向
4:识别为数字,生成Token(NUM, 4)。
- 指针指向
- 输出 (Output):一个 Token 序列(数组):
Tokens = [T1(NUM,2), T2(PLUS,+), T3(NUM,3), T4(STAR,*), T5(NUM,4)]
第二步:定义语法规则 (Grammar Rules)
在开始分析前,必须先定好“剧本”。为了处理优先级,我们使用 EBNF 规则:
- Expression (加法层):
E -> T { + T }(一个 T 后面跟着 0 到多个 "+ T") - Term (乘法层):
T -> F { * F }(一个 F 后面跟着 0 到多个 "* F") - Factor (基础层):
F -> NUMBER | ( E )(数字或者括号里的表达式)
第三步:语法分析与语法树生成 (Parsing & AST Generation)
这是你要求的重点。递归下降会通过函数调用栈,一边匹配 Token,一边像搭积木一样返回节点。
- 输入:上一步的 Token 序列。
- 当前 Token 指针:指向
T1(NUM,2)。
详细推导步骤:
- 入口:调用
parse_E()。 - 下降到乘法层:
parse_E第一步就是调用parse_T()。 - 下降到基础层:
parse_T第一步就是调用parse_F()。 - 识别数字 2:
parse_F看到T1(NUM,2),匹配成功。- 生成节点:创建一个叶子节点
Node(val:2)。 - 返回:
parse_F将该节点向上返回给parse_T。
- 尝试乘法循环:
parse_T拿到Node(2),看下一个 Token 是T2(PLUS,+)。parse_T的规则是T -> F { * F },它不认识+,所以循环不执行。- 返回:
parse_T将Node(2)向上返回给parse_E。
- 识别加法循环:
parse_E拿到Node(2),看下一个 Token 是T2(PLUS,+)。- 匹配成功:
parse_E认识+,进入循环。 - 保存左手:它把
Node(2)存为Left。 - 匹配右手:它跳过
+,再次调用parse_T()来解析加号右边的部分。
- 右侧下降:
- 新的
parse_T调用parse_F,匹配到T3(NUM,3)。 parse_F返回Node(3)给parse_T。
- 新的
- 右侧乘法循环:
parse_T拿到Node(3),看下一个 Token 是T4(STAR,*)。- 匹配成功:
parse_T进入循环! - 保存乘法左手:把
Node(3)存为Mul_Left。 - 匹配乘法右手:跳过
*,再次调用parse_F()。 parse_F匹配到T5(NUM,4),返回Node(4)。
- 生成乘法子树:
parse_T循环结束,创建一个乘法节点:Node(op:*, left:Node(3), right:Node(4))。- 返回:这个乘法子树被返回给了
parse_E。
- 生成最终加法树:
parse_E现在手里有左手的Node(2)和刚才拿到的右手MulNode(*)。- 创建一个最终的加法节点。
语法分析推导详表
准备工作:
- 输入 Token 流:
[2],[+],[3],[*],[4],[EOF] - 语法规则简记:
E(加法层) ->T {+ T}T(乘法层) ->F {* F}F(基础层) ->数字
- 核心逻辑:函数调用 = 下降;函数返回 = 上升/回溯。
| 步骤 | 当前指针 (Token) | 执行动作 (函数调用栈) | 说明 | 生成的中间结果 (AST片段) |
|---|---|---|---|---|
| 1 | [2] + 3 * 4 | 调用 parse_E() | 从最顶层“加法层”开始解析 | (等待 T 返回) |
| 2 | [2] + 3 * 4 | E 内部调用 parse_T() | 加法是由项组成的,下降到“乘法层” | (等待 F 返回) |
| 3 | [2] + 3 * 4 | T 内部调用 parse_F() | 乘法是由因子组成的,下降到“基础层” | (寻找数字) |
| 4 | [2] + 3 * 4 | F 匹配到数字 2 | 匹配成功!指针右移到 + | 返回 Node(2) |
| 5 | 2 [+] 3 * 4 | F 执行完毕,返回 T | F 把数字 2 交给“上级” T | T 拿到 Node(2) |
| 6 | 2 [+] 3 * 4 | T 检查下一个 Token | 下一个是 +,T 只管 *。循环不成立 | T 完成,返回 Node(2) |
| 7 | 2 [+] 3 * 4 | T 执行完毕,返回 E | T 把 2 交还给“最上级” E | E 拿到左手 Node(2) |
| 8 | 2 [+] 3 * 4 | E 检查下一个 Token | 下一个是 +,匹配成功!进入循环 | 指针右移到 3 |
| 9 | 2 + [3] * 4 | E 调用 parse_T() | E 要找加号右边的“项”,再次下降 | (等待右侧 T 返回) |
| 10 | 2 + [3] * 4 | T 内部调用 parse_F() | 同样,右侧的项也要先找基础因子 | (寻找数字) |
| 11 | 2 + [3] * 4 | F 匹配到数字 3 | 匹配成功!指针右移到 * | 返回 Node(3) |
| 12 | 2 + 3 [*] 4 | F 执行完毕,返回 T | F 把 3 交给右侧的 T | T 拿到左手 Node(3) |
| 13 | 2 + 3 [*] 4 | T 检查下一个 Token | 下一个是 *,匹配成功!进入循环 | 指针右移到 4 |
| 14 | 2 + 3 * [4] | T 内部调用 parse_F() | T 要找乘号右边的因子,再次下降 | (寻找数字) |
| 15 | 2 + 3 * [4] | F 匹配到数字 4 | 匹配成功!指针右移到 EOF | 返回 Node(4) |
| 16 | 2 + 3 * 4 [EOF] | F 返回 T,T 结束循环 | T 把 3 和 4 缝合。返回给 E | 返回 Node(*, 3, 4) |
| 17 | 2 + 3 * 4 [EOF] | E 拿到右边结果,结束循环 | E 把左手的 2 和右手的 3*4 缝合 | *返回 Node(+, 2, ) |
| 18 | 解析完成 | 根节点已生成 | 此时系统栈清空,拿到整棵语法树 | 最终 AST 根节点 |
关键细节剖析:
-
为什么
+不会先被处理?- 看步骤 5-7:虽然指针到了
+,但parse_T和parse_F发现自己处理不了,会逐层把结果“向上抛”。只有回到parse_E时,+才会被正式认领。这体现了优先级。
- 看步骤 5-7:虽然指针到了
-
为什么
*会被先缝合?- 看步骤 13-16:当
parse_T拿到3发现后面是*时,它没有急着把3还给加法层,而是自己扣下了3并继续往下找4。这保证了乘法节点先于加法节点组装完成。
- 看步骤 13-16:当
-
递归的“高度”:
- 在步骤 9,
parse_E并没有直接找数字,而是再次启动了完整的parse_T流程。这种“重新开始”的机制,使得程序可以处理无限复杂的结构(比如2 + 3 * 4 + 5 * 6...)。
- 在步骤 9,
最终生成的 AST 结构:
(+) <-- 这是步骤 17 生成的根
/ \
(2) (*) <-- 这是步骤 16 生成的子树
/ \
(3) (4)
第四步:代码生成 (Code Generation)
任务:将 AST 转换成指令序列。
-
输入:生成的 AST。
-
方法:对树进行后序遍历 (左-右-中)。
-
推导过程:
- 遍历根节点
+:先去左边。 - 左边是
2:叶子节点,输出PUSH 2。 - 回到
+:再去右边*。 - 遍历
*:先去左边3。 - 左边是
3:输出PUSH 3。 - 回到
*:再去右边4。 - 右边是
4:输出PUSH 4。 - 回到
*节点:左右都完了,输出自己:MUL。 - 最后回到根节点
+:左右都完了,输出自己:ADD。
- 遍历根节点
-
输出 (指令集):
PUSH 2 PUSH 3 PUSH 4 MUL ADD
第五步:指令执行 (Execution)
任务:模拟 CPU 运行这些指令。
- 输入:指令序列。
- 推导过程:
PUSH 2: 栈 =[2]PUSH 3: 栈 =[2, 3]PUSH 4: 栈 =[2, 3, 4]MUL: 弹出4, 3,计算3 * 4 = 12,压回。栈 =[2, 12]ADD: 弹出12, 2,计算2 + 12 = 14,压回。栈 =[14]
- 输出:结果
14。
细节总结:
- 语法分析的本质:是通过函数嵌套调用,利用“系统调用栈”记录了还未处理完的优先级。
- AST生成的关键:每个
parse_X函数在退出前,必须把自己的成果(节点)包好交给“上级”。 - 优先级的实现:因为
parse_E必须等parse_T返回,而parse_T内部可能处理了*。这意味着乘法节点在树中被创建的时间点比加法节点更早、位置更深,从而保证了先算乘法。