Linux 基础体系 · 第 80/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux eBPF 与 bpftrace:Hook、Map、Verifier、观测脚本和安全

eBPF 是 Linux 内核中的一种受限程序执行机制。用户态程序把一段 BPF 字节码加载到内核,内核通过 verifier(验证器)检查其安全性,再把程序挂接到某个内核或用户态事件上。当事件发生时,内核执行这段程序,并通过 map、ring buffer 或 perf buffer 把结果交给用户态。

bpftrace 是建立在 eBPF 之上的高级追踪工具。它使用类似 awk 的脚本语法描述“在哪个 Hook 上执行什么动作”,负责把脚本编译、加载、附着,并读取结果。理解 bpftrace 不能只记住几条命令,因为脚本的行为最终受到 Hook 类型、内核上下文、Map 生命周期、verifier 规则、权限模型和数据传输路径的共同限制。


一、先建立整体模型:事件、Hook、程序、Map 和输出

一次典型的 eBPF 观测流程如下:

flowchart LR
    A[用户执行 bpftrace] --> B[解析脚本]
    B --> C[生成 BPF 程序与 Map]
    C --> D[系统调用 bpf]
    D --> E[Verifier 检查]
    E -->|通过| F[程序加载到内核]
    F --> G[附着到 Hook]
    H[进程/内核事件] --> G
    G --> I[更新 Map 或写入 Ring Buffer]
    I --> J[bpftrace 用户态读取]
    J --> K[聚合、打印或清理]

这里有五个容易混淆的对象:

  • Hook:程序被执行的位置或事件入口。
  • BPF 程序:在内核上下文运行的受限代码。
  • Map:内核中用于保存状态、计数、索引和配置的键值存储。
  • Verifier:加载前检查 BPF 程序是否满足安全和可证明执行的规则。
  • 用户态工具:例如 bpftrace、libbpf 程序,负责加载、附着、读取和解除程序。

例如下面的脚本:

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat
{
    @[comm] = count();
}
'

它并不是“在用户态轮询系统调用”。实际过程是:

  1. bpftrace 找到 syscalls:sys_enter_openat 这个 tracepoint。
  2. 编译出一个在该 tracepoint 上运行的 BPF 程序。
  3. 创建一个保存计数的 Map。
  4. 将程序附着到 tracepoint。
  5. 每次进程进入 openat 系统调用时,内核执行程序。
  6. count() 更新以进程名为键的计数。
  7. bpftrace 在退出或按指定间隔时读取 Map 并格式化输出。

@ 是 bpftrace 对 Map 的脚本级表示,并不是内核 C API 中的固定语法。


二、Hook:eBPF 到底挂在哪里

2.1 Hook 的含义

Hook 是一个可被观测或控制的执行点。不同 Hook 提供不同的上下文、稳定性和权限边界。

从“观察谁”的角度,常见 Hook 可以分为以下几类:

Hook 类型 典型用途 稳定性与限制
Tracepoint 观测系统调用、调度、块设备、网络等内核事件 事件名和字段通常比内核函数稳定
kprobe/kretprobe 进入或返回某个内核函数 依赖内核符号和实现细节
fentry/fexit 进入或退出 BTF 描述的内核函数 开销通常较低,但需要内核和 BTF 支持
Uprobe/uretprobe 进入或返回用户态 ELF 函数 依赖二进制符号、路径和编译方式
USDT 用户程序主动提供的静态探针 需要程序预埋探针
Perf event/profile 定时采样 CPU、硬件或软件事件 适合统计和火焰图,不等同于每次事件追踪
LSM 安全策略检查点 可以影响访问结果,风险高于只读观测
cgroup 按控制组观测或控制进程 适合容器和服务边界
tc/XDP 网络包处理路径 可能直接改变或丢弃网络包

Hook 的选择决定了你能看到什么。

例如:

  • 想知道“某个系统调用调用了多少次”,tracepoint 通常比 kprobe 更合适。
  • 想知道“某个具体内核函数的参数和返回值”,kprobe 或 fentry 更直接。
  • 想知道“函数为什么慢”,只在函数入口和返回处打点,可以计算延迟,但不能自动得到完整调用栈。
  • 想知道“CPU 时间花在哪里”,profile 或 perf 采样更合适。
  • 想知道“线程在等待什么”,需要结合调度事件、阻塞点或 off-CPU 分析,而不是只追踪系统调用入口。

2.2 Tracepoint:首选的稳定观测入口

Tracepoint 是内核源码中定义的静态事件。系统通过 tracefs 暴露它们,例如:

sudo bpftrace -l 'tracepoint:syscalls:sys_enter_openat'

也可以查看字段:

sudo bpftrace -lv 'tracepoint:syscalls:sys_enter_openat'

典型输出会包含类似字段:

tracepoint:syscalls:sys_enter_openat
    int __syscall_nr
    int dfd
    const char * filename
    int flags
    umode_t mode

具体字段可能随架构和内核版本变化,因此应以本机 -lv 输出为准,而不是盲目复制其他机器上的脚本。

