go unsafe学习

118 阅读9分钟

什么是unsafe包?

unsafe是Go语言标准库中的一个包,提供了一些不安全的编程操作,如直接操作指针、修改内存等。

由于这些操作可能会引发内存错误和安全漏洞,因此需要非常小心使用。

通用指针unsafe.Pointer

unsafe.Pointer是一个通用的指针类型,可以指向任何类型的变量。

它可以通过uintptr类型的指针运算来进行指针操作,但是需要注意指针类型的对齐和内存边界问题。

当我们使用Pointer这个包,可以实现类似于C语言中的操作指针及数据。

在学习操作之前,我们先来学习下内存对齐。

内存对齐

我们先来看个例子:

定义一个结构体,其中一个成员变量是1个字节,另一个是4字节的

type Person struct {
	age   int8
	score int32
}

测试代码

func main() {
	p := &Person{
		age:   18,
		score: 99,
	}
	fmt.Println("Current SizeOf>", unsafe.Sizeof(p))
}

结构体变量输出结果

Current SizeOf> 8

咦,你发现没有,这个结构体占了8个字节,那我们逐个来分析看看

代码如下

func main() {
	p := &Person{
		age:   18,
		score: 99,
	}
	fmt.Println("Current SizeOf>", unsafe.Sizeof(p))
	fmt.Println("age SizeOf>", unsafe.Sizeof(p.age))
	fmt.Println("socre SizeOf>",unsafe.Sizeof(p.score))
}

输出结果

Current SizeOf> 8
age SizeOf> 1
socre SizeOf> 4

为什么会这样呢?

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

我们继续补充多一个int64的成员变量

type Person struct {
	age         int8
	score       int32
	StudentCode int64
}

使用刚刚上述的代码去执行

输出结果依旧是

Current SizeOf> 8
age SizeOf> 1
socre SizeOf> 4

这是为什么呢?

注意到没有,我在初始化变量的时候用的是

p := &Person{
		age:   18,
		score: 99,
	}

这时的p是Person类型,也就是指针类型。指针类型的变量占8个字节,因为其指针变量是用来指向地址的变量,当我们要输出其具体值是通过(变量)的方式来访问。

那么,言归正传,我们要怎么体现出字节对齐呢?

type Person struct {
	age         int8
	score       int32
	StudentCode int64
	co          int
}

我们先继续看这个结构体,按照字节对齐的话,age和score会按照int64的方式进行补足,也就是这个结构体变量占24字节,age和score拼接成8个字节,而StudentCode是int64就是8个字节,最后的co也是64位,因为我的操作系统就是64位的。

我们重新来看新的代码

func main() {
	p := Person{
		age:         18,
		score:       99,
		StudentCode: 2024,
		co:          2000000000,
	}
	fmt.Println("Current SizeOf>", unsafe.Sizeof(p))
	fmt.Println("age SizeOf>", unsafe.Sizeof(p.age))
	fmt.Println("socre SizeOf>", unsafe.Sizeof(p.score))
}

注意,这是p不是指针变量了,我们来看输出结果

Current SizeOf> 24
age SizeOf> 1
socre SizeOf> 4

可以看到,我们单独输出其中的成员变量大小时,他是按照自己本身的字节位来的,但当我们封成一个结构体后,这个结构体内的成员变量合并拼成一个连续的内存地址,而且需要按照8的倍数来读取,而不足8字节的需要进行内存对齐。

unsafe包中有哪些函数?

这里我们刚刚用了SizeOf来演示结构体变量,能够体现出其内存的对齐,而我们接下来关注其中几个函数

Offsetof及StringData和String。

通过一个样例来更加深入了解unsafe的Pointer

Pointer实践

我们还是以一个结构体为例

	p := Person{
		age:         18,
		score:       99,
		StudentCode: 2024,
		co:          2000000000,
	}
	fmt.Println("Current SizeOf>", unsafe.Sizeof(p))
	fmt.Println("age SizeOf>", unsafe.Sizeof(p.age))
	fmt.Println("socre SizeOf>", unsafe.Sizeof(p.score))
	fmt.Println("p Addr>", unsafe.Pointer(&p))
	fmt.Println("p.score Addr>", unsafe.Pointer(&p.score))

