×
广告

x64函数调用过程分析

96
CodingCode
2017.05.19 12:21* 字数 1732

这篇文章主要介绍x64平台下函数调用的过程。
主要内容包括caller如何完成到callee的转换,两者之间参数传递方式,函数的栈分配模型,以及callee如何返回到caller。


还是以一个例子来说明(为了简化说明过程,全部参数和局部变量均采用long类型,主要是因为其大小正好是8字节和寄存器大小一致,另外浮点的传参规范使用的是浮点寄存器,不在这篇文章里面讨论)。

long callee(long i, long j) {
    long k;
    long l;
    i = 3333L;
    j = 4444L;
    k = 5555L;
    l = 6666L;
    return 9999L;
}

void caller() {
    long r = foo(...);
}

caller函数调用callee的汇编代码:

long ret = foo(1111L, 2222L);   

先介绍一点x64的通用寄存器集:
x64的寄存器集有16个通用寄存器,即rax, rbx, rcx, rdx, rbp, rsp, rsi, rdi, r8, r9, r10, r11, r12, r13, r14, r15。
乍一看这个寄存器排列毫无章法,命名也不整齐;了解RISC处理器的同学应该都比较喜欢RISC的寄存器命名,r0, r1, ..., r15或者r0, r1, ..., r31,简单直白。x64主要是借鉴的RISC处理器的一些特点,增加了通用寄存器的个数,然后又为了兼容历史版本,导致现在我们看到的通用寄存器命名不规范。早期x86处理器就没有这么多寄存器,也没有通用寄存器概念,基本都是专用寄存器即每个寄存器都有专门的用途,因为CISC不是采用load/store结构,大量指令都是直接操作内存运算的。

x64的函数传参规范:

  • 对于整数和指针类型参数, x64使用6个寄存器传递前6个参数。
    第一个参数使用rdi,第二个参数使用rsi,第三、四,五,六个参数依次使用rdx, rcx, r8, r9;从第七个开始通过栈传递,因此如果函数参数不超过6个,那么所有参数都是通过寄存器传递的。比如函数:
    void callee(int a, int b, int c, int d, int e, int f);
param # param name register
1 a rdi
2 b rsi
3 c rdx
4 d rcx
5 e r8
6 f r9

这个传参过程是从RISC处理器里面借鉴过来的,RISC处理器一般采用寄存器传参,比如ARM就使用四个寄存器R0-R4传参,而早期的x86系统都是使用栈传参的。
至于为什么x64传参使用的寄存器命名这么没有规则,主要是为了和之前的x86处理器兼容,x86系统的ABI已经定义过一套寄存器使用规范。

先看caller生成的汇编指令:

    movq    $2222, %rsi
    movq    $1111, %rdi
    call    callee
    movq    %rax, -4(%rbp)

代表含义如下:

........instruction........ description
movq $2222, %rsi 把第二个参数值2222放在寄存器rsi,前面说过第二个参数使用rsi传递
movq $1111, %rdi 把第一个参数值1111放在寄存器rdi,第一个参数使用rdi传递
call callee call指令调用函数callee;call指令完成两件事情:把当前指令的下一条指令(即将来callee函数的返回地址)压栈,然后把pc指向callee函数的入口,开始执行callee函数代码
movq %rax, -4(%rbp) 读取callee的返回值,函数返回值通过寄存器rax传递

再看callee的汇编代码:

    pushq   %rbp
    movq    %rsp, %rbp
    movq    %rdi, -24(%rbp)
    movq    %rsi, -32(%rbp)
    movq    $3333, -24(%rbp)
    movq    $4444, -32(%rbp)
    movq    $5555, -16(%rbp)
    movq    $6666, -8(%rbp)
    movq    $9999, %rax
    leave
    ret

这些指令大致分为三大块,第一块入口指令,第二块函数功能代码,第三块返回指令;指令含义如下:

.........instruction......... description
pushq %rbp 保存caller的%rbp寄存器值,这个%rbp在函数返回给caller的时候需要恢复原来值,通过leave指令完成。
moveq %rsp, %rbp 把当前的%rsp作为callee的%rbp值
moveq ..., offer(%rbp) 这些moveq指令都是callee函数体的功能,不细说
movq $9999, %rax 设置函数的返回值到%rax,函数返回值是通过寄存器%rax传递的
leave leave完成两件事:把%rbp的值move到%rsp,然后从栈中弹出%rbp;这条指令的功能就是恢复到caller的frame结构,即把%rsp和%rbp恢复到caller函数的值
ret 指令负责从栈中弹出返回地址,并且跳转的返回地址。

