函数调用规范的内容和区别
1. 核心区别
特性 | cdecl (x86) | stdcall (x86) | System V AMD64 (x86-64) | AAPCS (ARM64) |
---|---|---|---|---|
参数传递 | 从右到左压栈 | 从右到左压栈 | 优先寄存器(RDI, RSI…) | 优先寄存器(X0-X7) |
清理栈的责任 | 调用者清理 | 被调用者清理 | 调用者清理(栈对齐) | 调用者/被调用者混合模式 |
返回值存放位置 | EAX(32 位) | EAX | RAX(64 位) | X0(64 位) |
寄存器保存规则 | EBX, EBP, ESI, EDI 需保存 | 同 cdecl | RBP, RBX, R12-R15 需保存 | X19-X29 需保存 |
典型应用场景 | C 语言(默认) | Windows API | Unix/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 V | ARM64 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 cdecl | x64 System V |
---|---|---|
参数传递 | 全部压栈(从右向左) | 前 6 个通过寄存器,其余压栈 |
栈清理责任 | 调用者清理(add esp, N ) | 调用者清理(若参数压栈) |
返回地址位置 | 栈顶(由 call 压入) | 栈顶(由 call 压入) |
局部变量访问 | 通过 EBP-偏移 | 通过 RBP-偏移 或 RSP+偏移 (无帧指针时) |
关键差异总结
-
参数传递方式:
- x86 架构(32 位):参数全部压栈。
- x86-64/ARM64:优先寄存器传递,溢出参数压栈。
-
返回地址处理:
- x86/x86-64:
call
指令自动压栈。 - ARM64:返回地址在
X30
寄存器,若函数嵌套调用需手动保存到栈。
- x86/x86-64:
-
栈对齐规则:
- x86-64/ARM64:必须 16 字节对齐,调用者需调整栈指针。
-
基址指针使用:
- 可省略(编译器优化):通过
RSP
直接访问局部变量和参数。 - 显式使用(调试友好):通过
RBP/X29
稳定访问栈帧。
- 可省略(编译器优化):通过
-
寄存器保存开销:
- 调用者保存寄存器:高频临时使用,减少保存次数。
- 被调用者保存寄存器:长期变量,确保多次调用一致性。
示例对比
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 个参数),被调用函数无法预先知道需要清理多少栈空间。 -
对比
cdecl
:cdecl
约定中,调用者(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
- x86-64 System V:
2.2 被调用者保存寄存器(Callee-saved)
- 规则:被调用函数如果使用了这些寄存器,必须在函数开头保存其原始值,并在返回前恢复。
- 用途:保存长期有效的值(如循环变量、基址指针)。
- 示例:
- x86-64 System V:
RBX
,RBP
,R12-R15
- ARM64 AAPCS:
X19-X29
- x86-64 System V:
对比示例
; 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
为例):
-
压栈顺序:
c
→b
→a
→ 返回地址。 -
栈布局(从高地址到低地址):
| 返回地址 | 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)是调用约定中对寄存器使用责任的一种划分方式。它结合了两种规则:
- 调用者保存寄存器(Caller-Saved Registers):调用者在调用函数前,若希望保留这些寄存器的值,需主动保存。
- 被调用者保存寄存器(Callee-Saved Registers):被调用者若使用这些寄存器,必须在其函数内部保存和恢复原始值。
4. 为什么需要混合模式?
目标:在灵活性和性能之间平衡。
- 灵活性:允许被调用函数自由使用某些寄存器(如长期变量),无需调用者干预。
- 性能:减少不必要的寄存器保存操作,临时寄存器(调用者保存)可被快速覆盖,无需额外保存开销。
示例场景:
// 调用者函数
void caller() {
int a = 10; // 存储在调用者保存寄存器(如 RAX)
callee(); // 调用函数
printf("%d", a); // 若 RAX 未被调用者保存,此处 a 可能被覆盖
}
- 若
callee
使用了RAX
(调用者保存寄存器),则caller
必须在调用前保存RAX
。 - 若
callee
使用RBX
(被调用者保存寄存器),则callee
自身负责保存和恢复RBX
,caller
无需关心。
5. 混合模式的具体规则
(1) 调用者保存寄存器(Volatile Registers)
- 责任:调用者在调用函数前,若需要保留这些寄存器的值,必须主动保存(如压栈)。
- 用途:传递参数、临时计算结果。
- 典型寄存器:
- x86-64:
RAX
,RCX
,RDX
,RSI
,RDI
,R8-R11
- ARM64:
X0-X18
- x86-64:
(2) 被调用者保存寄存器(Non-Volatile Registers)
- 责任:被调用者若使用这些寄存器,必须在函数入口保存原始值,并在退出前恢复。
- 用途:保存长期变量或上下文(如循环计数器、基址指针)。
- 典型寄存器:
- x86-64:
RBX
,RBP
,R12-R15
- ARM64:
X19-X29
- x86-64:
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
)的实现依赖混合模式:
-
参数传递:
- 前 6 个参数通过寄存器传递(
RDI
,RSI
,RDX
,RCX
,R8
,R9
)。 - 多余参数通过栈传递,调用者负责压栈和清理。
- 前 6 个参数通过寄存器传递(
-
寄存器保存:
-
可变参数函数内部通过
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 压栈,调用者清理
-