编译基础回顾:编译器工作流程

说到编译器,很多做重构的同学会觉得这是底层系统工程师才需要关心的事。但我个人的经验是——不懂编译流程,你连代码是怎么被“吃掉”的都不知道,重构时很容易踩坑。

今天我们就来聊聊编译器的工作流程。说白了,它就是把人类能读懂的代码,一步步翻译成机器能跑的东西。整个过程分为六个阶段:词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成。

词法分析 语法分析 语义分析 中间代码生成 优化 目标代码生成 源代码 目标代码 编译器工作流程:从源代码到目标代码的六阶段转换 1 2 3 4 5 6

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 是否在循环内
避坑指南:我曾经重构一个大型 C++ 项目时,发现一个函数在头文件里声明返回 int,但实现里返回了 string。编译器在语义分析阶段报了类型不匹配的错误。但因为代码量太大,我一开始没注意到这个细节,结果浪费了两天时间定位。所以我的建议是——重构时一定要重视编译器的每一个语义警告,别想着“先改完再说”。

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;
我的习惯:在重构时,我通常会先关掉优化选项(比如 GCC 的 -O0),等代码逻辑完全正确后再打开优化。为什么?因为优化有时会改变调试信息的对应关系,让你在调试时看到“奇怪”的变量值。等代码稳定了,再让编译器帮你做性能优化。

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,你就明白为什么某些语法结构会导致解析歧义
  • 知道语义分析怎么查类型,你就懂得类型安全的重要性
  • 知道优化阶段会做什么,你就不会写出“看似高效实则无用”的微优化

说白了,编译器是你代码的第一个“读者”。了解它的阅读习惯,你写出来的代码自然更干净、更可靠。

一句话总结:编译器工作流程就是“拆词→组句→查意→转译→优化→生成”六步走。每一步都有它的职责,也都有你可能踩的坑。重构时多想想编译器会怎么处理你的代码,很多问题就能提前避免。