编译原理入门

参考资料

https://zhuanlan.zhihu.com/p/130702001

一、编译器组成

是的,现代编译器通常被设计成由前端 (Frontend)后端 (Backend) 组成,有时还会包含一个中间端 (Middle-end)。这种模块化的设计带来了很多优势。

编译器前端的主要任务是分析源代码,理解其含义,并将其转换为一种中间表示 (Intermediate Representation, IR)。这个阶段主要关注源语言的特性,与目标机器无关(不关心最终程序将运行在哪种 CPU 架构上)。前端包含的阶段通常有:

  • Lexical Analysis / Scanning
  • Syntax Analysis / Parsing
  • Semantic Analysis
  • Intermediate Code Generation

编译器后端的主要任务是将中间表示转换为目标机器代码,并进行优化以提高代码性能。这个阶段主要关注目标机器的特性(与具体的CPU架构(如 x86、ARM、MIPS)强相关),与源语言无关。后端包含的阶段通常有:

  • Code Optimization
  • Code Generation

一些更复杂的编译器(如 GCC、LLVM)还会引入一个中间端 (Middle-end)。它位于前端和后端之间,主要进行与语言和机器都无关的优化。中间端专注于 通用优化算法,这些优化可以应用于任何语言生成的 中间代码,并适用于任何目标机器。

这种分层设计带来了显著的优势:

  1. 可重用性 (Reusability):
    • 多语言支持: 如果你想为一种新的编程语言编写编译器,可以复用现有的后端。只需要为新语言编写一个前端,将它编译成通用的中间表示即可。例如,GCC 和 LLVM 支持多种前端(C, C++, Rust, Go, Swift等)共享同一个后端。
    • 多平台支持: 如果你想让现有的语言支持新的硬件平台,可以复用现有的前端。只需要为新平台编写一个后端,将中间表示翻译成新平台的机器代码即可。
  2. 模块化和简化 (Modularity and Simplification):
    • 将复杂的编译过程分解为更小、更易于管理的模块。每个模块都有清晰的职责,这使得编译器的开发、测试和维护变得更加容易。
  3. 优化与目标独立 (Optimization and Target Independence):
    • 可以在中间代码层面进行大量的、与机器无关的优化。这些优化只需要实现一次,就可以适用于所有支持的源语言和目标机器,提高了优化效率。
    • 后端可以专注于机器相关的优化和代码生成,无需关心源语言的细节。
  4. 接口标准化:
    • 中间表示(IR)充当了前端和后端之间的标准接口。这使得不同团队或开发者可以分别开发前端和后端,只要它们都遵循IR的规范。

简而言之,前端处理“理解”源代码的部分,后端处理“生成”目标代码的部分,中间端(如果有的话)处理“优化”代码的部分。这种清晰的分工是现代编译器设计的基础,大大提高了编译器的灵活性、可扩展性和开发效率。

1. Lexical Analysis

Lexical analysis is the first phase of a compiler, where the input source code is read as a sequence of characters and divided into meaningful sequences called tokens. These tokens are the atomic units such as keywords, identifiers, literals, and operators that are used by the subsequent phases of the compiler.

词法分析生成 tokens 并归类(关键字、标识符、字面量、运算符等)。

注意在词法分析阶段是会出错的,例如:

  • int 111;:这个声明在词法分析阶段是正确的,因为会识别出两个 token
    • <关键字,int><数字,111>
  • a = b + $;:这个声明在词法分析阶段是错误的,因为 $ 无法被识别,变量名不能以 $ 开头。

2. Syntax Analysis

Syntax analysis, also known as parsing, is the phase of a compiler that takes the sequence of tokens produced by the lexical analyzer and arranges them into a tree-like structure called a parse tree or abstract syntax tree (AST) according to the grammar rules of the programming language. It checks whether the token sequence conforms to the language’s syntax.

根据语言的 文法规则,读取 tokens 并生成抽象语法树。在这个阶段会检查语法错误,例如:

  • int 111:在语法阶段会被检查出来,int 后面应该跟关键字而不是数字,这不符合文法规则。以递归下降解析(top-down parsing)为例:

    parseDeclaration() {
        expectToken("int") // 确认第一个Token是int关键字
        if (currentToken.type == IDENTIFIER) {
            // 合法,创建节点
            create AST node for variable declaration
        } else {
            throw syntax error("Expect identifier after int")
        }
    }
    

3. Semantic Analysis

Semantic analysis is the phase in a compiler where the compiler performs static checks on the AST to ensure the program's correctness beyond syntactic structure, such as type checking, scope resolution, and other semantic rules.

