汇编语言基础之六- 调用栈和各种调用约定的总结对比
调用栈
栈这个概念在数据结构中有详细的讲解,就不罗嗦了。
列出一些要点:
1. 先入先出。
2. 永远只能从栈的最上方存或取数据。
在x86处理器中,压栈的指令时PUSH。将一个item压入栈顶会导致栈顶指针减小4个字节。栈顶指针用寄存器ESP来存储, 相应的,这个寄存器的名字是Stack Pointer的缩写。
压栈
压栈时,会依次发生下面的事:
1. 栈顶指针ESP减小4个字节。
2. 要压入栈中的数据被拷贝到ESP指向的地址中。
可以看出,栈是向低地址端增长的,也就是说栈里的内容越多,栈顶指针就越小。
出栈
出栈时,会一次发生下面的操作:
1. 栈顶指针(ESP)指向的栈中的数据被取回。
2. 栈顶指针增加4个字节。
这样,栈顶指针ESP就总是指向下一个栈中可用的数据了。
下图中,我们依次向栈中压入A,B,C三个数据,栈中情况如图所示。
线程栈
在Win32中,每当创建一个线程,就有一兆字节(一百万个,一千个一千,别拍我,我不啰嗦了)的虚拟内存被当做栈空间而保留,供这个线程使用。ESP寄存器指向这块保留内存的最顶部(地址值最大的那一端),这样栈就被初始化完毕了。要检验当前栈指针的值,可以在debugger中使用‘r’命令。ESP寄存器的值,就是栈指针的值。
嵌套函数调用
程序很少是一个巨长无比的单个函数。一般都是不同的函数负责不同的功能,在需要的时候会被主函数调用到。在前面的汇编语言基础之五的例子中,很好的表现了这一情况。那么改程序是如何做到调用一个函数,然后回来继续执行的呢?文章中已经做了详细的分析和代码注释。
调用约定
如果你写的代码使用的是同一种语言,并且使用同一种编译器,那么调用约定这个说法对于你来说没啥用处。如果是你用多种语言,不同的编译器,那么处理不同语言的标准的问题就出现了。为了让程序员能够处理多语言混合编程的情况,牛人们建立了一些用来指定如何调用函数和传递参数的规则。这些规则叫做调用约定。
比如,一个程序员用Pascal语言写了一个名为Sqrt的函数。该函数带有一个浮点型的参数,返回参数的平方根。
function Sqrt(NumArgument: real) : real;
C语言的程序员需要知道Pascal程序员期望如何传递那个NumArgument参数给Sqrt函数。要处理这样的混合语言编程,Pascal语言定义了Pascal调用约定。这个约定就是我们经常看到的STDCALL,而术语PASCAL已经过时了,不再使用了。
开始和结束代码都被编译器自动添加,用以保存寄存器,设置栈底,和在调用结束时恢复栈的状态。这两部分代码跟CPU架构和编译器相关。理解这部分代码对于理解如何debug非常重要。下面的例子展现了使用STDCALL调用约定时,自动产生出来的开始和结束代码。
典型被调用函数的开始代码(prolog)
push |
ebp |
保存栈底(Frame Pointer) |
mov |
ebp, esp |
修改栈底为当前栈顶的位置 |
sub |
esp, 8 |
栈顶向上移动八个字节,用来保存局部变量 |
push push push |
ebx ecx edx |
保存寄存器中的值 |
典型的被调用者的结束代码(epilog)
pop
pop pop |
edx
ecx ebx |
恢复寄存器的值 |
mov |
esp, ebp |
恢复栈顶的指针,让它指向栈底。这样局部变量占用的空间就被释放了。 |
pop |
ebp |
恢复EBP(Frame Pointer) |
ret |
0x8 |
栈顶向下移动8个字节,来释放栈中压入的函数参数。(结合下面ret指令的说明再想想) |
有些函数需要在调用另一个函数时先保存一些寄存器的值,以便恢复调用之后继续使用它们。栈是个暂时保存寄存器的好地方,所以开始代码会做一些额外的压栈操作来保存一些以后需要用到的寄存器的值。
上面的图已经很清楚了,结合这个图再仔细的看一下RET指令的说明,就会更清楚RET指令的每一个含义了。
RET∶ 指令助记符——返回。
一、段内返回。先将栈顶的字送入IP,然后SP增2 。若带立即数,SP再加立即数(丢弃一些在执行CALL之前入栈的参数)。
二、段间返回。栈顶的字送入IP后(SP增 2),再将栈顶的字送入CS,SP再增2 。若带立即数,则SP再加立即数。
在本系列文章的第一篇就说明了Win32中使用平面寻址,用不着段寄存器的。不过出于完整性考虑,还是将过时的段寄存器表列在下面。
1, 代码段寄存器CS:存放当前正在运行的程序代码所在段的段基值,表示当前使用的指令代码可以从该段寄存器指定的存储器段中取得,相应的偏移值则由IP提供。
2, 数据段寄存器DS:指出当前程序使用的数据所存放段的最低地址,即存放数据段的段基值。
3, 堆栈段寄存器SS:指出当前堆栈的底部地址,即存放堆栈段的段基值。
4, 附加段寄存器ES:指出当前程序使用附加数据段的段基址,该段是串操作指令中目的串所在的段。
STDCALL调用约定
该调用约定有如下的特点:
*参数自右向左入栈
*被调用者负责清理栈
*函数命名用下划线开头
*函数命名之后加一个@符号,再紧跟参数的尺寸
*函数的命名过程没有大小写的转换
不能处理带有可变参数的函数
调用者职责
先压栈最右边的参数
再压下一个
最后压最左边的参数
调用函数functionX
被调用函数的职责
push ebp
mov ebp, esp
sub esp, local_size
…
mov esp, ebp
pop ebp
ret 参数的字节数
CDECL调用约定
该调用约定有如下的特点:
*参数自右向左传递
*调用者负责清理栈
*函数命名用下划线开头
*不进行大小写转换。
所以,名为FunctionCall的函数,在符号表中被记录为_FunctionCall
*可以处理可变数目参数的函数。
*是C和C++程序默认的调用约定。
这个说法正确么?
由于参数按照从右向左顺序压栈,因此最开始的参数在最接近栈顶的位置,因此当采用不定个数参数时,第一个参数在栈中的位置肯定能知道,只要不定的参数个数能够根据第一个后者后续的明确的参数确定下来,就可以使用不定参数。
分析如下:
如果仅仅是因为压栈顺序的原因,那么stdcall也是从右向左压栈呀,为什么stdcall就不能处理可变参数呢?stdcall和cdecl最重要的区别就是谁去清理栈中参数。stdcall是由被调用函数清除参数的,而cdecl是由调用者清除的。所谓调用约定,就是调用者和被调用者之间约定好的对函数调用栈的一种职责分配。
MSDN解释cdecl可以处理可变参数的函数调用就一句话:Because the stack is cleaned up by the caller, it can do vararg functions. 看来cdecl能实现可变参数并不是由于参数压栈的顺序。
我们先来弄清一个问题,实现可变参数的难度在哪里?是如何得到参数的类型和个数么?
调用者肯定知道有多少个参数要传,确切的说是知道要传的参数的尺寸是多大,调用者将他们依次压入了栈。被调用者接手控制的时候,如何知道参数的尺寸和个数呢?正如上面所说,第一个参数可以得到,并且知道类型,可以访问。如同printf函数,第一个参数中有%d,%f之类的后继参数指示符。通过它们,就可以得到后面的参数的类型和个数了,参数的类型和个数都知道了,所有参数的尺寸也可以知道了。得到参数的类型和个数取决于函数的实现。stdcall和cdecl的被调用者总是能得到参数的尺寸和个数的。这与谁去清理栈无关。注意编译后的函数是没有代码去计算传递给它的参数的尺寸的。
还是没有回答那个问题,实现可变参数的难度在哪里?
假设在stdcall的情况下,含有可变参数的被调用函数顺利的得到了所有的参数,完成了操作,该返回了可是并没有去计算,也没有一个地方记录了它接受的栈的空间是多大。他不知道该如何去清理栈。而调用者又不负责清理栈。所以产生了麻烦。
cdecl的调用者负责清理栈,调用者是知道它传了多大的参数的,它可以顺利的清理栈,让程序继续运行下去。
本段参考文章:http://blog.csdn.net/ZhouHM/archive/2004/04/07/14721.aspx
http://hi.baidu.com/dtzw/blog/item/cc17ba119eb39374cb80c4eb.html
调用者职责
先压栈最右边的参数
再压下一个
最后压最左边的参数
调用函数functionX
增加esp,幅度为参数的尺寸
被调用函数的职责(functionX)
push ebp
mov ebp, esp
sub esp, local_size
…
mov esp, ebp
pop ebp
ret
FASTCALL调用约定
这种调用约定表示:当可能的时候,参数会被放到寄存器中。因为并不是所有的参数都有压栈操作,所以要比stdcall和cdecl要快一些,所以才叫fastcall吧。呵呵。
该调用约定有如下的特点:
*参数从右至左传递
*被调用函数清理栈中的参数
*函数名前加@前缀,之后再紧跟一个@,在加上参数的字节数,格式为@name@number
*函数名的大小写不被转换
*不能处理可变参数的函数
调用者职责
将最右面的参数压栈
将下一个参数压入EDX
将第一参数压入ECX
调用FunctionX
被调用者指责(FunctionX)
push ebp
mov ebp, esp
sub esp, local_size
Do Function Processing...
mov esp, ebp
pop ebp
ret <number_of_pushed_arguments>;注意,如果参数有两个的时候,这里的ret指令就不会带立即数参数,因为没有参数被压栈。
THISCALL调用约定
这是C++中拥有固定数目参数的成员函数的,默认的调用约定。该调用约定是不能被指定的,因为THISCALL并不像STDCALL,CDECL一样,它不是个关键字。THISCALL调用约定中,this指针通过ECX寄存器来传递给被调用的函数。
NAKED函数
声明了naked属性的函数,其中没有prolog或者epilog代码被插入。这样,你就可以用inline assembler来写你自己的prolog或者epilog指令序列了。Naked函数是以高级特性被提供的。他们允许你声明这样的函数- 函数处于非C或C++的上下文之中(不是C或C++的函数),而且参数放在什么地方也没有约定好,还有哪些寄存器被保留了也不清楚。总之,全靠程序员自己来处理了。
适用于写一些与已经存在了的,使用汇编语言写好的系统沟通的C语言程序。
什么是inline assembler? 摘自wikipedia
In computer programming, the inline assembler is a feature of some compilers that allows very low level code written in assembly to be embedded in a high level language like C or Ada.
比较几种调用约定
项目\调用方式 | __stdcall(Win32) | __cdecl | __fastcall | thiscall(Native C++) | COM | __declspec(naked)(__declspec是微软的关键字,其他系统上可能没有) |
参数压栈顺序 | 从右至左 | 从右至左 | 从右至左,
Arg1在ecx, Arg2在edx |
从右至左,
This指针在ecx |
从右至左,
最后压入this指针 |
程序员自定义 |
参数位置 | 栈 | 栈 | 栈 + 寄存器 | 栈,寄存器ECX | 程序员自定义 | |
清除栈中参数的函数 | 被调用者 | 调用者 | 被调用者 | 被调用者 | 被调用者 | 程序员自定义 |
支持可变参数 | 否 | 是 | 否 | 否 | 否 | 程序员自定义 |
函数名字格式 | _name@number | _name | @name@number | 程序员自定义 | ||
函数名举例 | _NtWaitForSingleObject@12 | _printf | @AfpFsdDispCloseVol@4 | 程序员自定义 | ||
大小写转换 | 没有 | 没有 | 没有 | 没有 | 程序员自定义 | |
C++编译时函数名修饰约定的不同之处 |
函数名后面以"@@YG"标识参数表的开始,后跟参数表; |
参数表的开始标识为"@@YA"; | 参数表的开始标识为"@@YI"。 | 程序员自定义 |
练习:
1. 问:prologue和epilogue代码都是做什么的?
答:开场和收尾代码是由编译器自动产生的代码,用于帮助程序在执行的过程中保存寄存器,建立栈框架,和在函数调用结束后恢复栈。
2. 问:说说看都有哪些种calling convention?
答:STDCALL, CDECL, FASTCALL, THISCALL, NAKEDFUNCTION。
3. 哪种调用约定依赖于被调用的函数来清理栈上的参数?
答:stdcall, fastcall, thiscall.
4. 用C++写好的应用程序一般使用哪种调用约定?
答:cdecl和thiscall
5. Intel平台上的THIS指针一般通过那个寄存器来传送?
答:ECX。