eBPF 全景:原理、工具、应用场景与性能排查

eBPF 让用户把一小段程序交给 Linux 内核,内核先证明这段程序安全,再把它挂到某个事件上,事件发生时在内核态执行,结果通过 map 或缓冲区送回用户态。 全文分四部分:原理拆开这件事里的每个环节(指令集、验证器、JIT、程序类型与挂载、map、helper/kfunc、BTF/CO-RE); 工具讲 bpftrace、BCC、libbpf 的选型和用法;应用场景覆盖可观测性(内存泄漏、CPU、IO、用户态、网络)、网络数据面、安全、内核行为定制; 生产讲开销、限制、容器部署和与其他工具的对比。

适用范围:Linux x86-64,内核 5.x 起的特性为主线。特性标注了引入的主线内核版本,发行版可能回移植,以 bpftool feature probe 的结果为准。 用户态内存泄漏的完整排查流程见 memory-leak.md,本文只展开其中 eBPF 那一段。

实测说明:本文代码与命令均未实测,参数和格式以对应工具版本的 --help 为准。 网络章节中的负载均衡性能数字来自倪朋飞《eBPF 核心技术与实战》课程的 Docker 实验环境,只用于比较量级,不代表生产环境。


目录

# 部分 章节 主题
一 原理 定位:eBPF 解决什么问题 扩展内核的三种方式;发展历程
二 原理 整体架构与生命周期 五个组成模块;从源码到执行;内核对象与引用计数
三 原理 指令集与虚拟机 寄存器、指令格式、调用约定、与系统虚拟机的区别
四 原理 验证器 控制流检查、寄存器状态跟踪、上下文改写、特权分级
五 原理 JIT 与执行环境 JIT、执行上下文、递归保护、可睡眠程序
六 原理 程序类型与挂载机制 程序类型决定什么;各类钩子的实现原理;挂载接口演进
七 原理 map:状态与通信 类型分组、并发语义、ring buffer、全局变量
八 原理 helper、kfunc 与 BTF/CO-RE 内核接口的稳定性分层;CO-RE 重定位过程
九 工具与开发 工具层次与选型 bpftrace / BCC / libbpf / Go / Rust;bpftool
十 工具与开发 bpftrace 速成 语法、内置变量、聚合函数、常用一行脚本
十一 工具与开发 libbpf + CO-RE:手写一个程序 内核态 C、skeleton、用户态加载器、常见报错
十二 应用场景 可观测性:诊断思路与案例 短时进程、丢包、偶发 RST、容器感知、云原生观测平台
十三 应用场景 内存泄漏排查 memleak 原理与参数、bpftrace 自写版、内核泄漏
十四 应用场景 CPU 性能分析 on-CPU 火焰图、off-CPU、调度延迟、函数延迟、PMU
十五 应用场景 内存子系统与 IO 缺页、OOM、page cache、块设备延迟、文件系统慢请求
十六 应用场景 用户态程序跟踪 uprobe C++ 函数、读参数、USDT、TLS 明文、解释型语言
十七 应用场景 网络:跟踪与数据面 连接/重传/丢包工具;收发包钩子、XDP、tc、socket 重定向、四层负载均衡、Cilium
十八 应用场景 安全 分析、检测、阻断;LSM;TOCTOU;eBPF 自身的攻击面
十九 应用场景 内核行为定制 struct_ops、TCP 拥塞控制、sched_ext、HID-BPF
二十 生产与对比 生产环境:开销、限制与容器 各类探针开销、栈回溯、权限、容器部署、局限与应对
二十一 生产与对比 与 perf / ftrace / SystemTap 的比较
二十二 生产与对比 面试速答
— — 附录:特性与内核版本、命令速查、参考资料

一、定位:eBPF 解决什么问题

扩展内核的三种方式

想让内核多做一件事(多统计一个指标、多一条转发规则、多一个安全检查),过去只有两条路;eBPF 是第三条。

修改内核源码并合入主线 内核模块 eBPF
交付周期 以年计:提交、评审、发版、发行版跟进 天级,但要跟每个内核版本重编 分钟级:加载即生效
出错后果 整机 空指针即 panic 验证器拒绝加载,或运行时 helper 返回错误
能力边界 任意 任意 只能在预留的钩子上、通过白名单接口做事
跨内核版本 不涉及 依赖内核头和导出符号,每版重编 CO-RE:编译一次,按目标内核的 BTF 重定位
卸载 不能 rmmod,引用不干净时卸不掉 关闭 fd / 删除 pin 文件,引用计数归零即释放

eBPF 的交换条件是:用能力上的限制换安全和可移植性。内核提供三样东西——钩子(在哪执行)、受限执行环境(验证器 + JIT)、状态存储(map);用户只提供逻辑。

发展历程

时间 事件 意义
1992 McCanne 与 Jacobson 的 BSD Packet Filter 论文(1993 年 1 月 USENIX 冬季会议发表) 在内核里跑用户给的过滤字节码,避免把每个包复制到用户态;tcpdump 的基础
1997 Linux 2.1.75 引入 BPF(Linux Socket Filter)
2011 Linux 3.0 为 BPF 加入 x86-64 JIT 解释执行换成本机指令
2014 Alexei Starovoitov 的 eBPF;3.18 加入 bpf() 系统调用和 map 从"包过滤器"变成通用内核虚拟机
2015–2016 4.1 kprobe、tc;4.7 tracepoint;4.8 XDP;BCC 项目启动 进入跟踪和高性能网络
2017–2018 4.14 sockmap;4.18 BTF、AF_XDP Cilium、Katran 上生产
2019–2020 5.2 指令上限 100 万;5.3 有界循环;5.5 fentry;5.7 LSM;5.8 ring buffer、CAP_BPF 可写复杂程序;进入安全策略执行
2021 eBPF 基金会成立;微软 eBPF for Windows 跨操作系统
2024 6.12 合入 sched_ext 用 eBPF 写 CPU 调度器

命名:现在内核社区说 BPF 就指扩展版;老的只有两个 32 位寄存器的版本称 cBPF(classic BPF)。seccomp 和 tcpdump -d 输出的仍是 cBPF,内核把 cBPF 在加载时转译成 eBPF 执行。


二、整体架构与生命周期

五个组成模块

模块 作用
验证器(verifier) 加载时对字节码做静态分析,证明不会崩溃、不会越界、一定会结束
JIT 编译器 把字节码翻译成本机指令
寄存器与栈 11 个 64 位寄存器 + 512 字节栈,是程序唯一的"私有内存"
helper / kfunc 程序与内核其他子系统交互的唯一接口,可用集合由程序类型决定
map 键值存储,跨程序调用保存状态,也是内核态与用户态交换数据的通道

从源码到执行

flowchart LR
    A["prog.bpf.c"] -->|"clang -target bpf -g -O2"| B["prog.bpf.o<br/>ELF: 字节码 + map 定义 + BTF"]
    B --> C["加载器<br/>libbpf / BCC / bpftrace"]
    C -->|"BPF_MAP_CREATE"| M[("map")]
    C -->|"CO-RE 重定位后<br/>BPF_PROG_LOAD"| V["验证器"]
    V -->|"拒绝:返回日志"| C
    V -->|"通过"| J["JIT"]
    J --> P["程序对象 prog fd"]
    C -->|"挂载:perf_event / netlink /<br/>BPF_PROG_ATTACH / BPF_LINK_CREATE"| H["钩子"]
    P -.-> H
    H -->|"事件发生"| R["执行本机代码"]
    R <-->|"helper"| M
    M <-->|"bpf() 系统调用 / mmap"| U["用户态程序"]

关键点:

  1. 加载和挂载是两步。BPF_PROG_LOAD 只是让内核认可并编译这段代码,得到一个 prog fd;把它挂到钩子上才会被执行。
  2. 程序是事件驱动的。它不是一个常驻线程,没有自己的调度上下文,事件没发生就不占 CPU;一次执行通常几十到几百纳秒。
  3. map 只能由用户态通过系统调用创建。程序里引用 map 时,字节码中是一条特殊的 64 位立即数加载指令,携带 map fd;验证器把它替换成内核中 map 对象的地址。

用 strace 看一次加载

以 BCC 加载一个挂在 do_sys_openat2 上的 kprobe 程序为例,系统调用序列是:

sequenceDiagram
    participant U as 用户态(BCC)
    participant K as 内核
    U->>K: bpf(BPF_PROG_LOAD, {prog_type=KPROBE, insns=[...], license="GPL"})
    K-->>U: prog fd = 4(已通过验证并 JIT)
    U->>K: 读 /sys/bus/event_source/devices/kprobe/type
    K-->>U: "6"(kprobe 这个 PMU 的类型号)
    U->>K: perf_event_open({type=6, config1=&"do_sys_openat2"})
    K-->>U: perf fd = 5(内核在 do_sys_openat2 上注册 kprobe)
    U->>K: ioctl(5, PERF_EVENT_IOC_SET_BPF, 4)
    Note over K: 之后每次调用 do_sys_openat2,<br/>kprobe 处理函数执行 prog 4

复现命令:sudo strace -f -e trace=bpf,perf_event_open,ioctl,openat python3 hello.py。

内核对象与引用计数

prog、map、link、BTF 都是内核对象,用户态持有的是 fd。对象在引用计数归零时释放,引用来源有三种:

引用来源 说明 进程退出后
进程持有的 fd 加载时返回 fd 关闭,引用减一
bpffs 中的 pin 文件 BPF_OBJ_PIN 到 /sys/fs/bpf/xxx 保留;rm 该文件才减一
挂载关系 钩子持有程序 取决于挂载方式,见下

挂载关系是否持有引用决定了"用户进程退出,程序还在不在":

挂载方式 进程退出后程序
kprobe/uprobe/tracepoint(perf_event fd) 随 perf fd 关闭自动卸载
XDP / tc(netlink 方式) 留在网卡上,需 ip link set dev X xdp off 或 tc filter del
cgroup(BPF_PROG_ATTACH) 留在 cgroup 上,需 detach
bpf_link(5.7+,BPF_LINK_CREATE) link fd 关闭即卸载;pin 住 link 可以常驻

bpf_link 统一了语义:"谁持有 link 谁拥有这次挂载"。它还防止别的进程误替换同一钩子上的程序,这对 Cilium 这类常驻 agent 升级时的平滑替换很重要(BPF_LINK_UPDATE 原子替换程序)。


三、指令集与虚拟机

和系统虚拟机不是一回事

KVM 等系统虚拟机 eBPF 虚拟机
指令集 x86/ARM 完整指令集 约百条精简指令,无浮点,无特权指令
目的 模拟一台完整计算机 在内核里安全执行一段受限逻辑
执行 硬件虚拟化 JIT 成宿主机指令直接跑在内核上下文里
内存 独立的客户机物理内存 只有寄存器、512 字节栈、map,访问其他内存必须经 helper
运行时长 长期运行 每次触发执行一遍,必须在有限步内结束

"虚拟机"在这里的准确含义是:一套可以被静态验证、再被 JIT 的中间指令集。

寄存器

寄存器 用途 x86-64 JIT 映射
r0 helper 返回值、程序返回值 rax
r1–r5 helper 调用参数;程序入口时 r1 = 上下文指针 rdi rsi rdx rcx r8
r6–r9 被调用者保存 rbx r13 r14 r15
r10 只读栈帧指针 rbp

映射取自 arch/x86/net/bpf_jit_comp.c 的 reg2hex,实现相关,可能随版本变化。

设计意图:r1–r5 与 x86-64 System V ABI 的前 5 个参数寄存器一一对应,r0 对应返回值寄存器,所以调用 helper 在 JIT 后就是一条原生 call,没有参数搬运。代价是 helper 最多 5 个参数、只有一个返回值。

指令格式

每条指令定长 8 字节(include/uapi/linux/bpf.h 的 struct bpf_insn):

 bit  0        8      12      16              32                              64
      ┌────────┬───────┬───────┬───────────────┬───────────────────────────────┐
      │ opcode │ dst   │ src   │ off (s16)     │ imm (s32)                     │
      │  8 bit │ 4 bit │ 4 bit │ 跳转偏移/内存偏移 │ 立即数                          │
      └────────┴───────┴───────┴───────────────┴───────────────────────────────┘
  • 格式接近 x86-64 的寄存器-寄存器/寄存器-内存形式,JIT 基本是一条对一条(或一条对几条)翻译
  • 程序大小上限:5.2 之前 4096 条,5.2 起特权程序 100 万条
  • opcode 低 3 位是指令类:LD、LDX、ST、STX、ALU、JMP、JMP32、ALU64
  • 唯一的 16 字节指令是 ld_imm64(加载 64 位立即数),占两个槽位。所以 bpftool prog dump xlated 输出的指令编号会跳一格;map 引用也用这条指令表达
  • 例:(b7) r1 = 33 中 0xb7 = ALU64 | MOV | K(把立即数写入 64 位寄存器);(85) call bpf_trace_printk 中 0x85 = JMP | CALL

查看一个已加载程序的字节码和 JIT 结果:

sudo bpftool prog list                      # 找到 id
sudo bpftool prog dump xlated id <ID>       # 验证器改写后的字节码,带源码行注释(需要 BTF)
sudo bpftool prog dump jited  id <ID>       # JIT 后的 x86 指令

函数调用的三种形态

形态 引入 语义 限制
helper 调用 3.18 调内核提供的固定函数 可用集合由程序类型决定
BPF-to-BPF 调用 4.16 程序内部的子函数调用,会返回 调用深度 8 层;所有帧的栈合计 512 字节
tail call 4.2 bpf_tail_call(ctx, &prog_array, idx) 跳到另一个程序,不返回,复用当前栈 最多 33 次(5.17 前为 32)

tail call 的用途:把大程序拆成流水线(Cilium 按协议分派到不同的处理程序),以及不停机替换其中一段(更新 PROG_ARRAY 中的一项即可)。


四、验证器

验证器(kernel/bpf/verifier.c)是 eBPF 能进内核的前提。它要在加载时、不运行程序的前提下,证明程序对所有输入都满足:

  • 一定结束(没有无界循环)
  • 不读未初始化的寄存器和栈
  • 每次内存访问都在合法对象的边界内
  • 只调用该程序类型允许的 helper,参数类型匹配
  • 不向用户态泄露内核指针(非特权模式)

第一遍:控制流检查

对指令做深度优先遍历,构建控制流图:

  • 存在从未到达的指令 → 拒绝(unreachable insn)
  • 跳转目标越界 → 拒绝
  • 5.3 之前:存在回边(循环)→ 拒绝,循环只能靠编译器 #pragma unroll 展开
  • 5.3 起:允许回边,交给第二遍证明循环有界

第二遍:沿所有路径模拟执行

验证器为每个寄存器和每个栈槽维护一个抽象状态,逐条指令推演,遇到分支两边都走:

状态字段 含义
类型 NOT_INIT、SCALAR_VALUE、PTR_TO_CTX、PTR_TO_STACK、PTR_TO_MAP_VALUE、PTR_TO_MAP_VALUE_OR_NULL、PTR_TO_PACKET、PTR_TO_PACKET_END、PTR_TO_BTF_ID 等
取值范围 有符号/无符号的最小最大值(smin/smax/umin/umax)
已知位 tnum:哪些位已知为 0/1、哪些未知,用于跟踪 &、>> 之后的精确范围
指针偏移 指针相对对象起点的偏移(常量部分 + 变量范围)

两个典型例子。

例 1:map 查找必须判空。

struct val* v = bpf_map_lookup_elem(&m, &key);   // r0 的类型: PTR_TO_MAP_VALUE_OR_NULL
v->cnt++;                                        // 拒绝: invalid mem access 'map_value_or_null'
struct val* v = bpf_map_lookup_elem(&m, &key);
if (!v) return 0;                                // 在"非空"分支上,r0 被改标为 PTR_TO_MAP_VALUE
v->cnt++;                                        // 通过,且偏移 < value_size 已被证明

例 2:报文访问必须先比较 data_end。

void* data     = (void*)(long)ctx->data;         // PTR_TO_PACKET, off=0, range=0
void* data_end = (void*)(long)ctx->data_end;     // PTR_TO_PACKET_END
struct ethhdr* eth = data;
if ((void*)(eth + 1) > data_end) return XDP_DROP;
// 在不越界的分支上,验证器把 data 的可访问范围记为 14 字节
// 之后读 eth->h_proto(偏移 12,宽 2)才能通过

验证器不知道包有多长,它只认"你已经和 data_end 比过"。这就是所有 XDP/tc 程序里到处是边界检查的原因。

验证器拒绝时返回的是一段模拟执行日志(libbpf 会打印出来),最后几行是原因,例如 invalid mem access 'map_value_or_null'(忘了判空)、back-edge from insn X to Y(5.3 前的循环)、R1 offset is outside of the packet(报文越界)。常见报错对照见第十一章。

复杂度控制

  • 路径数随分支指数增长。验证器在跳转目标处保存已验证过的状态,新到达的状态如果被某个旧状态"包含"(范围更窄、类型相同),就剪掉这条路径(state pruning)
  • 上限:已处理指令数 100 万条(5.2 起,BPF_COMPLEXITY_LIMIT_INSNS);超过报 BPF program is too large
  • 循环写法演进:#pragma unroll(任何版本)→ 有界循环(5.3)→ bpf_loop() helper(5.17,回调形式,循环体只验证一次)→ open-coded 迭代器 bpf_for(6.4)

上下文访问改写

每种程序类型给程序看的上下文结构(如 struct __sk_buff、struct xdp_md、struct bpf_sock_ops)是 UAPI 里的"影子结构",字段固定;内核里真实的结构(struct sk_buff、struct xdp_buff)字段和布局随版本变化。

验证器在通过后执行 convert_ctx_accesses:把"读 __sk_buff 偏移 X"改写成"读 sk_buff 真实偏移 Y"。所以:

  • 网络程序访问上下文字段不需要 CO-RE,天然跨版本
  • bpftool prog dump xlated 看到的是改写后的指令,和 clang 输出的不一样

特权分级

能力 需要的权限(5.8 起)
加载任意类型程序 CAP_BPF
跟踪类程序、读内核内存 CAP_BPF + CAP_PERFMON
网络类程序(XDP、tc 等) CAP_BPF + CAP_NET_ADMIN
非特权加载(socket filter 等少数类型) 受 kernel.unprivileged_bpf_disabled 控制,主流发行版默认禁止

5.8 之前统一要求 CAP_SYS_ADMIN。非特权模式下验证器额外做:禁止指针与标量互转、禁止把指针写进 map、对指针运算插入 Spectre 防护掩码。

验证器证明不了什么

  • 逻辑正确性:它只证明安全,不证明程序做了对的事。XDP 程序改错了 MAC 地址照样通过
  • 可证明但被拒绝:clang 优化可能把边界检查和访问拆到验证器跟丢的形式(例如先把偏移存到另一个寄存器再比较)。常见应对是 __always_inline、把长度与常量 & 一下收窄范围、调整代码顺序
  • 验证器自身的漏洞:历史上多个本地提权 CVE 出自验证器的范围推演错误(如 CVE-2021-3490、CVE-2021-31440)。这是发行版默认关闭非特权 eBPF 的主要原因

五、JIT 与执行环境

JIT

配置 作用
net.core.bpf_jit_enable=1 开启 JIT(主流发行版默认)
CONFIG_BPF_JIT_ALWAYS_ON=y 编译时去掉解释器,防止 Spectre v2 利用解释器做 gadget
net.core.bpf_jit_harden=1/2 常量盲化:把立即数拆成两个数异或,防 JIT spray(1 只对非特权,2 对所有)
net.core.bpf_jit_kallsyms=1 JIT 后的程序出现在 /proc/kallsyms,perf 能解析到 bpf_prog_<tag>_<name>

主流 64 位架构(x86-64、arm64、riscv64、s390x、ppc64、loongarch 等)都有 JIT。

执行上下文

程序在触发它的那个内核执行流里同步运行,没有自己的线程:

钩子 运行在
syscall tracepoint、大部分 kprobe 触发系统调用的进程上下文
XDP、tc ingress 软中断(NAPI poll)
perf_event 采样 硬件 PMU 溢出的 NMI 上下文
uprobe 被跟踪进程陷入内核后的上下文

共同约束:

  • 执行期间持有 RCU 读锁、禁止迁移 CPU。per-CPU map 正是依赖"一次执行不会换 CPU"
  • 不能睡眠:不能缺页、不能拿可睡眠的锁。bpf_probe_read_user 遇到缺页不会触发换入,而是直接返回错误并把目标缓冲区清零——这就是为什么读用户态字符串偶尔拿到空值
  • 5.10 起的可睡眠程序(SEC("lsm.s/...")、SEC("fentry.s/...")、SEC("uprobe.s/..."))可以调用 bpf_copy_from_user 这类会缺页的 helper,只允许挂在本身可睡眠的钩子上

递归保护

一个 kprobe 程序里调用的 helper 如果本身又触发了 kprobe,会无限递归。内核用 per-CPU 计数 bpf_prog_active(及每个程序的 active 计数)防止同一 CPU 上重入:重入时直接跳过执行,计入 bpftool prog show 输出的 recursion_misses。结论:高频钩子上的事件可能被静默丢掉,统计类工具要看这个计数。


六、程序类型与挂载机制

程序类型决定四件事

bpf_prog_type(5.13 有 30 种,之后持续增加)决定:

决定项 例子
上下文 r1 指向什么 kprobe → struct pt_regs;XDP → struct xdp_md;tc → struct __sk_buff
能调用哪些 helper 只有 kprobe 类能调 bpf_override_return;只有网络类能调 bpf_redirect
能挂到哪里 XDP 只能挂网卡;cgroup_skb 只能挂 cgroup
返回值的含义 XDP:动作码;tc:TC_ACT_*;cgroup:1 放行 / 0 拒绝;kprobe:被忽略

libbpf 用 ELF 段名(SEC("..."))推断程序类型和挂载点,所以段名不是随意取的。

三大类

跟踪类:从内核和应用中提取状态,不改变系统行为。

类型 段名示例 上下文 挂载点
KPROBE kprobe/tcp_v4_connect、uprobe//usr/bin/bash:readline、usdt/... pt_regs 任意内核/用户函数
TRACEPOINT tracepoint/syscalls/sys_enter_execve tracepoint 格式化后的结构 静态跟踪点
RAW_TRACEPOINT raw_tp/sched_switch bpf_raw_tracepoint_args(u64 args[]) 静态跟踪点,不格式化
TRACING fentry/、fexit/、tp_btf/、fmod_ret/、iter/ BTF 类型化的原始参数 内核函数入口/出口、跟踪点、迭代器
PERF_EVENT perf_event bpf_perf_event_data 定时采样、PMU 计数器

网络类:处理和控制数据包。

类型 段名示例 上下文 挂载点
XDP xdp xdp_md 网卡驱动收包路径
SCHED_CLS tc、tcx/ingress __sk_buff tc 入向/出向
SOCKET_FILTER socket __sk_buff 单个 socket(SO_ATTACH_BPF)
SOCK_OPS sockops bpf_sock_ops cgroup:TCP 连接建立、RTO、状态变化等事件
SK_SKB / SK_MSG sk_skb/stream_verdict、sk_msg __sk_buff / sk_msg_md sockmap:socket 间重定向
CGROUP_SKB cgroup_skb/ingress __sk_buff cgroup 内进程收发的包
CGROUP_SOCK_ADDR cgroup/connect4、cgroup/sendmsg4 bpf_sock_addr cgroup 内进程的 connect/bind/sendmsg
SK_REUSEPORT sk_reuseport sk_reuseport_md 在 SO_REUSEPORT 组里选 socket
FLOW_DISSECTOR flow_dissector __sk_buff 替换内核的包头解析

其他类:安全控制与内核扩展。

类型 用途
LSM 挂到 LSM 钩子,返回负错误码即拒绝操作(5.7)
CGROUP_DEVICE 控制 cgroup 内进程能访问哪些设备(cgroup v2 的设备控制就是它)
CGROUP_SYSCTL / CGROUP_SOCKOPT 控制 sysctl 读写、setsockopt
STRUCT_OPS 用 BPF 实现内核的一组函数指针(TCP 拥塞控制、sched_ext)
EXT 替换另一个 BPF 程序中的某个函数(freplace),用于程序热替换
SYSCALL 在内核里执行一系列 bpf() 操作(轻量加载器)

同一个事件,为什么上下文各不相同

以内核函数 __set_task_comm()(进程改名)为例,它内部调用了 tracepoint trace_task_rename()。可以用三种方式跟踪,拿到的参数完全不同:

方式 上下文 原因
kprobe/__set_task_comm struct pt_regs* kprobe 通过断点(或跳转)进入内核的异常/ftrace 路径,内核把当时的寄存器保存成 pt_regs。按 x86-64 调用约定,第一个参数在 regs->di,要自己解释
tracepoint/task/task_rename 按 /sys/kernel/tracing/events/task/task_rename/format 布局的结构 tracepoint 由 TRACE_EVENT 宏定义,调用时先把参数按 TP_STRUCT__entry 复制成一条记录,程序拿到的就是这条记录(pid、oldcomm、newcomm…)
raw_tp/task_rename bpf_raw_tracepoint_args,即 u64 args[] 跳过格式化,直接给 TP_PROTO 里的原始参数(task_struct*、const char*),开销更低,能拿到整个 task_struct
tp_btf/task_rename / fentry/__set_task_comm 带 BTF 类型的原始参数 验证器知道参数类型,程序可以直接解引用 task->pid,无需 bpf_probe_read_kernel

写 eBPF 程序的难点不在语法,而在知道内核里有哪些钩子、每个钩子能拿到什么。

跟踪钩子的实现原理与开销

钩子 实现 未启用时的开销 触发一次的开销(量级)
tracepoint 编译进内核的调用点,由 static key 控制:未启用时是一条 nop,启用时改写成跳转 一条 nop 低
kprobe 在目标指令处写入 int3;可优化时替换为 jmp 到跳板(optprobe);在函数入口且内核启用 ftrace 时走 ftrace 路径 0 百纳秒级
fentry/fexit(5.5) 函数入口有编译器插入的 5 字节 nop(-mfentry),改写成 call 到 BPF trampoline,trampoline 直接调用 BPF 程序 一条 nop 低于 kprobe(无异常处理、无 pt_regs 保存)
uprobe 在用户进程代码页写入 int3(写时复制出该进程私有的页),命中后陷入内核执行程序,再在一个"越界执行槽"(XOL)里单步执行被替换的原指令 0 微秒级(两次内核态/用户态切换)
USDT 应用在源码里用 DTRACE_PROBE 等宏埋点,编译成一条 nop,位置和参数描述记在 ELF 的 .note.stapsdt 段;启用时按 uprobe 方式改成 int3 一条 nop 同 uprobe
perf_event PMU 计数溢出触发 NMI,或定时器触发 0 与采样频率成正比

由此得到的选型规则:

  • 有 tracepoint 用 tracepoint(稳定 ABI);没有再用 kprobe;新内核用 fentry 代替 kprobe
  • kprobe 挂不到被内联的函数;编译器生成的 .isra.0、.constprop.0 等后缀函数要用带后缀的名字挂
  • uprobe 不要挂在 malloc、锁这类每秒百万次的函数上,开销会叠加到被跟踪进程的每一次调用上
  • 用户态优先 USDT,其次 uprobe

各类探针开销的具体量级和估算方法见第二十章。

查可用的钩子

ls /sys/kernel/tracing/events/                         # tracepoint 分类
cat /sys/kernel/tracing/events/syscalls/sys_enter_openat/format   # 参数格式
bpftrace -l 'tracepoint:sched:*'                       # bpftrace 列出
bpftrace -l 'kprobe:tcp_*' | head
bpftrace -l 'uprobe:/lib/x86_64-linux-gnu/libc.so.6:*' | wc -l
bpftrace -lv 'tracepoint:syscalls:sys_enter_openat'    # -v 显示参数
sudo bpftool feature probe | grep program_type         # 当前内核支持的程序类型

挂载接口的演进

接口 适用 说明
perf_event_open + ioctl(PERF_EVENT_IOC_SET_BPF) kprobe、uprobe、tracepoint、perf_event 最早的方式,见第二章的时序图
BPF_RAW_TRACEPOINT_OPEN raw_tp、fentry/fexit、LSM 返回一个 fd,关闭即卸载
netlink(ip link / tc) XDP、tc 挂在网络设备上,不随进程退出
BPF_PROG_ATTACH cgroup 类、sockmap 类、flow dissector 挂在 cgroup / map / netns 上
BPF_LINK_CREATE(5.7 起逐步覆盖各类型) 几乎所有类型 统一为 link 对象;kprobe_multi(5.18)一次挂上千个函数;tcx(6.6)替代 tc 的 netlink 挂载

七、map:状态与通信

eBPF 程序每次执行都从空的寄存器和栈开始,跨调用的一切状态都在 map 里。map 同时是用户态与程序、程序与程序之间的共享内存。

类型分组