对语法单位的 静态语义审查(动态的在运行时),例如:

  • 类型检查:例如不能把字符串和数字直接相加等。
  • 变量声明和使用:检查变量是否声明后使用,避免使用未声明变量。
  • 函数调用参数检查:检查函数调用时传入的参数个数和类型是否匹配函数定义。
  • 作用域检查:变量和函数的作用域是否合法。
  • 常量赋值限制:例如不能修改常量的值。

语义分析的实现通常依赖于以下几个核心机制和技术:

3.1 抽象语法树(AST)

语法分析阶段会生成一个抽象语法树(AST),它比语法分析树更抽象,去掉了许多语法细节,只保留了程序的本质结构。语义分析通常是通过对这棵AST的遍历来完成的。遍历过程中,会对 AST 的每个节点执行相应的语义检查和 信息收集操作

注意,语义分析不仅仅是分析语义,它在遍历 AST 的过程中还会添加一些信息。

1. 语义检查

语义检查是确保程序遵循语言的语义规则,发现那些 语法上合法但意义上不正确 的构造。常见的语义检查操作包括:

类型检查 (Type Checking):

  • 例子: 当遍历到一个加法表达式节点(如 a + b)时,检查操作数 ab 的类型。如果 a 是整数,b 是字符串,那么这是一个类型错误,需要报告“操作数类型不兼容”的错误。如果都是整数,则认为合法。
  • 实现: 查阅符号表获取 ab 的类型信息,然后根据语言规则判断它们是否可以进行加法运算。
  • 信息收集: 如果是合法的加法,确定结果的类型(例如,两个整数相加结果还是整数),并将这个类型信息标注在加法表达式节点上,供后续使用。

声明检查 (Declaration Checking) / 引用消解 (Reference Resolution):

  • 例子: 当遍历到一个变量使用节点(如 x = 10; 中的 x)时,检查变量 x 是否已经被声明过。如果 x 在当前作用域或其父作用域中没有对应的声明,则报告“未声明的变量”错误。
  • 实现: 在符号表中查找 x。查找时需要考虑作用域规则,优先查找最近声明的 x
  • 信息收集: 如果找到声明,将该变量节点与符号表中对应的声明条目关联起来(例如,存储一个指向符号表条目的指针或索引),这样后续就能方便地获取 x 的类型、地址等信息。

作用域检查 (Scope Checking):

  • 例子: 当遍历到一个函数调用节点(如 myFunction(param1, param2))时,检查 myFunction 是否在当前作用域内可见且是一个函数。
  • 实现: 在符号表中查找 myFunction,并验证其条目类型是否为函数。
  • 信息收集: 确定是哪个函数,将其在符号表中的位置标记在调用节点上。

控制流检查 (Control Flow Checking):

  • 例子: 检查 return 语句是否出现在函数内部。或者确保 breakcontinue 语句只出现在循环或 switch 语句中。
  • 实现: 在遍历过程中,维护一个状态来跟踪当前所处的代码块类型(是否在循环或函数内)。
  • 信息收集: 记录每个 return 语句返回的值的类型,以便与函数声明的返回类型进行比较。

唯一性检查 (Uniqueness Checking):

  • 例子: 在同一作用域内,变量名不能重复声明。当遍历到变量声明节点时,检查该变量名是否已在当前作用域中存在。
  • 实现: 在将新声明的变量添加到符号表之前,先在当前作用域的符号表中查找是否存在同名变量。
  • 信息收集: 将新声明的变量及其属性(类型、作用域等)添加到符号表。

2. 信息收集操作

信息收集是为了在AST节点上附加更多的语义信息,这些信息对于后续的代码生成至关重要。

类型标注 (Type Annotation):

  • 例子: 对于每个表达式节点(如 2 + 3a * btrue && false),计算并标注其结果的类型。2 + 3 的结果是 inta * b 如果 a, bfloat,结果就是 float
  • 用途: 供后续类型检查和中间代码生成阶段使用。

值/常量计算 (Value/Constant Evaluation):

  • 例子: 如果表达式完全由常量组成(如 1 + 2 * 3),在语义分析阶段就可以直接计算出其结果 7,并用这个常量值替换掉整个表达式节点。
  • 用途: 优化代码,简化后续处理。

作用域信息 (Scope Information):

  • 例子: 在每个声明节点(变量声明、函数声明)上,标注其所属的作用域。
  • 用途: 帮助在引用消解时正确查找标识符。

地址/偏移量计算 (Address/Offset Calculation):

  • 例子: 对于局部变量,可以在语义分析阶段计算出它们在栈帧中的相对偏移量。对于结构体成员,可以计算它们在结构体内部的偏移量。
  • 用途: 直接为后续的代码生成(如生成汇编指令)提供内存地址信息。

其他属性的填充:

  • 数组维度信息: 对于数组声明,记录其维度大小。
  • 结构体成员信息: 记录结构体成员的名称、类型和偏移量。
  • 函数签名 (Function Signature): 收集函数的返回类型、参数列表(参数数量和类型)等。

