编译器的六大阶段详解以一行赋值语句为例

引言:打开编译器的"黑盒"

上一篇我们知道了编译器是一个"翻译器"------输入源程序,输出目标程序。但这个翻译过程内部到底发生了什么?

如果打开编译器这个"黑盒",我们会看到两大部分:

  • 分析 (Analysis) --- 也叫前端 (Front End):拆解源程序,理解它的结构和含义
  • 综合 (Synthesis) --- 也叫后端 (Back End):根据理解的结果,构造出目标程序

进一步细化,编译过程由 六个阶段 (phases) 组成,外加一个贯穿始终的符号表 (Symbol Table)。本文将用龙书中最经典的例子------一行赋值语句------带你走完编译的全过程。

一、编译器的整体结构

前三个阶段属于前端 (分析),后三个阶段属于后端(综合),符号表管理贯穿整个过程。

二、经典例子:编译一行赋值语句

我们用龙书中贯穿全章的经典例子来走完每个阶段:

c 复制代码
position = initial + rate * 60

这是一条简单的赋值语句。假设 positioninitialrate 都是已声明的浮点型变量,60 是一个整数常量。


阶段一:词法分析 (Lexical Analysis / Scanning)

任务 :将字符流分解为有意义的词素 (lexeme) ,并为每个词素生成一个词法单元 (token)

输入 :字符流 p o s i t i o n = i n i t i a l + r a t e * 6 0

处理过程:词法分析器逐字符扫描,识别出以下词素:

序号 词素 (Lexeme) 词法单元 (Token) 说明
1 position <id, 1> 标识符,指向符号表第1项
2 = <=> 赋值运算符
3 initial <id, 2> 标识符,指向符号表第2项
4 + <+> 加法运算符
5 rate <id, 3> 标识符,指向符号表第3项
6 * <*> 乘法运算符
7 60 <60> 整数常量

输出:词法单元流

复制代码
<id,1>  <=>  <id,2>  <+>  <id,3>  <*>  <60>

关键点

  • 空格被丢弃(它们只是分隔符,不携带语义信息)
  • 每个标识符只存储一次到符号表中,token 通过指针引用
  • token 的第一个分量(如 id=+)是抽象符号,用于后续语法分析
  • token 的第二个分量(如 123)是属性值,指向符号表条目

符号表状态

条目 变量名 类型 ...其他属性
1 position float ...
2 initial float ...
3 rate float ...

阶段二:语法分析 (Syntax Analysis / Parsing)

任务 :根据 token 流构造一棵语法树 (syntax tree),描述程序的语法结构。

输入<id,1> <=> <id,2> <+> <id,3> <*> <60>

处理过程:语法分析器根据语言的文法规则,构造出如下语法树:

这棵树告诉我们

  • * (乘法) 在 + (加法) 的子树中 → 乘法先于加法执行
  • 整棵树的根是 = → 最后将右侧的计算结果赋给 position
  • 树的结构自然地反映了运算的优先级结合性

关键点

  • 语法分析器不关心变量的具体值,它只关心结构是否合法
  • 如果写了 position = initial + * 60,语法分析器会报告语法错误 (syntax error)
  • 语法树的构造依赖于文法 (grammar)------这将在第4章深入讨论

阶段三:语义分析 (Semantic Analysis)

任务 :利用语法树和符号表信息检查源程序的语义一致性 ,主要是类型检查 (type checking)

输入:上一步的语法树

处理过程

  1. 检查语法树中每个运算符的操作数类型是否匹配
  2. 发现 rate(float)和 60(int)相乘------需要类型转换
  3. 插入一个 inttofloat 节点,将整数 60 转换为浮点数 60.0

输出:标注了类型信息的语法树

关键点

  • 语义分析发现的是含义层面的错误,而非结构层面的
  • 例如:用浮点数做数组下标、类型不兼容的赋值等
  • 类型强制 (coercion):编译器自动插入类型转换(如 int→float)
  • 有些语言的语义检查更严格(如 Rust 的所有权检查),也在这个阶段完成

阶段四:中间代码生成 (Intermediate Code Generation)

