函数调用规范的内容和区别

1. 核心区别

特性cdecl (x86)stdcall (x86)System V AMD64 (x86-64)AAPCS (ARM64)
参数传递从右到左压栈从右到左压栈优先寄存器(RDI, RSI…)优先寄存器(X0-X7)
清理栈的责任调用者清理被调用者清理调用者清理(栈对齐)调用者/被调用者混合模式
返回值存放位置EAX(32 位)EAXRAX(64 位)X0(64 位)
寄存器保存规则EBX, EBP, ESI, EDI 需保存同 cdeclRBP, RBX, R12-R15 需保存X19-X29 需保存
典型应用场景C 语言(默认)Windows APIUnix/Linux 系统ARM 架构程序

2. 具体示例分析

示例函数int add(int a, int b) { return a + b; }


2.1 x86 cdecl

; 调用者代码(C语言)
push 3        ; 第二个参数b=3(从右到左压栈)
push 2        ; 第一个参数a=2
call add      ; 调用函数
add esp, 8    ; 调用者清理栈(2个参数 × 4字节)

被调用函数

add:
    mov eax, [esp+4]  ; 取a
    add eax, [esp+8]  ; 加b
    ret               ; 返回,不清理栈

关键区别:调用者用 add esp, 8 清理栈。


2.2 x86 stdcall

; 调用者代码(Windows API)
push 3        ; 参数b=3
push 2        ; 参数a=2
call add      ; 函数调用
; 无需清理栈(由被调用者清理)

被调用函数

add:
    mov eax, [esp+4]
    add eax, [esp+8]
    ret 8     ; 返回并清理8字节栈空间

关键区别:被调用者通过 ret 8 清理栈。


2.3 x86-64 System V AMD64

; 调用者代码
mov edi, 2    ; 第一个参数a=2(使用寄存器RDI)
mov esi, 3    ; 第二个参数b=3(使用寄存器RSI)
call add      ; 调用函数
; 无栈清理(参数通过寄存器传递)

被调用函数

add:
    lea eax, [rdi + rsi]  ; 计算结果到EAX
    ret

关键区别:参数通过寄存器传递,无需栈操作。


2.4 ARM64 AAPCS

// 调用者代码
MOV X0, #2    ; 第一个参数a=2(X0)
MOV X1, #3    ; 第二个参数b=3(X1)
BL add        ; 调用函数

被调用函数

add:
    ADD X0, X0, X1   ; 结果存入X0(返回值)
    RET

关键区别:参数和返回值均通过寄存器(X0-X7)处理,无栈操作。


3. 关键场景对比

3.1 性能差异

  • 寄存器传递(如 System V、AAPCS)比栈传递(如 cdecl)更快,减少了内存访问。
  • 调用者清理栈(cdecl)允许可变参数函数(如 printf),但需要额外指令。

3.2 跨平台兼容性

  • Windows x64:前 4 个参数用 RCX, RDX, R8, R9,与 System V 不同(RDI, RSI, RDX…)。
  • ARM vs x86:ARM 使用更多寄存器(X0-X7),而 x86-64 System V 用 RDI, RSI 等。

3.3 调试与逆向

  • cdecl:需跟踪 add esp, N 确定参数数量。
  • AAPCS/System V:通过寄存器顺序可直接推断参数。

4. 总结

  • cdecl/stdcall:适用于 x86 环境,区别在栈清理责任。
  • System V/AAPCS:寄存器优先,提升性能,适用于现代架构(x86-64/ARM64)。
  • Windows x64:混合寄存器与栈,与 System V 寄存器分配不同。

理解调用约定对调试、逆向工程和跨平台开发至关重要。例如,在分析 ARM64 栈帧时(如用户提供的代码),需注意参数可能通过寄存器传递,而栈仅用于保存寄存器和局部变量。


不同调用方式的栈帧

通用栈帧结构示意图(以 x86-64 System V 为例)

以下是一个典型的函数调用栈帧布局,展示了调用者保存的寄存器、参数、返回地址、被调用者保存的寄存器、基址指针(BP)和局部变量:

高地址
+-----------------------+
| 调用者的栈帧           |
|                       |
| 调用者保存的寄存器      | ← 调用者负责保存(如RAX, RCX等)
|                       |
| 参数7~N(栈传递)       | ← 超出寄存器数量的参数(从右向左压栈)
|                       |
+-----------------------+ ← 调用者的栈顶(调用前SP)
| 返回地址               | ← call指令自动压入
+-----------------------+
| 旧RBP                 | ← 被调用者保存的RBP(基址指针)
+-----------------------+ ← 当前RBP(被调用者的帧指针)
| 被调用者保存的寄存器    | ← 如RBX, R12-R15(由被调用者保存)
|                       |
| 局部变量               | ← 被调用者的局部变量(通过RBP-偏移访问)
|                       |
| 调用者保存的寄存器副本  | ← 若被调用者使用调用者保存寄存器,需保存
+-----------------------+ ← 当前SP(栈顶)
低地址

