想象一家繁忙的餐厅后厨:厨师长喊一声“做菜!”,帮厨们必须按固定顺序把食材放到指定的台面上——第一个位置放主菜原料,第二个位置放配菜,第三个位置放酱汁,第四个位置放香料。台面位置不能乱,否则厨师会拿错东西。x86_64 的 System V ABI(应用程序二进制接口)就像是后厨的“摆盘规则”:CPU 执行函数调用时,参数必须按固定顺序放入指定的寄存器,返回值也必须从固定的“出菜口”端出来。在这篇文章里,我们会认识这些台面位置——RDI、RSI、RDX、RCX 等寄存器,以及谁负责收拾台面(调用者保存)和谁保证现场不乱(被调用者保存)。

岗位关联

学会 x86_64 寄存器约定,你就能读懂反汇编中的函数调用、定位崩溃时的参数值、理解编译优化后的代码逻辑——这是底层调试与性能分析的必备“翻译手册”。

目录
  1. 可视化演示
  2. 参数传递规则
  3. 整型参数寄存器
  4. 浮点与附加寄存器
  5. 返回值约定
  6. 调用者保存寄存器
  7. 被调用者保存寄存器
  8. 栈帧与栈指针
  9. 实战观察寄存器
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示x86_64 System V ABI整数参数RDI/RSI/RDX/RCX/R8/R9传递、浮点XMM0-7传递与RAX返回值、调用者保存与被调用者保存寄存器分类,揭示调用约定核心机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

整数参数传递:RDI/RSI/RDX/RCX/R8/R9 前6参数:依次通过RDI/RSI/RDX/RCX/R8/R9寄存器传递 第7+参数:超出6个后通过栈传递从右向左压栈 返回值:RAX存返回值RDX存高64位 f(a1..a6) → RDI,RSI,RDX,RCX,R8,R9 前6个整数参数通过寄存器传递提升效率

参数传递规则

后厨的台面摆放有规有矩:主菜、配菜、酱汁、香料,各有各的固定位置。就算厨师长临时多要了一道小料,帮厨也知道先摆满台面上的六个指定位置,剩下的料才往临时备料架上放——这就是 System V ABI 的“台面规则”。

参数传递规则规定了函数调用时实参的“落点”。在 x86_64 的 System V ABI 中,前 6 个整数参数依次放入 RDI、RSI、RDX、RCX、R8、R9 这 6 个通用寄存器;浮点参数则单独排进 XMM0 到 XMM7 这 8 个向量寄存器。参数超过这些寄存器容量后,剩余参数按从右到左的顺序压入进程栈。

这套规则保证了编译器、操作系统的动态链接器、调试器之间有一份共同“菜单”:只要双方都遵循同一 ABI,调用者和被调用者无论由哪个编译器编译,都能准确对接。这也是 Linux 上 C/C++、Rust、Go 等语言能互相调用的底层基础。

理解了这套“台面规则”,看汇编时就能一眼认出每个参数去了哪里。接下来的章节,我们把 6 个整型槽位逐一拆开细看。

