ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

BTF与CO-RE实战:从二进制格式到可移植eBPF

BTF与CO-RE实战:从二进制格式到可移植eBPF

1. 先把 BTF 这件事说清楚:它到底在解决什么问题

前段时间排查一个采集程序的加载失败,日志里一行字很扎眼:libbpf: prog 'trace_open': relo #0: field 'file.f_path.dentry' not found in target。编译机上的内核和运行节点上的内核不是同一个版本,结构体中间多插了一个字段,手写的偏移量全废。这类问题在没有 BTF 的年代,基本上只能靠“编译时打补丁、运行前先探测、探测不到就降级”这一套土办法硬扛。BTF(BPF Type Format)出现之后,这件事的解决方式变得非常干净:把内核的类型信息本身变成一种可加载、可查询的数据,让程序在运行期自己算偏移。

BTF 全称 BPF Type Format,是一种紧凑的、自描述的二进制类型元数据格式。它最初是为了支撑 BPF 程序的 CO-RE(Compile Once - Run Everywhere)能力而设计的,后来逐渐被扩展成一套通用的“内核类型数据库”。它解决的问题非常具体:让一段编译好的 BPF 目标文件,在类型布局完全不同的内核上依然能正确读写字段。做到这件事需要三块拼图——编译期在指令上留下重定位记录、运行期有一份目标内核的权威类型信息、加载器能根据这两者算出最终偏移并改写指令。BTF 就是第二块拼图里那份“权威类型信息”的标准载体。

适合谁来读这篇内容?如果你正在写基于 libbpf 的 BPF 程序、正在给内核模块做可移植性改造、正在维护一套跨版本的内核观测工具链,那 BTF 是你绕不开的底层设施,值得花时间啃透。如果你只是想快速跑一个 hello world 级别的 eBPF 示例,前半部分看完能跑起来就够了,后面关于格式细节的部分可以先跳过,等遇到具体问题再回来查。这篇内容会从“为什么需要它”讲到“二进制里每个字节是什么”,再讲到“怎么生成、怎么校验、怎么用、怎么排错”,尽量把一条完整链路摊开。

1.1 从一次字段偏移踩坑说起

传统写 BPF 程序读内核结构体字段,最直觉的做法是把struct task_struct里comm的相对偏移算出来,直接写死。这个偏移在编译机上算出来是0x6e0,换一台机器可能就变成0x710。中间的差异可能来自编译选项、可能来自某个配置项打开后多出来的字段、也可能来自内核小版本迭代中的字段重排。手工维护这种偏移表,工作量随内核版本数量线性增长,而且极难测试覆盖。

更麻烦的是“探测式”方案的天花板。业界曾经流行过一种做法:程序启动时先尝试读几个候选偏移,看哪个位置读出来的值像是一个合法的进程名,就采用哪个。这种启发式探测在字段含义相近时极易误判,比如两个相邻的指针字段,读出来的都是内核地址,根本区分不了。而且探测本身要发起系统调用或者触发一次 BPF 执行,成本不低,探测失败还得走降级路径,代码分支迅速膨胀。

BTF 把这条路径彻底换了。它不再让程序去猜,而是让内核自己报出类型布局。程序只需要表达“我要读task_struct里名为comm的字段”,剩下的事情交给加载器:加载器拿着目标内核的 BTF,按名字找到task_struct,再找到comm在其中的位置,算出实际偏移,回填到指令的立即数里。整个过程在加载阶段一次性完成,运行期零开销。这就是 CO-RE 的核心思想,而 BTF 是它的数据基础。

1.2 BTF 的定义与它在 BPF 生态里的位置

把 BTF 拆开看,它就是一份“类型字典”。字典里的每个条目描述一个类型:这个类型叫什么名字、属于哪种 kind(整型、指针、数组、结构体、联合体、枚举、函数原型、变量等)、大小是多少、成员怎么排布。条目之间通过 ID 互相引用,形成一个有向图。整个字典序列化成一段连续的字节流,可以嵌在 ELF 的.BTF段里,也可以单独存成文件,还可以由内核在启动时暴露成/sys/kernel/btf/vmlinux这样一个虚拟文件。

它在生态里的位置可以这样理解:BPF 指令是“代码”,BTF 是“代码所操作的数据的形状”。BPF 虚拟机本身是强类型的,但它只知道寄存器宽度,不知道struct sk_buff是什么。verifier 在检查内存访问合法性时,如果能拿到类型信息,就能做更精确的边界校验。这就是为什么后来的内核版本里,BTF 不只服务于 CO-RE,还被 verifier 用来做更严格的内存安全检查。

再往外扩一层,BTF 还承担了“内核符号与类型的最小可用描述”这个角色。传统的vmlinux调试信息(DWARF)动辄几百 MB,不适合随内核一起分发。BTF 把其中最关键的、BPF 程序真正需要的类型信息抽出来,体积压缩到几 MB 量级,就可以默认编进内核镜像了。这个取舍很关键——它让“每台机器都自带类型信息”从不可能变成了默认行为。

1.3 没有 BTF 的世界长什么样

对比一下会更清楚。假如 BTF 不存在,一个需要读内核结构的 BPF 程序有几种活法:一是针对每个内核版本单独编译一份,用版本号做分发,这是很多早期工具的做法,维护成本极高;二是在运行时用 BCC 那套方案,把 C 代码片段连同本机的内核头文件一起丢给 clang 现场编译,编译一次几秒到几十秒,还得依赖目标机器上装了完整的开发头文件;三是前面说的偏移探测,靠猜。

