使用unsafe库操作结构体的属性偏移和指针转换

本文涉及的产品
服务治理 MSE Sentinel/OpenSergo,Agent数量 不受限
可观测可视化 Grafana 版,10个用户账号 1个月
简介: 【5月更文挑战第19天】Go语言是类型安全的,但通过`unsafe`包,可以进行结构体属性偏移量计算和指针转换。`unsafe.Offsetof`获取结构体字段的偏移量,`unsafe.Pointer`允许不同类型指针间的转换。然而,这可能导致类型安全屏障被绕过,若使用不当,会引发安全问题或panic。应谨慎使用`unsafe`,因为它不遵循GO 1兼容性准则。

1 标准库中结构体属性偏移量和指针转换

我们知道go是类型安全的语言(大部分)。

所谓类型安全是一块内存数据一旦被特定类型解释,该内存数据与该类型变量建立关联,一定是这样吗?

logo_undefined9.png

在c语言这样的非类型安全语言中,可以声明为 int 类型,使用时通过c代码的内存地址的置换,解释为字符串类型。

类型安全是建立在编译器的静态检查以及 运行时runtime, 利用类型信息进行的运行时检查的。
在语法层面,为了实现常规类型安全,Go语言做了如下限制:

1,不支持隐式类型转换,所有类型转换必须显式执行,如下错误把int 给 uint64 赋值

        var i int = 17
        var j uint64 = i 

只有底层类型 underlying type 相同的两个类型的指针之间才能进行类型转换。

如下 错误 int类型不能转换为 uint64,而Newint底层类型为int,因此可以与int相互转换。

失败转换:

        var i int = 11
        var p *uint64 = (*uint64)(&i) 

成功转换:

        type Newint int
        var p *Newint = (*Newint)(&i)  

2 不支持指针运算

如下错误,*int类型和int类型无法相加,不能跨越数组元素的边界

        var a [100]int
        var p *int = &a[0]
        *(p+1) = 10 

Go的 类型安全 , 兼顾了性能和实现,并且与操作系统,以及C代码如何交互操作。

示例的变量i,一旦被解释为int类型 a := 0x12345678, 它就与某个内存(起始地址为 &i, 长度为int类型的大小) 建立了关联。

那么这块 &i 就不能再与其他类型 如 uint64 变量建立关联。

2 标准库包的偏移量和指针转换

  • Offsetof

Offsetof 用于获取结构体中某字段的地址偏移量(相对于结构体变量的地址)。
仅用于求结构体某字段的偏移量。

返回x所代表的字段在结构中的偏移量。必须是structValue.field的形式。
换句话说,它返回的是结构的开始和字段的开始之间的字节数。

如果参数x的类型不是可变大小的,Offsetof的返回值是一个Go常数。
不具有可变大小。
(关于可变大小类型的定义,请参见[Sizeof]的描述)。

偏移量

        t.Log(unsafe.Offsetof(f.a))  // 起始偏移量 0
        t.Log(unsafe.Offsetof(f.b))  //
        t.Log(unsafe.Offsetof(f.c))
        t.Log(unsafe.Offsetof(f.d))   
        t.Log(unsafe.Offsetof(f.e))   // 空结构体偏移量
        t.Log(unsafe.Offsetof(f.ff))  // 空结构体偏移量为0,与上一个相同
        t.Log(unsafe.Offsetof(f.fbs)) // 切片字段结构体偏移量 24
        t.Log(unsafe.Offsetof(f.fb))  // 字符字段结构体偏移量 24
  • Pointer

