函数调用过程——汇编的视角

DinS          Written on 2018/5/2

关于汇编的基础知识见《汇编基础》。

这里我们讲解一个典型的函数,并详细分析其对应的汇编代码。如果读者完全理解了这部分的内容,就可以说了解了汇编的基本知识,一般而言对于写高级代码的开发者足够了。

先看看这个典型的函数是什么。

|    void swap(int* px, int* py)

|    {

|             int t0 = *px;

|             int t1 = *py;

|             *px = t1;

|             *py = t0;

|    }

非常典型的swap,为了充分说明数值和地址,这里使用了指针来转换。这个函数会生成怎样的汇编代码呢?再详细讲解之前,让我们先来了解栈的知识。

我们知道C语言是一种函数调用函数的语言,核心是利用栈来实现。程序栈是各个函数运行实例的私有空间,函数变量与参数都存在栈中,那么程序是如何做到每个函数的栈互不影响的?一次完整的函数调用实质上就是程序栈的生长和销毁过程,具体是怎样进行的?

程序栈是一块内存区域,从高地址向低地址增长,“颠倒形式”。

寄存器%esp专门用于存储栈顶的地址,即栈顶指针。

栈无非是两种操作:push和pop

Push是压栈操作,语法pushl src。实际上进行了两步操作。第一步是从src取得数据并放入栈,如图所示。

栈增长了一块,但是注意根据约定%esp里必须指向栈顶,所以我们需要更新%esp。因为高地址在上,所以要把%esp向下移动一块,实际上是这样的操作%esp = %esp – 4。一块是4字节,这里以32位处理器为例。经过这样的操作后如图所示。

至此push操作结束。

Pop操作正相反,取出%esp所指地址,即栈顶的数据,然后将%esp向上拉一块,即%esp = %esp + 4。不画图了。

但是故事还没有结束,只这样是不足以调用一次函数的,函数的私有内容也要放到栈里,因此还有一个栈帧(stack frame)的概念。如图所示。

寄存器%ebp专门用于存储栈帧的起始地址,即栈帧指针。

可以这样理解:%ebp和%esp一头一尾卡出一片区域,这片区域就是一次函数调用所有内容的存放地点,即栈帧。因为栈帧的存在每一次函数调用都不会影响其他函数,只要调用其他函数就向下增长,更新%ebp和%esp;只要函数返回就向上缩,更新%ebp和%esp。

栈帧的内容因操作系统而异,以x86-32位Linux为例,栈的局部示意图如下。

这个局部有两个函数栈帧,%ebp和%esp卡出的区域是当前执行的函数栈帧。一旦当前函数执行完毕,%ebp和%esp会更新,然后卡出Caller的栈帧。


栈的知识终于讲完了,现在进入主题:swap函数的汇编代码

当我们调用swap函数时,比如swap(a,b);,汇编代码如下。(假设a和b是两个int指针,分别指向0x120和0x124,两块内存里的值分别是123和456)

对应三条指令,1和2条是压栈操作,用意是传递参数,默认规则是从右向左,最后一条call是将函数返回地址压栈。现在用示意图来展示这个过程。

因为C是函数调函数的所以调用swap时必然是在某个函数内部调用,于是栈里必然存在调用者栈帧,而且是%ebp和%esp卡出一头一尾。

执行完两个pushl后,栈向下增长2块,%esp指向栈顶。

Call实际上跟push没啥区别,就是压栈的是函数返回地址,这样处理器知道执行完后跳转到哪里继续执行。

接下来进入swap函数本身,汇编代码如下。

汇编代码就是这种感觉,看上去不知所云。

先看setup部分,一共三条指令

之后就是body部分了,swap的代码在这部分实现。粗略一看就是不停地在各个寄存器之间来还换,实际上也确实如此。看图。

执行完movl 12(%ebp), %ecx和movl 8(%ebp), %edx之后的情况。这两步的作用是将参数存入寄存器,参数的获得使用的是以%ebp为基址向上寻找若干块。

执行完movl (%ecx), %eax和movl (%edx), %ebx之后的情况。这两步即取出内存里真正的数值并放入寄存器中。实际上%eax就是t1,存储456;%ebx就是t0,存储123。

执行完movl %eax, (%edx)和movl %ebx, (%ecx)之后的情况,从寄存器和栈里面看不出变化。这两步是将%eax里存储的456覆盖到(%edx),即内存里地址为0x120对应到456,同理0x124对应到123。实现了swap。

最后是finish部分,即函数如何返回。

第一条movl -4(%ebp), %ebx的用意是恢复%ebx为原来的值。从%ebp开始往下走一块存的是Old %ebx,即setup时备份的值,现在把它放入%ebx即复原。接下来的两步操作看图。

至此该swap的善后工作已经结束,调用ret指令即返回到caller处调用swap后的下一句,然后继续执行即可。

最后的问题是为什么要备份%ebx?因为swap过程中要修改%ebx的值,可能跟父函数有冲突,所以提前备份以便还原。进一步的问题是swap过程同样修改了%eax、%ecx、%edx,为什么只备份%ebx?这个跟寄存器使用惯例有关,某些是调用者负责的,某些是被调用者负责的。这个太具体就不说了。看汇编代码时如果发现了某些跟高级语言对应不上的内容,很可能跟寄存器使用本身有关。

如果读者完全理解了这个swap过程对应的汇编代码,那么恭喜,对一个普通程序员而言足矣。


下面再说一下x86-64位处理器的函数调用。

因为寄存器多了一倍,所以x64优先使用寄存器传参,只要参数个数小于等于6个就可以不使用栈。另外将%rbp(原%ebp)解放,不再当做栈帧指针使用,只有%rsp(原%esp)做栈顶指针。

比如同样上述的swap函数,在64位处理器下生成的汇编代码如下。

整个过程无需传参,也没有了栈,效率自然提高。而且看上去跟高级语言的代码更接近。

换言之x64程序运行快不光是因为寄存器数量增多,跟汇编代码优化也有直接关系。