tracepoint 参数通常是事件记录中的字段,bpftrace 通过 args->字段名 访问。例如:

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat
{
    printf("pid=%d comm=%s filename=%s\n", pid, comm, str(args->filename));
}
'

这里的 args->filename 是用户进程传入的指针。str() 会尝试从该地址读取字符串;它不是无限长度读取,bpftrace 和内核 helper 会受到长度、地址有效性和上下文限制。打印用户输入本身也可能泄露敏感信息,因此不应在生产环境对所有进程无条件执行。

2.3 kprobe 和 kretprobe:灵活,但依赖实现

kprobe 可以在内核函数进入时执行,kretprobe 可以在函数返回时执行:

sudo bpftrace -e '
kprobe:do_sys_openat2
{
    @calls[comm] = count();
}
'

但是,do_sys_openat2 是否存在、是否可探测、参数如何解释,都取决于当前内核版本、架构、配置和编译优化。内核函数不是稳定用户态 ABI。即使两个发行版都基于同一主版本,函数名也可能不同。

列出可能的函数:

sudo bpftrace -l 'kprobe:*open*'

这只是当前系统可解析到的符号列表,不代表每个符号都适合作为长期生产接口。

kretprobe 还有一个重要语义:它观察的是函数返回,而不是“原始调用者立即得到结果”。如果函数被内联、递归、异常返回或受到实现限制,探测结果可能和源码直觉不同。对于系统调用延迟,通常优先选择 sys_enter_*sys_exit_* tracepoint,因为它们的入口和出口语义更明确。

2.4 fentry/fexit:依赖 BTF 的函数级入口

较新的内核支持基于 BTF 的 fentryfexit。BTF 是内核类型信息,能够描述函数参数、结构体和字段,使程序不必完全依赖手工偏移。

例如,某些 bpftrace 版本可以使用:

sudo bpftrace -e '
fentry:vfs_read
{
    @calls[comm] = count();
}
'

这类能力受内核版本、BTF 是否安装、bpftrace 版本和目标函数是否支持影响。检查本机能力:

ls -l /sys/kernel/btf/vmlinux
sudo bpftrace -l 'fentry:*read*'

没有 /sys/kernel/btf/vmlinux 并不必然意味着系统完全不能使用 eBPF,但会限制 CO-RE、fentry 等依赖 BTF 的功能。

2.5 Uprobe 和 USDT:用户态观测不是“内核 Hook”

Uprobe 把程序附着到用户态 ELF 文件的函数或地址上:

sudo bpftrace -e '
uprobe:/usr/lib/x86_64-linux-gnu/libc.so.6:malloc
{
    @malloc[comm] = count();
}
'

路径和符号名称必须以本机文件为准。静态链接、符号被裁剪、函数内联、PLT 调用方式和容器文件系统都会影响结果。

USDT 是用户程序主动定义的静态探针,例如某些数据库、JVM 或语言运行时提供的探针。相比对内部函数下 uprobe,USDT 的语义通常更接近应用作者承诺的事件接口,但仍需确认程序是否编译了探针以及探针参数定义。


三、上下文决定你能做什么

BPF 程序不是运行在普通用户态线程中。它执行时处于某个特定内核上下文,例如:

  • 进程上下文;
  • 硬中断或软中断相关上下文;
  • NMI 上下文;
  • XDP 或 TC 网络处理上下文;
  • LSM 或 cgroup 回调上下文。

上下文决定可调用的 helper、是否允许睡眠、可以使用哪些 Map 操作,以及程序应该多快返回。

例如,网络 XDP 程序运行在非常早的收包路径,不能执行可能阻塞的操作,也不能像普通程序一样调用任意内核函数。某些支持 sleepable BPF 的程序类型允许更丰富的操作,但这不是所有 Hook 的默认能力。

这解释了一个常见失败:

program is not allowed to call helper ...

这不一定是 helper 不存在,而可能是当前程序类型或当前执行上下文不允许调用它。

同样,printf() 不是内核直接向终端打印。bpftrace 通常将调试输出通过 perf buffer 或 ring buffer 传给用户态,再由 bpftrace 格式化。高频事件中每次 printf() 都产生数据传输,开销远高于只更新一个计数 Map。


四、Map:在事件之间保存状态

4.1 Map 的抽象

Map 是内核管理的键值存储:

M:KVM: K \rightarrow V

其中:

  • KK 是键,例如 PID、线程 ID、文件描述符或二元组;
  • VV 是值,例如计数、时间戳、字节数或状态结构;
  • M[k]M[k] 表示当前键对应的值。

没有 Map,BPF 程序通常只能处理单个事件,无法把入口和出口配对,也无法跨事件累计统计。

bpftrace 中:

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read
{
    @bytes[comm] = sum(args->count);
}
'

@bytes[comm] 可以理解为:

key   = comm
value = 累积字节数

sum() 的实际存储和更新由 bpftrace 生成的 BPF 程序实现,并不意味着用户态脚本拥有一个普通的全局变量。

