第2章 背景知识
Linux 内核与追踪技术演进
章节导读
要理解 BPF 为什么强大,必须先理解它站在什么之上。BPF 不是凭空出现的魔法,它是 Linux 内核三十年追踪技术演进的集大成者:ptrace 提供了进程级拦截的原型,kprobes 提供了内核任意函数的动态插桩能力,tracepoints 提供了稳定的静态追踪接口,perf_events 提供了统一的事件基础设施,而 BPF 把这一切粘合起来,加上了安全的内核态可编程性。本章就是这段技术史的导览,也是后续所有章节的地基。
让我们从一个真实的生产事故讲起。2016 年,某大型电商的订单服务在促销高峰期出现周期性延迟毛刺:每隔约 30 秒,P99 延迟从 20ms 跳到 800ms,持续两三秒后恢复。运维团队查遍了所有常规指标——CPU 利用率 40%,内存充足,磁盘 IOPS 正常,网络无丢包——一切"看起来"都健康。strace 不敢用(会拖垮生产进程),SystemTap 不敢装(需要编译内核模块,变更审批要一周)。最后是一位内核工程师用 perf 的调度事件加上一个临时编写的 ftrace 脚本,发现毛刺时刻所有应用线程都在等待同一个内核锁——根源是一个监控 agent 每 30 秒遍历一次 /proc 下所有进程,触发了内核 procfs 的全局锁竞争。这个案例的教训是:故障往往藏在常规指标看不到的地方,而排查能力取决于你手里有多少种"观察内核行为"的手段。
Linux 内核的追踪能力经历了几个时代。第一个时代是 ptrace(1979 年 Unix V7 就有雏形),它允许一个进程观察和控制另一个进程:读取寄存器、单步执行、拦截信号。gdb 和 strace 都基于 ptrace。ptrace 的问题是它基于"停止-恢复"模型,每次拦截都要让目标进程停下来等 tracer 处理,开销巨大,且一次只能追踪一个进程。
第二个时代是内核插桩。2002 年前后,IBM 的开发者为 Linux 引入了 kprobes:可以在内核几乎任意指令地址上动态设置断点,触发时执行注册的处理函数。它的实现非常精巧——把目标指令替换成 int3 断点指令,触发后执行处理逻辑,再单步执行原指令。kprobes 让"追踪任意内核函数"成为可能,但它只提供了机制,没有提供安全的可编程性:处理函数必须是预先编译进内核的 C 代码,普通用户无法注入自己的逻辑。
第三个时代是静态追踪点。内核开发者意识到,动态 kprobe 挂载的函数名和参数会随版本变化,追踪工具极其脆弱。于是从 2007 年开始(Mathieu Desnoyers 主导),Linux 引入了 tracepoints:由内核开发者在源码中显式埋设的稳定追踪点,带有文档化的参数签名,遵循内核的稳定性承诺。今天内核中有近两千个 tracepoint,覆盖调度、系统调用、块 I/O、网络、文件系统等所有子系统。tracepoint 的代价是只能追踪内核开发者"想到"要埋点的位置。
第四个时代是 perf_events(2009 年,Ingo Molnar 等),它把硬件性能计数器(PMC)、软件事件、tracepoint、kprobe 统一到一个框架下,提供了 perf 命令行工具和 perf_event_open 系统调用。perf 是 Linux 性能分析的第一件"全能武器",但它的事件处理逻辑仍在用户态,高频事件场景下数据传输成为瓶颈。
与此同时,用户态追踪也在演进:uprobes(2012 年进入主线)把 kprobes 的断点机制搬到了用户态程序,可以追踪任意进程的任意用户态函数;USDT(User Statically-Defined Tracing)则借鉴 DTrace 的理念,允许应用程序在源码里预埋追踪点(通过 sys/sdt.h 宏),编译后以近乎零开销存在,需要时才激活。Java、Python、Node.js、PostgreSQL、MySQL 都内置了 USDT 探针。
BPF 出现后,上述所有机制都成了它的"挂载点选项":BPF 程序可以 attach 到 kprobe、kretprobe、tracepoint、uprobe、uretprobe、USDT、perf_event、socket 等几乎所有事件源上,在内核态执行自定义的过滤、聚合逻辑。这就是为什么说 BPF 是集大成者——它不替代这些机制,而是把它们全部变成了可编程的。
本章还将建立必要的内核背景:系统调用的完整路径(用户态 glibc 封装 → syscall 指令 → 内核入口 → 权限检查 → 系统调用表分发 → 执行 → 返回)、中断与软中断的处理模型、进程调度器的基本流程。这些知识决定了你能否理解后续章节中每个追踪点"挂在哪里、看到什么"。如果你对这些内容已经熟悉,可以快速浏览;如果陌生,建议配合《深入理解 Linux 内核》或 LWN 的文章补齐——追踪工具的输出只有放在内核执行模型的语境下才有意义。
核心概念
- kprobes
- uprobes
- tracepoints
- perf_events
- USDT
- 静态追踪
- 动态追踪
要点精析
【Linux 内核架构回顾:系统调用路径】理解追踪点的位置,先要理解代码的执行路径。一次 read() 调用的完整旅程:应用程序调用 glibc 的 read() 封装 → 封装函数把系统调用号(x86_64 上 read 是 0)放入 rax 寄存器,参数放入 rdi/rsi/rdx → 执行 syscall 指令,CPU 切换到内核态 → 内核入口代码保存用户态寄存器(形成 struct pt_regs,这就是 kprobe 处理函数拿到的那个结构)→ 经过 seccomp、ptrace、audit 等检查钩子 → 通过系统调用表分发到 sys_read → VFS 层 → 具体文件系统 → 可能进入块层发起 I/O → 返回时把返回值放入 rax,执行 sysret 回到用户态。追踪点分布在这条路径的每个环节:tracepoint:syscalls:sys_enter_read 在入口处,kprobe:sys_read 在函数入口,kprobe:vfs_read 在 VFS 层。选择哪个追踪点,决定了你看到的信息粒度和稳定性。书中强调:sys_enter/sys_exit tracepoint 是所有系统调用追踪的首选,因为它们稳定且能拿到完整的参数和返回值。
【中断与软中断:容易被忽略的性能杀手】Linux 把中断处理分为两半:上半部(hardirq)快速响应硬件、做最紧急的事(如从网卡 DMA 环取包),下半部(softirq)延迟处理耗时工作(如网络协议栈收包 NET_RX_SOFTIRQ、块设备完成处理 BLOCK_SOFTIRQ、定时器 TIMER_SOFTIRQ)。软中断由 ksoftirqd 内核线程或中断返回路径执行,会抢占用户进程。性能排查中大量"莫名其妙"的延迟源于此:网卡中断集中在 CPU0 导致该核软中断饱和、其他核空闲(需要调 irqbalance 或手动设置 smp_affinity);软中断长时间运行饿死用户进程。BPF 工具 hardirqs 和 softirqs 分别测量硬中断和软中断的处理时间直方图,按中断类型分解。第 6 章将详细展开。关键认知:top 里显示的 si%(软中断 CPU 占比)超过 10% 就值得用 softirqs 深入分析,超过 30% 通常意味着网络或存储栈有严重瓶颈。
【ptrace:最古老的追踪机制及其致命缺陷】ptrace(PTRACE_SYSCALL, ...) 让 tracer 进程在目标每次进入/退出系统调用时收到通知。strace、gdb、tcpdump 的某些模式都基于它。致命缺陷有三:(1) 开销——每次拦截需要目标进程停止(进入 TASK_STOPPED)、调度 tracer 进程运行、tracer 处理完再唤醒目标,一个系统调用引入 2 次以上上下文切换,实测 strace 使系统调用密集应用慢 2-10 倍;(2) 串行化——tracer 是单点,多线程应用的所有拦截事件排队处理,进一步放大延迟;(3) 侵入性——被 ptrace 的进程行为可能改变(信号处理、调度时机),产生观察者效应。书中给出的对比:追踪一个每秒 10 万次 getpid 的程序,strace 使吞吐下降约 90%,而 BPF 追踪下降不到 1%。ptrace 今天仍有不可替代的场景:调试器需要单步执行和修改目标状态,这是 BPF(只读追踪)做不到的。但用于性能观测,ptrace 已被 BPF 全面淘汰。
【kprobes 原理:断点注入的艺术】kprobe 的工作机制:注册时在目标指令处保存原指令,替换为 int3(x86 的断点指令,0xCC);CPU 执行到 int3 触发陷阱,内核的 kprobe 处理器运行注册的处理函数(BPF 场景下就是运行 JIT 后的 BPF 程序),处理函数能读到完整的 pt_regs(所有寄存器,即函数参数);然后 kprobe 框架在一个单步执行环境中运行原指令,恢复执行流。kretprobe 追踪函数返回:它在函数入口把返回地址替换成一个 trampoline 函数的地址,函数返回时跳到 trampoline,触发处理(此时能读到返回值,位于 rax),再跳回真正的返回地址。开销数据:kprobe 触发一次约 1-2 微秒(主要是 int3 陷阱和单步执行的成本),kretprobe 略高。常见误区:kprobe 可以挂在几乎任意内核指令上,但挂在内联函数上会失败(函数不存在于符号表),挂在极热路径(如页表操作)上会显著拖慢系统。用 kprobe 前查 /proc/kallsyms 确认函数存在。
【tracepoints:稳定的静态追踪点】tracepoint 是内核源码中显式埋设的追踪点,例如 trace_sched_switch() 在调度器切换进程时调用。未激活时,tracepoint 只是一条几乎零开销的静态键检查(static key,一条 nop 指令);激活时替换为跳转指令,调用注册的处理函数。与 kprobe 的核心区别:(1) 稳定性——tracepoint 是内核 ABI 的一部分,参数签名跨版本保持兼容(原则上),而 kprobe 依赖的函数名和参数随版本任意变化;(2) 语义明确——每个 tracepoint 有文档(/sys/kernel/debug/tracing/events/ 下有 format 文件描述参数),是内核开发者认为"值得追踪"的位置;(3) 覆盖有限——只有埋了点的位置才能追踪。查看系统全部 tracepoint:ls /sys/kernel/debug/tracing/events/ 或 bpftrace -l 'tracepoint:*'。实践准则:写工具时优先用 tracepoint,只有 tracepoint 覆盖不到的位置才用 kprobe。书中特别指出,系统调用有专门的 raw_syscalls 和 syscalls 两组 tracepoint,后者按系统调用细分(sys_enter_openat 等),是类型化追踪的基础。
【uprobes:用户态动态追踪】uprobe 把 kprobe 的机制搬到用户态:注册时解析目标 ELF 文件找到函数偏移,把该地址的指令替换为断点;任何进程执行到该地址时触发陷阱,内核运行 BPF 程序,然后单步恢复。关键特性:(1) 基于 inode——uprobe 挂在文件上,所有运行该二进制的进程都会被追踪(可用 PID 过滤);(2) 需要符号——函数名解析依赖符号表(.symtab/.dynlib),strip 过的二进制只能用偏移地址;(3) 开销——每次触发约 1-2 微秒,对高频函数(malloc、热点循环内函数)全量追踪会严重拖慢应用,需要过滤或采样;(4) 共享库支持——可以挂 libc、libssl 等共享库函数,例如追踪 SSL_write 就能看到 OpenSSL 加密前的明文(这正是很多"HTTPS 抓包"BPF 工具的原理)。uretprobe 追踪用户函数返回,能拿到返回值,但对返回路径复杂的程序(longjmp、异常)可能失配。Go 程序的 uprobe 有特殊问题:Go 用自己的栈管理和调用约定(1.17 前参数在栈上而非寄存器),追踪 Go 函数参数需要按 ABI 版本处理。
【USDT:用户态静态定义追踪点】USDT 是应用开发者在源码中预埋的追踪点,通过 include <sys/sdt.h> 的 DTRACE_PROBE() 宏实现。编译后探针只是一条 nop 指令,运行时零开销;当追踪工具激活探针时,nop 被替换为断点。与 uprobe 相比,USDT 的优势:(1) 语义化——探针由应用开发者定义,参数是有意义的业务数据(如 PostgreSQL 的 query__start 探针带查询字符串),而不是底层函数参数;(2) 稳定——探针名称和参数是应用承诺的接口,不随重构变化;(3) 位置精准——埋在代码逻辑的关键点,而非恰好存在的函数边界。重要 USDT 提供者:JVM(hotspot 探针覆盖 GC、方法编译、锁、内存池,是 Java 生产诊断的利器)、Python 3.6+(function__entry/return)、Node.js(gc__start/stop、http__client__request)、PostgreSQL、MySQL、glibc(setjmp 等)。bpftrace 语法:usdt:/usr/lib/jvm/.../libjvm.so:hotspot:gc__begin。注意:很多发行版的二进制默认编译时去掉了 USDT(如某些 Python 构建),用 readelf -n 二进制文件 查看 .note.stapsdt 节确认探针存在。
【perf_events:统一的事件基础设施】perf_events 内核框架(2009+)统一了:硬件 PMC 事件(CPU 周期、指令、缓存缺失、分支预测失败)、软件事件(页面错误、上下文切换、CPU 迁移)、tracepoint 事件、kprobe/uprobe 动态事件。每个事件通过 perf_event_open() 系统调用创建,可以按 CPU 或按进程绑定,支持采样模式(每 N 个事件或每 T 时间中断一次,记录调用栈)和计数模式。perf 命令是它的用户态前端。BPF 与 perf_events 的关系是共生而非竞争:BPF 程序通过 perf_event 类型的挂载点获得"定时采样"能力(profile 工具就是 BPF 程序挂在 99Hz 的 perf_event 上,每次触发抓栈);perf 工具本身也支持把 BPF 程序作为事件处理器(perf record -e 加 BPF 对象)。理解这个关系很重要:书中 profile、offcputime 等剖析工具都构建在 perf_events 的采样机制之上。perf 的局限在于事件处理逻辑固定(记录原始数据到 ring buffer 由用户态后处理),高频事件下丢数据严重,而这正是 BPF 内核态聚合要解决的。
【静态追踪 vs 动态追踪:两种哲学的权衡】静态追踪(tracepoint、USDT)由开发者预埋,稳定、语义明确、有文档,但覆盖范围受限于开发者的预见;动态追踪(kprobe、uprobe)可以挂载任意位置,灵活强大,但脆弱(内核升级可能改变函数签名)、语义模糊(你追踪的函数参数需要自己解读)。Gregg 的建议是分层使用:日常监控和长期运行的工具用静态追踪点(不会因升级而坏);临时诊断用动态追踪(灵活应对未知问题)。一个历史教训:早期 BCC 工具大量使用 kprobe,结果每次内核大版本升级都有一批工具坏掉(函数改名、参数变化、内联优化),社区花了数年时间把核心工具迁移到 tracepoint 和 CO-RE。另一个实践细节:动态追踪点并非真的"任意"——内核函数可能被内联(无符号可挂)、可能被标记 notrace(如调度器核心路径的部分函数)、ftrace 的 mcount 机制也要求函数入口有特定指令。用 bpftrace -l 'kprobe:*' 列出实际可挂载的函数。
【追踪开销评估:量化每种技术的成本】生产环境使用追踪工具必须心中有开销账本。书中给出的量级(x86_64,现代内核):tracepoint 激活但未触发时约 0(static key nop);每次 tracepoint 触发 + BPF 程序执行约 100-500 纳秒;kprobe 触发约 1-2 微秒(int3 陷阱 + 单步执行);uprobe 触发约 1-2 微秒;uretprobe/kretprobe 略高于对应入口探针;perf 采样(99Hz)CPU 开销 < 1%;BPF map 更新约 100 纳秒;perf buffer 输出一个事件约 0.5-1 微秒。对比:strace 每系统调用约 5-10 微秒以上(含两次调度);SystemTap 处理函数与 kprobe 同级但编译部署成本巨大;ftrace 的 function tracer(追踪所有内核函数)开销可达 10-30%。决策规则:低频事件(< 每秒 1 万次)随便追;高频事件(每秒百万次,如 malloc、页错误)要么过滤、要么聚合在内核态、要么采样。所有 BCC/bpftrace 工具都遵循"内核态聚合"原则,这是它们能上生产的根本。
【ftrace 与 tracing 目录:BPF 之下的基础设施】ftrace 是内核内置的追踪框架(2008+),提供 function tracer(追踪所有/指定内核函数调用)、function_graph tracer(显示调用图和耗时)、irqsoff/wakeup 等专用 tracer、以及 tracepoint 的原始接口。所有这一切暴露在 /sys/kernel/debug/tracing/ 目录下。BPF 工具与 ftrace 的关系:kprobe 的底层注册机制复用了 ftrace 的基础设施;很多快速诊断可以直接用 ftrace 完成而无需 BPF(如 echo function > current_tracer)。实用技巧:cat /sys/kernel/debug/tracing/available_filter_functions 列出所有可追踪内核函数;trace-cmd 是 ftrace 的友好前端。Gregg 的建议:ftrace 适合快速看"某函数被谁调用、耗时多少",BPF 适合自定义聚合统计和复杂过滤。两者共享同一套底层机制(kprobe、tracepoint),学会一个另一个触类旁通。注意 debugfs 需要 root 挂载,某些加固内核限制了访问。
【为什么这些背景对 BPF 至关重要】本章内容看似与 BPF "无关",实则是使用 BPF 工具的地图。举个例子:runqlat 工具测量调度延迟,如果你不理解调度器的运行队列(runqueue)概念、不理解进程从 TASK_WAKEUP 到真正上 CPU 之间要经历 enqueue → select_task_rq → pick_next_task → context_switch 的流程,你就无法理解 runqlat 挂载的两个 tracepoint(sched_wakeup 和 sched_switch)分别在测什么、为什么两者的时间差就是"调度延迟"。再如:biolatency 的 -Q 选项测量的是 I/O 在内核队列中的时间,你需要知道块层有 plug list、调度器队列、设备队列三层排队,才能理解 -Q 与默认模式(设备时间)的差异。书中每个工具章节都遵循"先讲子系统工作原理,再讲工具"的结构,正是这个原因。建议读者把本章作为常备参考:遇到看不懂的追踪点名称(sched_switch、block_rq_issue、tcp_retransmit_skb),回到本章的机制说明中找到它属于哪个子系统、什么时机触发。
代码示例
/* 示例1:列出系统上所有可用的追踪点 */
bpftrace -l 'tracepoint:syscalls:*' | head -20
bpftrace -l 'kprobe:vfs_*' | head -20
bpftrace -l 'uprobe:/bin/bash:readline'
bpftrace -l 'usdt:*:*' 2>/dev/null | head
/* 也可以直接查看内核接口:
cat /sys/kernel/debug/tracing/available_filter_functions | wc -l
ls /sys/kernel/debug/tracing/events/
*/bpftrace -l 是探索追踪点的第一命令,支持通配符。tracepoint:syscalls:* 列出全部系统调用追踪点(每个系统调用有 enter 和 exit 两个,共约 700 个);kprobe:vfs_* 列出 VFS 层可挂载的函数;available_filter_functions 通常有 3-5 万个内核函数可追踪。这个探索过程本身就是学习内核的绝佳方式:列出 kprobe:tcp_* 你会看到 TCP 状态机的全部内部函数(tcp_v4_connect、tcp_close、tcp_retransmit_skb...),比读任何文档都直观。注意 usdt 探针需要先知道目标二进制路径,bpftrace -l 'usdt:/usr/bin/python3:*' 更实用。如果 kprobe 列表为空,检查内核配置 CONFIG_KPROBES 和 /proc/sys/debug/kprobes-optimization。养成习惯:写任何追踪脚本前,先 -l 确认挂载点存在,避免拼写错误导致的静默失败。/* 示例2:查看 tracepoint 的参数格式 */
cat /sys/kernel/debug/tracing/events/sched/sched_switch/format
/* 输出关键字段:
field:char prev_comm[16]; field:pid_t prev_pid;
field:long prev_state; field:char next_comm[16];
field:pid_t next_pid;
*/
bpftrace -e 'tracepoint:sched:sched_switch { printf("%s -> %s\n", args->prev_comm, args->next_comm); }'每个 tracepoint 的 format 文件是它的"函数签名文档",描述了所有字段的名字、偏移、类型。写 BPF 脚本前查 format 是必修课——bpftrace 中 args-> 后面能写什么完全由它决定。sched_switch 是最重要的 tracepoint 之一:每次上下文切换触发,携带切出进程(prev)和切入进程(next)的信息。prev_state 字段尤其关键:值为 0(TASK_RUNNING)表示进程是被抢占的(时间片用完或更高优先级抢占),说明它还想运行——这是 CPU 饱和的信号;值非 0 表示进程主动让出(睡眠等待 I/O、锁等)——这是正常行为。runqlat 和 cpudist 等工具的核心逻辑都建立在对这个字段的解读上。示例脚本实时打印每次上下文切换,在空闲系统上每秒也有几十次(内核线程活动),繁忙系统可达每秒数万次——这也提醒我们:sched_switch 是高频事件,脚本必须内核态聚合,逐事件 printf 会刷爆终端并产生可观开销。/* 示例3:kprobe 与 kretprobe 配合——测量内核函数耗时 */
bpftrace -e 'kprobe:vfs_read { @start[tid] = nsecs; }
kretprobe:vfs_read /@start[tid]/ { @ns = hist(nsecs - @start[tid]); delete(@start[tid]); }'
/* 输出 vfs_read 调用耗时的直方图(纳秒):
@ns:
[1K, 2K) 234 |@@@@@@@@@@@@@@@@@@@@ |
[2K, 4K) 456 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1M, 2M) 2 | | <- 触发磁盘 I/O 的读
*/这是"函数耗时测量"的标准模式:入口记录时间戳(以 tid 线程 ID 为键存入 map),出口计算差值并聚合。tid 作为键保证多线程下时间戳不串扰;kretprobe 的过滤条件 /@start[tid]/ 确保只处理有配对入口的事件(防止脚本中途启动导致的孤儿返回)。输出的直方图非常有信息量:大部分 vfs_read 在 1-4 微秒内完成(命中页缓存),但有两个落在 1-2 毫秒区间——这些是缺页后真正读磁盘的调用,延迟差了三个数量级。这个双峰分布正是页缓存系统的典型特征。同样的模式可以套用到任何函数对:tcp_sendmsg 看发送耗时、ext4_file_write_iter 看文件系统写入、甚至用户态函数(uprobe/uretprobe)。注意:kretprobe 对被追踪函数的返回路径有假设,如果函数通过非常规方式退出(内核中少见但存在),可能丢失配对;map 中残留的 @start 条目会缓慢泄漏内存,长时间运行的脚本应定期清理。/* 示例4:用 perf 观察静态事件——理解 perf_events 框架 */
perf stat -e cycles,instructions,context-switches,page-faults -a sleep 5
perf record -e sched:sched_switch -a sleep 2
perf script | head -20perf stat 展示计数模式:5 秒内全系统(-a)的 CPU 周期数、指令数(两者之比即 IPC,每周期指令数,低于 1 通常意味着内存瓶颈或分支预测失败)、上下文切换次数、缺页次数。perf record 展示采样/记录模式:把 sched_switch 事件的原始数据(时间戳、CPU、参数)写入 perf.data 文件,perf script 再解码成文本。对比 BPF 方式:perf record 把每个事件都传回用户态存储,每秒数万次切换会产生数十 MB 数据;而 bpftrace 在内核态直接聚合成直方图,输出只有几行。这就是两种架构的哲学差异——perf 保存原始事件供事后任意分析(灵活但昂贵),BPF 在事件现场提取答案(高效但问题必须预先定义)。实践中两者互补:先用 BPF 工具定位可疑子系统,再用 perf record 抓原始事件深入分析个别案例。perf 还支持 BPF 程序作为事件过滤器(perf record -e 挂载 BPF 对象),这是两个世界的桥梁。/* 示例5:USDT 探针实战——追踪 bash 的 readline */
readelf -n /bin/bash | grep -A2 stapsdt | head -20
bpftrace -e 'usdt:/bin/bash:readline:readline { printf("readline: %s\n", str(arg0)); }'
/* 在另一个终端使用 bash 输入命令,观察输出 */bash 编译时内置了 USDT 探针(readline、command 等),readelf -n 查看 .note.stapsdt 节可以列出二进制携带的全部 USDT 探针及其参数签名。这个例子追踪用户在交互式 bash 中输入的每一行命令——注意这是跨所有 bash 进程生效的(基于 inode),在共享服务器上使用要意识到隐私和安全边界。USDT 探针的参数(arg0、arg1...)含义由探针提供者定义,需要查文档:bash 的 readline 探针 arg0 是提示符字符串。USDT 与 uprobe 的选择:如果目标程序提供了语义化 USDT 探针,永远优先使用——它们更稳定(应用升级不会破坏)、参数更有意义(业务语义而非寄存器值)。Java 的 hotspot USDT 探针集尤其强大:method__entry、gc__begin、object__alloc 等覆盖了 JVM 内部几乎所有关键事件,让你无需修改 JVM 参数、无需 JMX 就能在生产环境追踪 Java 应用内部行为,第 5 章将详细展开。/* 示例6:观察 uprobe 的开销——定量理解断点成本 */
/* 目标程序:循环调用 libc 的 getpid 一百万次 */
cat > /tmp/loop.c << 'EOF'
#include <unistd.h>
int main() { for (int i = 0; i < 1000000; i++) getpid(); return 0; }
EOF
gcc -o /tmp/loop /tmp/loop.c
time /tmp/loop /* 基线:约 0.5 秒 */
sudo bpftrace -e 'uprobe:/lib/x86_64-linux-gnu/libc.so.6:getpid { @ = count(); }' &
time /tmp/loop /* 追踪下:明显变慢 */这个实验定量展示 uprobe 的成本。getpid 本身是极轻量的调用(vDSO 优化后甚至不进内核,但 libc 符号仍可被 uprobe 拦截),基线一百万次约 0.5 秒,即每次 0.5 微秒。挂上 uprobe 后,每次调用触发断点陷阱 + BPF 程序 + 单步恢复,成本约 1-2 微秒,总耗时膨胀 3-5 倍。结论清晰:uprobe 的绝对开销(微秒级)对低频函数(每秒几百次)无关紧要,对高频函数(每秒百万次)则是灾难。这解释了为什么 memleak 工具默认只追踪超过一定大小的分配、为什么 profile 用采样而非全量追踪、为什么生产环境的 USDT 探针都埋在低频关键路径上。同样的逻辑适用于 kprobe:挂载在 __alloc_pages(页分配器,极热)上的 kprobe 会显著拖慢整机,而挂载在 do_exit(进程退出,低频)上则毫无感觉。选择追踪点时,先估算事件频率(用 count() 快速测一秒),超过每秒 10 万次就要三思。实验笔记
本实验带你亲手操作本章介绍的各种追踪机制,建立对"挂载点"的直观认识。预计耗时 2-3 小时。需要 root 权限的 Linux 环境(内核 4.9+),已安装 bpftrace、BCC 工具和 perf(linux-tools 包)。
【实验一:追踪点普查】(1) 统计你系统上的追踪点数量:bpftrace -l 'tracepoint:' | wc -l(通常 1500-2000 个)、bpftrace -l 'kprobe:' | wc -l(通常 3-6 万个)、cat /proc/kallsyms | wc -l。(2) 按子系统浏览 tracepoint 目录:ls /sys/kernel/debug/tracing/events/,重点查看 sched、block、tcp、syscalls 四个目录,各挑一个 tracepoint 阅读其 format 文件,写下每个字段的含义。(3) 思考题:为什么 tracepoint 数量远少于 kprobe 可挂载函数数量?(提示:tracepoint 需要内核开发者显式埋设并承诺稳定性。)
【实验二:kprobe 单步执行验证】编写脚本测量 do_sys_openat2(或 vfs_open,视内核版本)的耗时分布:入口 kprobe 记录 nsecs 到 @start[tid],出口 kretprobe 计算差值放入 hist。在另一终端运行 find /usr -type f > /dev/null 制造文件打开负载。观察直方图形状。然后故意把 kretprobe 的过滤条件去掉,观察输出有何异常(提示:脚本启动前已进入函数的调用会有"孤儿返回")。最后用 bpftrace -l 'kprobe:open' 找出你内核上所有与 open 相关的可挂载函数,理解为什么不同内核版本函数名不同(do_sys_open → do_sys_openat2 的重命名史)。
【实验三:sched_switch 深度观察】运行 bpftrace -e 'tracepoint:sched:sched_switch { @[args->prev_comm, args->prev_state] = count(); }' 十秒钟,观察哪些进程在切换、prev_state 的分布。prev_state=0 表示被抢占(还想运行),非 0 表示主动睡眠。找出你系统上被抢占最多的进程。然后运行 /usr/share/bcc/tools/runqlat 5 1 对照:runqlat 测的正是"想运行但没轮到"的时间。把两个输出放在一起解读,你就理解了 CPU 饱和度分析的基本方法。
【实验四:uprobe 与 USDT 对比】(1) 用 readelf -n 检查 /bin/bash、/usr/bin/python3、你的 JVM(如果有)中的 USDT 探针,记录探针名称列表。(2) 写一个 C 程序定义函数 slow_function()(内部 usleep 随机时间),编译时加 -g。分别用 uprobe(挂函数名)追踪它的调用次数和耗时。(3) 给程序加上 sys/sdt.h 的 DTRACE_PROBE1(myapp, slow_done, elapsed) 宏重新编译,用 usdt 探针追踪并对比两种方式的输出信息量。(4) 用实验二的方法测量 uprobe 对高频小函数的开销放大倍数。
【实验五:strace vs BPF 终极对比】写一个循环执行 stat() 系统调用 10 万次的程序。分别测量:裸跑耗时、strace -c 包裹耗时、BPF 追踪(tracepoint:syscalls:sys_enter_statx 计数)下的耗时。计算三种方式的每调用开销(总时间差除以调用次数),填入表格。典型结果:裸跑每次约 0.3-0.5 微秒,strace 每次额外 5-15 微秒,BPF 每次额外 0.1-0.3 微秒。这个表格就是你向同事解释"为什么生产环境用 BPF 不用 strace"的证据。
【实验报告要求】记录每个实验的命令、关键输出、以及你的解读。特别要求:实验三中必须包含对 prev_state 字段的技术解释;实验五必须包含定量表格。遇到的每个报错(验证器错误、符号找不到、权限拒绝)都记录下来——这些错误在第 3 章和第 12 章会系统讲解,现在先积累感性认识。
延伸阅读
- Brendan Gregg, "BPF Performance Tools" 第 2 章——本章的原始出处,包含更完整的追踪技术对比表和每种技术的历史时间线,Gregg 以 DTrace 作者视角写就,对比分析尤为精到。
- Linux 内核文档 Documentation/trace/kprobes.rst 与 uprobetracer.rst——kprobes 和 uprobes 的官方文档,详细描述了断点注入机制、单步执行、jprobe(已移除)的历史,是理解底层实现的第一手资料。
- Mathieu Desnoyers 的 tracepoints 设计文档与 LWN 文章 "Tracepoints" (lwn.net/Articles/365835)——tracepoints 作者的原始设计说明,解释了为什么选择静态埋点、static key 如何实现零开销。
- 《Linux Kernel Development》(Robert Love) 第 4-7 章——中断、系统调用、内存管理、进程调度的经典入门,为理解追踪点位置提供必要的内核执行模型背景,篇幅适中适合快速补齐。
- LWN 文章 "An introduction to kprobes" (lwn.net/Articles/132196) 与 "ptrace, seccomp, and the future of sandboxing"——两篇高质量的机制解析,前者讲 kprobe 实现细节,后者梳理进程拦截机制的谱系。
- perf wiki (perf.wiki.kernel.org) 与 Brendan Gregg 的 "Linux perf Examples" (brendangregg.com/perf.html)——perf 工具的实战手册,Gregg 按子系统组织了数百个 perf 命令示例,很多可以直接对照 BPF 工具学习。
- systemtap 语言参考与 DTrace 文档 (docs.oracle.com, DTrace chapter)——了解 BPF 之前的两代动态追踪系统,特别是 DTrace 的探针提供者模型对 USDT 设计的直接影响,有助于理解 BPF 生态的设计取舍。
- x86_64 ABI 与 syscall 表 (filippo.io/linux-syscall-table)——系统调用号与参数的寄存器约定速查,写 syscalls tracepoint 脚本时经常需要查参数在哪个寄存器/字段。