输出结果

Current SizeOf> 24
age SizeOf> 1
socre SizeOf> 4
p Addr> 0xc00000e138
p.score Addr> 0xc00000e13c

可以看到,p.age的地址时e138,而p.score的地址时e13c,相差4个字节,这也就说明了go编译器在编译期间把age int8补充字节补成了4个也就是int32.

指针的大小与系统的位数有关。在 64 - bit 系统下,指针用于存储内存地址,需要足够的位数来表示整个内存空间的地址范围。64 位(8 字节)的指针大小可以表示个不同的内存地址,所以*Person类型的指针大小是 8 字节。这是因为指针变量只需要存储一个内存地址,而这个地址在 64位系统中的表示需要 8 字节的空间。

那么我们怎么通过unsafe.Pointer进行操作内存数据?

Go 语言的内存安全机制

Go 语言设计了一套严格的内存安全机制来确保程序的稳定性和可预测性。在正常情况下,结构体成员应该通过合法的、符合语言规范的方式进行访问和修改,也就是使用点语法(如 p.score)。

这种设计是为了防止出现一些因错误的内存操作而引发的问题,比如:

  • 非法内存访问:如果随意通过指针运算和强制解引用去修改结构体成员的值,很可能会不小心访问到不属于该结构体成员的内存区域,从而导致程序出现未定义行为,甚至可能崩溃。
  • 破坏内存布局一致性:结构体在内存中的布局是按照一定规则进行的,包括内存对齐等。通过非标准方式修改成员值可能会破坏这种布局的一致性,进而影响到后续对结构体其他成员的正常访问以及整个程序的运行逻辑。

以这个代码为例

func main() {
	p := Person{
		age:         18,
		score:       99,
		StudentCode: 2024,
		co:          2000000000,
	}
	fmt.Println("Current SizeOf>", unsafe.Sizeof(p))
	fmt.Println("age SizeOf>", unsafe.Sizeof(p.age))
	fmt.Println("socre SizeOf>", unsafe.Sizeof(p.score))
	fmt.Println("p Addr>", unsafe.Pointer(&p))
	fmt.Println("p.score Addr>", unsafe.Pointer(&p.score))
	newScoreAddr := unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + 4)
	fmt.Println("newScoreAddr>", newScoreAddr)
	newScore := *(*int32)(newScoreAddr)
	fmt.Println("newScore>", newScore)
	newScore = 100
	fmt.Println("OriginScore>", p.score)
	fmt.Println("newScore>", newScore)
	Score2Addr := unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + uintptr(unsafe.Offsetof(p.score)))
	fmt.Println("Score2Addr>", Score2Addr)
	fmt.Println("Score2>", *(*int32)(Score2Addr))
}

我们在这一块代码企图修改值发现运行结果并不会改变

newScoreAddr := unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + 4)
	fmt.Println("newScoreAddr>", newScoreAddr)
	newScore := *(*int32)(newScoreAddr)
	fmt.Println("newScore>", newScore)
	newScore = 100
	fmt.Println("OriginScore>", p.score)
	fmt.Println("newScore>", newScore)
	Score2Addr := unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + uintptr(unsafe.Offsetof(p.score)))
	fmt.Println("Score2Addr>", Score2Addr)
	fmt.Println("Score2>", *(*int32)(Score2Addr))

运行结果

p Addr> 0xc00009e030
p.score Addr> 0xc00009e034
newScoreAddr> 0xc00009e034
newScore> 99
OriginScore> 99
newScore> 100
Score2Addr> 0xc00009e034
Score2> 99

可以看到这里,newScore = 100,但输出时,并没有修改对应地址上的值。

Offsetof

但我们可以通过OffSet为例来指定对应的字节位移

Score2Addr := unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + uintptr(unsafe.Offsetof(p.score)))

