从反汇编角度窥探平时开发调用的函数或者方法的本质。平时我们编写的高级语言最终通过编译器、链接生成机CPU执行的机器指令。 不同的CPU对应着不同着机器指令,并且每一条机器指令对应着一条汇编。
先看一个最简单的C语言函数,这里主要通过C++来反编译分析汇编指令。
1.png可以通过反汇编看到调用func函数的汇编指令,当前环境是8086汇编。
2.png通过最终的汇编指令可以看出,在执行调用一个函数:本质就是通过call
指令调用函数在代码段的地址进行直接调用。
注意:在上面的汇编指令可以看到当函数执行完毕,执行ret
汇编指令退出函数。其实一个完整的函数调用必定包含call
和ret
指令。
那么只有了解了call
和ret
才能彻底从最根本了解函数的调用过程。
call 标号
1.将下一条指令的偏移地址入栈
2.转到标号出执行指令
ret
将栈顶的值出栈,赋值给IP
下面通过汇编代码调用 printf
函数标号打印 HelloWorld 执行验证上面的结论。
3.png
在即将执行执行 printf
函数之前栈顶指针SP指向内存单元的数据。
上面说到执行函数前会将下一条指令的偏移地址入栈,上图可以看出的下一条CPU执行的指令偏移地址IP为:000D。开始执行,看下栈顶指针SP的指向和指向内存单元的数据
5.png函数 printf
执行完毕后,执行 ret
指令,栈顶偏移地址出栈赋值给 IP
中,栈顶指针向上移动两个字节。
不管什么开发语言最终都会转成二进制汇编指令,对应着相应的汇编指令,本质都是一致的。这里是通过C++反汇编窥探函数调用本质。
上述介绍只是最简单函数调用,一说到函数首先就会想到函数的三要素,函数的返回值、函数的参数、局部变量**。
返回值
如果调用函数想拿到函数返回值,就得有容器来存放返回值,我们可以想到用栈、数据区、寄存器来保存。
首先栈段不可以的,如下图,函数内部push返回值,栈顶存储的是CPU函数执行完毕后的IP的偏移地址。
7.png可以考虑将返回值放入数据段,这个需要与调用者约好协议,比如约定好将返回值放在ds:[0]
8.png这样侧面证明了数据段里的数据是全局,全局区的数据是作用域是全局的。上面的实例代码好比下面的C++代码。
9.png在实际中,大多数平台,windows、linux、Android等通常的做法是将方法返回值放在寄存器ax。其实这样的效率比上面返回值放在全局区效率高,CPU从寄存器中读取数据要快,放在全局区需要从内存先读取到寄存器。
10.png下面在X86环境下写一段代码看下汇编指令
11.png参数
同样我们先考虑将参数放入数据段来实现一个求和的函数。
12.png放在数据段是可以的,在我们概念中形参的作用于是数据函数内部,函数执行完毕形参所占用的内存空间会被回收。这样就很明显了,通常,形参是放在栈中的。
13.png注意:在函数调用完毕后,一定要保证栈平衡,否者会导致栈的空间会被用完,通常保持栈平衡有两种方式:内平栈和外平栈。
上面的案例是使用了外平栈方式,也就是在函数调用完毕后,对栈顶指针进行回复到函数调用前的位置。
14.png对于函数的封装性跟人觉的栈内平衡的方式会好一些,让函数调用者不用关心内部细节。函数的形参本质了解后,接下来窥探最后一个函数的局部变量本质,这个相对复杂一些。
局部变量
函数的内部需要定义局部变量,C语言特别简单,那么在汇编中怎么分配内存空间给局部变量呢,局部变量的作用域只是当前函数,函数执行完毕后局部所栈中的空间被回收,因此局部变量空间分配还是通过栈来实现。
15.png上面开始没有问题,唯一缺陷是在函数内部调用函数时,由于我们没有对bp进行恢复,一旦对函数内部在调用函数就会存存在问题, 因此需要对bp进行记录和恢复。
16.png 17.png 18.png函数的调用流程总结
1 push参数,参数入栈
2 将函数的返回地址(下一条指令的地址)入栈
3 保护sp,将sp赋值给bp
4 分配一定的空间给函数的局部变量使用(让sp减去该空间大小),为了安全,用CC填充(int 3h)
5 保护寄存器, 因为在函数执行过程中会修改寄存器的值,所以在修改之前保存一下之前的值,后面再还原
6 具体的业务代码
7 恢复寄存器的值,跟第5步相反
8 将bp赋值给sp,恢复bp
9 返回(ret)
网友评论