学过 C 的朋友应该知道,有一种类型是指针类型,指针类型存储的是一个内存地址,通过这个内存地址可以找到它指向的变量。
go 虽然是一种高级语言,但是也还是给开发者提供了指针的类型 unsafe.Pointer,我们可以通过它来直接读写变量的内存。
正因为如此,如果我们操作不当,极有可能会导致程序崩溃。今天就来了解一下 unsafe 里所能提供的关于指针的一些功能,
以及使用 unsafe.Pointer 的一些注意事项。

内存里面的二进制数据表示什么?

我们知道,计算机存储数据的时候是以二进制的方式存储的,当然,内存里面存储的数据也是二进制的。二进制的 01 本身其实并没有什么特殊的含义。

它们的具体含义完全取决于我们怎么去理解它们,比如 0010 0000,如果我们将其看作是一个十进制数字,那么它就是 32,
如果我们将其看作是字符,那么他就是一个空格(具体可参考 ASCII 码表)。

对应到编程语言层面,其实我们的变量存储在内存里面也是 01 表示的二进制,这些二进制数表示是什么类型都是语言层面的事,
更准确来说,是编译器来处理的,我们写代码的时候将变量声明为整数,那么我们取出来的时候也会表示成一个整数。

这跟本文有什么关系呢?我们下面会讲到很多关于类型转换的内容,如果我们理解了这一节说的内容,下面的内容会更容易理解

在我们做类型转换的时候,实际上底层的二进制表示是没有变的,变的只是我们所看到的表面的东西。

内存布局

有点想直接开始讲 unsafe 里的 Pointer 的,但是如果读者对计算机内存怎么存储变量不太熟悉的话,
看起来可能会比较费解,所以在文章开头会花比较大的篇幅来讲述计算机是怎么存储数据的,
相信读完会再阅读后面的内容(比如指针的算术运算、通过指针修改结构体字段)会没有那么多障碍。

变量在内存中是怎样的?

我们先来看一段代码:

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var a int8 = 1
	var b int16 = 2
	// unsafe.Sizeof() 可以获取存储变量需要的内存大小,单位为字节
	// 输出:1 2
	// int8 意味着,用 8 位,也就是一个字节来存储整型数据
	// int16 意味着,用 16 位,也就是两个字节来存储整型数据
	fmt.Println(unsafe.Sizeof(a), unsafe.Sizeof(b))
}

在这段代码中我们定义了两个变量,占用一个字节的 a 和占用两个字节的 b,在内存中它们大概如下图:

在这里插入图片描述

我们可以看到,在图中,a 存储在低地址,占用一个字节,而 b 存储在 a 相邻的地方,占用两个字节。

结构体在内存中是怎样的?

我们再来看看结构体在内存中的存储:

package main

import (
	"fmt"
	"unsafe"
)

type Person struct {
	age   int8
	score int8
}

func main() {
	var p Person
	// 输出:2 1 1
	// 意味着 p 占用两个字节,
	// 其中 age 占用一个字节,score 占用一个字节
	fmt.Println(unsafe.Sizeof(p), unsafe.Sizeof(p.age), unsafe.Sizeof(p.score))
}

这段代码中,我们定义了一个 Person 结构体,其中两个字段 agescore 都是 int8 类型,都是只占用一个字节的,它的内存布局大概如下图:

在这里插入图片描述

我们可以看到,在内存中,结构体字段是占用了内存中连续的一段存储空间的,具体来说是占用了连续的两个字节。

指针在内存中是怎么存储的?

在下面的代码中,我们定义了一个 a 变量,大小为 1 字节,然后我们定义了一个指向 a 的指针 p

需要先说明的是,下面有两个操作符,一个是 &,这个是取地址的操作符,var p = &a 意味着,取得 a 的内存地址,将其存储在变量 p 中,
另一个操作符是 *,这个操作符的意思是解指针,*p 就是通过 p 的地址取得 p 指向的内容(也就是 a)然后进行操作。
*p = 4 意味着,将 p 指向的 a 修改为 4。

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var a int8 = 3
	// ... 其他变量
	var p = &a
	fmt.Println(unsafe.Sizeof(p))
	fmt.Println(*p) // 3
	*p = 4
	fmt.Println(a) // 4
}

在这里插入图片描述

需要注意的是,这里面不再是一个单元格一个字节了,p(指针变量)是要占用 8 个字节的(这个跟机器有关,我的是 64 位的 CPU,所以是 8 个字节)。

从这个图,我们可以得知,指针实际上存储的是一个内存地址,通过这个地址我们可以找到它实际存储的内容。

结构体的内存布局真的是我们上面说的那样吗?

