本节摘要:一次 eBPF 程序要经历编译、打开对象、创建 Map、加载、挂载、等待事件、通过环形缓冲区或 Map 回传。失败几乎都发生在加载和挂载两步:验证拒绝、程序类型与钩子不匹配、权限不足。把路径记熟,排错才不会在用户态日志里空转。
阅读完本节,你应当能够:
架构图看懂了,仍会在第一次加载时卡住。这一节改走时间轴:你在用户态做了什么,内核在哪一拍说不,成功之后事件怎么回来。
典型 libbpf 风格的路径可以压成八步。名字因框架而异,顺序不异。
两道闸的失败形态不同。验证拒绝:日志里是寄存器状态、未初始化读、无限循环嫌疑。挂载失败:常见是权限、接口不支持该程序类型、cgroup 路径不对、XDP 与网卡驱动能力不匹配。把两者混成一句“invalid argument”,你会空转很久。第 8 章给拆日志的顺序。
内核把程序和 Map 都当成对象,用文件描述符交给用户态。加载器拿着 prog fd 去挂载,拿着 map fd 去更新策略。进程一退出,无人引用的对象就消失——这既是安全特性(崩溃的加载器不会把探针留在内核里),也是运维陷阱(守护进程被杀,观测一起没)。
所以生产加载器几乎总要做两件事里的一件:把自己做成常驻进程;或把对象 pin 到 bpf 文件系统,让后续进程能重新打开。常驻适合“控制面要持续读事件”,pin 适合“程序留下、加载器可以重启”。两者都能做,混用时一定要写清楚所有权,否则会出现“我以为卸掉了,钩子还在”或反过来。
概念上的所有权关系:
加载器进程 ├─ prog fd ──► 内核中的程序对象 ├─ map fd ──► 内核中的 Map 对象 └─ link fd ──► 挂载关系 pin 目录可选地再持有一份引用
没有 link 这个抽象时,老接口把挂载绑在 cgroup 文件或 netlink 上,卸载语义更散。现代代码优先显式 link:创建、销销毁,行为像普通资源。
| 步骤 | 成功时你看见 | 失败时优先查 |
|---|---|---|
| 编译 | 对象文件、BTF 段 | 头文件、内核版本、编译器插件 |
| 打开对象 | Map 与程序列表 | 段名、许可证字符串、BTF 缺失 |
| 创建 Map | map fd | max_entries、key/value 大小 |
| 加载 | prog fd | 验证日志,路径数,未初始化 |
| 挂载 | link fd | 程序类型、权限、网卡/cgroup |
| 回传 | 计数增加或事件流 | 程序其实没被触发;环形缓冲区溢出 |
⚠️ 常见坑:用“加载成功”当“已经在干活”。没挂载、挂错接口、或事件条件从未命中,Map 会一直是零。先做一条无条件计数,证明钩子活着,再加过滤。
💡 关键直觉:fd 和 pin 决定程序的寿命。排“探针怎么还在”或“探针怎么没了”,先查引用还在不在,再查代码。
第 1.3 节的规格最后一步是“条件成立则输出”。工程上我建议拆成两次交付。
第一次:钩子上只做 count++ 到 per-CPU 数组。用户态每秒读一次。能涨,说明挂载点选对了、权限过了、没有被静默卸掉。
第二次:加条件、加调用栈、加环形缓冲区。环形缓冲区满了会丢事件,丢了你还以为故障没发生。所以缓冲区大小、批读取、丢失计数要一起设计。调试阶段用跟踪打印可以,生产热路径不要靠它——它慢,而且和内核跟踪缓冲区抢位置。
/* 概念性:先证明钩子活着 */ SEC("tracepoint/syscalls/sys_enter_connect") int on_connect(void *ctx) { /* 无条件给每 CPU 计数加一,用户态读到增长即证明已触发 */ increment_percpu_counter(0); return 0; }
不要在第一次交付里就解析深层结构体。CO-RE 重定位、空指针、变长路径,都会把“钩子没挂上”和“读字段失败”搅在一起。活着之后再加深,排错才能二分。

