0%

Go 的函数调用:从内存地址讲到栈帧

Go 的函数调用:从内存地址讲到栈帧

很多人学 Go 的函数调用机制,一上来就撞见”栈从高地址向低地址增长””参数由调用方预留在自己的栈帧里””寄存器参数的 spill 空间”这类说法,读完只留下一堆名词。问题往往不在于概念本身有多难,而在于最底下那块地基没铺:内存地址到底是什么,栈帧凭什么长成那个样子。

这篇文章从地址编号开始,一步步走到栈帧布局、返回地址的保存和变量的分配位置。每个概念出现时先给定义再展开,中间穿插可以自己动手复算的例子。

一、内存地址:一切的起点

把内存想成一条极长的储物柜,每个格子存放 1 个字节,每个格子有一个固定的编号。这个编号就是内存地址(memory address)。CPU 读写数据时,靠的就是报出编号。

地址从 0 开始往上数,编号小的位置称为低地址,编号大的位置称为高地址。这两个词只描述编号的大小关系,不涉及任何物理上的高低。

图里 0x 开头是十六进制写法,内存地址习惯这么记,0x1000 换算成十进制是 4096。

一个变量通常占好几格。以 64 位平台为例,一个 int64 占 8 字节,若它从 0x1000 开始存放,就占用 0x10000x1007 这 8 格。我们说”这个变量的地址是 0x1000”,指的是它第一格的编号。

至于画图时为什么把高地址画在上面,这纯粹是约定俗成,类似温度计把大数字画在上端。换个人反过来画也不算错,只是绝大多数教材和文档都用前一种,看惯了更省事。

这些编号都是虚拟地址。 程序能打印、能存进指针变量的地址,全部是虚拟地址(virtual address)。在 Go 里打印一个堆上对象的指针,常见是 0xc000018030 这样的形式,它与内存条上的真实位置没有直接对应关系。翻译由 CPU 内部的 MMU(memory management unit,内存管理单元) 依据页表在每次访存时完成,内核只负责建表和处理缺页,不逐条参与转换。后文关于栈帧偏移和指针的全部讨论,都发生在虚拟地址这一层。

二、栈为什么从高地址往低地址增长

先说结论:这个方向不是 Go 决定的,也不是任何一门语言决定的,Go 在这件事上没有选择权。

2.1 方向由谁决定

约束是自下而上传递的:

层级 由谁定义 对栈方向的约束
CPU 指令与寄存器 芯片架构方 PUSH 与 CALL 的语义直接把栈指针往小地址推
平台 ABI 平台规范组织 明文规定栈向低地址增长,并给出对齐要求
语言与编译器 语言实现团队 只能在既定方向内决定栈帧内布局,无权改方向

ABI 指应用程序二进制接口(Application Binary Interface),规定函数怎么传参、怎么使用栈、寄存器有哪些固定含义。ABI 是编译器与编译器之间的契约,规定编译好的机器码如何互相调用,从而让分开编译的代码能拼在一起运行。

2.2 历史上为什么选了向下

有两条主要原因。

第一条是让栈和堆相向而行。早期内存紧张,把代码和静态数据放在低地址一端,堆紧接着向上使用,栈从可用空间的最高端向下使用,两者中间共享同一块空闲区。谁用得多谁多占,不必事先给各自划死上限。若两者同向增长,就必须提前决定各自的容量上限,在内存稀缺的年代很浪费。

第二条是指令集顺势固化。一旦硬件的 PUSHPOPCALL 按这个方向实现,后来的操作系统、调试器、异常处理机制全都建立在这个假设上,改动代价过高。

准确的说法是:向下增长是各主流平台 ABI 的一致选择,不是硬性的物理约束。

注意,上面那张相向增长的图画的是经典 C 程序的进程布局,Go 的情况需要单独说明:goroutine 的栈不是操作系统在创建线程时划出的那块线程栈,而是 Go 运行时自己分配管理的一小段连续内存。它和堆对象共享底层的页管理和地址空间,但独立记账,不归垃圾回收器分配也不归它回收。所以在 Go 程序里,成千上万个 goroutine 栈其实散落在运行时管理的内存区域中,并不真的和堆相向而行。

