需求复述
结合栈帧结构、函数调用时栈的核心作用(参数传递、局部变量分配、保存返回地址、寄存器恢复),详细拆解 _cdecl、_stdcall、_fastcall 三种函数调用约定的技术细节,并通过具体的地址、汇编示例分析每种约定的执行流程和核心差异。
核心讲解(基于x86 32位架构)
首先明确基础前提:x86 32位下栈是向下生长 (从高地址→低地址),ebp 指向栈帧底部(高地址),esp 指向栈帧顶部(低地址);函数调用的通用栈帧流程是「参数传递→保存返回地址→建立栈帧→执行逻辑→销毁栈帧→恢复现场」,三种调用约定的核心差异体现在参数传递方式 和栈清理责任上。
通用栈帧结构(先理解基础)
以函数调用 func(a,b) 为例,栈帧的通用布局(高地址→低地址):
[旧ebp(调用者栈基址)] <-- ebp(当前栈帧底部)
[返回地址]
[参数a]
[参数b]
[局部变量1]
[局部变量2]
...
注:实际地址偏移需结合压栈顺序计算,下文会用具体数值举例。
1. _cdecl(C默认调用约定)
核心规则 :参数从右到左压栈,调用者负责清理参数栈;支持可变参数(如 printf);名字修饰为「_函数名」(如 _add)。
示例分析:int add(int a, int b) { return a + b; } 被 main 调用
假设初始状态:main 的 ebp=0x0012FF80,esp=0x0012FF80(栈空),以下是完整执行流程+地址变化:
| 步骤 | 操作(汇编伪代码) | ebp | esp | 栈地址&值(关键) | 说明 |
|---|---|---|---|---|---|
| 1 | push 2(压入b) |
0x0012FF80 | 0x0012FF7C | 0x0012FF7C = 0x2 | 从右到左压参,先压第二个参数b |
| 2 | push 1(压入a) |
0x0012FF80 | 0x0012FF78 | 0x0012FF78 = 0x1 | 再压第一个参数a |
| 3 | call _add |
0x0012FF80 | 0x0012FF74 | 0x0012FF74 = 0x00401020 | call 指令:先压返回地址(main的下一条指令地址),再跳转到_add |
| 4 | push ebp(add内) |
0x0012FF80 | 0x0012FF70 | 0x0012FF70 = 0x0012FF80 | 保存调用者(main)的ebp |
| 5 | mov ebp, esp(add内) |
0x0012FF70 | 0x0012FF70 | - | 建立add的栈帧,ebp指向当前栈底 |
| 6 | mov eax, [ebp+8](add内) |
0x0012FF70 | 0x0012FF70 | eax=1 | 取参数a:ebp+8 = 0x0012FF78(对应步骤2的a) |
| 7 | add eax, [ebp+12](add内) |
0x0012FF70 | 0x0012FF70 | eax=3 | 取参数b:ebp+12 = 0x0012FF7C(对应步骤1的b) |
| 8 | mov esp, ebp(add内) |
0x0012FF70 | 0x0012FF70 | - | 销毁局部变量(本例无),esp回到栈底 |
| 9 | pop ebp(add内) |
0x0012FF80 | 0x0012FF74 | - | 恢复main的ebp |
| 10 | ret(add内) |
0x0012FF80 | 0x0012FF78 | - | 弹出返回地址到EIP,跳回main |
| 11 | add esp, 8(main内) |
0x0012FF80 | 0x0012FF80 | - | 调用者清理参数栈(2个int共8字节) |
核心特点 :调用者清理栈是关键,因此支持可变参数(如 printf 不知道参数个数,只能由调用者清理),但代码体积更大(每个调用处都要加清理指令)。
2. _stdcall(Windows API常用)
核心规则 :参数从右到左压栈,被调用者 负责清理参数栈;不支持可变参数;名字修饰为「_函数名@参数字节数」(如 _add@8,8是2个int的总字节数)。
示例分析:同上述 add(1,2)(仅差异处)
步骤1-7 与 _cdecl 完全一致,差异在步骤8-11:
| 步骤 | 操作(汇编伪代码) | 关键差异 |
|---|---|---|
| 8 | mov esp, ebp(add内) |
同_cdecl |
| 9 | pop ebp(add内) |
同_cdecl |
| 10 | ret 8(add内) |
ret 8:弹出返回地址后,直接将esp+8(清理8字节参数),esp回到0x0012FF80 |
| 11 | -(main内) | 调用者无需清理栈,省去 add esp,8 |
核心特点 :被调用者通过 ret N 指令(N为参数总字节数)清理栈,代码体积更小;但因需要提前知道参数个数,无法支持可变参数(Windows API无可变参数,因此适用)。
3. _fastcall(高性能调用约定)
核心规则 :前两个DWORD参数(int/指针等)用寄存器传递(ECX=第一个参数,EDX=第二个参数),剩余参数从右到左压栈;被调用者清理栈;名字修饰为「@函数名@参数字节数」(如 @add@8)。
示例分析:同上述 add(1,2)(完全不同的参数传递方式)
| 步骤 | 操作(汇编伪代码) | ebp | esp | 关键说明 |
|---|---|---|---|---|
| 1 | mov ecx, 1(main内) |
0x0012FF80 | 0x0012FF80 | 第一个参数a放入ECX |
| 2 | mov edx, 2(main内) |
0x0012FF80 | 0x0012FF80 | 第二个参数b放入EDX |
| 3 | call @add@8(main内) |
0x0012FF80 | 0x0012FF7C | 仅压入返回地址(0x00401020),无参数压栈 |
| 4 | push ebp(add内) |
0x0012FF80 | 0x0012FF78 | 保存main的ebp |
| 5 | mov ebp, esp(add内) |
0x0012FF78 | 0x0012FF78 | 建立add的栈帧 |
| 6 | mov eax, ecx(add内) |
0x0012FF78 | 0x0012FF78 | 从ECX取a=1 |
| 7 | add eax, edx(add内) |
0x0012FF78 | 0x0012FF78 | 从EDX取b=2,eax=3 |
| 8 | mov esp, ebp(add内) |
0x0012FF78 | 0x0012FF78 | 销毁栈帧 |
| 9 | pop ebp(add内) |
0x0012FF80 | 0x0012FF7C | 恢复main的ebp |
| 10 | ret 0(add内) |
0x0012FF80 | 0x0012FF80 | 无栈参数,ret 0等价于ret |
扩展场景 :若函数有3个参数(如 sum(a,b,c)),则前两个参数(a/b)用ECX/EDX,第三个参数c从右到左压栈,被调用者通过 ret 4 清理c的4字节栈空间。
核心特点:寄存器访问比内存快,因此性能最优;但寄存器数量有限,仅前两个参数受益。
三种调用约定对比表
| 特性 | _cdecl | _stdcall | _fastcall |
|---|---|---|---|
| 参数传递方式 | 全部从右到左压栈 | 全部从右到左压栈 | 前两个寄存器,剩余压栈 |
| 栈清理责任 | 调用者 | 被调用者 | 被调用者 |
| 名字修饰 | _函数名 | _函数名@参数字节数 | @函数名@参数字节数 |
| 可变参数支持 | 支持(如printf) | 不支持 | 不支持 |
| 代码体积 | 较大 | 较小 | 最小 |
| 典型应用 | C默认、可变参数函数 | Windows API | 性能敏感的函数 |
总结
- 三种调用约定的核心差异是参数传递方式 和栈清理责任 :
_cdecl调用者清理(支持可变参数),_stdcall/_fastcall被调用者清理(性能/体积更优)。 _fastcall是性能最优的,通过寄存器传递前两个参数减少栈操作,逆向时需注意寄存器(ECX/EDX)而非栈来获取前两个参数。- 栈帧的核心作用始终围绕:保存返回地址、维护参数/局部变量、恢复寄存器/ebp,调用约定仅改变「参数怎么传」和「栈谁来清」。