C&Golang函数调用过程详解(一)

161 篇文章 12 订阅

在聊C&Golang函数调用过程前,先看看以下几个问题:

  1. 不是主要聊goroutine调度原理么?为什么有涉及到C函数调用过程?

  2. CPU是如何从调用者跳转到被调用函数执行的?

  3. 参数在调用者和被调用函数之间是如何传递的?

  4. 函数局部变量所占内存在栈上是如何分配的?

  5. 被调用函数返回值是如何返回给调用者的?

  6. 函数执行完毕后需做哪些清理操作?

上述问题是否清楚,对理解goroutine的调度有非常重要的作用,接下来带着上述问题来聊聊C&Golang函数调用过程。

相对于Go,C更接近于硬件,编译后的的汇编代码也更简单直观,更易于让编码人员理解函数调用的基本原理,所以先聊完C中函数调用过程在汇编指令层面的实现原理,在此基础之上,聊Go函数调用过程就简单多了。

根据以下例子分析C中函数调用过程,如下:

#include <stdio.h>// 对参数 a 和 b 求和int sum(int a, int b){   int s = a + b;   return s;}// main函数:程序入口int main(int argc, char *argv[]){    int n = sum(1, 2); // 调用sum函数对求和    printf("n: %d\n", n);  //在屏幕输出 n 的值    return 0;}

可用gcc编译上述代码得到可执行程序call,之后使用gdb调试。

在gdb中通过disass main指令反汇编main函数,找到main的第一条指令的所在内存地址【0x0000000000400540】,然后使用b*0x0000000000400540在该地址打个断点然后执行程序:

bobo@ubuntu:~/study/c$ gdb ./call(gdb) disass mainDump of assembler code for function main:  0x0000000000400540 <+0>:push   %rbp  0x0000000000400541 <+1>:mov   %rsp,%rbp  0x0000000000400544 <+4>:sub   $0x20,%rsp  0x0000000000400548 <+8>:mov   %edi,-0x14(%rbp)  0x000000000040054b <+11>:mov   %rsi,-0x20(%rbp)  0x000000000040054f <+15>:mov   $0x2,%esi  0x0000000000400554 <+20>:mov   $0x1,%edi  0x0000000000400559 <+25>:callq 0x400526 <sum>  0x000000000040055e <+30>:mov   %eax,-0x4(%rbp)  0x0000000000400561 <+33>:mov   -0x4(%rbp),%eax  0x0000000000400564 <+36>:mov   %eax,%esi  0x0000000000400566 <+38>:mov   $0x400604,%edi  0x000000000040056b <+43>:mov   $0x0,%eax  0x0000000000400570 <+48>:callq 0x400400 <printf@plt>  0x0000000000400575 <+53>:mov   $0x0,%eax  0x000000000040057a <+58>:leaveq  0x000000000040057b <+59>:retq  End of assembler dump.(gdb) b *0x0000000000400540Breakpoint 1 at 0x400540(gdb) rStarting program: /home/bobo/study/c/callBreakpoint 1, 0x0000000000400540 in main ()

程序停止在了断点也就是main的第一条指令的位置,再次反汇编一下将要执行的main,来看最前面的三条指令:

(gdb) disassDump of assembler code for function main:=> 0x0000000000400540 <+0>:push   %rbp  0x0000000000400541 <+1>:mov   %rsp,%rbp  0x0000000000400544 <+4>:sub   $0x20,%rsp  ......

上述三条指令一般称为函数序言,基本上每个函数都以函数序言开始,其主要用于保存调用者的rbp寄存器以及为当前函数分配栈空间。

看下gdb输出的反汇编代码的组成部分:

  1. 指令地址

  2. 指令相对于当前函数起始地址以字节为单位的偏移

  3. 指令

以第一行指令【0x0000000000400540 <+0>: push   %rbp】为例,它表示main函数第一条指令【push   %rbp】在内存中的地址为0x0000000000400540,偏移量为0(因为是第一条指令),看个图理解下:


需要说明的是,gdb反汇编的结果输出的指令地址和偏移只是为了易读,存在内存以及被CPU执行的只有上图的指令部分,也就是【push  %rbp】。

上述反汇编结果第一行最左侧有个【=>】符合,它表示的是当前指令是CPU将要执行的下一条指令,寄存器rip中的值为0x0000000000400540,当前的状态就是上一条指令执行完毕,这一条指令还未开始执行,使用i r rbp rsp rip命令看rbp、rsp、rip寄存器中的值结果如下:

(gdb) i r rbp rsp riprbp   0x4005800x400580 <__libc_csu_init>rsp   0x7fffffffe5180x7fffffffe518rip   0x4005400x400540 <main>

根据这些寄存器的值,可推断出当前时刻函数调用栈、rbp、rsp、rip的状态和它们之间的关系,如下图所示:

寄存器rbp、rsp、rip存放的是内存地址,所以它们各相当于一个指针,由上图可知,rip指向的是main的第一条指令,rsp指向当前函数调用栈的栈顶,其中rbp并未指向重要的栈和指令,所以上图并未画出rbp的具体指向,只是表明了它的值。

接下来模拟CPU从main第一条指令开始,一直到执行完毕。

来看第一条指令。

0x0000000000400540 <+0>:push   %rbp # 保存调用者的rbp寄存器的值

