arm 中的 go 堆栈展开

76 阅读4分钟

背景

在 arm64 环境,go 无法通过 fp 进行堆栈展开, github.com/golang/go/i…

目前最新的 ebpf agent,通过 eh_frame 格式进行堆栈展开,也不支持 go: github.com/parca-dev/p…

通过 eh_frame 进行堆栈展开的原理: www.polarsignals.com/blog/posts/…

通过 debug_frame 可以支持 arm64 环境下的 go 堆栈展开。

实现

程序:

package main

func main() {
    a()
}

func a() {
    b()
}

禁止内联优化:

go build -gcflags="all=-l"

汇编:

TEXT main.main(SB) /root/testjson/main.go
func main() {
  0x6eec0               f9400b90                MOVD 16(R28), R16
  0x6eec4               eb3063ff                CMP R16, RSP
  0x6eec8               54000109                BLS 8(PC)
  0x6eecc               f81f0ffe                MOVD.W R30, -16(RSP)
  0x6eed0               f81f83fd                MOVD R29, -8(RSP)
  0x6eed4               d10023fd                SUB $8, RSP, R29
        a()
  0x6eed8               9400000a                CALL main.a(SB)
}
  0x6eedc               a97ffbfd                LDP -8(RSP), (R29, R30)
  0x6eee0               910043ff                ADD $16, RSP, RSP 
  0x6eee4               d65f03c0                RET