这三种方案各有各的痛点。现场编译对部署环境要求高,容器里跑基本没戏,而且每次启动都要吃一次编译开销。按版本分发要求你把目标环境的内核版本矩阵摸清楚,一旦出现一个没覆盖到的版本就直接不可用。偏移探测则是可靠性问题,边界情况太多。

BTF 方案的优势在于:编译一次,产物是一份带重定位信息的 ELF;部署时只需要目标机器上有 BTF,加载器负责适配。它把“编译期”和“运行期”解耦了,这对需要大规模分发的工具来说意义重大。你可以在 CI 里编好一份二进制,扔到几千台内核版本参差不齐的机器上跑,不用为每一台单独打包。

2. BTF 的二进制格式拆解:每个字节都有交代

真要把 BTF 用明白,光知道“它是一份类型字典”不够,遇到解析异常、体积异常、类型找不到的时候,必须能钻进字节流里看。这一节按从外到内的顺序拆:先看整体布局,再看单个类型条目怎么编码,最后看扩展段和模块场景下的变体。

格式设计上有两个明显的取向。第一是紧凑,所有字段都是定长整数,没有变长编码,字符串集中放在一张表里靠偏移引用,靠这种方式把重复的类型名压掉。第二是可扩展,类型条目里的info字段留了保留位,kind 只用 5 个 bit,意味着未来还能加新类型而不破坏老解析器。理解这两个取向,很多设计细节就顺了。

2.1 文件头与三段式布局

BTF 的整体结构非常规整,就是“头 + 类型段 + 字符串段”三段。头部固定 24 字节,字段全是小端序。头部里记录了类型段和字符串段各自的偏移与长度,所以解析器可以先把头读出来,再按需定位到对应区域。

字段类型说明
magicu16固定0xeb9f,字节流里是9f eb
versionu8当前为 1
flagsu8保留,通常为 0
hdr_lenu32头部长度,便于未来扩展
type_offu32类型段相对头部结束位置的偏移
type_lenu32类型段字节长度
str_offu32字符串段相对头部结束位置的偏移
str_lenu32字符串段字节长度

有两个细节容易踩坑。一是type_off和str_off是相对hdr_len之后的位置算的,不是相对文件开头。二是hdr_len目前等于 24,但解析器应该读这个字段而不是硬编码,因为格式预留了扩展空间。写解析代码时如果图省事写死 24,将来格式扩展了就会读错位。

字符串段是一串以\0分隔的字节,整段以一个\0开头,所以偏移 0 永远指向空字符串。类型条目里所有name_off字段都是相对这段的起点。这个设计让匿名类型(比如纯指针类型、没有名字的函数原型)可以直接用name_off = 0表示,省掉一个可空标志。

2.2 类型条目的三段式编码:name_off / info / size_or_type

每个类型条目都以 12 字节的固定头开始,结构是name_off(u32)、info(u32)、size_or_type(u32)。name_off指向字符串表里的名字;size_or_type是个双关字段,对结构体、联合体、枚举、整型这类“有大小”的类型,它表示字节大小,对指针、typedef、const 这类“引用别的类型”的类型,它表示被引用类型的 ID。

info字段是这个格式里信息密度最高的地方,一个 32 位整数被切成了三块:

/* 内核 btf.h 中的定义,此处按位展开说明 */ kind = (info >> 24) & 0x1f; /* bit 24-28,5 bit,类型种类 */ kflag = (info >> 31) & 0x01; /* bit 31,kind 相关的标志位 */ vlen = info & 0xffff; /* bit 0-15,附属数据的个数 */ /* bit 16-23 与 bit 29-30 保留 */

vlen的含义随 kind 变化:结构体里是成员数量,枚举里是枚举值数量,函数原型里是参数数量,数据段里是变量数量。kflag的含义也随 kind 变化,最有代表性的是结构体——kflag = 1时,成员条目的 offset 字段被拆成“高 8 位表示位域宽度、低 24 位表示位偏移”,否则整个 32 位都是位偏移。这个双关设计省了不少空间,但解析时如果忘了判断kflag,位域字段的偏移会算得莫名其妙。

紧跟在这 12 字节之后的,就是该类型专属的附属数据。有的类型没有附属数据(比如指针、typedef、const),有的有固定长度的附属数据(比如数组、整型、变量),有的有变长的数组(比如结构体成员、枚举值、函数参数)。解析时严格按照 kind 决定读多少,不能靠猜。

注意:解析 BTF 时一定要先读info拿到 kind 和 vlen,再决定后续读多少字节。任何“按固定步长遍历类型段”的写法都是错的,因为类型条目是变长的。

2.3 kind 全表与各类型附属数据

把 kind 全部列出来,配合附属数据的形状,基本上就掌握了 BTF 的全貌。下面这张表是我自己整理时用的,比翻头文件快。

kind 值名称size_or_type含义附属数据
0UNKN无无,ID 0 保留给 void
1INT字节大小1 个 u32 编码位宽与符号性
2PTR目标类型 ID无
3ARRAY无意义3 个 u32:元素类型、索引类型、元素个数
4STRUCT字节大小vlen 个 12 字节成员
5UNION字节大小同 STRUCT
6ENUM字节大小vlen 个 8 字节枚举项
7FWD无意义无,前向声明
8TYPEDEF目标类型 ID无
9VOLATILE目标类型 ID无
10CONST目标类型 ID无
11RESTRICT目标类型 ID无
12FUNC函数原型 ID无,vlen表示链接属性
13FUNC_PROTO返回类型 IDvlen 个 8 字节参数
14VAR变量类型 ID1 个 u32 链接属性
15DATASEC字节大小vlen 个 12 字节变量区信息
16FLOAT字节大小无
17DECL_TAG目标类型 ID1 个 s32 组件索引
18TYPE_TAG目标类型 ID无
19ENUM64字节大小vlen 个 12 字节 64 位枚举项

