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_flag3 个可用字段,其中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 上下文信息。