第二章:编译器优化基础

说实话,很多C++开发者写了几年代码,对编译器到底干了什么,心里是没底的。我见过不少同事,优化代码时全靠「猜」——这里加个inline,那里改个循环,效果怎么样?不知道。其实,编译器是你最强大的盟友,也是最容易被忽视的。

这一章,我们就来聊聊编译器优化的那些事儿。我会从最基础的优化选项讲起,带你看看编译器背后到底在忙活什么,最后教你如何「偷看」编译器生成的汇编代码。嗯,看完这章,你写代码的感觉会完全不一样。

2.1 编译器优化选项:-O1, -O2, -O3, -Os

GCC和Clang的优化选项,说白了就是告诉编译器:「你帮我多费点心思,把代码整快点儿。」但不同的优化级别,付出的「心思」不一样。

优化级别 核心特点 编译时间 典型场景
-O0 不做优化,调试友好 最快 开发调试阶段
-O1 基础优化,减少代码体积和分支 较快 日常开发,需要一定性能
-O2 大部分优化,平衡体积与速度 中等 发布版本首选
-O3 激进优化,可能增加代码体积 较慢 计算密集型,对体积不敏感
-Os 优化体积,适合嵌入式 中等 存储受限场景

-O1 做的是「不亏本」的优化。比如去掉死代码、简化表达式。编译快,效果也还行。我个人习惯在写单元测试时用 -O1,既能跑得快一点,又不会因为优化太猛导致调试信息错乱。

-O2 是真正的「主力选手」。它开启了几乎所有不会显著增加编译时间的优化。包括函数内联、循环展开、公共子表达式消除等等。我在项目中交付的版本,99% 都用 -O2。它稳,真的稳。

-O3 就比较激进了。它会做更多循环变换、向量化,甚至可能把函数复制多份做不同分支的优化。代价是什么?代码体积变大,编译时间变长。我曾经在一个图像处理项目里试过 -O3,性能提升了 15%,但二进制体积膨胀了 40%。值不值?看场景。

-Os 是体积优先。它会在「能省体积」的地方做优化,哪怕牺牲一点速度。嵌入式开发、移动端、或者你那个只有 2MB Flash 的 MCU 项目,-Os 是好朋友。

核心建议: 不要默认用 -O3。先上 -O2,如果性能瓶颈明确且 -O3 能带来可测量的提升,再考虑升级。否则,你只是在浪费编译时间。

2.2 编译器优化原理:它到底在忙什么?

编译器优化,说白了就是「等价变换」。它保证程序的行为不变,但换一种更高效的实现方式。我把它分成三大类:

2.2.1 常量传播与折叠

// 原始代码
int x = 5;
int y = x + 3;
return y;

// 优化后
return 8;

编译器直接算出来了。这种优化在 -O1 就会做。你想想看,如果你写了一大堆常量运算,编译器其实早就帮你算完了,运行时根本不用算。

2.2.2 公共子表达式消除

// 原始代码
int a = b * c + d;
int e = b * c + f;

// 优化后
int tmp = b * c;
int a = tmp + d;
int e = tmp + f;

编译器发现 b * c 算了两次,就把它提出来只算一次。这种优化在 -O2 默认开启。我在做数据库引擎时,经常写一些复杂的表达式,编译器帮我省了不少事。

2.2.3 函数内联

这是最直观的优化之一。小函数被直接「粘贴」到调用处,省去了函数调用的开销。

// 原始代码
inline int add(int a, int b) { return a + b; }
int result = add(3, 4);

// 优化后
int result = 3 + 4;

但要注意,inline 关键字只是建议,编译器有权拒绝。反过来,编译器也可能自动内联你没有标记 inline 的函数。嗯,这里有个坑:我曾经把一个 200 行的函数标记为 inline,结果编译器根本没理我——太大了,内联反而会让指令缓存爆炸。

小技巧:__attribute__((always_inline)) 可以强制内联,但慎用。滥用内联会让代码体积失控,反而拖慢性能。

2.2.4 循环优化

循环是优化的重灾区。编译器会做循环展开、循环交换、循环融合等等。

// 循环展开示例
// 原始
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];

展开后减少了循环控制的开销,但代码体积变大了。编译器会自己权衡。我记得有一次,我手写了一个循环展开版本,结果和 -O2 自动展开的性能一模一样……从那以后,我很少手动做这种事了。

2.3 如何查看编译器生成的汇编代码?

想知道编译器到底把你的代码优化成什么样了?最好的办法就是看汇编。别怕,你不需要成为汇编专家,能看懂关键部分就行。

2.3.1 使用 -S 选项

GCC 和 Clang 都支持 -S 选项,只生成汇编文件,不生成目标文件。

g++ -O2 -S main.cpp -o main.s

打开 main.s,你就能看到编译器生成的汇编代码。我建议配合 -fverbose-asm 使用,它会添加注释,告诉你每段汇编对应哪行 C++ 代码。

g++ -O2 -S -fverbose-asm main.cpp -o main.s

2.3.2 使用 objdump 反汇编

如果你已经有编译好的二进制文件,可以用 objdump 反汇编。

g++ -O2 -c main.cpp -o main.o
objdump -d main.o | less

我个人更喜欢这种方式,因为可以看到最终的机器码布局。而且 objdump -S 可以混排源代码和汇编,对照着看非常直观。

2.3.3 在线工具:Compiler Explorer

如果你不想装环境,可以用 Compiler Explorer(也就是 Godbolt)。它支持多种编译器、多种优化级别,实时显示汇编。我在写性能分析文章时,经常用它来验证自己的猜想。

避坑指南: 我曾经在调试一个诡异的性能问题时,盯着汇编看了半天,发现编译器把某个循环完全优化掉了——因为循环里的计算结果根本没被使用。所以,看汇编之前,先确认你的代码确实「做了有用的事」。

2.4 知识体系总览

下面这张图,帮你把这一章的核心内容串起来。你可以把它当作一个「优化地图」,以后遇到性能问题,按图索骥就好。

编译器优化知识体系 编译器优化 优化选项 -O0: 无优化,调试 -O1: 基础优化 -O2: 主力优化 -O3: 激进优化 -Os: 体积优化 优化原理 常量传播与折叠 公共子表达式消除 函数内联 循环优化(展开/交换) 死代码消除 查看汇编 g++ -S 生成 .s 文件 objdump -d 反汇编 Compiler Explorer 在线 -fverbose-asm 加注释 核心原则:先 -O2,再分析,最后决定是否升级

2.5 实战:看一个例子

我们来写一段简单的代码,看看不同优化级别下的汇编差异。

// test.cpp
int add(int a, int b) {
    return a + b;
}

int main() {
    int x = 3;
    int y = 4;
    return add(x, y);
}

-O0 编译,你会看到完整的函数调用、栈操作。用 -O2 编译,你会发现 add 函数直接被内联了,main 函数里直接返回 7。这就是优化的力量。

我建议你亲自试一下。打开终端,敲几行命令,看看汇编代码的变化。这种「眼见为实」的感觉,比看任何理论都管用。

我的习惯: 每次写完一个性能关键的函数,我都会用 -O2 -S -fverbose-asm 看一眼汇编。如果发现编译器生成了奇怪的代码,或者有我没预料到的优化,我就会回去检查我的 C++ 代码。很多时候,问题出在代码本身,而不是编译器。

好了,这一章的内容就到这里。记住:编译器是你的朋友,但你不能完全依赖它。理解它的优化策略,学会查看它的「作业」,你才能写出真正高效的代码。


公众号:蓝海资料掘金营,微信 deep3321