本文是eBPF与Go系列的第一篇,主要介绍了Go语言的一些基础数据类型和特性,其中特别分析和总结Go语言中接口的原理和实现。
0.问题前言
去年,我使用eBPF技术对HTTPS网络流量进行明文数据采集,先后对OpenSSL、GnuTLS和LibreSSL等C/C++库进行了分析和适配。虽然说这已经能覆盖当前网络环境的大多数HTTPS场景了,但网络技术日新月异,现在服务端的网络环境远比我们已知的要复杂,尤其是近年来云原生概念的突起,一批批应用纷纷上云。在谈及云原生时,不可避免的的话题是有关Go、Docker和k8s等容器相关的技术。因而,当我想要更全面地采集网络中的HTTPS明文数据时,就需要针对Go语言编程环境下的SSL/TLS (即Go语言中的GoTLS模块)应用进行分析研究。
所以,eBPF与Go这个系列的文章其实最终是为使用eBPF采集Go程序的HTTPS明文数据目标服务的。在此之前,我在这方面的背景大约如下:
-
对eBPF技术已有足够的了解和应用,已有libbpf应用于OpenSSL、GnuTLS和LibreSSL的经验;
-
零散地翻阅过Go语言的相关书籍,但对其实语言底层和特性并不熟悉(因为没有真正写过Go);
-
对eBPF在Go程序的应用不了解,这方面和C/C++必然存在差异,应该需要特别地研究Go ABI;
-
已有众多有关eBPF在Go的开源项目(我学习研究过的cilium、eCapture、deepflow、pixie等),但有些坑是不得不亲自踩的;
-
众多博客都表示:对Go程序使用uretprobe时会引起的程序崩溃,需要另辟蹊径。
因此,eBPF与Go系列将分为以下3部分进行叙述:
-
即本文,将介绍Go语言的基础类型及其内存布局,尤其挑选其中接口的实现进行叙述;
-
进一步的,针对Go ABI 进行叙述;
-
最终介绍,如何使用eBPF技术捕获GoTLS的明文数据,并不引起崩溃的方法。
1.数据类型
在编程语言中,数据类型可谓是基础中的基础。对于Go语言,其基础数据类型及对应的内存大小和对齐如下:
Type | 64-bit | 32-bit | ||
---|---|---|---|---|
Size | Align | Size | Align | |
bool, uint8, int8 | 1 | 1 | 1 | 1 |
uint16, int16 | 2 | 2 | 2 | 2 |
uint32, int32 | 4 | 4 | 4 | 4 |
uint64, int64 | 8 | 8 | 8 | 4 |
int, uint | 8 | 8 | 4 | 4 |
float32 | 4 | 4 | 4 | 4 |
float64 | 8 | 8 | 8 | 4 |
complex64 | 8 | 4 | 8 | 4 |
complex128 | 16 | 8 | 16 | 4 |
uintptr, *T, unsafe.Pointer | 8 | 8 | 4 | 4 |
表格来源: https://github.com/golang/go/tree/master/src/cmd/compile/abi-internal.md
另外byte
和rune
类型分别是uint8
和int32
的别名,而map
、chan
和function
类型的布局相当于表中的*T
。当然,未来为了描述Go中存在的其他复合类型,我们定义一个序列S
的N个字段的布局,这些字段的类型为t1, t2,…我们定义每个字段相对于基数0开始的字节偏移量,以及序列的大小和对齐方式,如下所示:
|
|
-
对于数组类型
[N]T
,即由N个类型为T的字段组成的序列; -
字符串类型是一个
*[len]
字节指针的序列; -
结构体类型
struct {f1 t1;…;fM tM}
表示为序列t1,…, tM, tP,其中tP为:-
如果sizeof(tM) = 0且sizeof(ti)中的任何一个≠0,则类型为byte;
-
否则为空(大小为0,对齐为1).
-
1.0数组
数组(array) 是一个由固定长度的特定类型元素组成的序列,一个数组可以由零个或多个元素组成。数组的长度也是数组类型的组成部分,数组在初始化之后大小就无法改变,因而存储元素类型相同、但是大小不同的数组类型在 Go 语言看来也是完全不同的。数组的内存布局如下:
|
|
1.1字符串
字符串是由字符组成的数组,C 语言中的字符串使用字符数组 char[]
表示。数组会占用一片连续的内存空间,而内存空间存储的字节共同组成了字符串,Go 语言中的字符串只是一个只读的字节数组。
|
|
"hello"
字符串在内存中的存储方式:
1.2切片
切片(slice) 比其数组在Go中通常更常用一些,切片可以理解为动态数组。切片的实现如下:
-
Data
是指向数组的指针; -
Len
是当前切片的长度; -
Cap
是当前切片的容量,即Data
数组的大小。
|
|
切片的内存布局如下:
2.特别: 接口实现原理
2.0接口
接口是一种抽象类型,没有暴露所包含的数据的布局和内部细节,它只对外提供了一组方法的签名。作为使用者,你只需要知道它能做什么即可,而无需关心它里面有什么。
接口定义
Go语言提供了interface
关键字来定义相应的接口,在接口中我们只能定义方法签名,不能包含成员变量。
|
|
接口实现
Duck Typing的名称来自于“走路像鸭子,叫声像鸭子,那么就是鸭子”的俗语。在Duck Typing中,不需要显式地指定对象的类型,只要对象的行为与指定的类型相符合,那么它就可以被视作该类型的对象。
Go接口的实现方式其实就是Duck Typing,也可以说是Go的接口是隐式实现的。比如一个类型需要实现上述定义的error
接口,那么它只需要实现Error() string
方法即可:
|
|
在使用上述 RPCError
结构体时并不关心它实现了哪些接口,Go 语言只会在传递参数、返回参数以及变量赋值时才会对某个类型是否实现接口进行检查。
实现原理
接口也是Go语言中的一种类型,不过在Go语言中共有两种不同类型的接口runtime.iface
和runtime.eface
,其中前者是带有方法的接口,后者是不带任何方法的接口interface{}
。
runtime.eface的结构体定义如下:
|
|
由于interface{}
即runtime.eface
不包含任何方法,所以其结构相对简单,仅包含指向底层数据和类型类型的两个指针。runtime._type
的结构体定义如下:
|
|
可以看到runtime._type
包含了诸如类型大小、哈希、对齐、以及种类等信息。_
而runtime.iface的结构体定义如下:
|
|
其中包含了一个指向数据的指针和runtime.itab
的tab字段,runtime.itab
的结构定义如下:
|
|
runtime.itab
结构体是接口类型的核心组成部分,每一个runtime.itab
都占 32 字节,我们可以将其看成接口类型和具体类型的组合,它们分别用 inter
和 _type
两个字段表示。
hash
是对_type.hash
的拷贝,当我们想将interface
类型转换成具体类型时,可以使用该字段快速判断目标类型和具体类型runtime._type
是否一致;fun
是一个动态大小的数组,它是一个用于动态派发的虚函数表,存储了一组函数指针。虽然该变量被声明成大小固定的数组,但是在使用时会通过原始指针获取其中的数据,所以fun
数组中保存的元素数量是不确定的。
2.1反射
reflect
实现了运行时的反射能力,能够让程序操作不同类型的对象。反射包中有两对非常重要的函数和类型,两个函数分别是:
-
reflect.Typeof
能获取类型信息; -
reflect.ValueOf
能获取数据的运行时表示;
运行时反射是程序在运行期间检查其自身结构的一种方式。Go 语言反射的三大法则如下:
- 从
interface{}
变量可以反射出反射对象; - 从反射对象可以获取
interface{}
变量; - 要修改反射对象,其值必须可设置;
3.总结
本文主要总结了Go语言的基础类型的内存布局和底层数据结构实现,并特别地介绍了接口的实现细节,以及稍微提及了反射特性。当然,本文并没有足够深入剖析,不过上述提及的知识用于eBPF挂钩Go程序是足够的了。此外,如果想进一步深入学习Go的话,以下这些书籍或许可以成为参考:
4.链接参考
Golang bcc/BPF Function Tracing
Go语言高级编程 - Go语言高级编程 (chai2010.cn)