f(a1,a2,…,an)⇒{ai→GPRi,i≤6ai→XMMi−1,ai 为浮点ai→栈,i>6f(a_1, a_2, \dots, a_n) \Rightarrow \begin{cases} a_i \rightarrow \text{GPR}_i, & i \le 6 \\ a_i \rightarrow \text{XMM}_{i-1}, & a_i\text{ 为浮点} \\ a_i \rightarrow \text{栈}, & i > 6 \end{cases}
这个公式说明:整数走通用寄存器通道,浮点走向量寄存器通道,溢出部分走内存栈,三者各有各的“车道”。
  1. 函数参数映射将函数 f 的实参 a_1 到 a_n 按顺序分配给寄存器或栈。
    f(a1,a2,…,an)⇒\displaystyle f(a_1, a_2, \dots, a_n) \Rightarrow
  2. 整数入通用寄存器前 6 个整数参数各自占用一个通用寄存器,第 i 个对应第 i 个通用寄存器。
    ai→GPRi,i≤6\displaystyle a_i \rightarrow \text{GPR}_i, \quad i \le 6
  3. 浮点入向量寄存器浮点参数不计入整型计数器,而是按出现顺序进入 XMM 寄存器。
    ai→XMMi−1,ai 为浮点\displaystyle a_i \rightarrow \text{XMM}_{i-1}, \quad a_i \text{ 为浮点}
  4. 超出入栈多于寄存器容量的参数从右向左压入栈区,用 RSP 偏移量访问。
    ai→栈,i>6\displaystyle a_i \rightarrow \text{栈}, \quad i > 6

整型参数寄存器

餐厅的传菜口前有 6 个固定凹槽,第一个凹槽永远放“主菜”,第二个放“配菜”,第三个放“酱汁”,第四个放“香料”,第五、第六个放追加的小料。厨师长喊“鱼香肉丝,米饭,辣酱,葱花”,帮厨一听就知道:鱼香肉丝进 RDI,米饭进 RSI,辣酱进 RDX,葱花进 RCX。

在 x86_64 System V ABI 中,RDI(Destination Index)、RSI(Source Index)、RDX(Data Register)、RCX(Counter Register)这 4 个寄存器被赋予了固定的传参职责。func(a, b, c, d) 调用时,编译器生成的汇编就是依次把 a 装入 %edi,b 装入 %esi,c 装入 %edx,d 装入 %ecx——注意这里用的是 32 位子寄存器(EDI/ESI/EDX/ECX),因为写 32 位寄存器会自动清零高 32 位,传参效率更高。

这 4 个寄存器是传统 x86 时代的通用寄存器在 64 位模式下的延续,它们的名字带着历史包袱:RDI 里的 “D“ 源于字符串操作的目标索引,RSI 的 ”S“ 源于源索引,RCX 的 ”C” 源自循环计数器。虽然现代编译器主要把它们当作普通传参槽位,但名字里的历史痕迹仍保留在指令集中。

当你用 gdb 在一个函数入口处 info registers 时,看到的 RDI/RSI/RDX/RCX 的值,正是调用方传进来的前四个实参——这是调试时最实用的“读心术”。记住这四个槽位,汇编阅读量瞬间减半。

(a,b,c,d)⇒RDI←a,  RSI←b,  RDX←c,  RCX←d(a, b, c, d) \Rightarrow \text{RDI} \leftarrow a, \; \text{RSI} \leftarrow b, \; \text{RDX} \leftarrow c, \; \text{RCX} \leftarrow d
四个参数按顺序滑入四个固定寄存器槽位,一个不多,一个不少。
  1. 第 1 个参数实参 a 被装入 RDI,这是函数调用约定里的“第一顺位”。
    RDI←a\displaystyle \text{RDI} \leftarrow a
  2. 第 2 个参数实参 b 被装入 RSI,与 RDI 配对出现。
    RSI←b\displaystyle \text{RSI} \leftarrow b
  3. 第 3 个参数实参 c 被装入 RDX,对应第三个槽位。
    RDX←c\displaystyle \text{RDX} \leftarrow c
  4. 第 4 个参数实参 d 被装入 RCX,四个槽位全部就位。
    RCX←d\displaystyle \text{RCX} \leftarrow d

浮点与附加寄存器

台面上除了 6 个整料凹槽,旁边还并排放着 8 个“湿料碗”——专门放酱汁、高汤这类要计量的液体配料。厨师长喊“两勺糖,三勺盐,多半碗高汤”,整数配料走凹槽,液体配料走湿料碗,互不挤占。

浮点参数走的 XMM0-XMM7 是 128 位宽的向量寄存器,每个能装下两个 double 或四个 float。System V ABI 规定:调用函数时,浮点参数按从左到右的顺序装入 XMM0、XMM1……,而整型参数独立编号。例如 func(int a, double b, int c, double d) 的分配是:a→RDI,c→RSI(因为 C 是第二个整型),b→XMM0,d→XMM1。整型与浮点各数各的,互不干扰。

R8 和 R9 是 x86_64 新增的通用寄存器,R8 承接第 5 个整数参数,R9 承接第 6 个。它们没有 16 位/8 位的子寄存器别名,只有 64/32 位形式(如 R8D 代表低 32 位),使用时要稍加留意。

这套“双轨制”是许多初学者的盲区——以为混排时 XMM 要等整型排完才用。实际两者同时编号、独立推进,理解这一点,读那些“一半整数一半浮点”的库函数调用时就不会被绕晕了。

f(i1,f1,i2,f2)⇒RDI←i1,  XMM0←f1,  RSI←i2,  XMM1←f2f(i_1, f_1, i_2, f_2) \Rightarrow \text{RDI} \leftarrow i_1, \; \text{XMM0} \leftarrow f_1, \; \text{RSI} \leftarrow i_2, \; \text{XMM1} \leftarrow f_2
整型参数与浮点参数各排各的队,混排时不会互相抢占槽位。
  1. 整数与浮点分离编号i_1 是第一个整型参数,进入 RDI。
    RDI←i1\displaystyle \text{RDI} \leftarrow i_1
  2. 第一个浮点参数f_1 是第一个浮点参数,进入 XMM0。
    XMM0←f1\displaystyle \text{XMM0} \leftarrow f_1
  3. 第二个整数参数i_2 是第二个整型参数,进入 RSI,不与 XMM 槽位冲突。
    RSI←i2\displaystyle \text{RSI} \leftarrow i_2
  4. 第二个浮点参数f_2 进入 XMM1,整型与浮点的编号各自独立。
    XMM1←f2\displaystyle \text{XMM1} \leftarrow f_2

返回值约定

菜做好了,厨师从“出菜口”端出去——这个固定位置在 x86_64 ABI 里就是 RAX 寄存器。如果一道菜分量太大,一个盘子装不下(返回 128 位数值),那就 RAX 装低位,RDX 装高位,两个盘子拼一桌;如果是汤菜(浮点),就从 XMM0 这个“汤碗出口”端出。

几乎所有函数调用都以 RAX 作为主返回值寄存器。对于普通 int 或指针类型,返回值直接留在 EAX/RAX 中;对于 __int128 或 long long 构成的 128 位结构体,低 64 位放 RAX,高 64 位放 RDX;浮点数返回时放入 XMM0。若返回大型结构体(超过 16 字节),调用者会先在栈上预留一块内存,把内存地址作为隐藏的第一个参数传给被调用者,被调用者把结果写入该地址后,将该地址本身放入 RAX 返回。

调试时遇到“我的返回值哪去了”的问题,先看 RAX;如果函数签名里有 __int128 或大结构体,再补看 RDX 和调用前预留的栈地址。返回值约定的这套“出菜口”逻辑,让调用方与被调用方在结果交付上从不会扯皮。

func(...)⇒RAX←低 64 位,RDX←高 64 位\text{func}(...) \Rightarrow \text{RAX} \leftarrow \text{低 64 位}, \quad \text{RDX} \leftarrow \text{高 64 位}
128 位返回值像双拼披萨:RAX 装左半边,RDX 装右半边,拼在一起才是完整结果。
  1. 主返回值落 RAX函数返回时,主结果写入 RAX。
    RAX←低 64 位\displaystyle \text{RAX} \leftarrow \text{低 64 位}
  2. 扩展返回值落 RDX若返回类型超过 64 位,高 64 位写入 RDX。
    RDX←高 64 位\displaystyle \text{RDX} \leftarrow \text{高 64 位}
  3. 拼接语义调用者把 RAX 与 RDX 组合,还原完整返回值。
    result=(RDX≪64)∣RAX\displaystyle \text{result} = (\text{RDX} \ll 64) \mid \text{RAX}

调用者保存寄存器

后厨里有些台面是“用完即弃”的:切完葱花的案板,抹布一擦就能给下道菜用,不用特意保护。谁要用谁就自己留个心眼——如果你下道菜还要用这案板,请自己把葱花先收到碗里。这就是调用者保存寄存器的哲学。

System V ABI 规定 RAX、RCX、RDX、RSI、RDI、R8-R11 这 10 个通用寄存器为调用者保存(Caller-Saved),也叫易失性寄存器。含义是:函数内部可以随意修改这些寄存器,不需要事先保存原值;如果调用者希望在函数调用结束后仍保留这些寄存器中的值,调用者必须在调用之前自行压栈保存,调用结束后再从栈中恢复。

这套设计的精妙在于效率。函数体内大量临时计算都会用到 RAX(返回值)、RCX(移位计数)、RDX(乘除法扩展)这些高频寄存器,如果要求每个函数都保存恢复,指令开销会成倍增长。让“用完就扔”的寄存器免于保存义务,编译器就能自由地分配临时变量,生成更紧凑的代码。

编译器在优化时会在调用点自动插入 push/pop 指令来保护易失寄存器——这也是为什么反汇编代码里常见 push rbx 后跟一串 pop rbx 的“保护盾”模式。理解“谁用谁保护”这条责任链,你就掌握了阅读汇编 prologue/epilogue 的钥匙。

GPRcaller-saved={RAX,RCX,RDX,RSI,RDI,R8,R9,R10,R11}\text{GPR}_{\text{caller-saved}} = \{\text{RAX}, \text{RCX}, \text{RDX}, \text{RSI}, \text{RDI}, \text{R8}, \text{R9}, \text{R10}, \text{R11}\}
这组“自由台面”上的东西没人替你守护:你若不自己收好,被函数调用冲走就别怪人。
  1. 定义易失集合列出所有调用者保存的通用寄存器集合。
    GPRcaller-saved={… }\displaystyle \text{GPR}_{\text{caller-saved}} = \{\dots\}
  2. 调用前备份调用者若需保留其中某个寄存器,调用前自行 push 压栈。
    push r→栈\displaystyle \text{push } r \rightarrow \text{栈}
  3. 调用后恢复调用结束、寄存器被被调用者改写后,调用者 pop 恢复原值。
    pop r←栈\displaystyle \text{pop } r \leftarrow \text{栈}

被调用者保存寄存器

后厨里另一些台面是“专属珍藏”:每位墩子师傅的祖传刀,用完必须擦净放回原处,别人碰坏了要赔。被调用者保存寄存器(Callee-Saved)就是这样——函数要借用这些“传家宝”寄存器,进门时得先把自己的东西挪开,出门前必须原样放回。

System V ABI 规定 RBX、RBP、R12-R15 这 6 个寄存器为被调用者保存(非易失性)寄存器。函数如果要在函数体内部使用这些寄存器,必须先 push 保存原值(通常放在栈帧 prologue 里),函数返回前再 pop 恢复。这样调用者就能放心地在函数调用前后持续持有这些寄存器的值,不必额外备份。

RBP 还兼作帧指针,许多函数用它来定位栈上的局部变量;编译器开启优化后(-O2)经常把 RBP 也当作普通寄存器使用,此时帧指针被优化掉(frame pointer omission),调试体验会有所下降,但性能更好。RBX 常被用来保存跨函数调用仍然需要的循环计数或数组基址——因为它是“不会丢”的。

判断一个寄存器是否被调用者保存,最简单的记忆法:从 R8 到 R11 是“自由身”,而 RBX、RBP、R12-R15 是“有主之物”。在调试循环崩溃时,如果发现 R12 的值在函数返回后发生了变化,几乎可以断定被调用者违反了 ABI 约定——这种 bug 往往藏得很深。

∀r∈{RBX,RBP,R12,R13,R14,R15}:push r  ⋯  pop r\forall r \in \{\text{RBX}, \text{RBP}, \text{R12}, \text{R13}, \text{R14}, \text{R15}\}: \quad \text{push } r \;\cdots\; \text{pop } r
使用这些“传家宝”的前提是完璧归赵:借之前 push,还之前 pop,一条都不能少。
  1. 非易失集合列出被调用者保存的寄存器,函数内部不可随意丢弃其原值。
    r∈{RBX,RBP,R12,R13,R14,R15}\displaystyle r \in \{\text{RBX}, \text{RBP}, \text{R12}, \text{R13}, \text{R14}, \text{R15}\}
  2. 进入时保存函数 prologue 里将用到的非易失寄存器压栈。
    push r\displaystyle \text{push } r
  3. 返回前恢复函数 epilogue 里从栈中弹出原值,保证调用者视野中的值不变。
    pop r\displaystyle \text{pop } r

栈帧与栈指针

后厨再大的台面也有放不下的食材,这时就要往地上的备料柜里码——柜子从门口往最里面一层层叠放,新来的食材先放门口。进深越大的柜子,越贴墙存放的是最早放进去的东西。栈就是这样一种“后进先出”的备料柜。

栈指针 RSP(Stack Pointer)始终指向栈顶,也就是备料柜门口的位置。函数调用时,call 指令会把返回地址压栈,RSP 减 8;函数入口后,prologue 常把 RBP 压栈并把 RSP 赋给 RBP,建立当前函数的栈帧基址,随后通过 RSP 减去固定偏移为局部变量腾出空间。栈向低地址方向生长,所以“压栈”是 RSP 减,“弹栈”是 RSP 增。

System V ABI 还规定了 128 字节的“红区”(Red Zone):RSP 正下方 128 字节区域是函数私有领地,信号处理器不会踩踏它。叶子函数(不再调用其他函数的函数)可以放心在这片区域里暂存数据而不必调整 RSP,省掉两条指令的开销。

栈帧布局是理解调用约定的物理基础:返回地址压在栈顶,被保存的 RBP 位于其下,局部变量依次向低地址排列。用 gdb bt 查看调用栈时,帧与帧之间的链条正是靠 RBP 寄存器的链接串起来的。

(RSPentry+8) mod 16=0(调用时的栈对齐约束)(\text{RSP}_{\text{entry}} + 8) \bmod 16 = 0 \quad \text{(调用时的栈对齐约束)}
栈对齐规则保证进入函数时 RSP 满足 16 字节对齐,让 SSE 指令能安全地直接访问栈内存。
  1. 对齐基数call 指令压入 8 字节返回地址,使进入函数时 RSP 满足 16 字节对齐要求。
    (RSPentry+8) mod 16=0\displaystyle (\text{RSP}_{\text{entry}} + 8) \bmod 16 = 0
  2. 调用者维护调用者在 call 之前必须保证 RSP 是 16 的倍数,这通常意味着参数压栈后要补 sub rsp, 8。
    RSP←RSP−8\displaystyle \text{RSP} \leftarrow \text{RSP} - 8
  3. 对齐的目的保证 movaps 等需要 16 字节对齐的 SIMD 指令在栈上安全操作。
    align16(stack)\displaystyle \text{align16}(\text{stack})

实战观察寄存器

理论学会了,真正到了后厨现场,你要学会“看菜识位”:一盘菜端出来,看它放在哪个出菜口,就知道它是主菜还是配菜;看帮厨往哪个凹槽放料,就知道这是第几个参数。反汇编阅读和调试器就是你的“后厨监控”。

以 gcc 编译一个简单 C 函数后,用 objdump -d 反汇编,你会看到 func 开头一串 mov %edi, ... 或直接使用 %edi 的指令——这正是参数已经躺在寄存器槽位里的证据。在 gdb 中,info registers rdi rsi rdx rcx 能直接打印前 4 个参数的值;遇到 printf 之类的库调用,断点在 call 指令前停下,看看寄存器就明白这次调用传了什么。

实际工作中最常用的观察技巧:gdb 里用 x/6gx $rsp 查看压栈的溢出参数,配合 disassemble 反汇编定位 call 指令前的 mov 模式,就能反推出函数的真实签名。对 AI 项目的 CUDA/C++ 混合代码调试、PyTorch 的 ATen 底层调用分析,这套寄存器阅读法尤其好用——当你在 JIT 编译出的汇编里看到 %rdi 承载一个张量指针时,你已经能断言它是函数的第一个参数了。

从“看汇编如看天书”到“看汇编如看菜谱”,距离只差对寄存器约定的熟练度。下一次排查段错误时,先 info registers 看一眼 RDI 指向的地址是否可读,往往比追着源代码猜节省一个小时。

func(p,n,s)⇒RDI←p,  RSI←n,  RDX←s\text{func}(p, n, s) \Rightarrow \text{RDI} \leftarrow p, \; \text{RSI} \leftarrow n, \; \text{RDX} \leftarrow s
反汇编时看到 RDI/RSI/RDX 依次被赋值,你的大脑就自动“解码”出了函数签名——这就是 ABI 知识的力量。
  1. 指针参数第一个参数(如张量指针 p)存入 RDI。
    RDI←p\displaystyle \text{RDI} \leftarrow p
  2. 长度参数第二个参数(如元素个数 n)存入 RSI。
    RSI←n\displaystyle \text{RSI} \leftarrow n
  3. 步长参数第三个参数(如字节步长 s)存入 RDX。
    RDX←s\displaystyle \text{RDX} \leftarrow s

AI 应用场景

场景1:AI框架底层调用链分析

阶段:模型部署与推理优化

解决的问题:PyTorch/TensorFlow 在 GPU 或 CPU 上运行时会生成或调用大量底层 C++/CUDA 函数。模型推理速度异常或崩溃时,传统做法是盲猜源码;而按 ABI 规则读取寄存器,可以直接在汇编层还原函数参数——崩溃时 RDI 指向的地址是否为有效张量指针、RSI 中的维度值是否合理、RDX 中的步长是否越界,几条命令就能定位问题根因。

具体案例:某推理服务在 batch size 从 1 调到 32 后偶发 SIGSEGV。工程师用 gdb 挂住崩溃现场,info registers 发现 RDI(第一个参数)指向的地址落在未映射区域,顺着这个指针反查发现是上游把张量释放后未置空。整个过程未读一行框架源码。

场景2:JIT 编译代码调试

阶段:模型编译(JIT/AOT)

解决的问题:TVM、TorchScript、XLA 等编译器会动态生成机器码。生成代码若违反 ABI 约定,会在函数调用边界处产生“寄存器污染”——被调用者保存寄存器被改写,调用者却还以为值没变。理解 R12-R15 的保存义务,能快速区分“编译器 bug”和“手写汇编 bug”。

具体案例:某团队手写了一个 SIMD 优化算子,集成进 TVM 后随机产生错误结果。反汇编发现算子在函数尾部调用了一个辅助函数,辅助函数内部改写了 RBX 未恢复,导致主调用方的循环计数错乱。修正为 push rbx / pop rbx 后问题消失。

场景3:GPU Kernel 参数映射分析

阶段:CUDA 内核开发

解决的问题:CUDA kernel 启动时参数通过常量内存传递,但 CPU 侧调用约定仍是 System V ABI。理解 RDI/RSI 的映射规则,有助于在 cuda-gdb 中验证 host 侧传给 kernel 的参数正确性,尤其当 kernel 接受结构体参数、参数布局容易错位时。

具体案例:调试一个自定义 attention kernel 时发现 blockDim 计算异常。在 launch 处查看寄存器,发现第二个参数(grid dim)被错误地存入了 RDX 的高 32 位,导致后续 %edx 读取到垃圾值。修正参数构造代码后 kernel 行为恢复正常。

Python 演示

python66 行
# -*- coding: utf-8 -*-"""模拟 x86_64 System V ABI 的参数寄存器分配规则"""INT_REGS = ["RDI", "RSI", "RDX", "RCX", "R8", "R9"]FP_REGS = ["XMM0", "XMM1", "XMM2", "XMM3", "XMM4", "XMM5", "XMM6", "XMM7"]CALLEE_SAVED = ["RBX", "RBP", "R12", "R13", "R14", "R15"]CALLER_SAVED = ["RAX", "RCX", "RDX", "RSI", "RDI", "R8", "R9", "R10", "R11"]  def assign_args(args):    """args 是 (名称, 类型) 列表,类型为 'int' 或 'float'"""    int_idx = 0    fp_idx = 0    mapping = {}    stack_overflow = []    for name, arg_type in args:        if arg_type == "float":            if fp_idx < len(FP_REGS):                mapping[FP_REGS[fp_idx]] = name                fp_idx += 1            else:                stack_overflow.append((name, "float"))        else:            if int_idx < len(INT_REGS):                mapping[INT_REGS[int_idx]] = name                int_idx += 1            else:                stack_overflow.append((name, "int"))    return mapping, stack_overflow  def split_return_128(value):    """将 128 位整数拆成 RAX(低64位) 和 RDX(高64位)"""    low = value & 0xFFFFFFFFFFFFFFFF    high = (value >> 64) & 0xFFFFFFFFFFFFFFFF    return low, high  def main():    print("== x86_64 System V ABI 寄存器分配模拟 ==")    func_signature = [        ("a", "int"), ("b", "float"), ("c", "int"), ("d", "float"),        ("e", "int"), ("f", "int"), ("g", "int"), ("h", "float"),    ]    mapping, overflow = assign_args(func_signature)    print("参数分配:")    for reg, name in mapping.items():        print("  %-5s <- %s" % (reg, name))    if overflow:        print("栈溢出参数:")        for name, arg_type in overflow:            print("  [栈] %s (%s)" % (name, arg_type))    print()    print("函数返回 128 位值示例:")    result = 0x1122334455667788AABBCCDDEEFF0011    low, high = split_return_128(result)    print("  完整值: 0x%032X" % result)    print("  RAX(低64位): 0x%016X" % low)    print("  RDX(高64位): 0x%016X" % high)    print()    print("寄存器分类:")    print("  调用者保存(易失):", ", ".join(CALLER_SAVED))    print("  被调用者保存(非易失):", ", ".join(CALLEE_SAVED))  if __name__ == "__main__":    main()
目的:模拟编译器的参数分配逻辑,直观展示混排参数如何落入寄存器、溢出参数如何压栈,以及 128 位返回值的 RAX/RDX 拆分。
  1. INT_REGS = ["RDI", "RSI", "RDX", "RCX", "R8", "R9"]定义前 6 个整型参数寄存器槽位,与 ABI 逐一对齐。
  2. mapping[FP_REGS[fp_idx]] = name浮点参数独立编号,按顺序写入 XMM0、XMM1…… 与整型互不干扰。
  3. stack_overflow.append((name, "int"))超出寄存器容量的参数记录到栈区列表,模拟从右向左压栈的溢出行为。
  4. split_return_128(value)演示 128 位返回值按“低 64 位归 RAX、高 64 位归 RDX”的拆分规则。
  5. CALLER_SAVED` 与 `CALLEE_SAVED展示调用者保存与被调用者保存两大类寄存器的成员划分,方便记忆对照。

常见误区

误区我以为是按参数位置统一数数,函数 `func(1, 2.5, 3)` 里的 2.5 应该排在第二个槽位,也就是 RSI,结果 2.5 却进了 XMM0,搞得我调试时对不上号。

正解整型参数和浮点参数是两套独立的编号系统!整型按顺序进 RDI/RSI/RDX/RCX/R8/R9,浮点按顺序进 XMM0-XMM7。func(1, 2.5, 3) 中,整数 1 进 RDI,浮点 2.5 进 XMM0,整数 3 进 RSI——因为它是第二个整型参数。记住“整型排整型的队,浮点排浮点的队”,各自独立计数。

为何常见:我们习惯按“第几个参数”的线性思维理解传参,但 ABI 是按参数类型分组排队,不是按位置全局编号,这与人脑的自然直觉相冲突。

误区我以为每次函数返回后 RDX 都装着返回值的高 64 位,所以我在每次调用后都去读 RDX,发现值经常是乱七八糟的数,完全对不上我的结果。

正解RDX 只有在返回值超过 64 位时才携带高 64 位数据(如 __int128 或 16 字节结构体)。普通 64 位及以下的返回值只用 RAX,RDX 在这时是“自由身”,可能被函数内部当作临时寄存器随意改写。判断标准很简单:看函数签名。返回 int/指针只信 RAX;返回 __int128 才把 RAX、RDX 拼起来看。

为何常见:教材里讲 128 位返回时强调了 RDX,初学者容易把特例当通例,忽略 RAX 是唯一恒定返回值寄存器这一事实。

误区既然是“调用者保存”,那函数返回后这些寄存器肯定是脏的,每次调用完必须重新加载。为了防止出错,我干脆把所有寄存器都压栈保存一遍。

正解调用者保存寄存器的意思是“函数可以不用保护就随意改写”,但实际是否改写取决于函数体内容。一个简单的函数可能根本没碰 RCX,调用后 RCX 里的值依然完好。正确做法是“如果需要,就保存;如果不需要,就不管”。编译器优化时还会精确分析哪些寄存器真会被破坏,只保护必要的。无脑全保存会把性能拖垮。

为何常见:“会保存”与“必须保存”是两件不同的事——前者是义务,后者是事实。初学者容易把 ABI 的最大授权误解为必然行为。

误区RBP 属于被调用者保存寄存器,那我只要按规矩 push/pop 就可以随意存放临时数据,毕竟它就是个寄存器嘛。

正解RBP 身兼两职:既是非易失寄存器,又是调试器定位栈帧的“锚点”。没有 -fomit-frame-pointer 优化时,调试器依靠 RBP 链回溯调用栈;你把它当临时变量改写,虽然 push/pop 义务尽到了,但栈回溯链会断裂,gdb bt 看到的调用栈将是一团乱麻。在开启优化的发布版本里编译器会频繁征用 RBP,但在未优化代码中请珍惜它的帧指针身份。

为何常见:寄存器编号相同容易让人忽略 RBP 的语义角色,误以为它和 RBX 完全等价。它的“双重身份”是调试世界与执行世界的交汇点。

误区数组是往高地址长的,我想当然以为栈也是从低地址往高地址长,结果手写汇编时用 `add rsp, 8` 压栈,直接把函数搞崩了。

正解x86_64 的栈是向低地址方向生长的!push 让 RSP 减小 8,pop 让 RSP 增加 8。局部变量分配空间用的是 sub rsp, 48,释放用的是 add rsp, 48。红区也是 RSP 下方的 128 字节。方向反了,栈指针就会往函数调用方的栈帧里乱撞,数据被覆写得一塌糊涂。

为何常见:内存地址从低到高的直觉太强了,而栈恰好反其道而行之——这是计算机系统中少数几个“向下生长”的数据结构,违背了人的空间直觉。

面试题

Q1. 在 x86_64 System V ABI 中,函数的第 1 个整型参数放在哪个寄存器?

单选题 · easy

  • A.RAX
  • B.RDI← 正确答案
  • C.RDX
  • D.RSI

答案:B。第 1 个整型参数固定放入 RDI。RAX 是返回值寄存器,RDX 是第 3 个参数寄存器,RSI 是第 2 个参数寄存器。就像第一个凹槽永远放主菜,RDI 永远是第一个整型参数的“家”。

Q2. System V ABI 规定前几个整数参数可以使用寄存器传递?

单选题 · easy

  • A.4 个
  • B.5 个
  • C.6 个← 正确答案
  • D.8 个

答案:C。RDI、RSI、RDX、RCX、R8、R9 共 6 个通用寄存器传递前 6 个整数参数。超过 6 个的额外参数按从右向左顺序压入栈。这 6 个槽位就像台面的 6 个固定凹槽,满了才往备料架(栈)放。

Q3. 一个 __int128 类型的返回值,低 64 位和高 64 位分别存放在哪里?

单选题 · mid

  • A.RAX 低 64 位,RDX 高 64 位← 正确答案
  • B.RDX 低 64 位,RAX 高 64 位
  • C.RAX 低 64 位,RCX 高 64 位
  • D.XMM0 低 64 位,XMM1 高 64 位

答案:A。128 位返回值按 RAX 存放低 64 位、RDX 存放高 64 位的约定拆分,调用者把两者拼合恢复完整值。这就像双拼披萨,RAX 装左半边、RDX 装右半边,合在一起才是完整的一盘菜。

Q4. 以下哪些寄存器属于调用者保存(易失性)寄存器?

多选题 · mid

  • A.RAX← 正确答案
  • B.RBX
  • C.RCX← 正确答案
  • D.R8← 正确答案

答案:ACD。RAX、RCX、R8 都是调用者保存寄存器,函数内部可随意改写;RBX 属于被调用者保存寄存器,使用前必须 push/pop。调用者保存寄存器像用完即弃的案板,被调用者保存寄存器像用后复位的祖传刀。

Q5. 函数 func(int a, double b, int c) 的第三个整型参数 c 存放在哪个寄存器?

单选题 · mid

  • A.RCX
  • B.RDI← 正确答案
  • C.RSI
  • D.RDX

答案:B。整型参数独立计数,a 是第 1 个整型进 RDI,c 是第 2 个整型进 RSI;浮点参数 b 进 XMM0。整型与浮点各排各的队,不能按总参数位置数。这里 c 排在总参数第 3 位,但它只是第二个整型,所以进 RSI。

Q6. 浮点参数在 System V ABI 中使用哪些寄存器传递?

单选题 · easy

  • A.RDI-RDI 系列通用寄存器
  • B.XMM0 到 XMM7← 正确答案
  • C.RAX 与 RDX
  • D.R8 与 R9

答案:B。浮点参数按顺序放入 XMM0-XMM7 共 8 个向量寄存器,每个都是 128 位宽,可容纳两个 double 或四个 float。XMM 寄存器像料理台旁的湿料碗,与整型的干料凹槽分开陈列。

Q7. 以下哪些是 x86_64 System V ABI 中被调用者保存(非易失性)寄存器?

多选题 · mid

  • A.R12← 正确答案
  • B.R13← 正确答案
  • C.RSI
  • D.RBP← 正确答案

答案:ABD。R12、R13、RBP 都属于被调用者保存寄存器,函数使用前必须压栈保存、返回前恢复。RSI 是调用者保存寄存器。被调用者保存寄存器像“完璧归赵”的传家宝,借用者必须原样归还。

Q8. 超过 6 个整数参数的函数,第 7 个整数参数放在哪里?

单选题 · hard

  • A.放入 R10 寄存器
  • B.放入 XMM0 寄存器
  • C.按从右向左顺序压入栈← 正确答案
  • D.直接丢弃

答案:C。超过 6 个的整数参数按从右向左的顺序压入栈区,调用者通过 RSP 偏移访问。栈是唯一接纳溢出参数的区域,就像台面凹槽满了后食材被放到备料柜里,按后进先出的顺序叠放。

Q9. System V ABI 栈对齐规则要求,被调用函数入口处(call 指令已执行)RSP 满足什么条件?

单选题 · mid

  • A.RSP 是 4 的倍数
  • B.(RSP + 8) 是 16 的倍数
  • C.RSP 是 16 的倍数← 正确答案
  • D.RSP 是 8 的倍数

答案:C。调用前 RSP 必须是 16 的倍数,call 压入 8 字节返回地址后,进入函数时 (RSP+8) 是 16 的倍数。这个约束保证 16 字节对齐的 SIMD 指令(如 movaps)能安全操作栈内存。

Q10. 关于 x86_64 System V ABI 的红区(Red Zone),以下说法正确的是?

多选题 · hard

  • A.位于 RSP 正下方的 128 字节区域← 正确答案
  • B.信号处理器不会破坏这片区域← 正确答案
  • C.叶子函数可在此暂存数据而不调整 RSP← 正确答案
  • D.位于 RSP 正上方的 128 字节区域

答案:ABC。红区是 RSP 下方的 128 字节私有领地,信号处理器保证不踩踏它,因此叶子函数可以把临时数据放在红区里、不需要 sub rsp 腾空间。它是“零成本”的临时存储区,但只有不再调用其他函数的叶子函数才能放心使用。D 的方向说反了,栈向下生长,红区在 RSP 之下。