跳转至

04-中间代码与优化

重要性:⭐⭐⭐⭐⭐ 实用度:⭐⭐⭐⭐ 学习时间:2天 必须掌握:是


为什么学这一章?

中间代码(IR)是编译器前端和后端之间的桥梁。编译器优化在IR层面进行,是提高程序性能的关键。理解IR和优化能帮助你写出编译器更容易优化的高效代码。

学完这一章,你将能够: - ✅ 理解三地址码、SSA等中间表示形式 - ✅ 掌握常见的编译器优化技术 - ✅ 使用GCC/Clang查看优化结果 - ✅ 理解优化对性能的实际影响


📖 核心概念

1. 为什么需要中间表示?

Text Only
┌─────────────────────────────────────────────────────────────┐
│                 中间表示的作用                                 │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  没有IR的情况:M种语言 × N种目标 = M×N个编译器               │
│  ┌─────┐     ┌─────────┐                                   │
│  │  C  │────→│  x86    │                                   │
│  │  C++│────→│  ARM    │    需要 3×3 = 9 个编译器           │
│  │ Java│────→│ RISC-V  │                                   │
│  └─────┘     └─────────┘                                   │
│                                                             │
│  有IR的情况:M个前端 + N个后端 = M+N个模块                   │
│  ┌─────┐     ┌─────┐     ┌─────────┐                      │
│  │  C  │──┐  │     │  ┌─→│  x86    │                      │
│  │  C++│──┼─→│ IR  │──┼─→│  ARM    │  只需 3+3 = 6 个模块 │
│  │ Java│──┘  │     │  └─→│ RISC-V  │                      │
│  └─────┘     └─────┘     └─────────┘                      │
│                                                             │
│  LLVM IR 就是这个思路的成功实践                               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

2. 三地址码(Three-Address Code)

三地址码是最常用的中间表示形式,每条指令最多包含三个操作数:

Text Only
┌─────────────────────────────────────────────────────────────┐
│       C代码 → 三地址码转换示例                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  C代码:                                                     │
│    int result = (a + b) * (c - d);                          │
│                                                             │
│  三地址码:                                                  │
│    t1 = a + b        // 临时变量存中间结果                   │
│    t2 = c - d                                               │
│    t3 = t1 * t2                                             │
│    result = t3                                              │
│                                                             │
│  特点:每条指令最多一个运算符                                 │
│  形式:x = y op z  或  x = op y  或  x = y                 │
│                                                             │
└─────────────────────────────────────────────────────────────┘

3. SSA形式(Static Single Assignment)

SSA是现代编译器中广泛使用的IR形式,每个变量只被赋值一次:

C
// 原始代码
x = 1;
x = x + 1;
y = x * 2;

// SSA形式
x1 = 1;
x2 = x1 + 1;
y1 = x2 * 2;
// 每个变量只赋值一次,加了版本号

SSA的φ函数:处理控制流汇合点

C
// 原始代码
if (cond)
    x = 1;
else
    x = 2;
y = x + 3;

// SSA形式
if (cond)
    x1 = 1;
else
    x2 = 2;
x3 = φ(x1, x2);    // φ函数根据来源选择值
y1 = x3 + 3;

4. 实验:用GCC/Clang查看IR

C
// optimize_demo.c
int sum(int n) {
    int total = 0;
    for (int i = 1; i <= n; i++) {
        total += i;
    }
    return total;
}

int dead_code_demo(int x) {
    int a = x + 1;
    int b = x * 2;   // 如果b未使用,属于死代码
    return a;
}

int constant_folding_demo() {
    int x = 3 + 4;       // 常量折叠:编译期计算为7
    int y = x * 2;       // 常量传播:y = 7 * 2 = 14
    return y;
}
Bash
# GCC: 查看中间表示 (GIMPLE)
gcc -fdump-tree-gimple optimize_demo.c -o /dev/null

# Clang: 生成LLVM IR
clang -S -emit-llvm optimize_demo.c -o optimize_demo.ll
cat optimize_demo.ll

# 对比不同优化级别
gcc -S -O0 optimize_demo.c -o demo_O0.s
gcc -S -O2 optimize_demo.c -o demo_O2.s
diff demo_O0.s demo_O2.s

# 查看优化报告
gcc -O2 -fopt-info-all optimize_demo.c -o /dev/null

📖 编译器优化技术

5. 常见优化分类

Text Only
┌─────────────────────────────────────────────────────────────┐
│                  编译器优化分类                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  局部优化(基本块内)                                        │
│  ├── 常量折叠:3+4 → 7                                     │
│  ├── 常量传播:x=7; y=x+1 → y=8                            │
│  ├── 代数简化:x*1 → x, x+0 → x                           │
│  ├── 强度削减:x*2 → x<<1, x*8 → x<<3                     │
│  └── 公共子表达式消除:a+b出现两次,只算一次                 │
│                                                             │
│  循环优化                                                    │
│  ├── 循环不变量外提:把不变的计算移到循环外                   │
│  ├── 循环展开:减少循环控制开销                              │
│  ├── 强度削减:乘法变加法                                    │
│  └── 向量化:SIMD并行执行                                   │
│                                                             │
│  全局优化                                                    │
│  ├── 死代码消除:删除不可达/无效代码                         │
│  ├── 函数内联:把函数调用替换为函数体                        │
│  ├── 尾递归消除:递归改循环                                  │
│  └── 寄存器分配:减少内存访问                                │
│                                                             │
└─────────────────────────────────────────────────────────────┘