用途 类型 要点
通用键值 HASH、LRU_HASH、ARRAY ARRAY 预分配、按下标访问、不能删除元素;LRU_HASH 满了淘汰最久未用的,适合连接表
无竞争计数 PERCPU_HASH、PERCPU_ARRAY 每个 CPU 一份值,写不需要原子操作;用户态读到的是 N 份,自己求和。也常用作超过 512 字节的"临时堆"
事件输出 PERF_EVENT_ARRAY、RINGBUF(5.8) 向用户态推送逐条事件
调用栈 STACK_TRACE bpf_get_stackid() 存栈并返回 id,相同栈去重
程序组合 PROG_ARRAY、ARRAY_OF_MAPS、HASH_OF_MAPS tail call 跳转表;map 嵌套(按租户/按 CPU 换内层 map)
网络转发 DEVMAP、CPUMAP、XSKMAP XDP_REDIRECT 的目标:另一个网卡 / 另一个 CPU / AF_XDP socket
socket SOCKMAP、SOCKHASH 存 socket 引用,用于 sk_msg/sk_skb 重定向
路由匹配 LPM_TRIE 最长前缀匹配,CIDR 规则
cgroup 判断 CGROUP_ARRAY 配合 bpf_current_task_under_cgroup() 判断进程是否属于某个 cgroup
对象附属存储 TASK_STORAGE、SK_STORAGE、INODE_STORAGE、CGRP_STORAGE 把数据挂在内核对象上,对象销毁时自动回收,无需自己按 pid 做 key 再清理
共享内存 ARENA(6.9) 程序与用户态共享的一块稀疏内存,程序里可以用普通指针构建数据结构
队列 QUEUE、STACK、BLOOM_FILTER

并发语义

  • bpf_map_lookup_elem 返回的是指向 map 内部值的指针,不是拷贝。多个 CPU 同时拿到同一个指针再 v->cnt++,就是普通的数据竞争,会丢计数
  • 三种解法:用 PERCPU_* 类型(首选);用 __sync_fetch_and_add(&v->cnt, 1)(编译成 BPF 原子指令);值里嵌 struct bpf_spin_lock 配合 bpf_spin_lock()/bpf_spin_unlock()(锁内不能调用其他 helper)
  • bpf_map_update_elem 对哈希表是整元素替换(RCU),读者要么看到旧值要么看到新值
  • 哈希表默认预分配全部元素(max_entries 个),因为程序可能运行在 NMI 等不能分配内存的上下文里;BPF_F_NO_PREALLOC 关闭预分配,省内存但不能用于 NMI 上下文的程序

perf buffer 与 ring buffer

PERF_EVENT_ARRAY(perf buffer) RINGBUF(5.8)
结构 每个 CPU 一个环形缓冲区 所有 CPU 共享一个
顺序 跨 CPU 无序,用户态要按时间戳重排 按提交顺序
内存 按最坏情况给每个 CPU 分配 共享一份,利用率高
写法 先在栈上构造事件再 bpf_perf_event_output 拷贝 bpf_ringbuf_reserve 拿到缓冲区指针直接写,再 submit,少一次拷贝
满了 丢事件,用户态收到 lost 计数 reserve 返回 NULL,程序自己决定

新程序优先用 ring buffer。两者都会丢事件,统计类需求应先在内核里聚合进 map,只把结果交给用户态(例如直方图只传桶计数)。

全局变量

5.2 起,C 代码里的全局变量被 clang 放进 .bss、.data、.rodata 段,libbpf 把每个段创建成一个单元素 ARRAY map:

  • 用户态通过 skeleton 直接读写 skel->bss->counter(底层是 mmap)
  • .rodata 在加载前由用户态填好,加载时冻结。验证器把它当常量,不可达的分支会被当作死代码剪掉。常用来做"加载时配置":const volatile pid_t target_pid = 0; 用户态设置后,if (target_pid && pid != target_pid) 在未设置时整段消失

八、helper、kfunc 与 BTF/CO-RE

内核接口的稳定性分层

接口 稳定性 说明
helper 稳定 UAPI,一旦加入不删除不改签名 man bpf-helpers;数量二百多个;按程序类型开放
kfunc(5.13) 不保证稳定,可能随版本改变或删除 内核函数用 BTF 标注后允许 BPF 直接调用。新功能基本都以 kfunc 形式加入,避免 helper 表无限膨胀
tracepoint 格式 事实上稳定 内核尽量不改已有 tracepoint 的字段
kprobe 目标函数、内核结构体布局 不稳定 靠 CO-RE 适配

常用 helper:

类别 helper
map bpf_map_lookup_elem、bpf_map_update_elem、bpf_map_delete_elem
读内存 bpf_probe_read_kernel、bpf_probe_read_user、bpf_probe_read_*_str
当前任务 bpf_get_current_pid_tgid、bpf_get_current_uid_gid、bpf_get_current_comm、bpf_get_current_task_btf、bpf_get_current_cgroup_id
时间 bpf_ktime_get_ns、bpf_ktime_get_boot_ns
栈 bpf_get_stackid、bpf_get_stack
输出 bpf_ringbuf_output/reserve/submit、bpf_perf_event_output、bpf_printk(写 /sys/kernel/tracing/trace_pipe,仅调试)
网络 bpf_redirect、bpf_redirect_map、bpf_skb_store_bytes、bpf_l3_csum_replace、bpf_fib_lookup、bpf_sk_lookup_tcp
干预 bpf_send_signal(5.3)、bpf_override_return(4.16,需 CONFIG_BPF_KPROBE_OVERRIDE,只对标注了 ALLOW_ERROR_INJECTION 的函数有效)、bpf_probe_write_user

bpf_probe_read_* 存在的原因:程序里的指针(比如从 pt_regs 里取出来的 task_struct*)对验证器来说只是一个整数,它无法证明这个地址可读。这组 helper 在内部做异常保护的拷贝,读失败返回错误而不是崩溃。

BTF:内核自带的类型信息

BTF(BPF Type Format)是精简版的 DWARF。内核编译时开启 CONFIG_DEBUG_INFO_BTF=y(5.2 起支持;Ubuntu 20.10+、RHEL 8.2+ 等默认开启),所有内核类型会嵌入 vmlinux;5.4 起通过 /sys/kernel/btf/vmlinux 暴露给用户态。

BTF 的用途:

用途 说明
vmlinux.h bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h,一个头文件替代所有内核头
CO-RE 重定位 见下
类型化指针 fentry / tp_btf / LSM 程序的参数是 PTR_TO_BTF_ID,验证器知道结构布局,允许直接解引用
kfunc 靠 BTF 描述函数签名
可读的 map bpftool map dump 按结构体字段打印,而不是十六进制

没有 BTF 时(BCC 的做法):目标机器上必须装 linux-headers-$(uname -r) 和 LLVM,每次运行现场编译。问题是生产机常不允许装头文件、启动慢(秒级、占几百 MB 内存)、头文件和运行内核不一致时读错字段。

CO-RE:编译一次,到处运行

问题:程序要读 task->real_parent->tgid,而 tgid 在 task_struct 里的偏移在不同内核版本、不同编译配置下都不同。

#include "vmlinux.h"
#include <bpf/bpf_core_read.h>

pid_t ppid = BPF_CORE_READ(task, real_parent, tgid);
flowchart TD
    A["编译期:clang 遇到 BPF_CORE_READ<br/>(__builtin_preserve_access_index)"] --> B["生成重定位记录<br/>类型 task_struct,访问路径 real_parent→tgid<br/>写入 .BTF.ext 段"]
    B --> C["加载期:libbpf 读目标机 /sys/kernel/btf/vmlinux"]
    C --> D["按结构名 + 字段名匹配<br/>算出目标内核上的真实偏移"]
    D --> E["改写字节码里的偏移立即数"]
    E --> F["BPF_PROG_LOAD"]

结果是一个几十 KB 的静态二进制,目标机不需要内核头文件和编译器。

CO-RE 还能处理:

  • 字段在某些版本不存在:bpf_core_field_exists(task->some_field),加载时变成常量 0/1,另一个分支被剪掉
  • 字段改名:定义 struct task_struct___old { ... }(三下划线后缀会被忽略),分别尝试新旧两种布局
  • 枚举值、类型大小变化:bpf_core_enum_value、bpf_core_type_size

老内核没有内置 BTF:可以用 BTFHub 提供的按发行版内核预生成的 BTF 文件,加载时通过 libbpf 的 btf_custom_path 指定。


九、工具层次与选型

 上手快 ◀───────────────────────────────────────────────▶ 控制强

 bpftrace          BCC 工具集          BCC Python API       libbpf + CO-RE
 一行脚本          现成的 100+ 命令     Python + 内嵌 C       纯 C,静态二进制
 临时排查          日常性能分析         定制工具              产品级、长期运行
 需要 LLVM         需要内核头 + LLVM    同左                  只需 clang 编译一次
方式 内核态 用户态 编译时机 目标机依赖 适合
bpftrace DSL 内置 运行时 bpftrace(内含 LLVM) 一行脚本排查、验证想法
BCC C(内嵌字符串) Python / Lua / C++ 运行时 LLVM + 内核头 现成工具(execsnoop、biolatency…)、快速原型
libbpf + CO-RE C C(skeleton) 开发时一次 内核有 BTF 产品级、常驻 agent、静态二进制分发
cilium/ebpf C Go(纯 Go 加载器) 开发时 同上 Go 写的云原生组件(Cilium、Tetragon 等)
libbpf-rs C Rust 开发时 同上 Rust 用户态
Aya Rust Rust 开发时 同上 两端都用 Rust,不依赖 libbpf
内核 samples/bpf、tools/testing/selftests/bpf C C — — 查某个特性的最小可用例子

BCC、bpftrace 的运行时编译要在目标机上带 LLVM(几十到上百 MB),启动要几秒;libbpf 程序开发时编译好,启动是毫秒级。

Ubuntu 上的包名:bpftrace、bpfcc-tools(命令带 -bpfcc 后缀,如 memleak-bpfcc)、libbpf-dev、linux-tools-$(uname -r)(含 bpftool)。

工作流:先用 bpftrace 或 BCC 验证"这个钩子能拿到我要的数据",再用 libbpf 写正式版本分发(第十一章)。

编译器:LLVM 3.7 起支持 BPF 后端,是事实标准;GCC 10 起也有 BPF 后端,大型项目使用较少。

bpftool:查看与管理

sudo bpftool prog list                          # 已加载的程序、类型、加载时间、关联 map
sudo bpftool prog dump xlated id 42             # 查看验证器处理后的字节码
sudo bpftool prog dump jited id 42              # 查看 JIT 后的本机指令
sudo bpftool prog profile id 42 duration 10 cycles instructions   # 程序自身的开销
sudo bpftool map list
sudo bpftool map dump id 7                      # 读 map 内容
sudo bpftool btf dump file /sys/kernel/btf/vmlinux format c | head
sudo bpftool feature probe                      # 内核支持哪些程序类型/helper
sudo bpftool net list                           # XDP/tc 上挂了什么

十、bpftrace 速成

程序结构

probe[,probe...] /filter/ { action }
# 三个基本元素:事件源、过滤条件、动作
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat /comm == "nginx"/ { printf("%s\n", str(args->filename)); }'

探针写法

写法 含义
kprobe:tcp_sendmsg / kretprobe:tcp_sendmsg 内核函数入口/返回
kfunc:tcp_sendmsg / kretfunc:... fentry/fexit,参数可以按名字取(args->sk)
tracepoint:syscalls:sys_enter_read 静态 tracepoint,参数 args->fd
uprobe:/bin/bash:readline / uretprobe:... 用户态函数;库可以写 uprobe:libc:malloc
uprobe:/path/bin:0x1234 按偏移
usdt:/path/bin:provider:probe USDT
profile:hz:99 每 CPU 每秒 99 次定时采样
interval:s:5 单 CPU 每 5 秒一次,用于周期性打印
software:page-faults:1 / hardware:cache-misses:1000000 perf 软件/硬件事件,数字是采样周期
BEGIN / END 脚本开始/结束
watchpoint:0xaddr:8:w 内存写监视点
iter:task 遍历内核对象

通配符:kprobe:tcp_*、uprobe:libc:*alloc*。

内置变量

变量 含义
pid / tid 进程 id(tgid)/ 线程 id
comm 进程名(16 字节)
uid / gid / cpu / nsecs / elapsed 用户、CPU 号、纳秒时间戳、脚本启动以来的纳秒
arg0..argN kprobe/uprobe 的参数(按寄存器)
args tracepoint / kfunc / USDT 的结构化参数(args->filename)
retval kretprobe/uretprobe 返回值
kstack / ustack 内核/用户调用栈;ustack(6) 限制深度;ustack(perf) 换格式
func / probe 当前函数名 / 探针名
curtask 当前 task_struct*,可以 curtask->mm->...
$1, $2 / str($1) 命令行位置参数
cgroup 当前 cgroup id

聚合与函数

@name[key] = 聚合函数(值),脚本结束自动打印所有 map:

函数 作用
count() 计数
sum(x) / avg(x) / min(x) / max(x) 求和/平均/最小/最大
hist(x) 2 的幂直方图
lhist(x, min, max, step) 线性直方图
stats(x) count + avg + total
delete(@m[k]) / clear(@m) / zero(@m) 删除项 / 清空 / 归零
print(@m) / print(@m, top) 立即打印(配合 interval)
printf / time("%H:%M:%S ") / strftime 输出
str(ptr) / str(ptr, len) 读字符串
ksym(addr) / usym(addr) 地址转符号
ntop(addr) IP 转字符串
system("cmd") / cat("file") / exit() 需要 --unsafe

作用域变量 $x 只在一个动作块内有效;@x 是全局 map。

常用一行脚本

# 谁在执行新程序
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%-6d %-16s %s\n", pid, comm, str(args->filename)); }'

# 系统调用按进程计数
sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'

# read() 返回字节数分布
sudo bpftrace -e 'tracepoint:syscalls:sys_exit_read /args->ret > 0/ { @bytes = hist(args->ret); }'

# 某内核函数的延迟直方图(入口记时间戳,返回相减)
sudo bpftrace -e 'kprobe:vfs_read { @s[tid] = nsecs; }
                  kretprobe:vfs_read /@s[tid]/ { @us = hist((nsecs - @s[tid]) / 1000); delete(@s[tid]); }'

# 磁盘 IO 大小
sudo bpftrace -e 'tracepoint:block:block_rq_issue { @bytes = hist(args->bytes); }'

# 新建 TCP 连接
sudo bpftrace -e 'kretprobe:inet_csk_accept { @[comm] = count(); }'

# 谁在调 malloc(按调用栈)
sudo bpftrace -e 'uprobe:libc:malloc /pid == 1234/ { @[ustack(5)] = count(); }'

# CPU 采样火焰图原料
sudo bpftrace -e 'profile:hz:99 { @[kstack, ustack, comm] = count(); }' > out.stacks

# 每 5 秒打印一次 top 5 然后清零
sudo bpftrace -e 'kprobe:do_sys_openat2 { @[comm] = count(); }
                  interval:s:5 { print(@, 5); clear(@); }'