几个值得单独说的。INT的附属 u32 编码是这样的:低 8 位是位宽,中间一段是位偏移(处理非字节对齐的整型),高 4 位是编码类型,区分有符号、字符、布尔、位图这几种语义。这个编码直接决定了 CO-RE 重定位时该做符号扩展还是零扩展,读错会导致取值符号错误,而且这种错误非常隐蔽,只有在负数场景下才暴露。

STRUCT的成员条目结构是name_off、type、offset三个 u32,一共 12 字节。offset的单位是 bit 而不是 byte,这一点特别容易搞混。我第一次自己写解析器时就栽在这里,读出来的偏移全部乘 8 才对。当kflag = 1时,offset的高 8 位是位域宽度,低 24 位才是位偏移;此时如果位域宽度为 0,说明这是一个普通的非位域成员,只是结构体里存在别的位域成员导致整体被标了kflag。

DATASEC是给全局变量用的。它的附属数据是 vlen 个三元组,每个三元组描述一个变量:变量类型 ID、在数据段内的偏移、大小。BPF 程序里的全局变量会被编译成.data、.bss、.rodata这类数据段,加载时 libbpf 就是靠 DATASEC 信息去 mmap 对应区域并做重定位的。

DECL_TAG和TYPE_TAG是后来加的,用途是把源码里的注解(比如某些属性标记)带到 BTF 里。它们不参与 CO-RE 偏移计算,但在做类型驱动的高级分析时有用,比如标记某个指针是“用户态指针”还是“内核态指针”,方便做静态检查。

2.4 字符串表、去重与 BTF ID 的语义

字符串表本身没什么复杂的,就是一段\0分隔的字节流。真正值得聊的是去重。一个中大型内核的 BTF 里,类型名重复出现的次数非常夸张,int、unsigned long、struct list_head这类名字会出现在成百上千个地方。字符串表靠“同名只存一份、大家共用偏移”来压体积,这一层是编译器或 pahole 在生成时做的,解析侧不用管,直接按偏移取值就行。

类型 ID 的语义需要特别注意。ID 0 保留,代表 void,任何“没有类型”的场景都指 0。有效 ID 从 1 开始,连续递增。但这里有个陷阱:类型段里的条目数量和有效 ID 数量不一定相等。因为存在“类型条目被删除但 ID 空间不回收”的情况,遍历类型段拿到的条目序号,和按 ID 查类型,是两套不同的索引方式。写代码时如果拿遍历序号当 ID 用,遇到有空洞的 BTF 就会错位。

正确做法是用专门的 API 按 ID 取类型,而不是自己维护一个数组下标。内核侧的btf_type_by_id()、用户态的btf__type_by_id()都处理了这个细节。另外btf__type_cnt()返回的是“最大 ID + 1”,包含了 ID 0 这个空洞,所以真正有效的类型数量是它减一。

还有一个隐蔽点:同一个逻辑类型在 BTF 里可能对应多个 ID。比如struct foo在某个编译单元里被前向声明成FWD,在另一个单元里是完整的STRUCT,两者 ID 不同。做类型匹配时不能只比 ID,得比名字加 kind。这也是为什么 libbpf 里查找类型的接口都是btf__find_by_name_kind()这种带 kind 的形式。

2.5 .BTF.ext 与 split BTF:模块怎么复用内核类型

.BTF段只放类型字典,另外还有一个.BTF.ext段,放的是“和指令相关”的三类信息:函数信息、行号信息、CO-RE 重定位记录。这三类信息的组织方式类似,都是一段记录头加变长记录数组,记录头里带rec_size,这样不同平台、不同版本可以有不同的记录大小而不破坏兼容性。

CO-RE 重定位记录是重中之重,每条记录包含四个字段:关联的指令偏移、本地类型 ID、访问路径字符串偏移、重定位类型。访问路径是一个形如0:1:3的字符串,表示“从这个类型的第 0 个成员进去,再走第 1 个成员,再走第 3 个成员”。加载器拿着这条路径去目标 BTF 里逐层查找,最终算出目标偏移。这个字符串形式很关键,它让“按名字查找”和“按索引查找”可以互相转换——路径里的数字是成员索引,但匹配时是按名字匹配的,所以即使目标结构体成员顺序变了也能对上。

现在说 split BTF。内核模块的 BTF 不会把整个内核的类型重新描述一遍,那样体积会爆炸。它只描述本模块新增的类型,基础类型全部通过 ID 引用内核主 BTF。这种“主 BTF + 增量 BTF”的模式就叫 split BTF,模块 BTF 的头部会带一个基础 BTF 的引用。解析模块 BTF 时必须先加载主 BTF,否则所有指向基础的 ID 都解析不了。

这个机制带来的实际影响是:如果你在写一个需要解析模块类型的工具,加载顺序不能错,必须先btf__load_vmlinux_btf()拿到主 BTF,再用btf__parse_split()加载模块 BTF。顺序反了会直接报错。另外,模块 BTF 里的 ID 是“接着”主 BTF 的 ID 往后排的,所以同一个类型在主 BTF 里 ID 是 1234,在模块视角下看到的还是 1234,不会重新编号,这个设计避免了大量 ID 重映射的开销。