4.2 计数、直方图和数组

常见聚合操作包括:

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read
{
    @count[comm] = count();
    @bytes[comm] = sum(args->count);
    @size[comm] = hist(args->count);
}
'

三者含义不同:

  • count() 统计事件次数;
  • sum(x) 累加 x
  • hist(x) 统计值落入各个二进制桶的次数。

直方图不是平均值。假设读取大小依次为:

1, 2, 3, 1000

平均值为:

1+2+3+10004=251.5\frac{1+2+3+1000}{4}=251.5

但直方图会显示三个小读和一个大读,能够暴露长尾或双峰分布;平均值会被大值拉高,无法表达分布结构。

bpftrace 还常用线性直方图:

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read
{
    @linear = lhist(args->count, 0, 8192, 512);
}
'

其中 0 是下界,8192 是上界,512 是桶宽。具体语法以本机 bpftrace 版本为准,可用 bpftrace --helpman bpftrace 检查。

4.3 Map 键的选择决定统计是否有意义

以下三种脚本回答的是三个不同问题:

# 按进程名
@[comm] = count();

# 按进程 ID
@[pid] = count();

# 按线程 ID
@[tid] = count();

comm 通常是线程名,不是稳定的进程标识。多个进程可以同名,一个多线程进程也可能有不同线程名。pid 在 bpftrace 中通常表示进程 ID,tid 表示线程 ID;在 Linux 内核内部,许多调度和系统调用路径实际以线程为单位运行。

因此:

  • 统计服务类别时可以用 comm
  • 区分进程实例时使用 pid
  • 配对一个线程的入口和出口时使用 tid
  • 容器场景还可能需要 cgroup、namespace 或宿主 PID 维度。

4.4 并发更新与 per-CPU Map

多个 CPU 可能同时执行同一个 BPF 程序。如果所有 CPU 更新同一个共享计数,就必须考虑并发一致性和缓存争用。

一种方案是使用原子操作或内核提供的并发安全 Map 更新;另一种方案是使用 per-CPU Map,让每个 CPU 保存一个局部值,读取时再汇总:

C=cpu=0N1CcpuC = \sum_{cpu=0}^{N-1} C_{cpu}

per-CPU 设计减少了更新时的锁竞争,但读取和汇总成本增加,而且结果是某一时刻各 CPU 局部值的组合,不应被误解为严格事务快照。

bpftrace 会根据聚合操作选择适当的 Map 表示,但工程师仍要考虑键数量。以下脚本可能产生大量 Map 项:

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat
{
    @[str(args->filename)] = count();
}
'

如果攻击者或应用不断生成不同路径,Map 会持续增长,最终消耗内核内存或达到 Map 容量限制。按 comm、PID 或有限枚举集合聚合,通常更可控。

4.5 Map 不是消息队列

Map 适合保存状态和聚合结果,不适合传输每一个高频事件。若每次事件都要传递一条结构化记录,应使用 ring buffer 或 perf buffer。

两者的基本区别是:

  • Map:程序写入键值,用户态按键读取;
  • Ring buffer:程序追加事件记录,用户态按顺序消费;
  • Perf buffer:按 CPU 通道传递事件,历史上使用广泛;
  • printf():通常是调试和低频观察接口,不应当作为高吞吐日志系统。

如果需要记录每个请求的开始时间、结束时间和返回值,通常有两种设计:

  1. 用 Map 保存入口状态,出口计算延迟后更新直方图;
  2. 出口构造结构化事件写入 ring buffer,由用户态保存明细。

第一种更省数据量,第二种更适合离线关联,但更容易因为事件速率过高造成丢失或背压问题。


五、Verifier:不是编译器,而是可证明安全的执行检查器

5.1 Verifier 要证明什么

BPF 程序加载时,内核 verifier 会分析程序的控制流、寄存器状态、指针类型、内存访问和 helper 调用,目标是确保程序不会进行内核不允许的操作。

可以把一个被接受的程序粗略表示为满足以下条件:

PAP \in \mathcal{A}

其中 A\mathcal{A} 是当前内核、程序类型和权限共同允许的程序集合。这个集合不是固定不变的:

  • 内核版本可能改变允许的 helper;
  • 程序类型不同,允许的上下文和访问范围不同;
  • BTF、CONFIG 选项和架构会改变能力;
  • 权限和 LSM 策略会决定是否允许加载或附着。

Verifier 通常检查以下性质:

  1. 所有路径上的寄存器和栈状态可推导;
  2. 访问内存前能够证明边界合法;
  3. 指针类型没有被非法转换;
  4. 栈和 Map 值已经初始化;
  5. 循环是有界的,程序复杂度在限制内;
  6. helper 调用满足当前程序类型的约束;
  7. 程序不会执行任意不可控的内核代码。

它不是在证明“这个程序的业务逻辑正确”,也不是在证明“生产环境一定没有性能问题”。

5.2 一个边界检查的推导

设 BPF 程序拿到一个数据包指针区间:

[data, data_end)[data,\ data\_end)

