C 语言编译优化行为
更新时间:2026-08-26。本文是
languages/c/主题专家层文档。写 C 不只是"写代码",还要知道编译器拿你的代码做了什么——同样是-O0和-O2,生成的机器码可能天差地别,甚至同一段代码跑出不同结果(那多半是踩了 UB)。本文用真实的编译数据把优化行为讲清楚。
本文要回答的问题
-O0、-O1、-O2、-O3各做了什么?差别有多大?- 为什么
-O2下"没用到的代码"会整个消失? - 优化和
-g调试符号冲突吗?perf还准不准?
一、优化级别总览
| 级别 | 典型手段 | 代价 | 使用场景 |
|---|---|---|---|
-O0 | 几乎不优化,逐条对应源码 | 代码慢、体积大 | 调试、本站默认(保留符号与行号) |
-O1 | 常量折叠、基本块合并、死代码消除 | 编译变慢 | 快速验证 |
-O2 | 内联、循环优化、指令调度、向量化准备 | 编译更慢 | 生产默认 |
-O3 | 激进循环展开、函数版本化、更多向量化 | 体积膨胀、编译最慢 | 数值密集计算 |
编译器不是"盲目加速",它按优化级别逐层放开手脚。下面这张图把 -O0 到 -O3 的能力递进画出来:级别越低,编译器越"老实"——几乎逐行翻译;级别越高,越敢做循环展开、删掉你"算了没用"的代码、把常量提前算好直接替换。理解这条阶梯,你才明白为什么"-O0 能跑、-O2 崩了"往往不是编译器有 bug,而是你的代码依赖了未定义行为(UB)。

二、实测:死代码消除(最直观的优化)
写一个"算了但没用"的函数:
int dead_code(int a, int b) {
int unused = a * b; // 结果没人用
return a + b;
}编译后看反汇编(objdump -d),a * b 这句直接消失了:
; -O2 下的 dead_code(gcc 4.8.5 实测)
00000000004005c0 <dead_code>:
4005c0: 8d 04 37 lea (%rdi,%rsi,1),%eax ; 直接算 a+b
4005c3: c3 retqint unused = a * b 的结果没有任何人读取,编译器判定它是"死代码",整句删除——这就是死代码消除(dead code elimination)。本站 demos/cpu-demo 的 make release 演示点:sum += i 这种"结果没被观察"的循环,在 -O2 下可能被整个消除。
实测命令:
gcc -std=c99 -O2 -o opt-O2 opt-test.c && objdump -d opt-O2
三、实测:常量折叠
int constant_fold(void) {
int x = 2 + 3 * 4; // 编译器在编译期直接算出 14
return x;
}2 + 3 * 4 是编译期就能算出的常量表达式,编译器直接折叠成 14,运行期不做任何运算。这就是常量折叠(constant folding)——它让 #define 常量、字面量运算零成本。
四、实测:循环优化(-O0 vs -O2 对比)
累加函数:
int sum_to(int n) {
int sum = 0;
for (int i = 1; i <= n; i++) sum += i;
return sum;
}-O0(无优化,逐条对应源码):
; -O0 的 sum_to(gcc 4.8.5 实测,17 条指令)
000000000040052d <sum_to>:
40052d: 55 push %rbp
40052e: 48 89 e5 mov %rsp,%rbp
400531: 89 7d ec mov %edi,-0x14(%rbp) ; n 存栈
400534: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) ; sum=0
40053b: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp) ; i=1
400542: eb 0a jmp 40054e ; 跳到条件判断
400544: 8b 45 f8 mov -0x8(%rbp),%eax
400547: 01 45 fc add %eax,-0x4(%rbp) ; sum += i
40054a: 83 45 f8 01 addl $0x1,-0x8(%rbp) ; i++
40054e: 8b 45 f8 mov -0x8(%rbp),%eax
400551: 3b 45 ec cmp -0x14(%rbp),%eax ; i <= n ?
400554: 7e ee jle 400544
400556: 8b 45 fc mov -0x4(%rbp),%eax
400559: 5d pop %rbp
40055a: c3 retq-O0 把每行 C 都"忠实"翻译:sum、i 都在栈上,循环有显式的比较 cmp 和跳转 jle。这就是本站 demo 默认 -O0 -g 的原因——指令与源码行一一对应,perf 能精确定位热点。
-O2(优化,循环体用寄存器):
; -O2 的 sum_to(gcc 4.8.5 实测)
0000000000400590 <sum_to>:
400590: 85 ff test %edi,%edi ; n <= 0 直接返回
400592: 7e 17 jle 4005ab
400594: 83 c7 01 add $0x1,%edi
400597: ba 01 00 00 00 mov $0x1,%edx
40059c: 31 c0 xor %eax,%eax
4005a0: 01 d0 add %edx,%eax ; 累加在寄存器
4005a2: 83 c2 01 add $0x1,%edx
4005a5: 39 fa cmp %edi,%edx
4005a7: 75 f7 jne 4005a0
4005a9: f3 c3 repz retq-O2 把 sum、i 都放进了寄存器(%eax、%edx),省去反复读写栈的开销,循环体更紧凑。
注:这里是 gcc 4.8.5 的行为——它优化了循环但没有识别出等差数列求和公式。较新的 gcc(11+)会直接用
n*(n+1)/2把整个循环消除。优化器能力随版本演进,这也是"为什么要实际验证而非背结论"的原因。
五、优化与调试/剖析的冲突
-O2 会打乱"指令 ↔ 源码行"的对应关系:
| 现象 | 原因 |
|---|---|
| 变量被优化没 | 编译器判断它无用,不再分配存储 |
| 函数被内联 | 小函数调用点直接展开,符号表里函数"消失" |
| 单步调试跳行 | 指令重排后与源码行不再一一对应 |
perf annotate 对不上号 | 热点汇编与源码行映射错位 |
本站约定:剖析时先 -O0 -g 定位热点(保符号),再 -O2 验证优化效果(看性能)。完整流程见 与汇编、性能剖析衔接。
六、与本站主线衔接
- 优化器消除空转循环的教学实验,见 demos/cpu-demo 的
make release。 - 优化触发未定义行为的风险,见 未定义行为与优化。
- 用
-S看汇编、objdump反汇编,见 编译、链接与 ELF。 - 优化对缓存/TLB 的影响,见 L3 内存子系统。
一句话总结
优化级别是"可读性 ↔ 性能"的旋钮:-O0 保符号逐条对应源码、-O2 做死代码消除/常量折叠/循环优化;剖析时先 -O0 定位热点、再 -O2 验证效果,而优化器的"自由发挥"也正是 UB 翻车的温床。