浅谈函数调用!

324次阅读  |  发布于2年以前

导语 | 在任意一门编程语言中,函数调用基本上都是非常常见的操作;我们都知道,函数是由调用栈实现的,不同的函数调用会切换上下文;但是,你是否好奇,对于一个函数调用而言,其底层到底是如何实现的呢?本文讲解了函数调用的底层逻辑实现。

一、汇编概述

既然要讲解函数调用的底层逻辑实现,那么汇编语言我们是绕不过的。

因此,首先来复习一下汇编相关的知识。

我们都知道,计算机只能读懂二进制指令,而汇编就是一组特定的字符,汇编的每一条语句都直接对应CPU的二进制指令,比如:mov rax,rdx就是我们常见的汇编指令。

汇编语言就是通过一条条的 助记符+操作数实现的,并且汇编指令经过汇编器(assemble,例如Linux下的as)转变为实际的CPU二进制指令。

(一)一个简单的汇编例子

上面讲的有些空洞,来看一个实际的例子:


; 将寄存器rsp的值存储到寄存器rbp中
mov rbp, rsp

; 将四个字节的4存储到地址为rbp-4的栈上
mov DWORD PTR [rbp-4], 4

; 将rsp的值减去16
sub rsp, 16

需要注意的是:汇编语言是和实际底层的CPU息息相关的;上面的汇编格式使用的便是Intel的语法格式。

常见的汇编语言有两种截然不同的语法:

若将上面的Intel汇编改写为AT&T汇编,则为:

movq %rsp, %rbp

movl $4, -4(%rbp)

subq $16, %rsp

可以看到,AT&T汇编的另外一个特点是:有前缀和后缀。

比如:前缀%,$;后缀q,l等等。

这些前缀后缀有特殊的意思,后文会讲解,不同的格式侧重点不太一样。

(二)常用汇编指令

下面是一些非常常用的汇编指令,在后文中都会用到:

二、通用寄存器概述

对于汇编语言,仅仅了解其语法内容是远远不够的!

由于汇编语言和CPU是息息相关的,因此在硬件层面我们还需要关注CPU的通用寄存器。

在所有CPU体系架构中,每个寄存器通常都是有建议的使用方法的,而编译器也通常依照CPU架构的建议来使用这些寄存器,因而我们可以认为这些建议是强制性的。

(一)8086架构(16bit)

让我们把视线首先转移到8086

下图展示了在8086 CPU中的各个寄存器:

主要包括下面几类寄存器:

这些寄存器的作用主要是:暂存计算机过程中的数据。

另外,AX、BX、CX、DX这四个寄存器又可以分为两个8位的寄存器来使用,分别是AH、AL、BH、BL、CH、CL、DH、DL。

注意:其中H表示高位(high),L表示低位(low)的意思。

下面来看下控制单元:

IP寄存器就是指令指针寄存器(Instruction Pointer Register),指向代码段中下一条指令的位置;CPU会根据它不断地从内存的代码段中取出指令并加载到CPU的指令队列中,然后交给运算单元去执行。CS、DS、SS、ES这四个寄存器都是16位寄存器,用来存储进程的地址空间信息。

比如:

如何根据上述段寄存器找到所需的地址呢?

CS和DS中都存放着一个段的起始地址,代码段的偏移值存放在IP寄存器中,而数据段的偏移值放在通用寄存器中;由于8086架构中总线地址是20位的,而段寄存器和IP寄存器以及通用寄存器都是16位的,所以为了得到20位的地址,先将段寄存器中起始地址左移4位,然后再加上偏移量,就得到了20位的地址;也正是由于偏移量是16位的,所以每个段最大的大小是64K的。

另外,对于20位的地址总线来说,能访问到的内存大小最多也就只有2^20=1MB。

如果计算得到某个要访问的地址是1MB+X,那么最后访问的是地址X,因为地址线只能发送低20位的。

8086CPU设置了一个:16位标志寄存器PSW(也叫FR),其中规定了9个标志位,用来存放运算结果特征和控制CPU操作。

9个标志位可以分为两类大类:

其中条件码包括:

控制标志位包括:

(二)x86架构

接着,让我们步入32位机时代,来看看x86体系下的CPU寄存器:

可以看到,为了使得运行在8086架构上的程序在移到32位架构之后也能执行,32位架构对8086架构进行了兼容:

关于选择子:

先根据段寄存器拿到段的起始地址,再根据段寄存器中保存的选择子,找到对应的段描述符,然后从这个段描述符中取出这个段的起始地址;就相当于由之前的直接找到段起始地址变成了间接找到段起始地址;这样改变之后,段起始地址会变得很灵活。

