调用约定
函数调用约定(Calling Convention),是一个重要的基础概念,用来规定调用者和被调用者是如何传递参数的,既调用者如何将参数按照什么样的规范传递给被调用者。
在参数传递中,有两个很重要的问题必须得到明确说明:
1.当参数个数多于一个时,按照什么顺序把参数压入堆栈;
2.函数调用后,由谁来把堆栈恢复原装。
假如在C语言中,定义下面这样一个函数:
int func(int x,int y, int z)
然后传递实参给函数func()就可以使用了。但是,在系统中,函数调用中参数的传递却是一门学问。因为在CPU中,计算机没有办法知道一个函数调用需要多少个、什么样的参数,也没有硬件可以保存这些参数。也就是说,计算机不知道怎么给这个函数传递参数,传递参数的工作必须由函数调用者和函数本身来协调。为此,计算机用栈来支持参数传递。
函数调用时,调用者依次把参数压栈,然后调用函数,函数被调用以后,在堆栈中取得数据,并进行计算。函数计算结束以后,或者调用者、或者函数本身修改堆栈,使堆栈恢复原装。
在高级语言中,通过函数调用约定来说明参数的入栈和堆栈的恢复问题。常见的调用约定有:
l stdcall
l cdecl
l fastcall
l thiscall
l naked call
不同的调用规约,在参数的入栈顺序,堆栈的恢复,函数名字的命名上就会不同。在编译后的代码量,程序执行效率上也会受到影响。
7.9.1 stdcall调用规约
stdcall调用约定声明的格式:
int __stdcall func(int x,int y)
stdcall的调用约定意味着:
参数入栈规则:参数从右向左压入堆栈
还原堆栈者:被调用函数自身修改堆栈
函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸。
在微软Windows的C/C++编译器中,常常用Pascal宏来声明这个调用约定,类似的宏还有WINAPI和CALLBACK。
int __stdcall func1(int x, int y)
{
return x+y;
}
int __stdcall func1(int x, int y)//采用stdcall
{
42D640 push ebp
0042D641 mov ebp,esp
0042D643 sub esp,
0042D649 push ebx
0042D
0042D64B push edi
0042D
0042D652 mov ecx,30h
0042D657 mov eax,0CCCCCCCCh
0042D
return x+y;
0042D65E mov eax,dword ptr [x]
0042D661 add eax,dword ptr [y]
}
0042D664 pop edi
0042D665 pop esi
0042D666 pop ebx
0042D667 mov esp,ebp //ebp(调用前的栈顶)放入esp中,然后出栈,恢复老ebp
0042D669 pop ebp
0042D
7.9.2 cdecl调用规约
cdecl调用约定又称为C调用约定,是C语言缺省的调用约定,它的定义语法是:
int func (int x ,int y) //默认的C调用约定
int __cdecl func (int x,int y) //明确指出C调用约定
该调用约定遵循下面的规则:
参数入栈顺序:从右到左
还原堆栈者:调用者修改堆栈
函数名:前加下划线:_func
由于每次函数调用都要由编译器产生还原堆栈的代码,所以使用__cdecl方式编译的程序比使用__stdcall方式编译的程序要大很多,但是 __cdecl调用方式是由函数调用者负责清除栈中的函数参数,所以这种方式支持可变参数,比如printf()和Windows的API wsprintf()就是__cdecl调用方式。
由于参数按照从右向左顺序压栈,因此最开始的参数在最接近栈顶的位置,因此当采用不定个数参数时,第一个参数在栈中的位置肯定能知道,只要不定的参数个数能够根据第一个后者后续的明确的参数确定下来,就可以使用不定参数了。
int __cdecl func2(int x, int y)
{
return x+y;
}
11: int __cdecl func2(int x, int y)
12: {
00401070 55 push ebp
00401071 8B EC mov ebp,esp
00401073 83 EC 40
sub esp,40h
00401076 53 push ebx
00401077 56 push esi
00401078 57 push edi
00401079 8D 7D C0 lea edi,[ebp-40h]
00401081 B8 CC CC CC CC mov eax,0CCCCCCCCh
13: return x+y;
00401088 8B 45 08 mov eax,dword ptr [ebp+8]
0040108B 03 45
14: }
0040108E
00401090 5B pop ebx
00401091 8B E5 mov esp,ebp
00401093 5D pop ebp
cdecl与stdcall的调用栈示意图
7.9.3 fastcall调用规约
fastcall的声明语法为:
int fastcall func (int x,int y)
该调用约定遵循下面的规则:
参数入栈顺序:函数的第一个和第二个参数通过ecx和edx传递,剩余参数从右到左入栈
还原堆栈者:被调用者修改堆栈
函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸
以fastcall声明执行的函数,具有较快的执行速度,因为部分参数通过寄存器来进行传递的。
int __fastcall func3(int x, int y,int z)
{
return x+y+z;
}
16: int __fastcall func3(int x, int y, int z)
17: {
004010AA 8D 7D B8 lea edi,[ebp-48h]
004010AD B9 12 00 00 00
mov ecx,12h
004010B2 B8 CC CC CC CC
mov eax,0CCCCCCCCh
004010B
004010B9 59 pop ecx
004010BA 89
004010BD 89 4D FC mov dword ptr [ebp-4],ecx
18: return x+y+z;
19: }
004010CA 5E pop esi
004010CB 5B pop ebx
004010CC 8B E5 mov esp,ebp
004010CE 5D
pop ebp
004010CF C2 04 00 ret
4 ;返回时,被调用者做栈平衡,x,y在寄存器中,所以清空4个字节
注意,在X64平台,默认使用了fastcall调用约定,其规则如下:
1,一个函数在调用时,前四个参数是从左至右依次存放于RCX、RDX、R8、R9寄存器里面,剩下的参数从右至左顺序入栈;
2,浮点前4个参数传入XMM0、XMM1、XMM2 和 XMM3 中。其他参数传递到堆栈中。
3,调用者负责在栈上分配32字节的“shadow
space”,用于存放那四个存放调用参数的寄存器的值(亦即前四个调用参数);小于64位(bit)的参数传递时高位并不填充零(例如只传递ecx),大于64位需要按照地址传递;
4,调用者负责栈平衡;
6,RAX,RCX,RDX,R8,R9,R10,R11是“易挥发”的,不用特别保护(所谓保护就是使用前要push备份),其余寄存器需要保护。(x86下只有eax,
ecx, edx是易挥发的)
7,栈需要16字节对齐,“call”指令会入栈一个8字节的返回值(注:即函数调用前原来的RIP指令寄存器的值),这样一来,栈就对不齐了(因为RCX、RDX、R8、R9四个寄存器刚好是32个字节,是16字节对齐的,现在多出来了8个字节)。所以,所有非叶子结点调用的函数,都必须调整栈RSP的地址为16n+8,来使栈对齐。比如sub
rsp,28h
8,对于 R8~R15 寄存器,我们可以使用 r8, r8d, r8w, r8b 分别代表 r8 寄存器的64位、低32位、低16位和低8位。
X64调用栈示意图
使用了上述调用约定的main函数对应汇编分析:
int main(int argc, char* argv[])
{
func1(1, 2);
func2(1, 2);
func3(1, 2);
printf("Hello World!\n");
return 0;
}
//func1采用stdcall,参数2,1从右往左,依次入栈:先push 2,再push 1
//栈平衡由被调用者自己完成
24: func1(1, 2);
004010FA
004010FC E8 1D FF FF FF call @ILT+25(func1) (0040101e)
//func2采用cdecl,参数从右往左依次入栈,先push 2,再push 1
//栈平衡由调用者负责完成:add esp, 8
25: func2(1, 2);
00401101
00401103
00401105 E8
//func3采用fastcall,参数前2个进入ecx和edx,剩下的从右往左依次入栈
//栈平衡由被调用者完成
26: func3(1, 2, 3);
0040110D
00401114 B9 01 00 00 00 mov ecx,1;第一个参数入ecx
00401119 E8
7.9.4 naked
call调用规约
这是一个不常用的调用约定,编译器不会给这种函数增加初始化和清理代码,也不能用return语句返回值,只能程序员控制,插入汇编返回结果。因此它一般用于驱动程序设计,比如inline hook等。假设定义一个求减的减法程序,可以定义为:
__declspec(naked) int sub(int a,int b)
{
__asm mov eax,a
__asm sub eax,b
__asm ret
}
上面讲解了函数的各种调用规约。那么如果定义的约定和使用的约定不一致,会出现什么样的问题呢?结果就是:则将导致栈被破坏。最常见的调用规约错误是:
1. 函数原型声明和函数体定义不一致
2. DLL导入函数时声明了不同的函数约定
此外,thiscall是C++类成员函数缺省的调用约定,但它没有显示的声明形式。因为在C++类中,成员函数调用还有一个this指针参数,因此必须特殊处理,thiscall意味着:
参数入栈:参数从右向左入栈
this指针入栈:如果参数个数确定,this指针通过ecx传递给被调用者;如果参数个数不确定,this指针在所有参数压栈后被压入堆栈。
堆栈恢复:对参数个数不定的,调用者清理堆栈,否则函数自己清理堆栈。
7.9.5 C语言活动记录(栈帧)
下面来研究C语言的活动记录,即它的栈帧。所谓的活动记录,就是在程序执行的过程中函数调用时栈上的内容变化。一个函数被调用,反映在栈上的与之相关的内容被称为一个帧,其中包含了参数,返回地址,老ebp值以及局部变量,以及esp,ebp。
图就是程序执行时的一个活动记录。C语言的默认调用规约为cdecl。因此C语言的活动记录中,参数是从右往左依次入栈。之后是函数的返回地址入栈,接着是ebp入栈。
图7
图7-5非常重要,建议读者朋友们一定要对该图做到胸有成竹。可以用图7-5来分析很多实际问题。比如,可以用ebp+8取得第一个参数,然后依次取得第二个,第三个,第N个参数。也可以通过ebp-N来获得栈中的局部变量。
1.分析下面程序运行情况,有什么问题呢?
1 #include <stdio.h>
2 void main(void)
3 {
4 char
x,y,z;
5 int
i;
6 int
a[16];
7 for(i=0;i<=16;i++)
8 {
10 printf("\n");
11 }
12 return
0;
13 }
在分析程序执行时,一个重要的方法就是首先画出它的活动记录。根据它的活动记录,去分析它的执行。对于本题的问题,画出了图7-6的活动记录。
图7-6 程序活动记录
结合该活动记录,通过对程序的执行分析,for循环中对数组的访问溢出了。那么溢出的后果是什么呢?通过图7-6 的活动记录,大家可以看出a[16]实际上对应的是变量i。因此循环的最后一次执行的时候,实际上a[16] = 0就是将i值重新设为了0,于是i永远也不会大于16。因此整个程序中for循环无法退出,程序陷入死循环。
2.考虑下面的C程序:
void main(void)
{
char *cp1, *cp2;
cp1 = "12345";
cp2 = "abcdefghij";
strcpy(cp1, cp2);
printf("cp1 = %d\ncp2 = %s\n", cp1, cp2);
}
该程序经某些C编译器的编译,其目标程序运行的结果是:
cp1 = abcdefghij
cp2 = ghij
试分析,为什么cp2所指的串被修改了?
答案:因为常量串"12345"和"abcdefghij"连续分配在常数区,执行两个赋值语句后,cp1和cp2分别等于字符1和a所在的地址。如图7-7所示:
图7-7 字符串拷贝时内存布局
执行串拷贝语句后,常数区变成图中所示,cp1和cp2所指的地址没有变,所以cp2所指的串被修改了。
3.一个C语言程序如下:
void func(void)
{
char s[4];
strcpy(s, "12345678");
printf("%s\n", s);
}
void main(void)
{
func();
printf("Return from func\n");
}
该程序在X86/Linux操作系统上运行的结果如下:
12345678
Return from func
Segmentation fault(core dumped)
试分析为什么会出现这样的运行错误。
答案:func()函数的活动记录如图7-8所示。在执行字符串拷贝函数之后,由于”
图7-8
func()函数的活动记录
4.下面程序在SPARC/SUN工作站(整数存放方式是高位优先)上运行陷入死循环,试说明原因。如果将第7行的long *p改成short *p,并且将第22行long k改成short k后,loop中的循环体执行一次便停止了。试说明原因。
void main(void)
{
addr();
loop();
}
long *p;
void loop(void)
{
long i,
j;
j = 0;
for (i = 0; i < 10; i++)
{
(*p)--;
j++;
}
}
void addr(void)
{
long k;
k = 0;
p =& k;
}
分析:
首先变量p是一个全局变量,因此在程序执行期间都有效。然后画出了addr() 和loop()的活动记录如图7-9所示。由addr()和loop()的活动记录,可以看出,p在add()执行结束之后,在loop()执行之时指向了i。
图7-9
addr()和loop()活动记录
当第一次执行循环时,i=0,p的类型为long *,(*p)--之后i为-1,之后i++,i为0。所以i的值在0与-1之间变化,但始终小于10,因此无法退出循环而造成了死循环。
而当p的类型为short*,k为short类型时,p指向了i的高2字节。(*p)--运算前后i的值的情况如图7-10和7-11所示:
图7-10
(*p)--之前i的值
图7-11
(*p)--之后i的值
由于此时p 为short*类型,所以只有i的高2字节参加了运算,此时(*p)--后,i的高2字节为-1,即0xffff。所以对于long类型的i来说,由于系统是高位优先存储整数,那么它的值为:0x0000ffff,即4294901760,远远大于了10。因此循环执行了一次便停止了。
综上分析,程序运行时陷入死循环的原因是由于p指向分配给i的存储单元引起的。循环体执行一次便停止是由于p指向分配给i的高位引起的。可见,要解答好此题,必须要牢固掌握C语言的活动记录和整数的存储方式。
5.试分析下面程序在X86平台的输出结果:
//限定x86平台
#include "stdafx.h"
#include <windows.h>
#include <string.h>
int _tmain(int argc, _TCHAR* argv[])
{
int x=5;
float y=
printf("int
x=%d,float x=%f,y=%f,y=%d\n",x,x,y,y);
return
0;
}
输出为:int x=5,float x=-2.000000,y=-2.000000,y=1074318540
为什么y被赋值为3.1f,打印的结果却是-2.000000呢?
分析请参考:函数调用约定解释诡异程序输出