各部分详解

1. 调用者保存的寄存器(Caller-Saved Registers)

  • 位置:调用者的栈帧中(可能在调用前压栈)。
  • 责任:调用者在调用函数前,若需保留这些寄存器的值,需主动压栈保存。
  • 示例寄存器(x86-64):RAX, RCX, RDX, RSI, RDI, R8-R11

2. 参数传递

  • 寄存器传递:前 6 个参数通过寄存器(RDI, RSI, RDX, RCX, R8, R9)。

  • 栈传递:第 7 个及以后的参数从右向左压入调用者的栈帧。

  • 示例

    ; 调用者代码(传递8个参数)
    mov rdi, arg1      ; 参数1 → RDI
    mov rsi, arg2      ; 参数2 → RSI
    mov rdx, arg3      ; 参数3 → RDX
    mov rcx, arg4      ; 参数4 → RCX
    mov r8, arg5       ; 参数5 → R8
    mov r9, arg6       ; 参数6 → R9
    push arg8          ; 参数8(从右向左压栈)
    push arg7          ; 参数7
    call function
    add rsp, 16        ; 调用者清理栈上的参数7和8

3. 返回地址

  • 生成方式call 指令自动将返回地址压入栈顶。
  • 位置:在被调用者的栈帧底部(紧邻旧 RBP)。

4. 被调用者保存的寄存器(Callee-Saved Registers)

  • 位置:被调用者的栈帧顶部(紧接返回地址和旧 RBP)。

  • 责任:被调用者在函数开头保存这些寄存器,退出前恢复。

  • 示例寄存器(x86-64):RBX, RBP, R12-R15

  • 示例代码

    function:
        push rbp        ; 保存旧RBP
        mov rbp, rsp    ; 设置新RBP
        push rbx        ; 保存被调用者寄存器RBX
        push r12        ; 保存被调用者寄存器R12
        ; ...函数逻辑...
        pop r12         ; 恢复R12
        pop rbx         ; 恢复RBX
        pop rbp         ; 恢复旧RBP
        ret

5. 基址指针(BP,如 x86-64 的 RBP)

  • 作用:作为帧指针,稳定访问参数和局部变量(即使 SP 变化)。
  • 位置:旧 RBP 保存在被调用者栈帧底部,新 RBP 指向当前栈帧基址。

6. 局部变量

  • 位置:通过 RBP-偏移 访问(如 RBP-8)。

  • 分配方式:通过 sub rsp, N 分配空间。

  • 示例

    function:
        push rbp
        mov rbp, rsp
        sub rsp, 16     ; 分配16字节局部变量空间
        mov [rbp-8], 5  ; 局部变量1
        mov [rbp-16], 10 ; 局部变量2
        ; ...
        add rsp, 16     ; 释放局部变量空间
        pop rbp
        ret

不同调用约定的栈帧差异

1. x86-64 System V vs. ARM64 AAPCS

特性x86-64 System VARM64 AAPCS
参数传递前 6 个参数通过寄存器,其余压栈前 8 个参数通过寄存器(X0-X7),其余压栈
返回地址存储call 指令压入栈存储在 X30(LR)寄存器,需手动压栈保存
栈对齐要求16 字节对齐(调用时)16 字节对齐
基址指针可选(-fomit-frame-pointer 可省略 RBP)通常使用 X29(FP)作为帧指针
寄存器保存规则调用者保存:RAX, RCX 等;被调用者保存:RBX 等调用者保存:X0-X18;被调用者保存:X19-X29

2. x86 cdecl vs. x64 System V

