函数基本结构
G语言中函数的声明的基本形式如下:
func name(parameter-list) (result-list) {
body
}
其中函数的类型被称为函数的签名。如果两个函数形式参数列表和返回值列表中的变量类型一一对应,那么这两个函数被认为有相同的类型或签名。
形参和返回值的变量名不影响函数签名,也不影响它们是否可以以省略参数类型的形式表示。
Go语言的实参通过值的方式传递,因此函数的形参是实参的拷贝,只有传递指针、slice(切片)、map、function、channel等类型才可能导致实参的修改。
函数栈帧布局
我们用Go语言写的函数,会被编译器编译为一堆机器指令,写入可执行文件,程序执行时,可执行文件被加载到内存,这些机器指令对应到虚拟地址空间中,位于代码段
如果在一个函数中调用另一个函数,编译器就会对应生成一条call
指令,程序执行到这条指令时,就会跳转到被调用函数处开始执行,而每个函数的最后都有一条ret
指令,负责在函数结束后跳回到调用处,继续执行。

由图中可以看出,一个栈帧的从栈底到栈顶的布局是:
调用者栈基 => 局部变量 => 被调用函数返回值 => 被调用函数参数
而之前说的call
指令,就只做两件事:
- 将下一条指定的地址入栈,这就是返回地址,被调用函数执行结束后会跳回到这里。
- 跳转到被调用函数的入口处开始执行
所有的函数的栈帧布局都遵循统一的约定,故被调用者是通过栈指针加上相应的偏移来定位到每个参数和返回值的
注意,返回地址是被CALL指令压栈的,故其既不属于调用者栈帧,也不属于被调用者栈帧的内容。
栈帧的内存分配
Go语言的栈不是逐步扩张的,而是一次性分配,也就是在分配栈帧时,直接将栈指针移动到所需最大栈空间的位置,然后通过栈指针+偏移值这种相对寻址方式使用函数栈帧。
之所以这样分配,就是为了防止出现上图中所示的访问越界的情况。
函数栈帧的大小,可以在编译时期确定,对于栈消耗较大的函数,Go语言的编译器会在函数头部插入检测代码,如果发现需要进行“栈增长”,就会另外分配一段足够大的栈空间,并把原来栈上的数据拷贝过来,并且将原来这段栈空间释放。
函数跳转与返回的实现
假设一个函数$A$在$a1$处调用函数$B$,起初栈内存布局以及寄存器情况如下:
代码执行到$a1$,调用call
指令,便会执行两步:
- 把下一条指令执行地址$a2$入栈保存起来,即保存到$s3$处
- 跳转到指令执行地址$b1$处
接着函数$B$代码开始执行,先把 $sp$ 向下移动24字节(这里是说明性演示,假设分配的栈帧大小即为24字节),为自己分配足够大的栈帧;接着执行$b2$这条指令,把调用者栈基(caller’s bp -> s1)
存到$sp+16$的地方,接下来$b3$指令把$sp+16$的地址存入栈基寄存器$bp$,接下来就可以执行函数$B$剩下的指令了。
执行完$B$剩余的指令后,在ret
指令之前,编译器还会插入两条指令:
- 恢复调用者$A$的栈基地址,它之前被存储在$sp+16$字节这里,这就是为什么栈帧布局第一条就是
caller’s bp
的原因,- 释放自己的栈帧空间,分配时向下移动多少,释放时就向上移动多少
接着就是ret
指令了,其首先是弹出call
指令压栈的返回地址,这里即为$sp$指向的$a2$;第二,指令指针寄存器跳转到这个返回地址,然后代码就可以重新从$a2$执行了
总的来说,函数通过
call
指令实现跳转,而每个函数开始时会分配栈帧,结束前又会释放自己的栈帧,ret
指令又会把call
恢复到call
之前的样子,通过这些指令的配合能够实现函数的层层嵌套。
参考
- 幼麟实验室的Golang合辑
- 《Go语言圣经》