《老码识途》读书笔记:第一章(中)
《老码识途》读书笔记:第一章--欲向码途问大道,锵锵bit是吾刀(中)
3、函数调用和局部变量
要研究函数的调用过程,先来看下面的一段代码:
1 int Add(int x, int y) 2 { 3 int sum; 4 sum = x + y; 5 return sum; 6 } 7 8 void main() 9 { 10 int z; 11 z = Add(1, 2); 12 printf("z = %d\n", z); 13 }
对于 z = Add(1, 2); 这一句,我们可以看到其汇编代码和机器码如下:
1 z = Add(1, 2); 2 3 00413762 6a 02 push 2 4 00413764 6a 01 push 1 5 00413766 e8 7a da ff ff call 004111e5 6 0041376B 83 c4 08 add esp, 8 7 0041376E 89 45 18 mov dword ptr [ebp-8], eax
上述指令表明主函数将跳转到内存地址004111e5来进行Add函数的调用执行。在机器码中,如果已知e8代表的是call指令,那后面的四个字节代表什么呢?call指令采用的是相对偏移量寻址,也就是是通过 基址 + 偏移量 的方式来获取最终的目的地址的。根据对mov指令进行分析后的经验,知道小端机内存中的7a da ff ff 代表 0xffffda7a 。但是00413766加上0xffffda7a得到的结果与目的地址004111e5相去甚远。因此还要考虑0xffffda7a有可能为负数。在计算机中有符号数的第一位为1则说明该数为负数,显然按照这样来看0xffffda7a是一个负数,与其对应的相反数正数为0x2586。如果用call指令的地址00413766减去2586,得到的结果为0X4111e0,与真正的目的地之间相差5个字节。通过观察发现call指令的机器码长度刚好为5个字节,因此即可得出最终的结论:
x86系列CPU的call指令的寻址方式为:用与call指令相关的偏移量定位跳转到的地址
偏移量计算如下:偏移量 = 跳转到的地址 - call指令后一条指令的起始地址
进行函数调用的时候,要使用到两个寄存器ESP和EIP,它们保存着与函数跳转和返回相关的信息。通过在函数调用过程中对两个寄存器的值进行观察,可知EIP中存储的是call跳转到的指令的地址004111E5。而ESP中存储的则是0X00116E60这个内存地址,显然,它并不是call指令的返回地址0x0041376b。再根据该内存地址去获取保存在其中的值,得到的结果为从该地址开始往高地址依次数四个字节的值分别为 6b 37 41 00,即0x0041376b,正好是call指令返回的的地址。由此可知:
call指令将返回地址保存在内存中,而且ESP寄存器指向了该内存。call指令相当于以下两条指令的组合:
push 返回地址
jmp 函数入口地址
调用函数的时候有时还需要给函数传递参数,在上面的 z = Add(1, 2) 这一赋值语句对应的几句汇编代码中,与传递参数相关的是下面的两句:
1 00413762 6a 02 push 2 2 00413764 6a 01 push 1
这两句汇编代码的作用是将两个参数按照从右至左的顺序压入到内存栈中。在此有必要说明一下,内存中栈的栈顶内存地址保存在ESP寄存器中。由于栈在内存中是从高地址向低地址扩展的,因此每次压入一个参数,ESP寄存器中的值指向的内存地址都会按照一定的字节数减少相应的值。在压入第一个参数2之前,ESP寄存器中的值为0x00116e6c。压入参数2之后,ESP寄存器的值变为0x00116e68,此时ESP寄存器指向的是参数2存放在内存中的地址。再压入参数3之后,ESP寄存器的值减少为为0x00116e64,指向参数1存放在内存中的地址。在执行call指令之后,函数的返回地址被压栈,ESP寄存器的值又减少了4个字节,变成0x00116e60。
由于参数存储在内存栈中,因此被调用的函数要获得参数,就必须借助esp寄存器中的值。由于参数之上还压入了函数的返回地址,且每个内存地址长度都为四个字节,因此第一个参数的内存地址即为esp+4,而第二个参数的内存地址为esp+8,以此类推。但是由于esp的值会随着栈的变化而变化,且难保在函数执行过程中不会改变栈的当前状态,因此还需要另外一个寄存器ebp(扩展基址指针寄存器)来暂时存放esp寄存器中的值。但是在函数层层嵌套时,内层函数执行完毕退出后如果不改变ebp寄存器的值而让外层函数继续使用的话就会出现不可预知的错误情况。因此在每次调用一个函数时,要先将当前ebp的值push入栈保存起来,然后才将当前esp的值存入ebp寄存器中。内层函数执行完毕之后,要将函数执行之前保存的ebp寄存器的值出栈并恢复到ebp寄存器中,外层函数就可以继续使用ebp的值了。要注意的是,由于ebp压栈后esp的值又减小了4,所以在将esp的值赋给ebp后,第一个参数的内存地址应该为ebp+8,同时第二个参数的内存地址应该为ebp+0ch(即12),以此类推。
为了验证上面的结论,先来看看下面的这一段代码:
1 int Add(int x, int y) 2 { 3 4 00411430 push ebp 5 00411431 mov ebp, esp 6 00411433 sub esp, 0cch 7 00411439 push ebx 8 0041143a push esi 9 0041143b push edi 10 0041143c lea edi, [ebp+ffffff34h] 11 00411442 mov ecx, 33h 12 00411447 mov eax, 0cccccccch 13 0041144c rep stos dword ptr es:[edi] 14 15 int sum; 16 sum = x + y; 17 0041144e mov eax, dword ptr [ebp+8] 18 00411451 add eax, dword ptr [ebp+0ch] 19 }
观察上面的代码可知,跳转到Add函数之后,在执行第一条语句之前程序预先做了一连串的准备工作。先将ebp的值压栈,然后将esp的值赋给ebp。在后面的语句中,获取参数x是通过内存地址ebp+8,而获取参数y则是通过内存地址ebp+12,与之前的结论相同。
在函数的执行过程中可能还会使用到用户定义的局部变量,如下面的代码中就使用到了局部变量sum:
int Add(int x, int y) { int sum; sum = x + y; }
在VS2008中反汇编得到与该函数中的赋值语句相对应的三条汇编语句如下:
1 0041144e mov eax, dword ptr [ebp+8] 2 00411451 add eax, dword ptr [ebp+0ch] 3 00411454 mov dword ptr [ebp-8], eax
在最后一条汇编语句中,将两个参数相加得到的值保存在了地址为ebp-8的内存空间中。这是因为局部变量的特点与参数一样,都是当函数调用完毕就不再使用,所以仿效参数将其分配在栈上。但是栈上方已经被参数和返回地址等使用,因此只能使用栈更低地址的空间,每分配一个局部变量都要进行一次压栈。但是要注意的是,压栈一次的话地址应该为ebp-4而非上面见到的ebp-8。事实上,在VC 6.0编译器中反汇编得到的代码如下:
1 00401038 mov eax, dword ptr [ebp+8] 2 0040103b add eax, dword ptr [ebp+0ch] 3 0040103e mov dword ptr [ebp-4], eax
此处的局部变量地址确为ebp-4。造成这种不同的原因主要是在VS2008中为了防止溢出攻击而采用的StackGaurd溢出攻击防护机制,在ebp和局部变量的地址之间空出了四个字节。
函数执行完毕就要返回调用的地方,由之前的叙述可知call指令已经将其后指令的地址压栈保存,因此可以使用该地址进行返回。函数返回要用到返回指令ret,ret指令的介绍如下:
ret指令:将栈顶保存的地址弹入指令寄存器EIP,相当于"pop eip",从而让程序跳转到该地址。执行ret指令后,寄存器EIP(存储了被弹出的栈顶地址)和ESP(在32位x86中加4)的值有变化