7. 代码优化:常量折叠、死代码消除、循环优化

说到编译器优化,很多人觉得这是后端的事,跟咱们写 C 的没关系。其实不然。你想想看,编译器在背后做了多少「小动作」?我当年刚入行时,写了一段看起来很漂亮的代码,结果反汇编一看,好家伙,编译器给我改得面目全非。嗯,从那时起我就明白了——不懂优化,你就看不懂编译器生成的机器码。

这一章,咱们就聊聊三个最基础的优化手段:常量折叠、死代码消除、循环优化。它们看似简单,但背后的原理值得深挖。

7.1 常量折叠:编译期就算好

什么叫常量折叠?说白了,就是编译器在编译阶段就把常量表达式算出来,省得运行时再算一遍。

举个例子:

int x = 10 + 20;
int y = 3 * 4 + 5;

你写的是 10+20,编译器直接给你变成 30。3*4+5 变成 17。这有什么好处?省一次加法指令,省一次乘法指令。虽然现代 CPU 算这个快得跟玩似的,但积少成多啊。

关键点:常量折叠发生在编译器的中间表示阶段,不是词法分析阶段。它依赖常量传播的结果。

我在项目中遇到过一个问题:一个嵌入式设备上,有个循环里反复计算 PI * 2,每次循环都算一遍。我一看汇编,发现编译器根本没优化。为什么?因为 PI 是个宏展开后的浮点常量,但编译器优化等级没开够。后来我把优化等级从 -O0 调到 -O2,问题就解决了。

常量折叠能处理哪些情况?

  • 整数常量运算:1 + 23 * 4
  • 浮点常量运算:3.14 * 2.0
  • 字符常量运算:'A' + 32
  • 枚举常量运算
  • sizeof 表达式:sizeof(int) * 4

小技巧:如果你想让编译器帮你做更多常量折叠,尽量把常量定义成 const#define,别用变量。变量在编译期值不确定,编译器不敢乱动。

7.2 死代码消除:别留没用的东西

死代码,就是永远不会被执行到的代码。比如:

int func(int x) {
    if (0) {
        return 42;  // 永远不会执行
    }
    return x * 2;
}

if(0) 这个条件永远为假,里面的 return 42 就是死代码。编译器会直接把它删掉。

还有一种情况更隐蔽:

int func(int x) {
    int a = x + 1;
    int b = a * 2;
    // b 后面再也没用过
    return x;
}

变量 b 计算出来了,但后面没用。编译器会直接把 int b = a * 2; 这条语句删掉。这叫「无用赋值消除」,是死代码消除的一种。

注意:死代码消除不是万能的。如果代码里有副作用(比如函数调用、volatile 变量),编译器不敢随便删。我曾经在调试代码时,故意留了一段打印日志的代码,结果优化一开,日志没了。后来才发现,我把打印函数声明成了纯函数,编译器认为它没有副作用,就给优化掉了。

