函数调用约定和堆栈
1 什么是堆栈
编译器一般使用堆栈实现函数调用。堆栈是存储器的一个区域,嵌入式环境有时需要程序员自己定义一个数组作为堆栈。Windows为每个线程自动维护一个堆栈,堆栈的大小可以设置。编译器使用堆栈来堆放每个函数的参数、局部变量等信息。
函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息,每个函数占用一个连续的区域。一个函数占用的区域被称作帧(frame)。
编译器从高地址开始使用堆栈。 假设我们定义一个数组a[1024]作为堆栈空间,一开始栈顶指针指向a[1023]。如果栈里有两个函数a和b,且a调用了b,栈顶指针会指向函数b的帧。如果函数b返回。栈顶指针就指向函数a的帧。如果在栈里放了太多东西造成溢出,破坏的是a[0]上面的东西。
在多线程(任务)环境,CPU的堆栈指针指向的存储器区域就是当前使用的堆栈。切换线程的一个重要工作,就是将堆栈指针设为当前线程的堆栈栈顶地址。
不同CPU,不同编译器的堆栈布局、函数调用方法都可能不同,但堆栈的基本概念是一样的。
2 函数调用约定
函数调用约定包括传递参数的顺序,谁负责清理参数占用的堆栈等,例如 :
参数传递顺序 | 谁负责清理参数占用的堆栈 | |
__pascal | 从左到右 | 调用者 |
__stdcall | 从右到左 | 被调函数 |
__cdecl | 从右到左 | 调用者 |
调用函数的代码和被调函数必须采用相同的函数的调用约定,程序才能正常运行。在Windows上,__cdecl是C/C++程序的缺省函数调用约定。
在有的cpu上,编译器会用寄存器传递参数,函数使用的堆栈由被调函数分配和释放。这种调用约定在行为上和__cdecl有一个共同点:实参和形参数目不符不会导致堆栈错误。
不过,即使用寄存器传递参数,编译器在进入函数时,还是会将寄存器里的参数存入堆栈指定位置。参数和局部变量一样应该在堆栈中有一席之地。参数可以被理解为由调用函数指定初值的局部变量。
3 例子:__cdecl和__stdcall
不同的CPU,不同的编译器,堆栈的布局可能是不同的。本文以x86,VC++的编译器为例。
VC++编译器的已经不再支持__pascal, __fortran, __syscall等函数调用约定。目前只支持__cdecl和__stdcall。
采用__cdecl或__stdcall调用方式的程序,在刚进入子函数时,堆栈内容是一样的。esp指向的栈顶是返回地址。这是被call指令压入堆栈的。下面是参数,左边参数在上,右边参数在下(先入栈)。
如前表所示,__cdecl和__stdcall的区别是:__cdecl是调用者清理参数占用的堆栈,__stdcall是被调函数清理参数占用的堆栈。
由于__stdcall的被调函数在编译时就必须知道传入参数的准确数目(被调函数要清理堆栈),所以不能支持变参数函数,例如printf。而且如果调用者使用了不正确的参数数目,会导致堆栈错误。
通过查看汇编代码,__cdecl函数调用在call语句后会有一个堆栈调整语句,例如:
-
a = 0x1234;
b = 0x5678;
c = add(a, b);
对应x86汇编:
-
mov dword ptr [ebp-4],1234h
mov dword ptr [ebp-8],5678h
mov eax,dword ptr [ebp-8]
push eax
mov ecx,dword ptr [ebp-4]
push ecx
call 0040100a
add esp,8
mov dword ptr [ebp-0Ch],eax
__stdcall的函数调用则不需要调整堆栈:
-
call 00401005
mov dword ptr [ebp-0Ch],eax
函数
-
int __cdecl add(int a, int b)
{
return a+b;
}
产生以下汇编代码(Debug版本):
-
push ebp
mov ebp,esp
sub esp,40h
push ebx
push esi
push edi
lea edi,[ebp-40h]
mov ecx,10h
mov eax,0CCCCCCCCh
rep stos dword ptr [edi]
mov eax,dword ptr [ebp+8]
add eax,dword ptr [ebp+0Ch]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret // 跳转到esp所指地址,并将esp+4,使esp指向进入函数时的第一个参数
再查看__stdcall函数的实现,会发现与__cdecl函数只有最后一行不同:
- ret 8 // 执行ret并清理参数占用的堆栈
对于调试版本,VC++编译器在“直接调用地址”时会增加检查esp的代码,例如:
-
ta = (TAdd)add; // TAdd定义:typedef int (__cdecl *TAdd)(int a, int b);
c = ta(a, b);
产生以下汇编代码:
-
mov [ebp-10h],0040100a
mov esi,esp
mov ecx,dword ptr [ebp-8]
push ecx
mov edx,dword ptr [ebp-4]
push edx
call dword ptr [ebp-10h]
add esp,8
cmp esi,esp
call __chkesp (004011e0)
mov dword ptr [ebp-0Ch],eax
__chkesp 代码如下。如果esp不等于函数调用前保存的值,就会转到错误处理代码。
-
004011E0 jne __chkesp+3 (004011e3)
004011E2 ret
004011E3 ;错误处理代码
__chkesp的错误处理会弹出对话框,报告函数调用造成esp值不正确。 Release版本的汇编代码要简洁得多。也不会增加 __chkesp。如果发生esp错误,程序会继续运行,直到“遇到问题需要关闭”。
3 补充说明
函数调用约定只是“调用函数的代码”和被调用函数之间的关系。
假设函数A是__stdcall,函数B调用函数A。你必须通过函数声明告诉编译器,函数A是__stdcall。编译器自然会产生正确的调用代码。
如果函数A是__stdcall。但在引用函数A的地方,你却告诉编译器,函数A是__cdecl方式,编译器产生__cdecl方式的代码,与函数A的调用约定不一致,就会发生错误。
以delphi调用VC函数为例,delphi的函数缺省采用__pascal约定,VC的函数缺省采用__cdecl约定。我们一般将VC的函数设为__stdcall,例如:
- int __stdcall add(int a, int b);
在delphi中将这个函数也声明为__stdcall,就可以调用了:
-
function add(a: Integer; b: Integer): Integer;
stdcall; external 'a.dll';
因为考虑到可能被其它语言的程序调用,不少API采用__stdcall的调用约定。
上文的original link:http://www.fmddlmyy.cn/text12.html
补充:
???
- ret // 跳转到esp所指地址,并将esp+4,使esp指向进入函数时的第一个参数
这是堆栈布局的问题
一般遍历局部变量和参数 都使用ebp
ebp+一般是访问参数
ebp-是访问局部变量
call 进去后
一般会
push ebp
mov ebp, esp
这时ebp指向栈顶 也就是保存esp的位置
所以ebp+4就是返回地址
ebp+8就是第一个参数
这个返回地址怎么使用的?
在call调用的时候
他首先会把eip+5的地址压入到堆栈
然后才是jmp 目标地址
等你ret的时候
它会从当前esp指向的堆栈取出4字节当做返回地址 然后jmp过去
为什么要加+5,一个指令字不是32位吗?
谁跟你说一个指令5个字节的?
虽然现在是精简指令集
但也不是5个啊
为什么eip+5?
intel 的iat32手册上有介绍
opcode
call 00xxxxx
一般是e8 xxxxxx4 字节
所以指令一共是5字节 返回地址就是call处的eip+5
mov edi,edi这种都属于保留或对齐的
像windows api 都是
mov edi,edi
push ebp
mov ebp,esp
其它:
void _stdcall std_call(int x,int y)
{
}
void _cdecl cdecl_call(int x,int y)
{
}
void _fastcall fast_call(int x,int y)
{
}
void main()
{
int a=1,b=2;
std_call(a,b);
cdecl_call(a,b);
fast_call(a,b);
}
1调用std_call(int x,int y)片段
mov eax, dword ptr [ebp-8] //[ebp-8]=2 b=2
push eax
mov ecx, dword ptr [ebp-4] //[ebp-4]=1 a=1
push ecx
call 0040100A //std_call
从上可以看出std_call的参数是通过堆栈传递,并且参数入栈顺序是从右到左
std_call(int x,int y)
{
.....
....
retn 8
}
retn 8 相当于esp+Ch 使堆栈恢复到这次函数调用之前的状态,所以stdcall是由被调用者平衡堆栈
2 调用_cdecl cdecl_call(int x,int y)片段
mov edx, dword ptr [ebp-8]
push edx
mov eax, dword ptr [ebp-4]
push eax
call 00401014
add esp, 8
_cedel参数也是通过堆栈传递,入栈顺序是从右到左,add esp, 8 由调用者平衡堆栈
3调用_fastcall fast_call(int x,int y)片段
00401133 |. 8B55 F8 mov edx, dword ptr [ebp-8]
00401136 |. 8B4D FC mov ecx, dword ptr [ebp-4]
00401139 |. E8 C7FEFFFF call 00401005
_fastcall的参数是寄存器传递 参数顺序传递顺序是从右到左 由被调用者平衡堆栈