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
        ;; ...省略栈分裂尾声...

我根据汇编代码绘制了栈的示意图: confusing 8 bytes

问题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

1 回复

更多关于Golang中函数调用约定与栈布局的困惑的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


在Go 1.16的ABIInternal调用约定中,函数栈帧布局确实有一些需要注意的细节。让我逐一解释你的问题:

问题1:为什么argsize是8字节而不是4字节?

在amd64架构上,Go使用8字节对齐的栈帧。即使参数是int32(4字节),在栈上分配时也会占用8字节的空间。这是因为:

  1. Go的栈操作以8字节为单位进行(SUBQ/ADDQ操作)
  2. 保持8字节对齐有利于内存访问性能
  3. 这是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字节。

这是正确的,因为:

  1. SP是栈指针,指向栈的顶部(最低内存地址)
  2. 栈向低地址增长,所以局部变量在SP的正偏移位置
  3. -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)的寻址方式。

关键点总结

  1. 8字节对齐:Go在amd64上使用8字节栈对齐,即使小类型也会占用8字节的栈槽
  2. 调用约定:参数和返回值都通过栈传递(Go 1.16)
  3. SP方向:SP指向栈顶,局部变量使用正偏移访问
  4. 帧布局:包含保存的BP、局部变量、参数空间等部分

这些设计选择主要是为了简化垃圾回收、栈扫描和调试信息的生成。从Go 1.17开始,Go转向了基于寄存器的调用约定,这些细节会有所不同。

回到顶部