3. 把 BTF 造出来:生成、裁剪、校验一条龙

理论讲完,落地第一步是“手上得有一份 BTF”。来源无非两种:内核自带的,和自己从调试信息生成的。前者是最常见的情况,现代主流发行版的内核基本都开了CONFIG_DEBUG_INFO_BTF,直接就能用。后者出现在你自己编译内核、或者需要为某个定制环境补一份类型信息的时候。这一节把两条路径都走一遍,再加上校验和阅读的方法。

先说结论性的建议:能用内核自带的就别自己生成。自带的 BTF 和内核镜像严格对应,没有任何版本漂移风险;自己生成的要么依赖调试信息完整性,要么依赖工具链版本匹配,多一层不确定性。只有在目标环境确实没有 BTF、又必须支持 CO-RE 时,才考虑自己补一份。

3.1 先确认内核有没有自带 BTF

检查动作只有一条命令,看这个虚拟文件在不在:

ls -l /sys/kernel/btf/vmlinux

文件存在就说明内核在编译时开启了 BTF 生成,并且已经把它暴露到 sysfs 上了。这个文件是只读的,内容就是裸的 BTF 字节流,可以直接被btf__parse()加载,也可以被bpftool解析。

顺手可以看一眼内核配置和模块的 BTF 覆盖情况:

# 确认编译选项 grep CONFIG_DEBUG_INFO_BTF /boot/config-$(uname -r) # 看有哪些模块也带了 BTF ls /sys/kernel/btf/ | head -20 # 验证主 BTF 能被正常解析 bpftool btf dump file /sys/kernel/btf/vmlinux format raw | head -20

这三条命令覆盖了三个不同的关注点。第一条确认编译配置,输出=y表示编进主镜像,=m在某些配置下也可能出现,取决于具体实现。第二条确认模块级 BTF 的可见性,/sys/kernel/btf/下每个文件对应一个加载了的模块,文件名就是模块名。第三条是端到端的可用性验证,如果bpftool能正常 dump 出内容,说明文件格式没问题,后续工具链使用不会在解析这步卡住。

注意:/sys/kernel/btf/vmlinux的存在与否和内核版本号没有绝对关系。同一个大版本的不同发行版内核,可能一个开了 BTF 一个没开。做部署兼容性判断时必须以实际文件探测为准,不能靠版本号推断。

如果文件不存在,说明内核没开这个配置。这时有三条路:换一个带 BTF 的内核、自己重新编译内核打开配置、或者退回到不带 CO-RE 的传统方案。第一种最省事,第二种要求你能控制目标机器的内核,第三种只在前两种都行不通时考虑。

3.2 用 pahole 从调试信息生成 BTF

自己生成 BTF 的路径是从带调试信息的内核镜像里抽取。前置条件是手上有一份带 DWARF 信息的vmlinux,通常来自CONFIG_DEBUG_INFO=y加上CONFIG_DEBUG_INFO_DWARF4(或 DWARF5)。工具是pahole,它属于 dwarves 这套工具集,主流发行版的软件仓库里都有打包。

最直接的用法是给内核镜像“就地”加上 BTF 段:

# -J 表示生成 BTF 并写入 .BTF 段 pahole -J vmlinux # 验证是否写进去了 readelf -S vmlinux | grep -i btf

如果目标是产出一个独立的 BTF 文件而不是改镜像,用 detached 模式:

pahole --btf_encode_detached=vmlinux.btf vmlinux

产出的vmlinux.btf就是裸 BTF 字节流,可以直接被btf__parse()加载。这种模式下通常还需要配合几个开关,因为默认行为未必满足 BPF 程序的需求。

pahole的版本很关键。BTF 格式在演进过程中加过新 kind,老版本pahole不认识新 kind,生成出来的 BTF 会缺类型或者直接报错。经验做法是让pahole版本不低于目标内核的主版本号。跨版本生成虽然有时能work,但一旦出现“某个类型在目标 BTF 里找不到”,排查成本很高。

3.3 裁剪与体积控制的几个开关

内核完整 BTF 的体积在几 MB 量级,对大多数场景无所谓,但在嵌入式或者容器镜像分发场景下,每一 MB 都要计较。pahole提供了若干裁剪开关,理解它们各自砍掉什么很重要。

开关作用什么时候该用
--skip_encoding_btf_vars不生成 DATASEC 和 VAR 类型程序不访问全局变量时
--btf_gen_floats生成浮点类型类型图里有浮点成员,不加会断链
--btf_gen_all打开全部可选类型生成不确定该开哪些时的稳妥选择
--btf_features=...按特性列表控制生成内容需要精细控制时

--skip_encoding_btf_vars是最有效的体积削减手段之一,因为全局变量相关的类型条目数量可观。但它有副作用:如果 BPF 程序里有.rodata常量或者.bss全局变量,加载时 libbpf 需要靠 DATASEC 信息来定位和初始化,砍掉之后这些功能会失效,报错通常是“找不到数据段”之类。所以这个开关只能在整个程序确实不用全局变量时打开。

--btf_gen_floats容易被忽略。有些型号的传感器数据、性能计数器结构体里带浮点成员,如果 BTF 里没有 FLOAT 类型,类型图在这一环就断了,从结构体顶层往下走的访问路径匹配会在浮点成员处失败。这个问题的表现是“某个本来存在的字段报 not found”,非常反直觉。不确定的时候就加上,多出来的体积可以忽略。

