2、编译原理基础回顾:词法分析、语法分析、语义分析、中间表示、优化与代码生成的基本流程
编译器的本质是一个“翻译器”,它将高级语言(如 C/C++、Rust、Swift)翻译为低级语言(如汇编或机器码)。无论目标平台是 x86、ARM 还是 RISC-V,这一翻译过程都遵循一套经典的分阶段架构。理解这套架构不仅是学习 LLVM 的前提,更是后续深入 IR(中间表示)、Pass 管线和后端代码生成的基础。本章将系统回顾编译器的六大核心阶段——词法分析、语法分析、语义分析、中间表示、优化与代码生成,并结合 LLVM 的实际工程实现,帮助读者建立从源码到机器码的完整认知链路。
上图展示了编译器从源码到机器码的完整流水线。在 LLVM 架构中,这条流水线被清晰地划分为三个部分:前端(Clang)、中端(优化器)和后端(代码生成器)。前端负责将源代码转换为 LLVM IR,中端对 IR 进行平台无关的优化,后端则将优化后的 IR 转换为目标平台的机器码。下面我们逐一深入每个阶段的原理与实现。
2.1 词法分析:从字符流到 流
核心原理
词法分析(Lexical Analysis)是编译器的第一个阶段,其输入是原始源代码字符流,输出是 序列。 是源代码的最小语法单元,通常包含关键字(如 if、while)、标识符(变量名、函数名)、字面量(整数、浮点数、字符串)、运算符(+、-、*)和分隔符(括号、分号)。词法分析器(Lexer)通过有限状态自动机(DFA)识别这些模式,并丢弃注释和空白字符。
在 LLVM 中,Clang 的 Lexer 实现于 clang/lib/Lex 目录,核心类是 clang::Lexer 和 clang::。Clang 的 Lexer 不仅处理常规 ,还处理预处理器指令(如 #include、#define),这是 C/C++ 词法分析的特殊之处。
判断方法与操作流程
词法分析器的实现通常遵循以下步骤:
- 输入缓冲:将源码读入内存缓冲区,维护当前字符指针。
- 状态转移:根据当前字符和状态,决定下一个状态。例如,遇到
/时,需要判断是除法运算符还是注释的开始。 - 生成:当识别出一个完整的词素(Lexeme)时,生成对应的 ,记录其类型、位置(行号、列号)和文本值。
- 错误处理:遇到无法识别的字符时,报告词法错误(如非法字符
@)。
| 类型 | 示例 | LLVM 中的表示 |
|---|---|---|
| 关键字 | if, return | tok::kw_if |
| 标识符 | foo, _count | tok::identifier |
| 整数字面量 | 42, 0xFF | tok::numeric_constant |
| 字符串字面量 | "hello" | tok::string_literal |
| 运算符 | +, -> | tok::plus, tok::arrow |
案例拆解:Clang 处理 int x = 42;
假设输入源码为 int x = 42;,Clang 的 Lexer 会依次生成以下 流:
tok::kw_int // 关键字 int
tok::identifier // x
tok::equal // =
tok::numeric_constant // 42
tok::semi // ;
每个 还携带 SourceLocation 信息,用于后续诊断和调试。例如,42 的 SourceLocation 指向第 1 行第 12 列。
llvm::StringRef 和正则表达式库自行实现。但要注意,C/C++ 的预处理与词法分析是交织的,Clang 通过 Preprocessor 类统一管理。
常见误区
- 混淆词法分析与语法分析:词法分析只负责识别 ,不负责检查语法结构。例如,
int int在词法上合法(两个关键字 ),但在语法上非法。 - 忽略 Unicode 支持:现代语言(如 Swift)支持 Unicode 标识符,词法分析器需要处理多字节字符编码。
- 错误恢复策略:生产级编译器在遇到词法错误时不应立即停止,而应跳过错误字符并继续扫描,以便报告更多错误。
2.2 语法分析:从 流到抽象语法树
核心原理
语法分析(Syntax Analysis / Parsing)接收 流,根据语言的上下文无关文法(CFG)构建抽象语法树(AST)。AST 是源代码的树状结构表示,去除了括号、分号等冗余信息,保留了表达式的嵌套关系和语句的执行顺序。语法分析器(Parser)通常采用两种策略:自顶向下(递归下降)或自底向上(LR 系列)。
Clang 使用递归下降解析器,手写实现于 clang/lib/Parse。每个语法规则对应一个解析函数,例如 ParseIfStmt()、ParseExpr()。手写解析器的优势是错误信息更友好、支持增量解析(用于 IDE 代码补全)。
判断方法与操作流程
递归下降解析的基本流程:
- 预测:根据当前 类型,决定调用哪个解析函数。例如,看到
if关键字,调用ParseIfStmt()。 - 匹配:解析函数内部按文法规则依次消费 ,并递归调用子解析函数。
- 构建 AST 节点:每解析完一个语法单元,创建对应的 AST 节点(如
IfStmt、BinaryOperator)。 - 错误恢复:当 不匹配时,尝试同步(Synchronization)——跳过 直到找到安全的恢复点(如分号或右花括号)。
案例拆解:解析 if (x > 0) return x;
Clang 的解析过程如下:
ParseIfStmt():
Expect tok::kw_if
Expect tok::l_paren
ParseExpr() → 解析 x > 0,生成 BinaryOperator 节点(Opcode=GT)
Expect tok::r_paren
ParseStmt() → 解析 return x;,生成 ReturnStmt 节点
构建 IfStmt 节点,包含条件表达式和 then 分支
最终生成的 AST 结构(简化):
IfStmt
├── Condition: BinaryOperator '>'
│ ├── LHS: DeclRefExpr 'x'
│ └── RHS: IntegerLiteral 0
└── Then: ReturnStmt
└── RetValue: DeclRefExpr 'x'
clang/include/clang/AST 中,所有节点继承自 Stmt 或 Decl。AST 不仅用于语义分析,还驱动 IDE 的代码高亮、重构和静态分析。LLVM 的 IR 生成(CodeGen)也是通过遍历 AST 完成的。
常见误区
- 文法歧义:如悬空 else(dangling else)问题,需要优先级和结合性规则解决。
- 左递归:递归下降解析器无法直接处理左递归文法,需要改写为右递归或使用循环。
- 性能陷阱:回溯(Backtracking)会显著降低解析速度,应通过预测(Lookahead)避免。
2.3 语义分析:类型检查与作用域解析
核心原理
语义分析(Semantic Analysis)确保程序在语义上合法,而不仅仅是语法上正确。主要任务包括:类型检查(如 int + float 是否允许)、作用域解析(变量是否已声明)、函数重载决议、隐式类型转换等。语义分析通常基于 AST 进行,维护一个符号表(Symbol Table)记录变量、函数、类型的属性和作用域。
在 Clang 中,语义分析由 Sema 类完成(clang/lib/Sema)。Sema 在 Parser 构建 AST 节点时被回调,例如解析完一个表达式后,Sema 会检查其类型是否匹配上下文。
判断方法与操作流程
语义分析的核心流程:
- 符号表管理:进入新作用域时压栈,退出时弹栈。查找变量时从当前作用域向外层逐级搜索。
- 类型推导:对表达式节点,根据子节点的类型推导出父节点的类型。例如,
a + b的类型取决于a和b的类型及运算符重载规则。 - 类型检查:验证操作数类型是否满足运算符要求。例如,取地址运算符
&要求操作数是左值。 - 隐式转换:当类型不匹配但可转换时,插入转换节点(如
int提升为long)。
案例拆解:类型不匹配的检测
考虑以下代码:
int foo() { return "hello"; }
Clang 的 Sema 在解析 return "hello"; 时,会检查返回表达式类型(const char[6])与函数返回类型(int)是否兼容。由于不存在从字符串字面量到 int 的隐式转换,Sema 会报告错误:
error: cannot initialize return object of type 'int' with an lvalue of type 'const char[6]'
常见误区
- 作用域泄漏:忘记在退出作用域时清理符号表,导致变量被错误地访问。
- 类型等价性:结构体类型是“结构等价”还是“名称等价”?C 语言使用名称等价,而某些语言使用结构等价。
- 延迟检查:某些语义检查(如模板实例化)需要延迟到实例化时进行,不能在前端立即完成。
2.4 中间表示:LLVM IR 的设计与生成
核心原理
中间表示(Intermediate Representation, IR)是编译器前端和后端之间的桥梁。LLVM IR 是一种静态单赋值(SSA)形式的低级别语言,具有以下特点:
- 三地址码:每条指令最多有一个运算符和三个操作数(两个源、一个目标)。
- 无限寄存器:SSA 形式下,每个变量只被赋值一次,因此不需要考虑寄存器数量限制。
- 显式类型:每个值都有明确的类型(如
i32、float、ptr)。 - 内存操作显式化:加载(
load)和存储(store)指令明确区分内存访问和寄存器操作。
LLVM IR 有三种表示形式:内存中的数据结构(llvm::Module、llvm::Function、llvm::BasicBlock)、文本形式(.ll 文件)和二进制位码(.bc 文件)。三种形式可以互相转换。
判断方法与操作流程
从 AST 生成 LLVM IR 的过程称为 IR CodeGen,在 Clang 中由 CodeGen 模块完成(clang/lib/CodeGen)。核心流程:
- 类型映射:将 Clang 类型(如
int)映射为 LLVM 类型(i32)。 - 函数生成:为每个函数创建
llvm::Function,并生成入口基本块。 - 语句翻译:遍历 AST 语句,生成对应的 IR 指令。例如,
return x + 1;会生成add指令和ret指令。 - SSA 构建:通过
llvm::IRBuilder自动处理 SSA 形式,插入phi节点(如有必要)。
案例拆解:C 代码到 LLVM IR
考虑以下 C 函数:
int add(int a, int b) {
return a + b;
}
生成的 LLVM IR(文本形式)为:
define i32 @add(i32 %a, i32 %b) {
entry:
%add = add i32 %a, %b
ret i32 %add
}
可以看到,a + b 被翻译为 add i32 %a, %b,结果存储在虚拟寄存器 %add 中,然后通过 ret 返回。
常见误区
- 混淆 IR 与机器码:IR 不是机器码,它不包含寄存器分配、指令选择等后端信息。
- 忽略 SSA 约束:手动构建 IR 时,必须遵守 SSA 规则——每个虚拟寄存器只能赋值一次。如果需要更新值,必须创建新的虚拟寄存器。
- 内存操作过度:初学者倾向于将所有变量都放在内存中(通过
alloca),但优化器更擅长处理寄存器值。应尽量使用 SSA 值,减少内存访问。
2.5 优化:LLVM Pass 管线
核心原理
优化阶段对 IR 进行一系列等价变换,以提高程序运行效率或减小代码体积。LLVM 的优化器由多个 Pass 组成,每个 Pass 执行一种特定的优化。Pass 分为两类:函数内 Pass(如指令合并、死代码消除)和模块级 Pass(如内联、全局常量传播)。
LLVM 的优化管线通过 opt 工具或 PassBuilder 配置。常用的优化级别包括 -O0(无优化)、-O1(基本优化)、-O2(推荐优化)、-O3(激进优化)和 -Os(优化代码大小)。
判断方法与操作流程
一个典型的优化 Pass 执行流程:
- 收集分析结果:运行分析 Pass(如
DominatorTreeAnalysis、LoopInfo),获取程序的控制流和数据流信息。 - 识别优化机会:遍历 IR,寻找可优化的模式。例如,
add i32 %x, 0可以简化为%x。 - 执行变换:修改 IR 结构,删除冗余指令、合并基本块、替换常量等。
- 验证正确性:LLVM 提供
verify工具检查 IR 的一致性(如 SSA 约束、类型正确性)。
案例拆解:常量折叠与死代码消除
考虑以下 IR:
define i32 @test() {
%1 = add i32 2, 3
%2 = mul i32 %1, 4
%3 = sub i32 %2, 1
ret i32 %3
}
经过 -O2 优化后,所有指令都是常量表达式,优化器会直接计算出结果:
define i32 @test() {
ret i32 19
}
如果 %3 的结果未被使用,死代码消除 Pass 会将其删除。
opt -passes='print<domtree>' 可以查看支配树信息,帮助理解优化 Pass 的行为。调试自定义 Pass 时,建议先用 -print-after-all 查看每个 Pass 执行后的 IR 变化。
常见误区
- 过度优化:
-O3并不总是比-O2快,因为激进的内联和向量化可能增加代码体积,导致指令缓存命中率下降。 - 忽略浮点语义:浮点运算不满足结合律,
(a+b)+c不等于a+(b+c)。优化器默认不进行浮点重关联,除非指定-ffast-math。 - Pass 顺序依赖:某些 Pass 需要先运行其他 Pass 才能生效。例如,
GVN(全局值编号)依赖于支配树分析。
2.6 代码生成:从 IR 到机器码
核心原理
代码生成(CodeGen)是编译器的最后一个阶段,将优化后的 IR 转换为目标平台的汇编代码或机器码。LLVM 后端的主要子阶段包括:
- 指令选择(Instruction Selection):将 IR 指令映射为目标机器的指令。LLVM 使用 SelectionDAG 或 GlobalISel 两种框架。
- 指令调度(Scheduling):重排指令顺序,以利用 CPU 的流水线并行性。
- 寄存器分配(Register Allocation):将无限虚拟寄存器映射到有限的物理寄存器,必要时插入溢出(Spill)代码。
- 指令输出(Emission):生成汇编文本或二进制目标文件。
判断方法与操作流程
以 SelectionDAG 为例,指令选择流程:
- 构建 DAG:将基本块转换为有向无环图(DAG),节点表示操作,边表示数据依赖。
- 模式匹配:通过 TableGen 生成的匹配器,将 DAG 节点组合为目标指令。例如,
add i32在 x86 上可能匹配ADD32rr指令。 - 合法化(Legalization):将不支持的类型或操作转换为目标支持的形式。例如,64 位加法在 32 位平台上需要拆分为多条指令。
- 优化 DAG:在 DAG 层面