死代码消除的常见场景:

  • 条件永远为假的 if 分支
  • 永远不会到达的 return 语句
  • 无用的变量赋值
  • 无用的循环(循环体为空且无副作用)
  • 调试代码(#ifdef 条件编译)

我个人习惯在写代码时,尽量用条件编译来控制调试代码,而不是靠编译器优化来删除。因为优化等级一变,行为可能不一样。

7.3 循环优化:性能提升的重头戏

循环优化是编译器优化的核心。为什么?因为程序大部分时间都花在循环里。你想想看,一个循环跑 100 万次,每次省一条指令,那就是省了 100 万条指令。

常见的循环优化手段有这些:

7.3.1 循环展开

循环展开,就是把循环体复制几份,减少循环控制的开销。

// 原始代码
for (int i = 0; i < 4; i++) {
    a[i] = b[i] + c[i];
}

// 展开后
a[0] = b[0] + c[0];
a[1] = b[1] + c[1];
a[2] = b[2] + c[2];
a[3] = b[3] + c[3];

展开后,少了 4 次 i 的比较和跳转。但也不是展开得越多越好。展开太多,代码体积变大,指令缓存可能装不下,反而变慢。

经验之谈:我一般建议循环展开 2 到 4 倍就够了。具体多少,得看你的 CPU 和缓存大小。嵌入式设备上,代码体积敏感,展开太多反而坏事。

7.3.2 循环不变代码外提

循环里有些计算,每次循环结果都一样。编译器会把它提到循环外面。

// 原始代码
for (int i = 0; i < n; i++) {
    a[i] = a[i] * (PI / 180.0);
}

// 优化后
double factor = PI / 180.0;
for (int i = 0; i < n; i++) {
    a[i] = a[i] * factor;
}

PI/180.0 每次循环都一样,提到外面只算一次。这个优化看起来简单,但实际效果很明显。我在做图像处理时,一个 1920x1080 的图片,循环里有个不变的计算,提出来后帧率从 30 涨到了 45。

7.3.3 循环合并与分裂

有时候,两个循环可以合并成一个,减少循环开销。

// 原始代码
for (int i = 0; i < n; i++) {
    a[i] = b[i] * 2;
}
for (int i = 0; i < n; i++) {
    c[i] = d[i] + 1;
}

// 合并后
for (int i = 0; i < n; i++) {
    a[i] = b[i] * 2;
    c[i] = d[i] + 1;
}

反过来,有时候一个循环太大,编译器会把它拆成多个小循环,提高缓存利用率。这叫循环分裂。

7.3.4 循环向量化

这个比较高级。编译器会把循环里的标量操作,转换成 SIMD 指令(单指令多数据流)。比如:

for (int i = 0; i < 4; i++) {
    a[i] = b[i] + c[i];
}

如果 CPU 支持 SSE 或 AVX,编译器可能生成一条指令,同时加 4 个 float。速度提升 4 倍。

注意:向量化有前提条件。数组必须对齐,循环次数要确定,数据之间不能有依赖。我曾经在项目中写了一个循环,看起来能向量化,但因为有指针别名问题,编译器死活不肯向量化。后来加了 restrict 关键字才解决。

7.4 知识体系总览

下面这张图,把本章的核心逻辑串起来了:

代码优化三大核心 常量折叠 编译期计算常量表达式 死代码消除 删除不可达代码与无用赋值 循环优化 提升循环执行效率 整数 / 浮点 / 字符 / sizeof 不可达分支 / 无用变量 / 空循环 展开 / 外提 / 合并 / 向量化 共同目标:减少指令数、提高缓存命中率、利用硬件特性 依赖数据流分析、控制流分析、别名分析等静态分析技术 优化等级控制:-O0(无优化)→ -O1(基本优化)→ -O2(更多优化)→ -O3(激进优化) 常用编译选项:-funroll-loops(强制展开) -ffast-math(快速数学) -fomit-frame-pointer 💡 建议:开发阶段用 -O0 -g,发布阶段用 -O2

7.5 优化等级与编译选项

不同的优化等级,开启的优化项不一样。我整理了一个表格,方便你对照:

优化等级 开启的优化 编译时间 适用场景
-O0 无优化 最快 调试、开发
-O1 常量折叠、死代码消除、部分循环优化 较快 日常开发
-O2 全部 -O1 + 更多循环优化、内联、指令调度 中等 发布版本(推荐)
-O3 全部 -O2 + 激进向量化、循环展开 较慢 计算密集型应用
-Os 以代码体积为优先的优化 中等 嵌入式、移动端

个人建议:我一般发布时用 -O2,除非确认 -O3 能带来明显提升。因为 -O3 有时会引入 bug,尤其是浮点运算和指针操作多的代码。我曾经被 -O3 坑过一次,一个循环展开后,数组越界访问了,调试了两天才找到原因。

7.6 避坑指南

最后,分享几个我踩过的坑:

  • volatile 关键字:加了 volatile 的变量,编译器不会对它做任何优化。别滥用,但该用的时候一定要用(比如硬件寄存器、多线程共享变量)。
  • 指针别名:两个指针指向同一块内存,编译器不敢优化。用 restrict 告诉编译器「这两个指针不会重叠」,能帮助优化。
  • 调试与优化冲突:优化后的代码,调试时变量可能看不到,断点可能跳来跳去。我习惯在调试时关掉优化,发布时再开。
  • 浮点运算:浮点运算的优化要小心。编译器可能改变运算顺序,导致精度变化。用 -ffloat-store 可以避免一些优化。

嗯,这一章的内容就到这里。代码优化是个大话题,但常量折叠、死代码消除、循环优化这三块,是理解编译器优化思路的基石。你写代码时,多想想编译器会怎么处理你的代码,慢慢就能写出「编译器友好」的代码了。