脚本文件

#!/usr/bin/env bpftrace
// vfsreadlat.bt — 用法:sudo ./vfsreadlat.bt <pid>
BEGIN { printf("Tracing vfs_read latency for pid %d... Ctrl-C to end\n", $1); }
kprobe:vfs_read /pid == $1/ { @start[tid] = nsecs; }
kretprobe:vfs_read /@start[tid]/ {
    @us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}
END { clear(@start); }

bpftrace 仓库的 tools/ 目录有几十个现成脚本(bashreadline.bt、biolatency.bt、runqlat.bt、tcpconnect.bt 等)。


十一、libbpf + CO-RE:手写一个程序

以"统计每个进程调 openat 的次数"为例。分内核态和用户态两个文件。

内核态:count.bpf.c

#include "vmlinux.h"                 // 从 BTF 导出,替代所有内核头
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";   // 部分 helper 只对 GPL 程序开放

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);                    // pid
    __type(value, u64);                  // 次数
} counts SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_openat")
int handle_openat(struct trace_event_raw_sys_enter* ctx)
{
    u32 pid = bpf_get_current_pid_tgid() >> 32;    // 高 32 位是 tgid
    u64 one = 1;
    u64* v = bpf_map_lookup_elem(&counts, &pid);
    if (v)
        __sync_fetch_and_add(v, 1);                 // 原子加,多 CPU 并发安全
    else
        bpf_map_update_elem(&counts, &pid, &one, BPF_NOEXIST);
    return 0;
}

要点:

  • SEC("...") 决定程序类型和挂载点,libbpf 按名字自动挂载
  • map 用 SEC(".maps") 声明,libbpf 加载时自动创建
  • bpf_map_lookup_elem 返回值必须判空,否则验证器拒绝
  • 读内核结构字段用 BPF_CORE_READ(task, mm, start_brk) 宏,CO-RE 会重定位偏移;直接 task->mm->start_brk 在 tracepoint/kprobe 程序里不通过验证器(fentry/tp_btf 可以)

编译与生成 skeleton

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c count.bpf.c -o count.bpf.o
bpftool gen skeleton count.bpf.o > count.skel.h

-g 必须加,BTF 和 CO-RE 重定位信息来自 DWARF。skeleton 是一个头文件,把 ELF 对象嵌进去并生成 count_bpf__open() 等函数。

用户态:count.c

#include <stdio.h>
#include <unistd.h>
#include <bpf/libbpf.h>
#include "count.skel.h"

int main(void)
{
    struct count_bpf* skel = count_bpf__open_and_load();   // 创建 map、CO-RE 重定位、验证器、JIT
    if (!skel) { fprintf(stderr, "load failed\n"); return 1; }
    if (count_bpf__attach(skel)) { fprintf(stderr, "attach failed\n"); return 1; }

    printf("counting openat per pid, Ctrl-C to stop\n");
    for (;;) {
        sleep(5);
        int fd = bpf_map__fd(skel->maps.counts);
        __u32 key, next; __u64 val;
        for (key = 0; bpf_map_get_next_key(fd, &key, &next) == 0; key = next) {
            bpf_map_lookup_elem(fd, &next, &val);
            printf("pid %-7u %llu\n", next, val);
        }
        printf("----\n");
    }
    count_bpf__destroy(skel);
    return 0;
}
clang -O2 -g count.c -o count -lbpf -lelf -lz
sudo ./count

向用户态推事件:ring buffer

计数用 map 够了;要把每个事件(含时间戳、pid、文件名)送到用户态时用 ring buffer:

// 内核态
struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); } rb SEC(".maps");
struct event { u32 pid; char comm[16]; char fname[128]; };

SEC("tracepoint/syscalls/sys_enter_openat")
int handle(struct trace_event_raw_sys_enter* ctx) {
    struct event* e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
    if (!e) return 0;                                       // 满了就丢
    e->pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(e->comm, sizeof(e->comm));
    bpf_probe_read_user_str(e->fname, sizeof(e->fname), (void*)ctx->args[1]);
    bpf_ringbuf_submit(e, 0);
    return 0;
}
// 用户态
static int on_event(void* ctx, void* data, size_t len) {
    struct event* e = data;
    printf("%-7u %-16s %s\n", e->pid, e->comm, e->fname);
    return 0;
}
struct ring_buffer* rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), on_event, NULL, NULL);
while (1) ring_buffer__poll(rb, 100 /* ms */);

ring buffer 与 perf buffer 的对比见第七章。

开发中的常见报错

现象 原因
invalid mem access 'map_value_or_null' lookup 返回值没判空
back-edge from insn 循环无界;改用 #pragma unroll 或 bpf_loop()(5.17)
R1 type=inv expected=fp 给 helper 传了标量而不是栈指针
program too large / BPF program is too large 超出指令上限,拆成 tail call
failed to find BTF for extern 目标机内核没有 BTF(CONFIG_DEBUG_INFO_BTF),需要 BTFHub 的外部 BTF
libbpf: failed to find valid kernel BTF 同上
挂载失败 ENOENT 函数名不存在或被内联;kprobe 要查 /proc/kallsyms

十二、可观测性:诊断思路与案例

为什么 eBPF 适合

需求 传统手段 eBPF
不改代码拿到内部状态 加日志、重新发版 动态挂到任意函数
高频事件统计 事件逐条拷到用户态(strace、perf record),开销大 在内核里聚合,只把直方图/计数交给用户态
抓瞬时现象 采样工具会漏掉短时进程、偶发丢包 事件驱动,每次发生都执行
跨层关联 用户态和内核态数据各自为政 同一个程序里同时拿到 pid、cgroup、内核栈、用户栈

通用数据流:

flowchart LR
    E["钩子<br/>tracepoint/kprobe/uprobe"] --> P["BPF 程序<br/>过滤 + 聚合"]
    P -->|"计数 / 直方图"| M[("HASH / PERCPU map")]
    P -->|"逐条事件"| RB[("RINGBUF")]
    M -->|"周期读取"| U["用户态:展示 / 导出指标"]
    RB -->|"epoll 唤醒"| U

本章讲几个有代表性的诊断思路;具体工具见后面几章:内存泄漏(第十三章)、CPU(第十四章)、内存与 IO(第十五章)、用户态程序(第十六章)、网络(第十七章)。

短时进程:top 看不到的 CPU 消耗

现象:CPU 使用率高,top 里找不到占用高的进程。原因往往是大量运行几毫秒就退出的进程(脚本里循环调用外部命令、崩溃重启),top 按间隔采样 /proc,看不到它们。

思路:进程创建一定经过 execve,挂 tracepoint/syscalls/sys_enter_execve 和 sys_exit_execve:入口记录文件名和参数到以 tid 为 key 的 hash map,出口取出并附上返回值,经 ring buffer 发给用户态。BCC 的 execsnoop、bpftrace 的 execsnoop.bt 就是这个结构。

参数不能在入口直接发送的原因:入口时参数还是用户态指针,入口和出口分开处理可以拿到 execve 是否成功。

网络丢包:丢在内核哪一行

内核释放 sk_buff 有两个函数:kfree_skb(异常释放,丢包时调用)和 consume_skb(正常处理完释放)。跟踪 kfree_skb 的调用栈就能定位丢包点:

# 5.17+:tracepoint 带丢包原因枚举
sudo bpftrace -e 'tracepoint:skb:kfree_skb { @[args->reason, kstack(5)] = count(); }'

# 旧内核:kprobe + 调用栈,限定进程
sudo bpftrace -e 'kprobe:kfree_skb /comm == "curl"/ { printf("%s\n", kstack); }'

拿到类似 nf_hook_slow+0x... 的栈帧后,用内核源码里的 scripts/faddr2line vmlinux nf_hook_slow+0x48/0xd0 换算成源码行号(需要带调试信息的 vmlinux)。栈里出现 nf_hook_slow 基本说明包被 iptables/nftables 规则丢了。

服务网格中的偶发 RST

现象(来自一个生产案例的简化):客户端向 Kubernetes Pod 上传数据,连接偶发中断;抓包看到 Pod 发出了 TCP RST。Pod 注入了 Envoy sidecar,入站流量经 iptables DNAT 先到 Envoy,再由 Envoy 转给业务进程。

诊断:内核发 RST 的函数是 tcp_v4_send_reset()(收到不属于任何连接的包时回复)和 tcp_send_active_reset()(本端主动中止)。kprobe 挂这两个函数,打印触发时的 socket 状态、收到的包的四元组、内核栈。

发现:

  1. 发 RST 的 socket 处于 TCP_LISTEN,监听的是业务端口——即业务进程的监听 socket 收到了一个包
  2. 这个包的目的地址是业务端口,说明它没有经过 DNAT 转给 Envoy

根因:conntrack 在默认配置下把窗口外的乱序包标记为 INVALID,INVALID 包不做 NAT,于是这个包直接送到了业务进程的监听端口;监听 socket 找不到对应连接,回复 RST,客户端的连接被打断。解决方法是开启 net.netfilter.nf_conntrack_tcp_be_liberal=1,或丢弃 INVALID 状态的包。

这个案例说明:eBPF 的写法很固定,真正的门槛是知道该跟踪哪个内核函数。它要求理解网络包从驱动到 socket 经过的每个环节。

容器感知的跟踪

容器共享宿主机内核,内核态的跟踪天然覆盖所有容器;问题是区分事件来自哪个容器。

方法 做法
命名空间编号 从 task_struct->nsproxy 读 pid_ns_for_children->ns.inum(PID 命名空间)或 uts_ns->name.nodename(容器主机名)
cgroup id bpf_get_current_cgroup_id(),用户态把 cgroup id 映射到容器 id / Pod 名(cgroup v2)
过滤 把关心的 cgroup 放进 CGROUP_ARRAY,程序里用 bpf_current_task_under_cgroup() 判断
// 读当前进程所在 PID 命名空间的编号(CO-RE 写法)
struct task_struct* t = (struct task_struct*)bpf_get_current_task_btf();
unsigned int pidns = BPF_CORE_READ(t, nsproxy, pid_ns_for_children, ns.inum);

跟踪容器内的用户态程序:容器的文件系统在另一个 mount 命名空间里,宿主机上通过 /proc/<宿主机 PID>/root/ 访问:

PID=$(docker inspect -f '{{.State.Pid}}' <容器名>)
sudo bpftrace -l "uprobe:/proc/$PID/root/usr/bin/bash:*"

uprobe 按"文件 inode + 偏移"挂载,所以挂在容器镜像里的某个二进制上,会命中所有使用这个文件(同一镜像层)的进程。

云原生可观测平台

项目 做什么 eBPF 的角色
Pixie(CNCF) K8s 内的请求级观测,自动解析 HTTP/gRPC/MySQL 等协议 kprobe 挂 socket 读写 + uprobe 挂 TLS 库,无侵入抓请求
Hubble(Cilium) 服务间网络流量可视化、策略审计 复用 Cilium 数据面的 BPF 程序输出的流事件
Parca、Pyroscope、OpenTelemetry eBPF Profiler 全集群持续 CPU 剖析 perf_event 定时采样 + 内核里做栈回溯(对无帧指针的程序用 .eh_frame 展开)
Grafana Beyla、DeepFlow 无需 SDK 的自动埋点、分布式追踪 uprobe/kprobe 关联请求与响应,生成 RED 指标和 trace
Inspektor Gadget kubectl 插件形式的 BCC 类工具集 按 Pod/命名空间过滤的跟踪工具

共同点:零侵入——不改应用代码、不注入 SDK、不重启 Pod,部署一个 DaemonSet 就覆盖整个节点。


十三、内存泄漏排查

原理

用户态内存泄漏的 eBPF 排查法与 memory-leak.md 第九章相同:在分配/释放函数上挂探针,配对记录,定期输出"分配了还没释放"的调用栈。

 uprobe:malloc   ──▶ 记 size 到 @sizes[tid]
 uretprobe:malloc ─▶ 拿返回地址 addr:@allocs[addr] = {size, stack_id, timestamp}
                      @stacks 是 STACK_TRACE map,同一个栈只存一份
 uprobe:free     ──▶ delete @allocs[addr]
 用户态每 N 秒   ──▶ 遍历 @allocs,按 stack_id 汇总字节数,排序输出

它不判断可达性(LSan 的做法),只看"分配了、还没释放"。所以:

  • 逻辑泄漏(可达但没用的缓存)能显现:那个栈的持有量单调涨
  • 正常长期持有的对象(启动时分配的全局对象)也会出现在列表里,要看趋势而不是单次绝对值,-o 参数过滤掉年轻分配是为了去掉正常的短命对象

memleak(BCC)

sudo memleak-bpfcc -p $PID 5 10            # 附着到进程;每 5 秒打印一次,共 10 次
sudo memleak-bpfcc -p $PID -o 60000 5      # 只报存活超过 60 秒的分配
sudo memleak-bpfcc -p $PID -a 5            # 同时列出每个未释放块的地址、大小、年龄
sudo memleak-bpfcc -p $PID -T 5 5          # 只显示 top 5 栈
sudo memleak-bpfcc -p $PID -s 10 5         # 每 10 次分配采样 1 次,降低开销
sudo memleak-bpfcc -p $PID -z 4096 5       # 只跟踪大于 4 KB 的分配
sudo memleak-bpfcc -p $PID -O /usr/lib/x86_64-linux-gnu/libjemalloc.so.2 5   # 程序用 jemalloc
sudo memleak-bpfcc -c ./prog 5             # 从头跟踪一个新进程
sudo memleak-bpfcc 5                       # 不带 -p:跟踪内核 kmalloc/kfree/kmem_cache_alloc/... ,查内核和驱动泄漏
sudo memleak-bpfcc -p $PID --combined-only 5   # 只在 BPF 侧按栈汇总,不逐个块传到用户态,大幅降低开销

输出格式:

[14:02:31] Top 10 stacks with outstanding allocations:
        2048000 bytes in 2000 allocations from stack
                operator new(unsigned long)+0x1c [libstdc++.so.6.0.33]
                std::__cxx11::basic_string<...>::_M_create(unsigned long&, unsigned long)+0x2a [prog]
                Cache::insert(std::string const&)+0x5b [prog]
                handle_request(Request const&)+0x120 [prog]
                main+0x38 [prog]
        4096 bytes in 1 allocations from stack
                ...

判断方法:连续几轮里同一个栈的 bytes 单调上涨且与请求量成正比,就是泄漏点;稳定不变的是启动期的长期对象。

它挂的函数:malloc、calloc、realloc、posix_memalign、aligned_alloc、valloc、memalign、pvalloc、free; -O 指定别的库;C++ 的 operator new 最终走 malloc,自动覆盖。 mmap 直接分配的内存不经过这些函数,要另挂 tracepoint:syscalls:sys_enter_mmap/munmap。