任务 :将语法树翻译为一种中间表示 (Intermediate Representation, IR) ------通常是三地址码 (three-address code)

输入:标注类型信息的语法树

输出:三地址码

复制代码
t1 = inttofloat(60)
t2 = id3 * t1
t3 = id2 + t2
id1 = t3

什么是三地址码?

三地址码的每条指令最多包含三个"地址"(操作数),形式为:

复制代码
x = y  op  z

其中 xyz 可以是变量名、常量或编译器生成的临时变量 (temporary variable) (如 t1t2t3)。

三地址码的关键特征

  1. 每条指令只有一个运算符 → 明确了运算顺序(先乘后加)
  2. 编译器生成临时变量 → 保存中间计算结果
  3. 有些指令少于三个操作数 → 如 t1 = inttofloat(60) 只有一个操作数

为什么需要中间代码?

  • 中间代码是一种"通用语言",独立于源语言和目标机器
  • 便于进行机器无关的优化
  • 一个前端可以对接多个后端(不同目标机器),一个后端可以对接多个前端(不同源语言)

阶段五:代码优化 (Code Optimization)

任务:改进中间代码,使生成的目标代码更高效(通常是更快或更小)。

输入

复制代码
t1 = inttofloat(60)
t2 = id3 * t1
t3 = id2 + t2
id1 = t3

优化过程

  1. 常量折叠 (Constant Folding)inttofloat(60) 可以在编译时计算,直接替换为 60.0
  2. 临时变量消除t3 只被使用一次就赋给了 id1,可以直接消除

输出

复制代码
t1 = id3 * 60.0
id1 = id2 + t1

从4条指令缩减为2条------代码量减半,执行速度更快。

关键点

  • 优化是可选的阶段------即使不优化,编译器也能生成正确的代码
  • 不同编译器优化程度差异很大------"优化编译器"会在这个阶段花费大量时间
  • "优化"是个不精确的术语:编译器无法保证生成"最优"代码(这个问题通常是不可判定的),它只是尽力改进
  • 第9章将系统地讨论各种优化技术

阶段六:代码生成 (Code Generation)

任务 :将中间代码翻译为目标机器的代码------分配寄存器、选择指令。

输入

复制代码
t1 = id3 * 60.0
id1 = id2 + t1

输出:(假设一个简单的目标机器,有寄存器 R1、R2)

asm 复制代码
LDF   R2, id3          # 将 id3 (rate) 的值加载到寄存器 R2
MULF  R2, R2, #60.0    # R2 = R2 * 60.0 (# 表示立即数)
LDF   R1, id2          # 将 id2 (initial) 的值加载到寄存器 R1
ADDF  R1, R1, R2       # R1 = R1 + R2
STF   id1, R1          # 将 R1 的值存储到 id1 (position)

指令含义

  • LDF = Load Float(加载浮点数到寄存器)
  • MULF = Multiply Float(浮点数乘法)
  • ADDF = Add Float(浮点数加法)
  • STF = Store Float(将寄存器值存储到内存)
  • 指令中的 F 后缀表示操作的是浮点数
  • #60.0 中的 # 表示这是一个立即数常量

关键点

  • 寄存器分配 (Register Allocation) 是代码生成中最关键的问题------寄存器数量有限,如何最高效地使用它们?
  • 代码生成器必须了解目标机器的指令集、寄存器数量、寻址模式等
  • 第8章将详细讨论代码生成技术

三、贯穿全程的符号表管理

符号表 (Symbol Table) 是编译器中的核心数据结构,记录源程序中所有标识符的相关信息。

符号表存储哪些信息?

  • 名字:变量/函数/类的标识符
  • 类型:int, float, char[], struct, 函数签名等
  • 作用域:在哪个代码块中可见
  • 存储位置:内存地址或栈偏移量
  • 对于函数:参数数量、参数类型、参数传递方式、返回类型

哪些阶段使用符号表?

  • 词法分析:将新标识符加入符号表
  • 语法分析:查询标识符的声明信息
  • 语义分析:获取类型信息进行类型检查
  • 中间代码生成:查询类型以确定运算指令
  • 代码生成:查询存储分配信息以生成地址

四、阶段的分组与"遍" (Passes)

