2026-09-08
Golang
00

LDR 引发的 SIGBUS
在 arm64 上打桩崩溃的根因与修复

太长不看版

这个 Go 打桩库,在 arm64 上生成"跳转到 mock"的机器码时,把一个本该当临时寄存器用的 x10 拿去加载地址了;而 x10 恰好是 Go 传第 11 个参数用的寄存器。参数被冲掉后,mock 里写内存写到了自己的代码段,于是 SIGBUS。解决思路:把 x10 换成 x17(平台保留的临时寄存器,不传参)。本文梳理这个 bug 从原理一路推到修复,顺便记下排查过程。


背景
是干嘛的

写Go单测时,我们经常要"把某个函数换成假的"。常规路子是接口 + 依赖注入,或者手写 mock。gomonkey 走的是另一条路子:直接在运行时改函数的机器码。

一个 Go 函数编译之后,就是一段可执行文件里的字节码,foo 这个名字本质上就是这段字节码的起始地址。gomonkey 的 ApplyFunc(foo, mock) 执行过程:

  1. 找到 foo 的入口地址;
  2. 把入口处开头的几十个字节就地覆写成"跳转到 mock"的跳转指令;
  3. 于是所有调用 foo() 的代码,一进 foo 入口就被劫持到 mock 里去了,foo 自己的逻辑根本没机会执行。

要恢复就 Reset(),把原始字节写回去。

这套跳转逻辑在 x86 上用了一直没啥大问题,在 arm64 上遇到问题了。

一、打桩在 CPU 发生了什么

先建立物理直觉。CPU 不认"函数",它只认"PC 指向哪条指令就执行哪条"。调用 foo() 在 arm64 上是这个过程:

  1. 调用方按 ABI 把参数塞进寄存器(整数/指针放 R0R10,浮点放 V0V15,不够再溢出到栈);
  2. 执行 BL foo(branch with link),返回地址存进 LR(R30),PC 跳到 foo 入口;
  3. foo 从寄存器里读参数、干活、RET 回去。

gomonkey 要劫持的,就是第 2 步"跳进去之后、foo 第一条指令执行之前"这个空当——它把 foo 入口处的前 6 条指令(24 字节)换成了一段跳板。

arm64 上这段跳板(jmp_arm64.go 里的 buildJmpDirective):

asm
MOVZ x26, #double[15:0] ; x26 = 地址低 16 位 MOVK x26, #double[31:16], lsl 16 ; x26 |= 次 16 位 MOVK x26, #double[47:32], lsl 32 ; x26 |= 再 16 位 MOVK x26, #double[63:48], lsl 48 ; x26 = 完整 64 位地址 LDR x10, [x26] ; x10 = *x26 ← 问题就出在这 BR x10 ; PC = x10

几个细节解释一下,

  • 为什么 4 条 MOVZ/MOVK?arm64 单条立即数指令一次只能装 16 位,64 位地址得拆成 4 段拼,拼进 x26。
  • 为什么 LDR x10, [x26] 而不是直接 BR x26?因为 Go 的函数值是间接的:f 这个变量里存的不是代码地址,而是一个叫 funcval 的结构体指针,funcval 的首字段才是真正的代码地址。所以得先取一次内存。
  • BR x10 把取出来的地址写进 PC,完成跳转。

不管你是 ApplyFunc 还是 ApplyPrivateMethod,最后都会汇到这段 6 条指令,区别只是写的位置不一样。bug 就藏在第 5、6 条用到的 x10 上。

image.png

上写代码段比 Linux/Windows 限制更多——Apple Silicon 强制 W^X(同一页不能同时可写可执行),所以 gomonkey 用 mach_vm_protect 一段段地写。但这只是"怎么把字节写进去"的问题,这个问题曾经也导致SIGBUS,但是这个问题在官方最新的补丁包已经修复了。

二、关键背景
在 arm64 上怎么传参

Go 1.17+ 在 arm64 上的寄存器传参约定:

  • 整数/指针参数从 R0 开始依次排,第 1 个在 R0,第 2 个在 R1……第 11 个在 R10;
  • R16/R17 是平台保留给链接器/运行时的临时寄存器(IP0/IP1),任何函数都不会经它们传参;
  • R18 平台保留;R19~R28 被调用者保存(callee-saved);R29=FP、R30=LR、R31=SP。

矛盾点:x10 既是跳板临时寄存器,又是第 11 个参数的寄存器。

反汇编数据:,在 Mac 上做了个实验验证"第 11 个参数确实在 R10":

go
//go:noinline func f(a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11, a16 int) int { return a11 + a16 } func main() { println(f(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 100, 200)) // 期望输出 300 }

反汇编出来,f 的核心就两条:

asm
ADD R11, R10, R0 ; R0 = R10 + R11 RET

返回值在 R0,而返回值 = a11 + a16,所以 a11 来自 R10、a16 来自 R11。实锤:第 11 个整数参数落在 R10。

另一个点

的 interface{} / any 在内存里是两个字 {type 字, data 字},ABI 上这两个字各自占一个寄存器槽,跟两个独立指针参数一样处理。所以一个 func foo(in ReadResult, out any) 里,out 的 data 字完全可能排到 R10——这正是后面崩溃用例的踩雷方式。

三、现象

Mac(darwin/arm64,Go 1.25)上跑 gomonkey 打桩的单测,一批用例直接崩:

