Go unsafe:指针转换和内存布局到底在做什么

2026年10月21日 · 610 字 · 3 分钟

通过指针转换、结构体对齐和字段偏移,理解 unsafe.Pointer、uintptr 与 unsafe.Add 的工作方式,以及 GC 和跨包访问带来的使用边界。

读 Go 的 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:换一种类型访问同一块内存

普通 Go 代码不能直接把 *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)(...) 是什么语法?

Go 类型转换的基本格式是:

目标类型(表达式)

例如:

int64(x)

目标类型是指针类型 *uint 时,需要用括号把类型括起来:

(*uint)(某个可转换的指针值)

前面的 (*uint) 表示目标类型,后面的括号放要转换的值。

unsafe.Pointer 是 Go 特殊支持的通用指针类型。它可以连接不同的指针类型,但不能直接用 *p 读取数据,需要先转成具体的 *T。

转换能编译通过,也不代表访问一定合法。目标类型的大小、对齐要求和内存布局,都需要调用者自己确认。

3. 结构体字段不一定紧挨着排列

看这个结构体:

type V struct {
    i int32
    j int64
}

直觉上可能会认为:i 占 4 字节,j 就从第 4 个字节后面开始。

但在常见的 64 位 Go 环境中,布局通常是:

相对结构体起点的偏移:

0           4           8                  16
┌───────────┬───────────┬───────────────────┐
│ i:4 字节 │填充:4 字节│    j:8 字节      │
└───────────┴───────────┴───────────────────┘

中间为什么多了 4 个字节?因为 j 有对齐要求。

对齐要求限制的是起始位置

在常见的 amd64、arm64 环境中,int64 字段通常要求 8 字节对齐,也就是:

字段起始地址 % 8 == 0

假设结构体从地址 1000 开始。

i 占用地址 1000 到 1003,下一个空位置是 1004。但 1004 不是 8 的整数倍,不能作为满足该对齐要求的 j 的起点。

编译器会留下 4 个填充字节,让 j 从 1008 开始:

1000~1003    1004~1007    1008~1015
┌───────────┬────────────┬────────────────┐
│     i     │    填充     │       j        │
└───────────┴────────────┴────────────────┘

这些填充由编译器自动安排。

大小和对齐要求是两个概念:

  • 大小回答数据占多少字节。
  • 对齐要求回答数据应当从什么位置开始放。

它们的数值不一定相同,具体规则还与目标平台有关。

Sizeof、Alignof 和 Offsetof

假设 V 定义在当前包中:

v := new(V)

在上述常见 64 位环境下:

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),
))

它按顺序完成这些操作:

  1. 把结构体指针 v 转成 unsafe.Pointer。
  2. 再转成 uintptr,把地址表示为整数。
  3. 加上 j 的偏移量,算出 j 的地址。
  4. 转回 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)

而且,同一个表达式只是要求之一。计算后的指针仍必须位于原来的已分配对象内部,后续读写也必须满足类型和对齐等要求。

原文把这类风险主要归因于移动 GC。Go 标准实现的堆 GC 目前是非移动式的,不过对象仍可能被回收,goroutine 的栈也可能搬迁。判断代码是否正确,应遵守 unsafe 的使用规则,不能依赖某次运行时地址恰好没变。

6. 用 unsafe.Add 简化地址计算

Go 1.17 起提供了 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)
}

在常见的 64 位 Go 环境中,输出为:

结构体大小: 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 的偏移量。以后调整结构体字段顺序,也不必让调用者重新计算地址。

参考