目标:理解词法/语法分析原理,掌握数据流分析和支配分析的框架——这是优化和后端的数学基础。
今天的内容分两大块:上午用龙书建立前端理论(源码→AST→IR),下午用 LLVM 书建立分析框架(数据流+支配+循环)。后者是 Day3 写优化 Pass、Day4 做寄存器分配的前置知识。
上午:前端技术(龙书第 3-5 章)
1. 词法分析(第 3 章)—— 把字符流切成 Token
1.1 词法分析器长什么样
编译器看到的是原始字符流,词法分析器(Lexer/Scanner)把它切成有意义的 Token:
输入字符流: "x = 42 + y\n"
输出Token流: IDENT(x) ASSIGN NUMBER(42) PLUS IDENT(y)
每个 Token 是一个二元组 (类型, 值):
// Token 的数据结构(C 语言表示)
typedef struct {
int type; // IDENT, NUMBER, PLUS, IF, WHILE, ...
char *lexeme; // 原始字符串,比如 "42", "foo"
int line; // 行号(用于报错)
} Token;
1.2 核心管线:正则 → NFA → DFA → 最小化 DFA
为什么不能直接在源码上搜字符串?因为 Token 是正则语言——恰好能用有限自动机精确识别:
正则表达式 ──Thompson构造──→ NFA(非确定有限自动机)
│
│ 子集构造法
↓
DFA(确定有限自动机)
│
│ Hopcroft算法
↓
最小化 DFA
第一步:从正则表达式构造 NFA(Thompson 构造法)
规则只有 5 条,每条按图拼接:
基本规则:
ε(空串): (s0)──ε──→((s1))
字符 a: (s0)──a──→((s1))
复合规则:
串联 st: 连接 NFA(s) 的终态到 NFA(t) 的初态(用 ε 边)
选择 s|t: 新增初态,用 ε 边分叉到 NFA(s) 和 NFA(t) 的初态
星号 s*: 新增初态,用 ε 边到达 NFA(s) 初态;NFA(s) 终态再 ε 回初态
以正则 a(b|c)* 为例,Thompson 构造过程:
Step 1: 'a' → (0)──a──→(1)
Step 2: 'b' → (2)──b──→(3)
Step 3: 'c' → (4)──c──→(5)
Step 4: 'b|c' → (6)──ε──→(2)──b──→(3)──ε──→(7)
└──ε──→(4)──c──→(5)──ε──→┘
Step 5: '(b|c)*' → 用两个新状态 + 4条ε边做 Kleene 星
Step 6: 'a(b|c)*' → 串联 Step1 结果 和 Step5 结果
第二步:NFA → DFA(子集构造法)
NFA 的问题是:同一个状态下读同一个字符,可能有多条出路——程序无法直接执行"猜测"。
子集构造法的思想:把 NFA 状态集合作为一个 DFA 状态。
def subset_construction(nfa, alphabet):
"""用 Python 实现子集构造法"""
start_set = epsilon_closure(nfa, {nfa.start})
dfa_states = {frozenset(start_set): 0} # 状态集 -> DFA状态编号
dfa_trans = {} # (dfa_state, char) -> dfa_state
worklist = [start_set]
while worklist:
S = worklist.pop()
s_id = dfa_states[frozenset(S)]
for c in alphabet:
# move(S, c): 从 S 中任一状态经字符 c 能到达的状态集合
T = set()
for state in S:
if (state, c) in nfa.transitions:
T.add(nfa.transitions[(state, c)])
# 对 T 中每个状态做 ε-闭包
T = epsilon_closure(nfa, T)
if not T:
continue
t_fs = frozenset(T)
if t_fs not in dfa_states:
dfa_states[t_fs] = len(dfa_states)
worklist.append(T)
dfa_trans[(s_id, c)] = dfa_states[t_fs]
return dfa_states, dfa_trans
def epsilon_closure(nfa, states):
"""从 states 出发,只走 ε 边能到达的所有状态"""
stack = list(states)
closure = set(states)
while stack:
s = stack.pop()
for t in nfa.epsilon_transitions.get(s, []):
if t not in closure:
closure.add(t)
stack.append(t)
return closure
子集构造出来的 DFA 状态数最多是 2^N(N = NFA 状态数),实际通常远小于上限。
第三步:DFA 最小化(Hopcroft 算法)
核心思想:不断把"行为不同"的状态分开(等价类划分)。
def hopcroft_minimize(dfa):
"""Hopcroft 算法:把等价状态合并"""
# 初始划分:终态一组,非终态一组
P = [dfa.accepting, dfa.states - dfa.accepting]
W = [dfa.accepting] # 工作队列
while W:
A = W.pop() # 分裂子
for c in dfa.alphabet:
# X = {s | δ(s,c) ∈ A},即读字符 c 后进入 A 的状态
X = {s for s in dfa.states if dfa.trans(s, c) in A}
for Y in P[:]:
inter = X & Y
diff = Y - X
if inter and diff:
# Y 被 X 切分
P.remove(Y)
P.append(inter)
P.append(diff)
if Y in W:
W.remove(Y)
W.append(inter)
W.append(diff)
else:
W.append(inter if len(inter) <= len(diff) else diff)
return P
1.3 用 flex 写一个词法分析器
不需要手写 DFA,用 flex 声明式描述即可:
/* scanner.l —— 一个迷你 C 语言词法分析器 */
%{
#include <stdio.h>
int line = 1; /* 当前行号 */
%}
/* 正则定义(类似宏) */
DIGIT [0-9]
ID [a-zA-Z_][a-zA-Z0-9_]*
%%
/* 规则:正则模式 动作(C代码) */
"int" { printf("KEYWORD int\n"); }
"if" { printf("KEYWORD if\n"); }
"else" { printf("KEYWORD else\n"); }
"while" { printf("KEYWORD while\n"); }
"return" { printf("KEYWORD return\n"); }
{ID} { printf("IDENT %s\n", yytext); }
{DIGIT}+ { printf("NUMBER %s\n", yytext); }
{DIGIT}+"."{DIGIT}+ { printf("FLOAT %s\n", yytext); }
"+" { printf("OP +\n"); }
"-" { printf("OP -\n"); }
"*" { printf("OP *\n"); }
"/" { printf("OP /\n"); }
"=" { printf("OP =\n"); }
"==" { printf("OP ==\n"); }
"<" { printf("OP <\n"); }
">" { printf("OP >\n"); }
";" { printf("SEMI\n"); }
"(" { printf("LPAREN\n"); }
")" { printf("RPAREN\n"); }
"{" { printf("LBRACE\n"); }
"}" { printf("RBRACE\n"); }
[ \t]+ { /* 跳过空白 */ }
\n { line++; }
"//".* { /* 跳过单行注释 */ }
"/*"([^*]|\*+[^/*])*"*"+"/" { /* 跳过多行注释 */ }
. { printf("ERROR line %d: unexpected '%s'\n", line, yytext); }
%%
int main(int argc, char **argv) {
if (argc > 1) {
yyin = fopen(argv[1], "r");
}
yylex();
return 0;
}
int yywrap() { return 1; } /* 到文件尾,结束 */
编译运行:
flex scanner.l # 生成 lex.yy.c(C语言词法分析器源码)
gcc lex.yy.c -o scanner # 编译
./scanner test.c # 对 test.c 做词法分析
flex 生成的 DFA 背后用的就是子集构造法——你写正则,它替你生成高效的状态机。
2. 语法分析(第 4 章)—— 把 Token 流建成语法树
2.1 上下文无关文法(CFG)
词法分析器识别"单词",语法分析器识别"句子结构"。CFG 用四条东西描述一门语言:
CFG: G = (N, T, P, S)
N: 非终结符集合(语法变量,比如 expr, stmt)
T: 终结符集合(就是 Token 类型,比如 NUMBER, PLUS, IF)
P: 产生式集合(重写规则)
S: 开始符号
例如,一个简单表达式文法:
E → E + T | E - T | T
T → T * F | T / F | F
F → ( E ) | id | num
这个文法有左递归(E → E + T),LL 分析器无法处理,必须先消除。
消除左递归的标准手法:
原始: E → E + T | T
T → T * F | F
F → ( E ) | id
改写: E → T E'
E' → + T E' | ε
T → F T'
T' → * F T' | ε
F → ( E ) | id
现在 E' 和 T' 都用右手边第一个符号做决策——LL 分析器可以工作了。
2.2 手写递归下降 Parser(最常用)
递归下降 = 每个非终结符写一个函数,一眼看懂:
# parser.py —— 递归下降表达式解析器
# 文法(已消除左递归):
# expr → term expr_tail
# exprtail → + term expr_tail | - term expr_tail | ε
# term → factor term_tail
# termtail → * factor term_tail | / factor termtail | ε
# factor → ( expr ) | NUMBER | IDENT
class Parser:
def __init__(self, tokens):
self.tokens = tokens # [(type, value), ...]
self.pos = 0
def peek(self):
"""看当前 token,不消费"""
if self.pos < len(self.tokens):
return self.tokens[self.pos]
return ('EOF', '')
def consume(self):
"""吃掉当前 token 并返回"""
tok = self.peek()
self.pos += 1
return tok
def expect(self, expected_type):
"""吃掉一个 token,类型必须是 expected_type"""
tok = self.consume()
if tok[0] != expected_type:
raise SyntaxError(f"行 {self.pos}: 期望 {expected_type},遇到 {tok}")
return tok
# ---- 真正的解析函数 ----
def parse_expr(self):
"""expr → term expr_tail"""
left = self.parse_term()
return self.parse_expr_tail(left)
def parse_expr_tail(self, left):
"""expr_tail → + term expr_tail | - term expr_tail | ε"""
if self.peek()[0] == 'PLUS':
self.consume()
right = self.parse_term()
node = ('+', left, right)
return self.parse_expr_tail(node) # 左结合:继续递归
elif self.peek()[0] == 'MINUS':
self.consume()
right = self.parse_term()
node = ('-', left, right)
return self.parse_expr_tail(node)
else:
return left # ε:没有更多运算符了
def parse_term(self):
"""term → factor term_tail"""
left = self.parse_factor()
return self.parse_term_tail(left)
def parse_term_tail(self, left):
"""term_tail → * factor term_tail | / factor term_tail | ε"""
if self.peek()[0] == 'STAR':
self.consume()
right = self.parse_factor()
node = ('*', left, right)
return self.parse_term_tail(node)
elif self.peek()[0] == 'SLASH':
self.consume()
right = self.parse_factor()
node = ('/', left, right)
return self.parse_term_tail(node)
else:
return left
def parse_factor(self):
"""factor → ( expr ) | NUMBER | IDENT"""
tok = self.peek()
if tok[0] == 'NUMBER':
self.consume()
return ('num', int(tok[1]))
elif tok[0] == 'IDENT':
self.consume()
return ('id', tok[1])
elif tok[0] == 'LPAREN':
self.consume()
node = self.parse_expr()
self.expect('RPAREN')
return node
else:
raise SyntaxError(f"意外的 Token: {tok}")
# ---- 测试 ----
# 输入: "3 + 4 * 5"
# Token 流: [('NUMBER','3'), ('PLUS','+'), ('NUMBER','4'), ('STAR','*'), ('NUMBER','5')]
tokens = [
('NUMBER', '3'), ('PLUS', '+'), ('NUMBER', '4'),
('STAR', '*'), ('NUMBER', '5')
]
parser = Parser(tokens)
ast = parser.parse_expr()
# AST 结果: ('+', ('num', 3), ('*', ('num', 4), ('num', 5)))
# 这棵 AST 正确表达了 "乘法优先于加法" 的语义
这段代码可以直接跑,修改 tokens 就能解析不同表达式。递归下降是工业界最常用的 Parser 写法——Clang 的 C 语言 Parser 就是递归下降的。
2.3 LL(1) 分析:FIRST 和 FOLLOW
递归下降的"选择哪个产生式"这件事,可以用FIRST/FOLLOW精确计算:
FIRST(α): 从 α 能推导出的所有串的**第一个终结符**的集合
(包含 ε,如果 α 能推导出空串)
FOLLOW(A): 在某个句型中,紧跟在 A 后面的**终结符**的集合
(从来不会包含 ε)
计算方法(不动点迭代):
def compute_first(grammar):
"""迭代法求所有符号的 FIRST 集"""
first = {sym: set() for sym in grammar.nonterminals + grammar.terminals}
for t in grammar.terminals:
first[t].add(t) # 终结符的 FIRST 就是它自己
changed = True
while changed:
changed = False
for lhs, rhs_list in grammar.productions:
for rhs in rhs_list: # 对产生式 A → X1 X2 ... Xn
all_nullable = True
for X in rhs:
# FIRST(A) ∪= FIRST(X) \ {ε}
old = len(first[lhs])
first[lhs] |= first[X] - {'ε'}
if len(first[lhs]) > old:
changed = True
if 'ε' not in first[X]:
all_nullable = False
break
if all_nullable:
# 所有符号都能推导出 ε,那么 ε ∈ FIRST(A)
if 'ε' not in first[lhs]:
first[lhs].add('ε')
changed = True
return first
LL(1) 分析表构造:
对每条产生式 A → α:
对 FIRST(α) 中的每个终结符 a(a ≠ ε):
把 A → α 填入 M[A, a]
如果 ε ∈ FIRST(α):
对 FOLLOW(A) 中的每个终结符 b:
把 A → α 填入 M[A, b]
如果某个格子被填了两次(冲突),说明不是 LL(1) 文法——要么改写文法消除左递归/提公因子,要么换更强的分析技术。
2.4 LR 分析:更强大的自底向上方法
递归下降(LL)是"预测式"的——根据已看到的来猜接下来是什么。LR 则是**“看到右手边足够多了才归约”**——能处理的文法范围更广。
LR 分析器的四个动作:
移入(shift): 把下一个 token 压入栈
归约(reduce): 栈顶的一串符号匹配某条产生式右手边 → 替换为左手边
接受(accept): 归约到开始符号
报错(error): 语法错误
LR 的状态机是一个 DFA,每个状态维护一个项目集。一个项目 A → α·β 表示"正在识别 A,已经看到了 α,期望看到 β"。
四种 LR 的递进关系:
LR(0):完全不看前瞻,只看项目集本身 → 冲突多,实用性有限
↓ 加入 FOLLOW 来解决归约/移入冲突
SLR(1):用 FOLLOW(A) 决定是否归约 → 简单,但有时太保守
↓ 在前瞻符号上做精确跟踪
LR(1):项目带前瞻符 A → α·β, a → 状态数爆炸(CS 理论最通用)
↓ 合并"核心相同"的 LR(1) 状态
LALR(1):LR(1) 的合并版本 → yacc/bison 实际使用的,状态数≈SLR,能力≈LR(1)
一个 LR(1) 项目示例:
项目: [A → α·β, a]
意思: 正在识别 A,已看到 α,期望看到 β
a 是前瞻符——只有下一个 token 是 a 时才能按这个项目归约
用 yacc/bison 写语法分析器:
/* calc.y —— 简单计算器(yacc 语法文件) */
%{
#include <stdio.h>
%}
%token NUMBER
%left '+' '-' /* 左结合,优先级低 */
%left '*' '/' /* 左结合,优先级高 */
%%
input:
/* 空 */
| input line
;
line:
'\n'
| expr '\n' { printf("= %d\n", $1); }
;
expr:
NUMBER { $$ = $1; }
| expr '+' expr { $$ = $1 + $3; }
| expr '-' expr { $$ = $1 - $3; }
| expr '*' expr { $$ = $1 * $3; }
| expr '/' expr { $$ = $1 / $3; }
| '(' expr ')' { $$ = $2; }
;
%%
int main() { return yyparse(); }
int yyerror(char *s) { fprintf(stderr, "错误: %s\n", s); return 0; }
编译运行:
yacc -d calc.y # 生成 y.tab.c 和 y.tab.h(LALR(1) 分析表)
gcc y.tab.c -o calc
echo "3+4*5" | ./calc # 输出: = 23
关键理解:yacc 的 %left 不仅指定了结合性,还隐式解决了移入-归约冲突——“看到 + 后栈顶有 3+4,是归约还是移入?→ 左结合 = 归约”。
3. 语法制导翻译(第 5 章)—— 边解析边干活
Parser 建好 AST / 生成 IR 的过程就是语法制导翻译。
3.1 综合属性 vs 继承属性
# 例子:计算表达式的值 + 生成三地址码
class SDT_Expr:
"""
expr → term expr_tail
综合属性: expr.val = term.val + expr_tail.val(从子传父)
综合属性: expr.code = term.code + expr_tail.code(连接的 IR)
继承属性: expr_tail.inherited = term.val(从父/兄弟传子)
"""
def __init__(self):
self.code = [] # 三地址码列表
def new_temp(self):
"""分配临时变量名"""
self.temp_counter = getattr(self, 'temp_counter', 0) + 1
return f't{self.temp_counter}'
def translate_expr(self, node):
"""
返回 (val_place, code)
val_place: 存放结果的"位置"(变量名或常量值)
code: 生成的三地址码列表
"""
if node[0] == 'num':
return node[1], [] # 常量,无需代码
elif node[0] == 'id':
return node[1], [] # 变量名
elif node[0] in ('+', '-', '*', '/'):
# 综合属性: left.val 和 right.val 来自子节点
left_val, left_code = self.translate_expr(node[1])
right_val, right_code = self.translate_expr(node[2])
# 分配临时变量存放结果
temp = self.new_temp()
# 生成三地址码
code = left_code + right_code
code.append(f'{temp} = {left_val} {node[0]} {right_val}')
return temp, code # 返回结果位置 + 代码
# ---- 翻译 ----
# AST: ('+', ('num', 3), ('*', ('num', 4), ('num', 5)))
sdt = SDT_Expr()
result, code = sdt.translate_expr(ast)
# result = 't1'
# code = ['t1 = 4 * 5', 't2 = 3 + t1']
print('\n'.join(code))
从这个例子理解 Clang 的工作方式:Clang 的 AST 节点(Expr、Stmt、Decl)每个都有语法制导定义的属性。CodeGen 模块(clang/lib/CodeGen/)就是一个巨大的 SDT 翻译器——遍历 C++ AST,生成 LLVM IR。
3.2 S-属性 vs L-属性
| S-属性定义 | L-属性定义 | |
|---|---|---|
| 属性流向 | 只从子 → 父 | 从左兄弟或父 → 子 |
| 适合什么 Parser | LR(自底向上) | LL(自顶向下) |
| 遍历方式 | 后序遍历即可求值 | 需要按从左到右的顺序 |
| LLVM 中的例子 | 常量折叠 | 作用域解析(外层符号表传入内层) |
实际场景:int a = b + 3; 中,a 的类型 int 是继承属性(从声明往下传),b + 3 的值是综合属性(从子节点往上算)。
下午:数据流分析与支配分析(LLVM 书第 3-5 章)
从这里开始,后面三天的内容(Day2 下午 → Day3 优化 → Day4 后端)全部依赖数据流分析。数据流分析是编译优化的统一数学框架。
4. 数据流分析(第 3 章)—— 全称是"基于格的数据流分析框架"
4.1 什么是数据流分析
编译器需要在不实际运行程序的前提下,回答关于程序运行时行为的问题:
| 我想知道… | 对应数据流问题 |
|---|---|
| 变量 x 在 p 点取什么值 | 常量传播 |
| 变量 x 在 p 点之后还用吗 | 活跃变量分析 |
| 表达式 a+b 之前算过了吗 | 可用表达式分析 |
| x 的定义在 p 点还能到达吗 | 到达定值分析 |
4.2 数据流分析的统一框架
所有数据流问题都可以填进同一个模板:
对每个基本块 B:
in[B] = ∧ { out[P] : P 是 B 的前驱 }
out[B] = gen[B] ∪ (in[B] - kill[B])
其中:
gen[B] = 块 B 内部"新产生"的事实
kill[B] = 块 B 内部"杀死"的事实
∧ = 交汇运算(∪ 或 ∩,取决于问题类型)
为什么是"格"? 因为数据流值构成一个半格(Semi-lattice):
- 值域 V:所有可能的数据流值
- 交汇运算 ∧:合并多条路径的信息,必须满足交换律、结合律、幂等律
- 偏序 ≤:信息量排序,a ≤ b 表示 a 的信息不比 b 多
- 顶元素 ⊤:最"保守"的值(什么都不知道)
- 底元素 ⊥:最"精确"的值(一切都知道)
关键性质:迭代应用 out[B] = gen[B] ∪ (in[B] - kill[B]) 单调递减(或递增),所以一定收敛到最大不动点(MFP)。
4.3 实例一:到达定值分析(Reaching Definitions)
问题:在 p 点,变量 x 的值可能是由哪些定义语句赋值的?
方向: 前向(顺着控制流)
交汇: ∪(汇集所有可能)
gen: { d | d 是块内定义的语句 }
kill: { 其他语句对同一个变量的定义 }
初始化: out[ENTRY] = ∅
实例——给定以下 CFG,手算到达定值:
[B1]
d1: x = 1
d2: y = 2
│
┌────┴────┐
▼ ▼
[B2] [B3]
d3: x = 3 d4: y = 4
d4: z = 5 d5: x = 6
│ │
└────┬────┘
▼
[B4]
d6: w = x + y
迭代求解过程:
初始化:
in[B1] = ∅
out[B1] = gen[B1] = {d1, d2}
第一遍迭代:
in[B2] = out[B1] = {d1, d2}
out[B2] = gen[B2] ∪ (in[B2] - kill[B2])
= {d3, d4} ∪ ({d1, d2} - {d1}) // d3 杀了 d1(都定义 x)
= {d3, d4, d2}
in[B3] = out[B1] = {d1, d2}
out[B3] = {d4, d5} ∪ ({d1, d2} - {d2, d1}) // d4 杀 d2(都定义 y),d5 杀 d1,d3
= {d4, d5}
in[B4] = out[B2] ∪ out[B3] = {d3, d4, d2} ∪ {d4, d5} = {d2, d3, d4, d5}
out[B4] = gen[B4] ∪ (in[B4] - kill[B4])
= {d6} ∪ ({d2, d3, d4, d5} - ∅)
= {d2, d3, d4, d5, d6}
第二遍迭代: 所有 in/out 不再变化 → 算法终止
结论:在 B4 的入口,x 可能来自 d3(B2 路径)或 d5(B3 路径),y 可能来自 d2 或 d4。这就是为什么编译器在 B4 不能把 x+y 常量折叠——x 的值不确定。
4.4 实例二:活跃变量分析(Live Variables)
问题:变量 x 在 p 点的值将来还会被用到吗?
方向: 后向(逆着控制流)
交汇: ∪(汇集所有可能)
def[B]: 块 B 内被**定值**且在定值前未在 B 内被引用的变量
use[B]: 块 B 内被**引用**且在引用前未在 B 内被定值的变量
公式: in[B] = use[B] ∪ (out[B] - def[B])
初始化: in[EXIT] = ∅
用途:死代码消除(DCE)—— 如果变量定值后不再活跃,这条赋值可以删除。寄存器分配也严重依赖活跃分析——两个变量同时活跃就不能用同一个寄存器。
同个 CFG,手算活跃变量分析(设最终 w 在 EXIT 被引用):
[B4] w = x + y def={w}, use={x,y}
[B2] x = 3; z = 5 def={x,z}, use=∅
[B3] y = 4; x = 6 def={y,x}, use=∅
[B1] x = 1; y = 2 def={x,y}, use=∅
逆序迭代(从 B4 往 B1):
Pass 1:
out[B4] = ∅
in[B4] = {x,y} ∪ (∅ - {w}) = {x,y} ← x,y 在 B4 用到
out[B2] = in[B4] = {x,y}
in[B2] = ∅ ∪ ({x,y} - {x,z}) = {y} ← 只有 y 从入口活跃到 B2
out[B3] = in[B4] = {x,y}
in[B3] = ∅ ∪ ({x,y} - {y,x}) = ∅ ← x,y 都在 B3 内重新定义了
out[B1] = in[B2] ∪ in[B3] = {y} ∪ ∅ = {y}
in[B1] = ∅ ∪ ({y} - {x,y}) = ∅ ← x,y 都在 B1 定义
Pass 2: 无变化 → 终止
结论:x 和 y 在 B1 的 out 处只有 y 活跃——因为 B2/B3 路径下 x 都会被覆盖。寄存器分配时,x 在 B1 之后的寄存器可以立即释放。
4.5 实例三:可用表达式分析(Available Expressions)
问题:在 p 点,表达式 a+b 是否已经在所有路径上计算过且操作数未变?
方向: 前向
交汇: ∩(必须所有路径都计算过才算"可用")
gen: 块内计算的且之后操作数未被定值的表达式
kill: 操作数被定值的所有表达式
初始化: out[ENTRY] = ∅(入口处没有可用表达式)
用途:全局公共子表达式消除(GCSE)——如果 a+b 可用,直接用上次的结果。
注意 ∧ 从 ∪ 变成了 ∩ ——这改变了格的方向!对于可用表达式,"什么都不知道"是全 ∅(顶),"一切都确定"是全 U(底)。交汇用 ∩ 意味着越往下走信息越精确(越接近底)。
5. 支配分析(第 4 章)—— CFG 的结构骨架
5.1 支配关系
d 支配 n (d dom n): 从入口到达 n 的**所有路径**都经过 d
每个节点支配它自己(反身性)
d 严格支配 n: d dom n 且 d ≠ n
立即支配者 idom(n): 严格支配 n 的节点中,"最接近 n"的
等价于: idom(n) 是支配树中 n 的父节点
手算支配集(还是上面的 CFG):
CFG:
ENTRY
│
B1
/ \
B2 B3
\ /
B4
│
EXIT
支配关系:
dom(B1) = {ENTRY, B1}
dom(B2) = {ENTRY, B1, B2}
dom(B3) = {ENTRY, B1, B3}
dom(B4) = {ENTRY, B1, B4} ← 所有路径从 B1 来,但 B2/B3 不是必经
dom(EXIT) = {ENTRY, B1, B4, EXIT}
立即支配者:
idom(B1) = ENTRY
idom(B2) = B1
idom(B3) = B1
idom(B4) = B1 ← 注意!B4 由 B1 支配,不是 B2/B3
idom(EXIT) = B4
5.2 支配树
把 idom 关系画成树:
ENTRY
│
B1
/ | \
B2 B3 B4 ← B4 也是 B1 的子节点!即使它在 CFG 中是 B2/B3 的后继
│
EXIT
CFG 中的边(B2→B4、B3→B4)在支配树中不可见。支配树展示的是必经关系,不是控制流。
5.3 支配边界(Dominance Frontier)—— 核心概念
DF(n) = { x | n 支配 x 的某个前驱,但 n 不严格支配 x }
换成人话:从 n 的"势力范围"走到"势力范围外"的第一站。具体来说,x 在 DF(n) 中当且仅当:存在一条边 y→x,使得 n dom y 但 n 不 sdom x。
为什么支配边界重要?
支配边界 = 放 φ 节点的确切位置。
SSA 构造算法的核心步骤(mem2reg Pass):
1. 对每个变量 x,找出所有给 x 赋值的块
2. 对每个赋值块 B,在 DF[B] 的每个块中插入 x 的 φ 节点
3. 新插入的 φ 节点本身也是赋值,所以递归应用步骤 2
4. 迭代直到不再新增 φ 节点
5. 用 φ 节点的参数重命名变量的所有使用
手算支配边界(同 CFG):
DF(B1) = ∅ ← B1 支配所有节点,没有"出走"的边
DF(B2) = {B4} ← B2 dom B2 但 B2 不 sdom B4,边 B2→B4 的终点在 DF[B2]
DF(B3) = {B4} ← 同理,边 B3→B4
DF(B4) = ∅ ← B4 的后继 EXIT 被 B4 严格支配
验证 φ 节点放置规则:假设 x 在 B2 中定值了一次,在 B3 中定值了一次。两个定值块都是"只在某些路径到达汇合点"的情况:
- DF[B2] = {B4},所以 B4 开头要放
x = φ(x_B2, x_B1) - DF[B3] = {B4},B4 已经有 φ 了,不重复放
- φ 节点本身在 B4 中是一个"新定值",但 DF[B4] = ∅,不需要继续传播
5.4 LLVM 中的支配信息
# 看 LLVM 怎么算支配树
clang -S -emit-llvm -O0 test.c -o test.ll
opt -passes='print<domtree>' test.ll -disable-output 2>&1
# 输出示例:
# [domtree] DominatorTree for function 'foo':
# [1] %entry {0,7} ← 入口
# [2] %loop_header {1,6} ← 父节点是 entry
# [3] %body {2,3}
# [4] %latch {2,5}
6. 循环识别(第 5 章)—— 不是所有环都是循环
6.1 自然循环的定义
回边 (back edge): 边 n → d,其中 d dom n(d 支配 n → 循环头支配循环体尾 → 这确实是往回跳)
自然循环 (natural loop): 回边 n → d 确定了一个循环
- 循环头 = d
- 循环体 = { d } ∪ { x | 存在路径 x→*→n 不经过 d }
简单说:不经过 d 就能到达 n 的所有节点,加上 d 本身
实例:
[entry]
│
▼
[B1] ←──┐ 回边: B3 → B1
│ │ dom(B1) = {entry, B1}, dom(B3) = {entry, B1, B2, B3}
▼ │ B1 dom B3 ✓ → 这是回边
[B2] │
│ │ 自然循环:
▼ │ 循环头 = B1
[B3] ────┘ 循环体 = {B1, B2, B3}
│ (B2 和 B3 都可以不经过 B1 到达 B3)
▼
[exit]
不是循环的环(irreducible graph):
[B1]
/ \
▼ ▼
[B2] [B3]
\ /
▼ ▼
[B4]
如果边 B4→B1 存在但不是回边(B1 不支配 B4),这仍然是一个不可约控制流。现代编译器通常对此做节点分裂转成可约形式。
6.2 LLVM 的循环规范化
LLVM 的 LoopSimplify Pass 保证每个循环都有标准形态:
[preheader] ← 唯一入口(循环外的块)
│ preheader 不属于循环体
▼
[header] ← 循环头(包含 phi 节点)
/ \
▼ ▼
[body] [latch] ← latch:唯一回边源
\ /
▼ ▼
[header] ← 循环头 φ 节点汇合 body 和 latch 的值
│
▼
[exit] ← 唯一出口
为什么需要 preheader? 循环不变量外提(LICM)的代码就移到 preheader 中——它在循环外只执行一次。
7. 今天的动手任务
任务 1:手写一个微型 Parser(30 分钟)
用 Python 实现一个支持 + - * / ( ) 和变量赋值的递归下降 Parser,输出三地址码。这是理解"语法制导翻译"最直接的方式。
# mini_compiler.py —— 完整的表达式+赋值 Parser → 三地址码
# 支持: + - * / ( ) 和 x = expr;
class MiniCompiler:
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
self.code = [] # 三地址码输出
self.temp_n = 0
def new_temp(self):
self.temp_n += 1
return f't{self.temp_n}'
def emit(self, instr):
self.code.append(instr)
# --- lexer helpers ---
def peek(self):
return self.tokens[self.pos] if self.pos < len(self.tokens) else ('EOF', '')
def consume(self):
tok = self.peek()
self.pos += 1
return tok
def expect(self, t):
tok = self.consume()
if tok[0] != t:
raise SyntaxError(f"期望 {t},遇到 {tok}")
return tok
# --- grammar: stmt → IDENT = expr ; ---
def parse_stmt(self):
if self.peek()[0] == 'IDENT':
name = self.consume()[1]
self.expect('ASSIGN')
val = self.parse_expr()
self.expect('SEMI')
self.emit(f'{name} = {val}')
return None
else:
val = self.parse_expr()
return val # 允许裸表达式(交互式计算器)
# --- grammar: expr → term expr_tail ---
def parse_expr(self):
left = self.parse_term()
return self.parse_expr_tail(left)
def parse_expr_tail(self, left):
t = self.peek()[0]
if t == 'PLUS':
self.consume()
right = self.parse_term()
temp = self.new_temp()
self.emit(f'{temp} = {left} + {right}')
return self.parse_expr_tail(temp)
elif t == 'MINUS':
self.consume()
right = self.parse_term()
temp = self.new_temp()
self.emit(f'{temp} = {left} - {right}')
return self.parse_expr_tail(temp)
return left
# --- grammar: term → factor term_tail ---
def parse_term(self):
left = self.parse_factor()
return self.parse_term_tail(left)
def parse_term_tail(self, left):
t = self.peek()[0]
if t == 'STAR':
self.consume()
right = self.parse_factor()
temp = self.new_temp()
self.emit(f'{temp} = {left} * {right}')
return self.parse_term_tail(temp)
elif t == 'SLASH':
self.consume()
right = self.parse_factor()
temp = self.new_temp()
self.emit(f'{temp} = {left} / {right}')
return self.parse_term_tail(temp)
return left
# --- grammar: factor → ( expr ) | NUMBER | IDENT ---
def parse_factor(self):
tok = self.peek()
if tok[0] == 'NUMBER':
self.consume()
return tok[1] # 常量值直接返回
elif tok[0] == 'IDENT':
self.consume()
return tok[1] # 变量名直接返回
elif tok[0] == 'LPAREN':
self.consume()
val = self.parse_expr()
self.expect('RPAREN')
return val
else:
raise SyntaxError(f"意外的 Token: {tok}")
# ============ 测试 ============
def tokenize(s):
"""简易词法分析器:字符串 → Token 流"""
import re
rules = [
('NUMBER', r'\d+'),
('IDENT', r'[a-zA-Z_]\w*'),
('ASSIGN', r'='),
('PLUS', r'\+'),
('MINUS', r'-'),
('STAR', r'\*'),
('SLASH', r'/'),
('LPAREN', r'\('),
('RPAREN', r'\)'),
('SEMI', r';'),
]
tokens = []
pos = 0
while pos < len(s):
if s[pos].isspace():
pos += 1
continue
matched = False
for ttype, pattern in rules:
m = re.match(pattern, s[pos:])
if m:
tokens.append((ttype, m.group()))
pos += len(m.group())
matched = True
break
if not matched:
raise SyntaxError(f"无法识别的字符: '{s[pos]}'")
return tokens
# 测试 1:表达式
print("=== 测试 1: 3 + 4 * 5 ===")
tokens = tokenize("3 + 4 * 5")
c = MiniCompiler(tokens)
result = c.parse_stmt()
for line in c.code:
print(line)
# 期望输出:
# t1 = 4 * 5
# t2 = 3 + t1
# 测试 2:赋值
print("\n=== 测试 2: x = (a + b) * c ===")
tokens = tokenize("x = ( a + b ) * c ;")
c = MiniCompiler(tokens)
c.parse_stmt()
for line in c.code:
print(line)
# 期望输出:
# t1 = a + b
# t2 = t1 * c
# x = t2
# 测试 3:带减法和除法的表达式
print("\n=== 测试 3: 10 - 8 / 2 ===")
tokens = tokenize("10 - 8 / 2")
c = MiniCompiler(tokens)
c.parse_stmt()
for line in c.code:
print(line)
# 期望输出:
# t1 = 8 / 2
# t2 = 10 - t1
运行结果:
=== 测试 1: 3 + 4 * 5 ===
t1 = 4 * 5
t2 = 3 + t1
=== 测试 2: x = (a + b) * c ===
t1 = a + b
t2 = t1 * c
x = t2
=== 测试 3: 10 - 8 / 2 ===
t1 = 8 / 2
t2 = 10 - t1
关键观察:三地址码 t1 = 4 * 5 先生成,t2 = 3 + t1 后生成——这自然保证了运算优先级(乘除先于加减)。因为 Parser 递归时 parse_term() 先于 parse_expr_tail() 被调用,所以乘法子树先被完整翻译。
任务 2:数据流分析手算(20 分钟)
对下面的 CFG,分别做到达定值和活跃变量的迭代求解,逐遍记录 in/out。
B1: a=1; b=2
/ \
B2: c=a+b B3: b=3
\ /
B4: d=a+b
▼ 任务 2 参考答案
CFG 标注定义语句:
[B1]
d1: a = 1
d2: b = 2
/ \
▼ ▼
[B2] [B3]
d3: c = a+b d4: b = 3
\ /
▼ ▼
[B4]
d5: d = a+b
A. 到达定值分析(Reaching Definitions)
方向: 前向
交汇: ∪
gen/kill:
gen[B1] = {d1, d2}
kill[B1] = {d1, d2 以及任何对 a 或 b 的其他定义} → 对全局就是 {其他 a 或 b 的定义}
gen[B2] = {d3}
kill[B2] = {} (d3 定义了新变量 c,没杀任何已有定义)
gen[B3] = {d4}
kill[B3] = {d2} (d4 定义 b,杀了 d2 对 b 的定义)
gen[B4] = {d5}
kill[B4] = {}
迭代求解:
Pass 1:
out[B1] = gen[B1] = {d1, d2}
in[B2] = out[B1] = {d1, d2}
out[B2] = gen[B2] ∪ (in[B2] - kill[B2])
= {d3} ∪ ({d1, d2} - ∅)
= {d1, d2, d3}
in[B3] = out[B1] = {d1, d2}
out[B3] = gen[B3] ∪ (in[B3] - kill[B3])
= {d4} ∪ ({d1, d2} - {d2})
= {d1, d4}
in[B4] = out[B2] ∪ out[B3]
= {d1, d2, d3} ∪ {d1, d4}
= {d1, d2, d3, d4}
out[B4] = gen[B4] ∪ (in[B4] - kill[B4])
= {d5} ∪ ({d1, d2, d3, d4} - ∅)
= {d1, d2, d3, d4, d5}
Pass 2:
in[B2] = out[B1] = {d1, d2} (不变)
in[B3] = out[B1] = {d1, d2} (不变)
in[B4] = out[B2] ∪ out[B3] = {d1,d2,d3,d4} (不变)
→ 所有 in/out 不变 → 终止
结果解读:
在 B4 的入口:
a 的定义 → {d1} (只有一处定义 a)
b 的定义 → {d2, d4} (b 可能来自 B1 的 2 或 B3 的 3)
c 的定义 → {d3} (只有 if 分支定义)
→ 所以 d = a + b 在 B4 中,a 固定是 1,但 b 可能是 2 或 3
→ 编译器不能在这做常量折叠
B. 活跃变量分析(Live Variables)
方向: 后向
交汇: ∪
def/use:
def[B1] = {a, b} use[B1] = ∅
def[B2] = {c} use[B2] = {a, b}
def[B3] = {b} use[B3] = ∅
def[B4] = {d} use[B4] = {a, b}
初始化: in[EXIT] = ∅ (EXIT 是 B4 后面)
Pass 1 (逆序: B4 → B3 → B2 → B1):
out[B4] = ∅
in[B4] = use[B4] ∪ (out[B4] - def[B4])
= {a, b} ∪ (∅ - {d}) = {a, b}
→ a 和 b 在 B4 入口是活跃的(它们在 B4 中被用了)
out[B3] = in[B4] = {a, b}
in[B3] = use[B3] ∪ (out[B3] - def[B3])
= ∅ ∪ ({a, b} - {b}) = {a}
→ a 从 B3 入口活跃(穿过了 B3,b 在 B3 被重新定义了)
out[B2] = in[B4] = {a, b}
in[B2] = use[B2] ∪ (out[B2] - def[B2])
= {a, b} ∪ ({a, b} - {c}) = {a, b}
→ a, b 都从 B2 入口活跃
out[B1] = in[B2] ∪ in[B3] = {a, b} ∪ {a} = {a, b}
in[B1] = use[B1] ∪ (out[B1] - def[B1])
= ∅ ∪ ({a, b} - {a, b}) = ∅
→ 函数入口处没有活跃变量(合理:参数 cond 没用上)
Pass 2: 所有 in/out 不变 → 终止
结果解读:
在 B1 出口: a 和 b 都活跃 → 它们的寄存器不能释放
在 B3 出口: 只有 a 活跃 → b 在 B3 被重新定义,原来的 b 可以释放
在 B4 出口: 没有活跃变量(除了返回值 d)
任务 3:支配关系手算(10 分钟)
对任务 2 的 CFG 算支配关系、支配树、支配边界。验证:如果在 B2 和 B3 中分别给同一个变量赋值,φ 节点应该放在哪个块?
▼ 任务 3 参考答案CFG:
[ENTRY]
│
[B1]
/ \
[B2] [B3]
\ /
[B4]
│
[EXIT]
支配关系(dom):
从 ENTRY 到每个节点的所有路径:
- 到 B1: ENTRY → B1,所有路径必过 ENTRY 和 B1 →
dom(B1) = {ENTRY, B1} - 到 B2: ENTRY → B1 → B2,所有路径必过 ENTRY、B1、B2 →
dom(B2) = {ENTRY, B1, B2} - 到 B3: ENTRY → B1 → B3,所有路径必过 ENTRY、B1、B3 →
dom(B3) = {ENTRY, B1, B3} - 到 B4: 有两条路径 (B1→B2→B4) 和 (B1→B3→B4)。两条路径都经过 ENTRY 和 B1,但不都经过 B2 或 B3 →
dom(B4) = {ENTRY, B1, B4} - 到 EXIT: 所有路径必过 ENTRY、B1、B4、EXIT →
dom(EXIT) = {ENTRY, B1, B4, EXIT}
立即支配者(idom):
idom(B1) = ENTRY (ENTRY 是唯一严格支配 B1 的节点)
idom(B2) = B1 (严格支配 B2 的有 ENTRY, B1,B1 是最近的)
idom(B3) = B1 (同理)
idom(B4) = B1 (严格支配 B4 的有 ENTRY, B1,B1 是最近的)
idom(EXIT) = B4 (严格支配 EXIT 的有 ENTRY, B1, B4,B4 最近)
支配树:
ENTRY
│
B1
/ | \
B2 B3 B4
│
EXIT
注意:B4 在支配树中直接挂在 B1 下面,而不是 B2 或 B3。支配树不等于 CFG!
支配边界(DF):
DF(ENTRY) = ∅ ; ENTRY 支配所有节点,没有任何边"逃出"它的势力范围
DF(B1) = ∅ ; B1 的每个后继 (B2, B3) 都被 B1 严格支配,没有"逃出"的边
B4 也被 B1 严格支配
DF(B2) = {B4} ; B2→B4 是一条边,B2 dom B2 但 B2 不 sdom B4
DF(B3) = {B4} ; B3→B4 是一条边,同理
DF(B4) = {EXIT} ; B4→EXIT 是一条边,B4 dom B4 但 B4 不 sdom EXIT(?)
不对——B4 sdom EXIT,所以 B4→EXIT 不产生支配边界
修正: DF(B4) = ∅
DF(EXIT) = ∅ ; EXIT 没有后继
φ 节点放置验证:
假设变量 x 在 B2 中赋值(x = ...),也在 B3 中赋值:
x 在 B2 中赋值 → DF[B2] = {B4} → 在 B4 开头插入 φ
x 在 B3 中赋值 → DF[B3] = {B4} → B4 已有 φ,不重复
B4 中的 φ: %x = phi [x_from_B2, %B2], [x_from_B3, %B3]
答案:φ 节点放在 B4。
这个结果很直观——B4 是两条路径的汇合点,变量在两条路径上分别被赋值,所以在 B4 需要用 φ 来汇合。
LLVM 验证:
cat > phi_test.c << 'EOF'
int foo(int cond, int a, int b) {
int x;
if (cond) {
x = a + 1; // B2
} else {
x = b - 1; // B3
}
return x; // B4
}
EOF
clang -S -emit-llvm -O0 phi_test.c -o phi_test.ll
opt -passes=mem2reg phi_test.ll -S | grep -A5 "phi"
你会发现 φ 节点确实在汇合块(if.end 或类似标签)中。
任务 4:用 LLVM 验证(15 分钟)
# 把任务 2 的 CFG 写成 C 代码
cat > test.c << 'EOF'
int foo(int cond) {
int a = 1, b = 2, c, d;
if (cond) {
c = a + b;
} else {
b = 3;
}
d = a + b;
return d;
}
EOF
clang -S -emit-llvm -O0 test.c -o test.ll
# 手工找出 IR 中的基本块,标出 phi 节点
opt -passes='print<domtree>' test.ll -disable-output 2>&1
# 对比自己算的支配树
opt -passes='mem2reg,print<domtree>' test.ll -disable-output 2>&1
# 观察 mem2reg 怎么插入 phi 节点
▼ 任务 4 参考答案
Step 1: 生成的 -O0 IR(精简)
define i32 @foo(i32 %cond) {
entry:
%a = alloca i32
%b = alloca i32
%c = alloca i32
%d = alloca i32
store i32 1, ptr %a ; a = 1
store i32 2, ptr %b ; b = 2
%tobool = icmp ne i32 %cond, 0
br i1 %tobool, label %if.then, label %if.else
if.then:
%0 = load i32, ptr %a
%1 = load i32, ptr %b
%add = add nsw i32 %0, %1 ; c = a + b
store i32 %add, ptr %c
br label %if.end
if.else:
store i32 3, ptr %b ; b = 3
br label %if.end
if.end:
%2 = load i32, ptr %a
%3 = load i32, ptr %b
%add1 = add nsw i32 %2, %3 ; d = a + b
store i32 %add1, ptr %d
ret i32 %add1 ; return d
}
基本块识别:entry、if.then、if.else、if.end(4 个块)。和我们任务 2 的 CFG 一一对应。
Step 2: mem2reg 后
define i32 @foo(i32 %cond) {
entry:
%tobool = icmp ne i32 %cond, 0
br i1 %tobool, label %if.then, label %if.else
if.then:
%add = add nsw i32 1, 2 ; a=1, b=2 常量:c = 1+2 = 3
br label %if.end
if.else:
br label %if.end
if.end:
%b.0 = phi i32 [ 2, %if.then ], [ 3, %if.else ] ; ← φ 节点!b 来自 2 或 3
%add1 = add nsw i32 1, %b.0 ; d = 1 + b
; 注意 a=1 也被常量折叠了
ret i32 %add1
}
对比我们手算的结果:
- φ 节点插在
if.end(= B4)——正好是我们任务 3 算出的支配边界! if.then路径 → b = 2(原始值没被改)if.else路径 → b = 3(else 中改了)- a = 1 是常量,被折叠了(LLVM 将
a+b变成了1 + %b.0)
Step 3: 支配树输出
opt -passes='print<domtree>' test.ll -disable-output 2>&1
输出(精简):
[domtree] DominatorTree for function 'foo':
[1] %entry {0,3}
[2] %if.then {1,2}
[2] %if.else {1,2}
[2] %if.end {1,2}
解读:
[1]表示 entry 是根(深度 1)[2]表示 if.then / if.else / if.end 都是深度 2,父节点都是 entry- 这和我们手算的 idom 一致——if.end 的立即支配者是 entry(经过 mem2reg 后,entry 中的 store 被清空,但支配关系不变)
Step 4: 最终 O2 输出
clang -S -emit-llvm -O2 test.c -o test_O2.ll
define i32 @foo(i32 %cond) {
entry:
%tobool.not = icmp eq i32 %cond, 0 ; cond == 0 ?
%. = select i1 %tobool.not, i32 3, i32 2 ; cond ? 2 : 3(倒过来了因为 icmp eq)
%add1 = add nsw i32 %., 1 ; d = (cond?2:3) + 1
ret i32 %add1
}
O2 干的事:
- 把
b的两条路径值2和3合并为一个select a=1内联为常量1- 消除了所有基本块——整个函数变成一个基本块
if.then中的c = a+b = 1+2 = 3被常量折叠,但 c 没被返回 → 死代码消除删了它- 这个函数体现的是:常量传播 + mem2reg + simplifycfg 的协同效果
今日小结
上午(龙书理论):
词法分析: 正则 → NFA → DFA → 最小化 DFA
语法分析: 递归下降(LL) vs LR(0)→SLR→LR(1)→LALR(1)
语法制导: 综合属性 ↑ / 继承属性 ↓ / SDT 动作嵌入
下午(LLVM 分析基础):
数据流分析: in[B] = ∧out[前驱]; out[B] = gen ∪ (in - kill)
三种实例: 到达定值(前向∪) / 活跃变量(后向∪) / 可用表达式(前向∩)
支配分析: dom / idom / 支配树 / 支配边界
支配边界 = φ 节点放置位置
循环识别: 回边 / 自然循环 / 循环规范化(preheader→header→body/latch→exit)
关键认知:
-
数据流分析是统一的:所有优化问题都在同一个
in/out/gen/kill/∧模板里,差异只是方向(前/后向)和交汇(∪/∩)。Day3 的所有 LLVM Pass 都是这个模板的特化。 -
支配边界直接解释 SSA:为什么
alloca + store + load经过mem2reg就变成 SSA?因为支配边界精确标定了 φ 节点的位置。这不是魔术,是可以在纸上推导的算法。 -
循环规范化是所有循环优化的前提:LICM、循环展开、向量化——LLVM 的每个循环 Pass 都假设 preheader 存在、latch 唯一。

135

被折叠的 条评论
为什么被折叠?



