编译基础回顾:编译器工作流程
说到编译器,很多做重构的同学会觉得这是底层系统工程师才需要关心的事。但我个人的经验是——不懂编译流程,你连代码是怎么被“吃掉”的都不知道,重构时很容易踩坑。
今天我们就来聊聊编译器的工作流程。说白了,它就是把人类能读懂的代码,一步步翻译成机器能跑的东西。整个过程分为六个阶段:词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成。
1. 词法分析:把代码拆成“单词”
编译器做的第一件事,就是把你的源代码拆成一个个最小的语法单元——Token。Token 包括关键字(if、while)、标识符(变量名、函数名)、运算符(+、-)、字面量(数字、字符串)等等。
举个例子,对于代码 int a = 10 + b;,词法分析器会把它拆成:
Token: "int" (关键字)
Token: "a" (标识符)
Token: "=" (赋值运算符)
Token: "10" (整数字面量)
Token: "+" (加法运算符)
Token: "b" (标识符)
Token: ";" (分号)
嗯,这里要注意——词法分析只关心“拆”,不关心“对不对”。比如你写了个 int 123abc = 0;,词法分析器照样会拆出 int、123abc、=、0、;。至于 123abc 是不是合法的标识符?那是后面语法分析的事。
2. 语法分析:检查句子结构
词法分析拆出了 Token 流,语法分析就要检查这些 Token 的组合是否符合语言的语法规则。它会把 Token 流组织成一棵抽象语法树(AST)。
你想想看,自然语言里“我吃饭”是合法句子,“饭吃我”就不对。编程语言也一样。int a = 10; 是合法的声明语句,int = a 10; 就不行。
语法分析器通常用上下文无关文法来描述语言结构。比如一个简单的赋值语句可以定义为:
assignment_stmt ::= type identifier '=' expression ';'
expression ::= term ( '+' term )*
term ::= identifier | number
对于 int a = 10 + b;,语法分析会生成类似这样的 AST:
assignment_stmt
/ | | \
type id '=' expression
| | / | \
int a term '+' term
| |
10 b
int a = "hello"; 在语法层面是合法的(赋值语句结构完整),但类型不匹配——那是语义分析要管的事。
3. 语义分析:检查代码有没有“意义”
语法分析通过了,不代表代码就是对的。语义分析要检查代码的静态语义——类型是否匹配、变量是否声明、作用域是否正确等等。
我举个例子:
int a = "hello"; // 语法正确,但语义错误(类型不匹配)
b = 10; // 语法正确,但语义错误(b 未声明)
语义分析阶段会维护一个符号表,记录每个标识符的类型、作用域等信息。然后遍历 AST,检查每个节点的语义约束。
常见的语义检查包括:
- 类型检查:赋值、函数参数的类型是否匹配
- 作用域检查:变量是否在当前作用域内声明
- 唯一性检查:同一作用域内是否有重复声明
- 控制流检查:break/continue 是否在循环内
4. 中间代码生成:架起一座“桥梁”
语义分析之后,编译器会生成一种中间表示(IR)。它既不像源代码那么高层,也不像机器码那么底层。常见的 IR 形式有三地址码、SSA(静态单赋值形式)等。
为什么要多此一举?说白了,是为了解耦。前端(词法、语法、语义分析)只跟语言相关,后端(目标代码生成)只跟硬件平台相关。中间代码就是它们之间的桥梁。
比如 int a = 10 + b; 可能被翻译成这样的三地址码:
t1 = 10 // 加载常量
t2 = t1 + b // 加法运算
a = t2 // 赋值
你可能会问:为什么不直接生成机器码?嗯,原因很简单——如果编译器要为每种语言和每种硬件都写一套完整的编译流程,那工作量就太大了。有了 IR,前端只需要生成 IR,后端只需要处理 IR,两者可以独立开发和优化。
5. 优化:让代码跑得更快
优化阶段是编译器最“聪明”的地方。它会在不改变程序语义的前提下,对中间代码进行各种变换,让最终生成的代码更高效。
常见的优化技术包括:
- 常量折叠:
a = 2 + 3→a = 5 - 死代码消除:删除永远不会执行的代码
- 循环优化:循环展开、循环不变式外提等
- 内联展开:将函数调用替换为函数体本身
举个例子,常量折叠:
// 优化前
int x = 10;
int y = x + 20;
int z = y * 2;
// 优化后(常量折叠 + 常量传播)
int x = 10;
int y = 30;
int z = 60;
6. 目标代码生成:变成机器能懂的语言
最后一步,编译器把优化后的中间代码转换成目标机器的汇编代码或机器码。这一步涉及指令选择、寄存器分配、指令调度等复杂问题。
比如,对于 a = b + c 这个三地址码,在 x86 架构上可能生成:
mov eax, [b] ; 将 b 的值加载到 eax 寄存器
add eax, [c] ; 将 c 的值加到 eax
mov [a], eax ; 将结果存回 a
而在 ARM 架构上,可能是:
ldr r0, [b] ; 加载 b 到 r0
ldr r1, [c] ; 加载 c 到 r1
add r0, r0, r1 ; r0 = r0 + r1
str r0, [a] ; 存储结果到 a
你看,同样的逻辑,不同硬件平台的指令完全不同。这就是为什么中间代码这么重要——前端不用关心你是 x86 还是 ARM,后端也不用关心你写的是 C 还是 Java。
小结:这跟项目重构有什么关系?
你可能会想:我重构代码,了解编译器流程有什么用?
我个人的体会是——理解编译器的“思维方式”,能帮你写出更“友好”的代码。比如:
- 知道词法分析怎么拆 Token,你就不会写出让编译器“困惑”的代码(比如全角符号)
- 知道语法分析怎么建 AST,你就明白为什么某些语法结构会导致解析歧义
- 知道语义分析怎么查类型,你就懂得类型安全的重要性
- 知道优化阶段会做什么,你就不会写出“看似高效实则无用”的微优化
说白了,编译器是你代码的第一个“读者”。了解它的阅读习惯,你写出来的代码自然更干净、更可靠。