第一章 传统编译器基础
学习目标
- 理解编译器的定义、发展历程及其在计算机系统中的核心地位
- 掌握编译器的完整工作流程,包括词法分析、语法分析、语义分析、中间代码生成、优化和目标代码生成六个阶段
- 了解 GCC 和 LLVM 两大主流编译器架构的设计理念和工程实践
- 理解编译器前端与后端分离的架构思想及其对软件复用的重要意义
- 掌握常量折叠、代数化简、死代码消除等经典编译器优化技术的原理和实现
引言
在计算机科学的发展历程中,编译器技术无疑占据着举足轻重的地位。从 1957 年 IBM Fortran 编译器的诞生,到如今 LLVM、GCC 等开源编译器的广泛应用,编译器始终是连接人类编程思想与机器执行指令之间的桥梁。对于学习人工智能系统的人来说,理解传统编译器的原理不仅是计算机科学素养的体现,更是深入理解 AI 编译器、AI 框架等上层技术的必要基础。
现代深度学习系统大量借鉴了传统编译器的设计思想。例如,AI 编译器中的多层 IR(Intermediate Representation,中间表示)设计、Pass 优化机制、前端与后端分离架构,都可以追溯到传统编译器几十年的技术积累。因此,在进入 AI 编译器学习之前,我们有必要先打好传统编译器的理论基础。
本章将带领读者系统地认识传统编译器,从编译器的基本概念出发,深入剖析编译器的整体架构,详细讲解词法分析、语法分析、语义分析等核心技术,最后介绍 GCC 和 LLVM 两大主流编译器的基础架构。通过本章的学习,读者将为理解 AI 编译器奠定坚实的理论基础。
1.1 编译器概述
1.1.1 什么是编译器
编译器(Compiler) 是一种计算机程序,其主要功能是将一种编程语言(源语言)编写的代码翻译成另一种编程语言(目标语言)或机器代码。在大多数情况下,编译器将高级语言(如 C、C++、Java、Python)翻译成机器语言(低级语言),使得计算机能够直接执行人类编写的程序。
理解编译器的工作原理对于理解整个计算机系统至关重要。打个比方,如果把计算机程序比作一首乐谱,那么编译器就是将作曲家脑海中的音乐构思转化为演奏家能够演奏的音符的翻译者。没有编译器,程序员就必须直接用机器码编写程序,这不仅效率低下,而且极易出错。
在软件开发的生态系统中,编译器处于核心位置。它位于编程语言和硬件之间,向上承接了各种高级编程语言的语法和语义规范,向下则要充分利用硬件的特性生成高效的机器代码。可以说,编译器的质量直接影响着程序的执行效率、可靠性和可维护性。
1.1.2 编译器与解释器的区别
在讨论编译器时,不得不提到另一个重要的概念——解释器(Interpreter)。虽然两者都是将高级语言转换为机器语言的工具,但它们的工作方式有着本质的区别。
编译器采用先编译后执行的模式。编译器一次性将整个源程序翻译成目标代码(通常是机器码或中间字节码),生成独立的可执行文件。程序运行的时候,不需要源代码的存在,也不需要编译器的参与,直接执行编译后的目标代码即可。这种方式的优点是程序执行效率高,因为翻译工作已经在编译阶段完成;缺点是调试周期较长,修改代码后需要重新编译。
解释器采用边解释边执行的模式。解释器逐行读取源代码,将每一条语句翻译成机器码并立即执行,翻译和执行交替进行。这种方式的优点是交互性强,便于调试,特别适合脚本语言和快速原型开发;缺点是执行效率较低,因为每行代码都需要在运行时翻译。
用生活中的例子来理解:编译器就像是一位同声传译员,在会议开始前已经将所有发言稿翻译成目标语言,会议开始时直接播放翻译好的内容;而解释器则像是一位交替传译员,发言人说一句,译员翻译一句。两种方式各有优劣,适用于不同的场景。
在深度学习框架中,这种区分也很重要。例如,PyTorch 默认采用动态图模式(解释器风格),而 TensorFlow 1.x 则主要采用静态图模式(编译器风格)。PyTorch 2.0 引入的 TorchDynamo 正是为了让 PyTorch 能够同时获得静态图的分析优化能力和动态图的灵活交互性。
1.1.3 编译器的编译流程
现代编译器的编译流程通常可以分为六个主要阶段:词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成。这六个阶段构成了编译器的主体结构,每个阶段都有其独特的功能和实现方式。
下面我们将逐个介绍这六个阶段的基本概念,让读者对编译器的工作原理有一个整体的认知。
1.1.3.1 词法分析
词法分析(Lexical Analysis) 是编译过程的第一个阶段,其任务是将源程序的字符序列分割成一个个有意义的词素(Lexeme),并将这些词素转换为词法单元(Token)序列。
词法分析器(Scanner)是执行词法分析的程序模块。在词法分析过程中,扫描器从左到右逐个读取源程序的字符,按照预定的规则将字符序列组合成词素,同时识别每个词素的类型。
例如,对于如下 C 语言代码片段:
int a = 10 + 5;
词法分析器会将其分割成以下词法单元序列:
| 词素 | Token 类型 | 属性值 |
|---|---|---|
| int | 关键字 | - |
| a | 标识符 | a 的符号表入口 |
| = | 运算符 | - |
| 10 | 整数常量 | 10 |
| + | 运算符 | - |
| 5 | 整数常量 | 5 |
| ; | 分号 | - |
词法分析器在识别词素时,需要处理一些复杂的情况,包括空白字符的处理、注释的忽略、多字符符号的识别(如 ==、!=)、字符串常量的处理等。词法分析器的实现通常基于有限状态自动机(Finite State Automaton,FSA)的理论,其核心是将所有可能的词素模式表示为一个或多个状态机,然后根据输入字符在状态机中转移,最终识别出完整的词素。
有限状态自动机分为确定性有限状态自动机(DFA)和非确定性有限状态自动机(NFA)两种。在编译器实现中,通常先将正则表达式描述的词法规则转换为 NFA,再将 NFA 转换为 DFA,最后根据 DFA 生成词法分析程序。这种方法能够保证词法分析的效率,因为 DFA 的状态转移是确定的,不需要回溯。
词法分析阶段还会进行一些其他的处理工作,包括:过滤掉源程序中的空白字符和注释、记录词素在源程序中的位置信息(行号、列号),以便后续阶段报告错误时使用、以及将标识符加入符号表并记录相关信息。
词法分析虽然是编译过程中相对简单的阶段,但它直接影响后续阶段的工作效率和编译器整体的执行性能。一个高效的词法分析器应该能够快速准确地识别各类词素,同时为后续阶段提供完整准确的词法信息。
1.1.3.2 语法分析
语法分析(Syntax Analysis) 是编译过程的第二个阶段,其任务是根据语言的语法规则,将词法分析得到的 Token 序列分析成语法树(Syntax Tree)或抽象语法树(Abstract Syntax Tree,AST)。
语法分析器(Parser)是执行语法分析的程序模块。在语法分析过程中,解析器从词法分析器接收 Token 序列,根据语言的文法规则检查 Token 序列是否符合语法规范。如果发现语法错误,解析器需要报告错误的位置和类型,并尝试进行错误恢复,以便继续分析后续的 Token。
语法分析的核心是文法(Grammar)。文法是描述语言语法规则的形式化表示,目前最常用的是上下文无关文法(Context-Free Grammar,CFG)。上下文无关文法由四个组成部分:终结符集合、非终结符集合、产生式集合和起始符号。
例如,考虑一个简单的算术表达式文法:
E → E + T | T
T → T * F | F
F → ( E ) | id
这个文法描述了算术表达式由加法和乘法运算组成,其中乘法运算的优先级高于加法运算,且可以使用括号改变运算顺序。
语法分析的方法主要分为两类:自顶向下分析和自底向上分析。
自顶向下分析从文法的起始符号开始,试图推导出整个输入的 Token 序列。递归下降分析是最常见的自顶向下分析方法,它为每个非终结符编写一个递归函数,函数之间相互调用来匹配输入。预测分析是另一种自顶向下方法,它使用 lookahead(向前看)的 Token 来选择正确的产生式,适用于没有左递归的文法。
自底向上分析从输入的 Token 序列开始,试图将其归约为文法的起始符号。LR 分析是最常见的自底向上分析方法,包括 LR(0)、SLR(1)、LALR(1) 和 Canon LR(1) 等多种变体。LR 分析能够处理更广泛的文法,语法错误报告也更为准确,但分析表的构造比较复杂。
抽象语法树是语法分析的产物,它是一种树形数据结构,用于表示源代码的语法结构。在 AST 中,每个节点代表一个语法构造,如表达式、语句、声明等,节点的子节点代表该构造的组成部分。与语法树相比,抽象语法树省略了那些对语义理解不必要的节点,如括号等。
语法分析阶段产生的抽象语法树是后续语义分析和代码生成的重要基础。一个设计良好的 AST 应该能够清晰地表达源代码的语法结构,同时便于后续阶段的遍历和处理。
1.1.3.3 语义分析
语义分析(Semantic Analysis) 是编译过程中连接语法和语义的桥梁阶段,其任务是对语法分析产生的抽象语法树进行上下文相关的检查和处理,收集必要的信息,为后续的代码生成阶段做准备。
语义分析的主要工作包括类型检查、作用域分析、符号表管理和常量计算等。
类型检查确保程序中运算符和操作数的类型是兼容的。例如,在强类型语言中,整数不能直接与字符串进行加法运算;在 C 语言中,将浮点值赋给整数变量可能会产生警告(虽然不是错误)。类型检查可以在编译时进行(静态类型检查),也可以在运行时进行(动态类型检查)。现代编程语言通常采用静态类型检查配合类型推断的策略,在保证类型安全的同时保持编程的灵活性。
作用域分析确定每个标识符的引用对应哪个声明。作用域规则决定了在程序的某个位置能够访问哪些标识符。常见的包括词法作用域(静态作用域)和动态作用域。词法作用域根据源代码的结构确定标识符的可见性,是大多数现代语言采用的方案;动态作用域根据程序的执行栈确定标识符的可见性,已较少使用。
符号表管理贯穿整个编译过程。符号表是存储标识符相关信息的数据结构,包括标识符的名称、类型、作用域、内存位置(在生成代码后)等信息。词法分析阶段将标识符加入符号表,语义分析阶段更新符号表中的信息,后续阶段则查询符号表获取所需信息。
常量计算(常量折叠)是语义分析的一个简单但重要的任务。在语义分析阶段,编译器可以计算出那些只包含常量操作数的表达式的值,而不需要等到程序运行时才计算。例如,3 + 5 可以被计算为 8,10 * 20 可以被计算为 200。这种优化称为常量折叠(Constant Folding),是编译器优化的一部分。
语义分析阶段还会处理类型转换(Type Conversion)、函数调用检查、控制流分析等任务。函数调用检查确保函数调用的参数数量和类型与函数定义一致,确保函数返回值被正确处理。
语义分析阶段通常会输出增强的抽象语法树,树中包含更多的语义信息,如每个节点的类型信息、符号引用等。这些信息将传递给后续的中间代码生成阶段使用。
1.1.3.4 中间代码生成
中间代码生成(Intermediate Code Generation) 是编译过程中的重要阶段,其任务是将经过语义分析的抽象语法树翻译成一种与源语言无关、与目标机器也无直接关系的中间表示(Intermediate Representation,IR)。
中间表示是编译过程中使用的抽象数据结构,它介于源代码和目标代码之间。选择中间表示需要考虑几个因素:首先是表达能力,中间表示应该能够准确地表达源代码的语义,包括控制流、数据流等;其次是易于优化,中间表示应该便于进行各种编译优化;最后是易于翻译,中间表示应该能够方便地翻译成目标机器代码。
常见的中间表示形式包括三地址码(Three-Address Code,TAC)、静态单赋值形式(Static Single Assignment,SSA)、有向无环图(Directed Acyclic Graph,DAG)等。
三地址码是最常用的中间表示形式之一,其特点是每条指令最多涉及三个地址(操作数)。三地址码的指令类型包括:
- 算术指令:
a = b + c - 赋值指令:
a = b - 条件跳转:
if a == b goto L - 无条件跳转:
goto L - 函数调用:
param a,call f,return a - 数组访问:
a = b[i],a = &b[i]
三地址码的简单性和规范性使其特别适合进行代码优化。一个复杂的表达式可以被分解成一系列简单的三地址码指令,例如表达式 a + b * c 可以被翻译成:
t1 = b * c
t2 = a + t1
静态单赋值形式(SSA)是另一种重要的中间表示,其特点是每个变量在程序中只能被赋值一次。SSA 形式便于进行数据流分析和各种优化,因为它清晰地表示了每个值的定义和使用位置。在 SSA 形式中,如果一个变量需要在不同控制流路径中接受不同的值,需要引入phi函数来合并这些值。
有向无环图(DAG)是表达表达式的一种方式,图中叶子节点代表操作数,内部节点代表运算符,相同子表达式在 DAG 中只表示一次。DAG 便于识别公共子表达式和进行其他优化。
中间代码生成阶段还会进行一些翻译工作,包括:
- 将高级语言的控制结构(如 if、while、for)翻译成跳转指令序列
- 处理函数调用,包括参数传递、返回值处理、调用约定等
- 管理内存布局,包括局部变量、全局变量、参数等的存储分配
中间表示的设计是编译器架构的关键决策。良好的中间表示应该具有足够的通用性,能够表达各种源语言的特性,同时也要有足够的具体性,便于进行优化和翻译成目标代码。
1.1.3.5 优化
优化(Optimization) 是编译过程中提升代码质量的核心阶段,其任务是对中间代码进行分析和变换,生成更高效的目标代码。优化的目标通常是减少代码的执行时间、减少内存占用、降低能耗等。
编译器优化可以从多个维度进行分类。按照优化范围分,可以分为本地优化和全局优化。本地优化在一个基本块(Basic Block)内部进行,基本块是一段顺序执行的代码,只有唯一的入口和出口,没有跳转指令的目标或跳转指令的来源。全局优化跨越多个基本块进行,考虑的是整个函数甚至整个程序范围内的优化。
按照优化阶段分,可以分为与机器无关的优化和与机器相关的优化。与机器无关的优化在中间代码层面进行,不依赖于目标机器的特性,如常量折叠、死代码消除、公共子表达式消除等。与机器相关的优化在目标代码生成阶段进行,需要考虑目标处理器的架构特性,如指令调度、寄存器分配等。
下面介绍几种常见的编译器优化技术:
常量折叠(Constant Folding)是最简单也是最常用的优化技术之一。它在编译时计算出那些只包含常量操作数的表达式的值,用计算结果替换原来的表达式。例如:
// 优化前
int a = 24 * 60 * 60;
// 优化后
int a = 86400;
代数化简(Algebraic Simplification)利用代数规则对表达式进行化简。例如,识别出某些运算满足交换律、结合律、分配律等,进行重排或合并。例如:
// 优化前
a = b * 1;
c = d + 0;
// 优化后
a = b;
c = d;
死代码消除(Dead Code Elimination)删除那些执行结果不会被使用的代码。死代码分为两类:一类是不可达代码,即从程序入口无法到达的代码;另一类是无用代码,即虽然可达,但其结果没有被任何有效途径使用的代码。
公共子表达式消除(Common Subexpression Elimination)识别那些重复计算且结果相同的表达式,用之前计算的结果替换后续的计算。例如:
// 优化前
a = b + c;
d = b + c + 5;
// 优化后
a = b + c;
d = a + 5;
循环优化是编译器优化的重要领域,包括循环展开、循环合并、循环分块、循环交换等。循环是程序中执行最频繁的部分,对循环的优化往往能带来显著的性能提升。
内联展开(Inlining)将函数调用替换为函数体本身。这可以消除函数调用的开销,同时为其他优化创造机会,如常量传播、死代码消除等。但过度的内联可能导致代码膨胀,影响指令缓存命中率。
优化的一个重要原则是保真性(Faithfulness),即优化后的程序必须保持与原程序相同的语义。任何优化都不能改变程序的外部行为,包括输出结果和异常行为。编译器优化通常基于保守的分析,只有在能够保证语义不变的情况下才会应用优化。
1.1.3.6 目标代码生成
目标代码生成(Code Generation) 是编译过程的最后一个阶段,其任务是将优化后的中间代码翻译成特定目标机器的机器代码或汇编代码。
目标代码生成涉及多个复杂的任务,包括指令选择、寄存器分配、指令调度等。
指令选择确定如何将中间代码的每一条指令翻译成目标机器的指令。不同的目标机器有不同的指令集,指令选择需要考虑指令的功能、效率等因素。对于某些中间代码结构,可能有多条目标指令可以实现,选择哪一条需要考虑整体效率。
寄存器分配确定中间代码中的变量映射到哪些物理寄存器。寄存器是处理器中访问速度最快的存储资源,但数量有限。寄存器分配的目标是尽量减少寄存器溢出(即将变量的值存储到内存中),同时保持寄存器的高效利用。
指令调度优化指令的执行顺序,以减少数据依赖和结构冲突,提高处理器的流水线效率。现代处理器采用指令流水线技术,指令之间的执行顺序会影响流水线的效率。指令调度需要考虑指令之间的数据依赖关系和处理器的微架构特性。
目标代码生成还需要处理调用约定(Calling Convention)、内存布局、代码布局等问题。
调用约定规定函数调用时参数如何传递(通过寄存器还是栈)、返回值如何传递、哪些寄存器需要由调用者保存(调用者保存寄存器)、哪些寄存器需要由被调用者保存(被调用者保存寄存器)等。不同的编译器、不同的平台可能有不同的调用约定。
内存布局确定程序中各种数据(如全局变量、局部变量、参数等)在内存中的位置。栈帧(Stack Frame)是函数调用时在栈上分配的内存区域,用于存放函数的局部变量、参数、返回地址等。栈帧的布局是调用约定的一部分。
代码布局确定目标代码中基本块和函数的排列顺序。良好的代码布局可以提高指令缓存的命中率,减少跳转指令带来的性能损失。常见的策略包括将高频执行的基本块放在连续的位置、将基本块按照跳转概率排列等。
目标代码生成完成后,通常还需要进行链接(Linking)。链接将一个或多个目标文件与库文件合并,解析符号引用,生成最终的可执行文件或库文件。链接分为静态链接和动态链接。静态链接在编译时将库代码直接合并到可执行文件中,动态链接则在运行时加载所需的共享库。
1.1.4 编译器前端与后端分离
现代编译器架构的一个重要设计原则是前端与后端分离。这种设计使得编译器能够更容易地支持多种源语言和多种目标机器。
在传统编译器中,前端负责处理特定的源语言,将源语言翻译成中间表示;后端负责处理特定的目标机器,将中间表示翻译成目标机器代码。如果要支持 m 种源语言和 n 种目标机器,在没有前后端分离的情况下,需要 m × n 个编译器;而在前后端分离的架构下,只需要 m 个前端和 n 个后端。
这种模块化设计的优势是显而易见的:
- 可复用性:一个前端可以服务于多个后端,一个后端可以服务于多个前端
- 可维护性:修改一个前端或后端不会影响其他部分
- 可扩展性:添加新的源语言支持只需要添加新的前端,添加新的目标机器只需要添加新的后端
- 灵活性:编译器可以灵活组合不同级别的前端和后端
LLVM 项目是前后端分离架构的典型代表。在 LLVM 中,前端(如 Clang)将各种源语言(C、C++、Objective-C 等)翻译成统一的 LLVM IR(中间表示),优化器对 LLVM IR 进行各种优化,后端将 LLVM IR 翻译成特定目标机器的汇编代码或机器代码。这种设计使得 LLVM 能够支持大量的源语言和目标平台。
AI 编译器借鉴了传统编译器的这种设计思想。AI 编译器通常也采用多层 IR 的设计,前端负责将各种 AI 框架(如 PyTorch、TensorFlow)的模型转换成统一的图 IR,后端负责将图 IR 翻译成特定硬件(如 GPU、NPU)的执行代码。这种前后端分离的设计使得 AI 编译器能够更容易地支持多种框架和多种硬件。
1.2 GCC 编译器架构
1.2.1 GCC 发展历史与设计理念
GCC(GNU Compiler Collection,GNU 编译器集合)是 GNU 计划的核心组成部分,最初由理查德·斯托曼(Richard Stallman)于 1987 年创建。GCC 的诞生标志着自由软件运动在编译器领域的重要突破,它不仅是 GNU/Linux 系统的基础工具,也被广泛应用于各种商业和非商业项目中。
GCC 最初的名字是 GNU C Compiler(GNU C 编译器),因为它最初只支持 C 语言。随着项目的发展,GCC 逐渐支持了 C++、Objective-C、Fortran、Java、Ada、Go 等多种编程语言,于是被重命名为 GNU Compiler Collection,以反映其多语言支持的能力。
GCC 的设计理念体现了 GNU 计划的核心价值观:自由、开放、可移植。GCC 采用 GNU GPL(General Public License)许可证发布,这意味着任何人都可以自由获取其源代码、学习其实现、修改并重新发布修改后的版本。这种开放的模式吸引了全球无数开发者参与到 GCC 的改进中来,使其成为最成熟、最稳定的开源编译器之一。
在技术层面,GCC 的设计有几个重要特点:
模块化架构:GCC 采用了相对模块化的设计,各语言前端和各机器后端相对独立,但共享大量的中间组件,如中间表示、优化 passes、错误处理等。
可扩展性:GCC 提供了良好的扩展机制,可以通过插件(plugins)添加新的优化 pass、警告选项、语言支持等。这种设计使得研究人员和工程师可以在不修改 GCC 核心代码的情况下进行实验和定制。
优化能力:GCC 包含了丰富的代码优化技术,从简单的常量折叠到复杂的循环变换、过程间分析等。GCC 提供了多个优化级别(-O0、-O1、-O2、-O3、-Os 等),用户可以根据需要选择不同的优化级别。
多种目标平台支持:GCC 支持几乎所有主流的处理器架构(x86、x86-64、ARM、RISC-V、MIPS、PowerPC 等)和操作系统(Linux、BSD、macOS、Windows 等),是跨平台开发的重要工具。
1.2.2 GCC 编译流程详解
GCC 的编译过程可以分为四个主要阶段:预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。这四个阶段将源文件逐步转换成可执行文件。
1.2.2.1 预处理
预处理(Preprocessing) 是 GCC 编译过程的第一步。在真正的编译开始之前,预处理器会对源文件进行初步处理。预处理主要完成以下工作:
头文件展开:预处理器将 #include 指令指定的文件内容插入到源文件中。例如,#include <stdio.h> 会将标准输入输出库的头文件内容复制到当前源文件中。预处理器会递归处理嵌套的 include 文件。
宏替换:预处理器处理所有的宏定义(#define),将宏调用替换为宏体。宏可以是对象宏(如 #define PI 3.14159)或函数宏(如 #define MAX(a, b) ((a) > (b) ? (a) : (b)))。函数宏的替换会涉及参数的替换和括号添加,以正确处理运算符优先级。
条件编译:预处理器根据条件指令(#if、#ifdef、#ifndef、#else、#elif、#endif)决定哪些代码需要被编译,哪些需要被跳过。这使得同一份源代码可以适应不同的平台或配置。
注释删除:预处理器会删除所有的注释,用空白字符替换。这确保注释不会对编译过程产生任何影响。
行号标记:预处理器会在输出中插入行号标记,使得编译器能够在错误消息中引用原始源文件的行号。
使用 GCC 进行预处理的命令是:
gcc -E source.c -o source.i
或者更简单地:
gcc -E source.c # 输出到 stdout
预处理后的 .i 文件(如果指定了 -o source.i)是一个纯 C 代码文件,包含了展开后的头文件、替换后的宏等,但尚未经过编译。
1.2.2.2 编译
编译(Compilation) 是 GCC 编译过程的核心阶段。在这个阶段,编译器前端对预处理后的代码进行词法分析、语法分析和语义分析,然后将源代码翻译成汇编代码。
编译阶段的主要工作包括:
词法分析:将预处理后的代码分割成 Token(标识符、关键字、常量、运算符等)。
语法分析:根据 C 语言的文法规则,检查 Token 序列是否符合语法规范,并生成抽象语法树(AST)。
语义分析:对抽象语法树进行语义检查,包括类型检查、作用域分析、常量计算等。
中间代码生成:将抽象语法树翻译成中间表示(GCC 使用的是 GIMPLE,一种三地址形式的中间表示)。
优化:对中间表示进行各种优化,包括本地优化(如常量折叠、死代码消除)和全局优化(如过程间分析、循环优化)。
汇编代码生成:将优化后的中间表示翻译成目标处理器的汇编语言。
使用 GCC 进行编译的命令是:
gcc -S source.i -o source.s
或者直接编译源文件(不进行汇编和链接):
gcc -S source.c -o source.s
生成的 .s 文件是汇编语言文件,可以用文本编辑器查看。下面是一个简单的 C 程序及其对应的汇编代码:
// source.c
int add(int a, int b) {
return a + b;
}
int main() {
return add(2, 3);
}
生成的汇编代码(x86-64)可能如下:
add:
lea eax, [rdi + rsi]
ret
main:
mov eax, 5
ret
可以看到,GCC 在编译阶段就进行了常量折叠,add(2, 3) 被直接替换为 5。
1.2.2.3 汇编
汇编(Assembly) 是将汇编语言翻译成机器代码的过程。汇编器(Assembler)将 .s 文件中的汇编指令转换为机器指令,生成目标文件(Object File)。
目标文件是二进制格式的,包含:
- 机器指令:编译后的二进制代码
- 符号表:文件中的符号(如函数名、变量名)及其地址
- 重定位信息:在链接时需要被链接器修改的地址信息
- 调试信息(如果使用
-g选项):用于调试器的符号信息
使用 GCC 进行汇编的命令是:
gcc -c source.s -o source.o
或者直接编译汇编源文件:
as source.s -o source.o
生成的 .o 文件是目标文件,无法直接执行,需要通过链接才能生成可执行文件。
1.2.2.4 链接
链接(Linking) 是编译过程的最后一步。链接器(Linker)将一个或多个目标文件与所需的库文件合并,解析符号引用,生成最终的可执行文件。
链接分为两种类型:
静态链接(Static Linking):在编译时将库代码直接合并到可执行文件中。静态链接生成的可执行文件是完整的,不依赖外部库,但文件体积较大,且多个程序可能包含相同的库代码,造成存储空间浪费。
动态链接(Dynamic Linking):在程序运行时才加载所需的共享库。动态链接生成的可执行文件较小,且多个程序可以共享同一个共享库,节省内存和磁盘空间,但程序运行需要依赖正确的共享库存在。
使用 GCC 进行完整编译并链接的命令是:
gcc source.c -o program # 静态链接
gcc -shared source.c -o libprogram.so # 生成动态库
GCC 默认使用动态链接。如果要使用静态链接,可以使用 -static 选项:
gcc -static source.c -o program
1.2.3 GCC 优化选项与级别
GCC 提供了多个优化级别,每个级别启用不同的优化集合。用户可以通过优化选项控制编译器的优化行为。
-O0(默认):不进行优化。这个级别编译速度最快,生成的代码与源代码的结构最接近,便于调试。但代码执行效率较低。
-O1(或 -O):基础优化。这个级别启用一些不涉及空间-速度权衡的优化,如常量折叠、死代码消除、简单指令调度等。编译速度和生成的代码质量处于平衡状态。
-O2:更多优化。这个级别在 -O1 的基础上启用了更多优化,包括循环展开、函数内联(受限)、寄存器分配优化等。-O2 是常用的发布级别,生成的代码质量较高,编译时间可接受。
-O3:最高级别优化。在 -O2 的基础上启用了更激进的优化,如更多循环变换、完全函数内联、矢量化优化等。-O3 可能生成更大更快的代码,但也可能因为过度优化导致性能下降或增加编译时间。
-Os(优化空间):专门优化代码大小。这个级别在 -O2 的基础上禁用了某些会增加代码大小的优化,如循环展开,生成的代码体积更小,适合存储空间受限的环境。
-Ofast:非常高的优化级别,同时忽略严格的标准合规性。这个级别启用了 -O3 的所有优化,并额外启用了 fast math 等可能违反 IEEE 标准的优化,可能产生不精确的结果。
除了优化级别,GCC 还提供了针对特定优化的选项,如:
-funroll-loops:循环展开-finline-functions:函数内联-fvectLoop.s = -ftree-vectorize:向量化和SIMD优化-fgcse:全局公共子表达式消除
了解这些优化选项对于性能敏感的应用程序开发非常重要。开发者可以根据应用的特点选择合适的优化级别和选项。
1.2.4 GCC 的局限性
尽管 GCC 是最成功和最广泛使用的开源编译器之一,但它也存在一些局限性:
代码耦合度高:GCC 的内部模块之间耦合度较高,代码复用和定制的难度较大。虽然 GCC 采用了相对模块化的设计,但多年的发展导致各模块之间存在大量的内部依赖和隐式依赖。这使得将 GCC 集成到专用 IDE 或其他工具中变得困难。
难以作为 API 使用:GCC 在设计时并未考虑作为 API 供其他程序调用。虽然 GCC 提供了 -fdump- 选项可以输出某些中间结果,但缺乏稳定、文档完善的 API,这限制了它在构建定制工具时的应用。
后期版本代码质量问题:随着功能的不断增加和代码量的增长,部分版本的代码质量和可维护性有所下降。这给新开发者的参与和持续改进带来了挑战。
编译时间:GCC 的某些优化 pass,尤其是过程间分析和全局优化,可能导致编译时间较长。这在大型项目的开发中可能影响开发效率。
这些局限性促使了一些新项目的出现,如 LLVM/Clang,它们在设计上更强调模块化、API 友好和快速编译。
1.3 LLVM 编译器架构
1.3.1 LLVM 起源与发展
LLVM(Low Level Virtual Machine) 最初于 2000 年由伊利诺伊大学厄巴纳-香槟分校的维克拉姆·艾夫(Vikram Adve)和克里斯·拉特纳(Chris Lattner)发起。LLVM 的名称来自项目创建时的原始目标——创建一个低层次的虚拟机平台。随着项目的发展,LLVM 的范围远远超出了最初的目标,不再是单纯的虚拟机,而是一个模块化的编译器基础设施集合。因此,LLVM 现在被解释为 "LLVM",不再作为缩写词使用。
2005 年,苹果公司聘请了克里斯·拉特纳及其团队,为 macOS 和 iOS 开发工具链。苹果对 LLVM 的大力支持极大地推动了 LLVM 的发展。苹果将 LLVM 集成到其开发工具中,用 Clang(LLVM 的 C/C++/Objective-C 前端)取代了 GCC 作为 Xcode 的默认编译器。
LLVM 项目的核心贡献得到了广泛认可。2012 年,计算机协会(ACM)授予维克拉姆·艾夫、克里斯·拉特纳和 Evan Cheng ACM 软件系统奖,以表彰他们在 LLVM 项目上的开创性工作。
LLVM 采用 Apache 2.0 许可证发布(从 9.0.0 版本开始,之前使用 GPL),这是一个比 GPL 更宽松的许可证,允许在闭源软件中使用 LLVM 的代码。这使得 LLVM 被广泛应用于各种商业和非商业产品中,包括苹果的 Xcode、Adobe 的 Creative Suite、Unity 游戏引擎等。
1.3.2 LLVM 架构设计
LLVM 架构的核心设计哲学是模块化和可重用性。LLVM 不是一个大而全的编译器,而是一组解耦良好的库和工具,开发者可以根据需要选择使用其中的组件。
LLVM 架构的主要组成部分包括:
前端(Frontend):负责处理特定的源语言,将源代码翻译成 LLVM IR。LLVM 支持多种前端,包括 Clang(C/C++/Objective-C)、Swift 编译器(Swift 语言)、Rust 编译器(Rust 语言)等。每个前端负责特定语言的词法分析、语法分析、语义分析,最终生成 LLVM IR。
优化器(Optimizer):对 LLVM IR 进行各种分析和优化。LLVM 的优化器采用 Pass 机制,每个 Pass 完成特定的优化任务。优化器与源语言和目标机器无关,只专注于 LLVM IR 层面的优化。
后端(Backend):负责将 LLVM IR 翻译成特定目标机器的汇编代码或机器代码。后端包括指令选择、寄存器分配、指令调度、代码布局等组件。LLVM 支持大量的目标平台,包括 x86、x86-64、ARM、RISC-V、MIPS、PowerPC 等。
LLVM IR:这是 LLVM 的核心。LLVM IR 是一种强类型的中间表示,类似于低层次的汇编语言,但更加抽象和通用。LLVM IR 支持无限数量的虚拟寄存器,采用静态单赋值(SSA)形式。LLVM IR 有三种表示形式:内存中的数据结构、LLVM 位码(.bc 文件,二进制格式)、LLVM 汇编语言(.ll 文件,文本格式)。
这种架构的优势在于:
- 语言无关性:任何语言只要能生成 LLVM IR,就可以使用 LLVM 的整个优化和代码生成基础设施
- 平台无关性:LLVM IR 是平台无关的,同一份 IR 可以被编译到不同的目标平台
- 模块化:各组件之间通过清晰的接口交互,可以独立使用或替换
- 可扩展性:可以方便地添加新的 Pass、新的目标支持、新的语言前端
1.3.3 Clang 编译器前端
Clang 是 LLVM 项目的 C、C++、Objective-C 编译器前端,由苹果公司主导开发。Clang 的目标是提供一个快速、友好的编译器,同时提供高质量的诊断信息和现代 IDE 集成所需的基础设施。
Clang 相比 GCC 前端有几个显著优势:
编译速度:Clang 在设计上特别强调编译速度。Clang 的词法分析和语法分析比 GCC 快 2-3 倍,这大大缩短了大型项目的编译时间。
内存占用:Clang 生成的 AST(抽象语法树)占用的内存比 GCC 少很多(大约是 GCC 的 20%),这使得 Clang 在处理大型源文件时更加高效。
诊断信息:Clang 提供了更加清晰、有用的错误和警告消息。Clang 会尝试指出错误的根本原因,而不是简单地报告语法不匹配。许多开发者认为 Clang 的错误消息是业界最佳的。
易于集成:Clang 提供了库形式的 API(libclang),使得其他工具可以方便地使用 Clang 的前端功能进行代码分析、重构、格式化等任务。
模块化设计:Clang 的代码结构清晰,模块之间耦合度低,便于理解和扩展。
Clang 的工作流程如下:
- 预处理:Clang 内置了自己的预处理器,处理宏展开、条件编译等任务
- 词法分析:将源代码分割成 Token 序列
- 语法分析:将 Token 序列解析成抽象语法树(AST)
- 语义分析:进行类型检查、作用域分析等
- 代码生成:将 AST 翻译成 LLVM IR
Clang 生成的 LLVM IR 可以被 LLVM 优化器和后端进一步处理,最终生成目标机器的机器代码。
1.3.4 LLVM IR 详解
LLVM IR(Intermediate Representation) 是 LLVM 编译系统的核心中间表示。LLVM IR 被设计成三种完全等价的格式,可以在需要时互相转换:
- 内存中的数据结构:供编译器内部各 Pass 使用
- LLVM 位码(Bitcode):二进制格式,文件扩展名为
.bc,适合序列化和快速加载 - LLVM 汇编语言:人类可读的文本格式,文件扩展名为
.ll
1.3.4.1 LLVM IR 基本结构
LLVM IR 的基本单元是模块(Module),一个模块对应一个源文件编译后的结果。模块包含:
- 函数定义和声明:模块中的函数
- 全局变量:模块级别的变量
- 符号表:模块中所有符号的信息
- 目标信息:模块要编译到的目标平台信息
每个函数(Function) 由一系列基本块(Basic Block) 组成。基本块是只有一个入口和一个出口的指令序列,块内的指令按顺序执行。
指令是 LLVM IR 的基本执行单元。LLVM IR 是一种静态单赋值(SSA)形式的 IR,每个变量只能被赋值一次。LLVM IR 支持无限数量的虚拟寄存器,用 % 前缀标识(如 %a、%result)。
1.3.4.2 LLVM IR 指令类型
LLVM IR 的指令可以分为几大类:
算术指令:add、sub、mul、sdiv、udiv、fadd、fsub、fmul、fdiv 等,分别用于整数和浮点数的加减乘除运算。
位操作指令:and、or、xor、shl(左移)、lshr(逻辑右移)、ashr(算术右移)。
内存访问指令:alloca(在栈上分配内存)、load(从内存读取)、store(向内存写入)、getelementptr(计算数组/结构体元素的地址)。
控制流指令:br(条件/无条件跳转)、ret(函数返回)、call(函数调用)、switch(多分支跳转)。
比较指令:icmp(整数比较)、fcmp(浮点数比较),产生布尔值结果。
转换指令:zext(零扩展)、sext(符号扩展)、trunc(截断)、fptosi/sitofp(浮点数与整数互转)。
聚合指令:extractvalue(从结构体/数组提取元素)、insertvalue(向结构体/数组插入元素)。
1.3.4.3 LLVM IR 示例
让我们通过一个简单的例子理解 LLVM IR。考虑以下 C 代码:
int add(int a, int b) {
return a + b;
}
使用 Clang 编译到 LLVM 汇编:
clang -S -emit-llvm -O2 add.c -o add.ll
生成的 .ll 文件内容可能是:
; ModuleID = 'add.c'
source_filename = "add.c"
target datalayout = "e-m:w-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-unknown-linux-gnu"
define i32 @add(i32 noundef %a, i32 noundef %b) #0 {
entry:
%add = add nsw i32 %a, %b
ret i32 %add
}
这个例子展示了 LLVM IR 的几个关键特点:
- 使用
@前缀标识函数名(全局符号) - 使用
%前缀标识虚拟寄存器(局部符号) i32类型表示 32 位整数noundef属性表示值不能是未定义的值nsw表示"no signed wrap",即有符号整数运算不会溢出
再看一个更复杂的例子,包括循环和条件:
int sum(int n) {
int total = 0;
for (int i = 0; i < n; i++) {
total += i;
}
return total;
}
对应的 LLVM IR:
define i32 @sum(i32 noundef %n) #0 {
entry:
%total = alloca i32, align 4
%i = alloca i32, align 4
store i32 0, ptr %total, align 4
store i32 0, ptr %i, align 4
br label %for.cond
for.cond: ; preds = %entry, %for.inc
%0 = load i32, ptr %i, align 4
%cmp = icmp slt i32 %0, %n
br i1 %cmp, label %for.body, label %for.end
for.body: ; preds = %for.cond
%1 = load i32, ptr %total, align 4
%2 = load i32, ptr %i, align 4
%add = add nsw i32 %1, %2
store i32 %add, ptr %total, align 4
br label %for.inc
for.inc: ; preds = %for.body
%3 = load i32, ptr %i, align 4
%inc = add nsw i32 %3, 1
store i32 %inc, ptr %i, align 4
br label %for.cond
for.end: ; preds = %for.cond
%retval = load i32, ptr %total, align 4
ret i32 %retval
}
LLVM IR 的可读性使得开发者可以直接阅读和理解 IR 层面的代码,这对于学习编译器原理和进行性能分析非常有帮助。
1.3.5 LLVM Pass 机制
LLVM 优化器的核心是 Pass 机制。Pass 是 LLVM 进行优化和分析的基本单元,每个 Pass 完成特定的优化任务或信息收集任务。
1.3.5.1 Pass 的分类
LLVM 的 Pass 可以分为几类:
分析 Pass(Analysis Pass):计算并提供 IR 的某些信息,但不修改 IR。例如:
BasicAA:基于基本块的别名分析LoopInfo:循环信息分析DominatorTree:支配树分析ScalarEvolution:标量演化分析
分析 Pass 的结果通常被其他 Pass(特别是转换 Pass)使用。
转换 Pass(Transform Pass):使用分析 Pass 的结果,对 IR 进行修改。例如:
LoopUnroll:循环展开LoopVectorize:循环向量化InlineFunction:函数内联GVN:全局值编号(一种公共子表达式消除)
工具 Pass(Utility Pass):既不属于分析也不属于转换,但提供有用功能的 Pass。例如:
PrintFunction:打印函数信息ExtractBlocks:提取基本块
1.3.5.2 Pass 的结构
一个 LLVM Pass 通常继承自 FunctionPass、ModulePass 或 LoopPass 等基类,表示该 Pass 的作用域(函数级别、模块级别、循环级别)。
Pass 的核心是 runOnXXX 方法,如 runOnFunction、runOnModule。这个方法接收要处理的 IR 对象,执行分析和/或转换,返回一个布尔值表示是否进行了任何修改。
class MyPass : public FunctionPass {
public:
static char ID;
MyPass() : FunctionPass(ID) {}
void getAnalysisUsage(AnalysisUsage& AU) const override {
AU.addRequired<DominatorTreeWrapperPass>();
AU.setPreservesAll(); // 这个 Pass 不破坏已有的分析结果
}
bool runOnFunction(Function& F) override {
// 对函数 F 进行处理
return modified; // 返回是否进行了修改
}
};
1.3.5.3 Pass 管理器
LLVM 使用 Pass 管理器(PassManager) 来组织和管理 Pass 的执行。Pass 管理器负责:
- 按照正确的依赖顺序执行 Pass
- 缓存分析 Pass 的结果,避免重复计算
- 支持 Pass 的懒加载,提高启动速度
优化时通常会使用一系列预定义的 Pass 组合,如 -O2 级别会使用一组固定的 Pass,按照它们之间的依赖关系排序后执行。
1.3.6 LLVM 与 GCC 的对比
LLVM/Clang 和 GCC 是当今最重要的两个开源编译器。它们各有优劣,适用于不同的场景。
| 特性 | GCC | LLVM/Clang |
|---|---|---|
| 许可证 | GPL v3 | Apache 2.0 |
| 代码模块化 | 一体化架构 | 模块化设计 |
| API 友好性 | 较差 | 较好(libclang) |
| 编译速度 | 较慢 | 快 2-3 倍 |
| 内存占用 | 较高 | 较低 |
| 语言支持 | 更多(C++、Fortran、Java、Ada、Go 等) | 主要 C、C++、Objective-C |
| 目标平台 | 非常广泛 | 广泛但略少 |
| 标准合规性 | 非常好 | 越来越好 |
| 诊断信息 | 基本 | 非常优秀 |
| 调试支持 | GDB | LLDB |
选择 GCC 的场景:
- 需要最好的 C++ 标准合规性
- 需要支持某些 GCC 特有的语言扩展
- 需要针对某些特殊目标平台进行开发
- 需要使用 GCC 的某些独特优化
选择 LLVM/Clang 的场景:
- 需要更快的编译速度
- 需要更好的 IDE 集成和代码分析
- 需要更好的诊断信息和错误消息
- 需要在闭源项目中使用 LLVM 技术
- 需要利用 LLVM 的模块化特性构建定制工具
值得注意的是,两个项目在竞争中相互学习,差距在不断缩小。GCC 在模块化和编译速度方面有所改进,LLVM 在标准合规性和优化质量方面不断提升。
1.4 编译器优化技术
1.4.1 优化概述
编译器优化是编译器技术中最具技术挑战性的领域之一。优化的目标是生成更高效的目标代码,同时保持程序的语义不变。这需要在编译时进行复杂的静态分析,推断出程序在运行时的行为,从而进行安全的代码改进。
优化可以从多个维度进行评估:
- 效果:优化能带来多大的性能提升或代码减少
- 通用性:优化适用于多少种情况
- 编译时间开销:优化分析需要多长的编译时间
- 编译空间开销:优化分析需要多少内存
这些维度往往相互制约。例如,更激进的优化可能带来更好的效果,但需要更长的编译时间和更多内存。因此,编译器通常提供多个优化级别,让用户在编译时间和代码质量之间做出权衡。
优化的安全性是一个核心问题。编译器必须保证优化后的程序与原程序在所有可能输入下的行为一致。这要求优化分析必须足够保守,只有在能够百分之百确认语义不变的情况下才应用优化。
1.4.2 常量折叠
常量折叠(Constant Folding) 是最基本的编译器优化技术之一。其核心思想是在编译时计算那些只包含常量操作数的表达式,用计算结果替换原来的表达式,从而消除运行时的计算开销。
1.4.2.1 常量折叠的原理
常量折叠看起来简单,但涉及几个关键问题:
如何识别常量表达式? 常量表达式是由常量组成的表达式。常量包括字面量(如 42、3.14、"hello")和用 const 修饰的编译时常量。
何时进行常量折叠? 常量折叠可以在多个阶段进行:
- 词法分析阶段:识别字符串常量拼接
- 语法分析阶段:识别只包含常量字面量的表达式
- 语义分析阶段:识别包含
const变量的常量表达式 - 中间代码生成阶段:识别更复杂的常量表达式
- 优化阶段:对中间代码进行常量折叠
折叠后语义是否保持不变? 某些情况下,即使表达式只包含常量,也可能无法进行常量折叠:
- 整数除法和取模:如果除数为零,运行时会产生除零错误
- 浮点运算:如果涉及 NaN(非数)或无穷大,结果可能与预期不同
- 溢出处理:某些语言的整数溢出是未定义行为,不能在编译时模拟
1.4.2.2 常量折叠的例子
// 原始代码
int a = 24 * 60 * 60; // 86400
double b = 3.14159 * 10 * 10; // 314.159
int c = (10 > 5) ? 100 : 200; // 100
char *d = "hello" "world"; // "helloworld"
// 优化后代码
int a = 86400; // 直接使用计算结果
double b = 314.159; // 直接使用计算结果
int c = 100; // 条件在编译时可确定
char *d = "helloworld"; // 字符串常量拼接在预处理阶段完成
1.4.2.3 常量折叠的实现
常量折叠的实现通常在语法分析或中间代码生成阶段。实现方式有两种:
方式一:直接在语法树或中间代码上计算
当语法分析器或中间代码生成器遇到一个表达式时,检查其所有操作数是否都是常量。如果是,计算表达式的值,并用常量节点替换原来的子树。
// 伪代码:常量折叠的实现
Expression* foldConstants(BinaryOp* expr) {
Expression* left = expr->left();
Expression* right = expr->right();
if (isConstant(left) && isConstant(right)) {
// 两个操作数都是常量,计算结果
return new Constant(eval(expr->op(), left->value(), right->value()));
}
// 否则返回原表达式,可能需要进行其他优化
return expr;
}
方式二:作为独立的 Pass 运行
将常量折叠实现为一个独立的 Pass,遍历中间代码(或语法树),识别可以折叠的表达式并替换。这种方式更灵活,但效率可能稍低。
1.4.3 代数化简
代数化简(Algebraic Simplification) 利用代数规则对表达式进行化简,消除冗余运算,使表达式更简洁、更高效。代数化简是常量折叠的自然扩展,当操作数是常量时就是常量折叠,当操作数是变量时就变成了代数化简。
1.4.3.1 代数化简的规则
代数化简涉及多种代数规则:
恒等元素:
x + 0 = xx - 0 = xx * 1 = xx / 1 = xx & 1 = x(位运算)x | 0 = x(位运算)
零元素:
x * 0 = 0(乘法零元素,假设不考虑 NaN)x * false = false(逻辑运算)x & 0 = 0(位运算)
幂等规则:
x & x = xx | x = xx ^ 0 = x(异或)x ^ x = 0(异或)
消去规则:
x * y + x * z = x * (y + z)(提取公因子)(x + y) * (x - y) = x^2 - y^2(平方差公式)
交换律和结合律:
x + y = y + xx * y = y * x(x + y) + z = x + (y + z)(x * y) * z = x * (y * z)
1.4.3.2 代数化简的例子
// 原始代码
int a = x + 0; // x + 0 → x
int b = y * 1; // y * 1 → y
int c = 0; // 保持不变
int d = a * 2 + a * 3; // a * 2 + a * 3 → a * (2 + 3) → a * 5
int e = (a + b) + (c + d); // 利用结合律重排
// 布尔代数化简
bool f = x && true; // x && true → x
bool g = x || false; // x || false → x
bool h = !(x == y); // 可能可以简化
1.4.3.3 代数化简的注意事项
代数化简需要注意一些特殊情况:
除零问题:x / 0 是未定义行为,不能简单地将 x * 0 / y 化简为 0 / y,除非能证明 x 不为零。
溢出问题:在有符号整数运算中,x - x 不一定等于零,因为可能发生溢出。更安全的做法是在化简时保留溢出检查。
浮点精度:浮点运算不满足严格的代数规则。例如,(x + y) - x 在数学上等于 y,但由于浮点精度限制,可能不等于 y。编译器通常不会对浮点表达式进行激进的代数化简。
操作符重载:在 C++ 等语言中,操作符可能被重载,编译器无法确定重载后的运算符是否满足代数规则。因此,代数化简通常只对内置类型进行。
1.4.4 死代码消除
死代码消除(Dead Code Elimination,DCE) 是编译器中非常重要的一项优化技术,其目标是删除那些在程序执行中不会被使用或产生任何效果的代码。
1.4.4.1 死代码的类型
死代码可以分为两种类型:
不可达代码(Unreachable Code):从程序入口无法到达的代码。这通常是由于无条件跳转、switch 语句的某些 case 等造成的。
void example(int x) {
return;
printf("This will never be printed\n"); // 不可达代码
}
无用代码(Useless Code):代码可达,但其执行结果对程序输出没有任何影响。
void example(int a, int b) {
int x = a + b; // x 定义后未使用
return a + b; // 最终返回值与 x 无关
}
在上面的例子中,变量 x 的赋值是无用代码,因为它对函数的返回值没有任何影响。
1.4.4.2 死代码消除的原理
死代码消除基于数据流分析。编译器通过跟踪变量的定义和使用位置,确定哪些变量的值会被使用,哪些不会。
对于不可达代码,编译器从程序入口开始遍历控制流图(Control Flow Graph,CFG),标记所有可达的基本块。不可达的基本块中的所有代码都是死代码。
对于无用代码,编译器通过活跃变量分析(Live Variable Analysis)来确定变量的"活跃性"。如果一个变量在某个点之后还被使用,则是活跃的;否则就是死变量,对该变量的赋值就是无用代码。
活跃变量分析是一个经典的数据流问题。对于每个基本块,编译器计算:
def:该基本块中定义的变量集合use:该基本块中使用(在定义之前使用)的变量集合live_out:在该基本块出口处活跃的变量集合
迭代公式为:
live_out[B] = use[B] ∪ (live_in[B] - def[B])
live_in[B] = use[B] ∪ (live_out[B] - def[B])
1.4.4.3 死代码消除的例子
// 原始代码
int dead_code_elimination(int a, int b) {
int x = a + b; // x 未被使用
int y = a * 2; // y 未被使用
int z = a - b;
if (z > 0) {
return z;
} else {
return -z;
}
}
// 优化后代码
int dead_code_elimination(int a, int b) {
int z = a - b;
if (z > 0) {
return z;
} else {
return -z;
}
}
更复杂的例子涉及条件分支:
// 原始代码
int complex_dead_code(int flag) {
int a = 10;
if (flag) {
a = 20;
}
printf("%d", a); // a 总是被使用,if 内部的赋值不能删除
return a;
}
// 如果 printf 被删除
int complex_dead_code(int flag) {
int a = 10;
if (flag) {
a = 20;
}
// a 的赋值可能变成死代码,但这需要更复杂的分析
return a;
}
1.4.4.4 死代码消除的实现
死代码消除通常作为编译器优化的一个独立 Pass 实现。实现步骤如下:
- 构建控制流图:将函数划分为基本块,并建立基本块之间的跳转关系
- 活跃变量分析:从后向前迭代计算每个基本块的活跃变量集合
- 标记无用赋值:对于每个赋值语句,如果左边的变量在活跃变量集合中,则该赋值不是死代码;否则标记为候选死代码
- 删除死代码:删除所有被标记的无效赋值
// 伪代码:死代码消除的实现
void deadCodeElimination(Function& F) {
// 步骤1:构建控制流图(假设已构建)
// 步骤2:活跃变量分析
bool changed = true;
while (changed) {
changed = false;
for (auto& BB : reverse(F)) { // 从后向前遍历
// 使用活跃变量分析的公式更新 live_out
// ...
}
}
// 步骤3:标记和删除无用代码
for (auto& BB : F) {
for (auto& inst : BB) {
if (isAssignment(inst) && !isLiveOut(inst.getDest(), BB)) {
// 标记为死代码
markAsDead(inst);
}
}
}
// 步骤4:删除死指令
removeDeadInstructions(F);
}
1.4.5 其他重要优化技术
除了上述三种基本的优化技术,现代编译器还实现了大量其他优化技术。以下简要介绍几种重要的优化:
1.4.5.1 公共子表达式消除
公共子表达式消除(Common Subexpression Elimination,CSE) 识别程序中重复计算的表达式,用之前计算的结果替换后续计算。
// 原始代码
a = b + c;
d = b + c + 5; // b + c 被重复计算
// 优化后代码
t = b + c;
a = t;
d = t + 5; // 复用之前的计算结果 t
CSE 的实现需要识别"相同"的表达式。相同意味着不仅运算相同,操作数也相同。这在 SSA(静态单赋值)形式中更容易判断。
1.4.5.2 函数内联
函数内联(Function Inlining) 将函数调用替换为函数体本身。这可以消除函数调用的开销(参数传递、跳转、返回),同时为其他优化创造机会。
// 原始代码
int add(int a, int b) {
return a + b;
}
int main() {
return add(2, 3); // 函数调用
}
// 优化后代码
int main() {
return 2 + 3; // 内联后直接计算
}
内联也有代价:增加代码大小(代码膨胀),可能影响指令缓存命中率。因此,编译器通常会设置一个内联阈值,只对小函数或冷函数进行内联。
1.4.5.3 循环优化
循环优化是编译器优化的重要领域,因为循环通常是程序中执行最频繁的部分。主要的循环优化技术包括:
- 循环展开(Loop Unrolling):减少循环控制的开销,增加指令级并行
- 循环合并(Loop Fusion):将多个相邻的循环合并为一个,减少循环开销
- 循环分块(Loop Tiling/Blocking):改善数据局部性,提高缓存命中率
- 循环交换(Loop Interchange):改变嵌套循环的顺序,改善内存访问模式
// 原始代码
for (i = 0; i < N; i++)
for (j = 0; j < N; j++)
C[i][j] = A[i][j] + B[i][j];
// 循环展开后(假设 N 是 4 的倍数)
for (i = 0; i < N; i++)
for (j = 0; j < N; j += 4) {
C[i][j] = A[i][j] + B[i][j];
C[i][j+1] = A[i][j+1] + B[i][j+1];
C[i][j+2] = A[i][j+2] + B[i][j+2];
C[i][j+3] = A[i][j+3] + B[i][j+3];
}
1.4.5.4 指令调度
指令调度(Instruction Scheduling) 优化指令的执行顺序,以充分利用处理器的流水线,减少停顿。
// 原始指令序列
load r1, [a] // 加载,可能需要多个周期
add r2, r1, r1 // 等待 load 完成
store [b], r2 // 等待 add 完成
// 调度后的指令序列
load r1, [a] // 加载
nop // 或者插入其他不依赖 r1 的指令
nop
add r2, r1, r1 // 在 load 完成后再执行
store [b], r2
指令调度需要考虑指令之间的数据依赖关系和处理器流水线的特性。现代编译器能够进行复杂的指令调度,包括寄存器重命名、寄存器分配等优化。
1.5 小结
本章系统地介绍了传统编译器的基础知识,内容涵盖编译器的定义与发展、编译流程、 GCC 和 LLVM 两大主流编译器架构、以及几种经典的编译器优化技术。
核心要点回顾:
-
编译器的定义和作用:编译器是将高级语言翻译成机器语言的程序,是连接人类编程思想与机器执行指令的桥梁。编译器与解释器的核心区别在于翻译和执行的顺序。
-
编译的六个阶段:词法分析将字符流转换为 Token 序列;语法分析将 Token 序列构建成语法树;语义分析检查类型和作用域等语义信息;中间代码生成将语法树翻译成中间表示;优化对中间代码进行各种改进;目标代码生成将中间代码翻译成机器代码。
-
前后端分离的架构思想:前端负责处理特定源语言,后端负责处理特定目标机器,中间通过统一的 IR 连接。这种设计提高了编译器的可复用性、可维护性和可扩展性。LLVM 是这种架构的典型代表。
-
GCC 编译器:作为 GNU 计划的核心组件,GCC 拥有悠久的历史和广泛的应用。GCC 的编译流程分为预处理、编译、汇编和链接四个阶段,提供了多种优化级别供选择。
-
LLVM 编译器:LLVM 采用高度模块化的设计,提供优秀的编译速度和诊断信息。Clang 是 LLVM 的 C/C++ 前端,以速度快、错误信息好著称。LLVM IR 是 LLVM 系统的核心,是一种强类型的 SSA 形式的中间表示。
-
经典优化技术:常量折叠在编译时计算常量表达式;代数化简利用代数规则化简表达式;死代码消除删除不可达或无用的代码。这些技术是理解 AI 编译器优化技术的基础。
思考与练习:
-
请解释编译器前端与后端分离设计的优势,并以 LLVM 为例说明这种设计如何实现多语言多平台支持。
-
在 GCC 编译流程中,预处理、编译、汇编、链接四个阶段分别完成什么任务?请说明它们之间的依赖关系。
-
请描述词法分析和语法分析的主要区别,以及它们在编译器中的角色。
-
什么是 SSA(静态单赋值)形式?它有什么优点?
-
常量折叠和代数化简都是表达式优化技术,它们有什么区别?请各举一个例子说明。
-
死代码消除需要使用数据流分析。请解释什么是活跃变量分析,以及它如何帮助识别死代码。
-
请比较 GCC 和 LLVM/Clang 的优缺点,并说明在什么场景下你会选择使用其中一个。
-
在循环优化中,循环展开和循环分块分别解决什么问题?请简要说明。
1.6 参考文献与扩展阅读
-
Aho, A. V., Lam, M. S., Sethi, R., & Ullman, J. D. (2006). Compilers: Principles, Techniques, and Tools (2nd Edition). Addison-Wesley. — 经典的编译器教材,被称为"龙书"。
-
LLVM Documentation. https://llvm.org/docs/ — LLVM 官方文档,包含 LLVM IR 规范、Pass 编写指南等。
-
GCC Documentation. https://gcc.gnu.org/onlinedocs/ — GCC 官方文档。
-
Lattner, C., & Adve, V. (2004). LLVM: A Compilation Framework for Lifelong Program Analysis & Transformation. International Symposium on Code Generation and Optimization (CGO). — LLVM 的原始论文。
-
Zakai, A. (2011). Emscripten: An LLVM-to-JavaScript Compiler. Proceedings of the LLVM symposium. — 关于 Emscripten 的论文,展示了 LLVM 的多样化应用。
-
Clang Documentation. https://clang.llvm.org/docs/ — Clang 官方文档。
-
Muchnick, S. S. (1997). Advanced Compiler Design and Implementation. Morgan Kaufmann. — 高级编译器设计与实现,深入讲解各种优化技术。
-
Dragon Book (2nd Edition) 中文版:《编译原理(第2版)》,机械工业出版社。