调用惯例,是在函数调用时如何传递参数和返回值的约定。
具体关注四个问题:
针对这四个问题,不同的调用惯例有不同的实现,当今比较主流的调用惯例有cdecl、stdcall、fastcall。这里我们只分析cdecl和stdcall。
调用惯例 | 传参方式 | 入栈顺序 | 传返回值 | 清栈者 |
---|---|---|---|---|
cdecl | 栈传参 | 从右到左压栈 | 寄存器(EAX首选) | 调用者函数 |
stdcall | 栈传参 | 从右到左压栈 | 寄存器(EAX首选) | 被调用者函数 |
可以看到,针对以上四个指标,这两种调用方式的差异只在清栈这一部分:cdecl约定由调用者函数负责清理栈上参数,stdcall则约定由被调用者函数清理栈。
那么这样看来我们的任务便很轻松了,不过抱着学习的心态,还是对前三种调用约定进行验证,就当拿od练手了。
学习从简单的问题入手往往更有效率。
鉴于博主水平有限,这里也是先分析一下简单的main函数和sub函数的调用关系。后续学到新东西再继续跟进啦。
main函数:
int main(int argc, char *argv[]) {
return sub(2, 1);
}
sub函数:
int __cdecl sub(int a, int b) {//cdecl调用惯例下的sub函数
return a - b;
}
int __stdcall sub(int a, int b) {//stdcall调用惯例下的sub函数
return a - b;
}
分别对两种调用惯例下的main(共用)和sub函数(区别所在)进行编译,生成二进制可执行文件,然后拖入od进行反汇编调试,观察main调用sub函数时栈状态的变化。注意,在编译源程序时,要取消优化选项,并采用debug模式(而不是release),这样我们才能看到更多细节。
好了,到这一步我们就可以安心打开od开始看汇编码了。
cdecl调用惯例下:
a.可以把这里理解为main函数执行时对栈的初始化,包括为自己申请栈空间、调整栈基址寄存器的值、保存寄存器等
00401050 |. 55 push ebp
00401051 |. 8BEC mov ebp,esp
00401053 |. 83EC 40 sub esp,0x40 ;这里将栈顶指针减40,即为main函数自己保留四十个字节的栈空间,用于存储局部变量等
00401056 |. 53 push ebx
00401057 |. 56 push esi
00401058 |. 57 push edi
b.这里是main函数把传递给sub函数的参数压栈,准备调用sub了,注意看压栈顺序哦。
00401068 |. 6A 01 push 0x1
0040106A |. 6A 02 push 0x2
c.这里call了sub函数,并且注意到语句”add esp,0x8“,这可是关键哦
0040106C |. E8 99FFFFFF call test-cde.0040100A
00401071 |. 83C4 08 add esp,0x8
00401074 |. 5F pop edi
00401075 |. 5E pop esi
00401076 |. 5B pop ebx
小知识:od以可执行文件名命名其中的用户函数,比如你的可执行文件是project.exe,那么od分析出来的用户函数命名基本上都是proje.XXXXXXA之类的形式,可不能帮你把名字都给原样搬出来,那简直就逆天了
这里就是sub函数的代码逻辑所在了,先把参数一移到eax,然后用eax减去参数二。
00401038 |. 8B45 08 mov eax,[arg.1]
0040103B |. 2B45 0C sub eax,[arg.2]
参数入栈,先压入1,后压入2,即是以从右到左的顺序压进去的。
返回值自然是存储在eax里的了。
这里sub函数返回并没有清理栈上的参数。
这里把ESP的值加了两个字,main(调用者)函数将参数1和2弹出了栈,完成参数清理(调用者清理栈)。
stdcall调用惯例下:
可以看到这里返回的时候是retn 8,意味着还要弹出8个字节,而从栈中可以看到,ESP+8之后恰好把参数1和2弹出,即在sub(被调用者)函数内部完成参数清理。