3.2 Symbol Table

符号表是语义分析的核心数据结构。它用于存储程序中所有标识符(变量名、函数名、类名等)的相关信息,例如:

  • 类型: 变量的类型(整型、浮点型、字符串等),函数的返回类型和参数类型。
  • 作用域: 标识符的可见范围(全局、局部、函数参数等)。
  • 存储位置: 标识符在内存中的相对地址或偏移量。
  • 其他属性: 例如,数组的维度、结构体的成员等。 语义分析阶段会根据标识符的声明和使用来动态地更新和查询符号表。

3.3 Attribute Grammar

属性文法是在上下文无关文法 (Context-Free Grammar, CFG) 的基础上,通过为文法符号(终结符和非终结符)关联“属性”以及为每个产生式关联“语义规则”来扩展的。 它的目的是为了描述上下文有关的语言特性和语义,而这些特性是单纯的上下文无关文法无法表达的(例如,变量在使用前必须先声明,表达式中操作数的类型必须兼容等)。

核心组成部分:

  1. 属性 (Attributes): 每个文法符号 X 都被关联了一组属性 X.a。这些属性可以看作是该文法符号所代表的语言结构片段的“值”或“信息”。属性的取值可以是各种数据类型,例如:
    • 一个表达式的值 (例如,E.val 表示表达式 E 的计算结果)。
    • 一个标识符的类型 (例如,id.type 表示标识符 id 的数据类型)。
    • 一个语句的中间代码 (例如,S.code 表示语句 S 对应的中间代码)。
    • 一个变量的存储地址或偏移量。
  2. 语义规则 (Semantic Rules): 与文法中的每个产生式 Aα 都关联了一组语义规则。这些规则定义了如何计算产生式中各个文法符号的属性值。语义规则本质上是一些函数或赋值语句,它们使用产生式中其他符号的属性值来计算某个符号的属性值。

属性的分类:

属性根据其值的计算方向,可以分为两种:

  • 综合属性 (Synthesized Attributes):
    • 属性值由其子节点(在抽象语法树中)的属性值计算得出。
    • 信息流向是自底向上 (Bottom-Up)
    • 例子: 计算表达式的值。对于产生式 EE1+T,可以定义语义规则 E.val = E_1.val + T.val。这里的 E.val 是一个综合属性,它的值由子节点 E1 和 Tval 属性计算而来。S-attributed Grammars (S-属性文法) 就是只使用综合属性的属性文法。
  • 继承属性 (Inherited Attributes):
    • 属性值由其父节点兄弟节点的属性值计算得出。
    • 信息流向是自顶向下 (Top-Down)横向 (Across)
    • 例子: 类型传播。在声明语句 int a, b; 中,int 这个类型信息需要传递给 ab。对于产生式 D -> T L (声明 D 包含类型 T 和标识符列表 L),可以定义语义规则 L.type = T.type,这里的 L.type 是一个继承属性,它从父节点 T 获取类型信息。L-attributed Grammars (L-属性文法) 是一类特殊的属性文法,允许某些形式的继承属性,其评估顺序通常可以从左到右进行一遍扫描。

属性文法的应用:

属性文法提供了一种形式化的方法来:

  • 类型检查: 确保操作数的类型兼容,函数调用参数匹配等。
  • 作用域管理: 记录标识符的声明信息及其作用域。
  • 常量计算: 对编译时已知的常量表达式进行求值。
  • 中间代码生成: 将语义信息转换为中间代码(如三地址码)。
  • 错误报告: 当检测到语义错误时,报告详细的错误信息。

优点:

  • 清晰的形式化: 提供了一种严谨的方式来定义语言的语义。
  • 模块化: 语义规则与语法产生式直接关联,易于理解和维护。
  • 自动化: 可以通过工具自动生成属性计算器。

缺点:

  • 复杂性: 对于大型语言,属性文法可能会非常庞大和复杂。
  • 计算顺序: 属性的计算顺序需要精心设计,以避免循环依赖。

3.4 Syntax-Directed Translation

语法制导翻译是一种实现属性文法并执行语义操作的技术。它通过将语义动作(Semantic Actions)直接嵌入到语法文法的产生式中来实现。当语法分析器在解析过程中识别出某个产生式时,就会执行与之关联的语义动作。

核心组成部分:

  1. 语法文法 (Context-Free Grammar): SDT 的基础是上下文无关文法。
  2. 语义动作 (Semantic Actions):这些是小段的程序代码(通常是函数调用或代码片段),嵌入在语法产生式的右部。它们在解析器识别到这些产生式时执行,用于:
    • 计算属性值。
    • 更新符号表。
    • 生成中间代码。
    • 报告错误。