但在每一块 goroutine 栈的内部,规则完全一样:从这块内存的高地址端往低地址端使用,栈指针递减。后面所有关于栈帧的讨论,都发生在这个内部尺度上。

三、一次函数调用,栈上发生了什么

先用这段代码举例

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
n := compute(3)
fmt.Println(n)
}

func compute(x int) int {
y := double(x)
return y + 1
}

func double(v int) int {
return v * 2
}

执行顺序是:main 调用 compute(3),compute 调用 double(3) 拿到 6,加 1 得到 7 返回给 main。

3.1 为什么需要栈帧

考虑这个问题:compute 执行到一半跑去调用 double,此时 compute 手上的东西(参数 x 的值 3、后续要用的中间结果)必须原封不动地留着,不能被 double 弄乱;double 自己也需要地方存放参数 v 和中间结果。

所以每个正在执行的函数都需要一块专属的临时空间。这块空间就是栈帧(stack frame),指一次函数调用期间,该函数在栈上独占的那段连续内存。它有三个性质:

  1. 函数被调用时才分配,函数返回时立刻作废;
  2. 一次调用对应一块,互不干扰;
  3. 分配在栈上,遵循后进先出的规律,这也是”栈”这个名字的由来。

3.2 四个时刻的栈快照

彩色的那个栈帧属于当前正在执行的函数,它上面的都是等着它返回的调用者。

下面几步会频繁用到一个寄存器:SP(stack pointer,栈指针),它始终指向当前栈已使用区域的最低地址。四步的变化,本质上都是这个数字在动。

① main 开始执行,栈上只有它一个栈帧。

② 执行到 compute(3),在 main 的栈帧下方(更低的地址)开出一块新空间,这就是”压入一个栈帧”。

③ compute 执行到 double(x),同样在下方再开一块。此时三个栈帧同时存在,只有 double 在执行。

④ double 算完返回 6,它的栈帧立刻作废。之后 compute 再返回 7,栈回到只剩 main 的状态。

把②和④换成具体数字。假设进入 compute 之前 SP 是 0x00c000040000,compute 的栈帧需要 48 字节,48 的十六进制是 0x30

  • 进入时 SP 减去 0x30,变成 0x00c00003ffd0,这个栈帧占用 0x00c00003ffd00x00c00003ffff
  • 返回时 SP 加回 0x30,恢复为 0x00c000040000

所谓”栈帧作废”就是这最后一步。那块内存不再有人引用,下次谁用谁覆盖,不需要任何清理动作。整个分配与回收,就是一个寄存器里的数字减一下再加回来。这就是”栈上分配几乎没有成本”的具体含义,成立条件是栈帧大小在编译期已知,编译器能生成一条固定立即数的减法指令。

3.3 一个贯穿全文的前提:栈帧大小是编译期算好的

栈帧大小由编译器在编译期算定,变量在栈帧内的偏移随之固定。编译器因此能生成一份元数据,标明每个函数的栈帧里哪些位置存放指针。这份数据随二进制一起发布,垃圾回收和调用栈回溯都靠它读懂栈帧的内容,运行时不必自己记账。

四、返回地址是怎么保存的

这是栈帧里最抽象的一部分,需要单独讲清楚。

double 执行完返回指令时,凭什么知道要回到 compute 而不是别处?靠的是调用发生时被记录下来的一个地址。

执行 CALL compute 时,CPU 会把这条 CALL 指令的下一条指令的地址保存下来,然后才跳去 compute。图里向左的那条箭头,就是 compute 执行 RET 时按这个地址跳回来的过程。

这个地址叫 return PC。PC 是 program counter(程序计数器)的缩写,指”下一条要执行的指令在哪”。在 x86 手册里这个寄存器叫 RIP,Go 的文档和运行时统一称为 PC。