在我们的代码块中有这样一句,看起来非常晦涩难懂,我们拆分一下

unsafe.Pointer()

首先这个需要传入一个地址,然后unsafe.Pointer(&p),也就是指向p结构体变量的第一个位置的地址。

unsafe.Offsetof(p.score)表示偏移量。

fmt.Println("unsafe.Offsetof(p.score)>", unsafe.Offsetof(p.score))

打印结果是

unsafe.Offsetof(p.score)> 4

也就说明了,Offsetof返回的是,相对于p的第一个地址后的偏移量,因为前面p.age是int8,由编译器进行补充字节,变成了int32,所以这里p.score作为第二个成员变量,相对于p.age自然也就便宜了4个字节的地址。

那么我们继续看

uintptr(unsafe.Pointer(&p))

这里就需要说明了,uintptr()也是打印地址,不过是不带前缀的

测试代码

fmt.Println("uintptr(unsafe.Pointer(&p))>", uintptr(unsafe.Pointer(&p)))

输出结果

uintptr(unsafe.Pointer(&p))> 824633778488

当你使用 fmt.Println("uintptr(unsafe.Pointer(&p))>", uintptr(unsafe.PPointer(&p))) 时,uintptr 类型是一个无符号整数类型,它将内存地址转换为一个无符号整数来表示,所以输出的 824633778488 是对应内存地址 0xc00000e138 的十进制数值表示形式。

那么两个地址相加,也就是uintptr(unsafe.Pointer(&p)) + uintptr(unsafe.Offsetof(p.score))

Score2Addr := unsafe.Pointer(uintptr(unsafe.Pointer(&p)) + uintptr(unsafe.Offsetof(p.score)))

实际上就是p.score的地址

StringData

  1. unsafe.StringData函数的用途
    • 在 Go 语言中,unsafe.StringData是一个非常底层的函数,用于获取字符串底层字节数组的起始地址(一个*byte类型的指针)。
    • 它允许你在遵守 Go 语言内存安全规则(尽管使用unsafe包本身就很容易违反这些规则,需要特别小心)的情况下,对字符串底层的数据进行一些特殊的操作。
    • 例如,你可以通过这个指针来查看字符串字节数组的一些细节,或者在某些特定的、严格控制的场景下(比如和其他底层系统交互或者进行高性能优化),对字符串的底层字节进行遍历等操作。
  1. 字符串在 Go 语言中的存储结构
    • 字符串在 Go 语言中是不可变的(immutable)。一个字符串实际上是一个包含指向字节数组的指针和长度的结构体,虽然你在代码中通常看不到这个结构体的定义。
    • unsafe.StringData函数就是让你能够访问到这个隐藏结构体中的字节数组指针部分,这样你就能够接触到字符串真正存储的数据部分。
	a := "This is test word"
	fmt.Println("StringData>", unsafe.StringData(a))

输出结果

StringData> 0x10a816f

String

1. unsafe.StringData 函数

如前面所讲,unsafe.StringData 函数用于获取字符串底层字节数组的指针(返回一个 *byte 类型的指针),它能让我们接触到字符串真正存储数据的字节数组部分。在给定的代码中,先通过 unsafe.StringData(a) 获取了字符串 a(这里 a := "This is test word")底层字节数组的指针。

2. unsafe.String 函数

unsafe.String 是一个相对较为底层且危险的函数(因为涉及到绕过 Go 语言正常的字符串处理机制,使用不当极易导致未定义行为)。它的作用是根据给定的字节数组指针和指定的长度来构造一个新的字符串。

其函数签名大致如下(实际在 Go 标准库中是通过 unsafe 包内部机制实现的):

func String(ptr *byte, len IntegerType) string

需要传入一个*byte的变量,因而这里我们使用unsafe.StringData(a),并设置长度为4

fmt.Println("长度位4的字符串a的内容>", unsafe.String(unsafe.StringData(a), 4))

输出结果

长度位4的字符串a的内容> This

参考地址

深入理解 go unsafe:juejin.cn/post/717496…