深入 GO unsafe.Pointer & uintptr

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、 不能直接对指针进行递增或递减操作。

ini 复制代码
func T() {
	var (
		x = 5
		y = &x
	)

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

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

go 复制代码
./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、 不同类型的指针之间不能直接相互转换。

csharp 复制代码
func TConvert() {
	var (
		n = int(100)
		u *uint
	)
	u = &n
	fmt.Println(u)
}

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

csharp 复制代码
cannot use &n (value of type *int) as *uint value in assignment

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

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

ini 复制代码
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) // 不同类型不能判断指针变量是否相等
}

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

go 复制代码
./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 包,定义如下:

go 复制代码
// 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进制地址,举一栗子:

go 复制代码
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))
}

日志输出:

diff 复制代码
=== RUN   TestOut
v=0xc00010e190,pointerAddress=0xc00010e190,address=824634827152
--- PASS: TestOut (0.00s)
PASS

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

特别注意

css 复制代码
// 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 的转换:

go 复制代码
// 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 简单好理解,获取任何类型大小返回的是字节

csharp 复制代码
func sizeof() {
	var v int
	fmt.Println(unsafe.Sizeof(v))
}

日志输出:

diff 复制代码
=== RUN   TestSizeOf
8
--- PASS: TestSizeOf (0.00s)
PASS

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

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

csharp 复制代码
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)))
}

日志输出:

ini 复制代码
=== RUN   TestOffsetOf
offsetName=0,offsetAge=16,offsetAddress=24,offsetPhone=40
--- PASS: TestOffsetOf (0.00s)
PASS

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

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

csharp 复制代码
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的内存对其要求
}

日志输出:

diff 复制代码
=== RUN   TestAlignOf
8
8
1
8
--- PASS: TestAlignOf (0.00s)
PASS

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

unsafe.Pointer

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

go 复制代码
// 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案例

go 复制代码
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和int32是无法转换的

go 复制代码
func float32ToInt8(in float32) int8 {
	return *(*int8)(unsafe.Pointer(&in))
}

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

日志输出:

diff 复制代码
=== RUN   TestT12T2
0
-131072
--- PASS: TestT12T2 (0.00s)
PASS

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

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

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

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

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

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

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

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

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

diff 复制代码
=== 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,加上偏移量,并将其转换回指针来在对象内进行偏移。

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

csharp 复制代码
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)
}

上段代码打印的值:

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

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

css 复制代码
func array() {
	var (
		tmpList = [6]int{2, 3, 1, 67, 8}
	)
	for i := 0; i < 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 := 0; i < len(tmpList); i++ {
		fmt.Printf("i=%d,v=%d\n", i, tmpList[i])
	}
}

上段代码打印的值:

ini 复制代码
=== 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?这跟数组寻址有关系,我先抛一个公式大家自行研究

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

基本用不着不过多解释

ini 复制代码
syscall.Syscall(syscall.SYS_PRCTL, PR_GET_KEEPCAPS, 0, 0); e != 0
将 reflect.Value.Pointer 或 reflect.Value.UnsafeAddr 的结果从 uintptr 转换为指针
css 复制代码
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。这个转换应该在同一个表达式中完成,以避免意外的行为。下面这段代码是官方给的错误例子

go 复制代码
// 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。

csharp 复制代码
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))) // 转为字符串
}

数据的打印如下:

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

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

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

数据的打印如下:

diff 复制代码
=== RUN   TestTT
Hell
--- PASS: TestTT (0.00s)
PASS
StringData(str string) *byte

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

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

数据的打印如下:

diff 复制代码
=== 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 的结构体定义如下:

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

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

go 复制代码
func makeslice(et *_type, len, cap int) unsafe.Pointer

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

scss 复制代码
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))
}

日志打印如下:

go 复制代码
=== 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函数是多变量方案)

go 复制代码
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 类型存储在变量中可能导致不可预料的行为,在变量重新被使用时可能造成程序的安全隐患。 顺便附带一张官方文档截图

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

相关推荐
蒙娜丽宁2 天前
Go语言错误处理详解
ios·golang·go·xcode·go1.19
qq_172805593 天前
GO Govaluate
开发语言·后端·golang·go
littleschemer3 天前
Go缓存系统
缓存·go·cache·bigcache
程序者王大川4 天前
【GO开发】MacOS上搭建GO的基础环境-Hello World
开发语言·后端·macos·golang·go
Grassto4 天前
Gitlab 中几种不同的认证机制(Access Tokens,SSH Keys,Deploy Tokens,Deploy Keys)
go·ssh·gitlab·ci
高兴的才哥5 天前
kubevpn 教程
kubernetes·go·开发工具·telepresence·bridge to k8s
少林码僧5 天前
sqlx1.3.4版本的问题
go
蒙娜丽宁6 天前
Go语言结构体和元组全面解析
开发语言·后端·golang·go
蒙娜丽宁6 天前
深入解析Go语言的类型方法、接口与反射
java·开发语言·golang·go
三里清风_6 天前
Docker概述
运维·docker·容器·go