从汇编角度看英特尔x86函数调用规范

 

原文地址: 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]
 
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
函数调用过程是程序中常见的一种操作,它通常涉及到参数传递、栈帧的建立与销毁、返回值的传递等多个方面。从汇编的角度来看,函数调用过程可以分为以下几个步骤: 1. 将函数的参数压入栈中。在调用函数时,需要将函数所需的参数传递给它。这些参数通常以一定的顺序压入栈中,以便在函数内部使用。在 x86 架构中,参数的传递是通过将参数压入栈顶实现的。 2. 调用函数函数调用的指令通常是 CALL 指令。在调用函数前,需要将函数的入口地址压入栈中,以便在函数执行完毕后返回到调用位置。CALL 指令会将当前的程序计数器(PC)压入栈中,并将函数的入口地址作为新的 PC。 3. 建立栈帧。在函数被调用时,需要为函数建立一个独立的栈帧,以便在函数内部使用局部变量和临时变量。栈帧通常包括以下几个部分:返回地址、旧的基址指针、局部变量和临时变量。在 x86 架构中,栈帧的建立是通过将 ESP 寄存器减去一个固定的值实现的。 4. 执行函数。在函数被调用后,CPU 会跳转到函数的入口地址并开始执行函数函数内部可以通过栈中的参数和局部变量完成相应的计算和操作。 5. 返回值传递。在函数执行完毕后,需要将函数的返回值传递给调用者。在 x86 架构中,函数的返回值通常通过 EAX 寄存器传递。 6. 销毁栈帧。在函数执行完毕后,需要将栈帧销毁,以便释放栈空间。栈帧的销毁通常是通过将 ESP 寄存器还原到旧的基址指针处实现的。 7. 返回到调用位置。在函数执行完毕后,需要返回到函数被调用的位置。在 x86 架构中,返回指令通常是 RET 指令。RET 指令会将栈顶的返回地址弹出,并将其作为新的 PC。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值