还有一个实践经验:不要对 BTF 做通用压缩后分发。BTF 解析器需要随机访问,压缩流没法直接 mmap 使用,每次加载都要先解压到内存,多一次拷贝和一份内存占用。真要省体积,用前面说的裁剪开关,而不是事后压缩。

3.4 校验与阅读:bpftool btf dump 的正确用法

生成完之后一定要校验,不然问题会推迟到加载 BPF 程序时才暴露。bpftool的btf dump子命令是最趁手的工具,它有三种输出格式,用途完全不同。

# raw 格式:贴近二进制,用来核对 ID 和 kind bpftool btf dump file vmlinux.btf format raw | head -40 # c 格式:生成 C 头文件,这就是 vmlinux.h 的来源 bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h # 带行号信息时还能看到源码位置 bpftool btf dump file vmlinux.btf format raw

raw格式的输出长这样,每行是一个类型,格式是[ID] KIND '名字' 大小/类型:

[1] INT 'long unsigned int' size=8 bits_offset=0 nr_bits=64 encoding=(none) [2] INT 'char' size=1 bits_offset=0 nr_bits=8 encoding=(none) [3] PTR '(anon)' type_id=4 [4] STRUCT 'list_head' size=16 vlen=2 'next' type_id=3 bits_offset=0 'prev' type_id=3 bits_offset=64

拿这个输出去核对几件事:ID 是否从 1 开始连续,指针类型的type_id是否指向存在的类型,结构体成员的总位宽是否不超过结构体大小乘 8。这几项能覆盖绝大部分生成错误。

c格式就是生成vmlinux.h的命令,这个头文件是写 CO-RE 程序的标准做法。它把内核 BTF 里的所有类型转成 C 声明,让 BPF 程序在编译时就能拿到完整的类型定义,不需要#include任何内核头文件。这一点对可移植性至关重要——传统方式依赖目标机器上的内核头文件版本,而vmlinux.h把类型定义固化在编译产物里,运行时靠 CO-RE 重定位去适配。

# 生成 vmlinux.h 的完整流程 bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h # 确认关键类型在里面 grep -c "^struct task_struct" vmlinux.h

vmlinux.h的体积不小,动辄几万行。它不影响运行时,只影响编译时间。有些团队会按需裁剪,只保留自己用到的类型,但我不建议这么做——裁剪过的头文件一旦遇到新需求就要重新生成,而且裁剪过程中容易漏掉依赖类型导致编译失败。让它大着,编译多花几秒而已。

4. 用 BTF 写可移植程序:CO-RE 重定位的完整链路

到这一步,手上有了 BTF,接下来是把它用起来。CO-RE 的完整链路分三段:编译期留下重定位记录、加载期按目标 BTF 算出偏移、回填到指令里。三段环环相扣,任何一段出问题都表现为“程序加载失败”,但失败原因完全不同,得分开排查。

这套机制的精妙之处在于它把“适配”这件事从运行期挪到了加载期。加载完成后,BPF 程序里的指令已经是针对当前内核的最终形态,执行时没有任何额外的查表或分支。对高频触发的探针来说,这个零开销特性是决定性的——如果每次触发都要做一次类型查找,性能根本没法接受。

4.1 CO-RE 的五个重定位类别

重定位记录里的类型字段决定了加载器要做什么计算。完整列表有十几种,但常用的可以归成五大类,理解这五类就够应付绝大多数场景。

第一类是字段偏移重定位,也是最常用的。BPF_CORE_FIELD_BYTE_OFFSET算的是某个字段相对结构体起始的字节偏移,BPF_CORE_FIELD_BYTE_SIZE算的是字段自身的字节大小。这两个是BPF_CORE_READ系列的基石,每次读嵌套字段都会生成一串偏移重定位。

第二类是字段存在性重定位,BPF_CORE_FIELD_EXISTS。它不产生偏移,而是产生一个 0 或 1 的常量,供程序做分支判断。典型用法是在程序里写if (bpf_core_field_exists(struct foo, bar)),如果不存则该分支在加载时就被优化掉了。这比运行时去猜要可靠得多。

第三类是位域相关的移位重定位,FIELD_LSHIFT_U64和FIELD_RSHIFT_U64。位域在结构体里的位置包含位偏移和位宽两部分信息,读取时需要先左移再右移来提取。这两个重定位分别算出两个移位量。值得一提的是,如果目标内核里这个字段从位域变成了普通字段,libbpf 会智能地把它处理成移位量 0 的情况,程序逻辑不需要改。

第四类是类型级别的重定位,包括TYPE_EXISTS、TYPE_SIZE、TYPE_ID_LOCAL、TYPE_ID_TARGET。做类型驱动的程序(比如根据类型选择不同的处理逻辑)时会用到,用法是配合bpf_core_type_exists()之类的宏。

第五类是枚举值重定位,ENUMVAL_EXISTS和ENUMVAL_VALUE。枚举值在不同内核版本里可能被重排,甚至被删除,直接写死数值会出错。用bpf_core_enum_value()可以让加载器在目标 BTF 里按枚举名查出实际值。

还有一类TYPE_MATCHES,用途是把本地类型和类型图里某个位置的类型做匹配判断,比较小众,但做复杂的嵌套类型推导时会用到。

4.2 编译期:preserve 内建函数与 vmlinux.h

编译期要做的事只有一件:让 clang 知道“这段代码里的字段访问需要在加载时重定位”。做法是用内建函数把表达式包起来。

/* 最基础的形式:告诉编译器保留这个访问的类型信息 */ struct task_struct *task = ...; const char *name = __builtin_preserve_access_index(task->comm);