保存的位置分两种情况。amd64 上,CALL 指令直接把地址压到栈上;arm64、riscv64 这类使用链接寄存器的架构,先把地址放进一个专用寄存器,再由被调用函数开头的几条指令存到栈上,若这个函数不再调用别人,还可以省掉存栈这一步。

由此也能解释递归为什么不会乱。递归调用时,栈上同时存在多份同一个函数的栈帧,每一份都有自己的 return PC、参数和局部变量,互不干扰。多个 goroutine 同时执行同一个函数也是如此,各自的栈帧在各自的栈上。

五、栈帧是怎么建起来又拆掉的

5.1 先分开四件事

进入细节之前,有四个概念必须分清楚,否则后面每一步都会卡住。

栈和栈帧不是一回事。 goroutine 的栈是 Go 运行时分配的一整块内存,起步 2KB,在任何函数调用发生之前就已经存在。栈帧是在这块内存上用 SP 划出的一段,没有申请动作,没有分配器参与,只有一个寄存器里的数字在变。

栈帧由被调用方自己开辟,不是调用方。 编译期算好每个函数的栈帧多大,运行时由这个函数自己划出来。调用方只做一件事:在自己栈帧的最低处放好参数。

栈上只有数据,没有指令。 CALL、RET 这些指令存放在代码段,程序启动时加载,运行期间只读。栈里存的是这些指令要用的数据。所以在栈帧的布局图上找不到 RET,就像找不到任何一条加法指令一样。

函数体访问栈帧,靠的是相对 SP 的固定偏移。 SP 指向本栈帧的最低地址,栈帧里所有位置都在它之上,偏移量全是正数:0(SP) 是最低处那一格,8(SP)16(SP) 依次往高地址走。这些偏移是编译期算好的常数,直接写进指令,运行时不做任何计算或查找。也正因为如此,函数体执行期间 SP 必须保持不动,否则所有编译好的偏移量会同时失效。

5.2 SP 为什么始终指向栈帧最低处

先澄清一个常见误解:SP 不会随着函数往下执行而不断下移。

一个函数的栈帧大小在编译期就已算定,包含它全部的局部变量和为所有调用点预留的出参区。序言执行完,SP 一次性减到位,此后整个函数体执行期间保持不动,直到尾声才加回去。

SP 指向最低处,含义是”这条线以下没人占用”。

它是一条边界线:线以上是本函数正在使用的空间,线以下是空闲区。调用指令压入返回地址、被调用方开辟自己的栈帧,都往线以下去。如果 SP 指在栈帧中间,线下还有本函数在用的数据,下一次调用就会覆盖掉它。

这条线也是双方对话的基准。约定成”参数从当前 SP 往上数”,调用方按这个偏移写入,被调用方按同一个偏移读取,编译期就能各自算出确切位置,运行时不需要传递任何信息,也不需要查找。

出参区是复用的。 一个函数里有多次调用时,编译器取参数区最大的那个作为出参区大小,每次调用复用同一块空间,不会调用一次就往下长一截。这是因为调用是串行的,上一次的返回值在下一次调用发生前就已被取走,覆盖是安全的。

这一切的前提是 SP 恒定。 假如函数体执行期间 SP 会变,所有编译期算好的偏移量会同时失效。C 的 alloca 和变长数组正是这种情况,所以那类函数必须依赖帧指针才能定位局部变量。Go 不提供任何变长栈分配手段,栈帧大小和 SP 都恒定,偏移量因而可以全部在编译期定死。

最后补一个反直觉的事实:访问栈帧里的变量不需要查任何表。 偏移量直接编码在指令的字节里,MOVQ 8(SP), AX 这条指令的机器码里就带着那个 8。CPU 取出 SP 的值加上它得到地址,读内存,全程没有查找。3.3 提到的那份元数据是给别人用的:垃圾回收和调用栈回溯需要在函数之外看懂一个栈帧,它们事先不认识这个函数,指令里的立即数帮不上忙,所以才需要查表。高频路径把信息编进指令,低频路径才留表。