6. 优化示例详解

C
// optimization_examples.c
#include <stdio.h>  // 引入头文件

// ===== 示例1:常量折叠与传播 =====
int const_opt() {
    int a = 10;
    int b = 20;
    int c = a + b;        // 编译器直接计算: c = 30
    int d = c * 2;        // 继续传播: d = 60
    return d;             // 优化后直接 return 60
}

// ===== 示例2:公共子表达式消除(CSE) =====
void cse_demo(int *arr, int i, int j) {  // 指针:存储变量的内存地址
    // 优化前
    arr[i * 4 + j] = 1;
    arr[i * 4 + j + 1] = 2;  // i*4+j 重复计算

    // 编译器优化后等效于:
    // int tmp = i * 4 + j;
    // arr[tmp] = 1;
    // arr[tmp + 1] = 2;
}

// ===== 示例3:循环不变量外提 =====
void loop_hoist(int *arr, int n, int x, int y) {
    // 优化前
    for (int i = 0; i < n; i++) {
        arr[i] = x * y + i;    // x*y 在循环中不变
    }
    // 编译器优化后等效于:
    // int tmp = x * y;
    // for (int i = 0; i < n; i++) {
    //     arr[i] = tmp + i;
    // }
}

// ===== 示例4:循环展开 =====
int loop_unroll(int *arr, int n) {
    int sum = 0;
    // 优化前
    for (int i = 0; i < n; i++) {
        sum += arr[i];
    }
    // 编译器可能展开为(假设n是4的倍数):
    // for (int i = 0; i < n; i += 4) {
    //     sum += arr[i] + arr[i+1] + arr[i+2] + arr[i+3];
    // }
    return sum;
}

// ===== 示例5:尾调用优化 =====
// 递归版本
int factorial_recursive(int n, int acc) {
    if (n <= 1) return acc;
    return factorial_recursive(n - 1, acc * n);  // 尾递归
}
// 编译器可优化为循环,避免栈增长

// ===== 示例6:函数内联 =====
static inline int square(int x) {
    return x * x;
}

int use_inline(int a, int b) {
    return square(a) + square(b);
    // 内联后:return a*a + b*b;(省去函数调用开销)
}

int main() {
    printf("常量优化: %d\n", const_opt());
    printf("阶乘(5): %d\n", factorial_recursive(5, 1));
    printf("内联: %d\n", use_inline(3, 4));
    return 0;
}
Bash
# 对比不同优化级别的汇编输出
gcc -S -O0 optimization_examples.c -o opt_O0.s
gcc -S -O2 optimization_examples.c -o opt_O2.s

# 在Compiler Explorer (godbolt.org) 上对比更直观

7. LLVM IR简介

LLVM
; LLVM IR 示例:计算两数之和
define i32 @add(i32 %a, i32 %b) {
entry:
  %result = add i32 %a, %b
  ret i32 %result
}

; 带条件分支的函数
define i32 @abs_val(i32 %x) {
entry:
  %cmp = icmp slt i32 %x, 0      ; x < 0 ?
  br i1 %cmp, label %neg, label %pos

neg:
  %negx = sub i32 0, %x          ; -x
  br label %done

pos:
  br label %done

done:
  %result = phi i32 [%negx, %neg], [%x, %pos]  ; SSA φ函数
  ret i32 %result
}

💡 面试常见问题

Q1:GCC的 -O0, -O1, -O2, -O3, -Os 各有什么区别?

-O0不优化(便于调试);-O1基础优化(常量折叠、死代码消除等);-O2常用优化(内联、循环优化、指令调度等,不牺牲编译速度);-O3激进优化(更多内联、向量化,可能增大代码体积);-Os优化代码大小(类似O2但禁用增大体积的优化)。

Q2:什么是SSA?为什么现代编译器都使用它?

:SSA(静态单赋值)要求每个变量只赋值一次。好处:①简化数据流分析;②使def-use链显式化;③许多优化(常量传播、死代码消除)在SSA上实现更简单高效。LLVM、GCC中端都使用SSA。

Q3:什么是循环不变量外提?编译器如何判断不变量?

:将循环中每次迭代结果都相同的计算移到循环前。编译器通过数据流分析检查:如果一个表达式的所有操作数在循环中不被修改(定值点在循环外),则为循环不变量。

Q4:编译器优化可能导致什么问题?

:①调试困难(变量被优化掉、代码重排);②浮点精度差异(-ffast-math改变计算顺序);③多线程程序中的内存序问题(编译器重排可能破坏无锁算法);④极少情况下优化器bug导致错误代码。

Q5:volatile 关键字如何影响编译器优化?

volatile告诉编译器:该变量可能被外部因素(硬件、中断、其他线程)修改,禁止对其进行优化,每次必须从内存重新读取。常用于硬件寄存器映射、信号处理程序中的共享变量。


📝 本章小结

Text Only
┌─────────────────────────────────────────────┐
│              本章核心知识点                    │
├─────────────────────────────────────────────┤
│                                             │
│  1. IR是前端与后端的桥梁                    │
│  2. 三地址码:每条指令最多三个操作数        │
│  3. SSA:每个变量只赋值一次+φ函数           │
│  4. 局部优化:常量折叠/传播、CSE等          │
│  5. 循环优化:外提、展开、向量化            │
│  6. 全局优化:内联、死代码消除等            │
│                                             │
└─────────────────────────────────────────────┘

下一章05-目标代码生成 - 从IR到机器码