likes
comments
collection
share

深入 GO unsafe.Pointer & uintptr

作者站长头像
站长
· 阅读数 6

这是我写 go 源码系列第 4 篇,感兴趣可以阅读另外几篇

GO unsafe.Pointer & uintptr 

你是否经常看源码,源码里用 unsafe.Pointer, uintptr 各种骚操作,有没有想过为啥源码会这么用?若不了解 unsafe.Pointer, uintptr 使用姿势,代码很难看懂。虽然 GO 官方不建议大家使用,但作为一个 GO 工程师怎么能不了解 unsafe.Pointer 呢。

本文讲解案例采用 GO SDK 版本是 1.20.4,如果你的 GO SDK 版本较低,SDK 函数可能会有一些差异

GO 普通指针(*T)

Go 语言中的普通指针(即非 unsafe.Pointer)受到一些限制,以确保代码的安全性和可靠性,下面是普通指针的一些限制。

限制一:不能进行数学运算操作

1、  Go 语言不允许对普通指针进行数学运算,例如加法、减法等。

2、  不能直接对指针进行递增或递减操作。

func T() {
	var (
		x = 5
		y = &x
	)

	y++
	y = &x + 3
	m := y * 2
}

上面这段代码编译会报错:

./T.go:9:2: invalid operation: y++ (non-numeric type *int)
./T.go:10:6: invalid operation: &x + 3 (mismatched types *int and untyped int)
./T.go:11:7: invalid operation: y * 2 (mismatched types *int and untyped int)

限制二:不同类型的指针不能相互转换

1、  不同类型的指针之间不能直接相互转换。

func TConvert() {
	var (
		n = int(100)
		u *uint
	)
	u = &n
	fmt.Println(u)
}

上面这段代码编译会报错:

cannot use &n (value of type *intas *uint value in assignment

限制三:不能类型的指针不能用"=="、"!="比较、相互赋值

1、不同类型的指针之间不能进行比较操作,也不能相互赋值。

func Compare() {
	var (
		n         = 100
		f         = 2.8
		t         = 100
		g    uint = 200
		ptrN      = &n
		ptrF      = &f
		ptrT      = &t
		ptrG      = &g
	)
	fmt.Printf("%v", ptrN == ptrT) // 同类型可以判等指针变量相等
	fmt.Printf("%v", ptrG == ptrT) // 不同类型不能判断指针变量是否相等
	fmt.Printf("%v", ptrF == ptrT) // 不同类型不能判断指针变量是否相等
}

上面这段代码编译会报错:

./T.go:37:27: invalid operation: ptrG == ptrT (mismatched types *uint and *int)
./T.go:38:27: invalid operation: ptrF == ptrT (mismatched types *float64 and *int)

2个指针变量类型相同可以相互转换的情况下,才可以进行比较。额外知识点指针变量可以通过"=="、"!="和nil进行比较

uintptr

uintptr 的定义在 builtin 包,定义如下:

// uintptr is an integer type that is large enough to hold the bit pattern of
// any pointer.
type uintptr uintptr

参考注释和定义我们知道:

1、  uintptr 是 integer 类型它足够大

2、  可以存储任何一种是数据结构对应的 Pointer 地址,通俗的解释 uintptr 本质存的是地址,uintptr 存的是10进制地址,举一栗子:

func out() {
	var v int
	pointer := unsafe.Pointer(&v)
	address := uintptr(pointer)
	fmt.Println(fmt.Sprintf("vAddress=%+v,pointerAddress=%+v,address=%v", &v, pointer, address))
}

日志输出:

=== RUN   TestOut
v=0xc00010e190,pointerAddress=0xc00010e190,address=824634827152
--- PASS: TestOut (0.00s)
PASS

vAddress = pointerAddress 因为指向同一个内存块,address 也是内存地址为什么是824634827152?其实很简单,pointerAddress 和vAddress 是16进制,address 是10进制

特别注意

// A uintptr is an integer, not a reference.
// Converting a Pointer to a uintptr creates an integer value
// with no pointer semantics.
// Even if a uintptr holds the address of some object,
// the garbage collector will not update that uintptr's value
// if the object moves, nor will that uintptr keep the object
// from being reclaimed.

intptr 并没有指针的语义,即使 uintptr 保存某个对象的地址,如果对象移动,uintptr 也不会阻止对象被 GC 回收。意思就是 uintptr 所指向的对象会被 gc 回收的。

unsafe 包主要提供3个函数支持【任意类型】=> uintptr 的转换:

// Sizeof takes an expression x of any type and returns the size in bytes
func Sizeof(x ArbitraryType) uintptr
// Offsetof returns the offset within the struct of the field represented by x
func Offsetof(x ArbitraryType) uintptr
// Alignof takes an expression x of any type and returns the required alignment
func Alignof(x ArbitraryType) uintptr

1、  第一个函数 Sizeof 简单好理解,获取任何类型大小返回的是字节

func sizeof() {
	var v int
	fmt.Println(unsafe.Sizeof(v))
}

日志输出:

=== RUN   TestSizeOf
8
--- PASS: TestSizeOf (0.00s)
PASS

输出8个字节,具体场景和用法后续会拓展

2、  第二个函数Offsetof代表偏移量,主要用与struct field 偏移量

func offsetof() {
	person := struct {
		Name    string
		Age     int
		Address string
		Phone   uint
	}{
		Name:    "李点点滴滴",
		Age:     10,
		Address: "ddddddd",
		Phone:   12344,
	}
	fmt.Println(fmt.Sprintf("offsetName=%+v,offsetAge=%+v,offsetAddress=%+v,offsetPhone=%+v"unsafe.Offsetof(person.Name), unsafe.Offsetof(person.Age), unsafe.Offsetof(person.Address), unsafe.Offsetof(person.Phone)))
}

日志输出:

=== RUN   TestOffsetOf
offsetName=0,offsetAge=16,offsetAddress=24,offsetPhone=40
--- PASS: TestOffsetOf (0.00s)
PASS

有内存对齐相关知识大家可以自己研究

3、  第三个函数Alignof接受任何类型的表达式x并返回所需的对齐方式,这个用的比较少了解下就行

func alignof() {
	fmt.Println(unsafe.Alignof(int(0)))       // 打印int类型的对齐要求
	fmt.Println(unsafe.Alignof(float64(0.0))) // 打印float64类型的对齐要求
	fmt.Println(unsafe.Alignof(struct{}{}))   // 打印空结构体类型的对齐要求
	fmt.Println(unsafe.Alignof("李四"))         // 打印string的内存对其要求
}

日志输出:

=== RUN   TestAlignOf
8
8
1
8
--- PASS: TestAlignOf (0.00s)
PASS

这三个函数开发者可以将任意类型变量传入获取对应的 uintptr,用来后续计算内存地址(比如基于一个结构体字段地址,获取下一个字段地址等)

unsafe.Pointer

我们看下unsafe 包下的 Pointer的定义和官方描述

// ArbitraryType is here for the purposes of documentation only and is not actually
// part of the unsafe package. It represents the type of an arbitrary Go expression.
type ArbitraryType int

// Pointer represents a pointer to an arbitrary type. There are four special operations
// available for type Pointer that are not available for other types:
//   - A pointer value of any type can be converted to a Pointer.
//   - A Pointer can be converted to a pointer value of any type.
//   - A uintptr can be converted to a Pointer.
//   - A Pointer can be converted to a uintptr.
//
// Pointer therefore allows a program to defeat the type system and read and write
// arbitrary memory. It should be used with extreme care.type
type Pointer *ArbitraryType

按照我个人的理解文档定义“ArbitraryType”是任意的类型,也就是说 Pointer 可以指向任意类型,实际上它类似于 C 语言里的 void*。

官方提供了四种 Pointer 支持的场景:

1、  任何类型的指针值都可以被转换为 Pointer

2、  Pointer 可以被转换为任何类型的指针值

3、  uintptr 可以被转换为 Pointer

4、  Pointer 可以被转换为 uintptr

unsafe.Pointer 常见几种使用技巧

T1转换为指向T2的指针

官方用了math.Float64bits案例

func Float64bits(f float64) uint64 {
	return *(*uint64)(unsafe.Pointer(&f))
}

1、  unsafe.Pointer(&f) 将 float64 类型的参数 f 的地址转换为一个 unsafe.Pointer 类型的指针

2、  *(*uint64)(unsafe.Pointer(&f)) 将 unsafe.Pointer 类型的指针转换为 *uint64 类型的指针,然后再对其进行解引用,从而将 float64 类型的值转换为 uint64 类型的整数

本质上是将unsafe.Pointer作为一种媒介,它可以由任何类型转换得到,也可以将其转换为任意类型

但这里有几点限制

1、  T2 的大小不能超过 T1

2、  T1 和 T2 必须具有相等的内存布局(即相同的字段和对齐方式)

如果满足这些条件,我们可以进行指针类型的转换。

下面这段代码int8和float32是无法转换的

func float32ToInt8(in float32) int8 {
	return *(*int8)(unsafe.Pointer(&in))
}

func int8ToFloat32(in int8) float32 {
	return *(*float32)(unsafe.Pointer(&in))
}

日志输出:

=== RUN   TestT12T2
0
-131072
--- PASS: TestT12T2 (0.00s)
PASS

float32 是一个单精度浮点数,占用四个字节(32 位),遵循 IEEE 754 标准。它的内存布局包含符号位、指数位和尾数位。

int8 是一个有符号的 8 位整数,占用一个字节(8 位),通常以二进制补码的形式表示。

尝试将这两种类型直接强制转换会导致浮点数的部分信息丢失,或者导致整数表示的范围溢出。

将 Pointer 转换为 uintptr(不转换回 Pointer)

将指针转换为 uintptr 会得到被指向值的内存地址,以整数的形式表示。通常情况下,这样的 uintptr 用于打印或记录内存地址。

如果对Go的数组和切片有更深的了解,肯定知道数组底层的内存地址是连续的,有没有测试过呢?举一个例子:

func main() {
	var x int
	size := unsafe.Sizeof(x)
	fmt.Printf("int 占用 %d 个字节\n", size)

	tmpList := []int{1, 2, 3, 4, 5}
	for i := 0i < len(tmpList); i++ {
		fmt.Printf("16进制地址=%p,10进制地址=%d,值=%+v\n", &tmpList[i], uintptr(unsafe.Pointer(&tmpList[i])), tmpList[i])
	}
}

上面这段代码打印的数据如下:

=== RUN   TestSizeof2
int 占用 8 个字节
16进制地址=0xc00001a1e0,10进制地址=824633827808,值=1
16进制地址=0xc00001a1e8,10进制地址=824633827816,值=2
16进制地址=0xc00001a1f0,10进制地址=824633827824,值=3
16进制地址=0xc00001a1f8,10进制地址=824633827832,值=4
16进制地址=0xc00001a200,10进制地址=824633827840,值=5
--- PASS: TestSizeof2 (0.00s)

看出来了吗?uintptr 是10进制的地址,首地址+8代表下一个下标的内存地址,这里留一个思考题,数据的下标为啥是从0开始?能推导数组下标的寻址公式吗?

将 Pointer 转换为 uintptr 并进行算术运算后再转换回 Pointer
Offsetof 获取成员偏移量

如果 p 指向一个已分配的对象,可以通过将其转换为 uintptr,加上偏移量,并将其转换回指针来在对象内进行偏移。

这种模式最常见的用法是访问结构体的字段或数组的元素。举一个例子:

func T() {
	employee := struct {
		Name string
		Age  int
	}{
		Name: "李四",
		Age:  18,
	}

	// 分别打印age和name的偏移量
	fmt.Printf("nameOffset=%v,ageOffset=%v\n"unsafe.Offsetof(employee.Name), unsafe.Offsetof(employee.Age))
	p := unsafe.Pointer(uintptr(unsafe.Pointer(&employee)) + unsafe.Offsetof(employee.Age)) // 转为 uintptr 并且通过算术运算计算 age 的内存地址
	*((*int)(p)) = 300                                                                      // Pointer 转换为 (*int)、取值、重新赋值,此时 employee.Age 值为300

	fmt.Printf("age=%d\n", employee.Age)
}

上段代码打印的值:

=== RUN   TestT
nameOffset=0,ageOffset=16
age=300
--- PASS: TestT (0.00s)
PASS
Sizeof 获取任意类型字节数

操作数组和struct有一些区别,再举一个数组的例子

func array() {
	var (
		tmpList = [6]int{2, 3, 1, 67, 8}
	)
	for i := 0i < len(tmpList); i++ {
		/*
			1、tmpList[i] 转换为Pointer获取基地址,通过基地址+i位置角标对应值的字节数计算下一个元素的地址
			2、做完算术运算后 uintptr 转 Pointer
		*/
		p := unsafe.Pointer(uintptr(unsafe.Pointer(&tmpList[i])) + unsafe.Sizeof(tmpList[i]))
		//pp := unsafe.Add(p, unsafe.Sizeof(tmpList[i])) // 或者用这个姿势也是可以的,更简单
		*(*int)(p) = i // 赋值
	}

	// 打印 tmpList
	for i := 0i < len(tmpList); i++ {
		fmt.Printf("i=%d,v=%d\n", i, tmpList[i])
	}
}

上段代码打印的值:

=== RUN   TestT
i=0,v=2
i=1,v=0
i=2,v=1
i=3,v=2
i=4,v=3
i=5,v=4
--- PASS: TestT (0.00s)
PASS

数组为啥要用Sizeof?这跟数组寻址有关系,我先抛一个公式大家自行研究

a[i]_address=基地址(base_address)+i(数组下标)*字节数(int是8个字节...类推就好了)
在调用 syscall.Syscall 时将指针转换为 uintptr

基本用不着不过多解释

syscall.Syscall(syscall.SYS_PRCTL, PR_GET_KEEPCAPS, 0, 0); e != 0
将 reflect.Value.Pointer 或 reflect.Value.UnsafeAddr 的结果从 uintptr 转换为指针
func TestT(t *testing.T) {
	var (
		n int
	)
	p := unsafe.Pointer(reflect.ValueOf(&n).Pointer())
	*((*int)(p)) = 3
	fmt.Printf("n=%v\n", n)
}

/*	=== RUN   TestT
	n=3
	--- PASS: TestT (0.00s)
	PASS*/

为什么Pointer不返回Pointer而是返回的是 uintptr ?

为了防止调用者在没有导入 unsafe 包并且可能会在不了解风险的情况下,将其转换为任意类型,从而导致不安全的操作。

这种转换过程需要注意下面这点:

当你使用 reflect.Value.Pointer 或 reflect.Value.UnsafeAddr 时,返回的结果是 uintptr。为了安全地处理这个结果,你应该在调用之后立即将其转换为 unsafe.Pointer。这个转换应该在同一个表达式中完成,以避免意外的行为。下面这段代码是官方给的错误例子

// INVALID: uintptr cannot be stored in variable
// before conversion back to Pointer.
u := reflect.ValueOf(new(int)).Pointer()
p := (*int)(unsafe.Pointer(u))
将 reflect.SliceHeader 或 reflect.StringHeader 的 Data 字段与指针进行相互转换

主要是为了实现字符串和byte切片相互零拷贝转换。这个可以不用了解,官方建议在新的代码中使用 unsafe.String or unsafe.StringData、unsafe.Slice or unsafe.SliceData 。reflect.SliceHeader 和 reflect.StringHeader 应该会在后面的发布中标记为废弃。

Go1.20 引入的几个新方法

SliceData(slice []ArbitraryType) *ArbitraryType

返回指向参数切片底层数组的指针

1、  如果 slice 的容量大于 0,SliceData 返回 &slice[:1][0]。

2、  如果 slice 为 nil,SliceData 返回 nil。

3、  否则,SliceData 返回一个非空指针,指向未指定的内存地址1。

func slice2String() {
	var (
		b = []byte{72, 101, 108, 108, 111} // 字符串 "Hello" 对应的 ASCII 码
	)

	ptr := unsafe.SliceData(b)
	fmt.Printf("address=%p,v=%v\n", ptr, *ptr)              // 若 slice cap>0返回第一个元素指针
	fmt.Printf("address=%p\n", &b[0])                       // 打印第一个元素的地址
	fmt.Println(unsafe.String(unsafe.SliceData(b), len(b))) // 转为字符串
}

数据的打印如下:

=== RUN   TestTT
address=0xc000024288,v=72
address=0xc000024288
Hello
--- PASS: TestTT (0.00s)
PASS
String(ptr *byte, len IntegerType) string

String 函数的作用是获取一个字符串,其底层字节从指定的内存地址 ptr 开始,长度为 len。

func bytes2string() {
	var (
		b = []byte{72, 101, 108, 108, 111} // 字符串 "Hello" 对应的 ASCII 码
	)
	fmt.Println(unsafe.String(&b[0], len(b)-1)) 
}

数据的打印如下:

=== RUN   TestTT
Hell
--- PASS: TestTT (0.00s)
PASS
StringData(str string) *byte

StringData 函数返回一个指向字符串 str 底层字节的指针。

func string2byte() {
	fmt.Println(unsafe.StringData("Hello"))
	fmt.Println(unsafe.StringData("Hello"))
	fmt.Println(unsafe.StringData("Hello1"))
	fmt.Println(*unsafe.StringData("Hello")) // 值返回第一个字符的 ASCII 码
}

数据的打印如下:

=== RUN   TestTT
0x1128c69
0x1128c69
0x1128ea1
72
--- PASS: TestTT (0.00s)
PASS

为啥字符串“Hello”打印的的地址是相同的?

在Go中,相同的字符串字面量会被编译器优化为同一个内存地址,这是因为字符串是不可变的,编译器可以安全地假设它们不会被修改,因此可以共享相同的内存空间,虽然变量是不同的,但是都指向同一个字符串字面量是相同的,所以的地址是一样的。

总结

在 Go 语言中,unsafe 包提供了一种与底层系统交互的手段,以及在必要时绕过 Go 语言的类型系统进行一些底层操作。

提供了以下操作

1、  直接操作指针和内存:unsafe 包允许程序员直接操作指针,例如读写内存、修改结构体的未导出成员等

2、  绕过类型系统的限制:Go 语言的指针相比 C 的指针有一些限制,例如不能进行数学运算、不同类型的指针不能相互转换等。

3、  性能优化:在某些场景下,使用 unsafe 包可以提高代码的性能。例如,通过直接操作内存,避免了一些不必要的类型转换和拷贝操作。(大家可以学习下零拷贝技术)

思考题

1、  如何通过上面学的知识获取 slice 长度和容量?

slice 源码位置 runtime/slice.go

通过源码看到 slice 的结构体定义如下:

type slice struct {
    array unsafe.Pointer // 元素指针
    len   int // 长度 
    cap   int // 容量
}

tmpList := make([]int, 1, 2),通过 make 函数 创建一个切片底层初始化方式如下:

func makeslice(et *_type, len, cap int) unsafe.Pointer

我们可以通过 unsafe.Pointer 和 uintptr 获取计算偏移量获取长度和 len,代码如下:

func TestGetSliceLen(t *testing.T) {
	// 首先打印 unsafe.Pointer 占用几个字节
	var (
		tmpp unsafe.Pointer
	)
	fmt.Printf("b=%d\n", unsafe.Sizeof(tmpp))

	s := make([]int, 8, 12)
	plen := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(8))
	fmt.Printf("slice 的长度 len=%v\n", *(*int)(plen))
	pcap := unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + uintptr(16))
	fmt.Printf("slice 的容量 cap=%v\n", *(*int)(pcap))
	fmt.Println("---------下面是通过 len 和 cap 直接获取长度和容量")

	fmt.Printf("通过 len 和 cap 语法糖获取 len=%d,cap=%d\n", len(s), cap(s))
}

