本页目录

目标代码与 ABI

函数调用不是几条指令的事——谁负责保存哪些寄存器、参数怎么传、栈帧怎么布局,全写死在 ABI 里,违反直接崩溃。x86-64 System V 和 Win64 的差异是跨平台编译最常见的坑。

概述

指令选择把 IR 操作映射到目标机器指令。但函数调用不是几条指令的简单映射——涉及谁负责保存哪些寄存器,参数怎么传,栈帧怎么布局,返回地址在哪⁠。这些约定全写在一份 ABI(Application Binary Interface)规范里,操作系统和编译器都要遵守。违反 ABI 程序直接 crash——不是逻辑错误,而是栈被污染,返回地址被覆盖。这篇以 x86-64 System V(Unix/Linux/Mac)为主,对照 Win64,把栈帧的每一块讲清楚。

Calling Convention:寄存器与栈的协议

x86-64 System V 的参数传递:

参数顺序: rdi, rsi, rdx, rcx, r8, r9, [栈 (右压左)]
返回值:   rax (64-bit 以内), rdx:rax (128-bit 结构体)
调用者保存: rax, rcx, rdx, rsi, rdi, r8-r11   ← call 后可能被破坏
被调者保存: rbx, rbp, r12-r15                  ← 被调函数要用的话必须保存+恢复

一条 fn(a, b, c, d, e, f, g) 的调用在 x86-64 System V 下:

mov rdi, a          ; 参数 1
mov rsi, b          ; 参数 2
mov rdx, c          ; 参数 3
mov rcx, d          ; 参数 4
mov r8, e           ; 参数 5
mov r9, f           ; 参数 6
push g              ; 参数 7 → 栈 (在 call 之前推入)
call fn

Win64 不同:前 4 个参数用 rcx, rdx, r8, r9,后全走栈;⁠且调用方必须在栈上为前 4 个参数预留"shadow space"(32 字节)——即使用不上也有这个位置。交叉平台编译时,这是个常踩的坑。

栈帧的解剖

函数栈帧解剖:从 rbp 出发,正偏移是调用方,负偏移是自己

栈高位(调用方)

调用方推入的参数 7+ ← 调用方在 call 前 push 返回地址 ← call 指令自动 push 旧的 rbp ← push rbp(prologue) rbp → 局部变量 ← sub rsp, N 分配的栈空间 callee-saved 寄存器 ← 保存被调者,如 rbx、r12 spill slots ← 寄存器分配溢出位 当前栈顶 rsp →

栈低位

帧指针可选:frameless 函数只用 rsp 偏移访问局部变量,省一条 push/pop,但调试时难以回溯栈帧; 调试信息(DWARF .debug_frame)负责记录从 rsp 恢复历史 rbp 的规则。

从 rbp(帧指针)出发,正偏移到返回地址和调用方参数;负偏移到自己函数的局部变量。⁠帧指针是可选的⁠——现代编译器常用 frameless:只用 rsp 偏移访问,不 push rbp(节省指令,但调试时难以回溯栈帧)。调试信息(DWARF .debug_frame)存着从 rsp 到历史 rbp 的恢复规则。

Red zone(x86-64 System V)

rsp 以下 128 字节是 red zone——函数可以用这段区域而不用动 rsp。仅当该函数不调用其他函数时有效(leaf function)。Win64 没有 red zone——Windows 上 rsp 以下不保证不被信号处理程序/中断践踏。

Prologue / Epilogue

每个函数在入口(prologue)和出口(epilogue)插入标准序列:

Prologue:
    push rbp              ; 保存帧指针
    mov rbp, rsp          ; 设新的帧指针
    sub rsp, N            ; 为局部变量 + spill + callee-saved 分配栈空间
    push rbx (etc.)       ; 保存被调者寄存器 (如果本函数用了)

Epilogue:
    pop rbx (etc.)        ; 恢复被调者寄存器
    mov rsp, rbp          ; 释放栈空间
    pop rbp               ; 恢复旧帧指针
    ret

编译器知道哪些寄存器本函数使用了——用不到 rbx 就不 push rbx。帧布局(哪个寄存器在栈上的哪个位置)是编译时确定的,prologue/epilogue 的 push/pop 和偏移是常量。

Variadic Function(变参函数)

printf(fmt, a, b, c, ...) 的参数数量和类型在编译时不知道。x86-64 ABI 规定变参函数的参数传递和普通函数一样(前 6 个到寄存器,后走栈),但**al 寄存器记录浮点参数用了几个 XMM 寄存器**⁠——这是让 printf 内部能检索 vararg 的关键。

对编译器的要求:在 vararg 调用点,编译器需要确保所有 XMM 寄存器里可能传的参数已被存到对应的 va_list 位置。va_start/va_arg 的实现完全基于 ABI 的寄存器+栈参数布局。

PIC / PIE:位置无关代码

共享库和现代可执行文件需要 PIC(Position-Independent Code):代码段可以在内存中任意位置加载,不需要重定位改代码本身(代码段是共享的,改了影响所有进程)。代价是全局变量和函数的访问多一层间接:

非 PIC:                           PIC:
  mov rax, [addr_of_x]              mov rax, [rip + offset_to_GOT_entry]
  call fn                           call [rip + offset_to_GOT_entry_for_fn]
                                     ; GOT = Global Offset Table, 在数据段中

编译器生成 PIC 时,所有"外部符号的地址"通过 GOT 间接——加载时动态链接器填 GOT 表项。rip + offset 是 PC 相对寻址(不需要绝对地址),保证代码段和地址无关。

参考

  • System V AMD64 ABI: https://gitlab.com/x86-psABIs/x86-64-ABI — 权威规范
  • Microsoft x64 ABI: https://learn.microsoft.com/en-us/cpp/build/x64-software-conventions
  • Agner Fog: "Calling Conventions" — 各平台 ABI 的工程化汇总

Keywords: ABI, calling convention, System V, Win64, stack frame, prologue, epilogue, frame pointer, red zone, caller-saved, callee-saved, register passing, shadow space, variadic, va_list, AL register, PIC, PIE, GOT, PLT, PC-relative addressing, stack alignment