上述指令将栈基地址寄存器rbp的值临时存在main的栈帧中,因为main需要使用rbp来存储自己的栈基地址,调用者也在调用main之前也将其栈基地址存在这个rbp中,所以main需要将这个rbp里面的值先保存起来,等main执行完毕返回时,再将这个rbp恢复原样,如果不恢复原样,main返回后调用者使用这个rbp就会出现问题,因为,在执行调用者的代码时,rbp应该指向调用者的栈,但现在却指向了main的栈。

在执行上述指令之前,代码还在使用调用者的栈帧,执行完毕之后,就开始使用main的栈帧,当前main栈帧里只保存调用者的rbp这一个值,在继续执行下条指令之前,栈和寄存器的状态如下图所示:

上图中飘红的指令表示的就是执行完毕的指令。

由上图可知,rsp和rip相较于之前都发生了改变,都指向了新的位置,rsp指向了main栈帧的起始位置,rip指向了main的第二条指令。

在【常用汇编指令基础,认识一下】一文中聊过,执行push指令会修改rsp的值,但不会修改rip,那为什么这里的rip的值改变了呢?

因为这是CPU自动完成的,CPU知道将要执行的每一条指令有几个字节,如这里的push  %rbp指令只有一个字节长,于是CPU在开始执行这条指令的时候就会将rip进行+1操作,因为执行这条指令之前rip的值为0x400540,+1之后就变成0x400541,也就是说它指向了main的第二条指令。

接下来执行第二条指令。

0x0000000000400541 <+1>:mov   %rsp,%rbp # 调整rbp寄存器,使其指向main函数栈帧的起始位置

上述指令将rsp的值拷贝给了rbp,让其指向了main栈帧的起始位置,这条指令完成后,rsp与rbp的值相同,都指向了main栈帧的起始位置,过程如下图:

再来执行第三条指令。

0x0000000000400544 <+4>:sub   $0x20,%rsp # 调整rsp寄存器的值,为局部和临时变量预留栈空间

上述指令将rsp的值减去32(16进制的0x20),使其指向了栈空间中一个更低的位置,看似只是简单的修改了rsp的值,实质上却是给main的局部变量和临时变量预留了32(0x20)字节的栈空间。

为什么说是预留而不是分配呢?

因为栈的分配是操作系统自动完成的,程序启动时操作系统就会分配一大块内存作为函数调用栈,至于程序到底使用了多少栈内存则有栈顶寄存器rsp来确定。

上述指令完成后,从rsp所指的位置到rbp所指的位置的这一段栈内存就构成了main的完整栈帧,其大小为40字节(8字节用于保存调用者rsp,32字节用于main局部变量和临时变量的存储),整个过程如下图所示: 

之后的四条指令一起执行来看看效果。

0x0000000000400548 <+8>:mov   %edi,-0x14(%rbp)0x000000000040054b <+11>:mov   %rsi,-0x20(%rbp)0x000000000040054f <+15>:mov   $0x2,%esi #sum函数的第2个参数放入esi寄存器0x0000000000400554 <+20>:mov   $0x1,%edi #sum函数的第1个参数放入edi寄存器

前两个指令负责把main得到的两个参数放到main的栈帧中,这里使用了rbp加上偏移量的方式来访问栈内存。

之所以需要保存两个参数,是因为调用者在调用main时使用了edi和rsi来分别给main传递了argc(整数)和argv(指数)两个参数,而main又需要两个寄存器来给sum函数传递参数,为了不覆盖argc(整数)和argv(指数)这两个参数,所以需要将其先存储在栈帧中,然后再将需要传递给sum的两个参数放到edi和rsi中。

后面两条指令在给sum函数准备参数,从指令中可以看出来,传递给sum的第一个参数放在了edi中,第二个参数放在了esi中。

到这里可能会有疑问了,被调用的函数sum如何知道这两个参数分别放在edi和esi中了呢?

说到底这只是一个约定而已。

调用者调用函数时,负责将第一个参数放在rdi,第二个参数放在rsi,而被调用函数则去这两个寄存器中取值。在上述指令中给sum传值的两个参数将值分别放到了edi和esi中,而不是rdi和rsi中,这是因为C中int是32位,rdi和rsi是64位,edi和esi可以分别当做rdi和rsi的一部分来使用。

执行完上述四条指令之后栈和寄存器的状态图如下所示:

上图中argc使用的是图中连续8字节中的高4字节,低4字节未使用。

sum要使用的参数准备好了之后,就可以运行call指令调用sum函数了。

0x0000000000400559 <+25>:callq 0x400526 <sum>  #调用sum函数

call指令比较特殊,刚开始执行它时rip是指向call下一条指令,也就是说rip中此时的值是0x40055e,但在call执行过程中,call会把rip中的0x40055e入栈,然后将rip修改为0x400526,也就是sum的第一条指令地址,这样一来,CPU就会跳到sum去执行。

call指令执行完毕后栈和寄存器的状态图如下所示:

从上图中可以看到,rip已经指向了sum函数的第一条指令,sum执行完成返回后需要执行的指令地址0x40055e也存到了main的栈帧之中。

好啦,到这里本文就结束了,喜欢的话就来个三连击吧。

扫码关注公众号,获取更多优质内容。    

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

luyaran

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值