C++函数的工作原理

"C++反汇编学习笔记"

Posted by 1r0nz on January 18, 2017

前言

       在程序编写并且运行过程中,不管是面向对象程序语言和面向过程程序语言,函数作为特有的执行过程体在其中始终占有举足轻重的地位。在程序汇编过程中,大部分汇编器对函数体的解析如出一辙,所以在编码过程中,了解函数体在内存中的表现形式及汇编过程能够较好的帮助自己了解程序底层执行并理解程序在应用层的执行。


函数栈

  • 函数在内存中的表现为栈的形式,栈在内存中是一块特殊的存储空间,汇编器常采用pop指令和push指令圧栈和出栈。
  • 通过ebp和esp这两个指针寄存器来保存当前栈的起始位置和结束位置(栈顶和栈底)。下图是《深入理解计算机系统》函数栈的结构示意图,个人认为最为经典所以弄了过来。
    stack
  • 这里记住的是函数栈的增长方向在内存中是从高地址向低地址增长的,所以当esp指向的地址值小于ebp指向的地址值时形成了__栈帧__。
  • 栈帧中包含局部变量、函数返回地址、函数参数等。
  • 不同的函数调用形成的栈帧不一样。当一个函数进入另一个函数时候,就会针对调用的函数开辟相应的栈空间,形成这个函数的栈帧。
  • 当函数结束调用时,需要对它的栈空间进行恢复清除,关闭栈帧,我们把这一过程称为栈平衡。
  • 在具体实现函数代码之前,一般先将栈底指针ebp保存,以便退出函数保留栈底。
  • 在退出函数事,会先将ebp和esp进行对比,判断函数是否正常关闭,栈顶与栈底是否平衡。如果不相等则调用_chkesp函数,弹出警告对话框。
    补充:假设过程P(调用者)调用过程Q(被调用者),则Q的参数放在P的栈帧中。另外,当P调用Q时,P中的返回地址被压入栈中,形成P的栈帧的末尾。返回地址就是当程序从Q返回时应该继续执行的地方。Q的栈帧从保存的帧指针的值(ebp)开始,后面是保存的其他寄存器的地方。

函数调用约定

       当函数参数为不定参数时,函数自身无法确定使用参数大小,无法由函数本身完成栈平衡操作,需要调用者自身实现。于是有了函数的调用规定。

  • _cdecl:c\c++默认的调用方式,调用放平衡函数栈,不定参数可以使用。
  • _stdcall:被调方平衡栈,不定参数函数无法使用。
  • _fastcall:寄存器方式传参,被调方平衡栈,不定参数函数无法使用。

ebp和esp寻址

       在众多寄存器寻址类型中,一般通过ebp和esp的加减法操作(寄存器相对间接寻址方式)来获取变量在内存中的数据,比如以下代码。
findaddress
可以看出局部变量是连续排列在栈空间内的。
当程序进入函数体内时,首先开辟栈空间并计算局部变量的栈空间的大小,退出函数前释放其占有的栈空间,所以其生命周期为整个函数体的生命周期
一般函数汇编过程:使用ebp保存函数作用域的栈地址,这样在函数退出前,用于esp的还原,以及栈平衡的检查。


函数的参数

       函数参数通过栈结构进行传递,参数进入栈的顺序为__从右往左__,最先定义的参数最后入栈。

  • 因为函数的传参是通过栈的方式传递的使用push指令将数据压入到栈中,而push将操作数复制到栈顶所以这时压入栈中的数据和原数据在两个不同的地方,所以这就是形参和实参。
  • 不定长参数实现:至少要有一个参数、所有不定长的参数类型传入时都是dword类型、需在某一个参数中描述参数总个数或将最后一个参数赋值为结尾标记。根据参数传递的特性,只要确定第一个参数的地址,对其地址做加法,就可访问到此参数的下一个参数所在的地址。获取参数类型是为了解释地址中的数据,防止数据越界。

函数的返回值

       函数调用结束后使用ret指令就可以返回到函数调用处的下一条指令。call指令被执行以后,该指令同时将下一条指令所在的地址压入栈中。如下图所示
ret
       call指令的下一条指令所在地址为0x0040DB39,当前esp保存的地址为0x0012FF2C。当执行call指令时,再次进入函数实现中观察esp与数据栈的变化,发现esp被减4,并且对应地址中的数据被修改。
ret1
如图所示,执行call后,由于有圧栈的操作,esp被减4,并且该地址中保存的信息为0x0040DB39为函数调用处的下一条指令地址。当函数执行到ret指令时,当前esp已经被平衡,此时将再次执行0x0012FF28。函数退出前,会执行ret指令,这个指令取得esp所指向的4字节内容作为函数的返回地址值更新eip,程序的流程回到返回地址处,同时执行esp加4操作,以释放返回的地址空间,平衡栈顶。
函数返回值通过eax返回,可是eax只能存放4字节数据,大于4字节数据需要使用其他方法保存。如果基本数据类型与sizeof(type)小于等于4的自定义类型。如果函数有返回值,那么最后的操作通常为对eax赋值后执行ret指令。如下图:
ret2
ret3
假如返回值是结构体等大于4字节,根据成员数使用多个寄存器传递返回值。
ret4


总结

  1. 函数调用的一般工作流程:通过栈或寄存器方式传递参数。
  2. 函数调用,将返回地址圧栈:使用call指令调用参数,并将返回地址压入栈中。
  3. 保存栈底:使用栈空间保存调用方的栈底寄存器ebp。
  4. 申请栈空间和保存寄存器环境:根据函数内局部变量的大小抬高栈顶让出对应的栈空间,并且将即将修改的寄存器保存在栈内。
    5.函数实现代码:函数实现过程的代码。
    6.还原环境:还原栈中保存的寄存器信息。
    7.平衡栈空间:平衡局部变量使用的栈空间。
  5. ret返回,结束函数调用:从栈顶取出第(2)步保存的返回地址,更新eip。在非_cdecl调用方式下,平衡参数占用栈空间。
  6. 调整esp,平衡栈顶:此处为_cdecl特有的方式,用于平衡参数占用的栈顶。