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 + 2、3 * 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 知识体系总览
下面这张图,把本章的核心逻辑串起来了:
7.5 优化等级与编译选项
不同的优化等级,开启的优化项不一样。我整理了一个表格,方便你对照:
| 优化等级 | 开启的优化 | 编译时间 | 适用场景 |
|---|---|---|---|
| -O0 | 无优化 | 最快 | 调试、开发 |
| -O1 | 常量折叠、死代码消除、部分循环优化 | 较快 | 日常开发 |
| -O2 | 全部 -O1 + 更多循环优化、内联、指令调度 | 中等 | 发布版本(推荐) |
| -O3 | 全部 -O2 + 激进向量化、循环展开 | 较慢 | 计算密集型应用 |
| -Os | 以代码体积为优先的优化 | 中等 | 嵌入式、移动端 |
个人建议:我一般发布时用 -O2,除非确认 -O3 能带来明显提升。因为 -O3 有时会引入 bug,尤其是浮点运算和指针操作多的代码。我曾经被 -O3 坑过一次,一个循环展开后,数组越界访问了,调试了两天才找到原因。
7.6 避坑指南
最后,分享几个我踩过的坑:
- volatile 关键字:加了 volatile 的变量,编译器不会对它做任何优化。别滥用,但该用的时候一定要用(比如硬件寄存器、多线程共享变量)。
- 指针别名:两个指针指向同一块内存,编译器不敢优化。用
restrict告诉编译器「这两个指针不会重叠」,能帮助优化。 - 调试与优化冲突:优化后的代码,调试时变量可能看不到,断点可能跳来跳去。我习惯在调试时关掉优化,发布时再开。
- 浮点运算:浮点运算的优化要小心。编译器可能改变运算顺序,导致精度变化。用
-ffloat-store可以避免一些优化。
嗯,这一章的内容就到这里。代码优化是个大话题,但常量折叠、死代码消除、循环优化这三块,是理解编译器优化思路的基石。你写代码时,多想想编译器会怎么处理你的代码,慢慢就能写出「编译器友好」的代码了。