上面我们说了,下面这个结构体占用了两个字节,结构体里面的一个字段占用一个字节:

type Person struct {
	age   int8
	score int8
}

然后我们再来看看下面这个结构体,它会占用多少字节呢?

type Person struct {
	age   int8
	score int16 // 类型由 int8 改为了 int16
}

也许我们这个时候已经算好了 1 + 2 = 3,3 个字节不是吗?说实话,真的不是,它会占用 4 个字节,
这可能会有点反常理,但是这跟计算机的体系结构有着密切的关系,先看具体的运行结果:

package main

import (
	"fmt"
	"unsafe"
)

type Person struct {
	age   int8
	score int16
}

func main() {
	var p Person
	// 输出:4 1 2
	// 意味着 p 占用 4 个字节,
	// 其中 age 占用 2 个字节,score 占用 2 个字节
	fmt.Println(unsafe.Sizeof(p), unsafe.Sizeof(p.age), unsafe.Sizeof(p.score))
}

为什么会这样呢?因为 CPU 运行的时候,需要从内存读取数据,而从内存取数据的过程是按字读取的,如果我们数据的内存没有对齐,
则可能会导致 CPU 本来一次可以读取完的数据现在需要多次读取,这样就会造成效率的下降。

关于内存对齐,是一个比较庞大的话题,这里不展开了,我们需要明确的是,go 编译器会对我们的结构体字段进行内存对齐。

内存对我们的影响就是,它可能会导致结构体所占用的空间比它字段类型所需要的空间大(所以我们做指针的算术运算的时候需要非常注意),
具体大多少其实我们其实不需要知道,因为有方法可以知道,哪就是 unsafe.Offsetof,下面会说到。

uintptr 是什么意思?

在开始下文之前,还是得啰嗦一句,uintptr 这种命名方式是 C 语言里面的一种类型命名的惯例,
u 前缀表示是无符号数(unsigned),ptr 是指针(pointer)的缩写,这个 uintptr
按这个命名惯例解析的话,就是一个指向无符号整数的指针。

另外,还有另外一种命名惯例,就是在整型类型的后面加上一个表示占用 bit 数的数字,(1字节=8bit)
比如 int8 表示一个占用 8 位的整数,只可以存储 1 个字节的数据,然后 int64 表示的是一个 8 字节数(64位)。

unsafe 包定义的三个新类型

ArbitraryType

type ArbitraryType int,这个类型实际上是一个 int 类型,但是从名字上我们可以看到,它被命名为任意类型,
也就是说,他会被我们用来表示任意的类型,具体怎么用,是下面说的 unsafe.Pointer 用的。

IntegerType

type IntegerType int,它表示的是一个任意的整数,在 unsafe 包中它被用来作为表示切片或者指针加减的长度。

Pointer

type Pointer *ArbitraryType,这个就是我们上一节提到的指针了,它可以指向任何类型的数据(*ArbitraryType)。

内存地址实际上就是计算机内存的编号,是一个整数,所以我们才可以使用 int 来表示指针。

unsafe 包计算内存的三个方法

这几个方法在我们对内存进行操作的时候会非常有帮助,因为根据这几个方法,我们才可以得知底层数据类型的实际大小。

Sizeof

计算 x 所需要的内存大小(单位为字节),如果其中包含了引用类型,Sizeof 不会计算引用指向的内容的大小。

有几种常见的情况(没有涵盖全部情况):

  • 基本类型,如 int8intSizeof 返回的是这个类型本身的大小,如 unsafe.Sizeof(int8(x)) 为 1,因为 int8 只占用一个字节。
  • 引用类型,如 var x *intSizeof(x) 会返回 8(在我的机器上,不同机器可能不一样),另外就算引用指向了一个复合类型,比如结构体,返回的还是 8(因为变量本身存储的只是内存地址)。
  • 结构体类型,如果是结构体,那么 Sizeof 返回的大小包含了用于内存对齐的内存(所以可能会比结构体底层类型所需要的实际大小要大)
  • 切片,Sizeof 返回的是 24(返回的是切片这个类型所需要占用空间的大小,我们需要知道,切片底层是 slice 结构体,里面三个字段分别是 array unsafe.Pointerlen intcap int,这三个字段所需要的大小为 24)
  • 字符串,跟切片类似,Sizeof 会返回 16,因为字符串底层是一个用来存储字符串内容的 unsafe.Pointer 指针和一个表示长度的 int,所以是 16。

这个方法返回的大小跟机器密切相关,但一般开发者的电脑都是 64 位的,调用这个函数的值应该跟我的机器上得到的一样。

例子:

package main

import (
	"fmt"
	"unsafe"
)

type Person struct {
	age   int8
	score int16
}

type School struct {
	students []Person
}