特性x86 cdeclx64 System V
参数传递全部压栈(从右向左)前 6 个通过寄存器,其余压栈
栈清理责任调用者清理(add esp, N调用者清理(若参数压栈)
返回地址位置栈顶(由 call 压入)栈顶(由 call 压入)
局部变量访问通过 EBP-偏移通过 RBP-偏移RSP+偏移(无帧指针时)

关键差异总结

  1. 参数传递方式

    • x86 架构(32 位):参数全部压栈。
    • x86-64/ARM64:优先寄存器传递,溢出参数压栈。
  2. 返回地址处理

    • x86/x86-64:call 指令自动压栈。
    • ARM64:返回地址在 X30 寄存器,若函数嵌套调用需手动保存到栈。
  3. 栈对齐规则

    • x86-64/ARM64:必须 16 字节对齐,调用者需调整栈指针。
  4. 基址指针使用

    • 可省略(编译器优化):通过 RSP 直接访问局部变量和参数。
    • 显式使用(调试友好):通过 RBP/X29 稳定访问栈帧。
  5. 寄存器保存开销

    • 调用者保存寄存器:高频临时使用,减少保存次数。
    • 被调用者保存寄存器:长期变量,确保多次调用一致性。

示例对比

x86-64 System V 函数调用

; 调用者
mov rdi, 42         ; 参数1 → RDI
call func           ; 返回地址压栈
add rsp, 0          ; 无栈参数,无需清理
 
; 被调用者 func
func:
    push rbp
    mov rbp, rsp
    sub rsp, 16     ; 分配局部变量空间
    push rbx        ; 保存被调用者寄存器
    ; ...逻辑...
    pop rbx
    add rsp, 16
    pop rbp
    ret

ARM64 AAPCS 函数调用

// 调用者
mov x0, 42          // 参数1 → X0
bl func             // 返回地址存入 X30(LR)
 
// 被调用者 func
func:
    stp x29, x30, [sp, #-16]!  // 保存 FP 和 LR
    mov x29, sp                 // 设置新 FP
    sub sp, sp, 16              // 分配局部变量
    stp x19, x20, [sp, #-16]!   // 保存被调用者寄存器
    // ...逻辑...
    ldp x19, x20, [sp], #16     // 恢复寄存器
    add sp, sp, 16              // 释放局部变量
    ldp x29, x30, [sp], #16     // 恢复 FP 和 LR
    ret

总结

  • 栈帧结构因架构和调用约定而异,主要体现在参数传递、寄存器保存规则和栈对齐要求。
  • 通用原则:调用者管理参数传递和溢出栈空间,被调用者保存关键寄存器并分配局部变量。
  • 调试与分析:理解栈布局有助于逆向工程中快速定位参数和局部变量。

一些问题

1. stdcall 是否不支持可变参数?

是的,stdcall 不支持可变参数函数(如 printf),原因如下:

  • 栈清理责任:在 stdcall 约定中,被调用函数(callee)负责清理栈上的参数。 例如,函数 int add(int a, int b) 在返回时会通过 ret 8 指令自动清理 8 字节的栈空间(两个 int 参数)。 问题:可变参数函数的参数数量在编译时不确定(如 printf("sum=%d, product=%d", a, b) 有 3 个参数),被调用函数无法预先知道需要清理多少栈空间。

  • 对比 cdeclcdecl 约定中,调用者(caller)负责清理栈空间。调用可变参数函数后,调用者可以通过 add esp, N 动态调整栈指针,无论参数数量如何变化。 示例

    ; cdecl 调用 printf
    push 3          ; 参数3
    push 2          ; 参数2
    push offset fmt ; 格式字符串
    call printf
    add esp, 12     ; 调用者清理12字节栈空间

结论stdcall 因栈清理机制的限制,无法支持可变参数函数;cdecl 是唯一支持可变参数的 x86 调用约定。


2. 寄存器保存规则是怎么回事?

寄存器保存规则定义了在函数调用过程中,哪些寄存器的值必须被保留。分为两类: 调用者保存(Caller-saved)被调用者保存(Callee-saved)

2.1 调用者保存寄存器(Caller-saved)

  • 规则:调用者在调用函数前,如果希望寄存器值在调用后保持不变,必须主动保存这些寄存器。
  • 用途:通常用于临时计算或参数传递,值可能在函数调用中被覆盖。
  • 示例
    • x86-64 System V:RAX, RCX, RDX, RDI, RSI, R8-R11
    • ARM64 AAPCS:X0-X18

2.2 被调用者保存寄存器(Callee-saved)

  • 规则:被调用函数如果使用了这些寄存器,必须在函数开头保存其原始值,并在返回前恢复。
  • 用途:保存长期有效的值(如循环变量、基址指针)。
  • 示例
    • x86-64 System V:RBX, RBP, R12-R15
    • ARM64 AAPCS:X19-X29

对比示例

; x86-64 System V 示例
foo:
    push rbx        ; 保存被调用者保存寄存器
    mov rbx, rdi    ; 使用RBX
    call bar        ; 调用其他函数
    pop rbx         ; 恢复RBX
    ret
 
; 调用者代码
mov rdi, 5
call foo           ; 无需保存RDI(调用者保存寄存器)

3. 栈上传递参数时,参数是否在比返回地址更高地址的方向?

是的。参数在栈上的地址比返回地址更靠近栈底,原因如下:

3.1 栈的增长方向

  • x86/ARM:栈向低地址方向增长。
    • push eax 会减小栈指针(SP),将数据存入更低地址。

3.2 函数调用时的栈操作

假设调用 func(a, b, c),参数从右到左压栈(以 cdecl 为例):

  1. 压栈顺序:cba返回地址

  2. 栈布局(从高地址到低地址):

    | 返回地址 | a | b | c | ...(更低地址)
    

3.3 具体示例

; x86 cdecl 调用 func(1, 2, 3)
push 3          ; c → 低地址
push 2          ; b
push 1          ; a
call func       ; 压入返回地址(高地址)
add esp, 12     ; 清理栈
  • 栈布局(假设初始 ESP=0x1000):

    0x0FF4: 3         ; 参数c
    0x0FF8: 2         ; 参数b
    0x0FFC: 1         ; 参数a
    0x1000: 返回地址   ; 高地址
    

3.4 参数与返回地址的位置关系

  • 参数 a 的地址:ESP+4(返回地址在 ESP,参数在 ESP+4, ESP+8, …)
  • 总结:参数在栈上的地址低于返回地址,向低地址方向排列。

调用者/被调用者混合模式详解

调用者/被调用者混合模式(Caller/Callee-Saved Hybrid Convention)是调用约定中对寄存器使用责任的一种划分方式。它结合了两种规则:

  1. 调用者保存寄存器(Caller-Saved Registers):调用者在调用函数前,若希望保留这些寄存器的值,需主动保存。
  2. 被调用者保存寄存器(Callee-Saved Registers):被调用者若使用这些寄存器,必须在其函数内部保存和恢复原始值。

4. 为什么需要混合模式?

目标:在灵活性和性能之间平衡。

  • 灵活性:允许被调用函数自由使用某些寄存器(如长期变量),无需调用者干预。
  • 性能:减少不必要的寄存器保存操作,临时寄存器(调用者保存)可被快速覆盖,无需额外保存开销。

示例场景

// 调用者函数
void caller() {
    int a = 10;        // 存储在调用者保存寄存器(如 RAX)
    callee();          // 调用函数
    printf("%d", a);   // 若 RAX 未被调用者保存,此处 a 可能被覆盖
}
  • callee 使用了 RAX(调用者保存寄存器),则 caller 必须在调用前保存 RAX
  • callee 使用 RBX(被调用者保存寄存器),则 callee 自身负责保存和恢复 RBXcaller 无需关心。

5. 混合模式的具体规则

(1) 调用者保存寄存器(Volatile Registers)

  • 责任:调用者在调用函数前,若需要保留这些寄存器的值,必须主动保存(如压栈)。
  • 用途:传递参数、临时计算结果。
  • 典型寄存器
    • x86-64:RAX, RCX, RDX, RSI, RDI, R8-R11
    • ARM64:X0-X18

(2) 被调用者保存寄存器(Non-Volatile Registers)

  • 责任:被调用者若使用这些寄存器,必须在函数入口保存原始值,并在退出前恢复。
  • 用途:保存长期变量或上下文(如循环计数器、基址指针)。
  • 典型寄存器
    • x86-64:RBX, RBP, R12-R15
    • ARM64:X19-X29

6. 混合模式的实际运作

场景示例(x86-64 System V)

假设函数 A 调用函数 B,且 B 使用了被调用者保存寄存器 RBX

; 函数 B 的实现
B:
    push rbx        ; 保存被调用者保存寄存器 RBX
    mov rbx, rdi    ; 使用 RBX 存储参数
    ...             ; 函数逻辑
    pop rbx         ; 恢复 RBX
    ret
 
; 函数 A 的调用代码
A:
    mov rax, 5      ; 使用调用者保存寄存器 RAX(临时变量)
    mov rbx, 10     ; 使用被调用者保存寄存器 RBX(长期变量)
    call B          ; 调用 B
    ; B 会自行保存/恢复 RBX,因此此处 RBX 仍为 10
    ; 但 RAX 可能被 B 覆盖,若 A 后续需要 RAX=5,需在 call B 前保存:
    push rax
    call B
    pop rax

关键点

  • RBX 的值在 B 中被自动保存和恢复,A 无需额外操作。
  • RAX 的值可能被 B 覆盖,A 需在调用前主动保存(若后续需要)。

7. 混合模式与可变参数函数

以 x86-64 System V 调用约定为例,可变参数函数(如 printf)的实现依赖混合模式:

  1. 参数传递

    • 前 6 个参数通过寄存器传递(RDI, RSI, RDX, RCX, R8, R9)。
    • 多余参数通过栈传递,调用者负责压栈和清理。
  2. 寄存器保存

    • 可变参数函数内部通过 va_list 访问栈参数,无需关心寄存器保存(调用者已处理)。

    • 示例:

      printf("%d %d %d", a, b, c); // a(RDI), b(RSI), c(RDX) → 无需栈操作
      printf("%d %d %d %d", a, b, c, d); // 第四个参数 d 压栈,调用者清理