程序想读取 4 字节整数 *(u32 *)data。要证明访问合法,必须证明:

data+4data_enddata + 4 \le data\_end

因此典型 BPF C 代码会写成:

void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;

if (data + 4 > data_end)
    return XDP_ABORTED;

__u32 value = *(__u32 *)data;

推导过程是:

  1. data 是 verifier 认可的 packet pointer;
  2. data_end 是同一数据包的边界指针;
  3. 程序先检查 data + 4 不超过边界;
  4. 只有在检查成立的路径上读取 4 字节;
  5. verifier 将该路径上的 data 访问标记为安全。

如果去掉检查:

__u32 value = *(__u32 *)data;

即使运行时某些数据包总是足够长,verifier 也不能仅凭经验接受,因为它必须面对所有可能的输入包。

5.3 指针类型不是普通整数

BPF verifier 会跟踪寄存器的类型,例如:

  • packet pointer;
  • packet end pointer;
  • Map value pointer;
  • context pointer;
  • kernel pointer;
  • user pointer;
  • scalar。

把一个指针转换成整数,或者对一个普通整数进行加法,不能自动保留原来的安全边界。于是下面这类逻辑可能失败:

void *p = ctx->data;
long offset = read_offset_from_map();
void *q = p + offset;

如果 verifier 无法证明 offset 在合法范围内,就不能允许通过。正确做法通常是先进行显式范围检查,并让控制流足够简单,使 verifier 能够推导出检查结果。

5.4 有界循环和复杂度

早期 BPF 程序通常要求没有普通循环,现代内核允许部分可证明有界的循环,但仍然必须能证明循环会结束,并且总执行复杂度可控。

例如:

#pragma unroll
for (int i = 0; i < 16; i++) {
    ...
}

固定小循环通常更容易被 verifier 展开和检查,但会增加程序指令数。循环边界来自输入数据时:

for (int i = 0; i < packet_len; i++) {
    ...
}

如果 packet_len 没有被限制在明确上界内,程序可能被拒绝。即使通过,过大的上界也可能导致 verifier 分析时间和程序执行时间增加。

内核对 BPF 程序复杂度、指令数量、栈空间和 Map 资源有多种限制。具体数值随内核版本和程序类型变化,不应把某个版本的固定上限当作所有发行版的永久保证。

5.5 verifier 日志是首要诊断材料

加载失败时,应保留 verifier 日志,而不是只看 bpftrace 的最后一行错误:

sudo bpftrace -v -e '
kprobe:some_function
{
    ...
}
'

对于 libbpf 程序,可以增加 verifier log buffer 并打印完整日志。常见错误及含义包括:

invalid mem access 'scalar'

程序把 verifier 只能当作普通数值的寄存器,当成了可解引用指针。

R1 type=ctx expected=...

当前程序类型要求某种上下文,但传入的寄存器类型不符合要求。

invalid indirect read from stack

从 BPF 栈读取了尚未初始化或边界无法证明的内容。

back-edge from insn ... to ...

循环回跳无法被 verifier 证明为有界。

program too large or too complex

程序或控制流超过当前内核和加载路径允许的复杂度。

诊断时应按“哪条路径、哪个寄存器、哪次内存访问”回看代码,而不是简单地增加任意边界判断。边界检查必须与实际访问对象和控制流相联系,verifier 才能利用它。

5.6 verifier 通过不代表程序安全无害

Verifier 主要保证 BPF 程序不会以任意方式破坏内核内存或执行不可证明的控制流。它不保证:

  • 不会暴露密码、令牌或业务数据;
  • 不会观察到隐私敏感的文件路径和命令行;
  • 不会因为高频事件造成显著开销;
  • 不会因为大 Map 消耗过多内核内存;
  • 不会通过 LSM、tc 或 XDP 改变系统行为;
  • 不会让用户态读取敏感事件。

所以“verifier 已通过”只是加载安全的一个条件,不是完整的生产安全审计。


六、一个完整观测例子:统计 openat 延迟和错误

6.1 先确认环境

现代发行版通常需要安装 bpftrace、内核调试接口和相应权限。先检查:

uname -r
bpftrace --version
ls -ld /sys/kernel/tracing /sys/kernel/debug/tracing 2>/dev/null
sudo bpftrace -l 'tracepoint:syscalls:sys_enter_openat'
sudo bpftrace -lv 'tracepoint:syscalls:sys_exit_openat'

如果找不到 tracepoint,可能是:

  • 内核未启用对应 tracepoint;
  • tracefs/debugfs 未挂载;
  • bpftrace 版本或探针名称不同;
  • 当前容器没有看到宿主机的 tracing 文件系统;
  • 权限或 lockdown 策略阻止访问。

不要直接假设所有发行版都使用相同路径。现代系统通常使用 /sys/kernel/tracing,较旧环境经常通过 debugfs 暴露 /sys/kernel/debug/tracing

6.2 最小的计数脚本

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat
{
    @calls[comm] = count();
}

