Go unsafe:指针转换和内存布局到底在做什么
2026年10月21日 · 610 字 · 3 分钟
通过指针转换、结构体对齐和字段偏移,理解 读 Go 的 括号套着括号,类型也换了好几次。这行代码的作用是:根据结构体的起始地址和字段偏移量,找到字段 执行下面这句,才会真正修改数据: 要理解其中的转换,得先弄清楚指针的类型,以及结构体在内存中是怎么摆放的。 先看一段普通代码: 执行 这里的两个 指针类型决定了如何读写那块内存。例如, 所以,拿到一个地址之后,还需要知道应该按什么类型访问它。 普通 Go 代码不能直接把 从里面往外读,转换过程是: 这些转换没有改变地址,也没有复制 通过 这和普通的数值转换有区别: 这里的 Go 类型转换的基本格式是: 例如: 目标类型是指针类型 前面的 转换能编译通过,也不代表访问一定合法。目标类型的大小、对齐要求和内存布局,都需要调用者自己确认。 看这个结构体: 直觉上可能会认为: 但在常见的 64 位 Go 环境中,布局通常是: 中间为什么多了 4 个字节?因为 在常见的 amd64、arm64 环境中, 假设结构体从地址 编译器会留下 4 个填充字节,让 这些填充由编译器自动安排。 大小和对齐要求是两个概念: 它们的数值不一定相同,具体规则还与目标平台有关。 假设 在上述常见 64 位环境下: 还要注意: 原文用 普通指针和 三种类型的区别可以放在一起看: 现在回到开头的代码: 它按顺序完成这些操作: 如果结构体起始地址是 得到的 在字段可以直接访问的情况下,这套操作达到的效果就是: 假设一个 因此,把指针转换成 下面这种写法不属于官方支持的地址计算模式: 即使某次运行得到了预期结果,也不能据此判断它正确。 对于这种指针运算,转换必须放在同一个表达式中: 这里限制的是把指针地址暂存为 而且,同一个表达式只是要求之一。计算后的指针仍必须位于原来的已分配对象内部,后续读写也必须满足类型和对齐等要求。 原文把这类风险主要归因于移动 GC。Go 标准实现的堆 GC 目前是非移动式的,不过对象仍可能被回收,goroutine 的栈也可能搬迁。判断代码是否正确,应遵守 Go 1.17 起提供了 下面是一份完整示例: 在常见的 64 位 Go 环境中,输出为: 它简化了写法,但不会自动检查你选的目标类型是否合适,也不能让越界访问变得合法。 原文把 在其他包里可以创建 但不能直接访问小写字段: 作者想演示的,就是绕过字段访问限制,找到 这里有个容易漏掉的细节:前面的完整示例把 如果 正常开发中,如果需要让外部修改 外部代码只需要: 修改行为发生在 unsafe.Pointer、uintptr 与 unsafe.Add 的工作方式,以及 GC 和跨包访问带来的使用边界。unsafe 代码时,很容易被这样的写法卡住:pj := (*int64)(unsafe.Pointer(
uintptr(unsafe.Pointer(v)) + unsafe.Offsetof(v.j),
))
j,得到一个指向它的指针。*pj = 763
1. 指针既有地址,也有类型
x := 10
p := &x
fmt.Println(*p) // 10
*p = 20
fmt.Println(x) // 20
&x 取得 x 的地址,p 保存这个地址,*p 则通过地址访问 x。p ─────→ x 所在的内存
10
*p = 20,修改的就是 x。* 容易混淆:var p *int // *int 是类型:指向 int 的指针
fmt.Println(*p) // *p 是操作:通过指针访问值
*int32 按 int32 读写,涉及 4 个字节;*int64 按 int64 读写,涉及 8 个字节。2.
unsafe.Pointer:换一种类型访问同一块内存*int 转成 *uint。借助 unsafe.Pointer,可以完成这种转换:v2 := int(13)
p := (*uint)(unsafe.Pointer(&v2))
fmt.Println(*p) // 13
&v2 类型为 *int
unsafe.Pointer(&v2) 类型为 unsafe.Pointer
(*uint)(unsafe.Pointer(&v2)) 类型为 *uint
v2 的数据。*int 指针 ──────┐
├──→ 同一块内存
*uint 指针 ─────┘
p 写入数据,也会影响 v2:*p = 20
fmt.Println(v2) // 20
v2 的声明类型仍然是 int。只是通过 p 访问它时,使用了 uint 的读写方式。v2 := int(13)
u := uint(v2)
u = 20
fmt.Println(v2) // 仍然是 13
u 是另一个独立的变量。(*uint)(...) 是什么语法?目标类型(表达式)
int64(x)
*uint 时,需要用括号把类型括起来:(*uint)(某个可转换的指针值)
(*uint) 表示目标类型,后面的括号放要转换的值。unsafe.Pointer 是 Go 特殊支持的通用指针类型。它可以连接不同的指针类型,但不能直接用 *p 读取数据,需要先转成具体的 *T。3. 结构体字段不一定紧挨着排列
type V struct {
i int32
j int64
}
i 占 4 字节,j 就从第 4 个字节后面开始。相对结构体起点的偏移:
0 4 8 16
┌───────────┬───────────┬───────────────────┐
│ i:4 字节 │填充:4 字节│ j:8 字节 │
└───────────┴───────────┴───────────────────┘
j 有对齐要求。对齐要求限制的是起始位置
int64 字段通常要求 8 字节对齐,也就是:字段起始地址 % 8 == 0
1000 开始。i 占用地址 1000 到 1003,下一个空位置是 1004。但 1004 不是 8 的整数倍,不能作为满足该对齐要求的 j 的起点。j 从 1008 开始:1000~1003 1004~1007 1008~1015
┌───────────┬────────────┬────────────────┐
│ i │ 填充 │ j │
└───────────┴────────────┴────────────────┘
Sizeof、Alignof 和 OffsetofV 定义在当前包中:v := new(V)
unsafe.Sizeof(*v) // 16:整个结构体的大小,包括填充
unsafe.Alignof(v.j) // 8:j 的对齐要求
unsafe.Offsetof(v.j) // 8:j 距离结构体起点的字节数
unsafe.Sizeof(v) // 8:指针本身的大小
unsafe.Sizeof(*v) // 16:指针指向的结构体的大小
Sizeof 不会顺着指针继续统计对象占用的空间。int32 的大小 4 来计算 j 的偏移量,这种做法不能直接套用到常见的 64 位环境。字段之间可能有填充,不能只把前面字段的大小相加。4.
uintptr 为什么出现在地址计算里?unsafe.Pointer 都不能直接做加减法。需要按字节移动地址时,传统写法会先把指针转换成 uintptr。uintptr 是一种整数类型,足以保存指针的位模式。
类型
可以直接通过
*p 访问值可以做加减法
GC 当作指针
*T可以
不可以
是
unsafe.Pointer不可以,要先转成
*T不可以直接加减
是
uintptr不可以
可以
否
pj := (*int64)(unsafe.Pointer(
uintptr(unsafe.Pointer(v)) + unsafe.Offsetof(v.j),
))
v 转成 unsafe.Pointer。uintptr,把地址表示为整数。j 的偏移量,算出 j 的地址。unsafe.Pointer,再转成 *int64。1000,j 的偏移量是 8,算出来的地址就是 1008。pj 指向原来的 j,没有产生另一个字段:*pj = 763
v.j = 763
5. 把地址放进
uintptr 后,会失去指针语义uintptr 能保存地址,但它仍然是整数。uintptr 变量的值是 1008。程序员知道它代表地址,垃圾回收器却不会据此认为某个对象还需要保留。uintptr 后:
// 不正确:把指针地址保存为 uintptr,之后再转回指针。
addr := uintptr(unsafe.Pointer(v)) + unsafe.Offsetof(v.j)
pj := (*int64)(unsafe.Pointer(addr))
pj := (*int64)(unsafe.Pointer(
uintptr(unsafe.Pointer(v)) + unsafe.Offsetof(v.j),
))
uintptr,单独保存偏移量没有问题:offset := unsafe.Offsetof(v.j)
unsafe 的使用规则,不能依赖某次运行时地址恰好没变。6. 用
unsafe.Add 简化地址计算unsafe.Add,可以直接给 unsafe.Pointer 加上字节偏移量,避免手动转换成 uintptr。package main
import (
"fmt"
"unsafe"
)
type V struct {
i int32
j int64
}
func main() {
v := new(V)
fmt.Println("结构体大小:", unsafe.Sizeof(*v))
fmt.Println("j 的对齐要求:", unsafe.Alignof(v.j))
fmt.Println("j 的偏移量:", unsafe.Offsetof(v.j))
// 第一个字段 i 位于结构体起点。
pi := (*int32)(unsafe.Pointer(v))
*pi = 98
// 结构体起始地址 + j 的偏移量,得到 j 的地址。
pj := (*int64)(unsafe.Add(
unsafe.Pointer(v),
unsafe.Offsetof(v.j),
))
*pj = 763
fmt.Println(v.i, v.j)
}
结构体大小: 16
j 的对齐要求: 8
j 的偏移量: 8
98 763
unsafe.Add 的偏移量以字节为单位。例如,对一个指向字节切片首元素的指针加 1,得到的是下一个字节的位置。7. 原文为什么要绕路修改字段?
V 定义在 p 包中:package p
type V struct {
i int32
j int64
}
V:v := new(p.V)
v.j = 763 // 编译错误:j 是未导出字段
j 的内存位置后直接修改它。V 和 main 放在同一个包中,所以可以使用:unsafe.Offsetof(v.j)
V 来自另一个包,且 j 未导出,这个表达式同样不能通过编译。unsafe.Offsetof 不会绕过字段可见性检查。跨包示例需要通过其他方式确定布局,也就更容易受到字段调整和平台差异的影响。j,可以由 p 包提供方法:func (v *V) SetJ(n int64) {
v.j = n
}
SetJ 是自己定义的方法,首字母大写,因此其他包可以调用。指针接收者 (v *V) 让方法修改原来的对象。v := new(p.V)
v.SetJ(763)
p 包内部,不需要外部代码了解 j 的偏移量。以后调整结构体字段顺序,也不必让调用者重新计算地址。参考