在实际实现中,上述六个逻辑阶段不一定对应六次独立的处理。多个阶段可以合并为一个遍 (pass)------读一次输入文件,写一次输出文件。

典型的分组方式

前端/后端分离的工程价值

通过精心设计的中间表示,可以实现前端和后端的解耦

复制代码
                   ┌──────────┐
  C 前端 ─────────>│          │─────────> x86 后端
                   │   中间   │
  Java 前端 ──────>│   表示   │─────────> ARM 后端
                   │  (IR)   │
  Python 前端 ────>│          │─────────> RISC-V 后端
                   └──────────┘
  • m 种源语言 + n 种目标机器 只需要 m + n 个组件(而不是 m × n 个编译器)
  • LLVM 就是这种架构的成功实践:Clang(C/C++)、Rust、Swift 等语言共享 LLVM 后端

五、编译器构造工具

龙书提到了几类帮助构建编译器的工具:

工具类别 功能 代表工具
语法分析器生成器 从文法自动生成语法分析器 Yacc, Bison, ANTLR
扫描器生成器 从正则表达式自动生成词法分析器 Lex, Flex
语法制导翻译引擎 遍历分析树并生成中间代码 ---
代码生成器的生成器 从规则自动生成代码生成器 BURG
数据流分析引擎 分析程序中值的传播方式 ---
编译器构造工具包 上述工具的集成套件 LLVM

六、六阶段完整图解总结

让我们用一张表把整个编译过程梳理清楚:

阶段 名称 输入 输出 核心任务 详细章节
1 词法分析 字符流 token流 识别词素,生成token Ch3
2 语法分析 token流 语法树 检查语法结构,构造树 Ch4
3 语义分析 语法树 标注语法树 类型检查,插入类型转换 Ch6
4 中间代码生成 标注语法树 三地址码等 IR 生成中间表示 Ch6
5 代码优化 IR 优化后的 IR 常量折叠、死代码消除等 Ch8-9
6 代码生成 优化后的 IR 目标机器代码 寄存器分配、指令选择 Ch8
--- 符号表管理 --- --- 贯穿全程,存储标识符信息 Ch2

七、思考题

Q1: 如果编译器不做优化阶段,程序还能正确运行吗?

答: 。优化是可选的,它只影响性能,不影响正确性。事实上,在开发调试阶段,关闭优化(如 gcc -O0)能让调试更容易,因为生成的代码与源代码的对应关系更直接。

Q2: 为什么不直接从源代码生成目标代码,而要经过中间代码?

答:中间代码是前端和后端之间的"契约"。它使得前端和后端可以独立开发和替换(m+n vs m×n 的优势)。同时,在中间代码层面可以做与机器无关的优化,这些优化对所有目标机器都有效。

Q3: position = initial + rate * 60 如果 rate 没有被声明,编译器会在哪个阶段报错?

答:取决于编译器的实现。词法分析阶段可以识别 rate 是一个标识符,但不检查它是否被声明。语义分析阶段 会发现符号表中没有 rate 的条目,从而报告"未声明的变量"错误。

相关推荐
阿昭L9 小时前
浅析64位Windows的SEH机制
windows·逆向工程·seh
阿昭L1 天前
静态反调试技术
逆向工程·反调试
胡耀超7 天前
Web Crawling 网络爬虫全景:技术体系、反爬对抗与全链路成本分析
前端·爬虫·python·网络爬虫·数据采集·逆向工程·反爬虫
阿昭L8 天前
CodePatch hook api
hook·逆向工程
阿昭L10 天前
《逆向工程核心原理》使用调试函数hook api的实验在64位Windows11上的复现
hook·逆向工程
阿昭L15 天前
PE文件之资源表
pe结构·逆向工程
九成宫16 天前
编译技术/编译原理期末复习
笔记·软件工程·编译原理·编译技术
故事和你9118 天前
sdut-程序设计基础Ⅰ-实验二选择结构(1-8)
大数据·开发语言·数据结构·c++·算法·优化·编译原理
Pure_White_Sword1 个月前
bugku-reverse题目-NoString
网络安全·ctf·reverse·逆向工程