func main() {
	var x int8
	var y int
	// 1 8
	// int8 占用 1 个字节,int 占用 8 个字节
	fmt.Println(unsafe.Sizeof(x), unsafe.Sizeof(y))

	var p *int
	// 8
	// 指针变量占用 8 个字节
	fmt.Println(unsafe.Sizeof(p))

	var person Person
	// 4
	// age 内存对齐需要 2 个字节
	// score 也需要两个字节
	fmt.Println(unsafe.Sizeof(person))

	var school School
	// 24
	// 只有一个切片字段,切片需要 24 个字节
	// 不管这个切片里面有多少数据,school 所需要占用的内存空间都是 24 字节
	fmt.Println(unsafe.Sizeof(school))

	var s string
	// 16
	// 字符串底层是一个 unsafe.Pointer 和一个 int
	fmt.Println(unsafe.Sizeof(s))
}

Offsetof 方法

这个方法用于计算结构体字段的内存地址相对于结构体内存地址的偏移。具体来说就是,我们可以通过 &(取地址)操作符获取结构体地址。

实际上,结构体地址就是结构体中第一个字段的地址。

拿到了结构体的地址之后,我们可以通过 Offsetof 方法来获取结构体其他字段的偏移量,下面是一个例子:

package main

import (
	"fmt"
	"unsafe"
)

type Person struct {
	age   int8
	score int16
}

func main() {
	var person Person
	// 0 2
	// person.age 是第一个字段,所以是 0
	// person.score 是第二个字段,因为需要内存对齐,实际上 age 占用了 2 个字节,
	// 因此 unsafe.Offsetof(person.score) 是 2,也就是说从第二个字节开始才是 person.score
	fmt.Println(unsafe.Offsetof(person.age), unsafe.Offsetof(person.score))
}

我们上面也说了,编译器会对结构体做一些内存对齐的操作,这会导致结构体底层字段占用的内存大小会比实际需要的大小要大。
因此,我们在取结构体字段地址的时候,最好是通过结构体地址加上 unsafe.Offsetof(x.y) 拿到的地址来操作。如下:

package main

import (
	"fmt"
	"unsafe"
)

type Person struct {
	age   int8
	score int16
}

func main() {
	var person = Person{
		age:   10,
		score: 20,
	}
	// {10 20}
	fmt.Println(person)
	// 取得 score 字段的指针
	// 通过结构体地址,加上 score 字段的偏移量,得到 score 字段的地址
	score := (*int16)(unsafe.Pointer(uintptr(unsafe.Pointer(&person)) + unsafe.Offsetof(person.score)))
	*score = 30
	// {10 30}
	fmt.Println(person)
}

这个例子看起来有点复杂,但是没关系,后面会详细展开的,这里主要要说明的是:

我们通过 unsafe.Pointer 来操作结构体底层字段的时候,我们是通过 unsafe.Offsetof 来获取结构体字段地址偏移量的,
因为我们看到的类型大小并不是内存实际占用的大小,通过 Offsetof 拿到的结果是已经将内存对齐等因素考虑在内的了。
(如果我们错误的认为 age 只占用一个字节,然后将 unsafe.Offsetof(person.score) 替换为 1,那么我们就修改不了 score 字段了)

Alignof 方法

这个方法用以获取某一个类型的对齐系数,就是对齐一个类型的时候需要多少个字节。
这个对开发者而言意义不是非常大,go 里面只有 WaitGroup 用到了一下,
没有看到其他地方有用到这个方法,所以本文不展开了,有兴趣的自行了解。

unsafe.Pointer 是什么?

让我们再来回顾一下,Pointer 的定义是 type Pointer *ArbitraryType,也就是一个指向任意类型的指针类型。
首先它是指针类型,所以我们初始化 unsafe.Pointer 的时候,需要通过 & 操作符来将变量的地址传递进去。我们可以将其想象为指针类型的包装类型。

例子:

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var a int
	// 打印出 a 的地址:0xc0000240a8
	fmt.Println(unsafe.Pointer(&a))
}

unsafe.Pointer 类型转换

在使用 unsafe.Pointer 的时候,往往需要另一个类型来配合,那就是 uintptr,这个 uintptr 在文档里面的描述是:
uintptr 是一种整数类型,其大小足以容纳任何指针的位模式。这里的关键是 “任何指针”,
也就是说,它设计出来是被用来存储指针的,而且其大小保证能存储下任何指针。

而我们知道 unsafe.Pointer 也是表示指针,那么 uintptrunsafe.Pointer 有什么区别呢?