下面我们详细一步一步介绍函数调用过程中,寄存器和函数栈的变化过程:

按照习惯下面步骤中的图示代码段地址从上往下以递增的方式排列,栈地址从上往下以递减的方式排列。

  1. call callee指令之前
    此时pc指向call指令,需要传递的参数已经放到传参寄存器,栈是caller的frame。


    1.jpg
  2. call callee指令之后
    call指令完成两件事情,1: 把返回地址压栈,可以看到在栈顶0x4005f6正是call指令的下一个指令地址;2: pc指向函数callee的第一条指令。


    2.jpg
  3. pushq %rbp指令之后
    把当前rbp的值压入栈,并且pc向前移动到下一条指令。


    3.jpg
  4. movq %rsp, %rbp指令之后
    移动rbp到当前rsp地址, 此时rbp和rsp指向同一个地址;rbp就是callee的frame地址,后面callee函数内都将通过rbp加上偏移的方式来访问局部变量。例如:
    movq $3333, -24(%rbp)
    movq $4444, -32(%rbp)


    4.jpg

5 执行函数体功能指令,例如:
movq %rdi, -24(%rbp)
movq %rsi, -32(%rbp)
这个时候我们可以清楚的看到,callee是如何分配栈空间的,rbp往下首先是局部变量,然后是参数预留空间。


5.jpg

6 movq $9999, %rax指令之后
这条指令就是把函数返回值放到寄存器rax,在这个例子中9999=0x270f;前面我们说过函数返回值都是通过rax返回的。


6.jpg

7 leave指令之后
leave指令完成两件事,1:把%rbp的值move到%rsp,在当前这个例子中,这个功能没有效果,因为 %rbp和%rsp的值始终相同。2:然后从栈中弹出%rbp。


7.jpg

8 ret指令之后
ret指令也是完成两件事情,1:栈中弹出返回地址,2:并且跳转的返回地址。


8.jpg

我们可以看到此时栈结构和函数进来之前是一样的,从而保证callee返回以后caller能够继续执行。


这个callee的代码其实有一点问题,不知道你有没有注意的,那就是callee只是调整了%rbp,但并没有调整%rsp,使得%rsp并没有真正指向栈顶,而是自始至终%rsp和%rbp指向同一个地址,按照前面的逻辑callee进来的时候保存了caller的%rbp和%rsp,并且在返回时需要恢复原来的值,而就是说%rbp和%rsp通常成对出现构成一个frame范围,那么这个callee为什么会这样呢?
原因是callee是一个叶子函数,它不再调用其他函数,就是说从进入这个函数到离开这个函数之间不会发生栈的操作,设置%rsp的操作就可以省略。
我们修改一下代码,添加一个子函数sub()让callee来使用:

void sub() {
}

long callee(long i, long j) {
    long k;
    long l;

    i = 3333L;
    j = 4444L;
    k = 5555L;
    l = 6666L;

    sub();
    return 99L;
}

生成的callee代码如下:

    pushq   %rbp
    movq    %rsp, %rbp
    subq    $32, %rsp
    movq    %rdi, -24(%rbp)
    movq    %rsi, -32(%rbp)
    movq    $3333, -24(%rbp)
    movq    $4444, -32(%rbp)
    movq    $5555, -16(%rbp)
    movq    $6666, -8(%rbp)
    movl    $0, %eax
    call    sub
    movl    $9999, %eax
    leave
    ret

相比较前面的callee代码,此时多了一条指令:
subq $32, %rsp
这条指令就是调整函数callee的新的%rsp值,使得%rbp和%rsp之间构成一个标准的callee函数frame范围。栈结构如下:

9.jpg

其实栈的内容和前面没有call sub的栈内容是一样的,只是调整了%rsp的指针,因为callee已经不是叶子函数了,它需要调用sub函数,这个过程中是有栈的操作的,所以必须把%rsp指向正确的位置。然后在函数返回的时候leave指令能够再把%rsp重新调整到%rbp的位置。

C/C++
Web note ad 1