SDT 的实现方式:

SDT 可以通过多种方式实现:

  • 构建语法树后遍历 (Post-Parse Tree Traversal):

    • 语法分析器首先完整地构建出一个语法树(或抽象语法树)。
    • 然后,通过对这棵树进行一次或多次遍历(例如,深度优先遍历),在访问每个节点时执行相应的语义动作来计算属性或生成代码。
    • 这种方式的优点是语义动作的顺序可以更灵活,因为它是在整个树结构完成后进行的。
  • 解析时嵌入语义动作 (During Parsing / Embedded Semantic Actions):

    • 这是更常见的实现方式,尤其在使用Yacc/Bison或ANTLR等解析器生成工具时。

    • 语义动作直接放置在语法产生式中,当解析器识别出该产生式并进行归约(对于自底向上解析器)或扩展(对于自顶向下解析器)时,相应的动作就会立即执行。

    • 例子 (Yacc/Bison 风格):

      代码段

      expr : expr '+' term  { $$ = $1 + $3; }  // 语义动作:将右部子表达式的值相加赋给左部表达式
           | term
           ;
      term : NUMBER        { $$ = $1; }     // 语义动作:将数字的值赋给term
           ;
      

      在这个例子中,{ $$ = $1 + $3; } 就是一个语义动作。当解析器识别出 expr '+' term 这个模式时,它会执行这个动作,将 expr 的值 ($1) 和 term 的值 ($3) 相加,并将结果赋给当前归约的 expr($$)。

SDT 与属性文法的关系:

  • SDT 是实现属性文法的一种具体技术。 属性文法是一种理论模型,它定义了属性及其计算规则;而SDT则提供了将这些理论概念转化为实际可执行代码的方法。
  • SDT 通常用于实现 S-属性文法(只包含综合属性)和 L-属性文法(包含综合属性和特定形式的继承属性)。
    • S-属性文法: 由于所有属性都是综合属性(自底向上计算),它们非常适合在自底向上解析过程中直接通过语义动作进行计算。当一个产生式的右部符号被归约时,其子节点的综合属性值已经可用,可以直接计算父节点的综合属性。
    • L-属性文法: 它们允许继承属性,但要求这些继承属性只依赖于其父节点的继承属性,或者其左侧兄弟节点的任何属性。这使得L-属性文法可以在自顶向下解析一遍自底向上解析中进行有效的计算。

优点:

  • 直接对应语法结构: 语义处理与语法结构紧密结合,易于理解。
  • 自动化工具支持: 许多解析器生成工具(如Yacc/Bison, ANTLR)都内置了对SDT的支持。
  • 效率: 在解析过程中直接执行语义动作,避免了显式构建和遍历整个语法树的开销(对于S-属性和L-属性)。

缺点:

  • 表达能力限制: 对于需要复杂的、非局部信息传递的语义规则,SDT(特别是简单的嵌入式动作)可能不够灵活,需要多遍遍历或更复杂的属性文法来处理。
  • 对解析器类型的依赖: 语义动作的位置和评估顺序可能依赖于所使用的解析器类型(自顶向下或自底向上)。

4. Intermediate Code Generation

The Intermediate Code Generation phase in a compiler is the stage that translates the source code (typically represented by an Abstract Syntax Tree after semantic analysis) into an Intermediate Representation (IR). This IR is a machine-independent and source-language-independent representation of the program.

4.1 为什么需要中间代码?

在深入介绍中间代码生成之前,我们先理解一下为什么编译器需要中间代码这个阶段:

  1. 独立于源语言和目标机器的桥梁 (Retargetability & Portability):
    • 独立于源语言: 编译器前端(词法、语法、语义分析)高度依赖于特定的编程语言。如果使用中间代码,对于不同的源语言(如C, C++, Java, Python等),它们可以各自生成同一套中间代码。这样,后端(优化、代码生成)就可以重用,无需为每种语言重新编写。
    • 独立于目标机器: 编译器后端(代码优化、目标代码生成)高度依赖于特定的CPU架构和操作系统。如果使用中间代码,同一个中间代码可以通过不同的后端生成适用于不同目标机器(如x86, ARM, MIPS等)的机器码。
    • 这种设计实现了所谓的 "N x M" 问题到 "N + M" 问题的简化:
      • 如果没有中间代码,要支持 N 种语言到 M 种机器,需要 N×M 个编译器。
      • 有了中间代码,只需要 N 个前端(生成中间代码)和 M 个后端(从中间代码生成目标代码),总共是 N+M 个组件。
  2. 易于代码优化 (Optimization):
    • 中间代码通常比源语言更抽象,更易于进行各种分析和转换,以提高代码的执行效率。它去除了源语言中一些复杂的、特定于语言的结构,同时也避免了目标机器指令集带来的底层限制,使得优化器可以更通用地应用各种优化技术。
    • 优化可以在多个层级进行:高级语言层面的优化(在AST上),中间代码层面的优化,以及目标机器层面的优化。中间代码提供了一个非常适合进行通用优化的平台。
  3. 结构化表示:
    • 中间代码介于源语言的高级抽象和目标机器的低级细节之间,它通常具有比汇编代码更高的抽象级别,但比源语言更接近机器执行的逻辑。这使得它既易于理解和分析,又易于转换为最终的机器指令。

