函数调用堆栈

函数调用主要由三部分实现:CPU指令+寄存器+堆栈

 

CPU指令主要有:

call类指令:跳转到被调用函数;需要说明的是call指令有两部分:先把下一条指令地址入栈,然后跳转到被调用函数。

ret类指令:释放栈空间,并且把call入栈的下一条指令赋值给PC寄存器。

(PC是16位程序计数器(Program Counter),它不属于特殊功能寄存器范畴,程序员不以像访问特殊功能寄存器那样来访问PC。PC是专门用于在CPU取指令期间寻址程序存储器。PC总是保存着下一条要执行的指令的16位地址。通常程序是顺序执行的,在一般情况下,当取出一个指令(更确切地说为一个指令字节)字节后,PC自动加1。如果在执行转移指令、子程序调用/返回指令或中断时,要把转向的地址赋给PC。 )

 

寄存器(32位,64位名称不一样)主要用到的有:

ebp,用作记录函数调用时当前函数的堆栈的最低位,也叫帧指针。需要说明的是,对于当前函数的ebp不会改变,所以对于参数以及调用函数存储的
        相关信息都是通过ebp的相对位置寻址或者表示的。

esp,用作记录函数调用时当前函数的堆栈的最高位。

eax,一般用做函数返回值的存储,在返回值大于4字节时协同edx一起返回,eax对应低4字节,edx对应高4字节;

         大于8字节的时候,调用函数会在自身栈上开辟空间,加一个隐藏参数传递给被调用函数,被调用函数把返回值拷贝到

         调用函数开辟的空间里面,最后把指针通过eax返回给调用者。

……

 

堆栈

堆栈是一种思想,粘合了寄存器以及CPU,让CPU在可控且有意义的运作,没有栈,函数调用就得重新做一套理论支持。

调用者被调用者栈的组织结构:

还是以经典的示例来写:

int sum(int x,int y)

{

    int temp = 0;

    temp = x+y;

    return temp;

}

int main()

{

int a=10;

int b=10;

int c=sum(a,b)

printf("c is %d\n",a+b);

}

重要的点:

 

call的时候调用函数负责把下一条指令地址入栈。

在执行被调用函数的时候的第一条指令是push %rbp,会把caller的rbp保存在堆栈中,并且rsp+4;

………………(功能语言)

%rsp+N(如果有需要)

leave会pop %rbp,把之前保存的caller的rbp值再次赋值到%rbp。

ret时pop %PC(?),把call的时候保存的下一个执行地址放到程序寄存器中,来执行。

 

 

优秀博文推荐:

https://www.jianshu.com/p/5a4f2d78cb53

拷贝过来:

这篇文章主要介绍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 nameregister
1ardi
2brsi
3crdx
4drcx
5er8
6fr9

这个传参过程是从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 calleecall指令调用函数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传递的
leaveleave完成两件事:把%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的位置。



作者:CodingCode
链接:https://www.jianshu.com/p/5a4f2d78cb53
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

 

 

  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值