但是这样就跟原来的8086架构不兼容了,因此为了兼容8086架构,32位架构中引入了实模式和保存模式:8086架构中的方式就称为实模式,32位这种模式就被称为保护模式。

当系统刚刚启动的时候,CPU是处于实模式的,这个时候和8086模式是兼容的;当需要更多内存时,进行一系列的操作,将其切换到保护模式,这样就能使用32位了。

模式可以理解为:CPU和操作系统的一起干活的模式:

这样操作系统给CPU下命令,CPU按照约定好的,就能得到操作系统预料的结果,操作系统也按照约定好的,将一些数据结构,例如段描述符表放在一个约定好的地方,这样CPU就能找到。两者就可以配合工作了。

下面是x86平台下一些寄存器的调用特殊约定:

作为通用寄存器,过程调用中,调用者栈帧需要寄存器暂存数据,被调用者栈帧也需要寄存器暂存数据。

为防止调用过程中数据不会被破坏丢失,C/C++编译器遵守如下约定的规则:

当产生函数调用时,子函数内通常也会使用到通用寄存器,那么这些寄存器中之前保存的调用者(父函数)的值就会被覆盖!为了避免数据覆盖而导致从子函数返回时寄存器中的数据不可恢复,CPU 体系结构中就规定了通用寄存器的保存方式。

如果一个寄存器被标识为Caller Save, 那么在进行子函数调用前,就需要由调用者提前保存好这些寄存器的值,保存方法通常是把寄存器的值压入堆栈中,调用者保存完成后,在被调用者(子函数)中就可以随意覆盖这些寄存器的值了。

如果一个寄存被标识为Callee Save,那么在函数调用时,调用者就不必保存这些寄存器的值而直接进行子函数调用,进入子函数后,子函数在覆盖这些寄存器之前,需要先保存这些寄存器的值,即这些寄存器的值是由被调用者来保存和恢复的。

具体来讲:

当该函数是处于调用者角色时,如果该函数执行过程中产生的临时数据会已存

储在%eax,%edx,%ecx这些寄存器中,那么在其执行call指令之前会将这些寄存器的数据写入其栈帧内指定的内存区域,这个过程叫做调用者保存约定(Caller Save)。

当该函数是处于被调用者角色时,那么在其使用这些寄存器%ebx,%esp,%edi之前,那么该函数会保存这些寄存器中的信息到其栈帧指定的内存区域,这个过程叫被调用者保存约定;%eax总会被用作返回整数值。

%esp,%ebp总被分别用着指向当前栈帧的顶部和底部,主要用于在当前函数推出时,将他们还原为原始值;往往会在栈帧开始处保存上一个栈帧的ebp,而esp是全栈的栈顶指针,一直指向栈的顶部。

注:在x86-64架构下也是类似的约定!

(三)x86-64架构

最后就是我们目前主流的x86-64架构了;

对于x86-64架构,最常用的有16个64位通用寄存器,各寄存器及用途如下所示:

从上面的表可以看到,除了扩展原来存在的通用寄存器,x64架构还引入了8个新的通用寄存器:r8-r15。

这些寄存器虽然都可以用,但是还是做了一些规定,如下:

函数返回值存放的寄存器:rax。

同时,和上面x32架构类似这里也要区分Caller Save和Callee Save寄存器,即寄存器的值是由 调用者保存 还是由 被调用者保存。

在x32的时代,通用寄存器少,参数传递都是通过入栈(汇编指令push)实现的(当然也有使用寄存器传递的,比如著名的C++ this指针使用ecx寄存器传递,不过能用的寄存器毕竟不多),相对CPU寄存器来说,访问太慢,函数调用的效率就不高;

而在x86-64时代,寄存器数量多了,CPU就可以利用额外的寄存器rdi、rsi、rdx、rcx、r8、r9来存储参数!

寄存器传参的好处是速度快,减少了对内存的读写次数。

注:多于6个的参数,依然还是通过入栈实现传递。

因此在x86_64位机器上编程时,需要注意:

注意:具体使用栈还是用寄存器传参数,这个不是编程语言决定的,而是编译器在编译生成CPU指令时决定的。如果编译器非要在x64架构CPU上使用线程栈来传参那也不是不行,这个对高级语言是无感知的。

(三)x86-64寄存器的向下兼容

上述的寄存器名字都是64位的名字,对于每个寄存器,我们还可以只使用它的一部分,并使用另一个新的名字:

下面这些寄存器可能也会需要用到其他寄存器:

和上面所述的x86架构类似,在x86-64架构下也存在实模式;更多关于 x86-64 处理器架构:

http://c.biancheng.net/view/3460.html

https://www.cnblogs.com/mazhimazhi/p/15236954.html

更多关于CPU寄存器历史见: https://zhuanlan.zhihu.com/p/272135463

三、函数调用结构

