我们按照编程语言的语法定义的函数会被编译器编译为一堆堆机器指令,写入可执行文件,程序执行时可执行文件被加载到内存中,这些机器指令对应到虚拟地址空间中,位于代码段。
如果在一个函数中调用另外一个函数,编译器就会对应生成一条 call 指令,当程序执行到这条指令时,就会跳转到被调用函数的入口处开始执行,而每个函数的最后都有一条 ret 指令,负责在函数结束后跳回到调用处,继续执行。
函数执行时需要有足够的内存空间,供它存放局部变量、参数等数据,这段空间对应到虚拟地址空间的栈。栈只有一个口可供进出,先入栈的在底部,后入栈的在顶部,最后入栈的可以先被取出(先进后出,或后进先出)。
函数栈帧
运行时栈上面是高地址,向下增长,分配给函数的栈空间,被称为函数栈帧(stack frame),栈底通常称为栈基,栈顶又叫栈指针。
1
2
3
4
5
6
7
8
高地址
↓ ↓
函 ↓ ↓ 栈基(调用者)
数 ↓ 局部变量 ↓
栈 ↓ 返回值 ↓
帧 ↓ 参数 ↓
↓ 返回地址 ↓ 栈指针
↓ ↓
Go语言中函数栈帧布局是这样的:先是调用者栈基地址,接下来是局部变量,然后是调用函数的返回值,最后是参数。
调用函数的参数入栈顺序是由右至左,比如调用函数 add(a, b),会按照参数 b 、a 的顺序入栈(参数 b 先入栈)。
call 指令只做两件事,
将下一条的指令入栈,这就是返回地址,被调用函数执行结束后会跳回到这里。
跳转到被调用函数入口处执行,这后面就是被调用函数的栈帧了。
所有函数的栈帧布局都遵循统一的约定,被调用者通过栈指针加上偏移来定位到每个参数和返回值的。
程序执行时,CPU用特定的寄存器来存储运行时栈基和栈指针,同时也有指令指针寄存器用于存储下一条要执行的指令地址。
Go语言中函数栈帧不是逐步扩张的,而是一次性分配,也就是在分配栈帧时直接将栈指针移动到所需最大栈空间的位置。然后通过栈指针加上偏移值这种相对寻址的方式使用函数栈帧。
之所以一次性分配是为了避免栈访问越界。由于函数栈帧的大小可以在编译时期确定,对于栈消耗较大的函数Go语言的编译器会在函数头部插入检测代码,如果发现需要进行『栈增长』就会另外分配一段足够大的栈空间,并把原来栈上的数据拷贝过来,之前的栈空间就被释放了。
简单来说,函数通过call指令实现跳,而每个函数开始时会分配栈帧,结束前又会释放自己的栈帧,ret指令又会把栈恢复到call之前的样子。通过这些指令的配合能实现函数的层层嵌套,如果函数A又调用了函数A,就是递归调用栈了。
通常认为返回值是通过寄存器传递的,但是Go语言支持多返回值,所以在栈上分配值空间更合适。
defer 函数
函数最后由编译器插入的指令负责释放函数栈帧,恢复到调用者栈,但在这之前要给返回值赋值并执行defer函数,那谁先谁后呢?
答案是:先赋值。当代码中执行return时会先把局部变量拷贝到返回值空间,然后再执行注册的defer函数。
看下这个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func incr(a int) int {
var b int
defer func() {
a++
b++
}
a++
b = a
return b
}
func main() {
var a, b int
b = incr(a)
fmt.Println(a, b) //0, 1
}
当代码执行到 incr 函数最后 return 时,会先把 b 的值存到栈『返回值空间』中,然后在执行defer函数。defer函数中修改后b的值会存到栈『参数空间』中,所以并不会修改到『返回值空间』中的值,最终return的也是『返回值空间』的值。
上面是匿名返回值的情况,再看下命名返回值的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
func incr(a int) (b int) {
defer func() {
a++
b++
}
a++
return a
}
func main() {
var a, b int
b = incr(a)
fmt.Println(a, b) //0, 2
}
incr 函数执行到 return 之前 a 已经自增为1,然后执行 return 时会先把 a 赋值给返回值 b(返回值空间,此时为1),然后执行defer函数是 b 又自增了1,此时返回值空间里值为2,那么incr执行结束,返回值就为2。
了解了函数栈帧的布局以及返回值被赋值的时机,就不会搞不清楚这两种情况了。