5.3 七个动作

先把源码和动作编号对上:

1
2
3
4
5
6
7
8
9
func main() {
n := compute(3) // ① ② ⑦
fmt.Println(n)
}

func compute(x int) int { // ③ 序言,编译器插入,源码里看不到
y := double(x) // ④
return y + 1 // ④
} // ⑤ ⑥ 尾声与返回,同样由编译器插入

配合这张图对照看:

三件事先讲清楚。

n := compute(3) 这一行占了三个动作。 一行 Go 代码会编译成多条机器指令:调用前要准备参数,调用后要接收结果,中间才是调用本身。所以①②⑦虽然分散在整个过程的两端,源头却是同一行。

③和⑤在源码里找不到对应的字符。 序言和尾声是编译器为每个函数自动生成的开头和结尾指令,负责开辟和撤销栈帧。你在 .go 文件里看不到它们,但每个需要栈空间的函数都有。

④里的 double(x) 会把这七步完整重复一遍,只是嵌套在 compute 内部。为了不绕,下面只跟 compute 这一层。

下面假设调用发生前 SP 是 0x00c000040000,compute 的栈帧需要 48 字节。

① main 把参数写好。 3 被写进 main 栈帧最低处那块专门留给 compute 的区域。这一步 SP 不动,因为那块空间早在 main 自己开始执行时就划好了。

② 调用指令执行。 SP 减 8 变成 0x00c00003fff8,返回地址写进 0x00c00003fff80x00c00003ffff 这 8 个字节,也就是图上②那一列新出现的紫色 return PC,然后跳到 compute 的第一条指令。这一步由 CALL 指令自动完成,编译器不为它生成额外代码。此刻 compute 还没有自己的栈帧,它只拿到了控制权。

③ compute 的序言执行。 SP 再减去 40,变成 0x00c00003ffd0。栈帧至此成型,占用 0x00c00003ffd00x00c00003ffff 这 48 个字节。序言还会顺手把进入时的帧指针存进栈帧里。

④ 函数体执行。 这期间 SP 一动不动。compute 靠相对 SP 的固定偏移访问栈帧里的每个位置:读参数 x、给 double 准备参数、把结果存进局部变量 y、最后把 y+1 的结果 7 写进返回值的位置。源码里的两行都在这一步完成。

⑤ compute 的尾声执行。 恢复帧指针,把 SP 加回 40,回到 0x00c00003fff8。栈帧体撤销了,但返回地址还留在栈上。

⑥ 返回指令执行。 从 SP 指向的位置读出 8 个字节,SP 加 8 回到 0x00c000040000,控制权回到 main。

⑦ main 取走返回值。 main 用当前 SP 加上编译期确定的偏移,读出返回值 7,存进 n 所在的位置。至此 n := compute(3) 执行完毕。

整个过程 SP 减两次加两次,走的是一条 V 字形轨迹,起点和终点是同一个数。栈帧的建立与撤销,全部由这四次加减完成,没有任何一步需要向分配器申请内存。

第⑥步有两点容易搞反,值得展开。

方向是从栈到 PC,不是从 PC 到栈。 PC 是一个寄存器,x86 上叫 RIP,它保存的是”当前执行到哪里了”,每执行一条指令就变一次,并不保存”该返回到哪里”。返回指令的动作恰好相反:从栈上读出返回地址,装进 PC 寄存器,让 CPU 跳过去。所以栈上那 8 个字节不是冗余,它是返回指令唯一的信息来源。

为什么不干脆找个寄存器存着。 因为寄存器只有一个,而调用可以嵌套任意深。main 调 compute,compute 调 double,此刻需要同时保存两个返回地址,一个寄存器装不下。只有栈这种后进先出的结构能容纳任意深度的嵌套,深几层就存几个。arm64 把这一点体现得更直白:它的调用指令把返回地址写进 LR 寄存器,如果被调用的是叶子函数,不再调用别人,LR 不会被覆盖,确实可以不存栈;但 compute 要调用 double,double 的调用指令会覆盖 LR,所以 compute 的序言必须先把 LR 存进自己的栈帧。这就是第四节说的”叶子函数可以省掉这一步”的原因。