日志打印如下:

=== RUN   TestGetSliceLen
b=8
slice 的长度 len=8
slice 的容量 cap=12
---------下面是通过 len 和 cap 直接获取长度和容量
通过 len 和 cap 语法糖获取 len=8,cap=12
--- PASS: TestGetSliceLen (0.00s)
PASS

大家看注释,这里不过多解释

2、  unsafe.Pointer 和任意类型、unsafe.Pointer 和 uintptr 相互转换可以拆开定义多个变量吗?为什么?(TT函数是多变量方案)

func T() {
	employee := Employee{}
	// 和TT函数的区别在下面这几行代码
	p := unsafe.Pointer(uintptr(unsafe.Pointer(&employee)) + unsafe.Offsetof(employee.Age)) // 转为 uintptr 并且通过算术运算计算 age 的内存地址
	*((*int)(p)) = 300                                                                      // Pointer 转换为 (*int)、取值、重新赋值,此时 employee.Age 值为300
	fmt.Printf("age=%d\n", employee.Age)
}

func TT() {
	employee := Employee{}
	// 和T函数的区别在下面这几行代码
	p := unsafe.Pointer(&employee)
	ptr := uintptr(p) + unsafe.Offsetof(employee.Age) // 转为 uintptr 并且通过算术运算计算 Age 的内存地址
	pp := unsafe.Pointer(ptr)                         // 将 Age 内存地址转换为 Pointer
	*((*int)(pp)) = 300                               // Pointer 转换为 (*int)、取值、重新赋值,此时 employee.Age 值为300
	fmt.Printf("age=%d\n", employee.Age)
}

type Employee struct {
	Name string
	Age  int
}

官方回答在转换回指针之前,不能将 uintptr 存储在变量中,主要原因是在进行指针到 uintptr 的转换时,我们无法保证得到的 uintptr 值会与原始指针一一对应,并且 uintptr 类型不会提供任何指针的语义信息,也不会阻止底层对象被垃圾回收。将 uintptr 类型存储在变量中可能导致不可预料的行为,在变量重新被使用时可能造成程序的安全隐患。 顺便附带一张官方文档截图

深入 GO unsafe.Pointer & uintptr

公众号原文链接:mp.weixin.qq.com/s?__biz=Mzk…