看引用。只有进程 fd、没有 pin,通常会走。已 pin 或被别的进程打开,就会留下。不要凭感觉,查 bpf 文件系统和当前加载的程序列表。
per-CPU 数组在用户态要按 CPU 求和。只读了 CPU0,或把带符号数当无符号展示,都会像“乱跳”。先排除消费端,再怀疑程序逻辑。
很多团队的事故单只写“bpf load failed”。下一回的人无法复用经验。一份合格记录至少有:内核版本与 BTF 是否存在、程序类型、挂载目标、验证日志尾 30 行、errno、当时 memlock、是否与平台程序冲突、无条件计数在失败前是否存在过。这些字段够你判断该找验证器、额度还是挂钩点。
自动化可以把记录做进加载器。加载失败不要只打一行 invalid argument,把验证日志存到崩溃报告目录(注意:这里说的是你们自己的日志系统,不要在教程里写具体路径当保存指令)。成功时也要打版本和 link id,方便幽灵探针对账。加载器是控制面,它的可观测性决定内核程序的可观测性。
并发加载值得单独说。两个控制器同时加载同一份策略,可能一个 pin、一个销毁,表现为闪烁。所有权要唯一:要么平台调和循环是唯一加载者,要么人工应急工具在清单里登记为互斥。故障夜两人同时“帮忙”,最容易留下卸不掉的 link。
还有时钟。程序里用的时间戳 Helper 和用户态指标的时间源可能不是同一个。事件对不齐时,先对时源,再怀疑逻辑。启动后的单调时钟适合测耗时,墙上时钟适合和日志对齐,混用会让 8 毫秒的延迟看起来像时间穿越。把时间源写进规格,和第 1.3 节的瞬间一样,属于必须写明的字段。
最后,把“第一次触发”做成发布门禁。灰度节点上若 5 分钟无条件计数仍为零,不是“暂时没流量”,是挂错了。支付节点凌晨可能真没流量,那就要用合成流量把钩子点亮一次。没点亮就扩开,等于把盲飞当成灰度。
一次发布回滚只杀了加载器进程,程序 pin 还在。指标管道以为探针没了,内核还在计数。一周后有人发现 Map 内存没回去。清单对不上,因为清单跟发布系统走,不跟内核对象走。修复是巡检改为以内核对象为准,发布系统来对账,对不上就告警,而不是以发布系统为准去“相信已经回滚”。
引用计数是寿命的真相。进程 fd、pin、另一个调试器打开的 fd,任何一个还在,对象就在。回滚剧本必须点名这三种引用。只点名进程,是在对内核撒谎。撒谎一时爽,内存和 CPU 会在下周的账单里揭穿。
合成流量点亮钩子,也应写进回滚验证:卸完之后心跳必须停。心跳还在,回滚失败,即使进程树看起来很干净。干净的进程树和干净的挂钩点是两件事。值班只看前者时,后者会变成文化里的幽灵故事,直到有人第一次把 bpf 对象列表加进巡检。
发布系统说回滚了,内核对象可能还在。巡检必须列出程序、Map、pin、link,用这份列表去对发布记录,对不上告警。不要用发布记录去想象内核。想象会养幽灵。三种引用都要点名:进程 fd、pin、别人打开的调试 fd。回滚验证包含心跳停止。心跳不停,回滚失败,即使进程树干净。干净的进程树不是干净的挂钩点。灰度门禁里加上第一次触发:五分钟计数仍零且不是零流量时段,禁止扩开。零流量时段用合成流量点亮。没点亮就扩开是盲飞。盲飞偶尔走运,走运不能当流程。流程要假设不走运。不走运时,清单和心跳是仅有的手电筒。手电筒要在白天充电,也就是日常巡检,而不是等停电再找电池。
下一章回答程序在钩子里能把状态放哪、能调用哪些内核入口:Map 与 Helper。