只需要记住最关键的一点,uintptr 是内存地址的整数表示,而且可以进行算术运算,而 unsafe.Pointer 除了可以表示一个内存地址之外,还能保证其指向的内存不会被垃圾回收器回收,但是 uintptr 这个地址不能保证其指向的内存不被垃圾回收器回收。

我们先来看看与 unsafe.Pointer 相关的几种类型转换,这在我们下文几乎所有地方都会用到:

  • 任何类型的指针值都能转换为 unsafe.Pointer
  • unsafe.Pointer 可以转换为一个指向任何类型的指针值
  • unsafe.Pointer 可以转换为 uintptr
  • uintptr 可以转换为 unsafe.Pointer

例子(下面这个例子中输出的地址都是变量 a 所在的内存地址,都是一样的地址):

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var a int
	var p = &a

	// 1. int 类型指针转换为 unsafe.Pointer
	fmt.Println(unsafe.Pointer(p)) // 0xc0000240a8

	// 2. unsafe.Pointer 转换为普通类型的指针
	pointer := unsafe.Pointer(&a)
	var pp *int = (*int)(pointer) // 0xc0000240a8
	fmt.Println(pp)

	// 3. unsafe.Pointer 可以转换为 uintptr
	var p1 = uintptr(unsafe.Pointer(p))
	fmt.Printf("%x\n", p1) // c0000240a8,没有 0x 前缀

	// 4. uintptr 可以转换为 unsafe.Pointer
	p2 := unsafe.Pointer(p1)
	fmt.Println(p2) // 0xc0000240a8
}

如何正确地使用指针?

指针允许我们忽略类型系统而对任意内存进行读写,这是非常危险的,所以我们在使用指针的时候要格外的小心。

我们使用 Pointer 的模式有以下几种,如果我们不是按照以下模式来使用 Pointer 的话,那使用的方式很可能是无效的,
或者在将来变得无效,但就算是下面的几种使用模式,也有需要注意的地方。

运行 go vet 可以帮助查找不符合这些模式的 Pointer 的用法,但 go vet 没有警告也并不能保证代码有效。

以下我们就来详细学习一下使用 Pointer 的几种正确的模式:

1. 将 *T1 转换为指向 *T2Pointer

前提条件:

  • T2 类型所需要的大小不大于 T1 类型的大小。(大小大的类型转换为占用空间更小的类型)
  • T1T2 的内存布局一样。

这是因为如果直接将占用空间小的类型转换为占用空间更大的类型的话,多出来的部分是不确定的内容,当然我们也可以通过 unsafe.Pointer 来修改这部分内容。

这种转换允许将一种类型的数据重新解释为另外一种数据类型。下面是一个例子(为了方便演示用了 int32int8 类型):

在这个例子中,int8 类型不大于 int32 类型,而且它们的内存布局是一样的,所以可以转换。

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var a int32 = 2
	// p 是 *int8 类型,由 *int32 转换而来
	var p = (*int8)(unsafe.Pointer(&a))
	var b int8 = *p
	fmt.Println(b) // 2
}

unsafe.Pointer(&a) 是指向 aunsafe.Pointer(本质上是指向 int32 的指针),(*int8) 表示类型转换,将这个 unsafe.Pointer 转换为 (*int8) 类型。

觉得代码不好理解的可以看下图:

在这里插入图片描述

在上图,我们实际上是创建了一个指向了 a 最低位那 1 字节的指针,然后取出了这个字节里面存储的内容,将其存入了 b 中。

上面提到有一个比较重要的地方,那就是:转换的时候是占用空间大的类型,转换为占用空间小的类型,比如 int32int8 就是符合这个条件的,
那么如果我们将一个小的类型转换为大的类型会发生什么呢?我们来看看下面这个例子:

package main

import (
	"fmt"
	"unsafe"
)

type A struct {
	a int8
}

type B struct {
	b int8
	c int8
}

func main() {
	var a = A{1}
	var b = B{2, 3}

	// 1. 大转小
	var pa = (*A)(unsafe.Pointer(&b))
	fmt.Println(*pa) // {2}

	// 2. 错误示例:小转大(危险,A 里面 a 后面的内存其实是未知的)
	var pb = (*B)(unsafe.Pointer(&a))
	fmt.Println(*pb) // {1 2}
}

大转小:*B 转换为 *A 的具体转换过程可以表示为下图:

在这里插入图片描述

在这个过程中,其实 ab 都没有改变,本质上我们只是创建了一个 A 类型的指针,
这个指针指向变量 b 的地址(但是 *pa 会被看作是 A 类型),所以 pa 实际上是跟 b 共享了内存。
我们可以尝试修改 (*pa).a = 3,我们就会发现 b.b 也变成了 3。

也就是说,最终的内存布局是下图这样的:

在这里插入图片描述