4.2 中间代码的常见形式

中间代码没有统一的标准,但通常分为几种类型,代表了不同的抽象级别和表示形式:

  1. 图形表示 (Graphical IRs):

    • 抽象语法树 (Abstract Syntax Tree, AST): 虽然通常认为 AST语法分析的输出语义分析的输入,但它也可以作为一种高级的中间表示。它保留了程序的结构信息,语义信息通常会附加到 AST 的节点上。代码生成可以直接从 AST 进行,也可以先将其转换为其他 IR。
    • 有向无环图 (Directed Acyclic Graph, DAG): 表达式的 DAG 是 AST 的优化形式,它能够识别并共享公共子表达式,避免重复计算。
    • 控制流图 (Control Flow Graph, CFG): 是一种描述程序执行流程的图形表示,节点代表基本块,边代表控制流的跳转。它常用于数据流分析和控制流优化。
  2. 线性表示 (Linear IRs):

    • 三地址码 (Three-Address Code, TAC):

      这是最常用和最重要的中间代码形式之一。每条指令最多包含一个操作符和三个地址(两个操作数地址,一个结果地址)。它将复杂的表达式分解为简单的、原子性的操作。

      • 例子:

        x = (a + b) * c
        

        会被转换为:

        t1 = a + b
        t2 = t1 * c
        x = t2
        
      • 优点:结构简单、易于生成、易于优化。

    • P-code (Pascal P-code): 早期 Pascal 编译器中使用的中间代码,它是一种基于栈的指令集。

    • 四元式 (Quadruples): 类似于三地址码,表示为 (操作符, 操作数1, 操作数2, 结果)。

    • 三元式 (Triples): 与四元式类似,但结果不显式给出,而是通过指令的索引来引用。

    • 静态单赋值形式 (Static Single Assignment, SSA)

      一种特殊的中间代码形式,每个变量在程序中只能被赋值一次。这对于许多高级优化(特别是数据流分析)非常有利。

      • 例子:

        x = ...
        if (...) {
            x = ...
        } else {
            x = ...
        }
        // x 在这里的值是来自if或else分支
        

        在SSA中会变成:

        x0 = ...
        if (...) {
            x1 = ...
        } else {
            x2 = ...
        }
        x3 = phi(x1, x2) // phi函数表示根据控制流选择正确的值
        

5. Intermediate Code Optimization

Intermediate Code Optimization is a critical stage within the compiler's "middle-end," occurring after the source code has been translated into an Intermediate Representation (IR) and before the final machine code generation. The primary goal of this phase is to apply various transformations and analyses to the IR to enhance the performance and resource efficiency of the resulting executable code.

Its primary goal is to improve the efficiency of the generated machine code without altering the program's observable behavior. This efficiency can manifest as:

  • Faster Execution Speed: Reducing the number of instructions executed, CPU cycles, or memory accesses.
  • Smaller Code Size: Reducing the memory footprint of the executable.
  • Reduced Power Consumption: Especially critical for embedded systems and mobile devices.

优化类型和常见技术

中间代码优化可以根据其作用范围分为不同的类型:

1. 局部优化(Local Optimizations)

这些优化仅限于单个基本块(Basic Block)内进行。一个基本块是一系列指令,其中只有一个入口点和一个出口点。局部优化实现相对简单,执行速度快。

  • 公共子表达式消除(Common Subexpression Elimination, CSE):识别并移除基本块内重复计算的表达式。如果一个表达式之前已经被计算过,并且其操作数没有发生变化,那么可以直接复用之前的结果。

    • 示例:

      t1 = a + b
      t2 = a + b  // 冗余
      t3 = c + t1
      

      优化后:

      t1 = a + b
      t3 = c + t1
      
  • 死代码消除(Dead Code Elimination):移除那些计算结果永不被使用的指令。

    • 示例

      x = y + z  // 如果 x 在后续代码中从未被使用,则此行是死代码
      
  • 常量折叠(Constant Folding):在编译时直接计算并替换常量表达式的值。

    • 示例x = 10 + 5 * 2 直接变为 x = 20
  • 常量传播(Constant Propagation):如果一个变量被赋值为一个常量,那么该变量后续的所有使用都可以直接替换为这个常量值。

    • 示例

      c = 10
      d = c + 5
      

      优化后(结合常量折叠):

      c = 10
      d = 15
      
  • 代数化简(Algebraic Simplification):应用代数恒等式简化表达式。

    • 示例x = y * 1 变为 x = yx = y + 0 变为 x = y

