原文地址: http://www.unixwiz.net/techtips/win32-callconv-asm.html
从汇编角度看英特尔x86函数调用规范
在阅读编译好的C代码中,一个大问题就是函数调用规范。调用规范是调用函数(caller)与被调用函数(callee)对他们之间如何传递参数和返回值,以及如何使用堆栈所达成的协议。堆栈的布局构成栈帧(stack frame),知道栈帧如何工作可以很好的帮助解析一些事情的工作方式。
此文站在低级语言的视角,因为站在C/C++程序员角度的描述到处都是:如Unixwiz.net Tech Tip: Intel x86 Function-call Conventions - C Programmer's View http://www.unixwiz.net/techtips/win32-callconv.html
为了讨论的方便,我们使用微软VC编译器中使用的术语来描述这些规范,尽管其它平台可能使用其它术语。
__cdecl (读作:see-Deck-'ll, 与"heckle"押韵)
这个规范是最普遍的,因为它支持C语言所要求的语义。C语言支持可变参数的函数(如printf),这意味着函数调用者必须在ccallee返回之后清理堆栈:callee无法知道如何做这一项工作。尽管这并不是最理想的,但这是C语言的语义所要求的。
__stdcall
又写为__pascal。这个规范要求每个函数拥有固定数量的参数,这意味着被调用函数可以在一个地方对参数进行清理,即在被调用函数内部进行堆栈参数的清理,而不是分散在每一次调用该函数的代码中。Win32 API主要使用__stdcall。
值得注意的是以上只是定义的规范,任何协作的代码集也可以达成任何其它规范。有一些规范(如使用寄存器来传递参数)的工作方式和这两者是完全不一样的,并且一些优化也会彻底打乱调用规范。
这里我们旨在提供一个概览,而并不是对这些规范的权威定义。
栈帧
(stack frame)
中的寄存器使用
在__cdecl和__stdcall规范中,函数调用帧中涉及到相同的三个寄存器:
%ESP - Stack Pointer
这个32位的寄存器值可以被一些CPU指令隐式的改变(PUSH,POP,CALL,以及RET),它总是指向堆栈上使用部分的末尾单元(不是第一个可使用的单元);这意味着PUSH和POP操作可以用以下的伪C代码来定义:
*--ESP = value; // push
value = *ESP++; // pop
%EBP - Base Pointer
这个32位寄存器用来指向当前栈帧中的函数参数和局部变量。不像寄存器%esp的值可以隐式改变,%ebp值只能显式改变。这个寄存器常常被称为“堆栈指针”
%EIP - Instruction Pointer
这个寄存器保存了下一条将被执行的CPU指令的地址, 它是在CALL指令执行的时候保存到堆栈上的。任何跳转类指令都直接修改EIP的值。
汇编符号
事实上在Intel汇编世界的人们使用Intel汇编符号,但GNU C编译器为了向后兼容而使用AT&T汇编语法。对我们来说这不是一个好主意,但这就是事实。
这两种语法之间有很多区别,其中最让人烦恼的是AT&T语法颠倒源操作数和目标操作数。给EAX赋予立即数4的示例:
mov $4, %eax //AT&T 语法
mov eax, 4 //Intel 语法
最近GNU编译器也可以生成Intel语法的汇编,但是不清楚GNU汇编器是否认识。无论如何,这里使用Intel语法汇编。
调用
__cdecl
函数过程
为了理解函数调用中栈的变化,最好观看函数调用中一步步的变化。这些步骤是由编译器自动完成的。当然有些特例中有变化(没有参数,没有局部变量,没有保存寄存器)。一下是调用过程:
1)将参数从右到左压入堆栈:参数从右到左依次压入堆栈,每次压入一个。调用者(caller)必须明确有多少Byte的参数,以便函数返回后清理掉。
2)调用函数: 处理器将下一条指令的EIP(即函数返回值)内容压入堆栈,同时EIP设置成被调函数的地址。这步完成之后,控制权交给Callee。到此时为止,%EBP不发生任何变化。
3)保存和更新%EBP: 现在,我们进入了新的函数(callee),需要一个局部栈帧, 使用%EBP指向新的栈帧, 老的%EBP值(属于caller的栈帧)保存在堆栈上, %EBP指向新的栈顶。
push ebp
mov ebp, esp // ebp <- esp
然后可以通过%EBP访问函数参数,如8(%ebp),12(%ebp). 注意0(%ebp)是caller的%EBP值,4(%ebp)是函数返回值.
4)分配局部变量: 函数可以使用堆栈空间来存放局部变量,直接减去%ESP值就相当于分配了堆栈空间. 分配是按照4Bytes对齐.
此时,局部变量在%ebp和%esp之间. 虽然通过%ebp和%esp都可以访问局部变量,但是约定(convension)使用%ebp寄存器来访问,所以 -4(%ebp)代表第一个局部变量.
5)保存要使用的寄存器: 如果这个函数要使用一些寄存器, 需要先保存这些寄存器的值,这些值将被保存在堆栈上,compiler需要记录下保存的顺序,以便之后恢复.
6)执行函数的功能: 此时,栈帧已经设置好
所有的参数和局部变量都通过%ebp的偏移来访问.
16(%ebp) :第三个参数
12(%ebp) :第二个参数
8(%ebp) :第一个参数
4(%ebp) :函数返回值
0(%ebp) :老的%EBP(caller的%EBP)
-4(%ebp) :第一个局部变量
-8(%ebp) :第二个局部变量
-12(%ebp):第三个局部变量
函数中可以自由使用任何已经保存过的寄存器, 但是堆栈指针(%esp)不能改变.
7)释放局部空间: 第四步中函数通过减去%esp来分配局部的临时空间,这里是一个相反的过程,通常通过给%esp加上减去的值来实现,一系列POP指令也能达到相同的效果.
8)恢复保存的寄存器: 第五部中保存的寄存器值,在这里按照相反顺序恢复.
9)恢复老的%ebp: 恢复第三步中保存的%ebp, 当前的栈帧也就被丢弃掉.
10)函数返回:这是callee的最后一步, RET指令从堆栈中弹出%EIP并跳转到那里.控制权重新交回给caller. Ret指令只修改%esp和%eip.
11)清理压入的参数: 在__cdecl约定中,caller负责清理堆栈上的参数,和第七步中类似,可以通过POP指令,也可以通过直接加%esp
__cdecl -vs- __stdcall
__stdcall约定主要被Windows API使用. 它比__cdecl更简洁一些. 主要区别是对于__stdcall任何函数的参数是不可变得(hard-coded).
正由于参数数量是固定的,清理堆栈参数的工作就可以由caller转交给callee.这就导致以下几点影响:
1.代码变得稍微少一些, 这是因为参数清理工作只在一处--在被调函数中(callee)--而不是任何调用函数的地方. 这大概只有几个bytes,但是对于广泛使用的函数,迭加起来就比较多了. 可以推测这样的code执行也会快那么一点点.
2.在调用函数的时候,如果准备了错误数量的参数,结果会是灾难性的,因为堆栈将不再对齐.
3.作为第二点的补充, Microsoft Visual C 给予__stdcall特别的关注. 由于调用参数数量在编译期可知, 编译器将参数数量编码进符号名(symbol name)中,这就意味着错误参数的函数调用将导致链接错误.
举例来说,函数 int foo(int a, int b)将生成符号 "_foo@8", 这里"8"就是期望的参数所占byte数, 这意味着不仅是1个或者3个参数的调用会失败(由于大小不匹配),而且__cdecl方式编译的函数调用也不会去选择调用__stdcall方式编译的函数(__cdecl需要寻找的是类似_foo的符号).这是一个聪明的机制,可以避免不少问题.
x86架构提供了一些内建的机制来帮助栈帧的管理,但是它们并没有被普遍使用在c编译器中.其中特别有趣的是ENTER指令, 它可以作为大部分函数的前缀.
ENTER 10,0 == PUSH ebp
MOV ebp, esp
SUB esp, 10
可以肯定,从功能上来说,上述两者是等价的, 80386处理器文档告之,ENTER更加简洁(6 bytes -vs- 9)*,但是更慢(15cycles -vs - 6). [*注:这里原文有错误,ENTER占3bytes,而另三条指令的实现占6bytes]