bpftrace 自写版

没有 BCC 时,bpftrace 能实现简化版(未实测):

#!/usr/bin/env bpftrace
// 用法:sudo ./leak.bt <pid>
uprobe:libc:malloc /pid == $1/ { @sz[tid] = arg0; }
uretprobe:libc:malloc /pid == $1 && @sz[tid]/ {
    @alloc[retval] = @sz[tid];             // 地址 → 大小
    @stack[retval] = ustack(8);            // 地址 → 分配栈
    delete(@sz[tid]);
}
uprobe:libc:free /pid == $1 && @alloc[arg0]/ {
    delete(@alloc[arg0]);
    delete(@stack[arg0]);
}
interval:s:10 {
    printf("---- outstanding allocations ----\n");
    print(@stack);                         // 每个未释放地址的栈;配合 sort | uniq -c 在外面聚合
}
END { clear(@sz); clear(@alloc); clear(@stack); }

bpftrace 不方便在 BPF 侧按栈汇总字节数(map 的 key 不能是另一个 map 的值),所以把地址级记录打出来在用户态聚合。高频分配的程序用 BCC 的 --combined-only。

只看"谁在大量分配"(不配对 free)更简单,也常常够用:

sudo bpftrace -e 'uprobe:libc:malloc /pid == '$PID'/ { @bytes[ustack(6)] = sum(arg0); }
                  interval:s:10 { print(@bytes, 5); }'

堆增长的直接证据:brk / mmap

分配器向内核要内存只有两条路,挂上它们看到的是"谁触发了堆增长":

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_brk /pid == '$PID'/ { @[ustack(8)] = count(); }'
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_mmap /pid == '$PID' && args->len > 1048576/
                  { printf("%d MB from:\n%s\n", args->len / 1048576, ustack(6)); }'

内核内存泄漏

/proc/meminfo 里 Slab、SUnreclaim 持续涨、slabtop 里某个 cache 的对象数只增不减,是内核或驱动泄漏。

sudo memleak-bpfcc 10                            # kmalloc/kfree、kmem_cache_alloc/free、get_free_pages 全挂上
sudo bpftrace -e 'tracepoint:kmem:kmalloc { @[kstack(6)] = sum(args->bytes_alloc); }'
sudo bpftrace -e 'tracepoint:kmem:kmem_cache_alloc /str(args->name) == "dentry"/ { @[kstack] = count(); }'

内核自带的 kmemleak(CONFIG_DEBUG_KMEMLEAK)做的是可达性扫描,和 LSan 一个思路,生产内核一般不开;eBPF 的方法不需要重新编译内核。

限制

  • frame pointer:uprobe 的用户态栈回溯默认走帧指针链,被测程序和它调用的库要 -fno-omit-frame-pointer,否则栈只有一两层。Ubuntu 24.04 起系统库默认保留 frame pointer;Fedora 38 起同样
  • 符号:strip 过的二进制只能显示地址;准备 debug 包或 debuginfod
  • 开销:每次 malloc/free 各一次 uprobe 陷入,单次 1-3 微秒;每秒百万次分配的程序会慢数倍。先用 -s 采样、-z 过滤小块、--combined-only
  • 短命进程:进程启动前挂不上,用 -c 让工具拉起进程
  • 静态链接的分配器:符号在主程序里,-O ./prog
  • 不判断可达性:报告里的栈是"嫌疑人",不是"定罪"

十四、CPU 性能分析

CPU 问题分两类:on-CPU(在跑,跑什么)和 off-CPU(没在跑,在等什么)。eBPF 两边都能覆盖,而且聚合在内核里完成,不需要像 perf record 那样把每个样本写盘。

on-CPU:profile 与火焰图

原理:perf_event 定时器每 CPU 每秒触发 99 次(错开 100 Hz 的内核时钟避免锁相),每次触发时 BPF 程序取当前的用户态 + 内核态调用栈,存进 STACK_TRACE map 并计数。结束时输出"栈 → 次数"。

sudo profile-bpfcc -F 99 -adf 30 > out.folded    # 99 Hz,30 秒,-a 标注内核/用户,-d 分隔符,-f 折叠格式
sudo profile-bpfcc -p $PID -F 99 -f 30 > out.folded
sudo profile-bpfcc -U -F 99 -f 30                # 只要用户态栈
sudo profile-bpfcc -K -F 99 -f 30                # 只要内核态栈
sudo profile-bpfcc -F 99 -f --stack-storage-size 65536 30   # 栈很多时加大存储

折叠格式每行是 comm;frame1;frame2;...;frameN count,直接喂给 FlameGraph:

git clone https://github.com/brendangregg/FlameGraph
./FlameGraph/flamegraph.pl --title "on-CPU" out.folded > cpu.svg

bpftrace 版:

sudo bpftrace -e 'profile:hz:99 /pid == '$PID'/ { @[ustack, kstack, comm] = count(); }' > out.stacks
./FlameGraph/stackcollapse-bpftrace.pl out.stacks | ./FlameGraph/flamegraph.pl > cpu.svg

怎么读火焰图:横轴是样本占比(不是时间顺序),纵轴是栈深度,顶部是正在执行的函数。找最宽的顶部平台,那是 CPU 真正花在哪里;顺着往下看是谁调的它。 颜色默认随机,--color=java 之类只是按语言着色。

与 perf 的差别:perf record -F 99 -g 把每个样本(含完整栈)写进 perf.data,30 秒几百 MB;profile 在内核里按栈去重计数,输出只有几百 KB,长时间采样无压力。

off-CPU:offcputime

on-CPU 火焰图看不见"在等锁、等 IO、等网络"的时间。off-CPU 分析挂在调度器切换点:线程被切走时记时间戳,切回来时算差值,按栈累加。

sudo offcputime-bpfcc -p $PID -f 30 > offcpu.folded   # 阻塞时间(微秒)按栈汇总
sudo offcputime-bpfcc -p $PID -m 1000 -f 30           # 只看阻塞超过 1 ms 的
sudo offcputime-bpfcc -u -f 30                        # 只看用户线程,去掉内核线程
./FlameGraph/flamegraph.pl --title "off-CPU" --countname us --colors io offcpu.folded > offcpu.svg

bpftrace 版:

sudo bpftrace -e '
tracepoint:sched:sched_switch { @start[args->prev_pid] = nsecs; }
tracepoint:sched:sched_switch /@start[args->next_pid]/ {
    @offcpu_us[args->next_comm, kstack, ustack] = sum((nsecs - @start[args->next_pid]) / 1000);
    delete(@start[args->next_pid]);
}'

off-CPU 火焰图里宽的平台通常是 futex_wait(锁竞争)、io_schedule(磁盘)、tcp_recvmsg(等网络)、epoll_wait(空闲,正常)。 sched_switch 每秒可能几十万次,这个工具开销比 profile 大得多,用 -p 限定进程。

把 on-CPU 和 off-CPU 拼在一起就是墙钟时间的完整分解(Brendan Gregg 称为 hot/cold 火焰图)。

调度延迟:runqlat / runqlen / runqslower

线程"想跑但排队"的时间。CPU 饱和、cgroup 限流、错误的亲和性都会体现在这里。

sudo runqlat-bpfcc 5 3          # 运行队列等待时间直方图,每 5 秒一次
sudo runqlat-bpfcc -p $PID 5    # 单进程
sudo runqlat-bpfcc --pidnss 5   # 按 pid namespace(容器)分开
sudo runqlen-bpfcc 5            # 队列长度分布
sudo runqslower-bpfcc 10000     # 打印等待超过 10 ms 的每一次事件

原理:sched_wakeup 记时间戳,sched_switch 切到它时相减。典型输出是 usecs 直方图,健康系统集中在 0-32 微秒,出现毫秒级的长尾就是 CPU 不够。

函数级:funccount / funclatency / argdist

sudo funccount-bpfcc 'tcp_*' 5                       # 内核函数调用次数
sudo funccount-bpfcc -p $PID 'c:malloc' 5            # libc 函数;c: 是 libc 的简写
sudo funccount-bpfcc -p $PID './prog:*Cache*' 5      # 应用函数,通配 C++ 符号(未 demangle)
sudo funclatency-bpfcc -p $PID -u c:malloc 5         # 延迟直方图,微秒
sudo funclatency-bpfcc -p $PID './prog:_ZN5Cache6lookupERKNSt7__cxx1112basic_string...' 5
sudo argdist-bpfcc -p $PID -H 'p:c:malloc(size_t size):size_t:size'   # 参数分布直方图
sudo argdist-bpfcc -p $PID -C 'r:c:malloc():size_t:$retval==0'         # 返回 NULL 的次数

funclatency -F 按函数分开、-m 毫秒、-T 带时间戳。

硬件计数器:PMU

sudo llcstat-bpfcc 10                      # 每进程的 LLC 命中率(需要硬件 PMU,虚拟机里通常不可用)
sudo bpftrace -e 'hardware:cache-misses:1000000 { @[comm, ustack(3)] = count(); }'   # 每 100 万次 miss 采一次栈
sudo bpftrace -e 'hardware:branch-misses:100000 { @[usym(reg("ip"))] = count(); }'

perf stat 看总量,eBPF 看"哪个栈"产生了这些事件。

中断与软中断

sudo hardirqs-bpfcc 5           # 各硬中断的耗时
sudo softirqs-bpfcc 5           # 各软中断(NET_RX、TIMER、RCU)的耗时

网络包处理时间大头在 softirq 里,不算在任何进程的 CPU 上,top 里表现为 si 高。

CPU 分析工具选择

症状 工具
CPU 高,不知道跑什么 profile → 火焰图
CPU 不高但慢 offcputime → 火焰图;看 futex / IO / 网络
延迟抖动 runqlat(调度)、hardirqs/softirqs(中断)、cpudist(时间片)
某个函数慢 funclatency,再用 argdist 看参数分布
吞吐上不去 llcstat、hardware:cache-misses 看内存访问
容器被限流 runqlat --pidnss,配合 cpu.stat 的 nr_throttled

十五、内存子系统与 IO

内存

sudo oomkill-bpfcc                    # 谁被 OOM 杀了、触发者是谁、当时的 loadavg
sudo drsnoop-bpfcc                    # 直接回收(direct reclaim)事件:分配时内存不够同步回收,是延迟毛刺的常见来源
sudo bpftrace -e 'software:page-faults:1 { @[comm, ustack(4)] = count(); }'   # 谁在缺页(首次触碰、COW、文件映射)
sudo bpftrace -e 'tracepoint:exceptions:page_fault_user { @[comm] = count(); }'
sudo bpftrace -e 'kprobe:shrink_node { @[kstack] = count(); }'               # 回收路径
sudo cachestat-bpfcc 5                # page cache 命中率
sudo cachetop-bpfcc                   # 按进程的 page cache 命中
sudo slabratetop-bpfcc 5              # slab 分配速率 top
sudo mmapsnoop-bpfcc                  # mmap 调用(部分版本有)
sudo memleak-bpfcc 10                 # 内核 kmalloc 泄漏

大页相关:

sudo bpftrace -e 'tracepoint:huge_memory:mm_khugepaged_scan_pmd { @ = count(); }'   # THP 合并扫描
sudo bpftrace -e 'kprobe:do_huge_pmd_anonymous_page { @[comm] = count(); }'

块设备与文件系统

sudo biolatency-bpfcc 5               # 块 IO 延迟直方图(从提交到完成)
sudo biolatency-bpfcc -D 5            # 按磁盘分开
sudo biolatency-bpfcc -Q 5            # 包含在队列里等待的时间
sudo biosnoop-bpfcc                   # 每个 IO:进程、磁盘、扇区、大小、延迟
sudo biotop-bpfcc                     # 按进程的 IO top
sudo bitesize-bpfcc                   # IO 大小分布
sudo ext4slower-bpfcc 10              # ext4 上超过 10 ms 的读写/open/fsync;xfsslower/btrfsslower/nfsslower 同理
sudo fileslower-bpfcc 10              # VFS 层,不分文件系统
sudo filetop-bpfcc                    # 按文件的读写 top
sudo opensnoop-bpfcc -p $PID          # open 了什么文件(失败的也有)
sudo filelife-bpfcc                   # 短命文件(创建后很快删除)
sudo vfsstat-bpfcc 5                  # VFS 调用速率
sudo dcstat-bpfcc / dcsnoop-bpfcc     # dentry cache 命中
sudo syncsnoop-bpfcc                  # 谁在调 sync/fsync

biolatency 看的是块层,ext4slower 看的是文件系统层(包含 page cache 命中的情况),应用感受到的是后者。两者差距大说明问题在文件系统或锁,不在磁盘。


十六、用户态程序跟踪

uprobe 挂 C++ 函数

C++ 符号是 mangled 的,先查名字:

nm -C ./prog | grep 'Cache::lookup'                      # 看 demangled 名对应的行
nm ./prog | grep lookup                                   # 拿 mangled 名
bpftrace -l 'uprobe:./prog:*Cache*lookup*'                # 通配也行
# 调用次数
sudo bpftrace -e 'uprobe:./prog:_ZN5Cache6lookupERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE { @ = count(); }'
# 延迟
sudo bpftrace -e '
uprobe:./prog:_ZN5Cache6lookup* { @s[tid] = nsecs; }
uretprobe:./prog:_ZN5Cache6lookup* /@s[tid]/ { @us = hist((nsecs - @s[tid]) / 1000); delete(@s[tid]); }'

注意:

  • 成员函数的 arg0 是 this,真正的第一个参数从 arg1 开始
  • 内联函数没有入口可挂;-O2 下小函数大多被内联。用 -fno-inline 重编或挂它的调用者
  • uretprobe 的实现是把返回地址替换成 trampoline,与某些栈保护机制、协程切换栈冲突;能用 fexit 式的 uprobe(6.x 起部分支持)就不用 uretprobe
  • 库函数写 uprobe:libc:malloc,bpftrace 会解析 libc 到实际路径;容器里的进程要用 /proc/PID/root/path

读参数

# std::string const& 参数:libstdc++ 的 string 对象前 8 字节是数据指针
sudo bpftrace -e 'uprobe:./prog:_ZN5Cache6lookup* { printf("%s\n", str(*(uint64*)arg1)); }'

# C 字符串
sudo bpftrace -e 'uprobe:./prog:process { printf("%s\n", str(arg0)); }'

# 结构体:用 bpftrace 的 struct 定义 + cast
sudo bpftrace -e '
struct req { int id; char name[32]; };
uprobe:./prog:handle { $r = (struct req*)arg0; printf("%d %s\n", $r->id, $r->name); }'

有 DWARF 调试信息时 bpftrace 0.20+ 能直接用 args->name(需要 -g 编译的二进制和 bpftrace 编译时开了 DWARF 支持)。