func main() {
  0x6eee8               aa1e03e3                MOVD R30, R3
  0x6eeec               97fff591                CALL runtime.morestack_noctxt.abi0(SB)
  0x6eef0               17fffff4                JMP main.main(SB)
  0x6eef4               00000000                ?
  0x6eef8               00000000                ?
  0x6eefc               00000000                ?

TEXT main.a(SB) /root/testjson/main.go
func a() {
  0x6ef00               f9400b90                MOVD 16(R28), R16
  0x6ef04               eb3063ff                CMP R16, RSP
  0x6ef08               54000109                BLS 8(PC)
  0x6ef0c               f81f0ffe                MOVD.W R30, -16(RSP) // 当前 lr 的值,表示的是 0x6eedc,也就是下一条指令地址,将其放入 sp - 16 中,执行后 sp 减小 16,增加了堆栈大小
  0x6ef10               f81f83fd                MOVD R29, -8(RSP)
  0x6ef14               d10023fd                SUB $8, RSP, R29
        b() 
  0x6ef18               9400000a                CALL main.b(SB) // 执行完这个指令后 lr 的值就变了,lr 的值为 0x6ef1c 
}
  0x6ef1c               a97ffbfd                LDP -8(RSP), (R29, R30) // 恢复 R30 的值
  0x6ef20               910043ff                ADD $16, RSP, RSP // 增大 sp,释放空间
  0x6ef24               d65f03c0                RET 
func a() {
  0x6ef28               aa1e03e3                MOVD R30, R3
  0x6ef2c               97fff581                CALL runtime.morestack_noctxt.abi0(SB)
  0x6ef30               17fffff4                JMP main.a(SB)
  0x6ef34               00000000                ?
  0x6ef38               00000000                ?
  0x6ef3c               00000000                ?

设置断点:

(gdb) break main.a
Breakpoint 1 at 0x6ef0c: file /root/testjson/main.go, line 7.
(gdb) run
Starting program: /root/testjson/testjson

寄存器输出

Thread 1 "testjson" hit Breakpoint 1, main.a () at /root/testjson/main.go:7
7       func a() {
(gdb) info registers lr
lr             0x6eedc             454364 // 执行完 a 函数后将要执行的地址
(gdb) info registers sp   // sp 寄存器的值
sp             0x400003e750        0x400003e750
(gdb) info registers pc    // 当前 pc 指令地址,还没有执行过。
pc             0x6ef0c             0x6ef0c <main.a+12>
(gdb) %10
Undefined command: "".  Try "help".
(gdb) x/10x $sp #此时0x000442a8为执行完函数 a 后会执行的指令地址。
0x400003e750:   0x000442a8      0x00000000      0x00000000      0x00000000
0x400003e760:   0x0006c704      0x00000000      0x00064000      0x00000040
0x400003e770:   0x00000000      0x00000000

此时可以通过 debug_frame 进行堆栈展开。

debug_frame 对于函数 a 的描述:

左闭右开的地址范围 [,)
0x6ef00 -> 0x6ef10, sp delta 为 0, 通过 lr 链接寄存器展开;
0x6ef10 -> 0x6ef24, sp delta 为 16, 从当前的 sp + 16 访问
0x6ef24 -> 0x63f3c, sp delta 为 0, 通过 lr 链接寄存器展开

如果当前 pc 地址在 0x6ef10, 则通过 lr 寄存器可以获取 nextpc:

nextpc = *lr, 为 0x6eedc。

也就是说,分两种情况:

当函数调用子函数时,会提前分配堆栈空间。

因此:

1,正常情况下,lr 为该函数返回后要执行的下一行指令地址,sp 为当前函数的堆栈。

2,函数调用子函数前后,sp 会减小,分配堆栈空间给子函数,lr 表示的是子函数返回后需要执行的下一条指令地址,之前的 lr 的值,会被保存在堆栈之中。

最大的展开表 unwind row 大小

pc uint64
spDelta int32 

fp 堆栈展开

堆栈展开原理:

// fpTracebackPCs populates pcBuf with the return addresses for each frame and
// returns the number of PCs written to pcBuf. The returned PCs correspond to
// "physical frames" rather than "logical frames"; that is if A is inlined into
// B, this will return a PC for only B.
func fpTracebackPCs(fp unsafe.Pointer, pcBuf []uintptr) (i int) {
    for i = 0; i < len(pcBuf) && fp != nil; i++ {
       // return addr sits one word above the frame pointer
       pcBuf[i] = *(*uintptr)(unsafe.Pointer(uintptr(fp) + goarch.PtrSize))
       // follow the frame pointer to the next one
       fp = unsafe.Pointer(*(*uintptr)(fp))
    }
    return i
}
  • nextfp = *fp
  • nextrip = (*(fp + 8)) - 1

需要注意 fp 是一个很抽象的东西,它跟 info registers fp 不是同一个东西。

arm64

在 amd64 上的一个汇编代码:

r30 寄存器就是 lr。 r29 就是 fp.

TEXT main.main(SB) /root/testjson/main.go
  main.go:3             0x6d660                 f9400b90                MOVD 16(R28), R16
  main.go:3             0x6d664                 eb3063ff                CMP R16, RSP
  main.go:3             0x6d668                 54000109                BLS 8(PC)
  main.go:3             0x6d66c                 f81f0ffe                MOVD.W R30, -16(RSP)
  main.go:3             0x6d670                 f81f83fd                MOVD R29, -8(RSP)
  main.go:3             0x6d674                 d10023fd                SUB $8, RSP, R29
  main.go:4             0x6d678                 9400000a                CALL main.a(SB)
  main.go:5             0x6d67c                 a97ffbfd                LDP -8(RSP), (R29, R30)
  main.go:5             0x6d680                 910043ff                ADD $16, RSP, RSP
  main.go:5             0x6d684                 d65f03c0                RET
  main.go:3             0x6d688                 aa1e03e3                MOVD R30, R3
  main.go:3             0x6d68c                 97fff609                CALL runtime.morestack_noctxt.abi0(SB)
  main.go:3             0x6d690                 17fffff4                JMP main.main(SB)
  main.go:3             0x6d694                 00000000                ?
  main.go:3             0x6d698                 00000000                ?
  main.go:3             0x6d69c                 00000000                ?

TEXT main.a(SB) /root/testjson/main.go
  main.go:7             0x6d6a0                 f9400b90                MOVD 16(R28), R16
  main.go:7             0x6d6a4                 eb3063ff                CMP R16, RSP
  main.go:7             0x6d6a8                 54000109                BLS 8(PC)
  main.go:7             0x6d6ac                 f81f0ffe                MOVD.W R30, -16(RSP)
  main.go:7             0x6d6b0                 f81f83fd                MOVD R29, -8(RSP)
  main.go:7             0x6d6b4                 d10023fd                SUB $8, RSP, R29
  main.go:8             0x6d6b8                 9400000a                CALL main.b(SB)
  main.go:9             0x6d6bc                 a97ffbfd                LDP -8(RSP), (R29, R30)
  main.go:9             0x6d6c0                 910043ff                ADD $16, RSP, RSP
  main.go:9             0x6d6c4                 d65f03c0                RET
  main.go:7             0x6d6c8                 aa1e03e3                MOVD R30, R3
  main.go:7             0x6d6cc                 97fff5f9                CALL runtime.morestack_noctxt.abi0(SB)
  main.go:7             0x6d6d0                 17fffff4                JMP main.a(SB)
  main.go:7             0x6d6d4                 00000000                ?
  main.go:7             0x6d6d8                 00000000                ?
  main.go:7             0x6d6dc                 00000000                ?

TEXT main.b(SB) /root/testjson/main.go
  main.go:11            0x6d6e0                 f9400b90                MOVD 16(R28), R16 // 此时 rip 是 0x0006d67c,注意不是 0x6d6bc,也就是说缺少了函数的 parent。
  main.go:11            0x6d6e4                 eb3063ff                CMP R16, RSP
  main.go:11            0x6d6e8                 54000109                BLS 8(PC)
  main.go:11            0x6d6ec                 f81f0ffe                MOVD.W R30, -16(RSP)
  main.go:11            0x6d6f0                 f81f83fd                MOVD R29, -8(RSP)
  main.go:11            0x6d6f4                 d10023fd                SUB $8, RSP, R29 // 执行完该行后是  0x0006d6bc 
  // 正常来说这里是 b 中实际要执行的代码。
  main.go:12            0x6d6f8                 9400000a                CALL main.c(SB)
  main.go:13            0x6d6fc                 a97ffbfd                LDP -8(RSP), (R29, R30) // 执行完该行后又是 0x0006d67c
  main.go:13            0x6d700                 910043ff                ADD $16, RSP, RSP 
  main.go:13            0x6d704                 d65f03c0                RET
  main.go:11            0x6d708                 aa1e03e3                MOVD R30, R3
  main.go:11            0x6d70c                 97fff5e9                CALL runtime.morestack_noctxt.abi0(SB)
  main.go:11            0x6d710                 17fffff4                JMP main.b(SB)
  main.go:11            0x6d714                 00000000                ?
  main.go:11            0x6d718                 00000000                ?
  main.go:11            0x6d71c                 00000000                ?

汇编:

TEXT ·getfp<ABIInternal>(SB),NOSPLIT|NOFRAME,$0
    MOVD R29, R0
    RET

在 amd64 上:

TEXT ·getfp<ABIInternal>(SB),NOSPLIT|NOFRAME,$0
    MOVQ BP, AX
    RET

正如注释中所说,通过 fp 堆栈展开可能会丢掉第二个函数。(在 amd64 上也是同样的)

或者实际上也不算。比如 0x6d6a0 - 0x6d6b4 虽然在 TEXT A 之下,但实际上是为执行 a 中真正的函数进行准备,可接受这个误差。