unsafe.Pointer 代表一个指向任意类型的指针。
可用于其他类型的指针,而其他类型的指针则不可用。 有四个特殊的操作

  • A 任何类型的指针值都可以被转换为一个Pointer。

     目标类型结果是相同的。
    
  • B 一个Pointer可以被转换为任何类型的指针值。

    需要注意选择对转换为的目标指针类型,否则可能在运行时才发现报错。 go vet 辅助。

  • C 一个uintptr可以被转换为一个Pointer。

          var i uintptr = 0x80010203
          p := unsafe.Pointer(i)
    
    • D 一个Pointer可以被转换为一个uintptr。

          p := unsafe.Pointer(&a)
          var i = uintptr(p)
      

    例子A:

     func TestPointer(t *testing.T) {
         var (
             a   int     = 5
             b   float64 = 5.89
             arr [10]string
             f   FuncFoo
         )
    
         defPointer := (unsafe.Pointer)(nil)
         p1 := (unsafe.Pointer)(&a)
         if reflect.TypeOf(p1) != reflect.TypeOf(defPointer) {
             ErrorHandler(fmt.Sprintf("Pointer: %v not type %v", reflect.TypeOf(p1), reflect.TypeOf(defPointer)), t)
         }
    
         p2 := (unsafe.Pointer)(&b)
         if reflect.TypeOf(p2) != reflect.TypeOf(defPointer) {
             ErrorHandler(fmt.Sprintf("Pointer: %v not type %v", reflect.TypeOf(p2), reflect.TypeOf(defPointer)), t)
         }
    
         p3 := (unsafe.Pointer)(&arr)
         if reflect.TypeOf(p3) != reflect.TypeOf(defPointer) {
             ErrorHandler(fmt.Sprintf("Pointer: %v not type %v", reflect.TypeOf(p3), reflect.TypeOf(defPointer)), t)
    
         }
    
         p4 := (unsafe.Pointer)(&f)
         if reflect.TypeOf(p4) != reflect.TypeOf(defPointer) {
             ErrorHandler(fmt.Sprintf("Pointer: %v not type %v", reflect.TypeOf(p4), reflect.TypeOf(defPointer)), t)
    
         }
     }
    

    因此,Pointer允许一个程序绕过类型系统,并读写任意的内存。使用它时应该非常小心。
    如上它可以把任意类型的指针,转换为 Pointer

    也可以把Pointer 转换为任意类型的指针,需要注意找到类型:

    Pointer 转换为任意类型的指针

         var pa = (*int)(p2)
         t.Logf("Pointer -> *int:%#v \n", pa)
    
         var paf = (*float64)(p2)
         t.Logf("Pointer -> *float64:%#v \n", paf)
    

    设置可以把实际上是int的 指向一个 自定义结构体,编译器不会报错,但是运行将报错

         var pas = (*FuncFoo)(p2)
         t.Logf("Pointer -> *FuncFoo:%#v \n", pas)
    

    如果转换错误,编译器不会发现,只有在执行运行的时候将报错:

          unexpected fault address 0x20202020
         fatal error: fault
         [signal 0xc0000005 code=0x0 addr=0x20202020 pc=0x37a102]
    
         goroutine 8 [running]:
         runtime.throw({0x41777d?, 0x0?})
             ....
    

    以下涉及指针的模式是有效的。

    不使用这些模式的代码今天可能是无效的,或在将来变得无效。

    即使是下面这些有效的模式,也有一些重要的注意事项。

    运行 "go vet "可以帮助找到不符合这些模式的Pointer的使用。 但 "go vet "的变量遮蔽并不能保证代码是有效的。

      go vet -c 1 -v -x .\main_test.go
    

3 将一个 T1转换为 T2的指针。

只要T2不比T1大,并且二者共享一个等效的这种转换允许将一种类型的数据重新解释为另一种类型的数据。

一个例子是实现 math.Float64bits。

应用实例,类型转换 float64 到 uint64

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

        var vp uint32 = 0x12345678

输出 0x12345678

        fmt.Printf("vp:0x%x\n", vp)  

利用unsafe Pointer性质,任意类型转换为Pointer

        p := (unsafe.Pointer)(&vp)  

