栈帧与函数调用:函数调用约定
函数调用,说白了就是让CPU从一个地方跳到另一个地方干活。但这里有个问题——跳过去容易,回来怎么办?参数怎么传?返回值怎么拿?这些规矩,就是调用约定。
我刚开始逆向的时候,看到一堆push、pop、mov,完全摸不着头脑。后来才明白,这些操作背后有一套固定的套路。今天咱们就把这层窗户纸捅破。
三种主流调用约定
Windows和Linux下,最常见的就三种:cdecl、stdcall、fastcall。它们最大的区别在于——谁负责清理栈,参数从哪边开始压,以及能不能用寄存器传参。
| 约定 | 参数传递 | 栈清理者 | 典型平台 |
|---|---|---|---|
| cdecl | 从右向左压栈 | 调用者 | Linux、Windows C程序 |
| stdcall | 从右向左压栈 | 被调用者 | Windows API |
| fastcall | 前两个参数用寄存器(ecx, edx),其余压栈 | 被调用者 | Windows内核、部分编译器优化 |
你可能会问:为什么搞这么多种?嗯,历史原因。cdecl最早出现在C语言里,优点是支持可变参数(比如printf)。但每次调用完都要调用者自己清理栈,代码体积就大了。stdcall把清理工作交给被调用者,省了重复代码。fastcall则更进一步,用寄存器传参,速度更快。
关键点:逆向时,看到函数返回前有 ret 8 或 ret 12,基本就是stdcall或fastcall。如果看到调用后紧跟 add esp, 8,那就是cdecl。
栈帧结构
每次函数调用,都会在栈上开辟一块区域,叫栈帧。它长什么样?我画个图你就明白了。
看到没?EBP像个锚点,固定住了栈帧的底部。局部变量通过 EBP - 偏移 来访问,参数通过 EBP + 偏移 来访问。ESP则随着push/pop不断变化。
我的习惯:逆向时,我第一件事就是找到函数的 prologue(push ebp; mov ebp, esp)。只要定位了EBP,整个栈帧就清晰了。曾经有个恶意样本,故意把EBP搞乱来反调试,我硬是顺着栈回溯把调用链捋了出来。
参数传递的底层细节
咱们看个具体例子。假设有个函数:
int add(int a, int b, int c) {
return a + b + c;
}
// 调用
int result = add(1, 2, 3);
在cdecl下,编译出来大概是这样:
; 调用者视角
push 3 ; 参数c
push 2 ; 参数b
push 1 ; 参数a
call add
add esp, 12 ; 调用者清理栈(3个int,共12字节)
; 被调用者视角(add函数内部)
push ebp
mov ebp, esp
; 此时栈布局:
; [ebp+16] = c
; [ebp+12] = b
; [ebp+8] = a
; [ebp+4] = 返回地址
; [ebp] = 旧EBP
mov eax, [ebp+8] ; eax = a
add eax, [ebp+12] ; eax += b
add eax, [ebp+16] ; eax += c
pop ebp
ret
如果是stdcall,区别就在最后——被调用者用 ret 12 代替 ret,调用者就不用 add esp, 12 了。
我曾经踩过的坑:有一次逆向一个DLL,看到函数返回前是 ret 8,但调用者后面还有 add esp, 8。栈就乱套了!后来发现是调用约定不匹配——DLL用stdcall编译,但调用者按cdecl写的。这种bug极难排查,因为程序不会马上崩溃,而是跑着跑着突然挂掉。
返回值处理
返回值怎么拿?简单说:
- 整数/指针:通过 EAX 返回
- 64位整数:EDX:EAX 组合(高32位在EDX)
- 浮点数:通过 ST(0)(x87栈顶)或 XMM0(SSE)
- 大结构体:调用者悄悄传一个隐藏指针作为第一个参数,函数把结果写进去
最后一种情况很有意思。你看这个C代码:
struct BigStruct {
char data[64];
};
struct BigStruct get_data() {
struct BigStruct s;
// 填充s...
return s;
}
编译后,实际变成:
; 调用者
sub esp, 64 ; 为返回值预留空间
push esp ; 隐藏指针作为第一个参数
call get_data
; 此时栈上就是返回的结构体
; get_data内部
push ebp
mov ebp, esp
; [ebp+8] = 隐藏指针(指向调用者预留的缓冲区)
; 函数把数据写入这个指针指向的内存
mov eax, [ebp+8]
; ... 填充数据 ...
pop ebp
ret 4 ; 清理隐藏指针
嗯,这里要注意:逆向时如果看到函数第一个参数是个栈地址,而且函数内部大量使用它,基本就是在返回大结构体。
实战中的调用约定识别
我总结了一套快速识别的方法:
- 看函数名修饰:Windows下,stdcall函数名会加下划线前缀和@后缀(如 _add@12),cdecl只有下划线前缀(_add),fastcall是@前缀(@add@12)
- 看返回前的指令:ret不带数字 → cdecl;ret带数字 → stdcall或fastcall
- 看调用后的指令:有 add esp, X → cdecl;没有 → stdcall/fastcall
- 看参数传递:前两个参数用ecx/edx → fastcall
避坑指南:我曾经遇到一个加壳程序,把所有函数名都抹掉了。我靠观察ret指令后的数字,硬是把整个调用关系重建了出来。记住——调用约定是编译器定的规矩,只要规矩不变,逆向就有迹可循。
最后说一句:理解栈帧和调用约定,是逆向工程的基石。你想想看,无论是分析漏洞、破解软件、还是做恶意代码分析,最终都要落到函数调用上。这块搞明白了,后面的路就好走了。
个人建议:初学者可以自己写几个不同调用约定的函数,用IDA或x64dbg打开,单步跟踪栈的变化。看十遍不如动手一遍,真的。
公众号:蓝海资料掘金营,微信deep3321