bpftrace不是让你’编写’BPF程序,而是让你’描述’你想观察什么——它把复杂的BPF字节码编译过程藏进了一个简洁的AWK-like语法背后。
故事场景:周五下午的紧急排查
周五下午4:50,距离下班还有10分钟,小李的PagerDuty炸了。
"支付网关的P50延迟从50ms涨到了800ms,但QPS没变,错误率也没变,就是慢。"小李对着监控面板发呆,"我需要知道每一笔支付请求在内核里卡了多久。"
老张正在收拾背包,闻言叹了口气,把包放回椅子上:"给你三十秒。"
他敲了一行命令:
sudo bpftrace -e 'kprobe:tcp_sendmsg { @start[tid] = nsecs; }
kretprobe:tcp_sendmsg /@start[tid]/ {
@latency_us = hist((nsecs - @start[tid]) / 1000);
delete(@start[tid]);
}'五秒钟后,终端开始输出一个漂亮的对数直方图。老张扫了一眼:"看见这个双峰分布了吗?大部分请求在50微秒内完成,但有一小撮尾巴拖到了2毫秒以上——这是网络层面的延迟尖刺,不是应用层的问题。"
小李眼睛都直了:"这就……完了?"
"一行命令。"老张拎起背包,"下次记住:bpftrace(BPF Trace,BPF追踪器)的价值不是它能做多复杂的分析,而是它让你在问题发生的第一秒就开始观察,而不是等你写完了Python脚本,问题已经自愈了。"
门关上了。小李看着屏幕上还在刷新的直方图,默默把这条命令存进了自己的.bashrc别名里。
一、bpftrace的设计理念:从’编程’到’表达’
bpftrace的设计哲学与BCC截然不同。BCC让你编写程序,bpftrace让你描述意图。这种差异体现在每一个语法细节上:
flowchart LR
A["用户输入
bpftrace脚本 / 单行命令"] --> B["bpftrace编译器
语法解析 / 语义分析"]
B --> C["LLVM IR
中间表示"]
C --> D["BPF字节码
内核可执行"]
D --> E["Kernel Verifier
安全检查"]
E --> F["eBPF VM执行
事件触发"]
F --> G["Maps / Ring Buffer
用户态输出"]bpftrace的语法深受 AWK 和 DTrace 启发。如果你熟悉 awk '{print $1}',那么bpftrace的学习曲线几乎是平坦的。
1.1 核心语法元素
bpftrace脚本由三个核心要素组成:
| 元素 | 作用 | 示例 |
|---|---|---|
| Probe(探针) | 定义"在哪里观察" | kprobe:do_sys_open, tracepoint:syscalls:sys_enter_read |
| Predicate(谓词) | 定义"什么时候记录" | /pid == 1234/, /comm == "nginx"/ |
| Action(动作) | 定义"记录什么" | { @count++; printf("open %s\n", str(args->filename)); } |
一个完整的bpftrace单行命令看起来像这样:
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat /comm == "nginx"/ {
printf("PID %d opening %s\n", pid, str(args->filename));
}'这行命令的意思是:
- 探针:挂载在
openat系统调用的入口 tracepoint(静态追踪点) 上 - 谓词:只有当进程名(
comm)是"nginx"时才触发 - 动作:打印进程ID和打开的文件名
1.2 与BCC的关键差异
graph LR
subgraph "BCC"
B1["Python脚本"] -- 实时编译 --> B2["C BPF代码"]
B2 -- LLVM Clang --> B3["BPF字节码"]
B3 -- 每次运行重编译 --> B4["内核加载"]
end
subgraph "bpftrace"
P1["bpftrace脚本"] -- 内置编译器 --> P2["BPF字节码"]
P2 -- 预编译优化 --> P3["内核加载"]
end
style B1 fill:#f9f,stroke:#333
style P1 fill:#bbf,stroke:#333bpftrace把 LLVM/Clang 的编译过程内置进了自己的二进制中,并且做了大量预编译优化。这意味着:
- 启动速度更快:不需要等待Python启动和C代码编译
- 部署更简单:单个静态链接的二进制文件,无Python依赖
- 表达更简洁:语法糖让你用一行代码表达BCC需要三十行才能完成的事情
当然,代价是灵活性:bpftrace不适合需要复杂用户态数据处理的场景——那时你仍然需要BCC或libbpf。
二、单行程序:bpftrace的’杀手锏’
bpftrace最强大的使用模式是单行程序(One-Liners)。这些命令可以直接在终端里敲,不需要写脚本文件,是性能分析中的"急救包"。
2.1 进程级追踪
# 统计所有进程执行的新程序
sudo bpftrace -e 'tracepoint:sched:sched_process_exec { printf("%s launched %s\n", comm, str(args->filename)); }'
# 追踪某个特定PID的所有系统调用
sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter /pid == 1234/ { @[probe] = count(); }'
# 查看所有发送的信号
sudo bpftrace -e 'tracepoint:signal:signal_generate { printf("%s sent %s to PID %d\n", comm, args->sig, args->pid); }'2.2 文件系统分析
# 统计每个进程打开文件的次数
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { @[comm] = count(); }'
# 追踪慢于10ms的文件打开操作
sudo bpftrace -e 'kprobe:do_sys_open { @start[tid] = nsecs; }
kretprobe:do_sys_open /@start[tid] && (nsecs - @start[tid]) > 10000000/ {
printf("Slow open by %s: %d us\n", comm, (nsecs - @start[tid]) / 1000);
}'2.3 网络分析
# 统计TCP连接建立的目标端口分布
sudo bpftrace -e 'kprobe:tcp_v4_connect { @[ntohs(((sockaddr_in*)args->uservaddr)->sin_port)] = count(); }'
# 追踪TCP发送的字节数分布
sudo bpftrace -e 'kprobe:tcp_sendmsg { @bytes = hist(args->size); }'
# 追踪TCP重传事件(发现网络不稳定)
sudo bpftrace -e 'kprobe:tcp_retransmit { @[comm, args->sk->__sk_common.skc_daddr] = count(); }'
# 统计DNS查询延迟(通过追踪udp_sendmsg到recvmsg)
sudo bpftrace -e 'kprobe:udp_sendmsg /comm == "systemd-resolve"/ { @dns_start[tid] = nsecs; }
kprobe:udp_recvmsg /comm == "systemd-resolve" && @dns_start[tid]/ {
@dns_latency = hist((nsecs - @dns_start[tid]) / 1000);
delete(@dns_start[tid]);
}'老张特别推崇最后一条DNS追踪命令:"生产环境里至少30%的’诡异延迟’最终都指向DNS解析问题。systemd-resolve 卡一下,整个服务链路的P99就崩了。这条单行命令能帮你五秒钟内确认或排除DNS嫌疑。"
2.4 内存与调度
# 统计每个进程的缺页中断次数
sudo bpftrace -e 'software:page-fault:1 { @[comm, pid] = count(); }'
# 追踪线程上下文切换的原因
sudo bpftrace -e 'tracepoint:sched:sched_switch { @[args->prev_comm -> args->next_comm] = count(); }'这些单行程序的共同点是:不需要写文件、不需要编译、不需要部署。你发现问题,打开终端,敲命令,五秒钟后开始获得数据。这就是bpftrace的核心价值——将观察延迟降到零。
三、脚本编写:当单行不够用时
当分析逻辑变得复杂,单行命令会迅速变得不可读。这时就该把bpftrace脚本写进文件了。
3.1 脚本文件结构
一个完整的bpftrace脚本文件(.bt后缀)包含以下结构:
#!/usr/bin/env bpftrace
// 1. 头文件和预处理器(可选)
#include <linux/sched.h>
// 2. 全局变量和初始化(BEGIN探针)
BEGIN {
printf("Tracing disk I/O latency. Hit Ctrl-C to stop.\n");
}
// 3. 探针定义
kprobe:blk_account_io_start {
@start[arg0] = nsecs;
}
kprobe:blk_account_io_done /@start[arg0]/ {
$delta = (nsecs - @start[arg0]) / 1000; // 微秒
@latency = hist($delta);
delete(@start[arg0]);
}
// 4. 结束处理(END探针)
END {
printf("\nDisk I/O latency distribution (us):\n");
print(@latency, 10);
clear(@latency);
clear(@start);
}3.2 探针类型全景
bpftrace支持丰富的探针类型,覆盖了从内到外的所有观察点:
| 探针类型 | 前缀 | 用途 | 示例 |
|---|---|---|---|
| kprobe | kprobe: | 内核函数入口 | kprobe:do_sys_open |
| kretprobe | kretprobe: | 内核函数返回 | kretprobe:do_sys_open |
| uprobe | uprobe: | 用户态函数入口 | uprobe:/bin/bash:readline |
| uretprobe | uretprobe: | 用户态函数返回 | uretprobe:/bin/bash:readline |
| tracepoint | tracepoint: | 内核静态追踪点 | tracepoint:syscalls:sys_enter_read |
| USDT | usdt: | 用户态静态追踪点 | usdt:/usr/bin/node:http__server__request |
| software | software: | 内核软件事件 | software:page-fault:1 |
| hardware | hardware: | CPU硬件事件 | hardware:cache-misses:1000000 |
| interval | interval: | 定时触发 | interval:s:1 |
| profile | profile: | 定时采样 | profile:hz:99 |
其中 USDT(User Statically Defined Tracing,用户态静态定义追踪) 探针是bpftrace的隐藏宝藏。许多现代软件(Node.js、MySQL、PostgreSQL、Python)都内置了USDT探针,你可以在不修改代码的情况下追踪应用内部的逻辑事件。例如,追踪Node.js的HTTP请求处理时间:
usdt:/usr/bin/node:http__server__request {
@start[args->request] = nsecs;
}
usdt:/usr/bin/node:http__server__response /@start[args->request]/ {
@latency_ms = hist((nsecs - @start[args->request]]) / 1000000);
delete(@start[args->request]]);
}3.3 更多USDT实战案例
USDT探针不仅限于Node.js。许多关键基础设施都内置了USDT,覆盖了你日常排查的大部分场景:
MySQL:追踪查询执行时间(需要MySQL编译时启用DTrace支持):
usdt:/usr/sbin/mysqld:query__start {
@mysql_start[tid] = nsecs;
printf("Query started: %s\n", str(args->query, 128));
}
usdt:/usr/sbin/mysqld:query__done /@mysql_start[tid]/ {
$latency = (nsecs - @mysql_start[tid]) / 1000000;
if ($latency > 1000) {
printf("SLOW QUERY: %d ms\n", $latency);
}
@mysql_hist = hist($latency);
delete(@mysql_start[tid]);
}Python:追踪函数调用(Python 3.6+内置了python provider):
usdt:/usr/bin/python3:function__entry {
@[str(args->filename, 64), str(args->funcname, 64)] = count();
}这条命令运行几分钟后,@[...] 的输出就是一份Python热点函数排行榜——不需要修改代码,不需要安装 cProfile,一行bpftrace搞定。
Java:追踪 method__entry 和 method__return(需要JVM启动时加上 -XX:+DTraceMethodProbes ):
usdt:/usr/lib/jvm/java-11-openjdk/bin/java:hotspot__method__entry {
@[str(args->class_name, 64), str(args->method_name, 64)] = count();
}老张特别提醒:"USDT是生产环境的’后门钥匙’——大部分应用都留了这扇门,只是文档里不说。你的任务是找到那把锁。"
四、变量、映射表与内置函数
bpftrace的表达式能力很大程度上来自于它丰富的内置变量和内置函数。
4.0 控制流与变量作用域
bpftrace支持完整的控制流语法,让脚本可以处理更复杂的逻辑:
// if-else 条件分支
tracepoint:syscalls:sys_enter_read /pid == 1234/ {
if (args->count > 1048576) {
printf("Large read: %d bytes by %s\n", args->count, comm);
} else if (args->count < 64) {
@small_reads[comm] = count();
}
}
// 三元运算符
kprobe:tcp_sendmsg {
$size = args->size;
$label = $size > 65536 ? "large" : ($size > 1024 ? "medium" : "small");
@[$label] = count();
}
// 局部变量(以 $ 开头)与全局变量(以 @ 开头)
kprobe:do_sys_open {
$pid = pid; // 局部变量,只在当前探针内有效
@open_count[comm]++; // 全局变量,跨探针、跨CPU共享
}关键区别:
$var(局部变量):仅在当前探针动作内有效,存储在BPF寄存器中,速度快但无法跨探针传递@var(全局Map):存储在BPF Maps中,可以跨探针、跨CPU、跨时间窗口共享——但读写需要内存访问,开销更大@var[key](Hash Map):带键的全局变量,相当于线程安全的字典
小李初学bpftrace时,最大的困惑就是"什么时候用 $ 什么时候用 @ "。老张给了他一条口诀:"临时计算用美元,跨探针记数据用圈A。"
4.1 常用内置变量
| 变量 | 含义 | 示例 |
|---|---|---|
pid | 进程ID | /pid == 1234/ |
tid | 线程ID | @start[tid] = nsecs; |
comm | 进程名 | printf("%s\n", comm); |
nsecs | 当前纳秒时间戳 | nsecs - @start[tid] |
cpu | 当前CPU编号 | @percpu[cpu] = count(); |
uid | 用户ID | /uid != 0/ |
args | 探针参数结构体 | args->filename |
retval | 返回值(kretprobe/uretprobe) | retval < 0 |
probe | 当前探针名称 | @[probe] = count(); |
4.2 映射表(Map)类型
映射表是bpftrace中存储和聚合数据的核心机制。bpftrace自动选择合适的Map类型:
// 标量计数器
@count++;
// 线程级Hash Map(自动按线程隔离)
@start[tid] = nsecs;
// 进程级聚合
@[comm] = count();
// 直方图(对数分桶)
@latency = hist(delta);
// 线性直方图
@size = lhist(args->size, 0, 1000000, 10000);
// 统计(count/min/max/avg/sum)
@stats = stats(delta);4.3 关键内置函数
| 函数 | 作用 | 示例 |
|---|---|---|
count() | 计数 | @x = count(); |
sum(val) | 求和 | @total = sum(args->size); |
avg(val) | 平均值 | @avg = avg(delta); |
min(val) / max(val) | 最小/最大值 | @max = max(delta); |
hist(val) | 对数直方图 | @lat = hist(delta); |
lhist(val, min, max, step) | 线性直方图 | @sz = lhist(size, 0, 1024, 64); |
str(addr) | 读取字符串 | str(args->filename) |
printf(fmt, ...) | 格式化输出 | printf("PID %d\n", pid); |
time(fmt) | 打印时间 | time("%H:%M:%S") |
ntohs(val) / ntohl(val) | 网络字节序转换 | ntohs(args->sin_port) |
kstack() / ustack() | 采集内核/用户态栈 | @[kstack()] = count(); |
kstack() 和 ustack() 是两个极其强大的函数。它们可以在任意探针触发时采集调用栈。例如,统计所有导致内核态内存分配的调用路径:
kprobe:kmem_cache_alloc {
@[kstack(5), comm] = count(); // 采集最多5层内核栈
}运行一段时间后,@[...] 的输出会告诉你:哪些代码路径最频繁地触发内核内存分配?是文件系统?网络协议栈?还是某个驱动?
4.4 更多实用单行与脚本示例
下面是一些bpftrace社区中广为流传的"经典配方",每一个都能在特定场景下节省你数小时的排查时间:
追踪程序启动时的库加载顺序(发现启动慢的原因):
sudo bpftrace -e 'uprobe:/lib/x86_64-linux-gnu/libc.so.6:__libc_start_main {
printf("[%s] PID %d started at %s\n", comm, pid, ustack(3));
}'统计每个CPU上硬中断的分布(NUMA调度问题排查):
sudo bpftrace -e 'hardware:cpu-cycles:1000000 {
@[cpu, comm] = count();
}'追踪所有失败的系统调用及其错误码:
sudo bpftrace -e 'tracepoint:raw_syscalls:sys_exit /retval < 0/ {
@[probe, comm, -retval] = count();
}'这条命令的输出是一份"系统调用失败排行榜"——如果某个错误码(如 -EAGAIN 、 -ENOMEM 、 -ENOENT )频繁出现,你就知道该往哪个方向排查了。
五、调试技巧与常见陷阱
bpftrace虽然简洁,但也有一批让人头大的"坑"。
5.1 调试方法
当bpftrace脚本不工作时,依次检查:
- 语法检查:
bpftrace -d script.bt可以打印AST,帮助定位语法错误 - 探针存在性:
bpftrace -l '*open*'列出所有匹配的探针,确认你要挂接的探针存在 - 权限问题:bpftrace需要
CAP_SYS_ADMIN或 root 权限,且内核需要启用CONFIG_BPF_SYSCALL - 内核版本:某些探针(如 BTF-enabled tracepoints)需要较新的内核(5.2+)
5.2 常见陷阱
| 陷阱 | 现象 | 解决 |
|---|---|---|
| 字符串读取越界 | str(args->path) 崩溃或输出乱码 | 使用 str(args->path, 128) 限制长度 |
| Map键不存在 | 访问 @map[key] 时程序被拒绝 | 先用 /@map[key]/ 谓词检查存在性 |
| 返回值类型不匹配 | 在kprobe中访问 retval | retval 只在kretprobe中可用 |
| 栈深度过大 | kstack(100) 导致Verifier拒绝 | 内核栈通常限制在10-20层 |
| 高频事件淹没 | 探针触发频率太高,系统变卡 | 使用硬件计数器采样(hardware:)或增加谓词过滤 |
老张给小李总结了一张"bpftrace速查卡",贴在显示器边框上:
# 进程创建 → execsnoop.bt
# 文件打开 → opensnoop.bt
# 网络连接 → tcpconnect.bt
# 系统调用频率 → syscount.bt
# 火焰图采样 → profile.bt
# 内存分配栈 → memleak.bt"bpftrace不是取代BCC,"老张说,"它是BPF生态的’入口 drug’——让你先用一行命令尝到甜头,然后自然会想去学BCC和libbpf。"
小李深以为然。他已经把bpftrace的单行命令集整理成了一个内部Wiki页面,标题叫《周五4:50急救手册》。
5.3 实战案例:用bpftrace定位Java服务的神秘卡顿
某天下午,小李接到告警:一个Java微服务的P99突然从200ms涨到了4秒,但CPU、内存、网络都正常。Java的GC日志也没有异常。诡异的是,问题持续约10分钟后自动恢复。
"间歇性问题最难搞,"老张说,"因为它通常在你打开IDE之前就已经结束了。bpftrace的长处就是够快——在它自愈之前,你已经拿到数据了。"
他们用了三条bpftrace命令并行采集:
# 终端1:采集off-CPU火焰图样本
sudo bpftrace -e 'profile:hz:99 { @[offcpu_stack, comm] = count(); }'
# 终端2:追踪Java方法的执行时间(需要JVM启用DTrace探针)
sudo bpftrace -e 'usdt:/usr/lib/jvm/java-11-openjdk/bin/java:hotspot__method__entry {
@java_start[str(args->method_name)] = nsecs;
}
usdt:/usr/lib/jvm/java-11-openjdk/bin/java:hotspot__method__return {
$name = str(args->method_name);
if (@java_start[$name]) {
$latency = (nsecs - @java_start[$name]) / 1000000;
if ($latency > 2000) {
printf("SLOW: %s took %d ms\n", $name, $latency);
}
delete(@java_start[$name]);
}
}'
# 终端3:追踪线程上下文切换,看谁在抢CPU
sudo bpftrace -e 'tracepoint:sched:sched_switch {
@[args->prev_comm, args->next_comm, args->prev_state] = count();
}'问题再次出现时,数据已经到手。分析结果:
- off-CPU火焰图显示大量Java线程卡在
futex_wait——某个Java锁被长时间持有 - Java方法追踪指向了同一个 culprit:一个第三方库的初始化方法在第一次调用时触发了类加载,而类加载持有了一个全局锁
- 上下文切换数据显示JVM的GC线程在高峰期频繁抢占业务线程
根因:应用重启后,JVM的 ClassLoader 锁在第一个请求到达时触发了一次性的类初始化,阻塞了所有其他请求。JVM的JIT编译器随后把这个方法内联优化了,所以问题只出现在重启后的前10分钟。
"这个案例里,"老张总结道,"Java层面的profiler看不到锁竞争(因为它发生在JVM内部),系统层面的 top 也看不到off-CPU等待。只有bpftrace这种能跨层穿透的工具,才能把内核态的 futex_wait 和应用层的 method_entry 关联起来。"
小李把这个案例写进了Wiki首页,标题是《为什么你需要在三秒内启动追踪器》。
总结
mindmap
root((bpftrace核心))
语法
探针 Probe
谓词 Predicate
动作 Action
数据类型
标量 @scalar
Hash Map @map[key]
直方图 hist / lhist
统计 stats
探针类型
kprobe / kretprobe
uprobe / uretprobe
tracepoint
USDT
software / hardware
interval / profile
内置函数
str printf time
count sum avg
hist lhist stats
kstack ustack
典型场景
单行ad-hoc分析
脚本化监控
火焰图采样
USDT应用追踪核心要点:
- bpftrace是’描述式’追踪语言:用探针+谓词+动作的三段式语法,一行命令就能完成复杂的内核级观察
- 内置编译器消除了部署负担:单个静态二进制,无需Python、无需LLVM开发库,随用随走
- 丰富的探针类型覆盖全栈:从内核函数(kprobe)到用户态静态探针(USDT),从软件事件到硬件计数器
- Map和内置函数是数据处理引擎:Hash Map做线程级关联,hist/lhist做分布统计,kstack/ustack做调用栈采集
- bpftrace与BCC互补而非替代:bpftrace做快速侦察(ad-hoc),BCC做深度攻坚(复杂聚合),libbpf做生产部署(CO-RE)
"BCC让你成为BPF程序员,"老张在周五的复盘会上说,"bpftrace让你成为BPF用户。先成为用户,再决定要不要成为程序员——这是学习任何技术的最短路径。"
小李把这句话也写进了Wiki。