text
[signal SIGBUS: bus error code=0x1 addr=0x102abeba0 pc=0x102abed0c]

四、排查:从一行 SIGBUS 追到 R10

image.png

下面用一个用例 TestCloudConfig_buildRequestLicenseMsg 说明。

第 1 步:先排除"写代码段被拒"。

一开始怀疑是 darwin 的 mach_vm_protect 写路径有问题,导致桩没装好、或者装到了错地址。但核对后发现:桩确实装上了、跳转也成功了——崩溃时的 pc 已经在测试自己的 mock 闭包里,而不是 gomonkey 的框架代码。写路径这条线排除。

第 2 步:读 panic 地址,发现"mock 在写自己的代码段"。

addr = 0x102abeba0 ← CPU 想写、却被拒绝的目标地址 pc = 0x102abed0c ← 当前正在执行的指令地址

pc - 0x16c = 0x102abeba0 = addr,也就是说 fault 地址 = 当前这个 mock 闭包的入口地址。用 go tool objdump 找到闭包的文件地址,反推 ASLR slide 完全吻合。

结论很明确:mock 闭包在向自己函数体所在的只读 text 段写数据。这是"某个写指针被悄悄换成了代码地址"的典型特征。

第 3 步:反汇编崩溃指令,定位被污染的寄存器。

崩溃处那条指令是:

asm
STP (R2, R3), (R1) ; 把 R2、R3 写进 [R1, R1+8],共 16 字节

这是 mock 里 out.(*License) 类型断言成功后、往结构体写数据的一串指令之一。类型字没 panic(否则会是 panic 而不是 SIGBUS),所以 interface 的类型字是对的,坏的是 data 字——它本该指向堆里的 License 结构体,实际却指向 mock 自己的入口地址。于是 STP 写过去,落在只读段,直接 SIGBUS。

第 4 步:对上 ABI

被桩函数的签名是:

go
func ParseDataToStruct(in ReadResult, out any)

in ReadResult 占掉前面若干个参数寄存器之后,out any 的两个字(type 字、data 字)按顺序往后排,它的 data 字恰好排到 R10。

而 gomonkey 在 ParseDataToStruct 入口写的桩指令里,第 5 条是 LDR x10, [x26]——一条指令就把调用方刚传进 R10 的参数冲成了 mock 自己的代码地址。mock 里再拿这个"参数"去解引用、写数据,自然就写到了只读段。

image.png

五、为什么时崩时不崩、为什么只有 Mac

  • 时崩时不崩:只有"参数恰好排到 R10"(参数 ≥ 11 个,或编译器把参数溢出分配到 R10)的函数才踩中。参数少的函数打桩没事,所以只有部分用例崩;-N -l 影响参数溢出策略,所以对编译参数敏感。
  • 为什么最先在 Mac 暴露:这是架构(arm64)问题,不是 OS 问题。darwin/arm64 和 linux/arm64 共用同一份 jmp_arm64.go,俩都中招。以前在 x86_64 上跑,arm64 是最近才引入的(Mac),所以这个 arm64-only 的 bug 才第一次被翻出来。linux/arm64 一样会崩,只是还没大规模跑过。

逐架构对比(gomonkey v2.14.2 各架构跳转指令):

架构源文件用的临时寄存器Go ABI 参数寄存器该寄存器传参吗中招?
amd64jmp_amd64.goRDXRAX,RBX,RCX,RDI,RSI,R8,R9,R10,R11否(RDX 不在列)否
386jmp_386.goEDXEAX,EBX,ECX,EDI,ESI,栈否否
arm64jmp_arm64.gox10R0–R10是,R10 = 第 11 个整型参数是
loong64jmp_loong64.gor29/r30不含 r29/r30(callee-saved)否否
riscv64jmp_riscv64.gox6(t1)A0–A7 = x10–x17否否

amd64/386 用的 RDX/EDX 压根不在参数寄存器里,所以一直没事;arm64 挑中了 x10,于是出问题。

六、修复:两行指令

把跳板末尾两条的目标寄存器从 x10 换成 x17:

go
// 改前 res = append(res, []byte{0x4A, 0x03, 0x40, 0xF9}...) // LDR x10, [x26] res = append(res, []byte{0x40, 0x01, 0x1F, 0xD6}...) // BR x10 // 改后 res = append(res, []byte{0x51, 0x03, 0x40, 0xF9}...) // LDR x17, [x26] res = append(res, []byte{0x20, 0x02, 0x1F, 0xD6}...) // BR x17

为什么 x17 绝对安全:它(R17)是平台保留的运行时临时寄存器 REGRT2(= R16/R17 这俩临时寄存器),调用方不依赖它跨调用存活,任何函数都不会经它传参。而 MOVZ/MOVK 用的 x26 是被调用者保存寄存器,本来就不走参数通道,保持不动。

字节层面的变化其实很小——只是把指令编码里的 Rt / Rn 字段从 10 改成 17:

指令改前改后
LDR x10, [x26]4A 03 40 F951 03 40 F9(LDR x17, [x26])
BR x1040 01 1F D620 02 1F D6(BR x17)

LDR 的编码里 Rt 在 bits[4:0],BR 的 Rn 在 bits[9:5],把 1010(10)换成 10001(17)

七、结果

已经在github上提了PR,等待作者处理中,详情见 https://github.com/agiledragon/gomonkey/pull/189