tracepoint:syscalls:sys_exit_openat
/args->ret < 0/
{
    @errors[comm, args->ret] = count();
}
'

脚本包含两个独立 Hook:

  1. 进入 openat 时,按线程名增加调用次数;
  2. 返回 openat 时,如果返回值小于 0,按线程名和错误码增加失败次数。

Linux 系统调用失败通常返回负的错误码,例如 -2 对应 ENOENT-13 对应 EACCES。这里统计的是内核 tracepoint 中的原始返回值;用户态 libc 通常会把它转换为返回 -1 并设置 errno,两者不要混为一谈。

预期输出类似:

@calls[nginx]: 1240
@errors[nginx, -2]: 37
@errors[nginx, -13]: 4

这只能说明事件数量和错误返回分布,不能说明每个错误对应哪个路径,也不能证明调用者最终是否重试。

6.3 使用入口和出口 Map 计算延迟

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat
{
    @start[tid] = nsecs;
}

tracepoint:syscalls:sys_exit_openat
/@start[tid]/
{
    $lat_us = (nsecs - @start[tid]) / 1000;
    @latency_us[comm] = hist($lat_us);
    delete(@start[tid]);
}
'

数据流如下:

  1. 线程进入 openat
  2. tid 为键写入当前纳秒时间戳;
  3. 同一线程离开 openat
  4. 读取入口时间;
  5. 计算:

Lμs=texittenter1000L_{\mu s} = \frac{t_{exit} - t_{enter}}{1000}

其中:

  • tentert_{enter}texitt_{exit} 是内核时间戳,单位为纳秒;
  • LμsL_{\mu s} 是近似微秒延迟;
  • hist() 将延迟放入对数桶。
  1. 删除 @start[tid],避免短生命周期线程不断留下状态。

输出类似:

@latency_us[worker]:
[4, 8)          183
[8, 16)         901
[16, 32)        126
[32, 64)         21
[128, 256)        3

这表示大多数调用落在 8 到 16 微秒之间,但仍有少量调用超过 128 微秒。不要把桶的边界当作精确百分位数;直方图只能近似重建分布。

6.4 这个脚本的边界

入口和出口配对使用 tid,因为同一个线程通常按顺序执行系统调用。但是仍存在几个边界:

  • 如果入口事件丢失而出口事件存在,出口不会更新直方图;
  • 如果程序被停止或附着过程发生在调用中间,Map 中可能没有入口;
  • 如果脚本在异常情况下没有执行清理,Map 可能暂时保留旧状态;
  • tid 被复用时,过期状态可能与新线程混淆,因此实际复杂脚本常加入 PID、时间检查或更严格清理;
  • 这测量的是从系统调用入口到出口的时间,不等同于应用从调用前到拿到结果的全部时间;
  • 进程可能在内核中睡眠,延迟包含调度等待和 I/O 等待,但仅凭这个直方图不能区分原因。

若要区分“CPU 上执行”与“睡眠等待”,需要结合调度 tracepoint、block I/O 事件或 off-CPU 分析,而不是把所有长延迟都归因于磁盘。


七、bpftrace 观测脚本的几个关键语法

7.1 按进程过滤

可以把目标 PID 作为脚本参数:

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_read
/pid == $1/
{
    @reads[comm] = count();
}
' 12345

$1 是 bpftrace 的位置参数。过滤条件在程序中执行,事件仍可能到达 Hook,只是非目标进程快速返回。对于极高频 Hook,尽早过滤有助于减少后续 Map 操作和数据处理,但不能把它误认为完全没有 Hook 开销。

也可以使用工具提供的进程过滤选项;不同 bpftrace 版本的命令行参数应以本机帮助为准。

7.2 过滤返回值

sudo bpftrace -e '
tracepoint:syscalls:sys_exit_read
/args->ret < 0/
{
    @[comm, args->ret] = count();
}
'

这里 args->ret 是系统调用返回值。对于 read,非负值通常是实际读取字节数,负值表示错误。不能把所有负值都直接打印成字符串错误名,除非工具版本提供对应转换;稳妥做法是结合 errno 表或用户态程序解释。

7.3 查看当前可用字段和探针

探针存在但字段不对,是脚本失败的常见原因:

sudo bpftrace -lv 'tracepoint:syscalls:sys_enter_read'
sudo bpftrace -lv 'kprobe:vfs_read'

tracepoint 通常有明确字段;kprobe 的参数可能需要依赖 BTF、架构 ABI 或函数实现。不要把 tracepoint 的 args->count 语法直接套到任意 kprobe 上。

7.4 控制输出频率

下面的脚本会对每次调度事件打印一行,通常非常嘈杂:

sudo bpftrace -e '
tracepoint:sched:sched_switch
{
    printf("%s -> %s\n", comm, args->next_comm);
}
'

更可控的写法是先聚合:

sudo bpftrace -e '
tracepoint:sched:sched_switch
{
    @[comm] = count();
}
'

如果确实需要明细事件,应考虑:

  • 限定 PID、cgroup 或 CPU;
  • 只采样一部分事件;
  • 使用 ring buffer 传递结构化数据;
  • 限制运行时间;
  • 明确丢失事件的监测方式。

