读汇编与编译器优化:调用约定、栈帧、向量化,以及让 GCC 自己交代为什么没向量化
环境:Windows 11 x64,GCC 13.1.0(MinGW-w64,全路径
D:/Qt/Tools/mingw1310_64/bin/gcc.exe),反汇编用同目录objdump -d --no-show-raw-insn。目标格式pei-x86-64,ABI 是 Microsoft x64(System V 的那套rdi/rsi/rdx/rcx/r8/r9规则在本机不适用,本机没有 Linux 可对照,见第七节)。本节所有指令串都是-O2(除特别标注)下的原文,与机器负载无关。栈帧、段结构、.pdata/.xdata已在编译与构建专栏第二节讲过,这里只补”怎么用它们读代码”。
一、调用约定不用背表,看前四个参数落在哪
__attribute__((noinline)) int sixargs(int a,int b,int c,int d,int e,int f){
return a+b+c+d+e+f;
}
0000000000000000 <sixargs>:
0: add %edx,%ecx <- a(ecx) + b(edx)
2: add %r8d,%ecx <- + c(r8d)
5: lea (%rcx,%r9,1),%eax <- + d(r9d)
9: add 0x28(%rsp),%eax <- + e(栈上)
d: add 0x30(%rsp),%eax <- + f(栈上)
11: ret
四个整型参数用寄存器:ecx、edx、r8d、r9d;第五、六个在栈上,偏移是 0x28 和 0x30。 这两个偏移量本身就说明了 MS x64 的结构:被调用者一进来,0x00(%rsp) 是返回地址,0x08–0x27 是调用者必须预留的 32 字节 shadow space(给 rcx/rdx/r8/r9 四个寄存器参数留的家),所以栈参数从 0x28 开始排。
三条实用推论:
- 看到函数开头有
mov %ecx,-0x20(%rbp)这类四连写,那不是bug,是它把寄存器参数”倒”进 shadow space——通常意味着它要转手调用别人,或者开了-O0。 - 参数超过 4 个的函数在 MS x64 下不划算:多出来的每一个都要走内存。热路径上把 6 个参数打包成一个结构体指针(第 3 个寄存器
r8)反而更快。 - 与
cdecl32 位那套”全部压栈、调用者清栈”完全不同;同一份 C 代码混用两种 ABI 的目标文件根本链不到一起(编译与构建专栏第十节里undefined reference的另一个来源)。
二、叶子函数可以完全没有栈帧
int leaf(int x){ return x > 0 ? x : -x; }
00000000000000f0 <leaf>:
f0: mov %ecx,%eax
f2: cltd
f3: xor %edx,%eax
f5: sub %edx,%eax
f7: ret
没有 push %rbp、没有 sub $n,%rsp、没有 ret 前的 leave——叶子函数(不调用任何人)不需要栈帧。第二行到第五行是 GCC 把三目运算符编成无分支绝对值:cltd 把 eax 的符号扩展进 edx(负数→edx=-1,正数→0),xor 再 sub 完成”取反加一”的分支less 写法。
这给出两个读码动作:
- 数一下函数开头有没有
push/sub %rsp,就能判断它是不是叶子、要不要 spill 寄存器,比猜优化效果快得多。 - 想验证”分支有没有被消掉”,就搜
jmp/je。这里一条跳转都没有(除了循环外的边界判断),说明-O2已经把它变成条件运算。反过来,如果你在热路径的汇编里看到je,分支预测成本就真的存在(第八节《缓存友好与数据布局》讲这个)。
三、结构体按值传参:16 字节是”隐式传指针”
struct P16 { long long x, y; };
__attribute__((noinline)) long long use16(struct P16 p){ return p.x + p.y; }
0000000000000020 <use16>:
20: mov (%rcx),%rax
23: add 0x8(%rcx),%rax
27: ret
(%rcx) 是间接寻址:调用者把结构体放在自己的栈上,把它的地址塞进第一个整数参数寄存器 rcx。也就是说,签名写着”按值传 16 字节”,机器上跑的其实是”传一个指针”——MS x64 的规则是:只有 1/2/4/8 字节的纯整数结构体能塞进寄存器,>8 字节一律隐式传指针;含浮点/混合成员的同尺寸结构体也走指针(本机未逐一穷举,见第七节)。
对读码的实际影响:凡是函数体里出现 mov (%rcx),...、mov 0x8(%rcx),...,第一反应该是”这其实是个按值传的大结构体或局部对象地址”,而不是”参数是指针”。写 API 时这条也决定成本:传 16 字节结构体按值和按指针,生成的代码几乎一样,不用担心”按值传会不会拷贝两次”。
四、向量化与否,让编译器自己交代理由
同一个整数求和循环:
__attribute__((noinline)) long long sum_i(int *v, int n){
long long s = 0;
for (int i = 0; i < n; i++) s += v[i];
return s;
}
-O2 的产物里一条向量指令都没有:
40: movslq (%rcx),%rdx
43: add $0x4,%rcx
47: add %rdx,%rax
4a: cmp %r8,%rcx
4d: jne 40 <sum_i+0x10>
(对照 -O3 -mavx2 -ftree-vectorize 之后,同一函数体里出现 19 条向量类指令。)
别猜,用 GCC 的自述开关:
$ gcc -O3 -fopt-info-vec -fopt-info-vec-missed -c a1.c -o a6.o
a1.c:10:23: optimized: loop vectorized using 16 byte vectors
a1.c:17:23: missed: couldn't vectorize loop
a1.c:17:23: missed: not vectorized: unsupported data-type
a1.c:23:23: missed: couldn't vectorize loop
a1.c:25:14: missed: not vectorized: complicated access pattern.
每一行都带 文件:行:列,直接指向源码位置。这四条本机实测的判定分别对应四种”能不能向量化”的真实边界:
| 循环 | 判定 | 为什么 |
|---|---|---|
sum_i(整数累加) | optimized(-O3 下) | 单位步长读取 + 可归约的加法;16 字节 = SSE 寄存器宽度 |
callhelper(循环体里调 helper) | couldn't vectorize loop + unsupported data-type | 循环体是函数调用(两侧都标了 noinline,调用不能被复制进向量 lane) |
aliasing(写 a[i]、读 b[i] 与 a[i]) | couldn't vectorize loop | 指针可能重叠,编译器无法证明 a 与 b 不别名 |
第三条是最贵的一类:它意味着”两个 int* 参数如果指向同一块内存,行为必须按重叠来保守处理”。GCC 靠严格别名假设(-fstrict-aliasing,-O2 默认开)来放行这类循环,代价是”用 char*/int* 混读写同一块内存”就成了 UB——第三节那两条 missed 就是这个保守性的账单。
五、-fno-strict-aliasing 在本机没让代码变长——因为它先没被向量化
想演示”关掉严格别名会让循环变慢”,实测结果却是否定的:
-O2 <aliasing> 指令数 13
-O2 -fno-strict-aliasing <aliasing> 指令数 13
一模一样。原因就在第四节:那个循环在 -O2 下根本没被向量化,所以”为保守处理重叠而插入的额外 load/store”没有出现的场合。这是一个很有价值的失败样本——“某个 flag 会拖慢代码”这类说法,必须先看它影响的那条优化是否真的启用了;否则你复现的是文档,不是编译器。
配套的读法:
- 想知道别名假设有没有被使用,先看
-fopt-info-vec是否报optimized; - 想知道某 flag 的真实影响,比较同一个函数的指令数(
objdump -d | sed -n '/<fn>:/,/^$/p' | grep -cE '^ +[0-9a-f]+:'),而不是比较总文件体积——第四节讲过.text大小与优化档位不是单调关系。
六、noinline 是读汇编的开关,不是性能开关
上面所有函数都加了 __attribute__((noinline)),为的是让每个函数在反汇编里独立可见。实测它的效果:
$ objdump -d a1.o | sed -n '/<callhelper>:/,/^$/p'
7e: lea (%rcx,%rdx,4),%r9 <- 数组尾指针
8b: add $0x4,%r8
8f: call 60 <helper> <- 真的发生调用,没被内联
94: add %eax,%edx
helper 是一个 5 行的 x*3+1,在 -O2 下本该被内联(GCC 的内联预算默认会吃掉它),加上 noinline 后 call 保住了。这带来两个用途:
- 调试场景:给想断点的函数加
noinline,否则断点会落到调用者身上(第一节《GDB 实战》里”断点停在不认识的行”就是这个原因)。 - 对照场景:想看”内联到底值不值”,必须能生成两份可对比的代码——
noinline与always_inline就是这对开关。
反过来,static 的小函数在 -O2 下几乎总被吃掉;判据同样是指令数与有没有 call。
七、本节未在本机实测
- System V AMD64 ABI 对照(Linux 下前六个整型参数用
rdi/rsi/rdx/rcx/r8/r9、无 shadow space、结构体可拆两个寄存器):本机没有 Linux/WSL,只写了 MS x64 一侧的实测。不要把本节的0x28(%rsp)当通用结论。 - 浮点与
__vectorcall/thiscall/stdcall等修饰:MS x64 下浮点参数一律走xmm0–3,本节没做样本;32 位(-m32)在本机也不能链接(编译与构建专栏第六节实测过--print-multi-lib只有一档)。 - 结构体传参的完整尺寸矩阵(1/2/4/8 字节纯整数、含浮点、含嵌套、>16 字节)只测了 16 字节纯整数这一档。
- AVX-512 与
-march=native的向量化上限:本机 CPU 支持到哪一档没查(cpuid未采集),只测到-mavx2下 256 位向量出现。 -fopt-info-vec-optimized与 perf 计数器的联动(证明向量化真的更快):本机无perf,本节只证明了”代码形状变了”,没有证明”变快了多少”——耗时属于第九节《基准测试方法论》的口径。- 手工内联汇编与 intrinsics(
__builtin_ia32_*)的可移植性。
八、小结
- 调用约定看三条指令就知道:MS x64 下前四个整型参数在
ecx/edx/r8d/r9d,第五、六个在0x28(%rsp)、0x30(%rsp)——这两个偏移量正好暴露了 32 字节 shadow space 的位置(返回地址0x00,0x08–0x27是影子区)。 - 有没有栈帧是信息:叶子函数可以没有
push/sub rsp/leave;int leaf(int x){return x>0?x:-x;}编成mov/cltd/xor/sub四条、零分支。搜jmp/je能判断分支有没有被消掉。 - 16 字节结构体按值传 = 隐式传指针:
mov (%rcx),%rax; add 0x8(%rcx),%rax的间接寻址就是证据;写 API 时按值与按指针生成的代码几乎一样。 - 向量化不要猜,问编译器:
-O3 -fopt-info-vec -fopt-info-vec-missed会逐行打印optimized: loop vectorized using 16 byte vectors与 missed 的具体理由(couldn't vectorize loop、unsupported data-type、complicated access pattern.)。同一循环-O2下零条向量指令,-O3 -mavx2 -ftree-vectorize下 19 条。 - flag 的影响要看它作用的那条优化是否启用:
-fno-strict-aliasing在本机让<aliasing>指令数 13→13 不变,因为那个循环本来没被向量化——复现”某 flag 会拖慢”之前,先确认对应的优化真的开了。 noinline是观察工具:它让helper在callhelper里保留成真正的call,既是断点可用的前提,也是内联收益对照的开关。