手写内建函数太啰嗦,实际都用 libbpf 提供的宏:

#include <bpf/bpf_core_read.h> /* BPF_CORE_READ 会自动拆解嵌套访问,每一层都生成重定位 */ char comm[16]; BPF_CORE_READ(task, comm); /* 读 task->comm */ /* 等价于展开成 */ bpf_probe_read_kernel(comm, sizeof(comm), (void *)task + bpf_core_field_offset(task->comm));

BPF_CORE_READ的实际展开比这复杂,它用___type()和___arrow()这样的宏拼接技巧,把a->b->c这种链式访问拆成逐层读取,每一层都套上__builtin_preserve_access_index。所以它能处理任意深度的嵌套,代价是每多一层就多一次读内存操作,性能上要考虑清楚。

这里有个必须强调的点:BPF_CORE_READ系列是给“内核态结构体”用的,它内部用的是bpf_probe_read_kernel。如果目标指针指向用户态内存,得用BPF_CORE_READ_USER,否则读出来的全是垃圾。这个错误不会报错,只会得到错误数据,排查起来非常痛苦。

还有BPF_CORE_READ_INTO和BPF_CORE_READ_STR_INTO两个变体,前者把结果读到指定缓冲区,后者专门处理字符串。读内核结构体里的char *字段指向的字符串时,用BPF_CORE_READ_STR_INTO比先读指针再读字符串要安全,因为它会自动处理跨页边界的读取。

编译命令要带上调试信息和 BTF 相关选项:

clang -g -O2 -target bpf -D__TARGET_ARCH_x86 \ -c minimal.bpf.c -o minimal.bpf.o

-g是必须的,没有它 clang 不会生成 BTF 和重定位记录。-D__TARGET_ARCH_x86影响的是寄存器别名,不影响 BTF 生成。编完之后可以用bpftool看一眼重定位记录有没有生成:

bpftool gen object minimal.bpf.o minimal.linked.o llvm-objdump -S minimal.bpf.o | head -40

如果.BTF.ext段是空的,或者llvm-objdump里看不到R_BPF_64_32之类的重定位项,说明编译选项漏了东西,得回头检查-g是否生效。

4.3 运行期:libbpf 怎么打补丁

加载阶段是整个链路里最“黑盒”的部分,但知道它做了什么,排错时能省大量时间。libbpf 的处理顺序大致是这样:

打开目标文件时,先解析.BTF段,拿到本地类型字典;然后解析.BTF.ext,把重定位记录读进内存。接下来定位目标内核的 BTF——优先用用户显式传入的btf_custom_path,没有就加载/sys/kernel/btf/vmlinux。这一步如果失败,后面的所有重定位都没法做,会给出一条很明确的“无法加载内核 BTF”的报错。

有了本地和目标两份类型字典,libbpf 开始逐条处理重定位记录。每条记录的处理流程是:拿访问路径字符串(形如0:1:3),在本地类型里逐层走一遍,收集每一层的成员名;然后从目标类型的同名字段开始,按名字逐层匹配,累加每层的字节偏移;最后把总偏移写回指令的立即数字段。

匹配过程中如果某层字段在目标类型里找不到,会根据重定位类型决定是报错还是降级。字段偏移重定位找不到就报错,字段存在性重定位找不到就写入 0。这就是为什么bpf_core_field_exists能安全使用——它本来就是为了处理“字段可能不存在”这个场景。

这里有个实际的性能考量:完整处理一遍几千条重定位记录耗时在几十到几百毫秒量级,对于长期驻留的守护进程无所谓,但对于“每次调用都启动一次”的短命工具,这个开销就明显了。如果业务场景是高频启停,可以考虑用BPF_TOKEN_CREATE配合 skeleton 的方式减少重复加载开销,或者把加载结果缓存下来复用。

注意:重定位是在加载时一次算定的,加载后如果内核被热补丁修改了结构体布局(极少见但理论上存在),已加载的程序不会自动更新。这种场景需要重新加载程序。

4.4 一个完整可跑的示例与加载日志

把前面几块拼起来看一个完整例子。功能很简单:跟踪文件打开操作,把进程名和文件名打到 ringbuf 里。重点看 CO-RE 相关的写法。

/* minimal.bpf.c */ #include "vmlinux.h" #include <bpf/bpf_helpers.h> #include <bpf/bpf_core_read.h> #include <bpf/bpf_tracing.h> struct event { __u32 pid; char comm[16]; char fname[64]; }; struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 1 << 20); } events SEC(".maps"); SEC("kprobe/do_sys_openat2") int BPF_KPROBE(trace_open, int dfd, const char __user *filename) { struct event *e; e = bpf_ringbuf_reserve(&events, sizeof(*e), 0); if (!e) return 0; e->pid = bpf_get_current_pid_tgid() >> 32; /* 读当前进程名,字段访问走 CO-RE 重定位 */ bpf_get_current_comm(&e->comm, sizeof(e->comm)); /* 读用户态传入的文件名 */ bpf_probe_read_user_str(&e->fname, sizeof(e->fname), filename); bpf_ringbuf_submit(e, 0); return 0; } char LICENSE[] SEC("license") = "GPL";

用户态加载部分用 libbpf 的标准流程:

/* loader.c 中的关键片段 */ struct bpf_object *obj; struct bpf_program *prog; int err; obj = bpf_object__open_file("minimal.bpf.o", NULL); if (!obj) { /* 处理打开失败 */ } /* 挂载点可以用内核里的具体函数名,也可以从环境变量读 */ prog = bpf_object__find_program_by_name(obj, "trace_open"); if (!prog) { /* 程序名写错了 */ } err = bpf_object__load(obj); if (err) { /* 这里会打印 libbpf 的重定位日志,是排错的关键 */ }