上文简单复习了一下汇编和寄存器相关的内容。下面来正式来看看函数调用的底层是如何实现的!

注:这里的说明采用的是:

(一)函数调用

子函数调用时,调用者与被调用者的栈帧结构如下图所示:

在子函数调用时,需要切换上下文使得当前调用栈进入到一个新的执行中:

保存返回地址和保存上一栈帧的%rbp都是为了函数返回时,恢复父函数的栈帧结构(保存函数调用上下文)。

在使用高级语言进行函数调用时,由编译器自动完成上述整个流程;甚至对于”Caller Save”和“Callee Save”寄存器的保存和恢复,也都是由编译器自动完成的。

需要注意的是:父函数中进行参数压栈时,顺序是从后向前进行的(调用栈空间都是从大地址向小地址延伸,这一点刚好和堆空间相反)。

这一行为并不是固定的,是依赖于编译器的具体实现的。

至少在GCC中,使用的是从后向前的压栈方式,这种方式便于支持类似于printf(“%d,%d”,i,j) 这样的使用变长参数的函数调用。

以下面的函数为例:

void func() {}

void my_func() {
    func();
}

对应的汇编为:


func():
        push    rbp
        mov     rbp, rsp
        nop
        pop     rbp
        ret
my_func():
        push    rbp
        mov     rbp, rsp
        call    func()
        nop
        pop     rbp
        ret

在函数my_func和func中:开始的两句就是由编译器默认生成的切换上下文语句(函数my_func中也存在这个语句是因为它最终也会被其他函数s调用)。

当my-func函数调用func函数时:

函数开头的push rbp和mov rbp,rsp又叫做函数的序言(prologue),几乎每个函数一开始都会该指令。

它和函数最后的pop rbp和ret(epilogue)起到维护函数的调用栈的作用。

接下来,顺理成章的我们来看一下函数的返回过程。

(二)函数返回

函数返回时,我们只需要得到函数的返回值(保存在%rax中),之后就需要将栈的结构恢复到函数调用之差的状态,并跳转到父函数的返回地址处继续执行即可。

由于函数调用时已经保存了返回地址和父函数栈帧的起始地址,要恢复到子函数调用之前的父栈帧,我们只需要执行以下两条指令:


pop rbp
ret

首先执行pop rbp指令,直接将调用栈地址恢复至调用函数之前的状态。

随后通过ret指令跳转至返回地址处并执行。

(三)数据参数传递

在函数调用中,另一个需要关注的便是函数参数的传递:入参传递以及返回值传递。

函数在计算的时候,存储数据的地方总共有三个:

知道了数据的存储地方,那么数据的传递就分为以下四个方面:

注意:数据不能从内存直接传递到内存,如果需要从内存传递到内存,要以寄存器为中介!

同时需要注意的是:数据是有大小的!

比如:一个word是两个字节(16bit),double words是四个字节(32bit),quadruple words是八个字节(64bit)。

所以传递数据的时候,要知道传递的数据大小:

Intel格式的汇编会在数据前面说明数据大小:比如 mov DWORD PTR [rbp-4],4,意思是将一个4字节的4存储到栈上(地址为rbp-4)。

而AT&T格式是通过指令的后缀来说明,同样的指令为movl $4, -4(%rbp);并且存储的地方,AT&T汇编是通过前缀来区别,比如%q前缀表示寄存器,$表示立即数,()表示内存。

学习了数据的传递方式之后,让我们看看函数的调用习惯。

之前我们简单学习了一下Caller和Callee的区别,在这里我们会深入的学习。

首先,什么是函数调用约定?

在Caller调用Callee时,要将参数(arguements)传递给Callee,一个函数可以接收多个参数,而Caller与Callee之间约定的每个参数的应该怎么传递就是调用习惯;这样,Callee才能到指定的位置获取到相应的参数。

比如下面的代码:


int square(int num) {
    return num * num;
}

int main() {
    int i = 4;
    int j = square(i);
}

在main函数中调用square,参数i是如何传递到square中的?

上面的代码对应的汇编如下:


