Go 的函数调用:从内存地址讲到栈帧
很多人学 Go 的函数调用机制,一上来就撞见”栈从高地址向低地址增长””参数由调用方预留在自己的栈帧里””寄存器参数的 spill 空间”这类说法,读完只留下一堆名词。
先把这些名词放下,问一个更基本的问题:没有栈行不行?函数调用一定要靠栈来完成吗?这个问题有确切答案,答案也不是”必须”。把它想清楚之后,栈帧凭什么长成那个样子、偏移量为什么能在编译期定死、闭包变量为什么留不住,都会跟着有据可依。
这篇文章从内存地址开始,先回答上面这个问题,再走到栈帧布局、返回地址的保存和变量的分配位置。每个概念出现时先给定义再展开,中间穿插可以自己动手复算的例子。
而”要不要栈”这件事,本身就取决于参数往哪里放。这个问题 Go 自己给过两个答案。
Go 用过两套调用惯例。老的一套把参数和返回值放在内存里传,新的一套放在寄存器里传。
打个比方。你要把三份材料交给同事处理。老办法是放到两人之间的柜台上让对方来取,柜台就是栈内存,位置双方事先约定好,谁都不会拿错;新办法是直接递到手上,手就是寄存器,CPU 内部的少量高速存储。少了一放一取两趟内存访问,而函数调用极其频繁,Go 1.17 换过去之后,官方给出的收益是性能提升约 5%,体积减少约 2%。
本文讲栈帧,用的是老办法,也就是 Go 1.17 之前的栈式 ABI0。选它首先因为柜台看得见:参数、返回值、返回地址、局部变量在栈上依次排开,每一格都能对照地址算出来;递到手上的东西不落地,学的时候没有抓手。
更要紧的是,新办法并没有取消柜台。寄存器装不下的参数仍然走栈,即使装得下,栈上也要留出应急的落脚点,供栈扩容、GC 扫描和 traceback 使用。不先弄清柜台怎么摆,就看不懂这些残留是什么。ABI0 也不是历史遗迹,手写汇编至今按它工作,编译器会生成适配代码在两套之间转换。
一、内存地址:一切的起点
把内存想成一条极长的储物柜,每个格子存放 1 个字节,每个格子有一个固定的编号。这个编号就是内存地址(memory address)。CPU 读写数据时,靠的就是报出编号。
地址从 0 开始往上数,编号小的位置称为低地址,编号大的位置称为高地址。这两个词只描述编号的大小关系,不涉及任何物理上的高低。

