本节摘要:调用约定是编译单元之间的公共契约:前几个参数走哪些寄存器、更多参数怎么排栈、返回值放哪、哪些寄存器归调用方保存哪些归被调方保存、栈在调用前后如何保持平衡。本节拆解 Linux 主流的 System V AMD64 约定全部核心条款,对比 Windows x64 约定的差异,并用一个 C 与汇编混编实验现场验证。读完你写的汇编函数能被 C 直接调用、你的 C 也能安全调用任何汇编函数。
想一想一个再普通不过的场景:文件 A 里的 C 代码调用文件 B 里的函数。编译 A 时它看不到 B,怎么知道该把第一个参数放在哪?答案是约定——双方各自按同一份契约行事,链接时自然对上。这份契约的名字是调用约定(calling convention),广义连同数据布局、名称修饰、二进制接口规范统称 ABI(应用二进制接口)。它管的事可以归成三类:参数怎么传(哪些走寄存器、哪些走栈、什么顺序)、结果怎么回(返回值放哪个寄存器、大结构体怎么处理)、现场谁负责(调用跨越时哪些寄存器允许被破坏、由谁保存)。
这份契约与 1.2 节的 ISA 契约是不同层面的东西:ISA 是硬件对软件的承诺,调用约定是软件对软件的承诺——同一个 ISA 上完全可以并存多套约定(Windows 与 Linux 就不同)。写汇编排错时搞混这两层,是常见的信息检索事故。
Linux 与 macOS 共用 System V AMD64 ABI,核心条款一张表说得清:
| 条款 | 规定 | 备注 |
|---|---|---|
| 整型与指针参数 | 依次走 rdi、rsi、rdx、rcx、r8、r9 | 第六个之后排到栈上 |
| 浮点参数 | 依次走 xmm0 至 xmm7 | 整型浮点各排各的队 |
| 返回值 | 整型返回 rax,浮点返回 xmm0 | 大结构体经隐藏指针参数 |
| 多余参数 | 从右往左压栈 | 所以 printf 的格式串总在 rdi |
| 栈对齐 | call 执行时 rsp 是 16 的倍数 | 4.3 节案例三的背景条款 |
| 调用方保存 | rax、rcx、rdx、rsi、rdi、r8 至 r11 | 跨调用后内容不可信 |
| 被调方保存 | rbx、rbp、r12 至 r15 | 想用先压栈、用完恢复 |
"调用方保存"与"被调方保存"的划分是全表最值钱的一行。语义是:被调函数可以随意破坏调用方保存组(反正调用方默认它们活不过一次 call,需要的话调用方自己先存);被调方保存组则相反——被调函数若想动它,必须开场压栈保存、收尾恢复原样(4.2 节开场礼仪里 push rbx 的真正动机就在这)。这套分工的精妙在于寄存器使用的自由度最大化:小函数随便用易失组,零保存开销;需要长期占用的变量交给非易失组,保存成本只付一次。
把条款串成一次完整调用的时间线(C 调用两参函数 add_up(3, 4)):
账本上有两个细节值得划线。其一,栈由调用方平衡:System V 的 64 位约定里,即便参数走了栈,也由调用方在 call 返回后调整 rsp——被调函数不动 rsp 的账。这与 32 位时代 stdcall"被调方清栈"的规矩相反,读老资料时注意时代背景。其二,printf 那类可变参数函数享受不到"参数写死在寄存器"的便宜,它的实现要靠 va_arg 机制把寄存器参数区与栈参数区统一编目——这也是为什么可变参数函数的反汇编开头总有一段把寄存器参数转存到栈上的"寄存器存款区"代码。
条款背得再熟,不如亲手接一次头。写一个汇编函数供 C 调用,故意同时验证"参数从寄存器来"与"被调方保存"两条:
; scale.asm —— 接收两个参数,返回 a*8 + b section .text global scale_up scale_up: ; 入口时 rdi = a,rsi = b(约定条款,无需任何声明) lea rax, [rdi*8] ; a 的 8 倍:比例寻址当乘法器用 add rax, rsi ; 加上 b ret
一个值得停留一秒的细节:8 倍用 lea rax, [rdi*8] 一条指令完成——比例寻址当乘法器用(3.1 节 lea 脾气的直接应用)。若想算 9 倍,则写 lea rax, [rdi + rdi*8]。接上 C 侧:
// main.c #include <stdio.h> long scale_up(long a, long b); // 声明与汇编符号同名即可 int main(void) { printf("%ld\n", scale_up(3, 5)); // 期待 29 return 0; }
$ nasm -f elf64 scale.asm -o scale.o $ gcc -O0 main.c scale.o -o demo && ./demo 29
没有任何胶水代码,C 直接调到了汇编函数——因为双方都按同一份约定行事:C 侧把 3 放进 rdi、5 放进 rsi,汇编侧从这两个寄存器取、把结果放 rax,接头天衣无缝。反向验证调用方保存条款也很容易:在汇编函数里破坏 rcx 后返回,C 侧若在 call 前后依赖 rcx 存活就会出错——编译器当然不会犯这个错(它知道约定),但这个实验能让你在调试"寄存器莫名被改"问题时有了理论依据:先查是不是跨了 call 还在信易失组。
顺带对比另一个世界的规矩。Windows x64 约定四处参数走 rcx、rdx、r8、r9(没有 rdi rsi 什么事),且要求调用方在栈上预留 32 字节影子空间供被调方转存参数。同一份 C 源码在两个平台汇编产物不同,根源就是约定不同——逆向 Windows 产物时若套用 System V 的寄存器表,第一条指令就会读错。
⚠️ 常见坑:手写汇编函数忘记维护 16 字节栈对齐。你的函数自己不用 SSE 对齐指令未必出事,但它调用的 C 库函数(memcpy、printf 内部常见 movaps)会替你踩雷——症状是"只在开某段优化或某个库路径上崩",与 4.3 案例三同根。进场时维持对齐是义务,不是可选项。
💡 关键直觉:调用约定的本质是"寄存器的产权登记表"。调用前,易失组是公共单车(用完随便还给谁),非易失组是自带车辆(借用要登记、归还要原样)。反汇编里看到开场一串 push r12 至 r15,读到的就是"本函数租用了这些自带车辆"。
契约在手,混编无碍。下一节做反向操作:当你确实要插手时,怎么在 C 里优雅地写汇编,以及编译器的产物能在多大程度上让你不必写。