编译原理
引论
语言处理器
语言处理器是将一种语言编写的程序转换为另一种形式的工具,主要包括:
- 编译器:将高级语言源程序一次性翻译为目标代码(如机器码、汇编代码),常见于 C/C++等静态语言。
- 解释器:逐条读取、分析并执行源程序,不生成独立目标代码,常见于 Python、JavaScript 等动态语言。
- 汇编器:将汇编语言翻译为机器指令。
- 其他相关工具:如虚拟机、链接器、装载器等。
补充说明与例子:
- 编译器常见工作流:源代码 -> 预处理 -> 编译为汇编 -> 汇编为目标文件 -> 链接为可执行文件/库。
- 交叉编译:在平台 A 为平台 B 生成可执行文件,如在 x86_64 上为 ARM 生成二进制(Clang/LLVM 的
--target
)。 - JVM/CLR 等虚拟机体系:源代码 -> 字节码(IR)-> 解释执行/即时编译(JIT)-> 机器码。典型的热点探测与内联缓存优化策略。
常见混合实现:
- Python:解释器 + JIT(PyPy)、C 扩展模块。
- JavaScript 引擎(V8):解释器(Ignition)+ JIT(TurboFan)+ 隐式类(Hidden Class)与内联缓存(IC)。
- Java:CLASS 字节码 + C1/C2 分层 JIT + AOT(GraalVM Native Image)。
编译器结构与主要阶段
编译器通常分为前端和后端:
- 前端:负责源代码的分析,包括词法分析、语法分析、语义分析,生成中间表示。
- 后端:对中间表示进行优化,生成目标代码。
主要阶段如下:
- 词法分析:将字符流分割成有意义的单元(记号/Token),如关键字、标识符、常量、运算符。常用自动机理论实现,典型工具有 lex/flex。
- 语法分析:根据上下文无关文法将记号序列构造成语法树或抽象语法树,检查结构合法性。常用自顶向下(递归下降、LL)和自底向上(LR、LALR)分析方法。
- 语义分析:在语法树基础上进行类型检查、作用域检查、名称绑定等,保证程序语义正确。常用符号表管理变量、函数等信息。
- 中间代码生成:将语法树转换为中间表示,如三地址码、四元式、静态单赋值等,便于后续优化和跨平台。
- 代码优化:对中间代码进行等价变换,提高效率或减少资源消耗。包括局部优化(如常量合并、死代码消除)、全局优化(如循环优化、内联展开)等。
- 代码生成:将优化后的中间代码翻译为目标机器代码或汇编代码,涉及寄存器分配、指令选择、指令调度等。
- 符号表管理:符号表用于记录变量、函数、类型等符号的属性信息,支持作用域嵌套、类型检查、重定义检测等。
- 多趟编译:复杂编译器常将编译过程分为多趟,每一趟处理特定任务,如先分析结构再优化代码,便于分阶段处理和调试。
- 编译器构造工具:如 lex/flex、yacc/bison 等自动生成分析器代码,提高开发效率。
典型例题方向与示范:
- 给定正则表达式,构造 NFA、DFA 并最小化;据此识别词法单元。
- 给定文法,计算 FIRST/FOLLOW 集;判断 LL(1) 条件;构建预测分析表。
- 构造 LR(0)/SLR(1)/LR(1)/LALR(1) 项目集族、ACTION/GOTO 表;模拟移进-归约。
- 编写 SDD/SDT 生成后缀式或三地址码;布尔表达式短路求值与回填。
- 控制流图、基本块划分、活跃变量分析、可达定义、CSE、循环不变外提与强度削弱。
- 寄存器分配:干涉图构造、图着色、溢出处理。
- 指令选择/调度:树匹配、依赖图、列表调度、循环展开与向量化可行性。
相关背景与应用
- 程序设计语言的发展历程:早期为机器语言,后有汇编语言,再到 FORTRAN、COBOL 等高级语言。高级语言更接近人类思维,支持抽象、结构化、模块化、面向对象等特性,极大提升了开发效率和程序可维护性。语言特性的丰富推动了编译技术的不断进步。
- 编译器相关科学:涉及形式语言与自动机、算法与数据结构、计算机体系结构、程序分析与优化理论等。
- 编译技术的应用:不仅用于高级语言实现,还广泛应用于不同硬件平台优化、新体系结构设计、源到源翻译、跨平台移植、静态分析、自动化重构、代码生成等。
补充:
- 源到源翻译(Transpiler):如 TypeScript->JS,Babel,Clang-Tidy/Clang-Format 的重写。
- 静态分析:类型推断、空指针、越界、未初始化、内存泄漏(如 Clang Static Analyzer)。
- 领域特定语言(DSL):SQL、正则、着色器语言(GLSL/HLSL),常借助编译技术实现解释与优化。
程序设计语言基础
- 静态与动态属性:静态属性在编译时确定,如类型、作用域、变量声明等;动态属性在运行时确定,如动态类型绑定、动态作用域等。
- 环境与状态:环境是变量名到存储位置的映射,状态是存储位置到实际值的映射。
- 静态作用域和块结构:静态作用域在编译时确定,支持嵌套作用域和块结构,便于局部变量管理。
- 显式访问控制:通过 public、private 等关键字控制变量/函数的可见性。
- 动态作用域:作用域在运行时根据调用链动态确定,部分脚本语言支持。
- 参数传递机制:包括值传递、引用传递、名称传递等,不同机制影响函数调用时变量的行为。
- 别名:多个名字引用同一存储位置,可能导致副作用和优化困难。
例子与推演:
- 静态作用域示例(C 风格):
int x = 1; void f() { int x = 2; void g(); g(); } // g 看到全局 x=1(静态作用域) void g() { printf("%d\n", x); }
- 动态作用域示例(伪代码):
x = 1 def f(): x = 2 g() def g(): print(x) # 动态作用域下输出 2;静态作用域输出 1 f()
- 名称传递(call-by-name,Algol68 风格)可模拟为使用 thunk:
// 传递 i++ 作为参数时,每次取值都会递增
对优化的影响:
- 别名阻碍优化:指针可能指向相同存储,编译器保守处理,限制寄存器缓存与重排序。
- 纯函数与无副作用便于 CSE、代码移动、并行化。
一个简单的语法制导翻译器
语法定义
**文法(Grammar)**用产生式规则描述语言结构。形式为 , 为非终结符, 为终结符和非终结符串。
**推导(Derivation)**是从开始符号出发,反复应用产生式,直到得到只含终结符的符号串。
**语法分析树(Parse Tree)**反映推导过程的树形结构,根为开始符号,叶为终结符。
**二义性(Ambiguity)**指同一符号串有多棵语法树。二义性会导致语义不确定,需通过调整文法、结合性和优先级消除。
运算符结合性与优先级:结合性决定运算符的结合方向(如加法左结合),优先级决定运算符的运算顺序。
典型例子:
-
经典表达式二义文法:
E -> E + E | E * E | ( E ) | id
对
id + id * id
既可解析为(id + id) * id
也可解析为id + (id * id)
。 -
通过引入层级与结合性消解:
E -> E + T | T T -> T * F | F F -> ( E ) | id
加法低于乘法,两个产生式左递归表达左结合。
-
替代方法:运算符优先级表与结合性声明(yacc/bison):
%left '+' '-' %left '*' '/' %right '^'
语法制导翻译(SDT/SDD)
在文法规则中嵌入语义动作,通过属性值传递实现语法制导翻译。
- 综合属性(Synthesized Attribute):属性值由子节点综合而来,常用于自底向上的分析。
- 继承属性(Inherited Attribute):属性值由父节点或兄弟节点传递,常用于自顶向下的分析。
后缀表示(逆波兰表示法):将中缀表达式转换为后缀表达式,便于无括号计算。
树的遍历:属性计算顺序依赖于语法树的遍历方式(如自底向上、深度优先等)。
例:生成后缀式的 S 属性定义(仅综合属性)
E -> E + T { E.out = E1.out || T.out || '+' }
E -> T { E.out = T.out }
T -> T * F { T.out = T1.out || F.out || '*' }
T -> F { T.out = F.out }
F -> ( E ) { F.out = E.out }
F -> id { F.out = id.lexeme }
若采用 LR 分析,可将语义动作放在产生式尾部;LL 分析可返回字符串。
例:带继承属性的宽度控制(如生成对齐空间):
L -> L S { S.indent = L.indent }
L -> S { S.indent = L.indent }
S -> if (E) S1 else S2 { ... } // 子句继承缩进层级
短路求值 SDT(回填见后):
E -> E1 || M E2
{ E1.true + E2.true -> E.true; E1.false -> M.next; E.false = E2.false }
E -> E1 && M E2
{ E1.false + E2.false -> E.false; E1.true -> M.next; E.true = E2.true }
E -> !E1 { swap(E1.true, E1.false) }
E -> id relop id
{ emit('if a relop b goto _'); E.true = [instr]; emit('goto _'); E.false=[instr] }
M -> { M.next = nextinstr }
语法分析
自顶向下分析:从文法开始符号出发,递归分析输入串。常见方法有递归下降分析、预测分析(LL 分析)。
预测分析法:利用 lookahead 符号预测产生式,构建预测分析表(如 LL(1)分析表),实现自动分析。
左递归:文法左递归会导致自顶向下分析无限递归,需通过改写文法消除。
系统步骤与例子:
- 消除直接左递归:
- A -> A α | β 变换为 A -> β A’,A’ -> α A’ | ε
- 消除间接左递归:按非终结符顺序替换前驱产生式后再消除直接左递归。
- 左因子提取:A -> αβ1 | αβ2 | … => A -> α A’,A’ -> β1 | β2 | …
FIRST/FOLLOW 计算例:
E -> T E'
E'-> + T E' | ε
T -> F T'
T'-> * F T' | ε
F -> ( E ) | id
- FIRST(F) = {(, id}
- FIRST(T) = {(, id}
- FIRST(E) = {(, id}
- FIRST(T’) = {*, ε}
- FIRST(E’) = {+, ε}
- FOLLOW(E) = {), $}
- FOLLOW(E’) = {), $}
- FOLLOW(T) = {+, ), $}
- FOLLOW(T’) = {+, ), $} 构造 LL(1) 表据此填入 M[A,a]。
递归下降过程示意:
- 对于 E -> T E’:调用 T() 后调用 E’(),E’ 依据 lookahead 选择 + T E’ 或 ε。
LL(1) 判定条件:
- 对任意 A -> αi, αj,FIRST(αi) ∩ FIRST(αj) = ∅
- 若 ε ∈ FIRST(αi),则 FIRST(αj) ∩ FOLLOW(A) = ∅
简单表达式的翻译器
抽象语法(AST):忽略具体语法细节,仅保留表达式结构,便于后续处理。
具体语法:包括所有语法细节,如括号、分号等。
翻译方案调整:通过调整语法制导定义,优化属性传递和语义动作,简化实现。
非终结符过程:每个非终结符对应一个分析过程,递归调用实现语法分析和翻译。
简化实现:通过合并规则、消除冗余,简化语法和语义动作,便于实现完整表达式翻译器。
示例:表达式到三地址码(引入新临时变量 newtemp)
E -> E1 + T { t = newtemp(); emit(t = E1.place + T.place); E.place = t; }
E -> T { E.place = T.place }
T -> T1 * F { t = newtemp(); emit(t = T1.place * F.place); T.place = t; }
T -> F { T.place = F.place }
F -> (E) { F.place = E.place }
F -> id { F.place = id.lexeme }
F -> num { F.place = num.value }
对输入 id + id * num,输出:
t1 = id2 * 3
t2 = id1 + t1
词法分析
剔除空白和注释:词法分析器需忽略空白字符和注释,保证分析准确。
预读(Lookahead):通过预读字符判断记号类型,支持多字符记号识别。
常量识别:支持整数、浮点数、字符串等常量的识别。
关键字与标识符识别:区分保留字和用户自定义标识符,通常通过查表实现。
词法分析器实现:可手写有限自动机,也可用工具(如 lex)自动生成。
正则与自动机构造关键步骤:
- 正则 -> NFA(Thompson 构造)
- NFA -> DFA(子集构造)
- DFA 最小化(Hopcroft 算法)
示例:整数与标识符
- 正则:digit = [0-9], letter = [A-Za-z_]
- int: digit digit*
- id: letter (letter|digit)*
- 关键字处理:将关键字插入保留字表;匹配 id 后查保留字表决定记号种类。
flex 简例:
%{
#include "y.tab.h"
%}
%%
[ \t\r\n]+ ;
"//".* ;
"/*"([^*]|\*+[^*/])*\*+"/" ;
"if" return IF;
"else" return ELSE;
[0-9]+ { yylval.ival = atoi(yytext); return NUM; }
[A-Za-z_][A-Za-z0-9_]* { yylval.sval = strdup(yytext); return ID; }
"=="| "!="| "<="| ">="| "<"| ">" { yylval.op = yytext[0]; return RELOP; }
"+"|"-"|"*"|"/" return yytext[0];
"(" | ")" | ";" return yytext[0];
. /* error token */
%%
常见细节:
- 最长匹配原则:DFA 会自然实现最长前缀匹配。
- 回退处理:若模式共享前缀,DFA 可避免回退;NFA 直扫需支持回退。
- 多行字符串/注释:注意贪婪匹配与转义处理。
- UTF-8 标识符:按字节还是代码点识别需明确。
符号表
作用域管理:为每个作用域设置独立符号表,支持嵌套作用域和作用域链。
符号表操作:包括插入新符号、查找已有符号、删除符号等,常用哈希表或链表实现。
层次结构:
- 使用栈维护作用域:进入块 push 新表,退出块 pop。
- 查找(静态作用域):从栈顶向下查找第一个命中表。
记录内容:
- 名称、种类(变量、函数、类型、标签)、类型(含数组/指针/函数签名)、存储类(static、extern)、偏移、是否常量、可见性、生命周期信息。
示意代码:
typedef struct Symbol { char* name; Type* type; int offset; ... } Symbol;
typedef struct Scope { HashMap* table; struct Scope* parent; } Scope;
Symbol* lookup(Scope* s, const char* name) {
for (; s; s = s->parent) {
Symbol* x = map_get(s->table, name);
if (x) return x;
}
return NULL;
}
void insert(Scope* s, Symbol* sym) { map_put(s->table, sym->name, sym); }
重定义与屏蔽:
- 同一作用域重定义需报错;内层允许屏蔽外层同名标识符。
语法制导的翻译
语法制导定义
语法制导定义(SDD)在文法产生式基础上附加属性和语义规则,实现语法与语义的结合。属性分为综合属性和继承属性,语义规则描述属性如何计算。
- 综合属性:由子节点计算结果综合而来,适合自底向上分析。
- 继承属性:由父节点或兄弟节点传递,适合自顶向下分析。
- 属性值可为类型、值、代码片段、符号表信息等。
类型检查 SDD 示例(算术表达式):
E -> E + T { if (E.type == T.type == int) E.type=int; else error; }
E -> T { E.type = T.type }
T -> F { T.type = F.type }
F -> (E) { F.type = E.type }
F -> id { F.type = id.type } // 查符号表
F -> num { F.type = int }
属性求值顺序
属性之间可能存在依赖关系。依赖图用于描述属性之间的依赖,求值顺序需满足依赖关系。
- S-属性定义:仅含综合属性,属性值可自底向上递归计算。
- L-属性定义:继承属性的依赖仅限于左侧兄弟和父节点,适合自顶向下递归分析。
- 属性求值顺序可通过拓扑排序确定。
依赖图构造:
- 节点:每个属性(如 E1.type)
- 边:从被依赖属性指向依赖该属性的属性
- 无环则存在合法求值顺序;有环说明定义不良(如循环依赖)。
例:列表打印缩进(L-属性)
S -> L { L.indent = 0 }
L -> L Item { Item.indent = L.indent }
L -> Item { Item.indent = L.indent }
Item -> stmt { print_with_indent(stmt, Item.indent) }
语法制导翻译的应用
- 抽象语法树构造:通过属性和语义动作生成 AST,便于后续优化和代码生成。
- 类型检查:属性记录类型信息,语义规则实现类型推断和检查。
- 中间代码生成:在语法分析过程中嵌入代码生成动作,直接输出中间代码。
AST 构造例(C 风格表达式):
E -> E + T { E.node = mkBin('+', E1.node, T.node) }
E -> T { E.node = T.node }
T -> F { T.node = F.node }
F -> id { F.node = mkId(id.lexeme) }
F -> num { F.node = mkNum(num.value) }
语法制导翻译方案
- 后缀翻译方案:将语义动作放在产生式右部末尾,适合自底向上分析。
- 前缀/中缀方案:语义动作可插入产生式任意位置,灵活实现属性传递。
- 消除左递归:左递归文法不利于自顶向下分析,需改写为右递归或使用特殊处理。
布尔表达式与控制流的回填(回跳链表 backpatch):
- 结构:
- 表达式属性:trueList, falseList(待回填的跳转目标)
- 语句属性:nextList(语句后继待回填)
- 基本动作:
- makelist(i):创建包含 i 的列表
- merge(p, q):连接两个列表
- backpatch(p, i):将列表 p 中每条跳转的目标回填为 i
例:if (E) S1 else S2
E.true -> backpatch to S1.start
E.false -> backpatch to S2.start
S1.next + S2.next -> S.next
实现 L-属性 SDD
- 在递归下降分析过程中传递继承属性,边分析边生成代码。
- LL 分析器适合实现 L-属性定义,LR 分析器适合 S-属性定义。
- 属性值可通过参数、返回值或全局结构传递.
伪代码例:递归下降 + 继承属性
void E(out Place p) {
Place t; T(t); E_(t, p);
}
void E_(in Place inh, out Place syn) {
if (lookahead == '+') {
match('+'); Place t; T(t);
Place r = newtemp(); emit(r = inh + t);
E_(r, syn);
} else { syn = inh; }
}
中间代码生成
中间代码的作用
中间代码(IR)是源代码与目标代码之间的桥梁,便于优化和跨平台。常见形式有三地址码、四元式、静态单赋值(SSA)等。
- 三地址码:每条指令最多包含三个操作数,结构简单,便于分析和优化。
- 四元式:用四元组表示操作、两个操作数和结果。
- 静态单赋值:每个变量只赋值一次,便于数据流分析。
三地址码表示形式:
- 赋值:x = y op z
- 一元:x = op y
- 赋值拷贝:x = y
- 数组访问:x = y[i], a[i] = x
- 取址/间接:x = *y, *x = y
- 条件跳转:if x relop y goto L
- 无条件跳转:goto L
- 函数:param x; call f, n; t = call f; return x
四元式与三元式:
- 四元式:(op, arg1, arg2, result)
- 三元式:使用指令编号作为临时值引用,减少 result 字段。
语法树与中间代码
- 语法树:表达式和语句结构的树形表示,节点类型包括操作符、常量、变量等。
- 从语法树生成中间代码:遍历语法树,递归生成三地址码或四元式。
例:((a + b) * (c - d)) + e
- 后缀:a b + c d - * e +
- 三地址码:
t1 = a + b t2 = c - d t3 = t1 * t2 t4 = t3 + e
表达式求值顺序与临时变量最少策略:
- 自底向上生成,使用 Sethi-Ullman 编号估计寄存器需求,决定左右子树顺序。
- 编号规则:叶子编号 1;节点编号 = max(左, 右)+[左==右]。大编号子树先生成。
控制流结构的中间代码
- 条件语句:生成条件跳转指令,使用标签表示分支目标。
- 循环结构:生成循环入口、条件判断、循环体和跳转指令。
- 函数调用:处理中间代码中的参数传递、返回值、调用和返回。
if-else 模板(短路求值 + 回填):
E.true -> L1; E.false -> L2
L1: S1
goto L3
L2: S2
L3:
while 模板:
Lbegin:
// 布尔表达式 E 短路,E.true -> Lbody, E.false -> Lafter
Lbody:
S
goto Lbegin
Lafter:
for (init; cond; step) S 转换为:
init
Ltest: // cond 短路
if cond goto Lbody
goto Lafter
Lbody:
S
step
goto Ltest
Lafter:
短路布尔表达式:
- 生成条件跳转而不是显式布尔值,避免额外临时。
临时变量与寄存器分配
- 中间代码生成过程中引入临时变量,便于表达复杂计算。
- 后续阶段需将临时变量映射到实际寄存器或内存位置。
基本策略:
- 局部寄存器分配(基本块内):自顶向下扫描活跃性,使用栈式寄存器分配。
- 全局寄存器分配:构建干涉图,图着色;溢出则插入 spill/load 指令,迭代直到可着色。
示例(干涉图构造):
语句顺序及liveOut集:
1: t1 = a + b liveOut={c,d,e}
2: t2 = c - d liveOut={t1,e}
3: t3 = t1 * t2 liveOut={e}
4: t4 = t3 + e liveOut={}
干涉:变量在同一 live 区间互相干涉。构图后尝试 K-着色(K=寄存器数量)。
中间代码优化
- 局部优化:如常量合并、公共子表达式消除、死代码删除。
- 全局优化:如循环不变代码外提、内联展开、数据流分析。
局部优化示例(基本块内):
- 常量折叠:t = 2*3 -> t = 6
- 强度削弱:x = y * 2^k -> x = y << k
- 消除冗余:x = y; z = x -> z = y;若 x 后不再使用可删除赋值
- 代价模型:多用寄存器少用内存,合并相邻加载/存储。
循环优化示例:
- 循环不变外提:
for i: t = a*b; x[i] = t + i => preheader: t = a*b loop: x[i] = t + i
- 强度削弱(指数、乘法 -> 加法、移位):
for i: x = i*8 => x = i << 3
- 归纳变量简化:i = i + c;可用计数器替换复杂表达式。
- 循环展开:增加每次迭代的工作以降低分支开销并暴露 ILP。
运行时刻环境
运行时环境的组成
运行时环境为程序执行提供必要的支持,包括内存分配、函数调用、变量存储等。
- 存储管理:管理全局变量、局部变量、堆和栈空间。
- 栈帧结构:每次函数调用分配新的栈帧,保存参数、返回地址、局部变量等。
- 作用域与可见性:支持静态作用域和动态作用域的变量查找。
典型栈帧布局(x86-64 System V,可能因编译器不同略有差异):
- 高地址向低地址增长:
- 参数入栈(寄存器溢出部分)
- 返回地址
- 旧帧指针(RBP)
- 保存的被调用者保存寄存器(rbx, r12-r15)
- 局部变量区(对齐)
- 临时溢出区(spill slots)
- 调用约定:前六整型参数用寄存器(rdi, rsi, rdx, rcx, r8, r9),返回值 rax;浮点使用 xmm0-7。
RISC-V LP64 约定简述:
- 参数 a0-a7,返回 a0-a1;被调用者保存 s0-s11;调用者保存 t0-t6。
参数传递与返回值
- 值传递:将实参值复制到形参。
- 引用传递:传递变量地址,实现共享。
- 名称传递:传递表达式或变量名,按需求值。
调用序列:
- 调用者:实参准备 -> 调用保存 -> call -> 调用后清理(按约定)
- 被调用者:保存旧帧指针 -> 建立新帧 -> 保存被调保存寄存器 -> 分配局部 -> 返回前恢复。
变长参数(C 的 stdarg):
- 固定参数通过寄存器/栈传递,额外参数通过栈;va_list 记录参数区指针与剩余计数。
动态内存分配
- 堆管理:动态分配和释放内存,常用 malloc/free、new/delete 等机制。
- 垃圾回收:自动回收不再使用的内存,常见于高级语言运行时。
GC 基础:
- 标记-清扫(Mark-Sweep):从根集可达对象标记,未标记对象回收;碎片化问题。
- 复制回收(Copying):两半区复制存活对象,紧凑无碎片;适合新生代。
- 分代收集(Generational):新生代复制 + 老年代标记整理;跨代记忆集与写屏障。
- 引用计数:实时回收不可达对象,但环无法处理,需配弱引用/周期检测。
异常处理与控制流
- 支持异常捕获、抛出和处理,维护异常表和控制流跳转。
- 支持协程、生成器等高级控制流结构。
异常实现:
- 栈展开(stack unwinding):查找匹配的 catch 块,沿调用栈回退,调用析构/清理。
- 映射表法(零开销异常):正常路径无开销,抛出时依据 PC 查表展开。
协程/生成器:
- 将函数状态(PC、局部变量)封装为帧对象;yield/suspend 恢复点;
- CPS(Continuation-Passing Style)或状态机转换实现。
运行时类型信息
- 记录类型信息,支持动态类型检查、反射等特性。
实现手段:
- 每对象隐藏头部:指向类元数据(方法表、字段表、RTTI)、GC 标志。
- vtable:虚方法调用通过 vptr 间接跳转。
- 动态语言字典:对象属性表、形状(Hidden Class)优化。
代码生成
目标代码生成的任务
代码生成器将中间代码翻译为目标机器代码或汇编代码,需考虑指令集、寄存器分配、内存布局等。
- 指令选择:将中间代码映射为具体机器指令,选择高效指令序列。
- 寄存器分配:将变量和临时量分配到有限的物理寄存器,溢出时使用内存。
- 指令调度:调整指令顺序,减少流水线冲突,提高并行度。
树覆盖(Pattern Matching):
- 使用目标机指令模式(树)覆盖 IR 表达式树,选择最小代价覆盖(如 IBURG)。
- 例:x = y + 1 在 x86 可选择 lea 指令代替 add 以减少依赖。
延迟槽(某些 RISC):分支延迟槽可填入对其安全的指令(或 NOP)。
地址与内存管理
- 变量分配:全局变量、局部变量、静态变量分配到不同内存区域。
- 数组与结构体访问:计算偏移量,生成相应的加载和存储指令。
- 函数调用约定:参数传递、返回值、调用保存、栈帧管理等。
数组寻址:
- a[i] 的地址 = base(a) + i * sizeof(elem)
- 多维数组(行主序):a[i][j] = base + ((i*col) + j)*elem_size
结构体:
- 字段偏移由对齐规则决定;通过 base + offset 访问。
示例(x86-64):
mov rax, [rbp-8] ; load i
lea rcx, [rax*8] ; i * 8
add rcx, [rbp-32] ; base + i*8
mov rdx, [rcx] ; load a[i]
目标代码优化
- 消除冗余指令、合并相邻操作、利用特殊指令提升性能。
- 针对特定硬件特性进行优化,如 SIMD、流水线、缓存友好等。
常见例:
- 强化寻址模式:x86 的
lea
合并加法与乘法 - 条件移动(cmov)减少分支预测失败
- 位操作代替乘除以常数:乘以 10 => (x<<3) + (x<<1)
- 矢量化:将标量循环用 SSE/AVX/RVV 指令成组处理
目标文件生成
- 生成可重定位目标文件,包含代码段、数据段、符号表等。
- 支持链接器和装载器进行后续处理,生成可执行文件。
概念:
- 段:.text, .data, .bss, .rodata
- 符号:全局符号、弱符号、重定位项
- 重定位:链接时修正地址(R_X86_64_PC32 等)
机器无关优化
优化目标与原则
机器无关优化关注于提升中间代码的执行效率,不依赖具体硬件结构。优化目标包括减少指令数、降低内存访问、提升并行度、减少分支等。
- 等价变换:保证优化前后程序语义一致。
- 局部与全局优化:局部优化作用于基本块内,全局优化跨越多个基本块或整个函数。
基本块与流图:
- 基本块:无分支进入点(除首条),无分支中断(除末条);入口只有第一条,出口只有最后一条。
- 划分:以 leaders 开始(第一条、跳转目标、跳转后继),直到下一个 leader 前的连续序列。
- CFG:结点为基本块,边为控制流可能性(分支/顺序)。
常见优化技术
- 公共子表达式消除(CSE):识别并消除重复计算的表达式。
- 常量传播与折叠:将常量值在程序中传播,提前计算常量表达式。
- 死代码消除:移除不会影响程序结果的代码。
- 复制传播:用变量的实际值替换对其的引用,简化表达式。
- 循环优化:包括循环不变代码外提、强度削弱、循环展开等。
- 内联展开:将小函数体直接插入调用点,减少函数调用开销。
- 代码移动:将无副作用的代码移动到执行频率更低的位置。
示例:可用表达式分析 + CSE
- 可用表达式:到达某点的所有路径上表达式 e 都已计算且自上次计算后其操作数未被修改
- 数据流方程(前向、交汇为交集):
IN[B] = ∩_{P∈pred(B)} OUT[P] OUT[B] = gen[B] ∪ (IN[B] - kill[B])
- gen/kill:由块内赋值决定;赋值给 x 杀死所有含 x 的表达式。
数据流分析
- 活跃变量分析:确定变量在何处被使用,指导寄存器分配和死代码消除。
- 可达定义分析:追踪变量定义的传播路径。
- 定值传播:分析变量是否为常量,便于常量折叠。
活跃变量(后向问题,交汇为并集):
OUT[B] = ∪_{S∈succ(B)} IN[S]
IN[B] = use[B] ∪ (OUT[B] - def[B])
- use:在定义前使用的变量集合
- def:在块内被定义的变量集合
可达定义(前向,交汇为并集):
- 定义 d 到达 B 的入口若存在某前驱路径未被重定义杀死。
- IN/OUT 类似可用表达式,但对定义流对象集合进行运算。
常量传播(稀疏解法):
- 基于 SSA,通过“格”传播(常量、非常量、未定义);Phi 函数合并格值。
SSA 形式与优化
- 静态单赋值(SSA):每个变量只赋值一次,便于数据流分析和优化。
- Phi 函数:在控制流汇合处合并不同路径的变量值。
构造步骤:
- 插入 Φ:在支配边界(dominance frontier)位置为变量插入 φ
- 重命名:沿支配树深度优先遍历,用栈记录每个变量当前版本 v_i
例:
x = 0
if (c) x = 1
y = x
转换:
x0 = 0
if (c) x1 = 1
x2 = phi(x0, x1)
y0 = x2
SSA 上的优化:
- 常量传播:phi(k, k) -> k;phi(k, ⊥) -> k;死 φ 删除
- 冗余消除:GVN(全局值编号)、PRE(部分冗余消除)
- 跨基本块复制传播与死代码消除更简单
从 SSA 恢复:
- 插入并行赋值的拷贝以实现 φ;然后寄存器/内存映射。
指令级并行
并行性基础
指令级并行(ILP)通过同时执行多条指令提升处理器利用率。现代处理器支持流水线、乱序执行、超标量等机制。
- 流水线:将指令分解为多个阶段,多个指令可在不同阶段并行处理。
- 乱序执行:允许指令不按程序顺序执行,只要数据依赖得到满足。
- 超标量:每周期可发射多条指令到不同执行单元。
并行性分析
- 数据相关性:包括数据依赖(RAW)、反相关(WAR)、输出相关(WAW),影响指令能否并行。
- 调度与重排序:编译器可调整指令顺序,减少依赖冲突和流水线停顿。
- 寄存器重命名:消除伪相关(WAR/WAW),提升并行度。
依赖图构造:
- 节点为指令,边表示依赖类型与延迟(latency)
- 列表调度:依据就绪集合与启发式(关键路径长度、资源可用性)选择发射顺序
小例(假设 mul 延迟 3,add 延迟 1):
1: t1 = a * b
2: t2 = c * d
3: t3 = t1 + t2
4: e = t3 + f
- 1、2 可并行;3 依赖 1、2;4 依赖 3。
- 调度:周期 0 发 1、2;周期 3 发 3;周期 4 发 4。
编译器支持
- 循环展开:增加循环体内指令数,提升并行度。
- 指令调度:重新排列指令,减少流水线气泡。
- 自动向量化:将标量操作转化为向量操作,利用 SIMD 指令集。
向量化前提:
- 无迭代间真实依赖(RAW);可通过依赖测试(GCD、Banerjee)判断。
- 内存对齐与步长一致;处理边界余数(remainder loop)。
GCD 测试简例:
- 访问 a[i] 与 a[i+3] 是否可能别名?差为 3,与步长 1 的 GCD 为 1,可能冲突;跨迭代依赖预防向量化。
并行性和局部性的优化
并行性优化
- 任务划分:将程序划分为可独立执行的任务或线程。
- 数据分区:将数据划分为独立块,减少线程间依赖。
- 同步与通信优化:减少锁和同步操作,降低并发开销。
OpenMP 示例:
#pragma omp parallel for schedule(static)
for (int i=0;i<n;i++) C[i] = A[i] + B[i];
注意避免伪共享:相邻线程写入同一缓存线不同元素引发竞争,解决:对齐/填充或按块划分。
局部性优化
- 时间局部性:同一数据多次访问,适合缓存。
- 空间局部性:相邻数据被连续访问,适合预取和缓存行优化。
- 循环交换:调整循环顺序,提升数据访问的局部性。
- 数据布局优化:调整数据结构布局,减少缓存未命中。
循环变换例:
- 交换(loop interchange):
for i for j A[j][i] // 行主序下差 => for j for i A[j][i] // 连续访问
- 分块/tiling:
for ii in 0..n step B for jj in 0..n step B for i in ii..ii+B for j in jj..jj+B C[i][j] += ...
并行编译技术
- 自动并行化:编译器自动识别可并行代码,插入并行指令或线程。
- OpenMP、CUDA 等支持:为多核和 GPU 编程提供编译器级优化。
CUDA 简要示例:
__global__ void saxpy(float a, float* x, float* y, float* out, int n) {
int i = blockIdx.x*blockDim.x + threadIdx.x;
if (i < n) out[i] = a*x[i] + y[i];
}
注意事项:
- 全局内存合并访问(coalescing)
- 共享内存用于块内数据重用
- 避免分支发散
过程间分析
过程间优化的意义
过程间分析(IPA)跨越单个函数或模块,分析和优化整个程序,提高全局性能。
- 跨过程常量传播:在多个函数间传播常量信息。
- 跨过程死代码消除:移除未被调用的函数和无用代码。
- 跨过程内联:将被频繁调用的小函数内联到调用点。
示例:
inline int add1(int x){ return x+1; }
int f(){ return add1(4); } => 5 // 内联 + 常量折叠
依赖分析与调用图
- 调用图:描述函数间调用关系,指导内联和优化顺序。
- 别名分析:分析指针和引用的别名关系,避免错误优化。
- 全局变量分析:追踪全局变量的定义和使用,优化其访问。
调用图构造:
- 节点为函数,边为调用
- 虚调用(C++/Java)需要去虚拟化:类层次分析(CHA)、快速类型分析(RTA)得到可能目标集
别名分析层级:
- 局部别名(栈对象)、全局别名(堆/全局)、场敏感/不敏感、上下文敏感/不敏感
- Andersen(包含式)与 Steensgaard(统一式)近似
链接时优化
- 链接时优化(LTO):编译器在链接阶段进行全程序优化,消除模块间边界。
- 跨模块内联与优化:提升最终可执行文件的性能。
实现:
- 以 IR(如 LLVM bitcode)为链接单元;链接器驱动优化管线(内联、DCE、CSE、GVN、CFG 简化)。
安全与可维护性
- 安全检查插桩:在过程间插入安全检查代码,防止越界、空指针等错误。
- 可维护性分析:辅助重构、代码分割和模块化。
示例:
- 边界检查插桩与消除:证明索引安全后移除检查。
- 空指针检查移动:将频繁异常路径外提,减少重复检查。