小转大:*A 转换为 *B 的具体转换过程可以表示为下图:

在这里插入图片描述

注意:这是错误的用法。(当然也不是完全不行)

*A 转换为 *B 的过程中,因为 B 需要 2 个字节空间,所以我们拿到的 pb 实际上是包含了 a 后面的 1 个字节,
但是这个字节本来是属于 b 变量的,这个时候 b*pb 都引用了第 2 个字节,这样依赖它们在修改这个字节的时候,
会相互影响,这可能不是我们想要的结果,而且这种操作非常危险。

2. 将 Pointer 转换为 uintptr(但不转换回 Pointer

Pointer 转换为 uintptr 会得到 Pointer 指向的内存地址,是一个整数。这种 uintptr 的通常用途是打印它。

但是,uintptr 转换回 Pointer 通常无效
uintptr 是一个整数,而不是一个引用。将指针转换为 uintptr 会创建一个没有指针语义的整数值。
即使 uintptr 持有某个对象的地址,如果该对象移动,垃圾收集器也不会更新该 uintotr 的值,
也不会阻止该对象被回收。

如下面这种,我们取得了变量的地址 p,然后做了一些其他操作,最后再从这个地址里面读取数据:

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var a int = 10
	var p = uintptr(unsafe.Pointer(&a))
	// ... 其他代码
	// 下面这种转换是危险的,因为有可能 p 指向的对象已经被垃圾回收器回收
	fmt.Println(*(*int)(unsafe.Pointer(p)))
}

具体如下图:

在这里插入图片描述

只有下面的模式中转换 uintptrPointer 是有效的。

3. 使用算术运算将 Pointer 转换为 uintptr 并转换回去

如果 p 指向一个已分配的对象,我们可以将 p 转换为 uintptr 然后加上一个偏移量,再转换回 Pointer。如:

p = unsafe.Pointer(uintptr(p) + offset)

这种模式最常见的用法是访问结构体或者数组元素中的字段:

// 等价于 f := unsafe.Pointer(&s.f)
f := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f))

// 等价于 e := unsafe.Pointer(&x[i])
e := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + i*unsafe.Sizeof(x[0]))

对于第一个例子,完整代码如下:

package main

import (
	"fmt"
	"unsafe"
)

type S struct {
	d int8
	f int8
}

func main() {
	var s = S{
		d: 1,
		f: 2,
	}
	f := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f))
	fmt.Println(*(*int8)(f)) // 2
}

最终的内存布局如下图(s 的两个字段都是 1 字节,所以图中 df 都是 1 字节):

在这里插入图片描述

详细说明一下:

第一小节我们说过了,结构体字段的内存布局是连续的。上面没有说的是,其实数组的内存布局也是连续的。这对理解下面的内容很有帮助。

  • &s 取得了结构体 s 的地址
  • unsafe.Pointer(&s) 转换为 Pointer 对象,这个指针对象指向的是结构体 s
  • uintptr(unsafe.Pointer(&s)) 取得 Pointer 对象的内存地址(整数)
  • unsafe.Offsetof(s.f) 取得了 f 字段的内存偏移地址(相对地址,相对于 s 的地址)
  • uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f) 就是 s.f 的实际内存地址了(绝对地址)
  • 最后转换回 unsafe.Pointer 对象,这个对象指向的地址是 s.f 的地址

最终 f 指向的地址是 s.f,然后我们可以通过 (*int8)(f)unsafe.Pointer 转换为 *int8 类型指针,最后通过 * 操作符取得这个指针指向的值。

对于第二个例子,完整代码如下:

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var x = [3]int8{4, 5, 6}
	e := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + 2*unsafe.Sizeof(x[0]))
	fmt.Println(*(*int8)(e)) // 6
}

最终的内存布局如下图,e 指向了数组的第 3 个元素(下标从 0 开始算的):

在这里插入图片描述

代码中的 2 可以是其他任何有效的数组下标。

  • &s 取得了数组 x 的地址
  • unsafe.Pointer(&x) 转换为 Pointer 对象,这个指针对象指向的是数组 x
  • uintptr(unsafe.Pointer(&x)) 取得 Pointer 对象的内存地址(也就是 0xab
  • unsafe.Sizeof(x[0]) 是数组 x 里面每一个元素所需要的内存大小,乘以 2 表示是元素 x[2] 的地址偏移量(相对地址,相对于 x[0] 的地址)
  • uintptr(unsafe.Pointer(&x)) + 2*unsafe.Sizeof(x[0]) 表示的是数组元素 x[2] 的实际内存地址(绝对地址)
  • 最后转换回 unsafe.Pointer 对象,这个对象指向的地址是 x[2] 的地址(也就是 0xab + 2)。

最终,我们可以通过 (*int8)e 转换为 *int8 类型的指针,最后通过 * 操作符获取其指向的内容,也就是 6。

以这种方式对指针进行加减偏移量的运算都是有效的。(em…这里说的是写在同一行的这种方式)。这种情况下使用 &^ 这两个操作符也是有效的(通常用于内存对齐)。
在所有情况下,得到的结果必须指向原始分配的对象。

不像 C 语言,将指针加上一个超出其原始分配的内存区域的偏移量是无效的:

// 无效: end 指向了分配的空间以外的区域
var s thing
end = unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Sizeof(s))

