汇编角度看函数调用

  我们以一个简单的例子来作为开始:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package main

func main() {
	add(1, 1)
}

func add(a, b int64) (c int64) {
	c = a + b
	return
}

  执行 go tool compile -S compliation_add.go 指令后,汇编代码如下

1
2
3
4
5
6
7
 ......
 0x0000 00000 (compliation_add.go:8)     MOVQ    "".b+16(SP), AX  #把变量b的值读取到寄存器ax中
 0x0005 00005 (compliation_add.go:8)     MOVQ    "".a+8(SP), CX   #把变量a的值读取到寄存器cx中
 0x000a 00010 (compliation_add.go:8)     ADDQ    CX, AX           #把寄存器cx和ax中的值相加,并把结果放回ax寄存器中
 0x000d 00013 (compliation_add.go:9)     MOVQ    AX, "".c+24(SP)  #把寄存器ax中的值写回变量c所在的内存
 0x0012 00018 (compliation_add.go:9)     RET                      #函数返回
 ......

  上文我们已经提到sp寄存器指向函数调用栈的栈顶地址

  现假设这个内存地址是X, 则第一条指令 MOVQ "".b+16(SP), AX 表示从起始地址为X + 16(1内存单元代表1B), 读取连续的8个字节的值到AX寄存器中

  MOVQ的后缀Q代表读取连续的8个字节,指令中的地址只是起始地址,代表变量b在内存中的地址;

  第二条指令类似,只是起始地址为X + 8(变量a在内存中的地址);

  最后一条指令表示把AX寄存器中的值写入从地址为X + 24开始的8个内存单元中。


  为什么变量a的起始地址不是X,而是X + 8个内存单元?这跟函数调用栈在内存中的布局有关

函数调用栈

  进程在内存中的布局如图所示

layout of process.png

  函数调用栈简称栈,在程序运行过程中,不管是函数的执行还是函数调用,栈都起着非常重要的作用,它主要被用来:

  1. 保存函数的局部变量;

  2. 向被调用函数传递参数;

  3. 返回函数的返回值;

  4. 保存函数的返回地址。返回地址是指从被调用函数返回后调用者应该继续执行的指令地址

  每个函数在执行过程中都需要使用一块栈内存用来保存上述这些值,我们称这块栈内存为某函数的栈帧(stack frame)。在AMD64 Linux平台下,栈是从高地址向低地址方向生长的。


AMD64 CPU提供了2个与栈相关的寄存器:

  • rsp寄存器(SP),始终指向函数调用栈栈顶

  • rbp寄存器(BP),一般用来指向函数栈帧的起始位置


下面用两个图例来说明一下函数调用栈以及rsp/rbp与栈之间的关系。

假设现在有如下函数调用链且正在执行函数C():

A()->B()->C() 则函数ABC的栈帧以及rsp/rbp的状态大致如下图所示(注意,栈从高地址向低地址方向生长): layout of stack1.png

对于上图,有几点需要说明一下:

  • 调用函数时,参数和返回值都是存放在调用者的栈帧之中,而不是在被调函数之中;

  • 目前正在执行C函数,且函数调用链为A()->B()->C(),所以以栈帧为单位来看的话,C函数的栈帧目前位于栈顶;

  • CPU硬件寄存器rsp指向整个栈的栈顶,当然它也指向C函数的栈帧的栈顶,而rbp寄存器指向的是C函数栈帧的起始位置;

  • 虽然图中ABC三个函数的栈帧看起来都差不多大,但事实上在真实的程序中,每个函数的栈帧大小可能都不同,因为不同的函数局部变量的个数以及所占内存的大小都不尽相同;

  • 有些编译器比如gcc会把参数和返回值放在寄存器中而不是栈中,go语言中函数的参数和返回值都是放在栈上的;


随着程序的运行,如果C、B两个函数都执行完成并返回到了A函数继续执行,则栈状态如下图:

layout of stack2.png

