Golang中函数调用约定与栈布局的困惑
Golang中函数调用约定与栈布局的困惑
package main
func main() {
var x int32 = 1
nop(x)
}
//go:noinline
func nop(x int32) {}
Go 版本:go1.16.15 windows/amd64
我编写了上面的代码,并使用 go1.16.15 tool compile -S -N -l main.go 将其编译为汇编代码,以真正理解 Go 函数的调用。
以下是 nop 函数的汇编代码:
"".nop STEXT nosplit size=1 args=0x8 locals=0x0 funcid=0x0
0x0000 00000 (main.go:9) TEXT "".nop(SB), NOSPLIT|ABIInternal, $0-8
0x0000 00000 (main.go:9) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:9) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (main.go:9) RET
显然,nop 函数直接返回,不做任何事情。它只有一个 int32 类型的参数,但在汇编中我们可以看到 argsize 是 8 字节:
TEXT “”.nop(SB), NOSPLIT|ABIInternal, $0-8
问题1: 为什么 argsize 是 8 字节而不是 4 字节?
以下是 main 函数的汇编代码:
"".main STEXT size=73 args=0x0 locals=0x18 funcid=0x0
0x0000 00000 (main.go:3) TEXT "".main(SB), ABIInternal, $24-0
;; ...省略栈分裂序言...
0x0016 00022 (main.go:3) SUBQ $24, SP
0x001a 00026 (main.go:3) MOVQ BP, 16(SP)
0x001f 00031 (main.go:3) LEAQ 16(SP), BP
0x0024 00036 (main.go:3) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0024 00036 (main.go:3) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0024 00036 (main.go:4) MOVL $1, "".x+12(SP)
0x002c 00044 (main.go:5) MOVL $1, (SP)
0x0033 00051 (main.go:5) PCDATA $1, $0
0x0033 00051 (main.go:5) CALL "".nop(SB)
0x0038 00056 (main.go:6) MOVQ 16(SP), BP
0x003d 00061 (main.go:6) ADDQ $24, SP
0x0041 00065 (main.go:6) RET
;; ...省略栈分裂尾声...
我根据汇编代码绘制了栈的示意图:

问题2: 我发现栈中存在令人困惑的 8 字节,为什么这 8 字节会存在?
我认为内存对齐导致了上述现象,但我不太确定。
问题3: 我认为 MOVL $1, "".x+12(SP) 中的 SP 是 “伪 SP”,那么为什么是 MOVL $1, "".x+12(SP) 而不是 MOVL $1, "".x-12(SP)?
更多关于Golang中函数调用约定与栈布局的困惑的实战教程也可以访问 https://www.itying.com/category-94-b0.html
更多关于Golang中函数调用约定与栈布局的困惑的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
在Go 1.16的ABIInternal调用约定中,函数栈帧布局确实有一些需要注意的细节。让我逐一解释你的问题:
问题1:为什么argsize是8字节而不是4字节?
在amd64架构上,Go使用8字节对齐的栈帧。即使参数是int32(4字节),在栈上分配时也会占用8字节的空间。这是因为:
- Go的栈操作以8字节为单位进行(SUBQ/ADDQ操作)
- 保持8字节对齐有利于内存访问性能
- 这是Go 1.17之前栈调用约定的特点
示例验证:
package main
//go:noinline
func testInt32(x int32) int32 {
return x
}
//go:noinline
func testInt64(x int64) int64 {
return x
}
func main() {
testInt32(1)
testInt64(2)
}
编译后可以看到两个函数的argsize都是8字节。
问题2:栈中8字节的困惑
你观察到的8字节空间是栈帧对齐和调用约定的结果。在main函数中:
$24-0表示24字节的局部变量空间- 栈布局如下:
SP+0 : 参数1 (为nop准备) SP+8 : 保存的BP SP+16 : 当前BP位置 SP+20 : 局部变量x (实际在SP+12,但占用4字节)
多出来的空间是为了保持8字节对齐和满足调用约定。
问题3:关于SP偏移的问题
在Go汇编中,SP始终指向栈顶(最低地址)。"".x+12(SP)中的+12表示从SP向高地址方向偏移12字节。
这是正确的,因为:
- SP是栈指针,指向栈的顶部(最低内存地址)
- 栈向低地址增长,所以局部变量在SP的正偏移位置
-12(SP)的语法在Go汇编中不存在
验证示例:
package main
//go:noinline
func frameTest() {
var a int64 = 1
var b int32 = 2
var c int16 = 3
_, _, _ = a, b, c
}
func main() {
frameTest()
}
编译查看汇编,可以看到所有局部变量都使用+offset(SP)的寻址方式。
关键点总结
- 8字节对齐:Go在amd64上使用8字节栈对齐,即使小类型也会占用8字节的栈槽
- 调用约定:参数和返回值都通过栈传递(Go 1.16)
- SP方向:SP指向栈顶,局部变量使用正偏移访问
- 帧布局:包含保存的BP、局部变量、参数空间等部分
这些设计选择主要是为了简化垃圾回收、栈扫描和调试信息的生成。从Go 1.17开始,Go转向了基于寄存器的调用约定,这些细节会有所不同。