八、eBPF 与 perf、火焰图和 strace 的关系

8.1 eBPF 追踪不等于 perf 采样

perf 采样通常按固定频率观察当前执行位置。若采样频率为 ff,运行时间为 TT,理论样本数近似为:

NfTN \approx fT

它回答的是“CPU 时间大致分布在哪里”,适合生成 CPU 火焰图。它不保证每一次函数调用都被记录。

eBPF tracepoint、kprobe 或 uprobe 可以对每次事件执行,但事件速率可能远高于采样速率。因此:

  • 找 CPU 热点:优先 perf 或 profile
  • 找某个系统调用的错误分布:优先 tracepoint;
  • 找延迟长尾:入口/出口 Map 加直方图;
  • 找阻塞原因:调度、I/O、锁和系统调用事件结合;
  • 找用户态函数参数:uprobe 或 USDT。

如果用 eBPF 对所有高频函数逐次记录调用栈,成本可能显著高于采样。

8.2 eBPF 与 strace 的边界

strace 在系统调用边界观察单个进程,能够显示参数、返回值和 errno,适合快速确认“程序调用了什么”。例如:

strace -f -ttT -p 12345

其中 -T 会显示系统调用耗时,-f 跟踪线程或子进程。

eBPF 的优势是:

  • 可以按进程名、PID、cgroup、返回值聚合;
  • 可以对全系统观测而不逐行输出;
  • 可以进入系统调用背后的内核函数;
  • 可以结合调度、块设备、网络和用户态探针。

strace 的优势是单进程调用序列直观,参数解码成熟。eBPF 看到的是内核事件和程序上下文,未必能恢复 libc 层面的语义。例如应用使用缓冲 I/O 时,一次用户态写操作可能对应不同数量的内核 write 系统调用。


九、生命周期:加载、附着、运行、读取和清理

一个 BPF 程序从创建到消失大致经历以下状态:

stateDiagram-v2
    [*] --> Scripted
    Scripted --> Compiled
    Compiled --> Verifying
    Verifying --> Rejected
    Verifying --> Loaded
    Loaded --> Attached
    Attached --> Running
    Running --> Detached
    Running --> Failed
    Detached --> [*]
    Failed --> [*]
    Rejected --> [*]

9.1 加载

用户态通过 bpf() 系统调用创建 Map、加载程序或查询对象。verifier 在加载阶段检查程序。加载失败时,程序不会进入运行状态。

9.2 附着

加载成功不等于已经接收事件。程序还必须附着到具体 Hook。附着可能失败,因为:

  • 探针不存在;
  • 内核不支持该程序类型;
  • 目标函数不可探测;
  • 权限不足;
  • 另一个机制占用冲突资源;
  • BTF 或 tracefs 不可用。

9.3 运行

事件触发后,BPF 程序在内核中执行。Map 通常在程序运行期间存在。bpftrace 退出时,程序和临时 Map 通常会被清理。

如果使用 libbpf 或 bpftool prog load 将对象 pin 到 bpffs,例如:

sudo mount -t bpf bpf /sys/fs/bpf 2>/dev/null || true
sudo bpftool prog show
sudo bpftool map show

被 pin 的对象可以脱离原始用户态进程继续存在。这样适合长期服务,但也带来残留风险:用户态程序崩溃后,程序、Map、Hook 可能仍然存在。生产系统必须明确 pin 路径、所有者、退出清理和升级策略。

9.4 验证和恢复

观察结束后应确认没有残留:

sudo bpftool prog show
sudo bpftool map show

bpftool 的输出格式和可用子命令随发行版版本变化。不要只根据进程是否退出判断内核对象已经清理,尤其是 pin 过的对象。

若发现残留程序:

  1. 记录程序 ID、类型、附着点和创建者;
  2. 确认不是其他生产观测任务;
  3. 优先通过管理程序正常 detach;
  4. 对明确无主的对象,再使用 bpftool 或删除 pin 文件清理;
  5. 清理后重新检查程序和 Map 列表。

直接删除系统中所有 BPF 对象可能中断网络、安全策略或关键观测任务。


十、权限、安全能力与发行版差异

10.1 谁可以加载和观察

eBPF 权限不是单一开关。现代 Linux 通常区分以下能力:

  • CAP_BPF:与 BPF 操作相关的能力;
  • CAP_PERFMON:性能监控和部分观测操作;
  • CAP_SYS_ADMIN:旧内核或某些高权限路径仍可能需要;
  • 文件、tracefs、debugfs 和目标进程本身的访问权限;
  • LSM、SELinux、AppArmor 和内核 lockdown 策略。

不同内核版本对 capability 的细分不同。不能简单地说“有 root 就一定成功”,也不能把“非 root 不能使用 eBPF”当作现代系统的普遍结论。

检查当前身份:

id
capsh --print 2>/dev/null

检查 BPF 相关限制:

sysctl kernel.unprivileged_bpf_disabled 2>/dev/null