因为C、B两个函数都已经执行完成并返回到了A函数之中,所以C、B两个函数的栈帧就已经被POP出栈了,也就是说它们所消耗的栈内存被自动回收了。因为现在正在执行A函数,所以寄存器rbp和rsp指向的是A函数的栈中的相应位置。如果A函数又继续调用了D函数的话,则栈又变成下面这个样子:


layout of stack3.png


D函数的栈帧其实使用的是之前调用B、C两个函数所使用的栈内存,因为B和C函数已经执行完了,现在D函数重用了这块内存,这也是为什么在C语言中绝对不要返回函数局部变量的地址,因为同一个地址的栈内存会被重用,而go语言中没有这个限制(逃逸分析),当它发现程序返回了某个局部变量的地址,编译器会把这个变量放到堆上去。从上面的分析我们可以看出,寄存器rbp和rsp始终指向正在执行的函数的栈帧。


示例代码 add 函数的栈帧大小为0,当调用到 add 函数栈时,sp 寄存器指向函数调用栈的栈顶地址 Xadd 函数执行完返回到 main 函数的地址,则第一个参数变量a的起始地址为 X + 8
最后,执行 RET 指令。这一步把被调用函数add栈帧清零,接着,弹出栈顶的 返回地址 ,把它赋给指令寄存器rip ,而 返回地址 就是main函数里调用 add函数的下一行。

go汇编提供的虚拟寄存器

  除了这些跟AMD64 CPU硬件寄存器一一对应的寄存器外,go汇编还引入了几个没有任何硬件寄存器与之对应的虚拟寄存器,这些寄存器一般用来存放内存地址,引入它们的主要目的是为了方便程序员和编译器用来定位内存中的代码和数据。

  下面重点介绍在go汇编中常见的2个虚拟寄存器的使用方法:

FP虚拟寄存器:主要用来引用函数参数。go语言规定函数调用时参数都必须放在栈上,比如被调用函数使用first_arg+0(FP)来引用调用者传递进来的第一个参数,用second_arg+8(FP)来引用第二个参数 ,以此类推,这里的first_argsecond_arg仅仅是一个帮助我们阅读源代码的符号,对编译器来说无实际意义,+0+8表示相对于FP寄存器的偏移量。我们用一个runtime中的函数片段作为例子来看看FP的使用。

   go runtime中有一个叫gogo的函数,它接受一个gobuf类型的指针

1
2
3
4
5
// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $16-8
MOVQbuf+0(FP), BX// gobuf -->bx
......

  MOVQ buf+0(FP), BX 这一条指令把调用者传递进来的指针buf放入BX寄存器中,可以看到,在gogo函数是通过buf+0(FP)这种方式获取到参数的。从被调用函数(此处为gogo函数)的角度来看,FP与函数栈帧之间的关系如下图,可以看出FP寄存器指向调用者的栈帧,而不是被调用函数的栈帧。

SB虚拟寄存器:保存程序地址空间的起始地址。还记得在函数调用栈一节我们看过的进程在内存中的布局那张图吗,这个SB寄存器保存的值就是代码区的起始地址,它主要用来定位全局符号。go汇编中的函数定义、函数调用、全局变量定义以及对其引用会用到这个SB虚拟寄存器。对于这个虚拟寄存器,我们不用过多的关注,在代码中看到它时知道它是一个虚拟寄存器就行了。

函数定义

  还是以go runtime中的gogo函数为例:

1
2
3
4
// func gogo(buf *gobuf)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $16-8
......

  下面对这个函数定义的第一行的各部分做个说明:

  • TEXT runtime·gogo(SB):指明在代码区定义了一个名字叫gogo的全局函数(符号),该函数属于runtime包。

  • NOSPLIT:指示编译器不要在这个函数中插入检查栈是否溢出的代码。

  • $16-8:数字16说明此函数的栈帧大小为16字节,8说明此函数的参数和返回值一共需要占用8字节内存。因为这里的gogo函数没有返回值,只有一个指针参数,对于AMD64平台来说指针就是8字节。go语言中函数调用的参数和函数返回值都是放在栈上的,而且这部分栈内存是由调用者而非被调用函数负责预留,所以在函数定义时需要说明到底需要在调用者的栈帧中预留多少空间。