利用unsafe.Pointer 性质2, Pointer转换为任意类型

        bp := (*[4]byte)(p)          

        bp[0] = 0x23
        bp[1] = 0x45
        bp[2] = 0x67
        bp[3] = 0x8a
        fmt.Printf("vp:0x%x\n", vp) // 0x12345678

运行输出:

            vp:0x12345678

            vp:0x8a674523

这里原本被解释为 uint32类型的一段内存,起始地址为 &vp,长度4字节,通过unsafe.Pointer 被重新解释为 [4]byte 并且通过变量 b *[4]byte 类型 可以对该内存进行修改。

4 将一个Pointer 转换为一个uintptr(但不返回到指针)。

将一个Pointer转换为一个uintptr会产生所指向的值的内存地址。
指向的内存地址,作为一个整数。这样一个 uintptr 的通常用途是打印它。

将一个uintptr转换回Pointer在一般情况下是无效的。

uintptr是一个整数,而不是一个引用。

将一个 Pointer 转换为一个 uintptr 会创建一个整数值。

没有指针的语义。即使一个 uintptr 持有某个对象的地址。

垃圾收集器将不会更新该uintptr的值如果该对象移动,uintptr也不会使该对象不被回收。

剩下的模式列举了唯一有效的转换方式,从 uintptr 到 Pointer。

5 将一个指针转换为一个uintptr,再转换回来,并进行算术运算。

如果p指向一个被分配的对象,它可以通过转换为uintptr,加上一个偏移量,再转换回Pointer,在该对象中前进。

通过转换为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[0]) ) + i*unsafe.Sizeof(x[0]))

以这种方式从指针中添加和减去偏移量都是有效的。

使用&^对指针进行取整也是有效的,通常是为了对齐。

在所有情况下,结果必须继续指向原来分配的对象。

与C语言不同的是,将一个指针推进到其原始分配的终点之后是无效的。它服从原始分配。

非法操作:端点在分配空间之外。

         var s thing
         end = unsafe.Pointer(uintptr(unsafe.Pointer(&s)) + unsafe.Sizeof(s))

非法操作: 端点在分配的空间之外。

         b := make([]byte, n)
         end = unsafe.Pointer(uintptr(unsafe.Pointer(&b[0]) ) + uintptr(n))

注意,这两种转换必须出现在同一个表达式中,以计算它们之间的算术。

非法操作: 在转换回指针之前,uintptr不能被存储在变量中。

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

注意,这个指针必须指向一个已分配的对象,所以它不可能是nil。

非法操作: 转换为nil的指针

         u := unsafe.Pointer(nil)
         p := unsafe.Pointer(uintptr(u) + offset)

6 调用syscall.Syscall,将一个Pointer转换为一个uintptr。

在软件包syscall中的Syscall函数直接将它们的uintptr参数传递给操作系统。

直接传递给操作系统,操作系统可能会根据调用的细节,将一些参数重新解释为指针。

也就是说,系统调用的实现是隐含地将某些参数从uintptr转回指针。

如果一个指针参数必须被转换为uintptr才能作为参数使用。

这种转换必须出现在调用表达式本身。

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

编译器会处理在调用一个在SYS_READ中实现的函数的参数列表中转换为uintptr的Pointer。

对一个用汇编实现的函数的调用,安排被引用的对象,如果有的话,被分配的对象将被保留,并且在调用完成之前不被移动。

即使仅从类型上看,该对象在调用过程中不再需要。

为了使编译器能够识别这种模式。 转换必须出现在参数列表中。

非法操作: uintptr不能被存储在变量中。

在系统调用时,在隐式转换回指针之前。

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

7 值指针和地址的转换 reflect.Value.Pointer或reflect.Value.UnsafeAddr 。

从 uintptr 到 Pointer。

封装 reflect 的名为 Pointer 和 UnsafeAddr 的 Value 方法返回 uintptr 类型。

而不是 unsafe.Pointer,以防止调用者在没有导入 "unsafe.Pointer "的情况下,将结果改为任意的类型。