2. 全局优化(Global Optimizations / Intra-procedural)

这些优化跨越单个基本块,但仍在单个函数或过程的范围内进行。它们通常依赖于控制流图(Control Flow Graph, CFG)数据流分析来理解程序在不同路径上的数据和控制流。

  • 循环优化(Loop Optimizations):由于循环常常是程序的性能瓶颈,这个领域有很多专门的优化技术。
    • 循环不变代码外提(Loop Invariant Code Motion, LICM):将循环体内每次迭代都产生相同结果的计算,移动到循环开始之前执行一次。
    • 强度削弱(Strength Reduction):用计算开销较小的操作替代开销较大的操作(例如,将循环中的乘法替换为加法)。
    • 循环展开(Loop Unrolling):复制循环体多次,以减少循环的控制开销(如分支指令)。
  • 全局公共子表达式消除:将 CSE 扩展到整个函数范围。
  • 拷贝传播(Copy Propagation):如果变量 x 被赋值为变量 y 的值(即 x = y),那么 x 后续的使用可以在一定条件下被 y 替代。

3. 过程间优化(Inter-procedural Optimizations)

这些优化跨越多个函数甚至整个程序。它们是最复杂、资源消耗最大的优化,但也能带来最显著的性能提升。

  • 函数内联(Function Inlining):将函数调用的地方直接替换为被调用函数的实际代码体。这消除了函数调用的开销,并且能够暴露出更多的局部和全局优化机会。
  • 过程间死代码消除:移除整个程序中从未被调用的函数。
  • 逃逸分析(Escape Analysis):确定一个变量的生命周期是否会超出其创建函数的作用域。如果不会逃逸,该变量就可以分配在栈上而非堆上,减少内存管理开销。

优化技术依赖的数据结构与分析

许多优化都依赖于编译器在前面阶段生成或在优化阶段构建的各种分析技术和数据结构:

  • 控制流图(CFG):表示程序执行路径的图形结构,是进行各种全局优化的基础。
  • 数据流分析(Data Flow Analysis):一系列技术(如可达定义、活跃变量分析、可用表达式分析)的集合,用于收集程序中数据如何传播的信息。这些信息对于确定哪些优化是安全且有效的至关重要。
  • 静态单赋值形式(Static Single Assignment, SSA):一种特殊的中间代码形式,保证每个变量只被赋值一次。SSA大大简化了许多数据流分析,使优化器能够更高效地工作。

挑战与考量

  • 成本与收益平衡:优化会增加编译时间。编译器设计者必须在生成高质量代码的性能收益和编译时间之间做出权衡。
  • 正确性:最核心的要求是优化绝不能改变程序的可观察行为。这是非常困难的,尤其是在处理浮点运算或有副作用的代码时。
  • 阶段顺序:不同的优化阶段的执行顺序会极大地影响最终代码的质量。一个优化可能会为另一个优化创造机会,但也可能破坏之前优化所做的改进。
  • 目标机器感知:虽然IR优化理论上是机器无关的,但某些优化在设计时可能需要考虑到目标机器的某些通用特性(例如,寄存器数量的限制)。

6. Target Code Generation

Target Code Generation (also known as Code Generation or Backend Code Generation) is the final phase of the compilation process. It takes the Intermediate Representation (IR), which has typically been optimized in the preceding phase, and translates it into machine-executable code for a specific target architecture. This output can be assembly language code, relocatable machine code, or even directly executable machine code.

