04-中间代码与优化¶
重要性:⭐⭐⭐⭐⭐ 实用度:⭐⭐⭐⭐ 学习时间:2天 必须掌握:是
为什么学这一章?¶
中间代码(IR)是编译器前端和后端之间的桥梁。编译器优化在IR层面进行,是提高程序性能的关键。理解IR和优化能帮助你写出编译器更容易优化的高效代码。
学完这一章,你将能够: - ✅ 理解三地址码、SSA等中间表示形式 - ✅ 掌握常见的编译器优化技术 - ✅ 使用GCC/Clang查看优化结果 - ✅ 理解优化对性能的实际影响
📖 核心概念¶
1. 为什么需要中间表示?¶
┌─────────────────────────────────────────────────────────────┐
│ 中间表示的作用 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 没有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)¶
三地址码是最常用的中间表示形式,每条指令最多包含三个操作数:
┌─────────────────────────────────────────────────────────────┐
│ 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形式,每个变量只被赋值一次:
SSA的φ函数:处理控制流汇合点
// 原始代码
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¶
// 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;
}
# 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. 常见优化分类¶
┌─────────────────────────────────────────────────────────────┐
│ 编译器优化分类 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 局部优化(基本块内) │
│ ├── 常量折叠: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. 优化示例详解¶
// 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;
}
# 对比不同优化级别的汇编输出
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 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告诉编译器:该变量可能被外部因素(硬件、中断、其他线程)修改,禁止对其进行优化,每次必须从内存重新读取。常用于硬件寄存器映射、信号处理程序中的共享变量。
📝 本章小结¶
┌─────────────────────────────────────────────┐
│ 本章核心知识点 │
├─────────────────────────────────────────────┤
│ │
│ 1. IR是前端与后端的桥梁 │
│ 2. 三地址码:每条指令最多三个操作数 │
│ 3. SSA:每个变量只赋值一次+φ函数 │
│ 4. 局部优化:常量折叠/传播、CSE等 │
│ 5. 循环优化:外提、展开、向量化 │
│ 6. 全局优化:内联、死代码消除等 │
│ │
└─────────────────────────────────────────────┘
下一章:05-目标代码生成 - 从IR到机器码