x64函数调用过程分析

作者: CodingCode | 来源:发表于2017-05-19 12:21 被阅读1032次

    这篇文章主要介绍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的位置。

    相关文章

      网友评论

      • dc438ce0bf52:你好,请问如果传递的参数是%rdi %rsi装不下的struct,那参数传递的方式会是怎样的呢?
        CodingCode:这种场景比较复杂,如果struct的结构不大例如16字节,占用两个64位寄存器,而函数其他参数没有占用满寄存器,那么这个struct可以使用两个寄存器传递;而如果struct很大例如1000字节,那么寄存器肯定是装不下的,这时这个参数就会用堆栈传递。
      • 王一航:分析得非常棒 很透彻

      本文标题:x64函数调用过程分析

      本文链接:https://www.haomeiwen.com/subject/pxzdxxtx.html