这个阶段高度依赖于目标机器,涉及到几个复杂而关键的任务:

  1. 指令选择(Instruction Selection)
    • 任务:最根本的任务是将 IR 操作映射到目标机器指令集架构(ISA)中可用的特定指令。一个简单的 IR 操作可能被翻译成一条或多条机器指令。
    • 挑战:不同的指令序列可能实现相同的功能,但成本(例如执行时间、代码大小)却不同。代码生成器必须选择最有效的指令序列。这通常涉及将 IR 模式与指令序列模板进行模式匹配。
  2. 寄存器分配与指派(Register Allocation and Assignment)
    • 任务:现代CPU高度依赖寄存器进行快速数据访问。这项任务决定哪些程序变量和中间结果应该驻留在 CPU 寄存器中,以及驻留多长时间。它还决定将这些值分配给哪个具体的物理寄存器(例如,x86 上的EAXEBX,ARM上的R0R1)。
    • 挑战:寄存器是有限资源。当活跃变量数量多于可用寄存器时,一些值必须“溢出”到主内存(速度慢得多)。高效的寄存器分配对性能至关重要。这通常使用图着色算法来实现。
  3. 指令调度(Instruction Scheduling)
    • 任务:重新排列机器指令的执行顺序,以最大程度地减少流水线停顿,提高 CPU 利用率。现代 CPU 使用流水线技术,某些指令可能需要更长时间执行或存在依赖关系,如果调度不当可能导致延迟。
    • 挑战:最佳调度方案严重依赖于目标 CPU 的流水线特性、操作延迟以及可用的功能单元。这是一个复杂的优化问题。
  4. 内存管理与寻址模式(Memory Management and Addressing Modes)
    • 任务:生成正确访问内存中数据的指令。这包括确定变量的内存位置(例如,局部变量在栈上,全局变量在全局数据段),以及选择合适的寻址模式(例如,直接寻址、寄存器间接寻址、基址加偏移量寻址)以高效访问数据。
    • 挑战:高效管理函数调用的栈帧、为局部变量分配空间、以及处理数组和结构体。
  5. 处理控制流(Handling Control Flow)
    • 任务:将 IR 中的控制流构造(如if-else语句、循环(forwhile)、switch语句)翻译成机器级的跳转、分支和条件指令。
    • 挑战:确保正确的跳转目标,并准确处理复杂的控制流。
  6. 函数调用约定(Function Call Conventions)
    • 任务:遵循目标系统为函数调用定义的应用程序二进制接口(ABI)。这包括如何传递参数(通过寄存器还是栈)、如何处理返回值、如何保存/恢复寄存器(调用者保存 vs. 被调用者保存),以及如何管理栈帧。
    • 挑战:不同的操作系统和体系结构有不同的 ABI,这使得这项任务具有高度的系统依赖性。
  7. 窥孔优化(Peephole Optimization)
    • 任务:在生成机器码(或汇编代码)之后进行的最后一次、小范围的优化。它通常通过扫描一小段“窗口”(窥孔)内的指令,并用更高效的指令序列替换效率低下的序列。
    • 示例:将 MOV R1, #0; ADD R2, R1, R3 替换为 MOV R2, R3(如果指令集允许)。

目标代码生成的输出

这个阶段的输出通常是:

  • 汇编语言(Assembly Language):这是机器码的一种人类可读的文本表示。它通常是首先生成的,因为它更容易调试,然后会通过汇编器生成机器码。
  • 可重定位机器码(Relocatable Machine Code):一种几乎可以直接执行的二进制代码,但包含需要由链接器解析的地址占位符。这在生成目标文件(.o.obj)时很常见。
  • 可执行机器码(Executable Machine Code):在某些简单的情况下(例如,对于非常小的程序或专用编译器),代码生成器可能会直接生成一个完全链接的可执行文件。

示例(简化版)

考虑IR中的一条指令:t1 = a + b

目标代码(x86 汇编 - 简化版):

代码段

; 假设 'a' 和 'b' 在内存中,'t1' 需要一个寄存器(例如 EAX)

MOV EAX, [b]   ; 将变量 b 的值从内存移动到 EAX 寄存器
ADD EAX, [a]   ; 将变量 a 的值从内存加到 EAX 中(EAX 现在持有 a+b 的结果)
MOV [t1], EAX  ; 将 EAX 中的结果存储到变量 t1 对应的内存位置

目标代码(ARM 汇编 - 简化版):

代码段

; 假设 'a' 和 'b' 是内存地址,'t1' 需要一个寄存器(例如 R0)

LDR R1, [b_addr]   ; 将 b_addr 处的内存值加载到 R1 寄存器
LDR R2, [a_addr]   ; 将 a_addr 处的内存值加载到 R2 寄存器
ADD R0, R1, R2     ; 将 R1 和 R2 的值相加,结果存入 R0(R0 现在持有 a+b 的结果)
STR R0, [t1_addr]  ; 将 R0 中的结果存储到 t1_addr 对应的内存位置

注意看,单条IR指令 t1 = a + b 如何展开为多条机器特定的指令,同时考虑了寄存器和内存访问。

7. Target Code Optimization

Target Code Optimization refers to the process of optimizing the machine code (or assembly code) generated by the compiler's backend. This phase occurs after the intermediate code has been translated into target-specific instructions, and often after global intermediate code optimizations have already been applied.

While intermediate code optimization focuses on machine-independent improvements, target code optimization specifically leverages the unique characteristics of the target processor's architecture to achieve further performance gains, code size reduction, or other efficiency improvements.

为什么需要优化目标代码?