USDT:应用主动埋点

在源码里放静态探针,未启用时只是一条 nop,几乎零开销;启用时被替换成 int3:

#include <sys/sdt.h>          // systemtap-sdt-dev
void handle(Request& r) {
    DTRACE_PROBE2(myapp, request_start, r.id, r.size);    // provider=myapp, probe=request_start
    ...
    DTRACE_PROBE1(myapp, request_done, r.id);
}
bpftrace -l 'usdt:./prog:*'
sudo bpftrace -e 'usdt:./prog:myapp:request_start { @sizes = hist(arg1); }'
sudo bpftrace -e 'usdt:./prog:myapp:request_start { @s[arg0] = nsecs; }
                  usdt:./prog:myapp:request_done /@s[arg0]/ { @lat = hist(nsecs - @s[arg0]); delete(@s[arg0]); }'

现成 USDT 探针:PostgreSQL、MySQL、Node.js、Python(--with-dtrace 构建)、JVM(-XX:+ExtendedDTraceProbes)、glibc(libc:memory_malloc_retry 等)、libpthread(mutex_acquired 等)。

TLS 明文

加密发生在用户态库里,在 SSL_write 入口和 SSL_read 返回处挂 uprobe,就能拿到加密前/解密后的数据(BCC sslsniff,Pixie 用同样的方法做 HTTP/gRPC 协议解析)。不需要证书,也不改应用。

sudo sslsniff-bpfcc -p $PID          # 挂 libssl 的 SSL_write / SSL_read,打印明文

解释型 / JIT 语言

语言 方法
Python USDT python:function__entry(需要 dtrace 构建);BCC 的 pythoncalls、pythonflow、pythongc;或 py-spy(非 eBPF)
Java perf-map-agent 导出 JIT 符号到 /tmp/perf-PID.map,profile 就能解析栈;USDT 需 -XX:+ExtendedDTraceProbes;javacalls、javagc
Node.js --perf-basic-prof 导出符号;USDT 内置
Go 静态链接、无 libc,uprobe 直接挂函数。不能用 uretprobe——Go 的 goroutine 栈会增长和搬迁,uretprobe 改写了栈上的返回地址,搬迁后返回地址错乱导致崩溃。做法是反汇编函数,在每条 RET 指令上挂 uprobe。另外 Go 1.17 起内部函数用寄存器传参,参数位置按 Go 自己的 ABI 取
Rust 与 C++ 相同,符号 mangled;rustfilt demangle

十七、网络:跟踪与数据面

网络问题的排查(本章前半)和网络数据面的实现(本章后半)用的是不同的钩子:前者用 kprobe/tracepoint 观察协议栈,后者用 XDP、tc、socket 程序改变包的去向。

跟踪

sudo tcpconnect-bpfcc                 # 主动发起的连接(connect):谁连了哪里
sudo tcpaccept-bpfcc                  # 被动接受的连接
sudo tcplife-bpfcc                    # 连接生命周期:时长、收发字节数,连接关闭时打印一行
sudo tcpretrans-bpfcc                 # 重传事件及当时的 TCP 状态
sudo tcpdrop-bpfcc                    # 内核丢包及丢包点的内核栈
sudo tcprtt-bpfcc                     # RTT 直方图
sudo tcpstates-bpfcc                  # 状态机迁移
sudo tcptop-bpfcc                     # 按连接的吞吐 top
sudo gethostlatency-bpfcc             # DNS 解析延迟(uprobe 在 getaddrinfo/gethostbyname 上)
sudo sofdsnoop-bpfcc                  # 通过 unix socket 传递 fd
sudo bpftrace -e 'kprobe:tcp_retransmit_skb { @[kstack] = count(); }'
sudo bpftrace -e 'tracepoint:skb:kfree_skb { @[args->reason] = count(); }'   # 5.17+ 有丢包原因枚举

tcpdrop 和 kfree_skb 的 reason 是排查"丢包在哪"的最直接手段,替代过去猜 netstat -s 里哪个计数在涨。

收发包路径上的钩子

flowchart TD
    subgraph RX["收包方向"]
        NIC["网卡"] --> XDP["XDP<br/>驱动收包,未分配 skb"]
        XDP --> SKB["分配 sk_buff、GRO"]
        SKB --> TCI["tc ingress<br/>有 skb,未进 IP 层"]
        TCI --> NF1["netfilter PREROUTING<br/>(iptables / conntrack)"]
        NF1 --> RT["路由"]
        RT --> CGI["cgroup_skb ingress"]
        CGI --> SK["socket 接收队列<br/>socket filter / sk_skb"]
        SK --> APP["应用 recv()"]
    end
    subgraph TX["发包方向"]
        APP2["应用 connect() / send()"] --> CSA["cgroup/connect4、sendmsg4<br/>系统调用时改写目的地址"]
        CSA --> SKM["sk_msg<br/>sockmap 重定向"]
        SKM --> TCP["TCP/IP 协议栈"]
        TCP --> CGE["cgroup_skb egress"]
        CGE --> NF2["netfilter OUTPUT / POSTROUTING"]
        NF2 --> TCE["tc egress"]
        TCE --> NIC2["qdisc → 网卡"]
    end

越靠近网卡越快、能拿到的上下文越少;越靠近 socket 越"懂"应用,但每个包走过的路径越长。

钩子 方向 数据结构 能看到的信息 典型用途
XDP 只有收 xdp_buff:原始帧的 data/data_end 只有报文字节 DDoS 丢包、四层负载均衡、快速转发
tc 收、发 sk_buff 报文 + mark、cgroup、ifindex 等元数据 容器网络、策略、NAT、限速
cgroup sock_addr 发起连接时 socket 地址 进程、cgroup、目的地址 socket 级负载均衡
sockops + sk_msg socket 事件 / 发送 socket 连接五元组、TCP 状态 同机 socket 直连、TCP 参数调优

XDP

三种运行模式:

模式 ip link 参数 运行位置 性能 条件
generic xdpgeneric 协议栈入口,skb 已分配 与 tc 相当 任何网卡(包括 veth),用于测试
native xdpdrv 驱动 NAPI 收包函数里,skb 分配之前 高 驱动支持(ixgbe、i40e、ice、mlx5、virtio_net、veth 等)
offload xdpoffload 网卡硬件 最高,不占主机 CPU 少数 SmartNIC(如 Netronome)

返回码:

返回码 动作
XDP_DROP 丢弃,驱动直接回收页,是内核里代价最低的丢包方式
XDP_PASS 交给协议栈继续处理
XDP_TX 从收包的同一网卡发回去
XDP_REDIRECT 转发到另一网卡(DEVMAP)、另一 CPU(CPUMAP)或 AF_XDP socket(XSKMAP)
XDP_ABORTED 程序出错,丢弃并触发 xdp:xdp_exception tracepoint

XDP 不是"绕过内核":它运行在内核里,处理完的包可以 XDP_PASS 继续走完整协议栈。这与 DPDK 那种把网卡交给用户态、彻底绕过内核的方案不同——XDP 可以只拦截关心的流量,其余流量和内核的路由、iptables、socket 照常工作。AF_XDP 则是折中:选定的流量经 XSKMAP 零拷贝送到用户态处理。

XDP 快的原因:在 skb 分配之前运行(省掉 skb 分配和初始化),不经过 GRO、netfilter、路由;一次 NAPI poll 处理一批包,程序和数据都在缓存里。单核 XDP_DROP 可达每秒千万包量级。

最小例子:丢弃发往 UDP 53 端口的包(未实测)。

SEC("xdp")
int drop_udp_53(struct xdp_md* ctx)
{
    void* data = (void*)(long)ctx->data;
    void* data_end = (void*)(long)ctx->data_end;
    struct ethhdr* eth = data;
    if ((void*)(eth + 1) > data_end) return XDP_PASS;         // 每次访问前必须做边界检查,否则验证器拒绝
    if (eth->h_proto != bpf_htons(ETH_P_IP)) return XDP_PASS;
    struct iphdr* ip = (void*)(eth + 1);
    if ((void*)(ip + 1) > data_end) return XDP_PASS;
    if (ip->protocol != IPPROTO_UDP) return XDP_PASS;
    struct udphdr* udp = (void*)ip + ip->ihl * 4;
    if ((void*)(udp + 1) > data_end) return XDP_PASS;
    return udp->dest == bpf_htons(53) ? XDP_DROP : XDP_PASS;
}
sudo ip link set dev eth0 xdpgeneric obj drop.bpf.o sec xdp    # generic 模式:任何驱动都行,性能一般
sudo ip link set dev eth0 xdpdrv obj drop.bpf.o sec xdp        # native 模式:驱动支持,性能最好
sudo ip link set dev eth0 xdp off

tc

  • 通过 clsact qdisc 挂在入向和出向,da(direct-action,4.4 起)模式下程序的返回值直接就是动作(TC_ACT_OK、TC_ACT_SHOT、TC_ACT_REDIRECT),不需要再配 tc 的 action
  • 收包时在 GRO 之后、IP 层和 iptables 之前执行;发包时在 IP 层和 iptables 之后、进入网卡队列之前执行
  • 能挂任何网络设备,包括容器的 veth,是容器网络的主力钩子
  • bpf_redirect_peer()(5.10):从宿主机侧 veth 的 ingress 直接把包送进容器命名空间内的对端设备,跳过一次软中断和 backlog 队列
  • 6.6 的 tcx 用 bpf_link 管理 tc 程序,支持多程序有序排列;6.7 的 netkit 设备专为容器设计,替代 veth,在设备的发送路径上直接执行 BPF 程序
sudo tc qdisc add dev eth0 clsact
sudo tc filter add dev eth0 ingress bpf da obj prog.bpf.o sec tc
sudo tc filter show dev eth0 ingress

socket 层:同机 socket 直连

场景:同一台机器上两个进程通过 TCP 通信(应用 ↔ sidecar、应用 ↔ 本机负载均衡器)。正常路径是发送方走完 TCP/IP 发送栈、经 loopback 或 veth、再走完接收栈,两遍协议栈处理只是为了把数据从一个 socket 挪到另一个 socket。

做法:

sequenceDiagram
    participant A as 进程 A 的 socket
    participant OPS as sockops 程序<br/>(挂在 cgroup)
    participant MAP as SOCKHASH<br/>key=五元组
    participant MSG as sk_msg 程序<br/>(挂在 SOCKHASH)
    participant B as 进程 B 的 socket
    Note over A,B: 连接建立(ACTIVE/PASSIVE_ESTABLISHED_CB)
    OPS->>MAP: bpf_sock_hash_update(本端五元组 → 本 socket)
    Note over A,B: 数据发送
    A->>MSG: send(),进入 sk_msg
    MSG->>MAP: 用对端五元组查找对端 socket
    MSG->>B: bpf_msg_redirect_hash():<br/>数据直接放进 B 的接收队列
    Note over A,B: 跳过 TCP/IP 发送栈、网卡、接收栈

要点:

  • sockops 挂在 cgroup(bpftool cgroup attach /sys/fs/cgroup/ sock_ops pinned ...),对 cgroup 内所有 socket 生效;在连接建立的两个回调里把 socket 存进 SOCKHASH
  • sk_msg 挂在 map 上(bpftool prog attach ... msg_verdict pinned <map>),每次 sendmsg 执行
  • 查找对端时,key 是把本端五元组的源和目的对调
  • 只对两端 socket 都在同一台主机内核里的连接有效;跨网络命名空间也可以(容器之间),前提是两端看到的五元组能对上,即中间没有 NAT。跨主机流量照常走协议栈
  • 用这种方式做 sidecar 加速的开源项目有 Merbridge(Istio / Linkerd)

四层负载均衡

课程案例:同一宿主机上的 Docker 容器,客户端 → 负载均衡器 → 两个 Web 后端,用 wrk -c100 压测。

方案 请求数/秒 平均延迟 说明
Nginx 反向代理 13798 7.53 ms 用户态七层代理
Nginx + sockops/sk_msg 15300(+10.8%) 6.88 ms Nginx 仍在路径上,省掉的是同机 socket 之间的协议栈
XDP 负载均衡(generic 模式,挂在 veth) 18048(比上一行 +18%) 6.37 ms 在收包时改写 MAC/IP、重算校验和后 XDP_TX 发回

数据来自课程的容器实验,环境不同结果不同。XDP 用的是 generic 模式,native 模式下差距会更大。

课程里的 XDP 程序是教学用的最小实现(后端 IP/MAC 写死、按时间戳最低位随机选后端)。逐包随机选后端会把同一个 TCP 连接的包发往不同后端,生产实现必须按连接保持一致。生产级四层负载均衡(如 Meta 的 Katran)的设计要点:

要点 做法
VIP 查找 HASH:(VIP, 端口, 协议)→ 服务配置
后端选择 对五元组做哈希,查一致性哈希环(Katran 用 Maglev 算法),后端增减时只影响少量连接
连接保持 LRU_HASH(per-CPU 版本)记录五元组 → 后端,已有连接优先查表,后端列表变化也不影响存量连接
转发方式 IPIP/GUE 封装后发给后端,不改原始包;后端解封装后直接回包给客户端(DSR),回程流量不经过负载均衡器
控制面 用户态进程做健康检查,更新后端 map;数据面程序无需重载
横向扩展 多台负载均衡器通过 ECMP 共享同一个 VIP;一致性哈希保证任一台都把同一连接发往同一后端
校验和 改写报头后用 bpf_csum_diff / bpf_l3_csum_replace 增量更新,否则接收方丢包

一个按连接保持的 XDP 骨架(示意,未实测,省略了 IP 选项、分片、IPv6、校验和细节):

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

struct flow { __u32 saddr, daddr; __u16 sport, dport; __u8 proto; };
struct backend { __u32 ip; __u8 mac[6]; };

struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 1 << 20);
    __type(key, struct flow);
    __type(value, __u32);                    // 后端下标
} conn_table SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 64);
    __type(key, __u32);
    __type(value, struct backend);
} backends SEC(".maps");

const volatile __u32 nr_backends = 2;       // 用户态加载前设置

