栈帧与函数调用:函数调用约定

函数调用,说白了就是让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。

栈帧结构

每次函数调用,都会在栈上开辟一块区域,叫栈帧。它长什么样?我画个图你就明白了。

栈帧结构示意图(高地址在上) 调用者栈帧 高地址 参数 n ... 参数 1 (从右向左压栈) 返回地址 (call指令自动压入) 保存的 EBP(旧基址) (push ebp) 局部变量区 (sub esp, 0x40 等) 低地址 栈增长方向(向低地址) EBP → ESP →

看到没?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               ; 清理隐藏指针

嗯,这里要注意:逆向时如果看到函数第一个参数是个栈地址,而且函数内部大量使用它,基本就是在返回大结构体。

实战中的调用约定识别

我总结了一套快速识别的方法:

  1. 看函数名修饰:Windows下,stdcall函数名会加下划线前缀和@后缀(如 _add@12),cdecl只有下划线前缀(_add),fastcall是@前缀(@add@12)
  2. 看返回前的指令:ret不带数字 → cdecl;ret带数字 → stdcall或fastcall
  3. 看调用后的指令:有 add esp, X → cdecl;没有 → stdcall/fastcall
  4. 看参数传递:前两个参数用ecx/edx → fastcall

避坑指南:我曾经遇到一个加壳程序,把所有函数名都抹掉了。我靠观察ret指令后的数字,硬是把整个调用关系重建了出来。记住——调用约定是编译器定的规矩,只要规矩不变,逆向就有迹可循。

最后说一句:理解栈帧和调用约定,是逆向工程的基石。你想想看,无论是分析漏洞、破解软件、还是做恶意代码分析,最终都要落到函数调用上。这块搞明白了,后面的路就好走了。

个人建议:初学者可以自己写几个不同调用约定的函数,用IDA或x64dbg打开,单步跟踪栈的变化。看十遍不如动手一遍,真的。


公众号:蓝海资料掘金营,微信deep3321