square(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     eax, DWORD PTR [rbp-4]
        imul    eax, eax
        pop     rbp
        ret
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     DWORD PTR [rbp-4], 4
        mov     eax, DWORD PTR [rbp-4]
        mov     edi, eax
        call    square(int)
        mov     DWORD PTR [rbp-8], eax
        mov     eax, 0
        leave
        ret

通过上面的汇编,我们可以知道:

在main里面,4先存到栈上(mov DWORD PTR [rbp-4],4),然后存在edi里面(mov eax,DWORD PTR [rbp-4]、mov edi,eax),而sqaure函数直接就从edi里面读取4的值了!

这就说明:参数4是通过寄存器edi传给了callee (sqaure) 。

可能有同学会以为,从代码看,参数不是直接就传给了sqaure吗?实际上,在汇编中,这个变量i是不存在的,只有寄存器和内存,因此我们需要约定好入参i的值存在哪里。

下面让我们来详细看看这些约定、常见寄存器负责传递的参数以及一些作用(前文简要介绍了一些):

在上面的列表中:

同时,如果函数返回比较大的对象,那么第一个参数rdi会用来传递存储这个对象的地址(这个地址是由caller分配的)。

有了这些基础,我们就更容易理解C++中的copy elision了。

相关阅读:

四、常见控制结构

在知道了函数参数是如何传递的之后,我们来更升一级。

下面根据具体代码来看一看我们经常使用的if、for、while等控制结构在底层是如何实现的。

if,while循环等控制结构,在汇编里面,都是基于判定语句,跳转语句实现的:做一个计算,检查相应的flag,然后根据flag的值确定要跳转到哪里。

比如下面的if语句:


int multiply(int j) {
    if (j > 6) {
        return j*2;
    } else {
        return j*3;
    }
}

对应的汇编语句如下:


multiply(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        cmp     DWORD PTR [rbp-4], 6
        jle     .L2
        mov     eax, DWORD PTR [rbp-4]
        add     eax, eax
        jmp     .L3
.L2:
        mov     edx, DWORD PTR [rbp-4]
        mov     eax, edx
        add     eax, eax
        add     eax, edx
.L3:
        pop     rbp
        ret

最前面和最后两条命令就是函数调用中的上下文切换,这个在前文中已经详细说明了。

函数的逻辑从第三条语句真正开始:

mov DWORD PTR [rbp-4],edi表示将寄存器edi中的4个字节的值(DWORD PTR)移至 [rbp-4] 对应内存地址中。

这里和上面所讲述的参数传递的约定是保持一致的,因为我们的入参j是int类型,只有32位,因此使用的是edi寄存器来传递的参数。

随后,使用cmp指令将内存中的数和立即数6进行比较(即,j>6),此指令会改变标志寄存器%eflags的状态。

然后jle会利用标志寄存器%eflags中的状态进行跳转:

无论是向下执行还是跳转至.L2执行,最终两者都会执行至.L3并返回。

下面再来看一个for循环的例子:


int add(int j) {
    int ret = 0;
    for (int i = 0; i < j; ++i) {
        ret+= i;
    }
    return ret;
}

对应的汇编如下:


add(int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-20], edi
        mov     DWORD PTR [rbp-4], 0
        mov     DWORD PTR [rbp-8], 0
        jmp     .L2
.L3:
        mov     eax, DWORD PTR [rbp-8]
        add     DWORD PTR [rbp-4], eax
        add     DWORD PTR [rbp-8], 1
.L2:
        mov     eax, DWORD PTR [rbp-8]
        cmp     eax, DWORD PTR [rbp-20]
        jl      .L3
        mov     eax, DWORD PTR [rbp-4]
        pop     rbp
        ret

从上面的汇编我们可以看到,入参j依旧是由寄存器edi传递,并存储在了内存[rbp-20]中。

随后两行分别初始化了参数ret:[rbp-4]、i:[rbp-8]。

紧接着,指令直接跳转至.L2处,首先比较了[rbp-8]和[rbp-20]中的值(即比较i和j):如果i<j则跳转至.L3处执行。

这里的判断是符合for循环的逻辑的:在进入for循环之前首先会判断一次条件。

.L3代码块是for循环的真正逻辑:

; ret += i;
mov     eax, DWORD PTR [rbp-8]
add     DWORD PTR [rbp-4], eax

; ++i
add     DWORD PTR [rbp-8], 1

其他控制结构的逻辑也是类似的,这里不再赘述了!

五、总结

本文首先简要复习了汇编以及通用寄存器相关的内容,随后进入到文章主题:函数调用。

在函数调用中讲述了函数调用中的调用和返回细节、上下文切换保护、函数传递等内容。

最后略微引申了函数中常见控制结构的底层实现。

参考资料:

1.《程序是怎样跑起来的》

2.《程序员的自我修养 : 链接、装载与库》

3.https://zhuanlan.zhihu.com/p/368962727

4.https://zhuanlan.zhihu.com/p/27339191

5.http://c.biancheng.net/view/3460.html

6.https://zhuanlan.zhihu.com/p/288636064

7.https://zhuanlan.zhihu.com/p/272135463

8.http://119.23.219.145/posts/%E8%AE%A1%E7%AE%97%E6%9C%BA%E4%BD%93%E7%B3%BB%E7%BB%93%E6%9E%84-x86-%E6%9E%B6%E6%9E%84%E7%9A%84%E8%AE%B2%E8%A7%A3/

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8