编译到汇编全流程
# 13.编译到汇编全流程
编译四阶段
.c→.i→.s→.o、词法分析/语法分析/语义分析/中间代码生成/优化/目标代码生成六步、gcc -S读汇编、函数序言push rbp; mov rbp, rsp与尾声leave; ret、-O0/-O2/-O3优化等级对汇编的影响、volatile阻止编译器优化删除的案例
# 目录
# 1. 案例引入
# 1.1 一个"幽灵变量"
先看一段在某嵌入式设备上跑了半年的代码,固件升级换了一个 GCC 版本后,设备间歇性死机——不是每次必死,但一天能死三四次:
// sensor_reader.c —— 传感器数据采集线程
#include <stdbool.h>
static int g_sensor_value = 0;
static bool g_data_ready = false;
/* ISR: 传感器中断服务程序 */
void sensor_isr(void) {
g_sensor_value = read_sensor_register(); // 读取传感器值
g_data_ready = true; // 标记数据就绪
}
/* 主循环中的消费者 */
void main_loop(void) {
int local_copy;
while (1) {
if (g_data_ready) {
local_copy = g_sensor_value;
process_data(local_copy);
g_data_ready = false;
}
// ... 其他工作 ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
现象:
- 旧固件(GCC 7.5
-Os):稳定运行,6 个月零故障 - 新固件(GCC 12.1
-Os):间歇性死机——process_data收到的值是 0(传感器的默认值),而不是 ISR 写入的读数 - 添加
printf调试日志后:问题消失(经典的"Heisenbug") g_data_ready明明被 ISR 设为true了,但main_loop好像永远看不到
用 JTAG 调试器观察 g_data_ready 的内存位置——值确实是 true。那为什么 main_loop 里的 if (g_data_ready) 不走进去?
# 1.2 顺藤摸到根因
带着 JTAG dump 往深挖:
- 假设 1:是不是 ISR 没写成功?—— JTAG 看内存,
g_data_ready= 1,ISR 工作正常。 - 假设 2:是不是
main_loop被优化得寄存器缓存了g_data_ready?—— 看汇编!
$ arm-none-eabi-gcc -Os -S sensor_reader.c -o sensor_reader.s
main_loop 的汇编(关键部分):
main_loop:
ldr r0, .LCPI0_0 @ r0 = &g_data_ready
ldrb r1, [r0] @ r1 = *g_data_ready (加载一次!)
cmp r1, #0
beq .L_skip @ 如果 false,跳到 skip
@ 注意:编译器发现 g_data_ready 在循环体内没被修改(它不知道 ISR 会改)
@ 于是把它提升到循环外 —— 只加载一次,后面都用寄存器里的 r1
@ 这就是 "Loop-invariant code motion" (LICM) 优化
.L_loop:
@ ... 循环体 ...
b .L_loop @ 永远循环
.L_skip:
@ ... 不处理数据的路径 ...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
凶手找到了:编译器优化 g_data_ready 的读取——它分析 main_loop 的代码,发现 g_data_ready 在循环内部从未被写入(编译器不知道 ISR 的存在),于是"好心"地把 g_data_ready 的读取提升到循环外,只读一次。此后 if (g_data_ready) 永远看到的是初始值 false。
假设 3:为什么旧编译器没问题?—— GCC 7.5 的 LICM 优化在这个 case 上恰好没触发(取决于寄存器压力和指令调度决策,这是启发式算法的内部行为,不保证稳定)。
假设 4:为什么加
printf问题消失?——printf是一个外部函数调用,编译器不知道它的副作用,因此保守地假设它可能修改任何全局变量——所以g_data_ready必须在printf之后重新加载。这就是典型的"Heisenbug"成因。
修复:一行代码:
static volatile int g_sensor_value = 0; // ← 加 volatile
static volatile bool g_data_ready = false; // ← 加 volatile
2
volatile 告诉编译器:"每次访问这个变量都必须从内存读取,不要缓存到寄存器里"。
这段事故里藏着至少 8 个原理点:
① g_data_ready 为什么被提升到循环外?这是哪种优化? → 第 6.2 节
② volatile 到底做了什么,为什么不加就出错? → 第 7 章
③ 编译器的优化 pass 是怎么组织的? → 第 4.5 节
④ .c 文件是怎么一步步变成机器码的? → 第 3 章
⑤ GCC 的 -O0/-O2/-Os 各自做了什么? → 第 6 章
⑥ 我怎么自己看编译器生成的汇编? → 第 5 章
⑦ 编译器内部看到了怎样的"中间表示"? → 第 4.4 节
⑧ volatile 能替代原子操作吗? → 第 7.4 节
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 8 个问号往下走,每讲完一段原理就解开一两个;最后在第 8 章把案例彻底剖开。
本篇路线:
架构总图 (第 2 章)
↓
编译四阶段 .c→.i→.s→.o (第 3 章) ─→ 解开"gcc 四个字母干了什么"
↓
编译器内部六步 (第 4 章) ─→ 解开"词法/语法/语义/IR/优化/代码生成"
↓
汇编读写实战 (第 5 章) ─→ 解开"自己读 .s 文件"
↓
优化等级对比 (第 6 章) ─→ 解开"-O0/-O2/-O3 到底差在哪" ← 本篇核心
↓
volatile (第 7 章) ─→ 解开"为什么优化会'误杀'变量"
↓
综合案例 (第 8 章) ─→ 彻底剖开 + 速查卡
2
3
4
5
6
7
8
9
10
11
12
13
📌 本篇定位:编译原理是连接"你写的 C 代码"和"CPU 执行的机器指令"的唯一桥梁。理解编译器做了什么优化、为什么做、以及什么时候"做过头了"——这是从"能用 C 写代码"到"能写出编译器不敢乱优化的 C 代码"的分水岭。
# 2. 架构概览
# 2.1 编译流水线总图
从 .c 源代码到可执行文件,GCC 经过 4 个阶段,对应 4 个子程序:
hello.c hello.i hello.s hello.o
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ C 源代码 │─── cpp ───► │ 预处理后 │─── cc1 ───► │ 汇编代码 │─── as ───► │ 目标文件 │
│ #include │ (预处理器) │ 纯C文本 │ (编译器) │ .s 文件 │ (汇编器) │ .o 文件 │
│ #define │ │ │ │ │ │ │
└──────────┘ └──────────┘ └──────────┘ └─────┬─────┘
│
┌───────▼───────┐
│ 可执行文件 │
│ hello │
│ (ELF 格式) │
└───────────────┘
▲
│
ld (链接器)
─────────────
合并 .o + .so
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
四个阶段的命令对应:
# 一步到位
$ gcc hello.c -o hello
# 分步执行(等于上面一步)
$ gcc -E hello.c -o hello.i # 阶段1: 预处理 (cpp)
$ gcc -S hello.i -o hello.s # 阶段2: 编译 (cc1)
$ gcc -c hello.s -o hello.o # 阶段3: 汇编 (as)
$ gcc hello.o -o hello # 阶段4: 链接 (ld)
# 查看每个阶段的产物
$ head -50 hello.i # 看预处理后的"纯C"
$ cat hello.s # 看编译器生成的汇编(最关键!)
$ objdump -d hello.o # 看目标文件的机器码
$ readelf -h hello # 看可执行文件的ELF头
2
3
4
5
6
7
8
9
10
11
12
13
14
# 2.2 gcc 的参数坐标系
GCC 的参数数以百计,但对于"理解编译流水线",只需掌握这张坐标图:
┌────────────────────────────────────────────────────┐
│ 控制"停在哪"的参数 │
│ │
│ gcc -E 停在预处理后,输出 .i │
│ gcc -S 停在编译后,输出 .s ← 最常用! │
│ gcc -c 停在汇编后,输出 .o │
│ (无) 走完全程,输出可执行文件 │
└────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────┐
│ 控制"怎么编译"的参数 │
│ │
│ -O0 不优化(调试用) │
│ -O1 基本优化 │
│ -O2 标准优化(生产默认) │
│ -O3 激进优化(含向量化、更多内联) │
│ -Os 体积优先优化 │
│ -Ofast 速度优先(牺牲标准兼容性) │
│ -Og 调试友好的优化 │
└────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────┐
│ 控制"输出什么信息"的参数 │
│ │
│ -v 打印编译全流程的子命令(cc1/as/ld) │
│ -### 只打印命令不执行(dry run) │
│ -fdump-tree-all 输出所有优化pass后的GIMPLE树 │
│ -fdump-rtl-all 输出所有RTL pass后的中间表示 │
│ -fverbose-asm 汇编中附带变量名注释 │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 2.3 链接器不在
本章聚焦在**.c → .o** 的编译过程。链接器做的事(符号解析、重定位、段合并)本质上是"把多个 .o 和 .so 拼成一个 ELF 文件",它属于链接原理范畴(专栏后续章节覆盖)。
但理解下面这一点很重要:编译器在看到一个 .c 文件时,完全不知道其他 .c 文件里有什么——它能做的优化仅限于"编译单元内"(除非开启 LTO,Link-Time Optimization)。这个边界在理解"为什么编译器不敢优化某些跨文件调用"时至关重要。
# 3. 编译四阶段全景
# 3.1 预处理:.c → .i
预处理器已经在 第 12 章 详细讲解。这里做最小回顾——用一段代码跑完预处理:
// hello.c
#include <stdio.h>
#define GREETING "Hello, World!"
#define REPEAT(n, thing) n thing
int main() {
REPEAT(printf, (GREETING));
return 0;
}
2
3
4
5
6
7
8
9
$ gcc -E hello.c -o hello.i
$ wc -l hello.i
# 845 lines —— 大部分是 #include <stdio.h> 展开的内容
2
3
预处理后的 hello.i(只截取我们自己代码的部分):
# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "hello.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 27 "/usr/include/stdio.h" 3 4
/* ... 800 多行 stdio.h 的内容 ... */
# 6 "hello.c" 2
int main() {
printf ("Hello, World!"); // ← 宏全部展开完毕
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键观察:
#include <stdio.h>被递归展开为 800+ 行- 宏
GREETING→"Hello, World!" - 宏
REPEAT(printf, (GREETING))→printf ("Hello, World!") - 注释被移除
# 3.2 编译:.i → .s
这是本章的核心——编译器(cc1)把 C 代码翻译成汇编:
$ gcc -S hello.i -o hello.s
# 或直接从 .c 生成
$ gcc -S hello.c -o hello.s
2
3
hello.s(x86-64, -O0):
.section .rodata
.LC0:
.string "Hello, World!" @ rodata 段的字符串
.text @ 代码段
.globl main @ main 是全局符号
.type main, @function
main:
pushq %rbp @ 保存旧栈帧基址
movq %rsp, %rbp @ 建立新栈帧
leaq .LC0(%rip), %rdi @ 第一个参数 = "Hello, World!" 的地址
call puts@PLT @ 调用 puts(printf 被优化成了 puts!)
movl $0, %eax @ 返回值 = 0
popq %rbp @ 恢复旧栈帧
ret @ 返回
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
编译器做的关键决定:
printf("Hello, World!\n")→ 优化成了puts("Hello, World!")——因为格式串没有格式化占位符,puts更高效(不需要解析格式串)- 字符串字面量放到了
.rodata段 - 函数入口自动加了序言(prologue:push rbp + mov rbp, rsp)和尾声(epilogue:pop rbp + ret)
# 3.3 汇编:.s → .o
汇编器(as)把汇编助记符翻译成机器码:
$ gcc -c hello.s -o hello.o
$ objdump -d hello.o # 反汇编看机器码
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi
b: e8 00 00 00 00 call b+0x1
10: b8 00 00 00 00 mov $0x0,%eax
15: 5d pop %rbp
16: c3 ret
2
3
4
5
6
7
8
9
10
11
12
13
对应关系:
| 汇编 | 机器码 | 长度 | 说明 |
|---|---|---|---|
push %rbp | 55 | 1 字节 | push 指令单字节编码 |
mov %rsp,%rbp | 48 89 e5 | 3 字节 | REX.W + MOV + ModR/M |
lea 0x0(%rip),%rdi | 48 8d 3d 00 00 00 00 | 7 字节 | RIP-relative 寻址 |
call puts@PLT | e8 00 00 00 00 | 5 字节 | call 相对偏移(待重定位) |
mov $0x0,%eax | b8 00 00 00 00 | 5 字节 | mov imm32 → eax |
pop %rbp | 5d | 1 字节 | pop 单字节编码 |
ret | c3 | 1 字节 | ret 单字节编码 |
注意:call 指令中的 00 00 00 00 是占位符——汇编器还不知道 puts 在最终可执行文件里的地址(那是链接器的事),所以填 0 并在 .rela.text 重定位表里做了标记。
# 3.4 链接:.o → executable
链接器把多个 .o 和 .so 拼成可执行文件:
$ gcc hello.o -o hello
# 链接器做了:
# 1. 把 hello.o + crt1.o + crti.o + crtn.o + libc.so 合并
# 2. 解析 puts@PLT → 找到 glibc 中 puts 的实际地址
# 3. 给每个段分配最终的虚拟地址
# 4. 生成 ELF header / program headers / section headers
2
3
4
5
6
$ objdump -d hello | grep -A7 '<main>:'
0000000000401126 <main>:
401126: 55 push %rbp
401127: 48 89 e5 mov %rsp,%rbp
40112a: 48 8d 3d d3 0e 00 00 lea 0xed3(%rip),%rdi # 已重定位!
401131: e8 ea fe ff ff call 401020 <puts@plt> # 已重定位!
401136: b8 00 00 00 00 mov $0x0,%eax
40113b: 5d pop %rbp
40113c: c3 ret
2
3
4
5
6
7
8
9
对比 .o 和最终可执行文件——lea 和 call 的地址从 0x00000000 变成了真实地址。这就是链接器的核心工作:重定位。
# 4. 六步流水线
# 4.1 词法分析
词法分析器(lexer)把字符串切分成"令牌(token)"。用 GCC 的 gcc -fdump-tree-original-raw 或 Clang 的 clang -Xclang -dump-tokens 可以查看:
int x = 42 + y;
词法分析输出:
int → 关键字 "int"
[空格] → 忽略
x → 标识符 "x"
[空格] → 忽略
= → 运算符 "="
[空格] → 忽略
42 → 整数字面量 "42"
[空格] → 忽略
+ → 运算符 "+"
[空格] → 忽略
y → 标识符 "y"
; → 分号 ";"
2
3
4
5
6
7
8
9
10
11
12
GCC 的词法分析器在 gcc/c/c-lex.c 中实现。它使用手工编写的递归下降解析器,而不是 yacc/lex 生成器(Clang 也是手工写的——现代编译器都倾向手工编写以获得更好的错误信息和性能)。
词法分析阶段的错误示例:
int x = 42 @ y; // GCC: error: stray '@' in program
// 词法分析器不认识 '@',直接报错
2
# 4.2 语法分析:从令牌到AST
语法分析器(parser)把令牌流组织成抽象语法树(AST):
int x = 42 + y;
AST:
┌─────────┐
│ INIT │ (声明+初始化)
├─────────┤
┌──┤ type int│
│ │ name x │
│ │ init ──┼────┐
│ └─────────┘ │
│ ┌──▼──────┐
│ │ PLUS │ (加法表达式)
│ ├─────────┤
│ ┌───┤ lhs: 42 │
│ │ │ rhs: y │
│ │ └─────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GCC 的语法分析器:
- C 前端是递归下降解析器(
gcc/c/c-parser.c) - 通过
c_parser_*系列函数解析各种 C 语法结构 - 用 libcpp 管理令牌流和预处理
语法错误示例:
int x = ; // GCC: error: expected expression before ';' token
// parser 期望在 '=' 之后看到一个表达式,但只看到了 ';'
2
GCC 的"消化策略":遇到语法错误后,parser 不会立刻退出——它会尝试错误恢复(跳过令牌直到找到分号或花括号),以便在一次编译中报告多个错误:
$ echo 'int a = ; int b = ; int c;' | gcc -x c -fsyntax-only -
<stdin>:1:9: error: expected expression before ';' token
<stdin>:1:18: error: expected expression before ';' token
# 两个错误都报告了,不是看到第一个就退出
2
3
4
# 4.3 语义分析
语义分析检查"语法上正确但不合理"的东西:
int x = "hello" + 42; // 语法正确,语义错误
// GCC: error: invalid operands to binary + (have 'char *' and 'int')
2
GCC 的语义分析核心数据结构——tree:
GCC 使用一个统一的树形数据结构 tree 来表示从声明到表达式的所有东西。在 gcc/tree.h 中定义:
// GCC 内部的 tree 节点(极简表示)
// int x = 42 + y;
//
// VAR_DECL "x"
// type: INTEGER_TYPE "int"
// init: PLUS_EXPR
// left: INTEGER_CST 42
// right: VAR_DECL "y"
// 用 GCC 的 -fdump-tree-original 查看
2
3
4
5
6
7
8
9
10
$ gcc -fdump-tree-original test.c -o /dev/null
$ cat test.c.005t.original # GIMPLE 中间表示
2
语义分析做的事:
- 类型检查:运算符的操作数类型是否兼容
- 符号表构建:记录所有声明的变量和函数
- 作用域解析:
x指的是哪个作用域的x - 隐式转换引用:
int + double→ 需要把int提升为double
# 4.4 中间代码生成:GIMPLE与SSA
疑惑:C/C++ 语法极其复杂,编译器怎么在保持正确性的同时做优化?
论证:编译器用**中间表示(IR, Intermediate Representation)**来拆解复杂度——GCC 的 IR 叫 GIMPLE。
GIMPLE 的三地址码形式:每个操作最多一个运算符 + 最多三个操作数:
// 原始 C 代码
int foo(int a, int b) {
return (a + b) * (a - b) - 42;
}
2
3
4
GIMPLE 表示(简化):
foo (int a, int b)
{
int D.1234;
int D.1235;
int D.1236;
D.1234 = a + b; // 三地址码形式
D.1235 = a - b;
D.1236 = D.1234 * D.1235;
return D.1236 - 42;
}
2
3
4
5
6
7
8
9
10
11
复杂表达式被"打平"成若干个简单的三地址码操作。这就是编译器的"普通话"——后续所有的优化 pass 都在这套语言上工作。
SSA(Static Single Assignment)形式:每个变量只被赋值一次:
// 原始 C
int bar(int x) {
x = x + 1;
x = x * 2;
return x;
}
// SSA 形式:每个版本一个名字
int bar(int x) {
int x_1 = x_0 + 1;
int x_2 = x_1 * 2;
return x_2;
}
2
3
4
5
6
7
8
9
10
11
12
13
SSA 为什么强:每个变量只有唯一定义点,数据流的分析变得精确(不需要做"must-alias"推理)。现代编译器的几乎所有优化(常量传播、死代码消除、值编号)都基于 SSA 形式。
用 GCC dump 看 SSA:
$ gcc -fdump-tree-all test.c -o /dev/null
$ ls test.c.*
test.c.005t.original # 原始 GIMPLE
test.c.006t.gimple # GIMPLE (SSA 化前)
test.c.011t.cfg # 控制流图
test.c.015t.ssa # SSA 形式 ← 这里!
test.c.042t.fre1 # 值编号 / 冗余消除
test.c.100t.optimized # 所有优化后的最终 GIMPLE
2
3
4
5
6
7
8
# 4.5 优化流水线
疑惑:-O2 到底做了哪些优化?为什么比 -O0 快那么多?
论证:GCC 的优化不是一个大黑盒,而是一个 pass 流水线(pass pipeline)——上百个小优化依次执行:
$ gcc -O2 -fdump-passes test.c -o /dev/null
# 输出所有 pass 的名字和顺序(约 300 个)
2
核心优化 pass 分类:
| 类别 | 代表 pass | 干什么 |
|---|---|---|
| 标量优化 | FRE (值编号), SCCP (稀疏条件常量传播), DCE (死代码消除) | 在单线程内消除冗余计算 |
| 循环优化 | LICM (循环不变量外提), IVOpt (归纳变量优化), 循环展开 | 把循环内的不变量提到循环外 |
| 内联 | IPA Inline (跨过程内联) | 把函数体嵌入调用点 |
| 向量化 | SLP (超字级并行), Loop Vectorize | 把标量操作转成 SIMD |
| 指令级 | 指令调度, 寄存器分配, peephole | 为特定 CPU 微架构优化 |
| 内存 | DSE (死存储消除), 别名分析 | 消除对同一地址的冗余读写 |
Pass 流水线示意(-O2):
GIMPLE 生成
→ SSA 构建
→ CCP (条件常量传播)
→ DCE (死代码消除) ← 消除常量传播中变死的代码
→ forwprop (前向替换) ← y=x, z=y+1 → z=x+1
→ PRE (部分冗余消除) ← a*b 重复计算 → 只算一次
→ → → 循环优化阶段
→ LICM (循环不变量外提) ← 就是它把 g_data_ready 提出循环!
→ IVOpt (归纳变量优化) ← for(i=0;i<N;i++) arr[i]=i → 用指针代替
→ Loop unrolling (循环展开)
→ → → 标量优化阶段
→ FRE (值编号)
→ → → 回到RTL层
→ combine (指令合并)
→ RA (寄存器分配)
→ peephole2 (窥孔优化) ← mov r0,r1; mov r1,r2 → mov r0,r2
→ final (输出汇编)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
每个 pass 都是"机会主义"的——它看到一个可以优化的模式就动手,看不到就跳过。这意味着:代码的微小变化(如添加一个 printf)可能会改变某个 pass 的决策,导致完全不同的汇编输出。这就是"安全编码"的意义——不要依赖编译器的特定优化行为。
# 4.6 代码生成
优化后的 GIMPLE 转换为 RTL (Register Transfer Language),然后在 RTL 层面做最后的"翻译":
- 指令选择:把 GIMPLE 的三地址码映射到目标 CPU 的指令——
i + j→ x86 的lea或add - 指令调度:重排指令顺序,使 CPU 流水线不停顿
- 寄存器分配:决定哪个变量放哪个寄存器、哪个"溢出"到栈上——这是编译器中最复杂的部分之一(图着色算法)
int add3(int a, int b, int c, int d, int e, int f, int g) {
return a + b + c + d + e + f + g; // 7 个参数,x86-64 只有 6 个寄存器传参
}
2
3
add3:
# 前6个参数在 rdi, rsi, rdx, rcx, r8, r9
# 第7个参数 g 在栈上
add edi, esi # a + b
add edi, edx # + c
add edi, ecx # + d
add edi, r8d # + e
add edi, r9d # + f
add edi, DWORD PTR [rsp+8] # + g (从栈读取)
mov eax, edi
ret
2
3
4
5
6
7
8
9
10
11
寄存器分配的可视化效果:同一个函数在 -O0 下每个变量单独放栈上,在 -O2 下全放寄存器——指令数可能差 3~5 倍。
# 5. 汇编读写实战
# 5.1 函数序言与尾声解码
每个 C 函数在汇编中都有标准的"序言(prologue)"和"尾声(epilogue)":
int add(int a, int b) {
int sum = a + b;
return sum;
}
2
3
4
add:
# ===== 序言 =====
push rbp ; 1. 保存调用者的栈帧基址
mov rbp, rsp ; 2. 把当前栈顶设为新栈帧基址
# ===== 函数体 =====
mov DWORD PTR [rbp-20], edi ; 把参数 a (edi) 存到栈上 a的位置
mov DWORD PTR [rbp-24], esi ; 把参数 b (esi) 存到栈上 b的位置
mov edx, DWORD PTR [rbp-20] ; 加载 a (没用!-O0 的冗余)
mov eax, DWORD PTR [rbp-24] ; 加载 b
add eax, edx ; sum = a + b
mov DWORD PTR [rbp-4], eax ; 把 sum 存到栈上
# ===== 尾声 =====
mov eax, DWORD PTR [rbp-4] ; 返回值 = sum
pop rbp ; 3. 恢复调用者的栈帧基址
ret ; 4. 返回(弹出返回地址并跳转)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-O0 生成的代码很"啰嗦"——每个变量都有独立的栈槽,load/store 频繁。这不是真实世界的性能代码,但它忠实反映了 C 代码的结构,方便调试。
同一函数 -O2 版:
add:
lea eax, [rdi + rsi] ; 一行!lea 同时做加法和结果传值
ret
2
3
序言和尾声中,ebp/rbp 可以省略:如果函数足够简单(不到处跳转、不使用 alloca/VLA),GCC/Clang 会用 -fomit-frame-pointer(-O1 以上默认开启)省略 rbp——这样子 rbp 就变成一个通用寄存器,更高效。
# -fomit-frame-pointer 版的 add (无 rbp)
add:
lea eax, [rdi + rsi]
ret
# push rbp / pop rbp 都没有了,函数体只有 3 字节!
2
3
4
5
# 5.2 x86-64 SysV ABI 调用约定速查
理解 C 代码怎么变成汇编,必须知道 ABI 的调用约定:
整数/指针参数(最多 6 个用寄存器,超过的压栈):
| 参数位置 | 寄存器 |
|---|---|
| 第 1 个 | RDI |
| 第 2 个 | RSI |
| 第 3 个 | RDX |
| 第 4 个 | RCX |
| 第 5 个 | R8 |
| 第 6 个 | R9 |
| 第 7+ 个 | 栈 (从右到左) |
浮点参数(最多 8 个用寄存器):
| 参数位置 | 寄存器 |
|---|---|
| 第 1-8 个 | XMM0 - XMM7 |
返回值:
| 类型 | 寄存器 |
|---|---|
| 整数/指针 | RAX |
| 浮点 | XMM0 |
| 128 位整数 | RAX + RDX |
调用者保存 vs 被调用者保存:
| 寄存器 | 谁保存 | 含义 |
|---|---|---|
RAX, RCX, RDX, RSI, RDI, R8-R11 | 调用者 | 调用后可能被改,调用者需自行保存 |
RBX, RBP, R12-R15 | 被调用者 | 如果被调用者要使用,必须先 push,返回前 pop |
XMM0-XMM15 | 全部是调用者保存 |
这些约定直接影响你能看到的汇编——如果看一个函数的汇编,rdi 被用之前没有赋过值,那它就是第一个参数。
# 5.3 控制流汇编
if-else:
int max(int a, int b) {
if (a > b)
return a;
else
return b;
}
2
3
4
5
6
max:
cmp edi, esi ; 比较 a 和 b
jle .L_else ; 如果 a <= b,跳到 else
.L_then:
mov eax, edi ; return a
ret
.L_else:
mov eax, esi ; return b
ret
2
3
4
5
6
7
8
9
while 循环:
int sum_to(int n) {
int s = 0;
while (n > 0) {
s += n;
n--;
}
return s;
}
2
3
4
5
6
7
8
sum_to:
xor eax, eax ; s = 0
test edi, edi ; 检查 n
jle .L_done ; 如果 n <= 0,直接返回
.L_loop:
add eax, edi ; s += n
sub edi, 1 ; n--
jne .L_loop ; 如果 n != 0,继续循环
.L_done:
ret
2
3
4
5
6
7
8
9
10
GCC -O2 的聪明之处:在这个循环中,编译器识别出 n 从参数值递减到 0,不需要 cmp 指令——利用 sub 指令已经设置的 ZF(零标志位),直接用 jne 判断。
# 5.4 栈帧在汇编中的样子
int compute(int x, int y) {
int buf[4] = {x, y, x+y, x-y};
int result = buf[0] + buf[1] + buf[2] + buf[3];
return result;
}
2
3
4
5
compute:
push rbp
mov rbp, rsp
sub rsp, 32 ; 栈上分配 32 字节 (16 字节对齐)
; buf[0..3] 初始化
mov DWORD PTR [rbp-16], edi ; buf[0] = x
mov DWORD PTR [rbp-12], esi ; buf[1] = y
lea eax, [rdi+rsi] ; x+y
mov DWORD PTR [rbp-8], eax ; buf[2] = x+y
sub edi, esi
mov DWORD PTR [rbp-4], edi ; buf[3] = x-y
; result = buf[0] + buf[1] + buf[2] + buf[3]
mov eax, DWORD PTR [rbp-16]
add eax, DWORD PTR [rbp-12]
add eax, DWORD PTR [rbp-8]
add eax, DWORD PTR [rbp-4]
leave ; 等价于 mov rsp, rbp; pop rbp
ret
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
栈帧布局(-O0):
高地址
┌──────────────────────┐
│ 调用者的 rbp │ ← rbp + 8
├──────────────────────┤
│ 返回地址 │ ← rbp + 16 (调用者在 call 之前 push 的)
├──────────────────────┤ ← 调用者的 rbp (最初)
│ 保存的旧 rbp │ ← push rbp 写的
├──────────────────────┤ ← rbp (sub rsp, 32 之后)
│ buf[3] (x-y) │ ← rbp - 4
│ buf[2] (x+y) │ ← rbp - 8
│ buf[1] = y │ ← rbp - 12
│ buf[0] = x │ ← rbp - 16
│ (padding) │
├──────────────────────┤ ← rsp
低地址
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-O2 版:编译器意识到 buf[0]+buf[1]+buf[2]+buf[3] = x + y + (x+y) + (x-y) = 3x + y,直接优化成:
compute:
lea edx, [rdi+rdi*2] ; edx = 3*x
lea eax, [rdx+rsi] ; eax = 3*x + y
ret ; 连栈帧都没有!buf 被完全优化掉了
2
3
4
# 6. 优化等级对比实测
# 6.1 三种汇编
用实际代码展示 -O0 / -O2 / -O3 的差异:
// bench.c
int sum_array(int* arr, int n) {
int total = 0;
for (int i = 0; i < n; i++) {
total += arr[i];
}
return total;
}
2
3
4
5
6
7
8
$ gcc -O0 -S bench.c -o bench_O0.s -fverbose-asm
$ gcc -O2 -S bench.c -o bench_O2.s -fverbose-asm
$ gcc -O3 -S bench.c -o bench_O3.s -fverbose-asm
2
3
-O0 输出(39 行汇编,逐行对应 C):
sum_array:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi # arr
mov DWORD PTR [rbp-28], esi # n
mov DWORD PTR [rbp-8], 0 # total = 0
mov DWORD PTR [rbp-4], 0 # i = 0
jmp .L_cond
.L_body:
mov eax, DWORD PTR [rbp-4] # 加载 i
cdqe
lea rdx, 0[0+rax*4] # i * 4
mov rax, QWORD PTR [rbp-24] # 加载 arr
add rax, rdx # &arr[i]
mov eax, DWORD PTR [rax] # arr[i]
add DWORD PTR [rbp-8], eax # total += arr[i]
add DWORD PTR [rbp-4], 1 # i++
.L_cond:
mov eax, DWORD PTR [rbp-4] # 加载 i
cmp eax, DWORD PTR [rbp-28] # i < n ?
jl .L_body
mov eax, DWORD PTR [rbp-8] # return total
pop rbp
ret
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-O2 输出(12 行汇编):
sum_array:
test esi, esi # 检查 n <= 0
jle .L_zero
lea eax, [rdi+rsi*4] # end_ptr = arr + n
xor edx, edx # total = 0
.L_loop:
add edx, DWORD PTR [rdi] # total += *arr
add rdi, 4 # arr++
cmp rdi, rax # arr < end_ptr ?
jne .L_loop
mov eax, edx # return total
ret
.L_zero:
xor eax, eax # return 0
ret
2
3
4
5
6
7
8
9
10
11
12
13
14
15
关键优化:归纳变量消除——i 这个计数器被干掉了,直接用指针 arr 的推进来模拟循环。
-O3 输出(18 行汇编,但可能用了 SSE/AVX 向量化):
sum_array:
test esi, esi
jle .L_zero
mov rax, rdi
lea ecx, [rsi-1]
lea rdx, [rdi+rcx*4+4]
cmp rdx, rax
jbe .L_small
; ---- SSE 向量化部分 ----
pxor xmm0, xmm0
mov r8, rdi
and r8, 15 # 对齐检查
; ... SSE 向量加法 ...
; ---- 标量尾部 ----
.L_tail:
...
.L_small:
; 小数组直接用标量
...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
性能对比(对 100万元素数组):
| 优化等级 | 耗时 | 关键优化 |
|---|---|---|
-O0 | ~2.1 ms | 无 |
-O2 | ~0.35 ms | 归纳变量消除、指针优化 |
-O3 | ~0.12 ms | 循环展开 + SSE 向量化(4 个 int 一起加) |
-Ofast | ~0.08 ms | 加上 -ffast-math(允许不精确的浮点结合律) |
# 6.2 死代码消除
常量折叠(constant folding):
int compute(void) {
int x = 60 * 60 * 24; // 常量表达式
int y = strlen("hello"); // strlen 也是常量!(GCC 内置)
return x / y;
}
2
3
4
5
# -O0:
compute:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 86400 # 86400 = 60*60*24
mov DWORD PTR [rbp-8], 5 # strlen("hello") = 5
mov eax, 86400
cdq
idiv DWORD PTR [rbp-8] # 86400 / 5
pop rbp
ret
# -O2:
compute:
mov eax, 17280 # 86400/5 直接算好!17280
ret
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
所有计算在编译期完成,运行时只剩一个 mov。
死代码消除(DCE, Dead Code Elimination):
int foo(int x) {
int a = expensive_computation(); // 费时计算
return x * 2; // 但 a 从未被使用!
}
2
3
4
# -O2:
foo:
lea eax, [rdi+rdi] # x*2,压根不调 expensive_computation
ret
2
3
4
编译器发现 a 的值永远不会被读取(没有副作用),于是整个计算被删除。这就是第 1 章案例的优化本质。
# 6.3 内联展开的决策边界
static int tiny(int x) { return x + 1; }
int outer(int a, int b) {
return tiny(a) + tiny(b);
}
2
3
4
5
# -O2(tiny 被内联了):
outer:
lea eax, [rdi+rsi+2] # (a+1) + (b+1) = a+b+2
ret
2
3
4
GCC 的内联决策基于启发式算法——它估算函数体的"代价"(cost),与调用开销对比:
inline cost < call cost → 内联
inline cost >= call cost → 不内联(除非 __attribute__((always_inline)))
2
查看内联决策:
$ gcc -O2 -fdump-ipa-inline test.c -o /dev/null
$ cat test.c.*i.inline* | grep -E "(Inlining|not inlining)"
# 输出:Inlining tiny into outer ← GCC 决定内联 tiny
2
3
# 6.4 循环展开与向量化
循环展开(loop unrolling):把循环体复制 N 份,减少分支和比较指令:
void copy(int* dst, int* src, int n) {
for (int i = 0; i < n; i++)
dst[i] = src[i];
}
2
3
4
# -O3 循环展开(每次迭代复制 4 个 int)
.L_unrolled:
mov eax, DWORD PTR [rsi]
mov DWORD PTR [rdi], eax # dst[0] = src[0]
mov eax, DWORD PTR [rsi+4]
mov DWORD PTR [rdi+4], eax # dst[1] = src[1]
mov eax, DWORD PTR [rsi+8]
mov DWORD PTR [rdi+8], eax # dst[2] = src[2]
mov eax, DWORD PTR [rsi+12]
mov DWORD PTR [rdi+12], eax # dst[3] = src[3]
add rsi, 16 # src += 4
add rdi, 16 # dst += 4
sub edx, 4 # 剩余计数 -= 4
jne .L_unrolled
2
3
4
5
6
7
8
9
10
11
12
13
14
向量化(auto-vectorization):用 SIMD 指令一次处理多个数据:
# -O3 自动向量化版(一次复制 4 个 int = 128 位 SSE)
.L_vectorized:
movdqu xmm0, XMMWORD PTR [rsi] # 加载 4 个 int
movdqu XMMWORD PTR [rdi], xmm0 # 存储 4 个 int
add rsi, 16
add rdi, 16
sub edx, 4
jne .L_vectorized
2
3
4
5
6
7
8
循环展开也是 x86-64 那条,向量化用 movdqu 一次 128 位。如果再开 AVX2 (-mavx2),一次可以处理 8 个 int(256 位)。
查看向量化报告:
$ gcc -O3 -fopt-info-vec test.c
test.c:5:3: optimized: loop vectorized using 16 byte vectors
test.c:8:3: optimized: loop vectorized using 32 byte vectors # AVX2
2
3
# 7. volatile 与编译器优化
# 7.1 volatile 的真正语义
volatile 告诉编译器三件事:
1. 每次读取必须从内存读,不能用寄存器缓存
2. 每次写入必须写回内存,不能延迟或合并
3. volatile 变量的读写顺序之间不可重排(但与非volatile变量之间可以!)
2
3
volatile int flag = 0;
// 线程 1
void wait_for_flag(void) {
while (flag == 0) { // 每次迭代都从内存读 flag
/* busy-wait */
}
}
// 线程 2 (ISR 或另一个线程)
void set_flag(void) {
flag = 1; // 立即写回内存
}
2
3
4
5
6
7
8
9
10
11
12
13
不加 volatile,-O2 优化后的 wait_for_flag:
wait_for_flag:
mov eax, DWORD PTR flag[rip] # 只读一次!
test eax, eax
jne .L_done
.L_infinite:
jmp .L_infinite # 死循环!再也不读 flag 了
.L_done:
ret
2
3
4
5
6
7
8
加 volatile 后:
wait_for_flag:
.L_loop:
mov eax, DWORD PTR flag[rip] # 每次迭代都从内存加载
test eax, eax
je .L_loop # 如果 flag==0,继续
ret
2
3
4
5
6
# 7.2 变量去哪了
疑惑:一个变量如果在编译期被完全优化掉,它"去哪了"?
论证:
int dead_store(void) {
int x = 42;
x = 100; // 第一次赋的值被覆盖,死存储
return x; // 或者 x 根本没被读,整个计算都死了
}
2
3
4
5
# -O2: x 完全消失了!
dead_store:
mov eax, 100 # 直接返回 100
ret
2
3
4
变量的"命运"取决于:
- 被完全优化掉:值直接内联到使用处,连寄存器/栈槽都不占
- 被提升到寄存器:在整个函数体里
x就是一个寄存器,不占内存 - 被优化到栈:
-O0默认,每个变量都在栈上有槽位
用 gdb 调试优化后的代码是场噩梦——局部变量可能在任何时刻"消失":
(gdb) p x
$1 = <optimized out> # GDB 告诉你:编译器把 x 优化没了
2
这就是为什么调试时用 -Og(而不是 -O0 或 -O2):它在保留调试体验的同时做了一些基本优化。
# 7.3 嵌入式寄存器
在嵌入式开发中,volatile 是不可或缺的:
// 微控制器的 GPIO 寄存器映射(0x40020000 是 GPIOA 基址)
#define GPIOA_ODR (*(volatile uint32_t*)0x40020014) // 输出数据寄存器
// LED 闪烁
void led_blink(int count) {
for (int i = 0; i < count; i++) {
GPIOA_ODR |= (1 << 5); // 点亮 PA5
delay(500);
GPIOA_ODR &= ~(1 << 5); // 熄灭 PA5
delay(500);
}
}
2
3
4
5
6
7
8
9
10
11
12
不加 volatile 的后果:
# -O2(没有 volatile):
led_blink:
mov r0, #0x40020014
ldr r1, [r0] # 只加载一次!
orr r1, r1, #0x20 # 设置 bit 5
; ⚠️ 被优化掉的写入:编译器发现 GPIOA_ODR 的值没被"代码"修改
; 于是认为第二次写入的值和第一次一样,直接删除了!
bl delay
; ⚠️ 缺少 "熄灭" 的代码!
bl delay
sub r2, r2, #1
bne ...
2
3
4
5
6
7
8
9
10
11
12
硬件寄存器必须标记 volatile——编译器的优化逻辑是"软件视角",不知道硬件在另一边读写。这是嵌入式领域"所有寄存器映射指针必须 volatile"的根源。
# 7.4 volatile 不是原子操作
疑惑:既然 volatile 保证每次都从内存读写,那是不是意味着 volatile int x 是线程安全的?
论证——经典反例:
volatile int counter = 0;
// 两个线程同时执行 counter++
void thread_inc(void) {
counter++; // 这仍然是三步:读→加→写,不是原子的!
}
2
3
4
5
6
thread_inc:
mov eax, DWORD PTR counter[rip] # 读 (线程 A 读 = 0)
add eax, 1 # 加
mov DWORD PTR counter[rip], eax # 写 (线程 A 写 = 1)
ret
# 如果线程 B 在线程 A 的"读"之后、"写"之前读了 counter:
# 线程 A 读 = 0, 线程 B 读 = 0
# 线程 A 写 = 1, 线程 B 写 = 1
# counter = 1(丢失了一次递增!)
2
3
4
5
6
7
8
9
10
结论:
volatile是编译器的约束,不是 CPU/缓存的约束- x86 的
counter++在汇编中仍然是mov + add + mov三条指令,中间可被中断 - 真正的线程安全需要
_Atomic(C11) 或std::atomic(C++) volatile= "每次从内存读";atomic= "读→改→写 是不可分割的"
# 8. 综合案例串讲
# 8.1 案例真相揭晓
回到第 1 章嵌入式传感器采集的八个疑问,逐条作答:
| 疑问 | 答案 |
|---|---|
① g_data_ready 为什么被提升到循环外? | 第 6.2 节:LICM 优化——编译器发现循环内不写它,就提到循环外只读一次 |
② volatile 到底做了啥? | 第 7.1:强制每次从内存读/写,禁止缓存到寄存器 |
| ③ 优化 pass 怎么组织的? | 第 4.5:GCC 有约 300 个 pass,按顺序组成流水线 |
④ .c 怎么变成机器码? | 第 3 章:cpp→cc1→as→ld 四阶段 |
⑤ -O0/-O2/-Os 各做了什么? | 第 6 章:LOCM/内联/向量化等,差异可达 20~30 倍性能 |
| ⑥ 怎么看汇编? | 第 5 章:gcc -S / -fverbose-asm / objdump -d |
| ⑦ 中间表示长什么样? | 第 4.4:GIMPLE 三地址码 + SSA 形式 |
⑧ volatile 能替代原子操作吗? | 第 7.4:不能——它不保证读-改-写的原子性 |
完整的根因链条:
ISR 修改 g_data_ready (内存)
↓
main_loop:
LICM pass 发现 g_data_ready 在循环体内"未被写入"
→ 把读取提升到循环外 → 只读一次 (寄存器缓存)
→ 之后永远看不到 ISR 的更新
↓
死机/丢数据
2
3
4
5
6
7
8
修复方案:
方案 A:加 volatile(正确,最简)
static volatile bool g_data_ready = false;
方案 B:用原子操作(更正确,但更重)
#include <stdatomic.h>
static atomic_bool g_data_ready = false;
// ISR:
atomic_store(&g_data_ready, true);
// main_loop:
if (atomic_load(&g_data_ready)) { ... }
2
3
4
5
6
7
8
atomic 不仅防止编译器重排,还插入内存屏障(memory barrier),阻止 CPU 的乱序执行——比 volatile 更强但更慢。
方案 C:memory barrier 宏(嵌入式常用)
#define MEMORY_BARRIER() __asm__ __volatile__("" ::: "memory")
// main_loop:
while (1) {
if (g_data_ready) {
MEMORY_BARRIER(); // 告诉编译器:所有内存可能被改了
...
}
}
2
3
4
5
6
7
8
9
把"可能被外部修改"的负担从变量定义转移到特定的代码位置。
# 8.2 一行 C 代码的编译之旅
把 int x = 42; 这一行从字符到机器码串起来:
"int x = 42;"
├─ 词法分析 (lexer)
│ 令牌: [关键字:"int"] [标识符:"x"] [运算符:"="] [整数:"42"] [";"]
│
├─ 语法分析 (parser)
│ AST: DECL(type=int, name="x", init=INTEGER_CST(42))
│
├─ 语义分析
│ 检查: int 类型 OK, 42 可以赋值给 int
│ 符号表: 添加 "x" → INTEGER_TYPE, 局部变量, 栈偏移 = -4
│
├─ GIMPLE 生成
│ x = 42;
│
├─ SSA 化
│ x_1 = 42;
│
├─ 优化 (常量传播)
│ 发现 x 的所有使用处都可以直接替换为 42
│ → x 被消除,其使用处直接写死 42
│ → 如果 x 从未被使用 → 整行代码被 DCE 删除
│
├─ RTL 生成 (如果 x 没被优化掉)
│ (set (reg:SI ax) (const_int 42))
│
├─ 寄存器分配
│ eax ← 42
│
└─ 最终汇编
movl $42, -4(%rbp) # -O0: 存在栈上
movl $42, %eax # -O2: 可能只存在于寄存器,或完全消失
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 8.3 面试高频问题清单
1. .c 到可执行文件经历了哪几个阶段?
预处理
.c→.i(cpp:宏展开、头文件包含)、编译.i→.s(cc1:词法→语法→语义→IR→优化→代码生成)、汇编.s→.o(as:助记符→机器码)、链接.o→executable(ld:合并 .o/.so、符号解析、重定位)。
2. 编译器的优化 pipeline 做了什么?最关键的有哪些?
标量优化(常量传播、死代码消除、值编号),循环优化(LICM、归纳变量消除、循环展开),跨过程优化(内联),向量化(SIMD)。LICM 就是第 1 章案例的元凶——它把循环不变量提到循环外。
3. -O0/-O2/-O3 的区别?
-O0:不优化,变量全部在栈上,方便调试。-O2:开启几乎所有大小合理的优化(内联、LICM、常量传播、死代码消除等)。-O3:在-O2基础上加循环展开、向量化、更激进的内联。-Os:体积优先。生产代码默认-O2。
4. 怎么读汇编?push rbp; mov rbp, rsp 是什么?
函数序言(prologue):保存调用者的栈帧基址,建立自己的栈帧。对应的尾声(epilogue)是
pop rbp(或leave)+ret。-fomit-frame-pointer(-O1以上默认)会省略这一对。
5. volatile 的作用是什么?什么时候必须用它?
告诉编译器:每次读写必须走内存,不能缓存到寄存器。三大场景:① 内存映射硬件寄存器(嵌入式 GPIO 等)② ISR 与主循环共享的全局变量 ③
setjmp/longjmp之间被修改的局部变量。volatile不能替代原子操作,它不保证读-改-写的原子性。
6. volatile 和 atomic 的区别?
volatile只约束编译器优化(每次从内存读写),不插入内存屏障。atomic(C11_Atomic) 保证操作的原子性和内存序。多线程同步必须用atomic,嵌入式硬件寄存器用volatile。
7. 编译器什么时候会优化掉我的变量?
当它发现变量的值在编译期可知(常量折叠)、或者从定义到使用从未被读取(死代码消除)、或者使用处的值可以通过寄存器/立即数表示(栈→寄存器提升)。用
-Og调试可以保留变量。
8. GCC IR (GIMPLE) 是什么?
GIMPLE 是 GCC 的中间表示,形式是简化的 C(三地址码:每个操作最多一个运算符 + 三个操作数)。所有优化 pass 都在 GIMPLE 上工作。之后再转换为 RTL(寄存器传输语言)做目标相关的优化和代码生成。可以用
-fdump-tree-all看所有 pass 后的 GIMPLE。
9. "被优化掉的变量"怎么找回来调试?
gcc -Og(优化 + 调试友好)、gdb中p var出现<optimized out>说明它被优化掉了。对于裸嵌入式:加volatile阻止优化;加 log/printf 也是一种"保持变量存活"的手段(虽然这是副作用,正是第 1 章的 Heisenbug 成因)。
10. 循环向量化是怎么自动发生的?
-O3(或显式加-ftree-vectorize)自动分析循环——如果循环体是"对连续数组元素做相同运算",且没有循环携带依赖,就生成 SIMD 指令(SSE/AVX)。-fopt-info-vec可以看到向量化报告。
# 8.4 编译调试速查卡
分阶段查看:
# 看预处理输出
gcc -E source.c -o source.i
gcc -E -dM source.c # 只看所有宏定义
# 看汇编(⭐ 最常用)
gcc -S source.c -o source.s
gcc -S -fverbose-asm source.c # 汇编中带变量名和C代码注释
gcc -S -masm=intel source.c # 用 Intel 语法(默认是 AT&T)
# 看目标文件
objdump -d object.o # 反汇编 .text 段
objdump -t object.o # 符号表
objdump -h object.o # 段头表
objdump -r object.o # 重定位表
nm object.o # 简洁版符号表
# 看 ELF 结构
readelf -h executable # ELF 头
readelf -l executable # 程序头(加载器用)
readelf -S executable # 节头(链接器用)
readelf -s executable # 符号表
size executable # text/data/bss 分段大小
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
看 GCC 优化决策:
# 看所有优化 pass 及顺序
gcc -O2 -fdump-passes source.c -o /dev/null
# 看每个 pass 后的 GIMPLE 树
gcc -O2 -fdump-tree-all source.c -o /dev/null
# 生成 source.c.* 文件,想看的 pass 都可以读
# 具体看内联决策
gcc -O2 -fdump-ipa-inline source.c
# 看向量化报告
gcc -O3 -fopt-info-vec source.c
# 看优化总览
gcc -O2 -fopt-info source.c
# 看 GCC 到底调了什么命令
gcc -v source.c
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
汇编对照技巧:
# 生成带 C 源代码交叉引用的汇编(最调试友好)
gcc -g -O2 -S -fverbose-asm source.c
# Clang 的对应命令
clang -S -emit-llvm source.c # 输出 LLVM IR
clang -S source.c # 输出汇编
2
3
4
5
6
下一篇:14.链接器符号解析与重定位 —— 我们已经知道".c 怎么变成 .o",下一步进入链接阶段:为什么
undefined reference to 'foo'?static函数和全局函数在符号表里有什么区别?动态库和静态库的链接差异是什么?