以防止调用者在没有导入 "unsafe "的情况下将结果改为任意类型。

然而,这意味着结果是脆弱的,必须在调用后立即转换为Pointer。

在同一个表达式中。

         p := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer() )

和上面的情况一样,在转换之前存储结果是无效的。

非法操作: 在转换回Pointer之前,uintptr不能被存储在变量中。

         u := reflect.ValueOf(new(int)).Pointer()
         p := (*int)(unsafe.Pointer(u))

8 数据域转换为指针或从指针转换到数据

     reflect.SliceHeader 或 reflect.StringHeader 

与前面的情况一样,反射数据结构SliceHeader和StringHeader。

将字段 Data 声明为 uintptr,以防止调用者在没有导入的情况下将结果改变为一个任意的类型,而不先导入 "unsafe"。

SliceHeader和StringHeader只有在解释实际切片或字符串的内容时才有效。

         var s string
         hdr := (*reflect.StringHeader)(unsafe.Pointer(&s)) // case 1
         hdr.Data = uintptr(unsafe.Pointer(p))              // case 6 (this case)
         hdr.Len = n

在这种用法中,hdr.Data实际上是指字符串头中的底层指针的另一种方式。

字符串头中的指针,而不是一个 uintptr 变量本身。

一般来说,reflect.SliceHeader和reflect.StringHeader应该被用作仅作为reflect.SliceHeader和reflect.StringHeader使用,

指向实际的切片或字符串,而不是作为普通的结构。程序不应该声明或分配这些结构类型的变量。

  • 非法操作:
    一个直接声明的头不会把数据作为一个引用。

           var hdr reflect.StringHeader
           hdr.Data = uintptr(unsafe.Pointer(p))
           hdr.Len = n
    

    p可能已经丢失了

           s := *(*string)(unsafe.Pointer(&hdr))  
    

9 小结

通过unsafe包,我们可以实现性能更高,与底层系统交互更容易的低级代码。

它的存在也让我们有了绕过Go类型安全屏障的路。

但是一旦使用不当,可能导致安全漏洞,容易引发程序panic错误。

正如官方介绍所讲,导入unsafe包可能是不被提倡的,并且不符合 GO 1 兼容性准则的。

目录
相关文章
|
1月前
|
算法 Linux C语言
【Linux系统编程】深入理解Linux目录操作:文件夹位置指针操作函数(telldir,seekdir,rewinddir)
【Linux系统编程】深入理解Linux目录操作:文件夹位置指针操作函数(telldir,seekdir,rewinddir)
34 0
|
6天前
|
算法 Java 程序员
面向对象编程(OOP)通过对象组合构建软件,C语言虽是过程式语言,但可通过结构体、函数指针模拟OOP特性
【6月更文挑战第15天】面向对象编程(OOP)通过对象组合构建软件,C语言虽是过程式语言,但可通过结构体、函数指针模拟OOP特性。封装可使用结构体封装数据和方法,如模拟矩形对象。继承则通过结构体嵌套实现静态继承。多态可通过函数指针模拟,但C不支持虚函数表,实现复杂。C语言能体现OOP思想,但不如C++、Java等语言原生支持。
24 7
|
21天前
|
C++
在C和C++中,指针的算术操作
在C和C++中,指针的算术操作
|
28天前
|
存储 C语言 C++
结构体指针
结构体指针
24 1
|
29天前
|
存储
结构体和结构体指针的区别
结构体和结构体指针的区别
|
21天前
|
存储 C语言 索引
指向结构体数组的指针
指向结构体数组的指针
18 0
|
21天前
|
存储 C++
指向结构体变量的指针
指向结构体变量的指针
15 0
|
21天前
|
C++
结构体变量与结构体变量指针作为函数参数
结构体变量与结构体变量指针作为函数参数
11 0
|
1月前
|
C语言 Perl
指针与结构体
指针与结构体
|
20天前
|
C语言
C语言指针带代码
C语言指针带代码
23 3