bigywater.tech
Go · 38

unsafe.Pointer:uintptr 转换的合法模式和被 GC 移走的风险

unsafe 包让你绕过类型系统,但规范里有一套“合法模式”,违反了在大多数情况下“碰巧能跑”,直到栈被搬动或 GC 改变时才出错。这篇用实验展示:合法写法、uintptr 为什么不是指针(栈会搬)、窥探内部布局的脆弱,以及 go vet 与 checkptr 能抓住哪些误用、抓不住哪些。

0xcbe0 → 0xfbe0
递归撑大栈之后同一个局部变量的地址(低 16 位),旧的 uintptr 已悬空
3 种错误
checkptr 报了:uintptr 往返、含指针类型的未对齐转换、堆上 unsafe.Add 越界
不报
目标为 *int64 的未对齐转换、*[4]byte 转 *[8]byte,checkptr 与 vet 都没发现

合法模式

实测 · 本机执行输出
  模式 1:*(*uint64)(unsafe.Pointer(&f)) = 0x3ff8000000000000;math.Float64bits(f) = 0x3ff8000000000000;相等?true
  模式 3:unsafe.Add(&arr[0], 8) → 30;uintptr(&arr[0]) + 3*Sizeof → 40(都在一个表达式里完成,合法)
  unsafe.Offsetof:A = 0、B = 8、C = 16;Sizeof(Rec) = 24;Alignof(Rec) = 8
  用 Offsetof 访问字段:*pc = 7(r.C = 7)
  unsafe.String / unsafe.Slice / unsafe.StringData:s = "hello",sl 长度 5

unsafe 文档列出的合法模式在实验里都能得到预期结果:同大小类型间的转换(float64 与 uint64 的位模式)、在同一个表达式里把 unsafe.Pointer 转成 uintptr 做加法再转回来、用 unsafe.Add 做指针偏移、用 Offsetof 访问字段。新代码优先用 unsafe.Add 而不是 uintptr 运算。

uintptr 与栈

实测 · 本机执行输出
  局部变量 x 的地址:增长前(用 uintptr 保存)0xabe0;增长后(取 &x)0x7be0;不相等?true(栈被搬走了,x 跟着搬)
  用增长前保存的 unsafe.Pointer p 读 x:42(运行时在拷贝栈时把 p 也改写了);p == &x?true
  堆上对象(逃逸到堆):3 次 GC 之后地址不变?true(当前实现的 GC 不移动堆对象,但语言规范不保证)
  栈上的局部变量(不逃逸):3 次 GC 之后地址不变?false

uintptr 只是一个整数,GC 与栈拷贝都不认识它。goroutine 栈增长时运行时会把整个栈搬到新地址,并把栈上所有指针(包括 unsafe.Pointer 变量)改写成新地址;uintptr 变量不会被改写,变成指向旧栈的悬空数字。堆对象在当前实现里不会被 GC 移动,但语言规范并没有保证这一点——所以“把地址存成整数以后再用”是一种在今天碰巧能用、明天可能出错的写法。栈上变量在 GC 之后地址也可能变(栈收缩),这是实验里观察到的。

窥探内部布局

实测 · 本机执行输出
  *(*[]byte)(unsafe.Pointer(&buf)) = "peek"(长度 4,容量 64);buf.Len() = 4,buf.Cap() = 64

用 unsafe 读标准库类型的未导出字段能工作,是因为布局恰好如此;它与库的内部实现耦合,升级一个版本就可能静默读错。能用公开方法就不要这样。

vet 与 checkptr

实测 · 本机执行输出
  uintptr 存进变量再转回指针(两条语句,且先做了加法):
    go vet:main.go:15:15: possible misuse of unsafe.Pointer
    默认:退出码 0,0;checkptr:退出码 2,fatal error: checkptr: pointer arithmetic result points to invalid allocation
  未对齐的转换:b 在堆上,从 b[1] 起转成 *[1]*int(元素含指针):
    go vet:无警告
    默认:退出码 0,<nil>;checkptr:退出码 2,fatal error: checkptr: misaligned pointer conversion
  同样的未对齐转换,但目标是 *int64(元素不含指针):
    go vet:无警告
    默认:退出码 0,0;checkptr:退出码 0,0
  unsafe.Add 越过堆上 16 字节切片末尾 100 字节:
    go vet:无警告
    默认:退出码 0,0;checkptr:退出码 2,fatal error: checkptr: pointer arithmetic result points to invalid allocation
  把堆上的 *[4]byte 转成 *[8]byte(越过原对象):
    go vet:无警告
    默认:退出码 0,0;checkptr:退出码 0,0

go vet 的 unsafeptr 检查只抓“uintptr 转回 unsafe.Pointer”这一类。运行时的 checkptr(-race 会自动打开,也可以用 -gcflags=all=-d=checkptr=1 单独打开)能抓到更多:指针运算越界、含指针类型的未对齐转换。从实验也看到它不是万能的:目标类型不含指针的未对齐转换、把 4 字节对象转成 8 字节数组类型的情形,都没有被报告(后一种我推测是因为 4 字节的对象实际落在 8 字节的分配块里,没有验证)。把 checkptr 当作测试时的辅助,而不是正确性的证明。

分步演示

unsafe.Pointer
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。所有输出来自真实运行;栈增长靠一个递归函数(每层 512 字节),地址只打印低 16 位;checkptr 用 -gcflags=all=-d=checkptr=1 编译;堆上的对象用赋给全局变量的方式确保逃逸
    • 没有验证:checkptr 的完整检查清单(只试了 4 种误用)、unsafe 在 cgo 指针传递规则下的要求、reflect.SliceHeader / StringHeader 的废弃替代用法、amd64 上的栈增长地址、被 GC 移动的可能(规范不保证,当前实现没有观察到)
    京ICP备2026067502号-1京公网安备11010502063487号