加载成功时日志大概是这样,能看到每条重定位的处理结果:

libbpf: loading object 'minimal_bpf' from buffer libbpf: elf: section(3) kprobe/do_sys_openat2, size 512, link 0, flags 6 libbpf: sec 'kprobe/do_sys_openat2': found program 'trace_open' at insn offset 0 libbpf: CO-RE relocating [0] struct task_struct: found target candidate [2891] libbpf: prog 'trace_open': relo #0: <byte_off> [0] struct task_struct.comm libbpf: prog 'trace_open': relo #0: matching candidate #0 struct task_struct.comm libbpf: prog 'trace_open': relo #0: patched insn #4 (LDX/ST/STX) off 1080 -> 1120

日志里off 1080 -> 1120这一行是关键信息,说明本地编译时算出来的偏移是 1080,目标内核实际是 1120,libbpf 已经完成了改写。看到这种行,就说明整条链路通了。

如果失败,日志会停在某个具体位置,这时候要按 4.3 讲的流程逐段排查:先确认目标 BTF 加载成功,再确认本地类型名字和目标名字能对上,最后确认访问路径上的每一层成员都存在。

5. 踩坑实录:BTF 相关问题排查速查

这一节是我自己以及身边人踩过的坑的汇总。BTF 相关的问题有个共同特点:报错信息往往指向“结果”,而不是“原因”。比如“字段找不到”,可能是字段真没有,也可能是类型名对不上,还可能是整份 BTF 压根没加载成功。按固定顺序排查能少走弯路。

排查的第一原则是:先确认 BTF 加载成功,再怀疑类型匹配。绝大多数绕远的排查都源于一开始没确认这一步。其次要分清“编译期问题”和“加载期问题”——编译期的问题是重定位记录没生成,加载期的是生成了但匹配不上。两者的日志特征完全不同。

5.1 加载失败的典型报错与定位顺序

先把常见报错和成因列出来,遇到时对号入座能快很多。

报错片段典型成因处理方向
failed to load BTF from /sys/kernel/btf/vmlinux内核没开 BTF,或文件不可读换内核或自己补 BTF
CO-RE relocation failed: field ... not found目标内核没这个字段,或类型名变了用bpf_core_field_exists做兼容
relo #0: type_id X is not found in target BTF本地类型在目标里不存在检查类型是否被裁剪或改名
prog 'x': unsupported kernel version用了老内核不支持的指令或 helper降级 helper 或加版本判断
invalid btf_id手写的类型 ID 越界改用名字查找 API

field not found是最常见的。定位顺序建议是:先在目标机器上手工查这个类型和字段是否存在,用bpftool btf dump配合 grep。查得到说明是匹配逻辑问题(比如路径中间某一层断链),查不到说明目标内核确实没有,需要在代码里加存在性判断。

# 在目标机器上确认类型和字段 bpftool btf dump file /sys/kernel/btf/vmlinux format raw \ | grep -A3 "STRUCT 'task_struct'"

如果输出里完全没有这个结构体,说明内核把这个配置项关掉了,结构体根本没编进去。这种情况只能靠bpf_core_type_exists()做分支,或者换一个更稳定的观测点。

还有一种隐蔽的失败:报错说字段找不到,但你手工 grep 明明有。这时候要看是不是类型名带struct前缀不一致。BTF 里的名字是不带struct关键字的,如果代码里用错了名字(比如用了 typedef 名但实际类型是匿名结构体),就会匹配不上。用bpftool btf dump format raw看实际名字最可靠。

5.2 类型匹配与枚举值问题

枚举值的坑比字段偏移更隐蔽,因为它不报错,只是值不对。不同内核版本里枚举项可能被增删、可能顺序变了。尤其是那些“打补丁式”增加的枚举项,往往插在中间而不是末尾。

/* 错误写法:值写死 */ if (state == 3) { ... } /* 正确写法:让加载器去查 */ int val; val = bpf_core_enum_value(enum xxx_state, XXX_RUNNING); if (state == val) { ... }

bpf_core_enum_value在目标 BTF 里按枚举名查找,找不到时行为是编译期直接报错(因为它是编译期求值的宏),这反而是好事——早暴露总比运行期静默出错强。

另一个常见问题是 typedef 的穿透。BTF 里 typedef 是独立类型,指向被 typedef 的类型。做类型匹配时,libbpf 会自动穿透 typedef、const、volatile 这些“透明层”,但你自己写解析逻辑时如果不穿透,就会在 typedef 处断链。判断依据是:TYPEDEF、CONST、VOLATILE、RESTRICT这四种 kind 的size_or_type字段都是“指向的目标类型”,需要继续往下走。

前向声明是另一种断链源。FWD类型的结构体没有成员信息,如果访问路径经过一个 FWD,匹配必然失败。这种情况通常出现在跨编译单元的场景,解决方式是确保完整的结构体定义在内核 BTF 里存在(大多数情况下内核主 BTF 里都是完整定义,FWD 只出现在模块 BTF 里)。

5.3 体积与内存的账

BTF 的体积问题在两类场景下会冒出来:一是把 BTF 打进容器镜像,镜像变大;二是内存受限的嵌入式环境。