发行版可能通过内核配置禁用非特权 BPF,也可能限制 perf 事件:

sysctl kernel.perf_event_paranoid 2>/dev/null

容器中执行 bpftrace 通常还需要宿主机内核能力、相应 capability、tracefs 挂载和目标 namespace 视图。容器内安装了 bpftrace,并不等于它可以观测宿主机所有进程。

10.2 观测本身可能泄露数据

下面的脚本会读取文件名:

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat
{
    printf("%s %s\n", comm, str(args->filename));
}
'

文件名可能包含:

  • 用户名、租户 ID;
  • 临时令牌;
  • 配置文件路径;
  • 业务对象标识;
  • 密钥或隐私信息。

读取用户指针也可能失败,或者只得到截断字符串。生产观测应优先记录计数、错误码、大小和延迟分布;确需记录参数时,应限制目标进程、脱敏并缩短保存时间。

uprobe 还可能读取函数参数、HTTP URL、SQL 文本或内存中的凭据。即使脚本不修改系统行为,它也可能构成敏感数据访问。

10.3 观测与控制的风险不同

只读追踪和行为控制必须分开评估。

  • tracepoint、kprobe、uprobe 通常用于观测;
  • LSM BPF 可以拒绝文件、进程或网络操作;
  • cgroup BPF 可以参与套接字、设备和进程控制;
  • tc/XDP 可以修改、转发或丢弃数据包。

一个 verifier 通过的 tc/XDP 程序可能完全合法,但如果逻辑错误,仍会导致网络包丢失。一个合法的 LSM 程序也可能阻止关键服务启动。因此控制类 Hook 应有:

  • 明确的加载和回滚路径;
  • 最小权限;
  • 仅作用于目标 cgroup 或接口;
  • 预生产验证;
  • 断开管理通道时的恢复方案。

十一、生产开销:事件频率比脚本长度更重要

eBPF 程序运行在内核路径中,单次执行通常很短,但总开销近似受以下因素共同影响:

CtotalNevent×(Chook+Cmap+Coutput)C_{\text{total}} \approx N_{\text{event}} \times (C_{\text{hook}}+C_{\text{map}}+C_{\text{output}})

其中:

  • NeventN_{\text{event}}:事件数量;
  • ChookC_{\text{hook}}:进入 BPF 程序和执行过滤逻辑的成本;
  • CmapC_{\text{map}}:Map 查找、更新和并发协调成本;
  • CoutputC_{\text{output}}:向 ring buffer、perf buffer 或调试输出传递数据的成本。

这不是一个跨内核版本的性能保证,只是分析开销来源的模型。

例如,下面两个脚本观测的是同一个高频事件:

# 聚合
tracepoint:sched:sched_switch
{
    @[comm] = count();
}
# 每次输出
tracepoint:sched:sched_switch
{
    printf("%s -> %s\n", comm, args->next_comm);
}

第二个脚本除了 Hook 和 Map 之外,还产生大量用户态传输和格式化工作,通常风险更高。

在生产环境启用脚本时,应记录:

  • 脚本的 Hook 类型和事件频率;
  • Map 的键基数和最大容量;
  • 是否逐事件输出;
  • 是否使用调用栈;
  • 是否启用用户内存读取;
  • 运行时间和自动停止条件;
  • 是否能观察事件丢失。

bpftrace 退出时通常会打印聚合 Map,但这不是可靠的长期存储。要构建持续观测系统,应使用 libbpf 等库编写守护进程,定义版本化事件结构、丢失计数、重启策略和资源上限。


十二、常见失败与诊断路径

12.1 “探针不存在”

错误可能类似:

Cannot attach to probe ...

检查:

sudo bpftrace -l 'tracepoint:*open*'
sudo bpftrace -l 'kprobe:*open*'

如果 tracepoint 存在而 kprobe 不存在,优先使用 tracepoint。若只有函数探针,需要根据当前内核符号调整名称,不要照搬其他系统的函数名。

12.2 “权限不足”

表现可能是:

Permission denied
Operation not permitted
Could not open perf buffer

诊断顺序:

  1. 确认当前用户和 capability;
  2. 检查 tracefs/debugfs 是否可读;
  3. 检查 kernel.unprivileged_bpf_disabled
  4. 检查 perf_event_paranoid
  5. 检查 SELinux、AppArmor 和 lockdown 日志;
  6. 确认容器是否拥有宿主机所需资源。

不应为了让脚本运行而长期赋予整个服务 CAP_SYS_ADMIN。应根据实际 Hook 和内核版本缩小权限,并使用专门的观测账户或受控服务。

12.3 “字段不存在”或类型错误

先查看本机定义:

sudo bpftrace -lv 'tracepoint:syscalls:sys_enter_openat'

错误的字段名、不同架构的类型差异、kprobe 参数解释错误,都可能导致编译或 verifier 失败。tracepoint 字段名与源码函数参数名并不总是相同。

12.4 Map 一直增长