在这里插入图片描述

下面对切片的这种操作也跟上图类似。

// 无效: end 指向了分配的空间以外的区域
b := make([]byte, n)
end = unsafe.Pointer(uintptr(unsafe.Pointer(&b[0])) + uintptr(n))

这是因为,内存的地址范围是 [start, end),是不包含终点的那个地址的,上面的 end 都指向了地址的边界,这是无效的。
当然,除了边界上,边界以外都是无效的。(end 指向的内存不是属于那个变量的)

注意:两个转换(Pointer => uintptr, uintptr => Pointer)必须出现在同一个表达式中,只有中间的算术运算:

// 无效: uintptr 在转换回 Pointer 之前不能存储在变量中
// 原因上面也说过了,就是 p 指向的内容可能会被垃圾回收器回收。
u := uintptr(p)
p = unsafe.Pointer(u + offset)

注意:指针必须指向已分配的对象,因此它不能是 nil

// 无效: nil 指针转换
u := unsafe.Pointer(nil)
p := unsafe.Pointer(uintptr(u) + offset)

4. 调用 syscall.Syscall 时将指针转换为 uintptr

觉得文字太啰嗦可以直接看图:

在这里插入图片描述

syscall 包中的 Syscall 函数将其 uintptr 参数直接传递给操作系统,然后操作系统可以根据调用的细节将其中一些参数重新解释为指针。
也就是说,系统调用实现隐式地将某些参数从 uintptr 转换回指针。

如果必须将指针参数转换为 uintptr 以用作参数,则该转换必须出现在调用表达式本身中:

syscall.Syscall(SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(p)), uintptr(n))

编译器通过安排被引用的分配对象(如果有的话)被保留,并且在调用完成之前不移动,来处理在调用程序集中实现的函数的参数列表中转换为 uintptr 的指针,
即使仅从类型来看,在调用期间似乎不再需要对象。

为了使编译器识别该模式,转换必须出现在参数列表中:

// 无效:在系统调用期间隐式转换回指针之前,
// uintptr 不能存储在变量中。
u := uintptr(unsafe.Pointer(p))
syscall.Syscall(SYS_READ, uintptr(fd), u, uintptr(n))

5. 将 reflect.Value.Pointerreflect.Value.UnsafeAddr 的结果从 uintptr 转换为 Pointer

reflect.ValuePointerUnsafeAddr 方法返回类型 uintptr 而不是 unsafe.Pointer
从而防止调用者在未导入 unsafe 包的情况下将结果更改为任意类型。(这是为了防止开发者对 Pointer 的误操作。)
然而,这也意味着这个返回的结果是脆弱的,我们必须在调用之后立即转换为 Pointer(如果我们确切的需要一个 Pointer):

其实就是为了让开发者明确自己知道在干啥,要不然写出了 bug 都不知道。

// 在调用了 reflect.Value 的 Pointer 方法后,
// 立即转换为 unsafe.Pointer。
p := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer()))

与上述情况一样,在转换之前存储结果是无效的:

// 无效: uintptr 在转换回 Pointer 之前不能保存在变量中
u := reflect.ValueOf(new(int)).Pointer() // uintptr 保存到了 u 中
p := (*int)(unsafe.Pointer(u))

原因上面也说了,因为 u 指向的内存是不受保护的,可能会被垃圾回收器收集。

6. 将 reflect.SliceHeaderreflect.StringHeaderData 字段跟 Pointer 互相转换

与前面的情况一样,反射数据结构 SliceHeaderStringHeader 将字段 Data 声明为 uintptr
以防止调用者在不首先导入 unsafe 的情况下将结果更改为任意类型。
然而,这意味着 SliceHeaderStringHeader 仅在解析实际切片或字符串值的内容时有效。

我们先来看看这两个结构体的定义:

// SliceHeader 是切片的运行时表示(内存布局跟切片一致)
// 它不能安全或可移植地使用,其表示形式可能会在以后的版本中更改。
// 此外,Data 字段不足以保证它引用的数据不会被垃圾回收器收集,
// 因此程序必须保留一个指向底层数据的单独的、正确类型的指针。
type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