即使经过了广泛的中间代码优化,在目标代码层面仍然有改进的空间,这主要归因于只有在选择了具体的机器指令后才能看到或利用的因素:

  1. 架构特定特性:利用目标 CPU 特有的指令、寻址模式或流水线特性,这些在 IR 中没有抽象表示。
  2. 细粒度资源管理:基于单个机器指令的精确延迟和吞吐量,对寄存器、内存访问模式和指令调度进行更精确的管理。
  3. 代码生成后的清理:修正指令选择过程中可能出现的低效率,或优化在IR层面不明显的指令序列。

关键技术和关注领域

目标代码优化技术高度依赖于特定的指令集架构(ISA)和目标处理器的微架构。

  1. 窥孔优化(Peephole Optimization)
    • 描述:这是一种经典的局部优化技术。它通过扫描一小段已生成的机器指令的“窗口”(或“窥孔”),并用更高效的指令序列替换低效或冗余的序列。它通常作为对汇编代码的最后一次遍历应用。
    • 示例:
      • 冗余加载/存储消除MOV R1, [Addr]; MOV [Addr], R1(如果Addr不是易失的且R1在此期间未被使用)可以被简化或移除。
      • 强度削弱(机器级别):如果目标架构提供,用更简单、更快的指令替换复杂指令(例如,MUL R1, #2 可能被 ADD R1, R1SHL R1, #1 替换)。
      • 不必要跳转消除:将 JMP L1; L1: JMP L2 替换为 JMP L2
      • 指令合并:如果可用,将一系列指令替换为一条更强大的单条指令(例如,ADD; JUMP 可能变为 ADDCJ)。
  2. 指令调度(Instruction Scheduling - 重复)
    • 描述:虽然通常在 IR 层面会部分进行,但最精确的指令调度发生在具体的机器指令已知之后。它旨在重新排序指令以最大化 CPU 流水线利用率,隐藏内存延迟,并避免停顿。这对于现代乱序执行处理器至关重要。
    • 影响因素:指令延迟(指令执行所需时间)、吞吐量(指令可以开始执行的频率)以及指令之间的依赖关系。
  3. 寄存器再分配/溢出优化(Register Re-allocation/Spill Optimization)
    • 描述:在初始寄存器分配之后,进一步的分析可能会发现机会,以减少寄存器溢出(数据在寄存器和内存之间移动)或根据实际指令流改进寄存器使用。这可能涉及重新评估活跃范围或特定的代码模式。
  4. 寻址模式选择(Addressing Mode Selection)
    • 描述:选择最有效的内存访问寻址模式(例如,直接寻址、寄存器间接寻址、基址加偏移量寻址、变址寻址)。不同的模式具有不同的指令编码、执行时间和寄存器需求。
    • 示例(x86):访问 array[i] 可能使用 MOV EAX, [array_base + EBX*4](变址寻址),而不是单独的 MOVADD 指令。
  5. 分支优化(Branch Optimization)
    • 描述:优化条件和无条件跳转。这包括:
      • 分支目标优化:确保跳转目标位置正确,以减小代码大小或改善缓存局部性。
      • 分支预测提示:在某些架构上,编译器可以插入提示信息以帮助分支预测器。
      • 缩短跳转:在可能的情况下,使用更短的相对跳转而不是更长的绝对跳转。
  6. 代码布局优化(Code Layout Optimization)
    • 描述:在内存中安排代码段(例如基本块),以提高缓存性能(指令缓存局部性)并减少页面错误。频繁执行的热点基本块可以放置在一起。
  7. 循环展开(Loop Unrolling - 机器特定)
    • 描述:虽然也是一种IR优化,但展开操作可以在机器层面进行调整,以与缓存行或向量指令长度对齐。
  8. 指令配对/打包(Instruction Pairing/Packing)
    • 描述:在VLIW(超长指令字)或超标量架构上,寻找可以在同一时钟周期内并行执行的独立指令。

挑战

  • 机器特异性:优化通常高度依赖于目标架构的细微差别,使其可移植性较差。
  • 复杂性:理解和建模现代CPU流水线和内存层次结构的复杂行为具有挑战性。
  • 权衡:优化速度可能会增加代码大小,反之亦然。功耗是另一个需要考虑的因素。
  • 阶段顺序:应用目标特定优化的顺序会显著影响最终结果。

与中间代码优化的关系

目标代码优化是中间代码优化的补充。

  • 中间代码优化:侧重于逻辑程序转换和机器无关的改进,通常简化整体计算或结构。
  • 目标代码优化:侧重于物理实现细节和机器特定的调整,使所选的机器指令在给定硬件上尽可能高效地运行。

在许多现代编译器中,“中间端”(IR优化)和“后端”(目标代码生成和优化)之间的界限有些模糊,优化遍数通常交织或集成到代码生成过程中。然而,其概念上的职责分离(机器无关优化 vs. 机器相关优化)仍然是基础。

posted @ 2025-05-26 10:49  光風霽月  阅读(36)  评论(0)    收藏  举报