太长不看版
这个 Go 打桩库,在 arm64 上生成"跳转到 mock"的机器码时,把一个本该当临时寄存器用的 x10 拿去加载地址了;而 x10 恰好是 Go 传第 11 个参数用的寄存器。参数被冲掉后,mock 里写内存写到了自己的代码段,于是 SIGBUS。解决思路:把 x10 换成 x17(平台保留的临时寄存器,不传参)。本文梳理这个 bug 从原理一路推到修复,顺便记下排查过程。
写Go单测时,我们经常要"把某个函数换成假的"。常规路子是接口 + 依赖注入,或者手写 mock。gomonkey 走的是另一条路子:直接在运行时改函数的机器码。
一个 Go 函数编译之后,就是一段可执行文件里的字节码,foo 这个名字本质上就是这段字节码的起始地址。gomonkey 的 ApplyFunc(foo, mock) 执行过程:
foo 的入口地址;foo() 的代码,一进 foo 入口就被劫持到 mock 里去了,foo 自己的逻辑根本没机会执行。要恢复就 Reset(),把原始字节写回去。
这套跳转逻辑在 x86 上用了一直没啥大问题,在 arm64 上遇到问题了。
先建立物理直觉。CPU 不认"函数",它只认"PC 指向哪条指令就执行哪条"。调用 foo() 在 arm64 上是这个过程:
BL foo(branch with link),返回地址存进 LR(R30),PC 跳到 foo 入口;RET 回去。gomonkey 要劫持的,就是第 2 步"跳进去之后、foo 第一条指令执行之前"这个空当——它把 foo 入口处的前 6 条指令(24 字节)换成了一段跳板。
arm64 上这段跳板(jmp_arm64.go 里的 buildJmpDirective):
asmMOVZ 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
几个细节解释一下,
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 上。

上写代码段比 Linux/Windows 限制更多——Apple Silicon 强制 W^X(同一页不能同时可写可执行),所以 gomonkey 用 mach_vm_protect 一段段地写。但这只是"怎么把字节写进去"的问题,这个问题曾经也导致SIGBUS,但是这个问题在官方最新的补丁包已经修复了。
Go 1.17+ 在 arm64 上的寄存器传参约定:
矛盾点: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 的核心就两条:
asmADD 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]

下面用一个用例 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 步:反汇编崩溃指令,定位被污染的寄存器。
崩溃处那条指令是:
asmSTP (R2, R3), (R1) ; 把 R2、R3 写进 [R1, R1+8],共 16 字节
这是 mock 里 out.(*License) 类型断言成功后、往结构体写数据的一串指令之一。类型字没 panic(否则会是 panic 而不是 SIGBUS),所以 interface 的类型字是对的,坏的是 data 字——它本该指向堆里的 License 结构体,实际却指向 mock 自己的入口地址。于是 STP 写过去,落在只读段,直接 SIGBUS。
第 4 步:对上 ABI
被桩函数的签名是:
gofunc ParseDataToStruct(in ReadResult, out any)
in ReadResult 占掉前面若干个参数寄存器之后,out any 的两个字(type 字、data 字)按顺序往后排,它的 data 字恰好排到 R10。
而 gomonkey 在 ParseDataToStruct 入口写的桩指令里,第 5 条是 LDR x10, [x26]——一条指令就把调用方刚传进 R10 的参数冲成了 mock 自己的代码地址。mock 里再拿这个"参数"去解引用、写数据,自然就写到了只读段。

-N -l 影响参数溢出策略,所以对编译参数敏感。jmp_arm64.go,俩都中招。以前在 x86_64 上跑,arm64 是最近才引入的(Mac),所以这个 arm64-only 的 bug 才第一次被翻出来。linux/arm64 一样会崩,只是还没大规模跑过。逐架构对比(gomonkey v2.14.2 各架构跳转指令):
| 架构 | 源文件 | 用的临时寄存器 | Go ABI 参数寄存器 | 该寄存器传参吗 | 中招? |
|---|---|---|---|---|---|
| amd64 | jmp_amd64.go | RDX | RAX,RBX,RCX,RDI,RSI,R8,R9,R10,R11 | 否(RDX 不在列) | 否 |
| 386 | jmp_386.go | EDX | EAX,EBX,ECX,EDI,ESI,栈 | 否 | 否 |
| arm64 | jmp_arm64.go | x10 | R0–R10 | 是,R10 = 第 11 个整型参数 | 是 |
| loong64 | jmp_loong64.go | r29/r30 | 不含 r29/r30(callee-saved) | 否 | 否 |
| riscv64 | jmp_riscv64.go | x6(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 F9 | 51 03 40 F9(LDR x17, [x26]) |
BR x10 | 40 01 1F D6 | 20 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