// StringHeader 字符串的运行时表示(内存布局跟字符串一致)
// ... 其他注意事项跟 SliceHeader 一样
type StringHeader struct {
    Data uintptr
    Len  int
}

使用示例:

// 将字符串的内容修改为 p 指向的内容
var s string
hdr := (*reflect.StringHeader)(unsafe.Pointer(&s))
hdr.Data = uintptr(unsafe.Pointer(p))
hdr.Len = n

这种转换是有效的,因为 SliceHeader 的内存布局和 StringHeader 的内存布局一致,并且 SliceHeader 所占用的内存空间比
StringHeader 所占用内存空间大,也就是说,这是一种大小更大的类型转换为大小更小的类型,这会丢失 SliceHeader 的一部分数据,
但是丢失的那部分对我们程序正常运行是没有任何影响的。

在这个用法中,hdr.Data 实际上是引用字符串头中的基础指针的另一种方式,而不是 uintptr 变量本身。
(我们这里也是使用了 uintptr 表达式,而不是一个存储了 uintptr 类型的变量)

通常来说,reflect.SliceHeaderreflect.StringHeader 通常用在指向实际切片或者字符串的
*reflect.SliceHeader*reflect.StringHeader永远不会被当作普通结构体使用
程序不应该声明或者分配这些结构体类型的变量,下面的写法是有风险的。

// 无效: 直接声明的 Header 不会将 Data 作为引用
var hdr reflect.StringHeader
hdr.Data = uintptr(unsafe.Pointer(p))
hdr.Len = n
s := *(*string)(unsafe.Pointer(&hdr)) // p 可能已经丢失

Add 函数

函数原型是:func Add(ptr Pointer, len IntegerType) Pointer

这个函数的作用是,可以将 unsafe.Pointer 类型加上一个偏移量得到一个指向新地址的 unsafe.Pointer
简单点来说,就是对 unsafe.Pointer 做算术运算的,上面我们说过 unsafe.Pointer 是不能直接进行算术运算的,
因此需要先转换为 uintptr 然后再进行算术运算,算完再转换回 unsafe.Pointer 类型,所以会很繁琐。
有了 Add 方法,我们可以写得简单一些,不用做 uintptr 的转换。

有了 Add,我们可以简化一下上面那个通过数组指针加偏移量的例子,示例:

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var x = [3]int8{4, 5, 6}
	//e := unsafe.Pointer(uintptr(unsafe.Pointer(&x)) + 2*unsafe.Sizeof(x[0]))
	e := unsafe.Add(unsafe.Pointer(&x), 2 * unsafe.Sizeof(x[0]))
	fmt.Println(*(*int8)(e)) // 6
}

在这个例子中,我们先是通过 unsafe.Pointer(&x) 获取到了一个指向 xunsafe.Pointer 对象,
然后通过 unsafe.Add 加上了 2 个 int8 类型大小的偏移量,最终得到的是一个指向 x[2]unsafe.Pointer

Add 方法可以简化我们对指针的一些操作。

Slice 函数

Slice 函数的原型是:func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

函数 Slice 返回一个切片,其底层数组以 ptr 开头,长度和容量为 len

unsafe.Slice(ptr, len) 等价于:

(*[len]ArbitraryType)(unsafe.Pointer(ptr))[:]

除了这个,作为一种特殊情况,如果 ptrnillen 为零,则 Slice 返回 nil

示例:

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var x = [6]int8{4, 5, 6, 7, 8, 9}
	// 这里取了数组第一个元素 x[1] 的地址,
	// 从这个地址开始取了 3 个元素作为新的切片底层数组,
	// 返回这个新的切片
	s := unsafe.Slice(&x[1], 3)
	fmt.Println(s) // [5 6 7]
}

需要非常注意的是,第一个参数实际上隐含传递了该地址对应的类型信息,上面用了 &x[1],传递的类型实际上是 int8

如果我们按照下面这样写,得到的结果就是错误的,因为它隐式传递的类型是 [6]int8(这是一个数组),而不是 int8

// 错误示例:
package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var x = [6]int8{4, 5, 6, 7, 8, 9}
	// unsafe.Slice 第一个参数接收到的类型是 [6]int,
	// 所以最终返回了一个切片,这个切片有三个元素,
	// 每一个元素都是长度为 6 数据类型为 int8 的数组。
	// 也即形如 [[6]int8, [6]int8, [6]int8] 的切片
	s := unsafe.Slice(&x, 3)
	// [[4 5 6 7 8 9] [91 91 52 32 53 32] [54 32 4 5 6 7]]
	fmt.Println(s)
}