5.4 栈帧的完整布局

把上面七步走完,compute 的栈帧就是这个样子。

调用方准备空间,双方分别填写各自负责的部分。 main 在跳进 compute 之前,先把 3 写进参数的位置,并为返回值预留出空间;compute 算完把 7 写进返回值的位置,然后返回;main 醒过来后从那里取走 7。两个函数靠这几处内存对话,不需要别的机制。

这里有个细节值得强调:参数是调用方填写的,返回值的位置是调用方预留的但由被调用方填写。两件事发生在同一块预留区里,但写入者不同。

最底下那一部分,就是下一层的参数区。 compute 栈帧最底下那个”给 double 准备的参数区”,从 double 的角度看,正是它自己栈帧里的”参数 v”。同一块内存,两个函数各叫各的名字。这也解释了为什么参数区在物理上属于调用方的栈帧:准备工作在调用发生之前就完成了,那时被调用方的栈帧还不存在。

保存的帧指针那一部分,用途和其他几部分不同。 帧指针(frame pointer)在 amd64 上是 RBP,在 arm64 上是 R29,它指向本栈帧的固定基准位置。Go 维护它不是自己要用,而是为了和平台的 perf、调试器、profiler 互操作,这些工具依靠帧指针串起整条调用链。Go 自身的调用栈回溯走的是另一条路,依赖 3.3 提到的那份编译期元数据。所以不需要栈空间的叶子函数可以省略这一部分,省掉之后 Go 程序的回溯依然正常,只是外部工具的效果会受影响。

上面按参数走栈描述,是为了让布局关系看得清楚。 Go 1.17 之后,参数和返回值优先通过 CPU 寄存器传递,装不下的才落到栈上,各部分的相对顺序不变。栈上仍然为寄存器参数预留着位置,称为 spill 空间,用途是需要时有地方把寄存器里的值写下来。所以按经典布局理解栈帧结构不会产生方向性错误,只是实际反汇编时会看到参数在寄存器里传递。完整规则可以查 Go 源码里的 src/cmd/compile/abi-internal.md,注意这套内部约定会随版本变化。

六、哪些变量不在栈帧里

前面反复提到”局部变量放在栈帧里”,但这句话有条件。看这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
func counter() func() int {
x := 0
return func() int {
x++
return x
}
}

func main() {
c := counter()
fmt.Println(c(), c(), c()) // 1 2 3
}

x 声明在 counter 里,是个不折不扣的局部变量。但 counter 早就返回了,x 却还在被反复读写,三次调用累加出 1、2、3。它显然不可能待在 counter 的栈帧里,那块内存在 counter 返回的瞬间就作废了。

编译器的处理是把 x 分配到堆上,闭包对象里存一个指向它的指针。counter 的栈帧里只剩这个闭包对象的地址,栈帧作废后,堆上的东西被 main 里的 c 继续引用,直到没人用了才由垃圾回收器收走。

这个判断过程称为逃逸分析(escape analysis),在编译期完成,判断标准是:这个变量的地址会不会在函数返回后仍被使用。会,就分配到堆上;不会,就留在栈帧里。

go build -gcflags=-m 可以直接看到结论,对这段代码它会报告 x 逃逸到了堆上。

有两个常见误解值得澄清。

不是所有闭包都会导致逃逸。 如果闭包只在函数内部用完就丢,没有被返回也没有被存到外面,编译器可以把它和捕获的变量都留在栈上。上面这个例子逃逸,是因为闭包被返回了。

new 不保证堆分配。 分配位置完全由逃逸分析决定,与用哪个关键字无关。反过来也一样,取地址不等于逃逸,只有地址真的离开当前函数的生命周期才算。