BTF 数据格式初探
Contents
TL;DR:本文会用相对比较啰嗦的篇幅来讲述完整的 BTF 数据格式,如果只是想浮光掠影地看一看 BTF 是什么或有什么用,只需要看前面几节内容即可。如果是想自己写写 BTF Parser,那么后面几节内容或许会有些帮助。本文重点参考了内核文档和 cilium/ebpf/internal/btf
的实现,具体探索了一下 .BTF
数据格式(.BTF.ext
与 .BTF
数据编码非常类似,后续有时间再聊聊)。
什么是 BTF
BTF(BPF Type Format)原本是用来描述 BPF prog 和 map 相关调试信息的元数据格式,后面 BTF 又进一步拓展成可描述 function info 和 line info,大大增强了 BTF 的能力。
那么 BTF 到底有什么用呢 ?举一个简单的例子。如果我们有这样一个结构体变量:
|
|
当我们在代码中使用这个结构体并进行编译后,foo
最终呈现的其实只是一段非常原始的字节流,而使用 foo
内部成员的地方也相应地转换为对字节流 offset 的引用。换句话来说,Foo
结构体最开始用以描述其每个字段的类型、字段命名、类型名等信息在编译的过程中都被优化掉了,因为机器并不需要这部分信息也能很好的工作。但是,这部分信息对于人类的日常调试中则非常有用,而 BTF 正是用以描述这部分元数据的编码格式。通过 BTF,我们可以还原出原始的数据类型定义,比如我们可以还原出内核头文件(vmlinux.h
),从而可以辅助我们更好地编译和使用依赖于内核头文件的代码。我们在调试程序的时候,调试器可利用 BTF 为我们提供更加丰富的调试信息(比如函数定义与具体的源代码行)。
BTF 为 Struct 和 Union 类型提供了对应成员的 offset 信息,并结合 Clang 的扩展(主要是 __builtin_preserve_access_index
)和 BPF 加载器(比如 libbpf),BPF Prog 就可以准确访问某个 Struct 或者 Union 类型的成员,而不用担心重定位问题(即 CO-RE 特性)。这背后的逻辑其实就是:
-
Clang 会根据扩展函数将对应结构体访问转化成可重定位信息(这部分 LLVM 逻辑可以参考:BPFAbstractMemberAccess.cpp#L761);
-
Libbpf 在加载对应的 BPF Prog 时,会根据实际 Host 上内核 BTF 信息来修正 1 中的可重定位信息,从而在加载时期来完成重定位;
BTF 对标的是 DWARF。这是 ELF 文件中调试信息元数据格式的事实标准,同样也是一门相对比较古老的数据格式。较之 DWARF,BTF 采用了更加轻量和紧凑的编码方式,从而可得到比 DWARF 小非常多的元数据。由于 BTF 生成的元数据非常小,因此可随目标文件一起分发和加载,比如我们开启 CONFIG_DEBUG_INFO_BTF=y
之后 Linux 内核(>= 5.2)就可以携带 BTF 数据,从而我们就无需再额外下载内核头文件。
BTF 标准包含两个方面:
-
BTF 内核 API
Linux 内核为 BTF 提供一组基于
bpf(2)
系统调用的 API,从而可以让用户将 BTF 加载入内核。这些 API 是用户空间与内核空间的约定。 -
BTF ELF 文件格式
BTF 通常将作为 ELF 文件中的一个 Section,此时我们需要约定好 ELF 文件格式与 BPF 加载器(比如 libbpf)之间的数据格式。
BTF 整体的编码格式
完整的 BTF 数据可分为 BTF 部分和 BTF 扩展部分两部分数据。前者将会放在 ELF 文件的 .BTF
Section 中,后者则会放在 BTF.ext
Section 中(之后都将简称为 .BTF
数据和 .BTF.ext
数据)。
这两部分数据的格式基本上大同小异,其中:
.BTF
数据:存放 BTF 类型和字符串数据;.BTF.ext
数据: 存放func_info
和line_info
数据,这部分数据需在加载到内核之前由 BPF 加载器处理;
.BTF
数据整体的编码格式可如下图所示:
.BTF
数据的开头是一个 24 bytes 大小的固定头部,头部之后则分别是类型编码(即上图的 type_data
)和字符串编码(string_data
)。
BTF Header 如果用 Go 语言描述的话,可为:
|
|
Magic
:总是0xeb9f
,且不同的大小端机器上会有不同的编码格式,这可以测试 BTF 所在系统是否为大小端系统;Version
:目前总是 1;Flags
:目前总是 0;HdrLen
:BTF Header 的长度,可认为HdrLen
与btfHeader
大小一致,即总是为 24;TypeOff
:BTF 类型编码部分相对于 BTF Header 之后的偏移量。如果TypeOff
为 0,而HdrLen
为 24,则 BTF 类型编码相对于文件开始的偏移量为0+24
,即为 24;TypeLen
:BTF 类型编码部分的数据长度;StringOff
:BTF 字符串编码部分相对于 BTF Header 之后的偏移量,与TypeOff
类似;StringLen
:BTF 字符串编码部分的数据长度;
由上可见,BTF Header 是一个非常容易理解的格式,我们重点需要理解什么是类型数据(type_data
)和字符串数据(string_data
)。
所谓类型数据,就是将我们常见的数据类型,比如 整型、指针、数组、结构体、函数等的定义转换成 BTF 二进制格式,比如目前 BTF 支持以下这几种数据类型:
|
|
按照内核 C 语言的枚举可以定义为:
|
|
BTF 字符串数据则是一组以 \x00
开头和 \x00
结尾的字符串数组(每个字符串均以 \x00
结尾)。这部分数据收集了我们代码中类型使用到的字符串(可以理解是一个 string table),比如结构体字段名、变量名等。BTF 类型数据将以偏移量的形式引用这部分数据。
我们可以用如下代码来实现对 BTF 数据的解析:
|
|
BTF 数据的生成和测试
我们使用 LLVM(>=8.0)工具可生成 BTF 数据格式。
比如我们有如下测试代码(结构体变量必须定义出变量,否则将不会触发生成相应的 BTF 数据):
|
|
我们可以直接用 clang 进行编译(必须指定为 BPF target):
|
|
我们可以用 readelf 工具来查看 foo.o
的 ELF Section 信息:
|
|
可以发现编译结果生成了 .BTF
和 .BTF.ext
数据(ELF Section 的 PROGBITS
类型表示该 Section 包含的是代码、数据或者调试信息,我们暂时先忽略 REL
Section)。
我们可以用 llvm-objdump
来 dump 具体的二进制数据:
|
|
我们可以用 bpftool 来查看 BTF 数据:
|
|
或者用 clang 的 -S
选项生成反汇编代码:
|
|
我们可从汇编代码观察到对应 BTF 数据的反汇编代码。
BTF 字符串数据
由于 BTF 字符串数据格式最为简单,我们先来介绍这部分数据格式。BTF 字符串数据可理解为以下数据:
|
|
这部分数据必须要以 \x00
开头和结尾,而这中间则是字符串(包括字符串结尾的 \x00
)数组。
我们可以观察 readStringTable()
的实现:
|
|
BTF 类型数据将以 offset 的形式引用 stringTable
,比如我们有下数据:
|
|
则几个典型的 offset 为:
- offset = 1:得到
int
; - offset = 5:得到
foo
; - offset = 9:得到
hello
;
如下 Lookup()
函数,即输入一个 offset,将返回对应的字符串:
|
|
BTF 类型数据
BTF 类型数据头部
类比于 BTF 字符串数据是一组字符串数组,而 BTF 类型数据则是一组 BTF 类型的数组。每一个 BTF 类型数据我们可以理解为如下格式:
如果用 Go 来表达可为:
|
|
如上图所示:
-
name_off
:执行 BTF 字符串数据的 offset,如上文所说,BTF 字符串数据以索引的形式被 BTF 类型数据引用; -
info
:描述当前类型的类型元数据,以 bits 模式编码,如下所示:如上所述,
info
其实包含了vlen
/kind
/kind_flag
3 个可用字段,其中kind
表示的是当前数据类型; -
size_type
:在 C 语言中,这个字段其实是一个枚举类型,根据不同的数据类型来决定表示的是size
还是type
,比如:- 如果数据类型是 INT / ENUM / STRUCT / UNION,则该字段是
size
,用以表达类型的长度; - 如果数据类型是 PTR / TYPEDEF / VOLATILE / CONST / RESTRICT / FUNC / FUNC_PROTO,则该字段是
type
,即下文中将提及的type_id
- 如果数据类型是 INT / ENUM / STRUCT / UNION,则该字段是
按照 BTF 类型的数组顺序,从 1 开始,依次为每个 BTF 类型数据赋予一个 type_id
,用以标识当前段的 BTF 类型。
综上,我们可以用如下逻辑来完成对 BTF 类型数据的解析:
|
|
BTF_KIND_INT
类型
此时 BTF 类型头部字段的取值为:
name_off
:任何有效的字符串数据索引;info.kind_flag
:此时为 0;info.kind
:此时为BTF_KIND_INT
;info.vlen
:此时为 0;size
:具体整型数据大小(bytes),比如int
类型为 4,short
类型为 2,char
类型为 1,等等;
BTF_KIND_INT
类型之后的 data 是一个 4 个 bytes 的数据(u32
),其中的编码格式如下所示:
bits
:表示该整型所对应的准确的二进制位数。btf_type.size * 8
必须大于或等于bits
(一般是等于bits
);offset
:一般总是为 0;encoding
:该字段提供了如下的额外信息:- 1(即
1 << 0
):有符号整型; - 2(即
1 << 1
):char 整型; - 4(即
1 << 2
):bool 整型;
- 1(即
BTF_KIND_PTR
类型
此时 BTF 类型头部字段的取值为:
name_off
:此时为 0;info.kind_flag
:此时为 0;info.kind
:此时为BTF_KIND_PTR
;info.vlen
:此时为 0;type
:指针类型的type_id
。指针的类型也会被当成一个 BTF 类型,所以也具有一个type_id
;
该类型之后没有数据。
BTF_KIND_ARRAY
类型
此时 BTF 类型头部字段的取值为:
name_off
:此时为 0;info.kind_flag
:此时为 0;info.kind
:此时为BTF_KIND_ARRAY
;info.vlen
:此时为 0;size/type
:此时为 0,没有使用;
BTF_KIND_ARRAY
类型之后的 data 是一个 btfArray
结构体,btfArray
类型用 Go 可表示为:
|
|
Type
:元素类型的type_id
;IndexType
:数组索引类型的type_id
;nelems
:数组个数(可以为 0);
BTF_KIND_STRUCT
/ BTF_KIND_UNION
类型
这两个类型将使用一样的数据结构。
此时 BTF 类型头部字段的取值为:
name_off
:0 或者是一个有效 C 语言标识的有效字符串数据 offset;info.kind_flag
:0 或者 1;info.kind
:BTF_KIND_STRUCT
/BTF_KIND_UNION
;info.vlen
:struct 或者 union 结构的成员个数;size
:struct 或者 union 结构的大小(bytes);
该类型后续将跟着类型为 btfMember
数组,个数为 info.vlen
。即每一个 btfMember
表示 struct 或者 union 的一个成员。
btfMember
类型用 Go 可表示为:
|
|
NameOff
:指向 BTF 字符串数据的索引,用以标识成员名;Type
:成员类型的type_id
;Offset
:如果kind_flag
为 0,则该字段对应成员的 offset(以 bit 为单位);如果kind_flag
为 1,则该字段包含位域大小和偏移:低 24 位为 offset,高 8 位为位域大小(bitfield size);
比如我们有这么一个结构体变量(没有使用位域):
|
|
则我们将得到如下 BTF 信息(由 bpftool 输出,内容有所省略):
|
|
每个 BTF 类型前面的编号即为 type_id
。
其中 type_id
为 1 则对应 Foo
结构体,其中有 3 个 btfMember
,其中:
a
:对应的type_id
为 2,即int
类型,其中offset
为 0(即第一个字段);b
:对应的type_id
为 3,即char
类型,其中offset
为 32(因为a
字段为 32 bits 大小,则紧接着a
字段的b
的 offset 就为 32);c
:对应的type_id
为 4,即short
类型,其中offset
为 48(同理,因为前面的a
和b
字段分别为 32 bits 和 16 bits,那么c
字段的 offset 则为 32 + 16 = 48);
BTF_KIND_ENUM
类型
此时 BTF 类型头部字段的取值为:
name_off
:0 或者是一个有效 C 语言标识的有效字符串数据 offset;info.kind_flag
:此时为 0;info.kind
:BTF_KIND_ENUM
;info.vlen
:枚举值的个数;size
:此时为 4;
该类型后续将跟着类型为 btfEnum
数组,个数为 info.vlen
。btfEnum
类型用 Go 可表示为:
|
|
NameOff
:指向 BTF 字符串数据的索引,用以标识枚举名;Val
:具体枚举值;
BTF_KIND_FWD
类型
此时 BTF 类型头部字段的取值为:
name_off
:任何有效的字符串数据索引;info.kind_flag
:如果是 struct 为 0,是 union 则为 1;info.kind
:BTF_KIND_FWD
;info.vlen
:此时为 0;type
:此时为 0;
该类型之后没有数据。
BTF_KIND_TYPEDEF
类型
此时 BTF 类型头部字段的取值为:
name_off
:任何有效的字符串数据索引;info.kind_flag
:此时为 0;info.kind
:BTF_KIND_TYPEDEF
;info.vlen
:此时为 0;type
:typedef
所定义的原始类型的type_id
;
该类型之后没有数据。
BTF_KIND_VOLATILE
类型
此时 BTF 类型头部字段的取值为:
name_off
:此时为 0;info.kind_flag
:此时为 0;info.kind
:BTF_KIND_VOLATILE
;info.vlen
:此时为 0;type
:带有volatile
限定符的类型的type_id
;
该类型之后没有数据。
BTF_KIND_CONST
类型
此时 BTF 类型头部字段的取值为:
name_off
:此时为 0;info.kind_flag
:此时为 0;info.kind
:BTF_KIND_CONST
;info.vlen
:此时为 0;type
:带有const
限定符的类型的type_id
;
该类型之后没有数据。
BTF_KIND_RESTRICT
类型
此时 BTF 类型头部字段的取值为:
name_off
:此时为 0;info.kind_flag
:此时为 0;info.kind
:BTF_KIND_RESTRICT
;info.vlen
:此时为 0;type
:带有restrict
限定符的类型的type_id
;
该类型之后没有数据。
BTF_KIND_FUNC
类型
此时 BTF 类型头部字段的取值为:
name_off
:任何有效的字符串数据索引;info.kind_flag
:此时为 0;info.kind
:BTF_KIND_FUNC
;info.vlen
:此时为 0;type
:相应BTF_KIND_FUNC_PROTO
类型对应的type_id
;
该类型之后没有数据。
BTF_KIND_FUNC_PROTO
类型
此时 BTF 类型头部字段的取值为:
name_off
:此时为 0;info.kind_flag
:此时为 0;info.kind
:BTF_KIND_FUNC_PROTO
;info.vlen
:参数的数量;type
:返回类型的type_id
;
BTF_KIND_FUNC_PROTO
是表示一个函数的签名信息。
该类型后续将跟着类型为 btfParam
数组,个数为 info.vlen
。btfParam
类型用 Go 可表示为:
|
|
NameOff
:指向 BTF 字符串数据的索引,用以标识参数名;Type
:对应参数类型的type_id
;
如果函数有可变参数,则最后一个参数的这两个字段均为 0。
BTF_KIND_VAR
类型
此时 BTF 类型头部字段的取值为:
name_off
:任何有效的字符串数据索引;info.kind_flag
:此时为 0;info.kind
:BTF_KIND_FUNC_VAR
;info.vlen
:此时为 0;type
:对应变量的类型;
该类型后续将跟着类型为 btfVariable
类型的数据。btfVariable
类型用 Go 可表示为:
|
|
Linkage
:目前只有静态变量设置为 0,而全局分配的变量则设置为 1;
BTF_KIND_DATASEC
类型
此时 BTF 类型头部字段的取值为:
name_off
:对应数据段 ELF Section 名的字符串数据索引,比如.data
、.bss
、.rodata
等。如果有多个这样的数据段,则对应有多个BTF_KIND_DATASEC
类型;info.kind_flag
:此时为 0;info.kind
:BTF_KIND_DATASEC
;info.vlen
:变量的数量;size
:该段的总的大小(以 bytes 为单位)。该字段编译的时候为 0,由 BPF 加载器(比如 libbpf)来修订为实际大小;
BTF_KIND_DATASEC
表示是全局变量的数据。
该类型后续将跟着类型为 btfVarSecinfo
数组,个数为 info.vlen
。btfVarSecinfo
类型用 Go 可表示为:
|
|
Type
:对应数据类型的type_id
;Offset
:段内偏移量(基本是 0);Size
:对应数据的大小(以 bytes 为单位);
BTF 与 Linux 内核
BTF 与 Linux 内核间的 API 同样是用了 bpf(2)
这个系统调用(不同的参数)。根据具体的作用不同,我们可以分为以下几种:
-
BPF_BTF_LOAD
将 BTF 数据加载入内核,加载成功将返回
btf_id
。用户层后续将通过btf_id
来获取 BTF 数据; -
BPF_MAP_CREATE
可利用
btf_id
和 key/value 的 type id 来创建 BPF Map:1 2 3
__u32 btf_fd; /* fd pointing to a BTF type data */ __u32 btf_key_type_id; /* BTF type_id of the key */ __u32 btf_value_type_id; /* BTF type_id of the value */
-
BPF_PROG_LOAD
我们加载 BPF Prog,可将
.BTF.ext
的信息也一同加载。 -
BPF_{PROG,MAP}_GET_NEXT_ID
在内核中,Prog / Map / BTF 都有其唯一的 ID,这些 ID 在其对象对应的生命周期内保持不变。我们可以利用该调用遍历出所有的 ID(最开始可以选择从 0 开始)。
-
BPF_{PROG,MAP}_GET_FD_BY_ID
我们无法通过 ID 获取到 Prog 或者 Map 更多的元数据信息,此时必须通过 fd。该调用就是利用 ID 换一个 fd。
-
BPF_OBJ_GET_INFO_BY_FD
一旦获取到了 fd,我们就可以利用这个获取 Prog 或者 Map 更多的元数据,而这些元数据也带有相应的 BTF 信息。通过这些 BTF 信息,我们可以得到更丰富的类型数据。
-
BPF_BTF_GET_FD_BY_ID
一旦我们获取到 BTF ID,我们可以利用该调用获取 BTF fd。然后再通过
BPF_OBJ_GET_INFO_BY_FD
我们可以得到用BPF_BTF_LOAD
加载的最原始的 BTF Blob 数据,从而可获得所有的 BTF 上下文信息。