图里 0x 开头是十六进制写法,内存地址习惯这么记,0x1000 换算成十进制是 4096。
一个变量通常占好几格。以 64 位平台为例,一个 int64 占 8 字节,若它从 0x1000 开始存放,就占用 0x1000 到 0x1007 这 8 格。我们说”这个变量的地址是 0x1000”,指的是它第一格的编号。
至于画图时为什么把高地址画在上面,这纯粹是约定俗成,类似温度计把大数字画在上端。换个人反过来画也不算错,只是绝大多数教材和文档都用前一种,看惯了更省事。
这些编号都是虚拟地址。 程序能打印、能存进指针变量的地址,全部是虚拟地址(virtual address)。在 Go 里打印一个堆上对象的指针,常见是 0xc000018030 这样的形式,它与内存条上的真实位置没有直接对应关系。翻译由 CPU 内部的 MMU(memory management unit,内存管理单元) 依据页表在每次访存时完成,内核只负责建表和处理缺页,不逐条参与转换。后文关于栈帧偏移和指针的全部讨论,都发生在虚拟地址这一层。
二、为什么需要栈
先看一段代码,后文会反复用到它。
1 | func main() { |
执行顺序是:main 调用 compute(3),compute 调用 double(3) 拿到 6,加 1 得到 7 返回给 main。
现在问一个基本问题:compute 的局部变量 y 应该放在哪里?
2.1 不用栈的第一条路:静态分配
最省事的办法是照全局变量办:编译期给每个函数的每个局部变量分配一个固定地址,compute 的变量在一处,double 的变量在另一处,地址不同,谁也覆盖不了谁。这种做法称为静态分配(static allocation),全程不需要栈。
它在两处失效。
第一处是递归。fact(5) 调用 fact(4),后者再调用 fact(3),此刻同一个函数有三份实例同时处于执行中,每份都有自己的 n。固定地址只有一个,存不下三份。早期 FORTRAN 采用静态分配,也就直接不支持递归。多个 goroutine 同时执行同一个函数,情况相同。
第二处是空间。静态分配要为程序里每一个函数常驻预留,不管它有没有被调用。真正同时在执行的只有调用链上那几层,其余预留在绝大部分时刻空着。
这条路今天仍在用。MISRA C:2012 有明确规则禁止递归,多数工业控制场景也限制动态分配,图的就是调用所需空间可以在编译期算死,运行时不会超限。代价就是接受上面两条限制。
2.2 不用栈的第二条路:分配到堆上
另一条路是把每次调用的局部状态放到堆上,调用结束后交给垃圾回收器处理。SML/NJ 采用续延传递风格编译,直接用堆分配活动记录,不需要控制栈,它没有递归和并发方面的限制。
代价不在分配动作本身。Appel 与 Shao 对两种方案的系统性测量显示,堆分配与栈分配活动记录的总执行开销接近,堆帧的实现还更简单;差别主要出在缓存局部性和垃圾回收负载上,随机器和实现而变。栈的优势在于分配与回收退化成一次指针加减,不依赖任何运行时结构,行为也不受垃圾回收时机影响。
所以准确的说法是:栈不是必需品,不用栈的程序可以正常运行。它是在特定前提下开销最低的那个方案。
2.3 栈成立的三个条件
- 每次调用需要一份独立的局部状态。少了这条,静态分配就够用,不需要栈。
- 这些状态的存活期严格后进先出(LIFO,last in first out):被调用方一定先于调用方结束,不会出现 compute 已经返回而 double 还在执行的情况。少了这条,后进先出的规律不成立,只能用堆。
- 分配和回收要足够便宜,因为函数调用的频率很高。
三条同时成立时,管理这些空间只需要一块连续内存加一个记录边界的指针:分配是指针往下移,回收是移回来,不需要空闲链表,也不需要分配器介入。这块内存就是栈(stack)。
一次函数调用期间,该函数在栈上独占的那段连续内存,就是栈帧(stack frame)。它有三个性质:
- 函数被调用时才分配,函数返回时立刻作废;
- 一次调用对应一块,递归的每一层、每个 goroutine 的每一层都是独立的一块;
- 分配与回收严格后进先出,管理它只需要移动一个指针。
回到 compute 调用 double 的场景:两者的栈帧位于栈上不同位置,double 只在自己那块里写,compute 的参数和中间结果不会被改动。这一点并非自动成立,靠的是一条约定:双方以栈指针为界,被调用方只使用界线以下的空间。界线的细节在 6.2 讲。
需要说明的是,栈并不只服务于函数调用,中断和信号处理同样依赖它。但在 Go 用户态代码这个尺度上,栈的存在理由就是上面这三个条件,后文不再扩展。
三、栈为什么从高地址往低地址增长
先说结论:这个方向不是 Go 决定的,也不是任何一门语言决定的,Go 在这件事上没有选择权。
3.1 方向由谁决定
约束是自下而上传递的:
| 层级 | 由谁定义 | 对栈方向的约束 |
|---|---|---|
| CPU 指令与寄存器 | 芯片架构方 | PUSH 与 CALL 的语义直接把栈指针往小地址推 |
| 平台 ABI | 平台规范组织 | 明文规定栈向低地址增长,并给出对齐要求 |
| 语言与编译器 | 语言实现团队 | 只能在既定方向内决定栈帧内布局,无权改方向 |
ABI 指应用程序二进制接口(Application Binary Interface),规定函数怎么传参、怎么使用栈、寄存器有哪些固定含义。ABI 是编译器与编译器之间的契约,规定编译好的机器码如何互相调用,从而让分开编译的代码能拼在一起运行。
3.2 历史上为什么选了向下
有两条主要原因。
第一条是让栈和堆相向而行。早期内存紧张,把代码和静态数据放在低地址一端,堆紧接着向上使用,栈从可用空间的最高端向下使用,两者中间共享同一块空闲区。谁用得多谁多占,不必事先给各自划死上限。若两者同向增长,就必须提前决定各自的容量上限,在内存稀缺的年代很浪费。

第二条是指令集顺势固化。一旦硬件的 PUSH、POP、CALL 按这个方向实现,后来的操作系统、调试器、异常处理机制全都建立在这个假设上,改动代价过高。
准确的说法是:向下增长是各主流平台 ABI 的一致选择,不是硬性的物理约束。
注意,上面那张相向增长的图画的是经典 C 程序的进程布局,Go 的情况需要单独说明:goroutine 的栈不是操作系统在创建线程时划出的那块线程栈,而是 Go 运行时自己分配管理的一小段连续内存。它和堆对象共享底层的页管理和地址空间,但独立记账,不作为垃圾回收的对象被分配和标记;栈的收缩由垃圾回收扫描栈时触发,这一点上两者并非完全无关。所以在 Go 程序里,成千上万个 goroutine 栈其实散落在运行时管理的内存区域中,并不真的和堆相向而行。
但在每一块 goroutine 栈的内部,规则完全一样:从这块内存的高地址端往低地址端使用,栈指针递减。后面所有关于栈帧的讨论,都发生在这个内部尺度上。
四、一次函数调用,栈上发生了什么
回到第二节那段代码:main 调用 compute(3),compute 调用 double(3) 拿到 6,加 1 得到 7 返回给 main。下面看这个过程在栈上的样子。
4.1 四个时刻的栈快照

彩色的那个栈帧属于当前正在执行的函数,它上面的都是等着它返回的调用者。
下面几步会频繁用到一个寄存器:SP(stack pointer,栈指针),它始终指向当前栈已使用区域的最低地址。四步的变化,本质上都是这个数字在动。
先说清一处命名差异,否则对照 x86 资料时会找不到对应关系。这个寄存器在 x86-64 手册里写作 RSP,低 32 位叫 ESP,低 16 位叫 SP,低 8 位叫 SPL(需 REX 前缀),它们是同一个寄存器的不同访问宽度;需要注意写 ESP 会清零高 32 位,写 SP 则保留高位。Go 的汇编器在 amd64 上把它写作 SP,go tool objdump 默认的 Plan 9 语法输出同样写作 SP(加 -gnu 才显示 %rsp)。本文沿用 Go 的写法,下文提到硬件 SP 时即指这个寄存器,与前面讲的伪 SP 区分开。
另外,别的材料常把这个位置称为栈顶。这里的顶是数据结构意义上的顶,指压栈和弹栈发生的那一端;由于栈向低地址增长,它在地址上是最低处,画图时落在图的下方。本文统一说最低地址,避免和图上的方位混淆。
① main 开始执行,栈上只有它一个栈帧。
② 执行到 compute(3),在 main 的栈帧下方(更低的地址)开出一块新空间,这就是”压入一个栈帧”。
③ compute 执行到 double(x),同样在下方再开一块。此时三个栈帧同时存在,只有 double 在执行。
④ double 算完返回 6,它的栈帧立刻作废。之后 compute 再返回 7,栈回到只剩 main 的状态。
把②和④换成具体数字。假设进入 compute 之前 SP 是 0x00c000040000,compute 的栈帧需要 48 字节,48 的十六进制是 0x30:
- 进入时 SP 减去 0x30,变成
0x00c00003ffd0,这个栈帧占用0x00c00003ffd0到0x00c00003ffff; - 返回时 SP 加回 0x30,恢复为
0x00c000040000。

所谓”栈帧作废”就是这最后一步。那块内存不再有人引用,下次谁用谁覆盖,不需要任何清理动作。整个分配与回收,就是一个寄存器里的数字减一下再加回来。这就是”栈上分配几乎没有成本”的具体含义,成立条件是栈帧大小在编译期已知,编译器能生成一条固定立即数的减法指令。
4.2 一个贯穿全文的前提:栈帧大小是编译期算好的
栈帧大小由编译器在编译期算定,变量在栈帧内的偏移随之固定。编译器因此能生成一份元数据,标明每个函数的栈帧里哪些位置存放指针。这份数据随二进制一起发布,垃圾回收和调用栈回溯都靠它读懂栈帧的内容,运行时不必自己记账。
五、返回地址是怎么保存的
这是栈帧里最抽象的一部分,需要单独讲清楚。
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 同时执行同一个函数也是如此,各自的栈帧在各自的栈上。
六、栈帧是怎么建起来又拆掉的
6.1 先分开四件事
进入细节之前,有四个概念必须分清楚,否则后面每一步都会卡住。
栈和栈帧不是一回事。 goroutine 的栈是 Go 运行时分配的一整块内存,起步 2KB。在任何函数调用发生之前就已经存在。栈帧是在这块内存上用 SP 划出的一段,没有申请动作,没有分配器参与,只有一个寄存器里的数字在变。
栈帧由被调用方自己开辟,不是调用方。 编译期算好每个函数的栈帧多大,运行时由这个函数自己划出来。调用方只做一件事:在自己栈帧的最低处放好参数。
栈上只有数据,没有指令。 CALL、RET 这些指令存放在代码段,程序启动时加载,运行期间只读。栈里存的是这些指令要用的数据。所以在栈帧的布局图上找不到 RET,就像找不到任何一条加法指令一样。
函数体访问栈帧,靠的是相对 SP 的固定偏移。 SP 指向本栈帧的最低地址,栈帧里所有位置都在它之上,偏移量全是正数:0(SP) 是最低处那一格,8(SP)、16(SP) 依次往高地址走。这些偏移是编译期算好的常数,直接写进指令,运行时不做任何计算或查找。也正因为如此,函数体执行期间 SP 必须保持不动,否则所有编译好的偏移量会同时失效。
6.2 SP 为什么始终指向栈帧最低处
先澄清一个常见误解:SP 不会随着函数往下执行而不断下移。
一个函数的栈帧大小在编译期就已算定,包含它全部的局部变量和为所有调用点预留的出参区。序言执行完,SP 一次性减到位,函数体执行期间通常不再变动,直到尾声才加回去。
说”通常”是因为这不是语言层面的保证:PUSHQ、POPQ 这类指令会实时改动 SP,编译器为函数体内每个程序点单独记账 SP 相对入口的位移,并把结果写进 pcsp 表(PC 到栈指针位移的映射表)。运行时按当前 PC 反查这张表得到栈帧大小,栈增长和 GC 扫描都依赖它。这张表按 PC 索引而不是存一个常数,本身就说明函数内 SP 是分段变化的。
SP 指向最低处,含义是”这条线以下没人占用”。
它是一条边界线:线以上是本函数正在使用的空间,线以下是空闲区。调用指令压入返回地址、被调用方开辟自己的栈帧,都往线以下去。如果 SP 指在栈帧中间,线下还有本函数在用的数据,下一次调用就会覆盖掉它。
这条线也是双方对话的基准。约定成”参数从当前 SP 往上数”,调用方按这个偏移写入,被调用方按同一个偏移读取,编译期就能各自算出确切位置,运行时不需要传递任何信息,也不需要查找。
这里有个容易踩的坑:Go 汇编里的 SP 有两个含义,判据是有没有符号前缀,不是偏移的正负。x-8(SP) 这种带符号名的形式指的是伪寄存器 SP,它的锚点是本栈帧局部变量区的最高地址,偏移可正可负,x+8(SP) 同样合法;不带符号名的 8(SP) 指的是硬件 RSP,锚点是栈帧最低地址,偏移为正。手写汇编和反汇编输出里两种写法都会出现。本文说的都是后一种。
arm64 上的情况不是分工反转,而是多做了一步消歧。两个架构上伪寄存器都叫 SP,区别在于硬件栈指针的名字:amd64 让 SP 一名两用,靠符号前缀区分;arm64 给硬件栈指针另起了 RSP 这个名字,SP 只留给伪寄存器。
需要注意 RSP 是 Go 汇编自造的名字。ARMv8-A 架构手册里这个寄存器就叫 SP,不存在 RSP 的写法。所以同样写 RSP,在 x86-64 手册里指的是硬件栈指针,在 ARM 手册里查不到。
出参区是复用的。 一个函数里有多次调用时,编译器取参数区最大的那个作为出参区大小(编译器内部记作 maxarg),位于栈帧最低处,每次调用复用同一块空间,不会调用一次就往下长一截。
复用的依据是编译器的活跃性分析(liveness analysis,判断某个栈槽在某个程序点之后是否还会被读取):只有当一个栈槽在下一次写入点处已经不再活跃,才允许覆盖。如果某次调用的返回值在后续调用之间仍然活跃,编译器会把它挪到局部变量区,不参与复用。调用串行只说明两次调用在时间上不重叠,不能推出覆盖安全,这一点需要区分开。
Go 1.17 起启用寄存器 ABI,参数主要走寄存器传递,栈上这块区域的角色更多是 spill 空间(供 GC 扫描、栈增长、defer 展开时溢出寄存器用)。maxarg 机制仍在。需要注意这块空间不会因为参数都进了寄存器就消失:按 Go 的 ABI 规范,调用方要为所有走寄存器的参数预留 spill 空间但不填充,理由是被调用方需要扩栈时,它自己帧里可能腾不出空间来完成溢出,所以必须由调用方提前留好。只要有寄存器参数,这块就不为 0。

这一切的前提是 SP 恒定。 假如函数体执行期间 SP 会变,所有编译期算好的偏移量会同时失效。C 的 alloca 和变长数组正是这种情况,所以那类函数必须依赖帧指针才能定位局部变量。Go 不提供任何变长栈分配手段,栈帧大小和 SP 都恒定,偏移量因而可以全部在编译期定死。
最后补一个反直觉的事实:访问栈帧里的变量不需要查任何表。 偏移量直接编码在指令的字节里,MOVQ 8(SP), AX 这条指令的机器码里就带着那个 8。CPU 取出 SP 的值加上它得到地址,读内存,全程没有查找。这条指令里的 AX 是一个通用寄存器(x86-64 手册里的 RAX),它和栈帧的结构没有关系,只是 Go 1.17 之后的调用约定把它定为第一个整型参数和第一个返回值的存放位置,反汇编里出镜频繁。4.2 提到的那份元数据是给别人用的:垃圾回收和调用栈回溯需要在函数之外看懂一个栈帧,它们事先不认识这个函数,指令里的立即数帮不上忙,所以才需要查表。高频路径把信息编进指令,低频路径才留表。
6.3 七个动作
先把源码和动作编号对上:
1 | func main() { |
配合这张图对照看:

三件事先讲清楚。
n := compute(3) 这一行占了三个动作。 一行 Go 代码会编译成多条机器指令:调用前要准备参数,调用后要接收结果,中间才是调用本身。所以①②⑦虽然分散在整个过程的两端,源头却是同一行。
③和⑤在源码里找不到对应的字符。 序言和尾声是编译器为每个函数自动生成的开头和结尾指令,负责开辟和撤销栈帧。你在 .go 文件里看不到它们,但每个需要栈空间的函数都有。
④里的 double(x) 会把这七步完整重复一遍,只是嵌套在 compute 内部。为了不绕,下面只跟 compute 这一层。
下面假设调用发生前 SP 是 0x00c000040000,compute 的栈帧需要 48 字节。
① main 把参数写好。 3 被写进 main 栈帧最低处那块专门留给 compute 的区域。这一步 SP 不动,因为那块空间早在 main 自己开始执行时就划好了。
② 调用指令执行。 SP 减 8 变成 0x00c00003fff8,返回地址写进 0x00c00003fff8 到 0x00c00003ffff 这 8 个字节,也就是图上②那一列新出现的紫色 return PC,然后跳到 compute 的第一条指令。这一步由 CALL 指令自动完成,编译器不为它生成额外代码。此刻 compute 还没有自己的栈帧,它只拿到了控制权。
③ compute 的序言执行。 SP 再减去 40,变成 0x00c00003ffd0。栈帧至此成型,占用 0x00c00003ffd0 到 0x00c00003ffff 这 48 个字节。序言还会顺手把进入时的帧指针存进栈帧里。
④ 函数体执行。 这期间 SP 一动不动。compute 靠相对 SP 的固定偏移访问栈帧里的每个位置:读参数 x、给 double 准备参数、把结果存进局部变量 y、最后把 y+1 的结果 7 写进返回值的位置。源码里的两行都在这一步完成。所以很重要一点:被调用方不接收任何”参数在哪”的信息。它只知道一件事:参数一定在自己的 SP 上方某个固定距离处,这个距离编译期就能算出来。
⑤ 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 存进自己的栈帧。这就是第五节说的”叶子函数可以省掉这一步”的原因。
6.4 栈帧的完整布局
把上面七步走完,compute 的栈帧就是这个样子。

调用方准备空间,双方分别填写各自负责的部分。 main 在跳进 compute 之前,先把 3 写进参数的位置,并为返回值预留出空间;compute 算完把 7 写进返回值的位置,然后返回;main 醒过来后从那里取走 7。两个函数靠这几处内存对话,不需要别的机制。
这里有个细节值得强调:参数是调用方填写的,返回值的位置是调用方预留的但由被调用方填写。两件事发生在同一块预留区里,但写入者不同。
最底下那一部分,就是下一层的参数区。 compute 栈帧最底下那个”给 double 准备的参数区”,从 double 的角度看,正是它自己栈帧里的”参数 v”。同一块内存,两个函数各叫各的名字。这也解释了为什么参数区在物理上属于调用方的栈帧:准备工作在调用发生之前就完成了,那时被调用方的栈帧还不存在。
保存的帧指针那一部分,用途和其他几部分不同。 帧指针(frame pointer)在 amd64 上是 RBP(Go 汇编里写作 BP),在 arm64 上是 R29,它指向本栈帧的固定基准位置。Go 维护它不是自己要用,而是为了和平台的 perf、调试器、profiler 互操作,这些工具依靠帧指针串起整条调用链。Go 自身的 panic 回溯和垃圾回收扫描栈走的是另一条路,依赖 4.2 提到的那份编译期元数据。这一点在 Go 1.21 之后需要补一句:运行时的执行追踪器在 amd64 和 arm64 上已默认改用帧指针回溯,把追踪开销从最高约 20% 降到 1% 量级,所以帧指针不再只服务于外部工具。不需要栈空间的叶子函数仍然可以省略这一部分,省掉之后 panic 回溯依然正常,受影响的是依赖帧指针的那部分工具和追踪路径。
上面按参数走栈描述,不只是为了让布局关系看得清楚。 Go 从 1.0 到 1.16 确实是这么实现的,所有参数和返回值一律走栈,不使用平台 ABI 的寄存器约定。Go 1.17 引入了内部的寄存器传参约定,当时只在 linux、darwin、windows 三个系统的 amd64 上启用;1.18 扩展到 arm64、ppc64 和 ppc64le,riscv64 到 1.19 才跟上。
启用之后,参数和返回值优先通过 CPU 寄存器传递,装不下的才落到栈上。走栈的参数与返回值保持原来的相对顺序,但整块区域多了一段新分区:栈上仍然要为每个走寄存器的参数预留位置,称为 spill 空间,用途是需要时有地方把寄存器里的值写下来,这一段排在栈上返回值之上。走寄存器的返回值则在栈上没有对应槽位,规范的说法是参数和返回值可以共用寄存器,但不共用栈空间。完整的分区顺序自低向高是:栈上接收者、栈上参数、对齐填充、栈上返回值、对齐填充、每个寄存器参数的 spill 空间、对齐填充。所以按上面这套布局理解栈帧结构不会产生方向性错误,只是实际反汇编时会看到参数在寄存器里传递。amd64 上用于整型参数和结果的是 RAX、RBX、RCX、RDI、RSI、R8、R9、R10、R11 这九个寄存器,浮点用 X0 到 X14。完整规则可以查 Go 源码里的 src/cmd/compile/abi-internal.md,注意这套内部约定不对外稳定,会随版本变化。
七、哪些变量不在栈帧里
前面反复提到”局部变量放在栈帧里”,但这句话有条件。看这段代码:
1 | func counter() func() int { |
x 声明在 counter 里,是个不折不扣的局部变量。但 counter 早就返回了,x 却还在被反复读写,三次调用累加出 1、2、3。它显然不可能待在 counter 的栈帧里,那块内存在 counter 返回的瞬间就作废了。

编译器的处理是把 x 分配到堆上,闭包对象里存一个指向它的指针。counter 的栈帧里只剩这个闭包对象的地址,栈帧作废后,堆上的东西被 main 里的 c 继续引用,直到没人用了才由垃圾回收器收走。
这个判断过程称为逃逸分析(escape analysis),在编译期完成,判断标准是:这个变量的地址会不会在函数返回后仍被使用。会,就分配到堆上;不会,就留在栈帧里。
用 go build -gcflags=-m 可以直接看到结论,对这段代码它会报告 x 逃逸到了堆上。
有两个常见误解值得澄清。
不是所有闭包都会导致逃逸。 如果闭包只在函数内部用完就丢,没有被返回也没有被存到外面,编译器可以把它和捕获的变量都留在栈上。上面这个例子逃逸,是因为闭包被返回了。
new 不保证堆分配。 分配位置完全由逃逸分析决定,与用哪个关键字无关。反过来也一样,取地址不等于逃逸,只有地址真的离开当前函数的生命周期才算。
回看 2.3 的三个条件,逃逸对应的是第 2 条不成立:x 的存活期越过了 counter 的返回,不再满足后进先出,栈的管理方式对它失效,编译器只能把它放到堆上。栈上分配之所以只是一次指针加减,前提就是变量的存活期不越过本次调用;不满足这个前提的变量,成本回到分配器和垃圾回收器那一侧。