最近忙了一阵,好几天没更了,不好意思,我来晚了。
转入正题,当在汇编中进行函数调用,是一种什么样的体验?
想象
想象你在计算一个非常复杂的数学题,在算到一半的时候,你需要一个数据,而这个数据需要套用一个比较复杂的公式才能算出来,怎么办?
你不得不把手中的事情停下来,先去套公式、代入数值然后…最后,算出结果来了。
这时候你继续开始攻克这个困难题目的剩下部分。
用脑子想
刚刚说的这个过程,可能有点小问题,尤其是对脑子不太好使的人来说。想象你做题目做到一半的时候,记忆力已经有点不好使了,中间突然停下来去算一个复杂的公式,然后回来,诶?我刚刚算到哪了?我刚刚想到哪了?我刚刚算了些什么结果?
在你工作切换的时候,很容易回头来就忘记了刚刚做的部分事情。这时候,为了保证你套完复杂的公式,把结果拿回来继续算题目的时候不会出差错,你需要把刚才计算题目过程中的关键信息写在纸上。
用CPU想
刚刚去套用一个复杂的公式计算某个数据的情景,就类似在计算机里进行函数调用的情景。
程序需要一个结果,这个结果需要通过一个比较复杂的过程进行计算。这时候,编程人员会考虑将这个独立的复杂过程提取为单独的函数。
而在发生函数调用的时候,CPU就像是先暂停当前所做的事情,转去做那个复杂的计算,算完了之后又跳回来继续整个计算。就像你做题的过程中去套了一个公式计算数据一样。
但是在去套用公式之前,你需要做一些准备。首先,默默记下现在这个题目算到哪一步了,一会套完公式回来接着做;默默记下现在计算出来的一些结果,一会可能还会用到;套用公式需要些什么数据,先记下来,代公式的时候直接代入计算,算出来的结果也需要记在脑子里,回头需要使用。
在CPU里面,也需要这几个过程。
第一个,记下自己现在做事情做到哪里了,一会儿套完公式回来接着做,这也就是CPU在进行函数调用时的现场保存操作,CPU也需要记下自己当前执行到哪里了。
默默记下一些在套用公式的时候需要用到的数据,然后去套公式了。这也就是程序中在调用函数的时候进行参数传递的过程。
然后开始执行函数,等函数执行完了,就需要把结果记下来,回去继续刚才要用到数据的那个地方继续算。这也就是函数调用后返回的动作,这个记下的结果就是返回值。
开撸
说了那么多故事,那么函数调用要干些啥应该就说清楚了。总结一下大概就这么几个事:
保存现场(一会好回来接着做)
传递参数(可选,套公式的时候需要些什么数据)
返回(把计算结果带回来,接着刚才的事)
到这里,我们先来一个事例代码,就着代码去发现函数调用中的套路:
1 | main |
首先,运行程序,得到结果:3。
上面的代码其实也比较简单,先从主干main这个地方梳理:
- 让eax和ebx的值都为0
- 调用eax_plus_1s,再调用eax_plus_1s
- 调用ebx_plus_1s
- 执行eax = eax + ebx
上述的两个函数也非常简单,分别就是给eax和ebx加了1。所以,这个程序其实也就是换了个花样给寄存器增加1而已,纯粹演示。
这里出现了一个陌生指令call,这个指令是函数调用专用的指令,从程序的行为上看应该是让程序的执行流程发生跳转。前面说到了跳转指令jmp,这里是call,这两个指令都能让CPU的eip寄存器发生突然变化,然后程序就一下子跳到别的地方去了。但是这两个有区别:
很简单,jmp跳过去了就不知道怎么回来了,而通过call这种方式跳过去后,是可以通过ret指令直接回来的
那这是怎么做到的呢?
其实,在call指令执行的时候,CPU进行跳转之前还要做一个事情,就是把eip保存起来,然后往目标处跳。当遇到ret指令的时候,就把上一次call保存起来的eip恢复回来,我们知道eip直接决定了CPU会执行哪里的代码,当eip恢复的时候,就意味着程序又会到之前的位置了。
一个程序免不了有很多次call,那这些eip的值都是保存到哪里的呢?
有一个地方叫做“栈(stack)”,是程序启动之前,由操作系统指定的一片内存区域,每一次函数调用后的返回地址都存放在栈里面
好了,我们到这里,就明白了函数调用大概是怎么回事了。总结起来就是:
本质上也是跳转,但是跳到目标位置之前,需要保存“现在在哪里”的这个信息,也就是eip
整个过程由一条指令call完成
后面可以用ret指令跳转回来
call指令保存eip的地方叫做栈,在内存里,ret指令执行的时候是直接取出栈中保存的eip值,并恢复回去达到返回的效果
何为栈?
前面说到call指令会先保存eip的值到栈里面,然后就跳转到目标函数中去了。
这都好说,但是,如果是我在函数里面调用了一个函数,在这个函数里面又调用了一个函数,这个eip是怎么保存来保证每一次都能正确的跳回来呢?
好的,这个问题才是关键,这也说到了栈这样一个东西,我们先来设想一些场景,结合实际代码理解一下CPU所对应的栈。
首先,这个栈和数据结构中的栈是不一样的。数据结构中的栈是通过编程语言来形成程序执行逻辑上的栈。而这里的栈,是CPU内硬件实现的栈。当然了,两者在逻辑上都差不多的。
在这里,先回想一下数据结构中基于数组实现的栈。里面最关键的就是需要一个栈顶指针(或者是一个索引、下标),每次放东西入栈,就将指针后移,每一次从栈中取出东西来,就将指针前移。
到这里,我们先从逻辑上分析下CPU在发生函数调用的过程中是如何使用栈的。
假设现在程序处在一个叫做level1的位置,并调用了函数A,在调用的跳转发生之前,会将当前的eip保存起来,这时候,栈里面就是这样的:
1 | ---------- <= top |
现在,程序处在level2的位置,又调用了函数B,同样,也会保存这次的eip进去:
1 | ---------- <= top |
再来,程序这次处在level3,调用了C函数,这时候,整个栈就是这样的:
1 | ---------- <= top |
好了,这下程序执行到了ret,会发生什么事,是不是就回到level3了?在level3中再次执行ret,是不是就回到level2了?以此类推,最终,程序就能做到一层层的函数调用和返回了。
实际的CPU中
在实际的CPU中,上述的栈顶top也是由一个寄存器来记录的,这个寄存器叫做
1 | esp(stack pointer) |
每次执行call指令的时候。
这里还有一个小细节,在x86的环境下,栈是朝着低地址的方向伸长的。什么意思呢?每一次有东西入栈,那么栈顶指针就会递减一个单位,每一次出栈,栈顶指针就会相应地增加一个单位(和数据结构中一般的做法是相反的)。至于为什么会这样,我也不知道。
eip在入栈的时候,大致就相当于执行了这样一些指令:
1 | sub esp, 4 |
翻译为C语言就是(假如esp是一个void*类型的指针):
1 | esp = (void*)( ((unsigned int)esp) - 4 ) |
也就是esp先移动,然后再把eip的值写入到esp指向的内存中。那么,ret执行的时候该干什么,也就非常的清楚了吧。无非就是上述过程的逆过程。
同时,eip寄存器的长度为32位,即4字节,所以每一次入栈出栈的单位大小都是4字节。
动手
没有代码,说个锤子。先来一个简单的程序:
1 | main |
这个程序中只有一个函数调用,但不影响我们分析。先编译,得到一个可执行文件,这里先起名为plsone。
然后载入gdb进行调试,进行反汇编:
1 | $ gdb ./plsone |
好了,找到反汇编中<+5>所在那一行,对应着的指令是call 0x80483f0,这个指令的地址为:0x080483f9(不同的环境有所不同,根据实际情况来)。按照套路,在这个call指令处打下一个断点,然后运行程序。
1 | (gdb) b *0x080483f9 |
好了,程序执行到断点处,停下来了。再来看反汇编,这次有一个小箭头指向当前的断点了:
1 | (gdb) disas main |
接下来,做这样一个事情,看看现在eip的值是多少:
1 | (gdb) info register eip |
正好指向这个函数调用指令。这里的call指令还没执行,现在的CPU处在上一条指令刚执行完毕的状态。前面说过,CPU中的eip总是指向下一条会执行的指令。在这里,珍惜机会,我们把想看的东西全都看个遍吧:
esp的值,这个很关键
1 | (gdb) info register esp |
esp所指向的栈顶的东西
1 | (gdb) p/x *(unsigned int*)$esp |
该看的都看过了,让程序走吧,让它先执行完了call指令,我们再回头看看什么情况:
1 | (gdb) stepi |
根据提示,程序现在已经执行到函数里面去了。可以直接反汇编看看:
1 | (gdb) disas |
现在正等着执行那条加法指令呢。别急,现在函数调用已经发生了,再来看看上面我们看过的一些东西:
esp的值,这个很关键
1 | (gdb) info register esp |
看到了,上次查看esp的时候是0xffffd6ec,进入函数后的esp值是0xffffd6e8。少了个4。
实际上这就是eip被保存到栈里去了,CPU的栈的伸长方向是朝着低地址一侧的,所以每次入栈,esp都会减少一个单位,也就是4。
esp所指向的栈顶的东西
1 | (gdb) p/x *(unsigned int*)$esp |
这次,我们看看栈顶到底是个什么东西,打印出来0x80483fe这么一个玩意儿,这是蛤玩意儿?别急,回头看看main函数的反汇编:
1 | (gdb) disas main |
在里面找找0x80483fe呢?刚好在<+10>所在的那一行。这不就是函数调用指令处的后一条指令吗?
对的,也就是说,一会函数返回的时候,就会到<+10>这个地方来。也就是在执行了eax_plus_1s函数里的ret之后。
是不是和前面描述的过程一模一样?
好了,到这里,探究汇编中的函数调用的过程和方法基本就有了,读者可以根据需要自行编写更加奇怪的代码,结合gdb,来探究更多你自己所好奇的东西。
附加一个代码,自己玩耍试试(在自己的环境中玩耍哦):
1 | global main |
总结
这回,我们说到这样一些东西:
汇编中发生函数调用相关的指令call和ret
call指令会产生跳转动作,与jmp不同的是,call之后可以通过ret指令跳回来
call和ret的配合是依靠保存eip的值到栈里,返回时恢复eip实现的
esp记录着当前栈顶所在的位置,每次call和ret执行都会伴随着入栈和出栈,也就是esp会发生变化
函数调用最基本的”跳转“和”返回“就这么回事了,下回咱们继续分析”函数调用中的参数传递、返回值和状态“相关的问题。
文中若有疏漏或是不当之处,欢迎指正。
发布于 2016-12-02