先给量级参考:一份完整的 x86_64 内核 BTF,原始大小在 5 MB 上下,裁剪掉变量后能降到 4 MB 出头。模块 BTF 每份从几十 KB 到几百 KB 不等。加载到内存后,libbpf 会做一次反序列化,内存占用大约是原始大小的 1.5 到 2 倍。

如果目标是省镜像体积,最有效的做法是在构建镜像时不要把vmlinux.h和 BTF 文件打进去——它们只在编译期和加载期需要,产物镜像里只需要编译好的.o。这一点经常被忽略,有人把整个开发目录都 COPY 进镜像,多出几十 MB。

如果目标是省运行时内存,可以考虑只在真正需要时加载 BTF,用完释放。libbpf 的btf__free()会释放整个类型字典。但要注意,如果程序里有多个 BPF 对象共享同一份内核 BTF,释放时机得协调,不然会出现 use-after-free。

还有一个经常被问到的点:BPF 程序的指令数受 verifier 限制,那 BTF 会不会也受限制?答案是不会。BTF 是加载期的数据,不进 verifier 的指令分析流程。真正受限制的是.BTF.ext里的重定位记录数量——但那个上限也很高,正常程序碰不到。

5.4 速查表

把排错时最常用的命令集中一下,出问题时按顺序敲一遍,基本能定位到问题层级。

# 第 1 层:内核有没有 BTF ls -l /sys/kernel/btf/vmlinux # 第 2 层:BTF 能不能被解析 bpftool btf dump file /sys/kernel/btf/vmlinux format raw | head -5 # 第 3 层:目标类型和字段在不在 bpftool btf dump file /sys/kernel/btf/vmlinux format raw \ | grep -A5 "STRUCT '<你的结构体名>'" # 第 4 层:本地编译产物有没有生成重定位 llvm-objdump -h minimal.bpf.o | grep -i btf llvm-objdump -s -j .BTF.ext minimal.bpf.o | head -20 # 第 5 层:加载时的详细日志 LIBBPF_DEBUG=1 ./your_loader 2>&1 | grep -i "relo\|core"

第 4 层经常被跳过,但它在区分“编译问题”和“加载问题”上非常关键。如果.BTF.ext段是空的,说明问题在编译期,后面怎么调加载参数都没用。

注意:LIBBPF_DEBUG=1会输出大量日志,排查时建议配合 grep 过滤。直接看全量输出容易被淹没,反而错过关键行。

6. 一些容易被忽略的用法与个人经验

写到这儿,BTF 的主线基本走完了。剩下的是些边角但实用的东西,以及我个人在实际项目里攒下来的习惯。这部分不适合当手册查,但当经验看比较合适。

6.1 BTF 不止服务 CO-RE

大部分人接触 BTF 都是因为 CO-RE,但它作为“内核类型数据库”的价值远不止于此。我见过几个有意思的用法,值得提一下。

一是做类型驱动的静态检查。你可以在 CI 里加载一份目标内核的 BTF,遍历所有结构体,检查某些约定是否被遵守——比如某个标记为“敏感”的字段是否被误放进了可序列化的结构体里。这种检查在内核代码评审里已经有实践,思路完全可以搬到自己的项目里。

二是做工具链的自检。写一个脚本,在发布前把 BTF 里的关键类型和字段清单跑一遍,确认目标内核版本覆盖到了。这比人工维护兼容性矩阵可靠得多,而且一旦内核升级导致字段变化,CI 会直接红。

三是配合bpftool gen skeleton做骨架代码生成。skeleton 会把 BTF 信息嵌进生成的 C 头文件里,让用户态代码能直接拿到结构体定义和字段偏移,不用自己写解析逻辑。这个在写复杂工具时能省大量样板代码。

还有一种用法是给性能分析工具做符号化。有些调用栈分析工具需要知道某个地址落在哪个结构体的哪个字段里,以前靠 DWARF,现在直接查 BTF 就行,查询速度快了一个数量级,内存占用也小得多。

6.2 我个人的几条习惯

第一条,永远用vmlinux.h而不是内核头文件。早期我也试过直接#include <linux/sched.h>,结果被各种依赖和宏展开折磨得不行。vmlinux.h是自包含的,编译快,而且类型定义和运行时 BTF 严格同源,不会出现“编译时看到的类型和运行时不一样”的诡异问题。

第二条,给每个字段访问都加存在性判断,如果这个字段是“可能没有”的。判断成本是零(编译期求值),收益是避免一整类加载失败。我的习惯是把这类判断集中在一个头文件里,写成static inline bool has_xxx(void)的形式,主逻辑里只调这些函数,可读性好很多。

第三条,保留.BTF段,别 strip 掉。有人为了减小.o体积会 strip 掉调试段,连.BTF一起干掉,结果加载时直接失败。正确做法是只 strip 掉.debug_*那些真正没用的段,保留.BTF和.BTF.ext。用llvm-strip --strip-debug而不是strip --strip-all。

第四条,版本探测用能力而不是版本号。判断目标内核支不支持某个特性,别去看uname -r的版本号,去看 BTF 里有没有对应的类型或字段。版本号在发行版回移补丁的场景下完全不可靠,而 BTF 是事实。这个习惯养成之后,兼容性代码会干净很多。

第五条,调试加载失败时,先关掉无关变量。把程序简化到只剩一条重定位记录,确认这条能通过,再逐步加回来。直接对着一个几百条重定位记录的程序看日志,效率极低。我一般会临时写一个最小的验证程序,只读一个字段,确认链路通了再回到完整程序。

最后再提一个容易忽略的细节:vmlinux.h是跟着内核版本走的,换了目标内核

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进