这样显然不是我们想要的结果,因为它读取到了一部分未知的内存,如果我们修改这部分内存,可能会造成程序崩溃。

一个很常见的用法

在实际应用中,很多框架为了提高性能,在做 []bytestring 的切换的时候,往往会使用 unsafe.Pointer 来实现(比如 gin 框架):

下面这个例子实现了 []bytestring 的转换,而且避免了内存分配。这是因为,切片和字符串的内存布局是一致的,只不过切片比字符串占用
的空间多了一点,还有一个 cap 容量字段,用来表示切片的容量是多少。具体我们可以再看看上面的 reflect.SliceHeaderreflect.StringHeader
在下面这个字节切片到字符串的转换过程中,是从占用空间更大的类型转换为占用空间更小的类型,所以是安全的,丢失的那个 cap 对我们程序正常运行无影响。

先看看 []bytestring 的类型底层定义:

// 字符串
type stringStruct struct {
	str unsafe.Pointer
	len int
}

// 切片,比 string 的结构体多了一个 cap 字段,但是前面的两个字段是一样的
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

[]byte 转字符串的示例:

func BytesToString(b []byte) string {
	// 将 b 解析为字符串
	return *(*string)(unsafe.Pointer(&b))
}

这个操作如下图:

在这里插入图片描述

在这个转换过程中,其实只是将 b 表示的类型转由 []byte 转换为了 string,之所以可以这么转,
是因为 []byte 的内存布局跟 string 的内存布局是一样的,
但是由于字符串实际占用空间比切片类型要小(不包括其底层指针指向的内容),
所以在转换过程中,cap 字段丢失了,但是 strin 也不需要这个字段,所以对程序运行没影响。

同时字符串长度是按照字节计算的,所以字节切片和字符串的 len 字段是一样的,不需要做额外处理。

字符串转 []byte 的示例:

func StringToBytes(s string) []byte {
	return *(*[]byte)(unsafe.Pointer(
		// 定义匿名结构体变量,内存布局跟 []byte 一致,
		// 这样就可以转换为 []byte 了。
		&struct {
			string
			Cap int
		}{s, len(s)},
	))
}

这个操作如下图:

在这里插入图片描述

这个过程只是需要分配很小一部分内存就可以完成了,效率比 go 自带的转换高。

go 里面字符串是不可变的,但 go 为了维持字符串不可变的特性,在字符串和字节切片之间转换一般都是通过数据拷贝的方式实现的。
因为这样就不会影响到原来的字符串或者字节切片了,但是这样做的性能会非常低。
具体可参考 slicebytetostringstringtoslicebyte 函数,这两个函数位于 runtime/string.go 中。

总结

本文主要讲了如下内容:

  • 内存布局:结构体的字段存储是占用了连续的一段内存,而且结构体可能会占用比实际需要空间更大的内存,因为需要对齐内存。
  • 指针存储了指向变量的地址,对这个地址使用 * 操作符可以获取这个地址指向的内容。
  • uintptr 是 C 里面的一种命名惯例,u 前缀的意思是 unsignedint 表示是 int 类型,ptr 表示这个类型是用来表示指针的。
  • unsafe 定义的 Pointer 类型是一种可以指向任何类型的指针,ArbitraryType 可用于表示任意类型。
  • 我们通过 unsafe.Pointer 修改结构体字段的时候,要使用 unsafe.Offsetof 获取结构体的偏移量。
  • 通过 unsafe.Sizeof 可以获得某一种类型所需要的内存空间大小(其中包括了用于内存对齐的内存)。
  • unsafe.Pointeruintptr 之间的类型转换。
  • 几种使用 unsafe.Pointer 的模式:
    • *T1*T2 的转换
    • unsafe.Pointer 转换为 uintptr
    • 使用算术运算将 unsafe.Pointer 转换为 uintptr 并转换回去(需要注意不能使用中间变量来保存 uintptr(unsafe.Pointer(p))
    • 调用 syscall.Syscall 时将指针转换为 uintptr
    • reflect.ValuePointerUnsafeAddr 的结果从 uintptr 转换为 unsafe.Pointer
    • reflect.SliceHeaderreflect.StringHeaderData 字段跟 Pointer 互相转换
  • Add 函数可以简化指针的算术运算,不用来回转换类型(比如 unsafe.Pointer 转换为 uintptr,然后再转换为 unsafe.Pointer)。
  • Slice 函数可以获取指针指向内存的一部分。
  • 最后介绍了 string[]byte 之间通过 unsafe.Pointer 实现高效转换的方法。
Logo

一起探索未来云端世界的核心,云原生技术专区带您领略创新、高效和可扩展的云计算解决方案,引领您在数字化时代的成功之路。

更多推荐