如果键包含路径、地址、随机 ID 或完整命令行,基数可能接近事件数量。表现包括内存增长、Map 达到上限或脚本输出异常。

修复思路不是简单增加 Map 容量,而是重新定义问题:

  • 是否真的需要每个路径一项?
  • 能否按进程、目录前缀或错误码聚合?
  • 是否应该在内核侧做采样?
  • 是否应该把明细发送到用户态,由用户态设置 TTL 或限流?

12.5 看到的结果与 strace 不一致

可能原因包括:

  • 只跟踪了一个系统调用入口,没有跟踪线程;
  • strace 使用 -f,而脚本没有按线程或子进程覆盖;
  • 应用使用 libc 缓冲,用户态操作不对应一次内核调用;
  • 系统调用在不同架构下名称不同;
  • 脚本只统计成功或只统计失败;
  • 事件输出发生丢失;
  • Hook 选在了实现函数而非系统调用边界。

比较时必须先定义观察层次:libc、系统调用、内核函数、块设备还是调度器。


十三、从 bpftrace 过渡到 libbpf

bpftrace 适合快速探索:

sudo bpftrace -e '
profile:hz:49
{
    @[ustack, comm] = count();
}
'

这类脚本可以快速观察用户态调用栈和进程分布,但长期运行时,脚本可维护性、事件协议、错误处理和兼容性会成为问题。

libbpf 程序通常把职责分成:

  1. BPF 侧程序:定义 Hook、上下文、Map 和事件结构;
  2. 用户态 loader:打开、加载、附着 BPF 对象;
  3. 事件消费器:读取 ring buffer/perf buffer;
  4. 生命周期管理:处理信号、detach、重连和清理;
  5. 兼容性逻辑:处理内核版本、BTF 和可选 Hook。

现代 libbpf 常结合 CO-RE(Compile Once, Run Everywhere)和 BTF,根据目标内核类型重定位字段访问。CO-RE 不是“任意内核都兼容”,它仍依赖目标内核提供足够匹配的 BTF 信息,且结构体语义变化仍需要程序设计者验证。

从 bpftrace 迁移到 libbpf 时,最容易遗漏的是错误路径:

  • verifier 加载失败;
  • 某个可选 Hook 附着失败;
  • ring buffer 创建成功但消费线程退出;
  • Map 读取失败;
  • 用户态崩溃后 pin 对象残留;
  • 事件丢失没有计数;
  • 内核升级后 BTF 重定位成功但业务字段语义改变。

因此长期系统不能只验证“程序能加载”,还要验证“数据完整性、清理和降级行为”。


十四、如何选择观测方法

可以按问题的因果边界选择工具:

问题 更合适的起点
CPU 时间花在哪里 perf 采样、CPU 火焰图
某系统调用是否失败 syscall tracepoint、strace
系统调用延迟分布 syscall enter/exit + Map 直方图
内核函数内部哪个阶段慢 kprobe/fentry、内核调用栈
用户态函数调用频率 uprobe/USDT
线程为什么没有运行 sched tracepoint、off-CPU 分析
I/O 延迟来自哪里 block tracepoint、文件系统和调度事件
网络包在哪里丢失 tc/XDP、网络 tracepoint
是否允许某类访问 LSM/cgroup BPF,但必须走控制变更流程

例如,一个请求延迟升高并不能直接推出“系统调用变慢”。完整推理可能是:

  1. perf 采样显示 CPU 并不繁忙;
  2. off-CPU 观察显示线程大量等待;
  3. syscall 延迟直方图显示 read 长尾;
  4. block 事件显示对应设备请求排队;
  5. 最终才有证据支持“应用线程在 I/O 路径等待”。

eBPF 的价值不是替代所有工具,而是把观测点放到更接近因果链的位置,并以较低的数据量保留关键状态。


十五、使用 eBPF 和 bpftrace 的边界原则

eBPF 程序必须通过 verifier,但系统工程师仍需对以下事实负责:

  • Hook 越底层,事件越多,误操作影响越大;
  • kprobe 依赖内核实现,不应自动视为稳定接口;
  • Map 键基数可能成为内核内存风险;
  • 逐事件输出可能比程序本身更昂贵;
  • 读取用户态字符串可能泄露敏感数据;
  • tracepoint 事件字段要以本机定义为准;
  • 入口和出口配对需要处理并发、丢失和生命周期;
  • fentry、BTF、CO-RE 和特定 helper 具有版本与配置依赖;
  • verifier 通过不等于业务逻辑正确,也不等于生产风险可接受;
  • pin 到 bpffs 的对象可能在用户态进程退出后继续存在;
  • LSM、cgroup、tc、XDP 等控制型 Hook 需要回滚和恢复路径。

最小可行的生产观测通常应从“限定目标、聚合结果、短时运行、可验证清理”开始。先用 tracepoint 和 Map 证明问题边界,再根据证据增加 kprobe、uprobe、调用栈或明细事件。这样既能利用 eBPF 的内核可见性,也能避免把一个诊断脚本变成新的性能、隐私或可用性故障源。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。