SEC("xdp")
int xdp_lb(struct xdp_md* ctx)
{
    void* data = (void*)(long)ctx->data;
    void* data_end = (void*)(long)ctx->data_end;

    struct ethhdr* eth = data;
    if ((void*)(eth + 1) > data_end) return XDP_DROP;
    if (eth->h_proto != bpf_htons(ETH_P_IP)) return XDP_PASS;

    struct iphdr* ip = (void*)(eth + 1);
    if ((void*)(ip + 1) > data_end) return XDP_DROP;
    if (ip->protocol != IPPROTO_TCP || ip->ihl != 5) return XDP_PASS;

    struct tcphdr* tcp = (void*)(ip + 1);
    if ((void*)(tcp + 1) > data_end) return XDP_DROP;

    struct flow f = { ip->saddr, ip->daddr, tcp->source, tcp->dest, ip->protocol };
    __u32* idx = bpf_map_lookup_elem(&conn_table, &f);
    __u32 i;
    if (idx) {
        i = *idx;
    } else {
        i = (f.saddr ^ f.sport ^ f.dport) % nr_backends;   // 示意:生产用一致性哈希
        bpf_map_update_elem(&conn_table, &f, &i, BPF_ANY);
    }

    struct backend* be = bpf_map_lookup_elem(&backends, &i);
    if (!be) return XDP_ABORTED;

    // 此处省略:IPIP 封装(bpf_xdp_adjust_head 扩出外层 IP 头)或改写目的地址 + 增量更新校验和
    __builtin_memcpy(eth->h_dest, be->mac, 6);
    return XDP_TX;
}

char LICENSE[] SEC("license") = "GPL";
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -c xdp_lb.bpf.c -o xdp_lb.bpf.o
sudo ip link set dev eth0 xdpgeneric obj xdp_lb.bpf.o sec xdp

调试 XDP 时应在容器或网络命名空间里挂到 veth 上:程序写错会把整张网卡的流量丢掉,包括远程登录用的 SSH。

Kubernetes 容器网络:Cilium

kube-proxy 的 iptables 模式把每个 Service 展开成 iptables 规则链。Cilium 用 eBPF 替代 kube-proxy 和大部分 iptables 规则:

维度 kube-proxy(iptables 模式) Cilium eBPF
Service 查找 KUBE-SERVICES 链上按规则顺序匹配,Service 越多越慢 hash map 查找,与 Service 数量基本无关
规则更新 规则集整体重写(iptables-restore),规模大时耗时明显 增量更新 map 中的单个条目
集群内访问 Service 每个包经过 conntrack + DNAT cgroup/connect4 在 connect() 时直接把目的地址改成后端 Pod,之后的包不再需要 NAT
NodePort / 外部流量 iptables DNAT + SNAT tc 或 XDP 程序处理,支持 DSR
网络策略 iptables 规则按 IP 匹配 按身份(由 Pod 标签派生的数字 identity)匹配,Pod IP 变化不需要改规则;可做 L7(HTTP 路径)策略
可观测 需要另外抓包 Hubble 直接输出每条流的策略判定结果

Cilium 同时用了本章所有的钩子:XDP 做 NodePort 加速和 DDoS 防护,tc 挂在每个 Pod 的 veth(或 netkit)上做策略和转发,cgroup sock_addr 做 socket 级负载均衡,sockops/sk_msg 做同机加速。GKE Dataplane V2 基于 Cilium,Calico 也提供了 eBPF 数据面。

其他网络用途

用途 机制
DDoS 防护 XDP 在驱动层按规则(LPM_TRIE 存黑名单网段、按特征匹配)直接 XDP_DROP;Cloudflare 公开过基于 XDP 的丢包方案
出向限速 tc 程序给 skb 设置发送时间戳(EDT,Earliest Departure Time),配合 fq qdisc 按时间发送,替代 HTB 的多级队列与锁(Cilium Bandwidth Manager)
SO_REUSEPORT 调度 sk_reuseport 程序决定新连接进入组内哪个 socket,实现按 CPU/按业务分流、进程热升级时的连接迁移
socket 查找 sk_lookup(5.9)程序决定包交给哪个监听 socket,一个 socket 可以接管整段 IP/端口范围
流量统计 cgroup_skb 按 cgroup 计量流量;Android 从 9 开始用 eBPF 统计每个应用的流量
可编程路由 LWT(轻量隧道)程序、SRv6(LWT_SEG6LOCAL)

十八、安全

容器共享宿主机内核,namespace、cgroup、capabilities 负责隔离和资源限制,但它们不负责"观察容器里正在发生什么"和"根据运行时行为动态拦截"。eBPF 补的是这一块。

三种安全能力

能力 目标 钩子 代表项目
分析诊断 事件发生后还原现场:谁、何时、执行了什么、访问了什么 kprobe、tracepoint、uprobe Tracee(Aqua)
检测告警 实时发现可疑行为:容器内起了 shell、写 /etc/passwd、提权、异常外连 syscall tracepoint、LSM、kprobe Falco(CNCF,Sysdig 贡献)
策略执行 在内核里阻止违规操作 LSM、bpf_send_signal、bpf_override_return、cgroup、XDP Tetragon(Cilium)、KubeArmor

eBPF 相对传统方案的优势:

  • 事件驱动:每次系统调用、每次文件打开都会触发,不存在采样工具的漏检
  • 在内核里就地过滤,只把命中规则的事件交给用户态,开销低
  • AppArmor、seccomp 需要预先写好策略、改策略要重启容器;eBPF 程序的规则在 map 里,用户态随时更新即时生效

阻断的三种方式与它们的差别

方式 时机 能否真正阻止 说明
tracepoint + bpf_send_signal(SIGKILL) 系统调用入口 不可靠 信号要等返回用户态时才处理,系统调用本身可能已经执行完成(文件已写入、连接已建立)。bpftrace 里对应 signal(),需要 --unsafe
kprobe + bpf_override_return 目标函数入口 能,但范围有限 让函数不执行、直接返回指定错误码,只对标注了 ALLOW_ERROR_INJECTION 的函数有效(包括大部分系统调用入口)
LSM 程序 LSM 钩子,内核做权限检查的位置 能 返回 -EPERM 即拒绝,与 SELinux/AppArmor 在同一个检查点,同步生效

LSM 程序示例(示意,未实测):禁止指定 cgroup 内的进程执行 /bin/sh。

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

const volatile __u64 target_cgid = 0;

SEC("lsm/bprm_check_security")
int BPF_PROG(deny_shell, struct linux_binprm* bprm, int ret)
{
    if (ret) return ret;                                  // 前一个 LSM 已拒绝
    if (bpf_get_current_cgroup_id() != target_cgid) return 0;

    char path[16];
    bpf_probe_read_kernel_str(path, sizeof(path), bprm->filename);
    if (__builtin_memcmp(path, "/bin/sh", 8) == 0)
        return -EPERM;
    return 0;
}

char LICENSE[] SEC("license") = "GPL";

启用条件:内核配置 CONFIG_BPF_LSM=y,且启动参数的 lsm= 列表中包含 bpf(cat /sys/kernel/security/lsm 查看)。部分发行版默认不包含,需要改 grub 配置后重启。

TOCTOU:系统调用参数不可信

在 sys_enter_openat 里读取路径参数做判断,存在检查时间与使用时间之间的竞争:

线程 A:openat(path_ptr) ──▶ [BPF 读 *path_ptr = "/tmp/ok",放行] ──▶ 内核 copy_from_user(*path_ptr)
线程 B:                         在这两步之间把 *path_ptr 改成 "/etc/shadow"

BPF 读到的和内核实际使用的不是同一份数据。攻击者可以借此绕过检测。对策:在内核已经把参数复制到内核内存之后的位置取数据——LSM 钩子(如 security_file_open 拿到的是已解析的 struct file)或 fentry 挂在内部函数上,而不是系统调用入口。Tetragon 等项目优先挂在 LSM 和内核内部函数上,原因就在这里。

与其他安全机制的关系

机制 与 eBPF 的关系
seccomp 使用的是 cBPF,只能看系统调用号和参数的值(不能解引用指针)。Docker/K8s 的 seccomp profile 走这条路
cgroup v2 设备控制 没有 devices.allow 文件,runc / systemd 生成 CGROUP_DEVICE 类型的 eBPF 程序实现
AppArmor / SELinux 同属 LSM 框架,BPF LSM 与它们叠加执行,任何一个拒绝即拒绝
iptables 网络层的访问控制可以由 tc/XDP/cgroup_skb 程序替代

eBPF 自身的攻击面

eBPF 的能力对攻击者同样有用:

  • 验证器漏洞提权:非特权用户加载精心构造的程序,利用验证器范围推演错误读写内核内存。对策:保持 kernel.unprivileged_bpf_disabled=1 或 2,及时更新内核
  • eBPF rootkit:已获得 root 的攻击者加载 BPF 程序持久化——挂 getdents64 出口用 bpf_probe_write_user 改写返回的目录项来隐藏进程和文件,或挂 XDP 实现隐蔽后门。对策:审计 bpf() 系统调用(auditd、Falco 规则)、定期用 bpftool prog list / bpftool link list 盘点、开启内核 lockdown(integrity/confidentiality 模式会限制 bpf_probe_write_user 和读内核内存)
  • 权限收敛:给 agent 只授予 CAP_BPF + CAP_PERFMON(或 CAP_NET_ADMIN),不要给 CAP_SYS_ADMIN;6.9 的 BPF token 允许把受限的 BPF 权限委派给容器内的非特权进程。程序签名机制在社区推进中

十九、内核行为定制

跟踪和网络之外,eBPF 正在成为"用程序替换内核策略"的通用机制。核心是 struct_ops:内核里很多子系统用一组函数指针描述一种策略(拥塞控制算法、调度类),struct_ops 允许用 BPF 程序实现这组函数指针并注册。

TCP 拥塞控制(5.6)

struct tcp_congestion_ops 的回调(ssthresh、cong_avoid、cwnd_event 等)可以用 BPF 实现,并通过 kfunc 调用内核已有的 tcp_slow_start()、tcp_reno_cong_avoid() 等函数。内核 selftests 里有 bpf_cubic、bpf_dctcp 的完整实现。

sudo bpftool struct_ops register bpf_cubic.bpf.o
sysctl -w net.ipv4.tcp_congestion_control=bpf_cubic

意义:换算法、改参数不必再编译内核模块,可以按业务灰度上线。

sched_ext:用 BPF 写 CPU 调度器(6.12)

sched_ext 新增一个调度类,其行为由 BPF struct_ops 程序定义:选择 CPU(select_cpu)、入队(enqueue)、分派(dispatch)等。

特性 说明
安全退出 BPF 调度器出错(任务长时间得不到调度、程序报错)时,内核自动卸载它,所有任务回到默认调度器(EEVDF)
快速迭代 改调度策略不用重新编译、重启内核
示例调度器 scx_simple(最简 FIFO/加权)、scx_lavd(面向游戏的延迟敏感调度,Valve/Igalia)、scx_rusty/scx_rustland(用户态 Rust 做决策)
使用场景 游戏与桌面交互延迟、特定负载(如 Meta 在数据中心试验)、调度算法研究

其他

机制 版本 用途
BPF iterator 5.8 遍历内核对象(任务、文件、socket、map),按自定义格式输出,替代解析 /proc
HID-BPF 6.3 修正 HID 设备(键盘、手写板)的报告描述符与数据,不必写内核驱动补丁
fmod_ret / 错误注入 5.7 修改内核函数返回值,用于故障注入测试
BPF timer / workqueue 5.15 / 6.10 程序内定时执行逻辑,不依赖事件触发
eBPF for Windows — 在 Windows 上提供 libbpf 兼容 API,使同一套 BPF 程序可在两个系统运行(开发中)
用户态运行时 — bpftime 等项目在用户态执行 BPF 字节码,用于 uprobe 加速和插件系统

二十、生产环境:开销、限制与容器

各类探针的开销

探针 单次开销(量级) 备注
tracepoint / raw_tracepoint 几十纳秒 最便宜
fentry / fexit 几十纳秒 直接跳转,无异常
kprobe 100 纳秒级 用 int3 或优化成跳转
kretprobe 比 kprobe 贵,且有 maxactive 限制(并发超过时丢事件)
uprobe 1-3 微秒 int3 陷入内核再返回,比 kprobe 贵一个数量级
uretprobe 比 uprobe 更贵 替换返回地址
USDT 未启用 0(nop),启用后同 uprobe
perf_event 采样 与频率成正比,99 Hz 可忽略
XDP 每包几十纳秒

总开销 = 单次开销 × 事件频率 + BPF 程序自身执行时间(通常几十到几百纳秒)+ 栈回溯(ustack 几微秒)。

估算:事件每秒 10 万次、每次 2 微秒 = 每秒 0.2 秒 CPU = 20% 一个核。sched_switch、malloc、tcp_sendmsg 都可能到这个量级,先用 funccount 数频率再决定挂不挂。

降低开销的手段:

  • 挂 tracepoint/fentry 而非 kprobe,挂 USDT 而非 uprobe
  • 过滤条件写在 BPF 侧(/pid == N/),不要把所有事件传到用户态再过滤
  • 聚合在 BPF 侧(count()、hist()),只把摘要传出来
  • 采样(memleak -s、profile -F)
  • per-CPU map 代替全局 map 计数,避免原子操作争用
  • ring buffer 代替 perf buffer
  • 限定 -p PID

bpftool prog profile 能直接测量某个已加载程序自身的 cycles/instructions。

栈回溯的前提

  • 用户态:frame pointer。ustack 顺着 rbp 链走,-fomit-frame-pointer(GCC/Clang -O2 默认)会让栈断在第一层。解决:编译加 -fno-omit-frame-pointer;发行版层面 Ubuntu 24.04+、Fedora 38+ 默认保留。DWARF 回溯(perf --call-graph dwarf 那种)eBPF 侧不支持,个别工具在用户态做后处理
  • 内核态:内核自带 ORC unwinder,kstack 总是完整的
  • 符号:主程序和库不能 strip;JIT 语言要导出 /tmp/perf-PID.map

内核与权限要求

需要 说明
内核 4.9+ 基本可用;5.4+ 才有 BTF 与 CO-RE;5.8+ ring buffer;越新越好
CONFIG_BPF_SYSCALL、CONFIG_BPF_JIT、CONFIG_DEBUG_INFO_BTF、CONFIG_KPROBES、CONFIG_UPROBES、CONFIG_TRACEPOINTS 主流发行版默认全开
权限 root,或 5.8+ 的 CAP_BPF + CAP_PERFMON(跟踪)+ CAP_NET_ADMIN(网络);kernel.unprivileged_bpf_disabled=1 是默认
/sys/kernel/debug 或 /sys/kernel/tracing 挂载 tracepoint 列表和 kprobe 事件在这里
kernel.perf_event_paranoid 采样类需要 ≤ 1 或 root
kernel.kptr_restrict=0 否则 kstack 全是 0 地址

在容器里跑 eBPF

  • 容器里的 BCC/bpftrace 需要 --privileged(或上面那组 capability)、挂载 /sys/kernel/debug、/sys/kernel/btf、/lib/modules
  • BCC 需要宿主机内核头文件:挂载 /usr/src 和 /lib/modules;libbpf CO-RE 工具不需要
  • 跟踪容器内进程:pid 是宿主机视角的 pid;用 --pidnss(runqlat)或 cgroup 内置变量按容器分组;uprobe 的路径写 /proc/PID/root/usr/lib/...
  • Kubernetes 上常见部署形态是 DaemonSet(Pixie、Cilium Hubble、Parca、Inspektor Gadget 都是这样)
  • 老内核(3.10 的 CentOS 7)不支持大部分功能,只能上 perf/ftrace

