Go 1.27.3 的 LICM 把 unsafe.Pointer 加法提到 if 外,栈增长时撞上 fatal error
Go 编译器的一个新 bug 报告展示了一种不太常见的失败模式:逻辑上被 if o != nil 保护的代码,仍然会留下一个跨调用活跃的无效指针;当 goroutine 栈增长时,这个活跃指针会让 runtime 崩溃。
报告于 11 月 5 日提交,编号 golang/go#82865,出现在 go1.27.3 / windows/amd64,CI 中在 linux/amd64 和 darwin/arm64 的 Go 1.27.x 上也复现了。报告者称 go1.26.5 和 go1.24.13 不受影响,因为这些版本没有涉事的 loop-invariant code motion(LICM)pass。随后修复落地,issue 以 completed 关闭。
关键点在于:这不是「Go 到处破坏内存」的宽泛 bug。失败模式很具体:cmd/compile 的 LICM pass 把 unsafe.Pointer 算术提出 guard,寄存器分配把该值 spill 到栈槽,并跨调用保持 live。调用触发 goroutine 栈增长时,Go 的栈复制和指针调整看到这个坏指针,直接 abort:
runtime: bad pointer in frame main.sum ... fatal error: invalid pointer found on stack
reproducer 做了什么
issue 里的最小程序只在 o != nil 时计算偏移:
func sideOf(o *obj) *side {
return (*side)(unsafe.Add(unsafe.Pointer(o), unsafe.Sizeof(obj{})))
}
//go:noinline
func sum(o *obj, n int) int {
t := 0
for i := 0; i < n; i++ {
if o != nil {
t += first(sideOf(o))
}
t += call(i)
}
return t
}
报告称该程序在 go1.27.3 上每次运行都失败,在 go1.26.5 上打印 ok。
崩溃的原因并不是算术本身不合法。报告者的论证是:指针只在 o != nil 路径上计算,且 o 有效时只指向同一个分配内。问题在于 LICM 把地址计算移到了循环 preheader,也就是 guard 之前,于是即使 o 为 nil,编译器也会把指针物化出来。一旦 spill,这个槽位就跨循环内的调用保持 live。
为什么栈增长会把它变成崩溃
报告认定的决定性触发点是 call(i) 让 goroutine 的栈增长。Go 在复制栈帧时会调整 live 的指针槽。一个装着 0x20——nil 指针算术产生的偏移——的 live 槽会被栈扫描器看到,然后被拒绝。
报告者说同一条路径也可能被 GC 栈收缩暴露,因为那条路径同样会扫描并调整 live 指针。
所以这不只是「漏掉一个优化屏障」,而是一个正确性 bug:优化后的代码一旦跨过 safepoint,就会变成硬性运行时失败。
版本边界比「所有当前 Go」窄
- 受影响:go1.27.3
- 不受影响:go1.26.5、go1.24.13
- 原因:这些旧版本没有涉事的 LICM pass
这重要是因为 bug 绑定的是编译器内部,而不是通用 runtime 变更。issue 还提到问题在 GOARCH=amd64 和 GOARCH=arm64、CI 中多个操作系统上都能复现,所以不是单平台的代码生成怪癖。
报告记录的是 1.27.3 上的复现失败,以及报告者所列旧版本上没有该 bug;后续修复在一个 Go change 中被引用,issue 于 11 月 6 日以 completed 关闭。
unsafe.Pointer 的类型细节才是重点
这个编译器 pass 本意就是避免把指针算术移出被 guard 的块。issue 引用了 LICM 的代码注释和当前检查:如果指针算术可能跨 safepoint 物化出坏指针,就不应移动它。
但报告者指出,实现检查的是 v.Type.IsPtr(),它只命中普通的 TPTR。unsafe.Add 产出的值类型是 unsafe.Pointer(TUNSAFEPTR),pass 因此把它当作非指针算术,直接提升。
这个区分是 bug 的核心:
unsafe.Pointer类型的算术可以被提升- 以字段 overlay 形式表达的
*T类型指针算术不受影响 - 下降到 SSA 之后,类型信号不足以区分危险情形,除非编译器检查更宽的 pointer-shaped 属性
issue 建议的修复是用 v.Type.IsPtrShaped() 或 v.Type.HasPointers() 替代 v.Type.IsPtr(),让 unsafe.Pointer 算术不会移出保护它的块。
哪些代码形态真的有风险
需要几个条件同时满足:
- 值位于最内层循环,且符合 LICM 条件
- 操作属于可提前移动的形式,比如 amd64 上的
ADDQ、ADDQconst、LEAQ*,或 arm64 上对应的形式 - 类型不是 TPTR——
unsafe.Pointer会漏过去 - 输入在循环外定义
- 地址作为独立值使用,而不是折叠进 load/store 的寻址模式
- 循环体内有调用,使被提升的值跨 safepoint 保持 live 并可能被 spill
报告者还说 -d=checkptr=2 会让更多加法变得 eligible,因为插桩引入了额外检查;普通构建在无关代码里可能不表现出同样的提升。在原程序中,bug 是在一条普通构建没有提升、而 checkptr 构建有大量提升的路径上发现的。
风险因此是集中的:在带 guard 的循环里使用 unsafe.Add 或类似的 unsafe.Pointer 算术,且循环体里有调用——这是需要检查的形态。
有 workaround,但会改变表达式形态
把偏移写成 struct overlay 上的字段访问,例如:
&(*T)(unsafe.Pointer(p)).f
这样产出的是 *F 类型的值,当前的 pass 不会以同样方式移动它。
这不是「一种 unsafe 风格优于另一种」的通用建议,而是针对这个 bug 在 LICM、类型分类和栈调整之间交互的 workaround。它也说明 unsafe 代码的源码形态,会决定编译器是否把 guard 留在原地。
影响不止一次崩溃报告
issue 链接到更早的 LICM 编译器工作和同一个 pass 的相关 bug 报告。这段历史说明编译器已经在积累特例规则,防止优化器移动那些跨调用或跨 safepoint 后会变不安全的值。
更一般地:在 Go 里,unsafe 代码不只受你手写的语言规则约束,还受编译器在 SSA 变换之后如何下降和分类这些值的约束。源码层面看起来安全的模式,如果优化器把它跨调用重新定位,而 runtime 之后又需要调整栈,就可能变得不安全。
对在性能敏感代码里用 unsafe 的团队,实际做法是有针对性地测试:用生产环境完全相同的 Go 版本构建;可能时用 -d=checkptr=2 跑;检查每个在调用之前有条件地计算 unsafe.Pointer 值的循环。issue 自带的 reproducer 表明,一旦编译器生成错误的放置,失败可以是确定性的。