局限与应对

限制 原因 应对
内核版本决定可用特性 特性随版本逐步加入 生产最低 4.9 起步,建议 5.8+(ring buffer、CAP_BPF);用 bpftool feature probe 探测,程序里对可选特性做降级
栈只有 512 字节 内核栈本身很小,BPF 栈嵌在其中 大结构放 PERCPU_ARRAY 当临时缓冲区
合法代码被验证器拒绝 编译器优化后的形式验证器跟不上 看验证器日志最后几行;__always_inline;用常量收窄范围;新内核验证器更聪明
没有浮点 内核上下文不保存 FPU 状态 定点数;复杂计算放用户态
字符串处理 helper 很少 验证器难以证明变长操作安全 复杂解析放用户态
不能随意分配内存 程序可能运行在不能分配内存的上下文 预分配的 map;6.2 起 bpf_obj_new;6.9 起 arena
不能做长计算 指令数上限、不能睡眠(可睡眠程序除外) 拆成 tail call;计算放用户态
不能任意调用内核函数 只能用 helper/kfunc 需要的能力没有对应 helper 就做不了
kprobe 目标不稳定 函数改名、被内联、签名变化 优先 tracepoint / fentry;CO-RE;多版本兼容逻辑
老内核没有 BTF 5.2 前无,发行版默认开启更晚 BTFHub;或退回 BCC 现场编译
高频钩子开销 uprobe 每次微秒级;kprobe 挂在每秒千万次的函数上也会有可见开销 先 funccount 估频率;内核里聚合;采样
事件丢失 ring buffer 满、map 满、递归保护 监控 lost 计数和 recursion_misses;调大缓冲区;改为聚合
需要 root 或特定 capability 安全模型 最小授权;K8s 里以 DaemonSet + 特定 capability 部署
调试手段少 不能单步、没有 printf 以外的输出 bpf_printk + trace_pipe;BPF_PROG_TEST_RUN 用构造的输入跑网络程序;bpftool prog profile 看程序自身开销;veristat 统计验证复杂度

二十一、与 perf / ftrace / SystemTap 的比较

eBPF(BCC/bpftrace/libbpf) perf ftrace SystemTap DTrace
机制 内核虚拟机,事件时执行程序 perf_event 采样/计数,样本写 perf.data 内核函数跟踪框架,/sys/kernel/tracing 文件接口 编译成内核模块加载 Solaris/BSD/macOS 原生;Linux 版基于 eBPF
可编程 完全可编程,内核内聚合 不可编程(只能记录) 少量过滤和触发器 完全可编程 完全可编程
安全 验证器保证 安全 安全 内核模块,有崩溃风险 安全
数据量 摘要传出,小 全部样本落盘,大 文本 ring buffer 可编程 可编程
开销 低,可控 采样低,跟踪(perf trace)高 低 中 低
用户态跟踪 uprobe/USDT uprobe uprobe(有限) 有 有
内核版本 4.9+ 可用,5.x 好用 2.6.31+ 2.6.27+ 需要内核调试信息
典型用法 定制分析、常驻观测 快速 CPU 剖析、PMU 计数 函数调用图 function_graph、延迟跟踪 老系统深度跟踪

实践中:perf stat / perf top 看整体,perf record 做一次性 CPU 剖析,eBPF 做需要过滤、聚合、配对(延迟、泄漏)或长期常驻的分析,ftrace 的 function_graph 看内核函数调用树。


二十二、面试速答

eBPF 是什么,和内核模块有什么区别

内核中的受限虚拟机。用户态把 BPF 字节码通过 bpf() 系统调用交给内核,验证器在加载时静态证明程序一定结束、不越界、只调白名单 helper,通过后 JIT 成本机指令,挂载到钩子上由事件触发执行,状态存在 map 里与用户态共享。 安全来自验证器(静态)+ helper 白名单(接口)+ bpf_probe_read 的异常保护(运行时)+ capability(权限)。和内核模块相比:安全(验证器保证)、可移植(CO-RE)、随 fd 关闭自动卸载;代价是只能调白名单 helper、栈 512 字节、循环必须有界。

验证器具体检查什么

两遍:第一遍 DFS 检查控制流(无不可达指令、5.3 前无回边);第二遍沿所有路径模拟执行,为每个寄存器跟踪类型(ctx 指针、map value 指针、可能为空的指针、报文指针、标量)和取值范围,确保解引用前指针类型合法且偏移在边界内、map 查找结果判过空、报文访问前与 data_end 比较过、helper 参数类型匹配。用状态剪枝控制路径爆炸,上限 100 万条已处理指令。

kprobe、tracepoint、fentry、uprobe 的区别和实现

tracepoint 是编译进内核的静态点,static key 控制,稳定;kprobe 在任意指令处写断点或跳转,灵活但不稳定;fentry 利用函数入口的 ftrace nop 跳到 BPF trampoline,比 kprobe 快且参数带 BTF 类型;uprobe 在用户进程代码页写 int3,每次命中两次上下文切换,微秒级。选型:tracepoint > fentry > kprobe;用户态 USDT > uprobe。

BTF 和 CO-RE 解决什么问题

内核结构体布局随版本变。BTF 把内核类型信息嵌进内核本身;CO-RE 让 clang 把字段访问记录成"类型名 + 字段路径"的重定位项,libbpf 加载时查目标机 BTF 算出真实偏移再改写指令。结果是编译一次的静态二进制可以在不同内核上运行,目标机不需要内核头和 LLVM。

怎么用 eBPF 查内存泄漏

memleak -p PID:uprobe 挂 malloc/free 等,分配时记大小和调用栈到 map,释放时删掉,定期按栈汇总未释放字节数。连续几轮里持有量单调上涨的栈是泄漏点。它不判断可达性,所以逻辑泄漏也能显现,但正常的长期对象也在列表里,要看趋势,-o 过滤年轻分配。前提是被测程序有 frame pointer 和符号。

怎么用 eBPF 做 CPU 剖析,和 perf 的区别

profile -F 99:perf_event 定时器每 CPU 每秒 99 次触发,BPF 程序取当前用户态 + 内核态栈存到 STACK_TRACE map 计数,输出折叠栈喂 FlameGraph。区别是聚合在内核里完成,不像 perf record 把每个样本写盘,长时间采样输出仍然很小。CPU 不高但慢时用 offcputime 挂 sched_switch 看阻塞在哪。

eBPF 的开销怎么估

单次开销 × 事件频率 + 程序执行时间 + 栈回溯。tracepoint 几十纳秒、kprobe 百纳秒级、uprobe 微秒级。先用 funccount 数频率;过滤和聚合放在 BPF 侧,采样,限定进程。

XDP 为什么快,和 DPDK 有什么区别

XDP 在驱动收包时、分配 skb 之前运行,省掉 skb 分配、GRO、netfilter、路由,按批处理;XDP_DROP 不进协议栈,XDP_TX 直接从同一网卡发回,单核每秒千万包量级。DPDK 把网卡整个交给用户态轮询,完全绕过内核,要独占 CPU 核、自己实现协议栈;XDP 仍在内核里,只处理关心的包,其余 XDP_PASS 给正常协议栈,和内核的路由、socket、工具链共存。用户态确实需要收包时,XDP_REDIRECT 到 AF_XDP socket 零拷贝。

XDP 和 tc 程序怎么选

XDP 只有收包方向、没有 skb、需要驱动支持才快,适合在最早位置丢包或转发(DDoS、四层负载均衡)。tc 收发双向、有 skb 元数据、挂任何设备包括 veth,适合容器网络、策略、NAT、出向处理。

Cilium 为什么能替代 kube-proxy

kube-proxy 的 iptables 模式按规则链顺序匹配 Service,规则更新要整体重写。Cilium 把 Service → 后端存在 hash map 里,查找和更新都是 O(1) 级别;集群内访问在 connect() 时由 cgroup 程序直接改写目的地址,之后的包不需要 conntrack 和 DNAT;外部流量由 tc/XDP 处理。网络策略按 Pod 身份而非 IP 匹配。

sockmap 加速的原理和适用范围

sockops 程序在连接建立时把 socket 按五元组存进 SOCKHASH;sk_msg 程序在发送时用对端五元组查到对端 socket,调用 bpf_msg_redirect_hash 把数据直接放进对端接收队列,跳过两遍 TCP/IP 协议栈。只对同一主机上的 socket 对有效,典型是应用与 sidecar 之间。

用 eBPF 做安全阻断,为什么推荐 LSM 而不是 syscall tracepoint

tracepoint 里只能发信号,信号在返回用户态时才处理,系统调用可能已经完成;而且在系统调用入口读用户态参数存在 TOCTOU,攻击者可以在检查后、内核复制前改掉内存。LSM 钩子位于内核权限检查点,参数已在内核内存里,返回 -EPERM 同步拒绝。

生产环境部署 eBPF 要注意什么

内核 4.9+ 基本可用,5.8+ 较完整,确认 BTF 可用;最小权限(CAP_BPF + CAP_PERFMON/CAP_NET_ADMIN);被测程序保留 frame pointer 和符号;高频钩子先估开销;监控事件丢失;XDP/tc 程序不随进程退出,要有卸载与升级流程(用 bpf_link 管理);容器里要特权或对应 capability 并挂载 /sys/kernel/debug、/sys/kernel/btf;把 eBPF 当作攻击面审计。


附录:特性与内核版本、命令速查、参考资料

特性与内核版本

版本号为主线内核首次合入的版本;发行版可能回移植,以 bpftool feature probe 为准。

版本 特性
3.18 bpf() 系统调用、hash/array map、eBPF socket filter
4.1 kprobe 程序、tc cls_bpf
4.2 tail call
4.4 bpffs pin、tc direct-action、非特权 socket filter
4.6 STACK_TRACE map、bpf_get_stackid
4.7 tracepoint 程序
4.8 XDP
4.9 perf_event 程序
4.10 cgroup skb/sock 程序
4.14 sockmap、sk_skb
4.15 bpf_perf_event_read_value
4.16 BPF-to-BPF 调用、bpf_override_return(错误注入)
4.17 raw tracepoint、sk_msg、cgroup sock_addr
4.18 BTF、AF_XDP、bpf_get_stack
5.1 bpf_spin_lock
5.2 指令上限 100 万(原 4096)、全局变量
5.3 有界循环、bpf_send_signal
5.5 fentry/fexit(BPF trampoline)、bpf_probe_read_user/kernel 区分
5.6 struct_ops(TCP 拥塞控制)
5.7 BPF LSM、bpf_link、fmod_ret
5.8 ring buffer、CAP_BPF/CAP_PERFMON、BPF iterator、bpf_ktime_get_boot_ns
5.9 sk_lookup
5.10 可睡眠程序、bpf_redirect_peer
5.11 map 内存改为 memcg 计费,不再受 RLIMIT_MEMLOCK 限制
5.13 kfunc
5.15 BPF timer
5.17 bpf_loop()、kfree_skb 丢包原因
5.18 kprobe_multi、XDP 多缓冲区
6.2 bpf_obj_new 动态分配
6.4 open-coded 迭代器
6.6 tcx、uprobe_multi
6.7 netkit
6.9 BPF arena、BPF token
6.12 sched_ext

命令速查

目的 命令
列出探针 bpftrace -l 'tracepoint:syscalls:*'、bpftrace -lv tracepoint:sched:sched_switch
一行计数 bpftrace -e 'kprobe:X { @[comm] = count(); }'
一行延迟 bpftrace -e 'kprobe:X { @s[tid]=nsecs; } kretprobe:X /@s[tid]/ { @=hist(nsecs-@s[tid]); delete(@s[tid]); }'
内存泄漏 memleak-bpfcc -p PID -o 60000 5
内核泄漏 memleak-bpfcc 10
谁在分配 bpftrace -e 'uprobe:libc:malloc /pid==N/ { @[ustack]=sum(arg0); }'
on-CPU 火焰图 profile-bpfcc -F 99 -adf 30 > o.folded && flamegraph.pl o.folded > cpu.svg
off-CPU 火焰图 offcputime-bpfcc -p PID -f 30 > o.folded && flamegraph.pl --colors io o.folded > off.svg
调度延迟 runqlat-bpfcc 5、runqslower-bpfcc 10000
函数延迟 funclatency-bpfcc -p PID -u c:malloc
参数分布 argdist-bpfcc -p PID -H 'p:c:malloc(size_t s):size_t:s'
块 IO 延迟 biolatency-bpfcc 5、biosnoop-bpfcc
文件系统慢请求 ext4slower-bpfcc 10
page cache cachestat-bpfcc 5
缺页 bpftrace -e 'software:page-faults:1 { @[comm, ustack(4)]=count(); }'
OOM oomkill-bpfcc
TCP 连接 tcpconnect-bpfcc、tcpaccept-bpfcc、tcplife-bpfcc
重传/丢包 tcpretrans-bpfcc、tcpdrop-bpfcc
DNS 延迟 gethostlatency-bpfcc
已加载程序 bpftool prog list、bpftool prog profile id N duration 10 cycles
map 内容 bpftool map dump id N
生成 vmlinux.h bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
编译 BPF clang -O2 -g -target bpf -c x.bpf.c -o x.bpf.o && bpftool gen skeleton x.bpf.o > x.skel.h
XDP 挂载 ip link set dev eth0 xdpdrv obj x.bpf.o sec xdp
tc 挂载 tc qdisc add dev eth0 clsact && tc filter add dev eth0 ingress bpf da obj x.bpf.o sec tc
socket 重定向挂载 bpftool cgroup attach /sys/fs/cgroup/ sock_ops pinned /sys/fs/bpf/sockops、bpftool prog attach pinned /sys/fs/bpf/redir msg_verdict pinned /sys/fs/bpf/sock_map
struct_ops 注册 bpftool struct_ops register x.bpf.o
内核支持的特性 bpftool feature probe
当前启用的 LSM cat /sys/kernel/security/lsm

参考资料

  • 倪朋飞,《eBPF 核心技术与实战》,极客时间
  • Brendan Gregg,BPF Performance Tools,Addison-Wesley,2019
  • Liz Rice,Learning eBPF,O'Reilly,2023
  • 内核文档:Documentation/bpf/(验证器、map、程序类型、kfunc 等)
  • man 2 bpf、man 7 bpf-helpers、man bpftool
  • ebpf.io、docs.cilium.io 的 BPF 参考指南
  • 内核源码:kernel/bpf/verifier.c、kernel/bpf/syscall.c、include/uapi/linux/bpf.h、tools/testing/selftests/bpf/