编辑推荐:
本文讲解了 eBPF 的核心机制(BPF 验证器如何在装载期做形式化证明),并通过 TCP、Go goroutine、Java GC 三个实战探针项目,揭示了用户态观测的工程量差异根本上取决于“状态地址由谁定义、类型信息由谁提供”这一条分档线。,
希望能为大家提供一些参考或帮助。
文章来自于CSDN,由火龙果Alice编辑推荐。
在 C++ 程序员的认知坐标系里,内存是一张平坦且任由驱遣的连续字节阵列。
我们用 reinterpret_cast 毫无顾忌地撕开网络协议的私有二进制包头,用 alignas(64) 在 CPU 缓存行上精雕细琢内存池,用 static_assert 与 C++20 Concepts 将所有类型契约在编译期压进二进制——在「零开销抽象」(Zero-Overhead Principle)的信条下,现代编译器 始终假设开发者拥有对硬件与地址空间的绝对控制权:只要代码在语法和类型系统上自洽,指令就会被毫不犹豫地喷射进机器码;如果指针算错、边界越界,那属于未定义行为(UB),代价由运行时的一记 SIGSEGV 自负,编译器绝不会在执行期对你横加阻拦。
但现实往往比纯粹的 C++ 内存模型冷酷得多。
当一个跑着核心交易逻辑的高并发服务在生产环境抖出致命的长尾延迟,你手头一切经典的 C++ 观测手段都会在瞬间失效:进程绝不能重启——重新编译带巨型模板实例化的二进制需要漫长的二十分钟,进程重启更会瞬间清空数万个活跃连接的本地缓存与 TCP 拥塞控制状态;二进制绝不能动态热补丁;甚至 GDB PTRACE_ATTACH 挂上去引发的数十毫秒进程冻结,都足以直接击穿 SLA 造成雪崩。
在这片死局里,你唯一的无损武器只剩下 eBPF:零侵入、零重启、在不碰任何二进制的前提下把探针挂进内核与用户态的热路径。
你按照写 C++ 底层代码的严谨习惯,迅速写好了一段挂在系统调用上的探针逻辑。Clang 18 在 -O2 -Wall -Wextra -Wpedantic 下编译得极其顺滑,一行 Warning 都没有,ASan 和 UBSan 跑起来也挑不出丝毫瑕疵。但当你满怀信心地调用 sys_bpf(BPF_PROG_LOAD) 试图把字节码载入内核时,Linux 内核当头就是一声无情的冷水:
libbpf: prog 'trace_write' : BPF program load failed: Permission denied
libbpf: -- BEGIN PROG LOAD LOG --
; long len = ctx-> args[2 ];
3 : (79 ) r3 = * (u64 * )(r6 + 32 ) ; R3 _w= scalar()
; bpf_probe_read _user(buf, len, (void * )ctx-> args[1 ]);
9 : (85 ) call bpf_probe_read _user#112
R2 unbounded memory access , use 'var &= const' or 'if (var < const)'
processed 11 insns (limit 1000000 ) max_states_per_insn 0
这声 Permission denied 彻底击碎了 C++ 工程师的肌肉记忆:代码通过了类型检查,目标内存也是只读的,甚至没有任何并发竞争,为什么内核会判定为非法?
因为拦截你的从来不是常规的编译器,而是 Linux 内核装载期的一座形式化证明闸门——BPF 验证器(Verifier)。
在内核看来,不存在“程序员自负其责”的契约。在装载(Load Time)的那一微秒里,内核验证器化身为一个极度偏执的形式化证明机:它沿着控制流图上的每一条基本块路径做抽象解释(Abstract Interpretation),穷举追踪每个虚拟寄存器的 tnum(已知位)、有符号/无符号数值上下界。只要有一条执行路径上的内存访问上界在数学上无法证明有界,哪怕实际运行中该分支的触发概率为零,内核也会无情拒载。
验证器拒绝你的措辞从来不是「你越界了」,而是冷酷的**「我无法证明你绝不越界」**。
行业里常有两种极其误导人的通俗比喻:有人说它是「安全的内核模块」,但内核模块开工第一件事往往是 kmalloc 并睡眠等待 I/O,BPF 里这两件事全被禁止;有人说它是「内核里的 JavaScript」,但 JS 依赖重型运行时与 GC 沙箱,而 BPF 是直接 JIT 成原生指令借用他人栈帧裸跑。
更硬核、更贴近 C++ 系统程序员认知的定义是:一段 eBPF 程序,是一个必须向内核预先提交形式化停机证明与内存安全证明、借用他人调用栈原位执行的纯函数(Pure Function)。
验证器拒的是它证明不了的代码
上面那段被拒的程序,我把它写完整贴出来。目标很朴素:挂在 sys_enter_write 上,把用户态传进来的那段 buffer 抄一份出来。
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
SEC ("tracepoint/syscalls/sys_enter_write" )
int trace_write (struct trace_event_raw_sys_enter *ctx)
bpf_probe_read_user (buf, len, (void *)ctx->args[1 ]);
一个写过十几年 C++ 的人看这段代码,第一反应大概是「len 来自用户态,当然要 clamp」。这个反应是对的,但它是工程习惯。验证器要的是另一样东西:一份能被机器检查的证明。
内核里干这件事的是 kernel/bpf/verifier.c,两千多行的 do_check() 是它的主循环。它的工作方式,用 C++ 的话说,是一次抽象解释(abstract interpretation):不真的执行程序,而是给每个寄存器维护一个「所有可能取值的集合」的近似,逐条指令往前推。
寄存器的这份近似状态叫 struct bpf_reg_state。它同时维护五套东西:
s64 smin_value, smax_value;
u64 umin_value, umax_value;
tnum 是最有意思的一个。它用一对 {mask, value} 表达「这个 64 位数里哪些位我知道、知道的那些位是什么」——mask 里为 1 的位表示未知,value 里为 1 的位表示确定为 1。这是一个标准的 bit-level 抽象域。r3 &= 0xff 这条指令在这个域上的语义非常干净:mask 的高 56 位直接清零,验证器立刻就知道 r3 的上界是 255。
所以修法只有一条,而且必须写成验证器认得出的形式:
long len = ctx->args[2 ];
if (len < 0 || len > sizeof (buf))
return 0 ;
bpf_probe_read_user (buf, len, (void *)ctx->args[1 ]);
或者更省事的位掩码写法 len &= sizeof(buf) - 1;。两种写法在 x86 上编译出来的机器码差不多,但在验证器眼里区别很大:前者是通过分支收窄区间(umax_value 被赋成 sizeof(buf)),后者是通过位运算收窄 tnum。两条路都通,走哪条取决于哪种写法在你的逻辑里更自然。
我个人的取舍是:能用 if 就别用位掩码。位掩码在 sizeof(buf) 不是 2 的幂时会静默给出一个错的上界,而验证器只关心「上界存在」,它不会替你检查这个上界对不对。这是 BPF 编程里第一个反直觉的地方——通过验证只意味着内存安全,与逻辑正确无关。
路径爆炸与状态剪枝
抽象解释要走遍所有路径,代价是路径数随分支指数增长。验证器为此做了两层控制。
第一层在 check_cfg():先把程序当成控制流图做一次 DFS,检查它是不是有向无环图,顺便标出不可达代码。5.3 以前,回边一律拒绝——这就是老 BPF 程序里满地 #pragma unroll 的由来。5.3 引入了有界循环支持,条件是验证器能证明循环会终止;5.17 加了 bpf_loop() 这个 helper,把循环体做成回调、循环次数当参数传,验证器只验一遍循环体;6.4 之后有了 open-coded iterators(bpf_for() 那一套),写起来终于像正常 C。
第二层在 states_equal() / regsafe():这是状态剪枝。走到某条指令时,如果之前在同一条指令上保存过的某个状态「包含」当前状态——所有寄存器的取值集合都是当前的超集——那么这条路后面的行为已经被验证过一遍了,直接剪掉。这里还叠了一层寄存器活跃性分析:一个寄存器如果后面根本不会被读,它的值是什么就无关紧要,比较时可以忽略。
剪枝的效果决定了一个程序能不能装载。验证器的硬上限是 BPF_COMPLEXITY_LIMIT_INSNS,5.2 之后是一百万条已处理指令——注意是处理次数,一条指令在不同路径上被走到十次就算十次。这个上限和程序本身的长度是两回事,Alexei Starovoitov 在提这个补丁时的原话大意是:能不能装载不再取决于程序有多大,而取决于验证器有多聪明。
对写代码的人来说,这条上限的实际形态是:程序里一个看似无害的嵌套分支,可能把已处理指令数推高一个量级。我遇到过一次,在一个解析七层协议头的程序里加了一个 switch 处理四种子类型,processed insns 从二十多万跳到八十多万,离上限只剩一步。当时的处置是把 switch 拆成一个 per-CPU array map 查表,四条分支变成一次 map 查找,指令数掉回三万。这不是优化技巧,是 BPF 里的常规手法:用数据结构换分支,因为分支要被验证,数据不用。
验证器的保守性是设计目标,不是缺陷
值得写进架构师笔记的一条:验证器给出的是单向保证。通过验证的程序一定内存安全;被拒的程序里有大量是完全正确的。它只放行它能证明的那部分。
最典型的例子是 Spectre 缓解。验证器会拒绝一些看上去毫无问题的指针算术,理由是这些指令在推测执行下可能被用来做侧信道。bpf_jit_harden 打开时,常量还会被 blinding 掉,JIT 出来的代码里找不到原始立即数。这些机制的共同点是:它们让「能写出来的正确程序」这个集合变小了,换来的是「能装载的程序」这个集合里没有危险分子。
C++ 里有个粗糙的对应物:constexpr 函数。编译期能求值的函数集合,永远小于数学上可求值的函数集合,标准每一版都在往外扩,但边界永远存在。验证器是同一个形状的东西,只不过它的求值发生在装载期,而且失败的代价是一条 -EACCES,而非一条编译错误。
这套指令集是为验证而设计的
理解了「必须先证明」,eBPF 那套看起来颇为局促的指令集就顺理成章了。它的每一条约束,都是为了让上一节那个证明过程能在一百毫秒内跑完。
指令编码是定长的,八字节一条:
┌────────┬────┬────┬────────────┬──────────────────────┐
│ opcode │dst │src │ offset │ imm │
│ 8 bit │4 bit │4 bit │ 16 bit │ 32 bit │
└────────┴────┴────┴────────────┴──────────────────────┘
dst 和 src 各四位,所以寄存器最多十六个,实际用了十一个:R0 到 R10。少数指令(比如把一个 64 位立即数装进寄存器的 BPF_LD | BPF_IMM | BPF_DW)占两条槽位,共十六字节,第二条里放立即数的高 32 位。
寄存器的分工是硬约定,写进 ABI:
寄存器
角色
对应 x86-64
R0
helper 返回值 / 程序返回值
rax
R1-R5
helper 调用的前五个参数
rdi rsi rdx rcx r8
R6-R9
callee-saved
rbx r13 r14 r15
R10
只读帧指针
rbp
R10 那一行是关键。它是只读的:任何试图对它做算术并写回的指令,验证器直接拒。想访问栈,只能写成 *(u64 *)(r10 - 8) 这种「帧指针加常量偏移」的形式,而这个偏移必须落在 [-MAX_BPF_STACK, 0) 里。MAX_BPF_STACK 是 512 字节,写死在 include/linux/filter.h。
五百一十二字节,是 BPF 编程里第一个真正咬人的约束。一个 struct sockaddr_in6 加几个字符串缓冲区就能把它吃光。标准解法是把大对象挪进 map:
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__type(value, struct event);
static __always_inline struct event *get_scratch (void )
return bpf_map_lookup_elem (&scratch, &zero);
这段代码里有个细节值得指出来:bpf_map_lookup_elem() 的返回值必须判空,if (!e) return 0; 一行都不能省。验证器把它的返回类型标成 PTR_TO_MAP_VALUE_OR_NULL,只有经过一次显式的空指针比较,这个寄存器的类型才会被收窄成 PTR_TO_MAP_VALUE,之后才允许解引用。C++ 里的 std::optional 强制你调 has_value(),是同一个思路,区别在于这边的检查发生在装载期而且无法绕过。
JIT 出来的东西长什么样
BPF 的寄存器分工与 x86-64 的调用约定几乎一一对应,这不是巧合。看一眼 arch/x86/net/bpf_jit_comp.c 里的映射表就明白了:
static const int reg2hex[] = {
一一映射意味着 JIT 不需要做寄存器分配。do_jit() 基本上是一个大 switch,逐条 BPF 指令翻成一到几条 x86 指令。这是 BPF 能有「装载即最优」这种性质的原因:编译期(clang)已经做完了所有优化,JIT 只做指令选择。
装载后可以直接看结果:
bpf_prog_a3f8c1e2_trace_write:
1e: movabs $0xffffc9000042a000 ,%rdi
28: call 0xffffffff811a3f40
开头那个五字节 nop 不是浪费。它是 __fentry__ 的补丁位,下一节的 BPF trampoline 就靠改写它工作——BPF 程序本身也可以被 fentry 挂载,用来剖析 BPF 程序自己的开销。
顺带说一件容易被忽略的事:bpf_jit_enable 在多数发行版上默认是 1,但有些加固内核会把它关掉,退回解释器执行(__bpf_prog_run())。同一段程序在解释器下慢一个量级以上。我的习惯是在部署脚本里加一行 cat /proc/sys/net/core/bpf_jit_enable 的断言——这个值出问题的时候,症状是「探针开着,CPU 高了几个点,但没人知道为什么」,排查起来非常费时间。
两条真实的表达力边界
写到这里,有两条边界该就地说清楚,免得后面三个项目里反复撞上。
一,BPF 程序里没有堆。 所有内存要么在那 512 字节栈上,要么在 map 里,而 map 的容量在创建时就定死了。这意味着任何「按需增长的数据结构」在 BPF 侧都做不出来。想聚合一个不定长的调用栈?只能用 BPF_MAP_TYPE_STACK_TRACE 这种预分配槽位的固定结构,槽位满了就丢。
二,BPF 程序里不能阻塞。 它跑在被探测代码的上下文里,很多时候还在关中断的临界区。所以没有 msleep,没有 mutex_lock,也不能主动让出 CPU。sleepable BPF(SEC("fentry.s/..."),5.7 引入)放宽了一部分,但只在明确安全的挂载点上可用,而且要配合 bpf_rcu_read_lock() 这类原语手工管理保护域。
这两条加起来的后果是:BPF 适合做「采样、过滤、计数、转发」,不适合做「解析、重组、聚合」。所有复杂逻辑得推到用户态。后面三个项目的架构全长成一个样子——内核里的 BPF 程序只负责在正确的时刻抓一份定长快照丢进 ring buffer,用户态进程负责所有解释工作。这个分工不是设计品味,是上面两条约束逼出来的。
探针挂在哪一层?
程序能跑之后,第二个问题是它在什么时刻被触发。这一节是整篇文章的枢纽——三个项目的成本差异,八成来自这里。
拿同一个内核函数做对照最省事。假设我想在 tcp_sendmsg() 入口上记一笔,有两条完全不同的路可以走。
第一条路是 kprobe。 内核把 tcp_sendmsg 第一条指令的首字节改写成 0xCC(int3),原指令拷到一块单独的 slot 里。CPU 执行到这里触发断点异常,走 do_int3() → kprobe_int3_handler() → 你的 BPF 程序 → 然后在 slot 里单步执行那条被换掉的原指令 → 跳回去。
这条路的开销是一次同步异常加两次上下文保存。公开的对比测量给出的量级是:一个空的 int3 版 kprobe 大约 1765 个 cycle,而经过跳转优化的 kprobe(CONFIG_OPTPROBES,条件允许时把 int3 换成一条五字节的 jmp)大约 168 个 cycle。一个数量级的差距,全在异常处理路径上。
第二条路是 fentry。 内核编译时打开 CONFIG_DYNAMIC_FTRACE 之后,几乎每个非 inline 函数的入口处都留了一个五字节的 nop(就是上一节 JIT 输出里那个)。挂载时,内核用 text_poke_bp() 把这个 nop 原地改写成一条 call,目标是一段动态生成的 BPF trampoline。这段 trampoline 的工作是:按被探测函数的原型把参数寄存器搬到一块栈上的 ctx 数组里,call 你的 BPF 程序,恢复寄存器,然后继续执行原函数体。
全程零异常,只有一次直接调用。Alexei 在提 BPF trampoline 补丁时的说法是「相比 k[ret]probe,调用 BPF 程序这件事本身的开销几乎为零」;社区的经验值是 fentry 比 kprobe 快十倍上下,也就是回到 168 cycle 那一档甚至更低。
fexit 那一侧的差距更大。kretprobe 需要一个 kretprobe_instance 池、要改写返回地址、要在函数返回时再陷一次;BPF trampoline 直接在 trampoline 里把原函数 call 起来,返回后顺手执行 fexit 程序——参数和返回值同时可见,这是 kretprobe 做不到的(kretprobe 里想看参数得自己在 kprobe 侧存一份到 map,再在 kretprobe 侧捞出来)。
SEC ("kprobe/tcp_sendmsg" )
int BPF_KPROBE (kp_sendmsg, struct sock *sk, struct msghdr *msg, size_t size)
SEC ("fentry/tcp_sendmsg" )
int BPF_PROG (fe_sendmsg, struct sock *sk, struct msghdr *msg, size_t size)
int BPF_PROG (fx_sendmsg, struct sock *sk, struct msghdr *msg,
fentry 的另一半价值在第二段注释里:参数类型由内核 BTF 校验。kprobe 那套 PT_REGS_PARM1 宏,本质是你自己声明「第一个参数是 struct sock *」,写错了没人拦你,直接读到一个错的地址。fentry 会在装载期拿内核 BTF 里的 tcp_sendmsg 原型比对,类型对不上直接拒绝装载。
五种挂载点,按「地址由谁定义」排一次序
上面两条路都属于动态探测:内核并没有事先答应你可以挂在 tcp_sendmsg 上,是你自己找到这个符号、自己决定挂上去的。与之相对的是静态埋点。把五种挂载点摊开,能看出一条很清楚的线:
挂载点
地址来源
谁承诺它稳定
触发开销量级
tracepoint
内核源码里的静态埋点
内核(准 UAPI)
直接调用
fentry/fexit
ftrace 留的 nop
内核 BTF(原型)
直接调用
kprobe
kallsyms 里的符号地址
无人承诺
异常,或优化后跳转
USDT
二进制里的 SDT note
应用作者
用户态陷入
uprobe
用户态符号或裸地址
无人承诺
用户态陷入
第三列是本文后面所有内容的主线。tracepoint 之所以特殊,是因为它在内核源码里长这样:
TRACE_EVENT (inet_sock_set_state,
TP_PROTO (const struct sock *sk, const int oldstate, const int newstate),
__field(const void *, skaddr)
__array(__u8, saddr_v6, 16 )
__array(__u8, daddr_v6, 16 )
这个宏展开后会在 /sys/kernel/tracing/events /sock / inet_sock_set_state/format 里生成一份可读的字段布局表。字段名和偏移是内核对外的承诺——不是严格意义上的 UAPI,但内核社区在实践中把它当准 UAPI 对待,删字段会招来强烈反对。这一条承诺,就是第五节那个 TCP 探针「跨大版本零改动」的全部理由。
一次静默失效,以及它教给我的事
kprobe 那一行「无人承诺」不是修辞。
几年前我做过一个按连接统计发送字节数的探针,kprobe 挂在 tcp_sendmsg_locked() 上——选它而非 tcp_sendmsg,是因为它在锁内、拿到的 sk 状态更完整。灰度、上线、面板出图,一切正常,跑了大半年。
后来一批机器做内核升级。升级完的第二周,值班同事报了一个奇怪现象:新内核那批机器的发送字节数指标全部归零,但业务流量正常。查了两天,根因是 tcp_sendmsg_locked 在新版本里被编译器内联进了 tcp_sendmsg,kallsyms 里那个符号没了。
真正让我记住这件事的是失效的形态。libbpf 在挂载失败时是会报错的,但我们那个 agent 里,挂载失败的处理是「记一条 warn 日志,继续跑其余探针」——写这段代码的时候,理由是「一个探针挂不上不该拖垮整个 agent」,这个理由现在看依然成立。问题在于,warn 日志淹没在别的日志里,而指标从「有值」变成「没数据」,在时序数据库里表现为曲线断掉,而当时那块面板的空值策略是「补零」。于是一条断掉的曲线被画成了一条贴地的直线,看起来像业务真的没流量。
处置是两件事:探针挂载失败改为让 agent 启动失败(宁可显式崩,别静默降级),以及把所有 kprobe 目标换成 fentry——fentry 挂的是 ftrace 的 nop,函数被内联时那个 nop 也一起消失,但 libbpf 在装载期就会因为 BTF 里找不到这个函数而报错,失败点从运行时提前到了装载时。
这件事的一般化版本,是本文后面会反复出现的一条判据:探针失效的方式,比探针的开销重要得多。崩溃是好消息,静默出错才是灾难。第七节那个 Go 的例子,会把这句话推到更难看的地步。
内核交出了自己的类型布局
先看结果,再回头看它凭什么成立。
下面这个 .o 文件,我在一台 5.15 的 Ubuntu 和一台 6.8 的 Debian 上分别装载过,同一个二进制,都能正确读出 bytes_acked:
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
int BPF_PROG (on_close, struct sock *sk, long timeout)
struct tcp_sock *tp = (struct tcp_sock *)sk;
__u64 acked = BPF_CORE_READ (tp, bytes_acked);
bpf_printk ("acked=%llu" , acked);
struct tcp_sock 在这两个内核之间字段布局是不一样的——中间加过字段、调过对齐、开关过 config。传统写法(BCC 那一套)解决这个问题的方式是在目标机器上带一个完整的 clang,运行时读内核头文件现场编译。代价是每台机器都得装 kernel-devel,agent 镜像几百 MB 起步,冷启动要好几秒。
CO-RE 的做法完全不同,它把这个问题拆成了「记录」和「重定位」两步。
记录发生在编译期。 BPF_CORE_READ(tp, bytes_acked) 这个宏,展开之后核心是 clang 的一个内建函数 __builtin_preserve_access_index()。它告诉 clang:这里我要访问 struct tcp_sock 的 bytes_acked 字段,请你不要把偏移直接烧进指令,而是生成一条指令 + 一条重定位记录,记录里写清楚「类型是 tcp_sock、访问路径是第 N 个成员」。这些记录汇总在 ELF 的 .BTF.ext 段里。
反汇编能看到编译期填进去的那个偏移只是个占位值:
console
$ llvm-objdump -d --section=fentry/tcp_close tcp_close.bpf.o
1: 79 11 60 09 00 00 00 00 r1 = *(u64 *)(r1 + 0x960)
重定位发生在装载期。 libbpf 打开 /sys/kernel/btf/vmlinux——这是内核自带的一份完整类型信息,由 pahole 在内核构建时从 DWARF 转换而来,CONFIG_DEBUG_INFO_BTF=y 时会被打进 vmlinux 并在运行时暴露出来。libbpf 拿本地 BTF 里的 tcp_sock 定义去目标 BTF 里找匹配类型,找到之后按成员名重新算一遍偏移,把结果直接改写进那条指令的立即数字段,然后才调 bpf() 装载。
所以 CO-RE 的本质是一套按名字而非按偏移的链接。它跟 C的名字修饰是同一类问题的两种答案:C 用 mangling 把类型编码进符号名,让链接器在符号级别做类型检查;CO-RE 用 BTF 把类型布局完整存下来,让加载器在字段级别做偏移重算。前者只能校验,后者能修正。
vmlinux.h 本身也是从这份 BTF 生成的:
console
$ bpftool btf dump file / sys/ kernel/ btf/ vmlinux format c > vmlinux.h
将近二十万行的一个头文件,涵盖内核里每一个有类型信息的结构体。它替换掉了 #include <linux/tcp.h> 那一整套——BPF 程序不再需要内核头文件,只需要目标内核的 BTF。
数据怎么从内核出来
CO-RE 解决了「读得对」,还剩「传得出」。BPF 侧能用的通道有两条。
老通道是 BPF_MAP_TYPE_PERF_EVENT_ARRAY 配 bpf_perf_event_output():每个 CPU 一个环形缓冲区,用户态 mmap 后轮询。它的两个毛病都很致命:per-CPU 缓冲意味着事件的全局顺序丢了(用户态收到的顺序与发生顺序无关,得靠时间戳自己排),以及内存按 CPU 数乘一遍,一百二十八核的机器上光缓冲区就要吃掉可观的一块。
5.8 引入的 BPF_MAP_TYPE_RINGBUF 把这两条都修了:全局一个缓冲区、保序、内存只占一份。它的 API 也更适合 BPF 那套约束:
C
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024 );
struct event *e = bpf_ringbuf_reserve (&events, sizeof (*e), 0 );
e->ts = bpf_ktime_get_ns ();
e->pid = bpf_get_current_pid_tgid () >> 32 ;
bpf_ringbuf_submit (e, 0 );
reserve/submit 这个两段式的意义在于:预留阶段就把空间和位置定下来,之后往里填数据是纯内存写,不会失败。这正好契合上一节那条「BPF 程序不能阻塞、不能重试」的约束——一个只能成功或立即放弃的 API,才是这个执行环境要的形状。
代价也得说清楚:ring buffer 是有损的。缓冲满了 reserve 返回 NULL,事件就丢了,而且 BPF 侧无法知道丢了多少(能知道的是用户态通过 bpf_ringbuf_query() 读到的水位)。任何基于 BPF 事件流做的「精确计数」都是不成立的,包括后面第五节那个 TCP 探针——它统计的是采到的连接,不是全部连接。要精确计数只有一条路:在 BPF 侧用 map 做原子累加,用户态定期拉取,牺牲的是每个事件的明细。
这套东西的边界画在哪里
BTF 覆盖内核,仅此而已。
/sys/kernel/btf/vmlinux 里有内核所有结构体的布局;/sys/kernel/btf/<module> 里有可加载模块的。用户态程序的类型信息不在这里面,一个字节都没有。想对用户态做 CO-RE 那样的「按名字读字段」,得自己造一份等价的东西——从 DWARF 里提取、按版本存表、装载时选表。
这就是本文后半段的全部剧情。第五、六节的 TCP 探针能享受到 BTF 的红利,第七、八节的 goroutine 探针必须自己造一份,第九、十节的 Java GC 连造的机会都没有。
那个稳定的挂载点
第一个项目:不改一行业务代码、不重启任何进程,把这台机器上每一条 TCP 连接的对端、生存时长、收发字节数记下来。
先问一个问题,因为它决定了整个探针的形状:一条 TCP 连接的字节统计,在哪一个时刻是完整且可读的?
不是 close() 系统调用返回的时候——那时候可能还有数据在发送队列里等 ACK。也不是 tcp_sendmsg 每次返回的时候——那只是拷进内核缓冲,不代表对端收到了。答案在 TCP 状态机上:struct sock 迁移到 TCP_CLOSE 那一刻,tcp_sock 里的字节计数器已经定格,套接字对象还没被释放。
验证这个答案的办法是去看内核给这次迁移留了什么埋点:
console
$ cat /sys/kernel/tracing/events/sock/inet_sock_set_state/format
name: inet_sock_set_state
field:const void * skaddr; offset:8; size:8; signed:0;
field:int oldstate; offset:16; size:4; signed:1;
field:int newstate; offset:20; size:4; signed:1;
field:__u16 sport; offset:24; size:2; signed:0;
field:__u16 dport; offset:26; size:2; signed:0;
field:__u16 family; offset:28; size:2; signed:0;
field:__u8 protocol; offset:30; size:1; signed:0;
field:__u8 saddr[4]; offset:31; size:4; signed:0;
field:__u8 daddr[4]; offset:35; size:4; signed:0;
field:__u8 saddr_v6[16]; offset:39; size:16; signed:0;
field:__u8 daddr_v6[16]; offset:55; size:16; signed:0;
这个 tracepoint 是 4.16 加进来的,同一批改动还补齐了几处漏掉的状态迁移,使得所有 TCP 状态变化都能被这一个埋点看到。bcc 的 tcplife 早年挂的是 tcp_set_state() 的 kprobe,4.16 之后切到了这个 tracepoint——切换的理由跟第三节那个内联事故是同一条:kprobe 挂的符号没人承诺,tracepoint 的字段布局有人承诺。
注意 skaddr 这个字段:它是 struct sock * 的地址值。tracepoint 只把裸指针交出来,具体要从这个 sock 上读什么,得自己配合 BTF 去取。这个组合——稳定的触发时机 + BTF 保证的字段读取——就是内核态观测的完整形态。
完整的探针
C
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_endian.h>
__u16 family, sport, dport;
__u8 saddr[16 ], daddr[16 ];
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 65536 );
__type(key, struct sock *);
struct ident { __u32 pid; char comm[16 ]; };
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 65536 );
__type(key, struct sock *);
__type(value, struct ident);
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 20 );
SEC ("tracepoint/sock/inet_sock_set_state" )
int on_set_state (struct trace_event_raw_inet_sock_set_state *ctx)
if (ctx->protocol != IPPROTO_TCP)
struct sock *sk = (struct sock *)ctx->skaddr;
if (ctx->newstate < TCP_FIN_WAIT1) {
__u64 ts = bpf_ktime_get_ns ();
bpf_map_update_elem (&birth, &sk, &ts, BPF_ANY);
if (ctx->newstate == TCP_SYN_SENT || ctx->newstate == TCP_LAST_ACK) {
id.pid = bpf_get_current_pid_tgid () >> 32 ;
bpf_get_current_comm (&id.comm, sizeof (id.comm));
bpf_map_update_elem (&owner, &sk, &id, BPF_ANY);
if (ctx->newstate != TCP_CLOSE)
__u64 *tsp = bpf_map_lookup_elem (&birth, &sk);
bpf_map_delete_elem (&owner, &sk);
struct event *e = bpf_ringbuf_reserve (&events, sizeof (*e), 0 );
bpf_map_delete_elem (&birth, &sk);
bpf_map_delete_elem (&owner, &sk);
e->span_us = (bpf_ktime_get_ns () - *tsp) / 1000 ;
struct tcp_sock *tp = (struct tcp_sock *)sk;
e->rx_b = BPF_CORE_READ (tp, bytes_received);
e->tx_b = BPF_CORE_READ (tp, bytes_acked);
if (ctx->family == AF_INET) {
__builtin_memcpy(e->saddr, ctx->saddr, 4 );
__builtin_memcpy(e->daddr, ctx->daddr, 4 );
__builtin_memcpy(e->saddr, ctx->saddr_v6, 16 );
__builtin_memcpy(e->daddr, ctx->daddr_v6, 16 );
struct ident *id = bpf_map_lookup_elem (&owner, &sk);
__builtin_memcpy(e->comm, id->comm, sizeof (e->comm));
e->pid = bpf_get_current_pid_tgid () >> 32 ;
bpf_get_current_comm (&e->comm, sizeof (e->comm));
bpf_ringbuf_submit (e, 0 );
bpf_map_delete_elem (&birth, &sk);
bpf_map_delete_elem (&owner, &sk);
char LICENSE[] SEC ("license" ) = "GPL" ;
有三处值得逐个说清楚。
birth 的写入条件是 newstate < TCP_FIN_WAIT1,而非只在 ESTABLISHED。 TCP_ESTABLISHED 的枚举值是 1,TCP_FIN_WAIT1 是 4,中间还夹着 SYN_SENT 和 SYN_RECV。之所以把早期状态全收下,是因为并非每条路径都保证走到 ESTABLISHED 时会触发一次状态设置——服务端从 LISTEN 接受连接的路径在某些内核版本上就绕过了。bcc 的源码里在这一段留了一条注释,说的是同一件事:宁可多存几个时间戳,也别漏掉出生时刻。这种「宁可放宽」的写法在探针里非常常见,因为漏采是静默的,多采是可发现的。
进程身份必须在 SYN_SENT/LAST_ACK 那一刻抓。 TCP_CLOSE 这次迁移经常发生在软中断上下文——对端发 FIN、本机在收包路径上处理——那时候 bpf_get_current_pid_tgid() 拿到的是恰好被中断的那个进程,跟这条连接毫无关系。这是所有内核态探针都要处理的一类问题:事件发生的上下文,与事件所属的实体,未必是一回事。解法固定是「在上下文正确的时刻把身份存进 map,在事件发生时捞出来」。
bytes_acked 是已被对端确认的字节数,而非已提交给内核的字节数。 它比 write() 返回值的累加要小,差额是发送队列里还没被 ACK 的部分。选它而非 tp->snd_nxt - tp->snd_una 这类推算,理由很简单:bytes_acked 是内核自己在 tcp_ack() 里维护的计数器,语义明确且在 TCP_CLOSE 时刻已经定格。
用户态那半用 libbpf skeleton,二十行不到:
C
static int handle (void *ctx, void *data, size_t sz)
const struct event *e = data;
inet_ntop (e->family == 2 ? AF_INET : AF_INET6, e->saddr, s, sizeof (s));
inet_ntop (e->family == 2 ? AF_INET : AF_INET6, e->daddr, d, sizeof (d));
printf ("%-7d %-12s %-21s %-21s %7llu %7llu %8.2f\n" ,
e->rx_b >> 10 , e->tx_b >> 10 , e->span_us / 1000.0 );
struct tcplife_bpf *skel = tcplife_bpf__open_and_load ();
tcplife_bpf__attach (skel);
struct ring_buffer *rb = ring_buffer__new (
bpf_map__fd (skel->maps.events), handle, NULL , NULL );
while (ring_buffer__poll (rb, 100 ) >= 0 )
跑起来是这样:
console
PID COMM LADDR RADDR RX_KB TX_KB MS
21521 curl 10.42.7.19:44182 93.184.216.34:443 14 1 118.24
2199 java 10.42.7.19:51002 10.42.7.31:9092 92 413 4210.77
2199 java 10.42.7.19:51004 10.42.7.31:9092 0 0 1002.11
3311 envoy 10.42.7.19:8080 10.42.9.4:39122 210 88 62.90
1024 node_export 10.42.7.19:9100 10.42.0.5:58822 1 12 3.55
第三行那种「零字节、活了一秒」的连接,是这个工具最有价值的产物之一。它通常意味着连接建立了但握手后什么都没发生——连接池预热、健康检查、或者某个客户端在建连之后就超时了。这类连接在应用日志里完全不可见,在 netstat 里也抓不到(生命周期太短),只有在状态机层面观测才拿得到。
这个探针的实际开销
它的维护成本,实测下来接近于零。原因逐条列出来:触发时机来自 tracepoint,字段布局是内核的准 UAPI;字段读取走 CO-RE,偏移在装载期按目标内核 BTF 重算;struct sock 与 struct tcp_sock 的字段名在 4.x 到 6.x 之间没变过。同一个 .o,我在 5.4、5.15、6.1、6.8 四个内核上都装载过,一行没改。
代价有两处,都不在维护上。一是 ring buffer 会丢事件,所以这份数据适合做拓扑发现和异常连接定位,不适合当计费依据。二是每条连接的两次 map 写入是有开销的——短连接密集的机器(比如一个每秒建连上万的 sidecar)上,我实测过大约百分之一到二的 CPU 上浮。这个数字可以接受,但它随建连速率线性增长,不是随流量增长;给一台跑长连接的 Kafka broker 挂这个探针,开销基本量不出来。
一个字段的单位,和一个字段的语义
连接的生老病死拿到了,下一个需求几乎必然被提出来:为什么慢。这一节把 tcp_sock 往深里读一层,顺便交代一个我付了两周代价才搞明白的坑。
struct tcp_sock 是内核里少数几个「什么都往里塞」的巨型结构体,6.8 上大约一千两百字节。跟延迟诊断有关的字段集中在几处:
C
第一行的注释就是这一节标题的来源。srtt_us 存的是八倍的平滑 RTT,单位微秒。内核这么做是为了在整数运算下保住三位小数的精度——RTT 的平滑公式是 srtt = 7/8 * srtt + 1/8 * rtt,把整个量放大八倍之后,这个更新可以写成纯移位加减,没有除法。tcp_rtt_estimator() 里那几行就是这个形式。
所以任何读 srtt_us 的代码都必须先右移三位:
C
/ * 正确:srtt_us >> 3 得到微秒 */
__u32 srtt_us_raw = BPF_CORE_READ (tp, srtt_us);
e-> rtt_us = srtt_us_raw >> 3;
漏掉这个移位不会崩,不会报错,只会让面板上所有 RTT 大八倍。八倍是个很讨厌的倍数——它足够大到让数字看起来不对劲,又足够小到让人怀疑是不是网络真的抖了。ss -ti 输出里那个 rtt: 已经帮你除过了,所以拿 ss 对一下就能立刻发现问题;我当时是花了半天才想到去对。
那个我把累计值当增量画了两周的曲线
真正让我记住这一节的是 total_retrans。
需求是「按服务维度看重传率」。我在上一节那个探针的 TCP_CLOSE 分支里加了一行 e->retrans = BPF_CORE_READ(tp, total_retrans);,上报到时序库,面板上按服务聚合。图出来了,曲线也在动,看起来一切正常。
两周后一次跨机房演练,我盯着这块面板想看重传的尖峰,结果发现那条线在演练期间几乎是平的,而同一时间 netstat -s 里的 TCPRetransSegs 涨得很凶。
根因很蠢:total_retrans 是连接维度的累计计数器,我在连接关闭时把它作为一个事件上报,时序库那边配的是 sum 聚合。于是这条曲线真正在量的东西是「本时间窗内关闭的所有连接的历史重传总和」——它跟当下发生了多少重传没有关系,它跟这个窗口里恰好关闭了多少条老连接有关系。演练期间长连接被保持着没关,所以曲线反而平了。
这个错误比第三节那个内联事故更难发现,因为曲线一直在动。一条会动的错曲线,比一条断掉的曲线难查十倍。
修法是换挂载点,从「连接结束时读累计值」改成「每次重传时记一次事件」:
C
SEC ("tracepoint/tcp/tcp_retransmit_skb" )
int on_retransmit (struct trace_event_raw_tcp_event_sk_skb *ctx)
struct retrans_event *e =
bpf_ringbuf_reserve (&retrans_events, sizeof (*e), 0 );
e->ts = bpf_ktime_get_ns ();
__builtin_memcpy(e->saddr, ctx->saddr, 4 );
__builtin_memcpy(e->daddr, ctx->daddr, 4 );
struct sock *sk = (struct sock *)ctx->skaddr;
struct tcp_sock *tp = (struct tcp_sock *)sk;
e->cwnd = BPF_CORE_READ (tp, snd_cwnd);
e->srtt = BPF_CORE_READ (tp, srtt_us) >> 3 ;
e->inflt = BPF_CORE_READ (tp, packets_out);
bpf_ringbuf_submit (e, 0 );
}
一般化之后,这是我现在设计任何指标探针时的第一条检查:我读的这个字段,是一个状态量还是一个累计量? 状态量(snd_cwnd、srtt_us、packets_out)适合在事件发生时采样;累计量(total_retrans、bytes_acked)只有做差才有意义,而做差需要两个时间点上的同一条连接——要么在 BPF 侧用 map 存上一次的值,要么干脆换一个「每次发生都触发」的挂载点。后者永远更简单。
再往下一层:per-ACK 的 RTT
如果要的是「这条连接的 RTT 分布」而非「一个平滑值」,srtt_us 就不够了——它已经被指数平滑吃掉了尾部。真实的 RTT 样本产生在 tcp_ack() 里,每收到一个 ACK 算一次。
拿到它的路子有两条。一条是挂 fentry/tcp_rcv_established,在函数入口读 srtt_us,函数返回后再读一次,两者不同就说明这次 ACK 更新了估计值——但这只能还原「平滑后的变化」,仍然拿不到原始样本。另一条更直接:tcp:tcp_probe 这个 tracepoint 在每个收到的数据包上触发,字段里带了 srtt、snd_cwnd、rcv_wnd 等一整套,等于内核替你把采样点埋好了。
代价在这里必须写清楚,因为它很容易被低估。tcp:tcp_probe 是按包触发的。一台跑着 10 Gbps 流量的机器每秒有百万量级的包,即便每次只做几次 map 写入,这个开销也已经不是「量不出来」的档次了。我的经验值是:按连接触发的探针可以常驻,按包触发的探针只能按需开启并且必须带采样。BPF 侧做采样的标准写法是在程序头部加一行:
C
if ((bpf_get_prandom_u32 () & 0x3f ) != 0 )
内核字段的稳定性,比 tracepoint 弱一档
这一节从头到尾在做同一件事:读 tcp_sock 的内部字段。这里有一条边界必须交代,否则前面「跨版本零改动」那句话会被误读。
BTF 加 CO-RE 保证的是字段还在、偏移会被修对。它不保证字段的语义没变。snd_cwnd 是个现成的例子:在 Reno/CUBIC 这类基于丢包的拥塞控制下,它就是拥塞窗口,直接决定在途数据量;在 BBR 下,真正控制发送速率的是 pacing rate,snd_cwnd 更多是一个上界,读它得出的结论完全不同。字段名一个字母没变,含义变了。
所以我给内核字段读取分了两档,写在团队的探针评审清单里:tracepoint 的字段是准 UAPI,可以直接依赖;结构体字段是内核内部状态,依赖它等于依赖一个实现细节,必须在代码注释里写清楚「这个字段在什么拥塞控制/什么配置下才是我理解的那个意思」,并且在内核大版本升级时列入回归检查项。这一档差别,正好是下一节那三个用户态项目全部麻烦的来源——因为在用户态,连第一档都没有。
R14 里那个指针
第二个项目:观测一个 Go 进程里 goroutine 的状态迁移——有多少在 runnable 队列里排队、有多少卡在 waiting、某个 goroutine 从创建到消亡走了多久。同样是不改代码、不重启。
这个需求一上来就撞上一堵墙:goroutine 在内核里不存在。
对内核来说,一个 Go 进程就是若干个线程(Go runtime 里的 M)。sched:sched_switch 这类调度 tracepoint 能看到线程切换,但一个线程在两次内核调度之间可能已经跑过几十个 goroutine——Go 的调度器是用户态的,goroutine 的切换是一次 runtime.gogo 里的栈指针替换,内核全程无感。挂在内核上的探针,在这里能拿到的信息量为零。
所以只剩一条路:uprobe,挂进 Go 进程自己的代码。而这条路上的每一步,都得自己重建第四节里 BTF 免费提供的那些东西。
第一步:找到当前的 g
Go runtime 的调度模型是 G-M-P。g 是 goroutine 的控制块,m 是 OS 线程,p 是调度上下文(逻辑处理器)。跑在某个线程上的代码想知道「我现在是哪个 goroutine」,得先拿到当前的 g 指针。
Go 1.17 之前,这个指针存在 TLS 里,取它要走一次 MOVQ (TLS), CX,编译器会把它翻成一条 fs 段前缀的访存。1.17 引入寄存器调用约定(ABIInternal)之后,amd64 上把当前 g 常驻在 R14 里,取它就是一条寄存器读。
这件事在反汇编里到处都能看到。任何一个会检查栈空间的 Go 函数,序言里都有这么一条:
console
$ objdump -d ./ myapp | grep -A3 '<main.handleRequest>:'
0000000000512340 < main.handleRequest> :
512340 : 49 3 b 66 10 cmp 0 x10 (%r14 ),%rsp
512344 : 76 4 a jbe 512390 < main.handleRequest+ 0 x50 >
512346 : 48 83 ec 38 sub $0 x38 ,%rsp
0x10(%r14) 就是 g.stackguard0。编译器在每个函数入口拿栈指针跟它比,不够就跳去 runtime.morestack 扩栈。这条指令是 R14 保存 g 的直接证据——也是下一节那个崩溃的伏笔。
在 BPF 侧读 R14 很直接,uprobe 的 ctx 就是 struct pt_regs:
C
void *g = (void *)ctx->r14;
第二步:从 g 里读出 goid
拿到 g 只是拿到一个地址。goid 是 runtime.g 结构体里的一个字段,而这个结构体的布局——这是整节的核心——Go 从来没有对外承诺过。
runtime.g 定义在 src/runtime/runtime2.go 里,是 runtime 的内部类型,没有任何兼容性保证。Go 团队每个版本都在往里加字段、调顺序。社区那份广为流传的 eBPF 追踪示例里写着:
C
这个 0x98 对应的是 Go 1.17.13。换一个版本就未必对——而这正是问题所在。
那么偏移从哪里来?Go 编译出的二进制默认带 DWARF 调试信息(除非显式加了 -ldflags="-w"),所以正路是从 DWARF 里查:
console
$ objdump --dwarf= info ./ myapp \
| grep -A40 'DW_AT_name.*: runtime.g$' \
| grep -B1 -A3 'DW_AT_name.*: goid'
< 2 > < 4 a1 c8 > : Abbrev Number : 12 (DW_TAG_member)
< 4 a1 c9 > DW_AT _name : goid
< 4 a1 ce> DW_AT _data _member_location : 152
< 4 a1 cf > DW_AT _type : < 0 x3 f21 >
152 就是 0x98。生产环境里更省事的做法是用 dlv(Delve)在启动时算一次,或者在构建流水线里生成一份「Go 版本 → 字段偏移」的映射表,随 agent 一起发布。Pyroscope、DeepFlow 这类做 Go 持续剖析的工具,内部都维护着这样一张表。
这张表,就是第四节那个 BTF 在用户态的手工替代品。 内核那边,pahole 在构建时把 DWARF 转成 BTF、内核把它挂在 /sys/kernel/btf/vmlinux 上、libbpf 在装载时自动重定位——这一整条流水线,在 Go 这边全部要你自己实现一遍:自己提取 DWARF、自己存表、自己在装载时选表、自己把偏移填进 BPF 程序(通常靠 .rodata 里的全局常量,libbpf 允许用户态在装载前改写)。
C
const volatile __u64 goid_offset = 0x98 ;
第三步:选一个能看见状态迁移的函数
Go runtime 里所有 goroutine 状态变更都收口在一个函数上:
Go
/ / If asked to move to or from a Gscanstatus this will throw. Use the castogscanstatus
/ / and casfrom_Gscanstatus instead.
func casgstatus(gp * g, oldval, newval uint32 ) {
这个收口非常干净:_Grunnable、_Grunning、_Gsyscall、_Gwaiting、_Gdead 之间的每一次迁移都要经过它。状态常量也在 runtime2.go 里:
text
_Gwaiting = 4 阻塞在 channel / 锁 / timer
_Gdead = 6 执行完毕,或刚从 free 列表取出
_Gcopystack = 8 栈正在被复制(morestack)
_Gpreempted = 9 被抢占,等待重新调度
按 ABIInternal 的寄存器顺序(RAX、RBX、RCX、RDI、RSI、R8、R9、R10、R11),casgstatus 的三个参数分别落在 RAX、RBX、RCX 上。所以完整的探针是:
C
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
const volatile __u64 goid_offset = 0x98 ;
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 18 );
SEC ("uprobe//opt/app/myapp:runtime.casgstatus" )
int probe_casgstatus (struct pt_regs *ctx)
void *gp = (void *)ctx->ax;
__u32 oldval = (__u32)ctx->bx;
__u32 newval = (__u32)ctx->cx;
if (bpf_probe_read_user (&goid, sizeof (goid), gp + goid_offset) != 0 )
struct g_event *e = bpf_ringbuf_reserve (&rb, sizeof (*e), 0 );
__u64 id = bpf_get_current_pid_tgid ();
e->ts = bpf_ktime_get_ns ();
bpf_ringbuf_submit (e, 0 );
char LICENSE[] SEC ("license" ) = "GPL" ;
这段里最值得单独说一句的是那三行取参数。libbpf 提供了 PT_REGS_PARM1/2/3 这组宏,写惯 kprobe 的人会顺手用上去——而它们是按 SysV C ABI 展开的,取的是 RDI、RSI、RDX。Go 的 ABIInternal 取的是 RAX、RBX、RCX。两套约定没有一个寄存器重合,用错了照样能读出值、照样不报错,goid 会是垃圾、状态码会是垃圾,而探针看起来在正常工作。
这是本文那条主线在寄存器层面的又一次露面:「参数在第几个寄存器」这件事的定义权,在 Go 编译器手里,而不在平台手里。内核那边之所以不用操心这个,是因为 fentry 会拿 BTF 里的函数原型替你校验;用户态没有 BTF,也就没有人替你校验。
跑起来的输出,一眼就能看出 Go 调度器的节奏:
console
# ./ goroutine-trace -p 30412
09 :12 :41.118 1 30412 RUNNING(2 ) WAITING(4 )
09 :12 :41.118 38 30412 RUNNABLE(1 ) RUNNING(2 )
09 :12 :41.119 38 30412 RUNNING(2 ) SYSCALL(3 )
09 :12 :41.121 38 30412 SYSCALL(3 ) RUNNING(2 )
09 :12 :41.121 38 30412 RUNNING(2 ) DEAD(6 )
09 :12 :41.121 39 30412 DEAD(6 ) RUNNABLE(1 )
输出末尾那一行值得注意:goid 39 从 DEAD 迁到 RUNNABLE。这是 Go 的 g 复用——runtime 把执行完的 g 结构体放回 gfree 列表,下次 go func() 时优先从这里取,避免重新分配栈。goid 会被重新分配,但结构体地址是复用的。做 goroutine 生命周期统计时,key 必须用 goid 而不能用 g 指针地址,否则两个不同 goroutine 会被合并成一个。
那张偏移表出错的时候,什么都不会发生
第三节我说过「探针失效的方式比开销重要」。这一节是这句话最难看的那个版本。
我们那个 Go 边车从 1.17 升到 1.20 的时候,GOID_OFFSET 那行常量没人动过——升级 PR 里根本没有 agent 的代码。上线之后一切正常:进程没崩,探针挂上了,事件在往外流,面板在出图。
问题在两周后被一个刚入职的同事发现。他看着 goroutine 生命周期分布的面板问了一句:为什么这个 goid 是十九位数?
bpf_probe_read_user 从 gp + 0x98 读了八个字节。这个地址在新版本的 runtime.g 里落在别的字段上——具体是哪个我到现在也没完全确认,从值的形态看像是某个指针字段的一部分。读操作成功了,返回 0,因为那块内存确实映射着、确实可读。它只是读到了错的东西。
于是所有 goid 变成了一堆离散的大整数。而这套面板的主要用途是「看有没有 goroutine 泄漏」,它的核心指标是「活着的 goid 的数量」。goid 全是垃圾之后,每个事件都像一个新 goroutine,泄漏检测的曲线只会一路向上——而那两周里恰好有一个服务确实在缓慢泄漏,我们把这个真实的泄漏归因成了探针噪声。
代价到账得很晚。事后复盘时我给自己记的那条,跟技术无关:一个读用户态内存的探针,必须有自校验。这里的自校验其实很便宜——goid 是单调递增的小整数,runtime 从 1 开始分配,一个跑了几个月的服务也就到千万量级。BPF 侧加一行范围检查,配一个「异常 goid 计数」的 map,用户态发现这个计数非零就告警,整个成本不到十行代码:
C
if (goid == 0 || goid > (1ULL << 32 )) {
__u32 k = 0 , *cnt = bpf_map_lookup_elem (&bad_goid, &k);
__sync_fetch_and_add(cnt, 1 );
更根本的处置是把偏移检测放进 agent 启动流程:attach 之前先读一遍目标二进制的 DWARF,对不上就拒绝 attach 并报错。这条现在写进了我们的探针评审清单第一行——凡是依赖偏移量的探针,偏移量必须在 attach 时从目标二进制推导,硬编码一律不批。
uretprobe 为什么会崩
上一节只挂了函数入口。真做起 goroutine 剖析,很快会想挂函数出口——比如想量某个业务函数的耗时,入口记时间戳、出口做差,这是最自然的写法。
在 C++ 或 Rust 的二进制上,这件事用 uretprobe 一行就能搞定。在 Go 上,它会把被观测的进程搞崩。
崩溃是怎么发生的
uretprobe 的实现思路跟 kretprobe 一样:内核在函数入口处的 uprobe 触发时,把栈上的返回地址替换成一个 trampoline 的地址,并把原返回地址存进一个内核侧的表。函数执行完 ret 时跳进 trampoline,内核在这里跑你的 BPF 程序,然后把控制权还给真正的返回地址。
这套机制有一个隐含前提:从函数入口到函数返回,栈上那个返回地址所在的位置不会动。
C 和 C++ 的栈满足这个前提。Go 的不满足。
Go 的 goroutine 栈是可增长的,初始只有几 KB。上一节反汇编里那条 cmp 0x10(%r14),%rsp 就是栈空间检查——不够时跳进 runtime.morestack,它会分配一块更大的栈,把旧栈内容整体复制过去,然后遍历栈上所有指针把它们调整到新地址。这个调整依赖 Go 编译器生成的栈映射(stack map):编译器精确记录了每个栈帧里哪些槽位是指针,runtime 才能安全地重定位。
现在把 uretprobe 塞进这个场景。栈上某个返回地址槽位里躺着一个内核 trampoline 的地址——这个地址不在 Go 的任何模块范围内,栈映射里也没有它的登记。栈复制时,runtime 遍历到这个位置,发现了一个它无法解释的值,直接 throw:
console
runtime: unexpected return pc for main.handleRequest
called from 0x7ffd2f4a1000
stack: frame={sp:0xc00003c738, fp:0xc00003c768}
fatal error: unknown caller pc
runtime.throw({0x5a1b40?, 0x1?})
/usr/local/go/src/runtime/panic.go:1023 +0x5c
runtime.adjustframe(0xc00003c6c0, 0xc0000841a0)
/usr/local/go/src/runtime/stack.go:625 +0x3a5
runtime.copystack(0xc0000841a0, 0x4000)
/usr/local/go/src/runtime/stack.go:932 +0x2d8
/usr/local/go/src/runtime/stack.go:1112 +0x5c5
/usr/local/go/src/runtime/asm_amd64.s:616 +0x71
cilium/ebpf 的 issue 区里这个问题被反复报过,结论一致:Go 程序上不要用 uretprobe。栈增长在 Go 里是高频事件,任何有一定递归深度或者局部变量较大的函数都会触发,所以这个崩溃不是「偶尔」,而是「几乎必然,只是时间早晚」。
为什么我说崩掉算好消息
把这一节和上一节并排看,两个失效形态的对比才是真正值得记的东西。
偏移表出错:进程活着,探针在跑,数据在流,全是错的,两周后被一个新同事偶然发现。
uretprobe:进程当场死掉,栈回溯直接指着 runtime.copystack,五分钟定位。
第二种在生产上当然更疼——一次进程崩溃是要写事故报告的。但从可发现性这个维度看,它是更好的失效方式。观测系统最危险的状态从来不是「不工作」,而是「工作,且给出错误答案」,因为下游所有基于它的判断都会跟着错,而且没有任何信号提示你该怀疑它。
这条判断我现在会直接用在探针设计上:在「静默降级」和「显式失败」之间,探针一律选后者。第三节那个内联事故的处置(挂载失败改为让 agent 启动失败)是同一条原则的应用。
替代方案:把 uprobe 挂在每一条 RET 上
想在 Go 上观测函数返回,可行的路只有一条:既然内核改栈会出事,那就别改栈——直接把 uprobe 挂在函数体内每一条 ret 指令的地址上。
uprobe 只改代码段(把目标地址那一字节换成 int3,通过私有 COW 页做到只影响这一个进程),完全不碰栈,所以栈复制照常工作。
做法是先反汇编找出所有 ret 的偏移:
console
$ objdump -d --start-address= 0 x512340 --stop-address= 0 x512450 ./ myapp \
| grep -E '^\s+[0-9a-f]+:.*\sret'
然后逐个 attach。用 libbpf 的话,bpf_program__attach_uprobe_opts() 接受一个函数内偏移(func_offset),把上面找到的每个地址减去函数起始地址传进去即可。这套逻辑在 go-bpf-gen、DeepFlow 这些项目里都有现成实现。
代价有三条,都得摆出来:
一,ret 可能不止一条,也可能一条都没有。 Go 编译器会做尾调用优化和跳转合并,某些函数的出口是 jmp 而非 ret。这类函数用这个方法探不到返回。
二,内联函数没有独立的符号,也没有独立的 ret。 Go 的内联比 C++ 保守,但 -gcflags="-l" 之外的默认构建里,小函数照样会被内联掉。想探的函数被内联了,objdump 里根本找不到它。
三,每条 ret 一个 uprobe,开销按 ret 条数乘。 而 uprobe 本身就贵——它走 int3 陷入内核,来回两次上下文切换,社区实测数据是比 kprobe 慢十倍上下。一个每秒调用十万次的热点函数,挂上入口加两个出口的 uprobe,实测能吃掉一个核的可观比例。
关于开销,有两件事值得跟上:内核 6.11 加了一个 uretprobe 系统调用,用系统调用代替 int3 陷入来加速返回探针——它优化的是开销,跟 Go 的移栈问题是两码事,别指望它能让 uretprobe 在 Go 上变安全。另一条路是把 BPF 程序搬到用户态执行(bpftime 这类基于 LLVM JIT 的用户态 eBPF 运行时),彻底绕开陷入;这条路我只在测试环境试过,把它写进生产方案还需要更多证据,这里只作提及。
我自己在 Go 服务上的做法,是尽量不探业务函数的返回。要延迟数据就从 runtime 层面拿——runtime.casgstatus 的状态迁移已经能还原「这个 goroutine 在 waiting 上待了多久」,配合 net/http 那几个固定的 runtime 入口,绝大多数延迟问题都能定位。业务函数级别的耗时,用应用内埋点(OpenTelemetry 那套)远比用 uprobe 划算:当被观测方愿意配合时,别用不需要它配合的手段。这句话是下一节的引子。
埋点不在,就到此为止
第三个项目:观测一个 Java 进程的 GC——每次 GC 什么时候开始、回收了哪个内存池、堆用量掉了多少。规则照旧:不重启、不改代码、不改启动参数。
Go 那边虽然麻烦,但至少所有信息都在进程里躺着,runtime.g 的字段是实实在在的内存,够执着就能读出来。Java 这边情况变了。
先确认内核那边什么都拿不到
按上一节的思路,第一反应是找 JVM 里那个「所有 GC 都要经过」的函数。这条路在 HotSpot 上走不通,原因有三层。
第一层:符号。 发行版打包的 libjvm.so 通常是 strip 过的,.symtab 被剥掉只留 .dynsym。GC 的内部函数(ParallelScavengeHeap::invoke_at_safepoint 之类)不在动态符号表里,nm -D 看不到,uprobe 也就没有地址可挂。
第二层:内联与 C++ 修饰。 就算符号在,HotSpot 是重度模板化的 C++ 代码,-O3 下 GC 的核心路径被内联得七零八落,函数边界跟源码里的名字对不上。而残留的符号名是 mangled 的,_ZN20ParallelScavengeHeap... 这种,跨 JDK 版本、跨编译器毫不稳定。
第三层,也是最根本的一层:JIT。 Java 方法在运行时被 C1/C2 编译成机器码放进 code cache,这块代码没有任何静态符号,地址每次启动都不同。想探一个 Java 方法,静态分析二进制这条路从起点就是断的。
内核侧能看到的只有 GC 的副作用:madvise(MADV_DONTNEED) 归还内存、mmap/munmap 调整堆边界、futex 上的线程同步。这些能告诉你「刚才发生了一件重的事」,但没有一个字节能告诉你「这是第几次 Young GC、Eden 从多少降到了多少」。
那么 HotSpot 到底给不给外部观测者留了口子?给了,而且只给了一个。
hotspot provider:JVM 自己埋的二十六个点
HotSpot 在源码里预置了一组 DTrace 探针,Linux 上以 USDT(User Statically-Defined Tracing)的形式编译进 libjvm.so。整个 hotspot provider 一共二十六个探针,分六组:
分组
探针
是否默认启用
VM 生命周期
vm-init-begin vm-init-end vm-shutdown
是
线程
thread-start thread-stop
是
类加载
class-loaded class-unloaded
是
GC
gc-begin gc-end mem-pool-gc-begin mem-pool-gc-end
是
编译
method-compile-begin/end compiled-method-load/unload
是
监视器与应用
monitor-* method-entry/return object-alloc
需 -XX:+ExtendedDTraceProbes
GC 那一行就是我们全部的可用面。gc-begin 只带一个 bool is_full,gc-end 连参数都没有。真正有内容的是 mem-pool-gc-begin / mem-pool-gc-end,它每个内存池触发一次,八个参数:
C
char *mgr_name, uintptr_t mgr_name_len,
char *pool_name, uintptr_t pool_name_len,
uintptr_t initial_size, uintptr_t used,
uintptr_t committed, uintptr_t max_size);
used 这个参数是关键——begin 和 end 上各取一次,做差就是这次 GC 在这个池上回收了多少。bcc 的 lib/ugc.py 走的正是这条路,源码注释里还留了一句吐槽,大意是 gc__begin/gc__end 这对探针实在没什么有用信息。
USDT 的机制本身比想象中简单:编译时在触发点放一条 nop,同时在 ELF 里写一条 .note.stapsdt 记录,记下探针名、nop 的地址、以及各个参数当前在哪个寄存器或栈偏移上。挂载时,工具读这条 note 拿到地址,然后——在这个地址上挂一个普通的 uprobe。
所以 USDT 的本质就是 uprobe,唯一的区别是地址和参数位置由被观测方在编译期声明好了,而非由观测方去逆向。这个区别不影响开销(照样是 int3 陷入),但它改变了「谁承诺这个地址稳定」——这正是第三节那张表末两行的分野。
探针在不在,取决于当年打包的人
这是整节最需要写清楚的一句:--enable-dtrace 是 OpenJDK 的构建期配置开关,不是运行期开关。
构建时没打开,libjvm.so 里就没有那些 nop、没有那条 note、也就没有任何探针。运行时加什么 JVM 参数都补不上,-XX:+ExtendedDTraceProbes 也只是打开那些已经编译进去的、默认休眠的探针,它变不出不存在的东西。
而各家发行版的选择并不一致。Adoptium 的 issue 区里有过明确记录:Temurin 的构建配置是 --enable-dtrace=auto,而构建机上没装 systemtap 开发包,于是这个 auto 判定为不启用——默认打出来的包里没有 USDT 探针。Red Hat 和 Fedora 的 OpenJDK 包则默认启用。同一个 JDK 版本号,换一个供应商,观测能力从「有」变成「零」。
所以第一件事永远是检测:
console
$ readelf -n /usr/lib/jvm/java-21/lib/server/libjvm.so | grep -c stapsdt
$ bpftrace -l 'usdt:/usr/lib/jvm/java-21/lib/server/libjvm.so:hotspot:*' | head
usdt:/usr/lib/jvm/.../libjvm.so:hotspot:class__loaded
usdt:/usr/lib/jvm/.../libjvm.so:hotspot:gc__begin
usdt:/usr/lib/jvm/.../libjvm.so:hotspot:gc__end
usdt:/usr/lib/jvm/.../libjvm.so:hotspot:mem__pool__gc__begin
usdt:/usr/lib/jvm/.../libjvm.so:hotspot:mem__pool__gc__end
usdt:/usr/lib/jvm/.../libjvm.so:hotspot:method__compile__begin
输出为空,这个项目就到此为止。没有降级方案,没有绕路,只能去换一个带探针的 JDK 构建——而换 JDK 意味着重启,意味着这个需求最初那句「不重启」已经破了。
顺带说明一处命名:DTrace 里的探针名用连字符(mem-pool-gc-begin),编译成 USDT 之后变成双下划线(mem__pool__gc__begin),因为连字符在 C 标识符里非法。bpftrace 和 bcc 里都要用双下划线那个形式。
探针
C
#include <bpf/bpf_helpers.h>
#include <bpf/usdt.bpf.h>
__u64 used_before, used_after, committed, max_size;
struct pool_key { __u32 tid; char pool[32 ]; };
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1024 );
__type(key, struct pool_key);
__type(value, struct gc_event);
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 18 );
static __always_inline void read_str (char *dst, int cap, void *src, __u64 len)
bpf_probe_read_user (dst, len, src);
SEC ("usdt/usr/lib/jvm/java-21/lib/server/libjvm.so:hotspot:mem__pool__gc__begin" )
int BPF_USDT (gc_begin, void *mgr, __u64 mgr_len,
void *pool, __u64 pool_len,
__u64 initial, __u64 used, __u64 committed, __u64 max_size)
e.ts = bpf_ktime_get_ns ();
e.tid = (__u32)bpf_get_current_pid_tgid ();
read_str (e.mgr, sizeof (e.mgr), mgr, mgr_len);
read_str (e.pool, sizeof (e.pool), pool, pool_len);
struct pool_key k = { .tid = e.tid };
__builtin_memcpy(k.pool, e.pool, sizeof (k.pool));
bpf_map_update_elem (&inflight, &k, &e, BPF_ANY);
SEC ("usdt/usr/lib/jvm/java-21/lib/server/libjvm.so:hotspot:mem__pool__gc__end" )
int BPF_USDT (gc_end, void *mgr, __u64 mgr_len,
void *pool, __u64 pool_len,
__u64 initial, __u64 used, __u64 committed, __u64 max_size)
struct pool_key k = { .tid = (__u32)bpf_get_current_pid_tgid () };
read_str (k.pool, sizeof (k.pool), pool, pool_len);
struct gc_event *begin = bpf_map_lookup_elem (&inflight, &k);
struct gc_event *e = bpf_ringbuf_reserve (&events, sizeof (*e), 0 );
__builtin_memcpy(e, begin, sizeof (*e));
e->committed = committed;
e->ts = bpf_ktime_get_ns () - begin->ts;
bpf_ringbuf_submit (e, 0 );
bpf_map_delete_elem (&inflight, &k);
char LICENSE[] SEC ("license" ) = "GPL" ;
read_str 那个辅助函数不是可有可无的样板。HotSpot 传出来的是不带结尾符的 modified UTF-8,指针加长度两个参数配对——Oracle 的 DTrace 文档在这一点上说得很明确,DTrace 那边要用带长度的 copyinstr()。在 BPF 里对应的做法就是先 clamp 长度再读,然后自己补 \0。直接 bpf_probe_read_user_str() 会一路读到下一个零字节为止,在 HotSpot 的常量池里这可能是几十个字节之后,输出就成了一串拼接的池名。
配套的 bpftrace 一行版本,用来快速确认探针能不能触发:
console
usdt:/usr/lib/jvm/java-21/lib/server/libjvm.so:hotspot:mem__pool__gc__begin
{ @[str(arg2, arg3)] = hist(arg5 / 1048576); }' -p 4471
[16, 32) 41 |@@@@@@@@@@@@@@@@@@@@ |
[32, 64) 107 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[512, 1024) 9 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
一条命令,不改代码、不重启、不开 JMX 端口,拿到了按内存池分的 GC 前占用分布。在这个层面上 eBPF 依然很好用——只要那些 nop 当年被编进去了。
停顿的真身是安全点
上一节结束时数据已经拿到了。这一节要说的是:这份数据回答不了提出这个需求的人真正关心的问题。
问「帮我观测一下 GC」的人,九成想知道的是应用停了多久。而 gc__begin 到 gc__end 之间的那段时间,量的是 GC 算法本身跑了多久,这两个量在 HotSpot 上系统性地不相等。
差在安全点上。
HotSpot 的 GC(以及类重定义、偏向锁撤销、部分 JFR 操作等一大票 VM operation)必须在安全点执行。进入安全点的流程是:VM 线程置一个全局标志位,所有 Java 线程在它们的下一个安全点轮询处发现这个标志,然后停下来登记;等到所有线程都登记完毕,VM 线程才开始干活。
这里有两段时间:
TTSP(time to safepoint):从发出停止请求,到跑得最慢的那个线程停下来。这段时间取决于最慢的那个线程多久才能走到下一个轮询点。JIT 编译的代码在方法返回处和循环回边处插入轮询,通常几微秒就能响应。经典的例外是可数循环(counted loop):C2 早年会把这类循环回边上的轮询优化掉,理由是循环次数有界、编译器认为它很快就会结束——碰上一个上界很大的循环,TTSP 能到几十甚至上百毫秒。JDK 10 引入的 loop strip mining 把长计数循环切成一段一段、每段之间放一个轮询点(-XX:+UseCountedLoopSafepoints 自此默认开启),堵上了这一类。JNI 那条路径没变:线程在 _thread_in_native 状态下不响应轮询,要等它自己回到 JVM,所以今天遇到的高 TTSP 里,本地调用的比例比十年前高得多。
安全点内的操作时间:真正干活的部分,GC 只是其中一种。
应用感受到的停顿是这两段之和。gc__begin 触发的位置在 GC 操作开始之后,也就是在整个 TTSP 之后。所以:
text
├──────────────────── TTSP ────────────────────┤
│ 请求停止 → 各线程轮询 → 全部线程登记完 │
├──────────── safepoint 内操作 ────────────────┤
│ ┌─ gc__begin gc__ end ─┐ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────────────┘
低估的幅度跟负载形态强相关。常规 Web 服务上 TTSP 是几百微秒,相对 GC 本身的几十毫秒可以忽略;而在有大数组扫描循环、或者有重 JNI 调用的服务上,TTSP 反过来能占停顿的大头。我见过一次真实的场景:GC 日志显示每次 Young GC 二十毫秒左右,应用侧测到的 P999 停顿却在一百五十毫秒上下,两个数字对不上,查了很久,根因落在一个做图像解码的 JNI 调用上。
那么安全点本身能观测吗?回到上一节那张表——hotspot provider 的二十六个探针里,一个安全点相关的都没有。VM 生命周期、线程、类加载、GC、编译、监视器,就这六组。
于是只剩三条路,逐条说清代价:
一,-Xlog:safepoint(JDK 9+)。 JVM 自己会把每次安全点的 TTSP 和操作时间打进日志,格式规整、信息完整。它是最准的一条路。代价是要在启动参数里加,也就是要重启——这个项目最初的约束在这里再一次被打破。
二,JFR。 jdk.SafepointBegin 这个事件带完整的时间信息,而且 JFR 可以通过 jcmd JFR.start 在运行中开启,无需重启。代价是它是 JVM 内部的观测通道,数据从 JFR 文件出来,跟 BPF 那套流式管道完全是两个体系,要另做一套采集。
三,uprobe 挂 libjvm.so 里的安全点函数。 SafepointSynchronize::begin() 和 ::end() 这两个符号,在未 strip 且导出了它们的构建上确实能挂。我试过,能跑。但它同时踩中了本节开头列的两条:符号是 mangled 的 C++ 名字,跨 JDK 版本不稳定;发行版包是否 strip 也不由我决定。它是一条能用但没法承诺的路——本质上跟第七节那张 Go 偏移表是同一类东西,只不过连 DWARF 都没有。
那次评审上我投了错的一票
这三个项目并排放,是我在一次可观测性平台的架构评审上花了两个月才换来的认识。
当时的方案要在一套混合语言的服务栈上做统一的运行时观测——C++ 主进程、Go 边车、Java 依赖,三种运行时。我在评审上投了赞成票,支持「统一走 uprobe,各语言只是符号名不同」。理由现在写出来还是很整齐:内核态那套 tracepoint 只看得见系统调用和网络,看不见应用内部;用户态函数不分语言,都是符号加地址;一套 attach 框架三种语言复用,工程量最小。
灰度当天出了第一个问题:Go 边车在挂上函数出口探针之后陆续崩溃,栈回溯全指着 runtime.copystack。第八节那个机制,我当时不知道。
第二个问题在一周后:Java 那边根本挂不上。我们的基础镜像用的是 Temurin,readelf -n libjvm.so 出来是空的。而换 JDK 供应商要走安全合规评估,排期以季度计。
第三个问题最伤:C那边一切正常——因为 C 恰好是唯一一个「符号稳定、栈不移动、地址可静态确定」的运行时。我拿它当作了三种语言的代表,而它其实是三种里最特殊的那一个。
最终方案退回到了一个完全不同的形状:连接级和系统调用级的观测统一走内核 tracepoint,语言无关且零维护;语言内部的观测每种语言单独做一层适配,Go 那层带 DWARF 偏移检测,Java 那层的第一步是检测 USDT 存在性、不存在就明确降级并告诉用户为什么。多花了两个月,而且这两个月里我一直以为问题出在实现质量上。
后来我把这次判断失误归结成一个词的误用:我把「用户态」当成了一个档位。它不是。「用户态」只说明代码跑在 ring 3,跟「这个状态的地址由谁定义」毫无关系。 而后者才是决定工程量的那个量。
三条命令
三个项目走完了。把它们的差异摊开来对,那个真正决定一切的量就浮出来了——它跟观测得多深无关,跟运行时是内核还是用户态也无关。
决定工程量的是:这个状态的地址由谁定义,以及这份定义有没有被写进一份机器能读的类型信息里。
按这个量,观测目标分三档:
第一档 · 内核定义
第二档 · 运行时定义
第三档 · VM 内部定义
例子
tcp_sock 字段、tracepoint
Go 的 runtime.g 布局
HotSpot 的 GC 与安全点
类型信息在哪
/sys/kernel/btf/vmlinux,内核自带
二进制里的 DWARF,要自己提
无。只有对方埋的 USDT
重定位由谁做
libbpf 装载时自动做
你自己写一层,按版本选表
做不了,地址由对方声明
升级后要做什么
什么都不用做
重新提一遍偏移,回归验证
看打包的人开没开那个编译开关
出错的形态
装载期报错
读到错的东西,静默
挂不上,显式
能观测到的上限
内核结构体的全部字段
runtime 暴露的全部内部状态
对方预先埋好的那二十六个点
倒数第二行是这张表里最该被记住的一行。第一档和第三档的失败都会当场告诉你,唯独第二档会一声不响地给你错数据——第七节那两周的错误面板就是它的产物。所以三档里维护量最高的不是第三档,是第二档:第三档做不了就是做不了,两分钟就能得出结论;第二档做得了,然后需要你在每一次目标程序升级时重新证明它还是对的。
三档的检测各是一条命令,加起来不到一分钟:
console
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c \
| grep -A20 'struct tcp_sock {'
struct inet_connection_sock inet_conn;
三条命令的答案凑齐,一个观测需求能不能做、做完之后每次升级要重做多少事,就已经定了。这个判断发生在写第一行 BPF 代码之前——我那次评审上投的错票,本质就是跳过了这一步,拿「都是用户态函数」这个表象代替了它。
三道题
把上面那张表压成三个可以在评审会上当场问出口的问题,比记住表格有用:
第一题:我要读的那个字段,它的偏移是谁算出来的? 三种答案对应三档——libbpf 在装载时按目标机器的 BTF 算的(第一档),我自己从 DWARF 提出来存进配置的(第二档),对方在编译期告诉我的(第三档)。答不上来,说明有人在某处硬编码了一个数字,去把它找出来。
第二题:这个探针读到错数据的时候,我怎么知道? 第一档不用问,装载期就拦下了。第二档必须自己造一个信号——目标字段总有某种可校验的性质(goid 单调递增且量级有限、时间戳落在合理区间、指针落在已知映射内),把它写成十行 BPF 检查加一个计数器。第三档也不用问,挂不上就是挂不上。
第三题:被观测方愿意配合吗? 愿意配合的时候,让它配合永远更划算。Java 的安全点数据用 JFR 拿远比用 uprobe 拿可靠;业务函数耗时用应用内埋点远比在每条 ret 上挂 uprobe 便宜。eBPF 真正无可替代的场景,是被观测方无法配合的时候——第三方二进制、遗留服务、以及那个「不能重启」的硬约束。把它当成万能钥匙,就会得到我那两个月。
这条线比可观测性这个词老得多
把尺度放大一格。
这条线并非 eBPF 的性质。任何一种从外部去看一个运行中系统的手段,都站在这条线的某一档上,只是大多数手段没把自己的档位写在脸上。
/proc 是第一档:字段名和格式是内核的 UAPI,二十年没怎么变过,解析它的代码写一次能用到退休。perf 的符号解析是第二档:它需要 .symtab、需要 DWARF、需要 JIT 运行时主动往 /tmp/perf-<pid>.map 里写映射——每一样都可能缺,缺了就是一堆十六进制地址。JFR 和各语言的 APM Agent 是第三档:它们能看到的东西极其丰富,但一样都不是你决定的,是 JVM 团队和 SDK 作者在设计时决定的。
同一条线,同一个提问方式:这个数据的形状由谁定义,谁承诺它不变。
eBPF 的特别之处,在于它把这条线画得格外清楚。别的手段可以含糊过去——解析个日志、抓个端口、读个文件,能跑就行;eBPF 连读一个指针都要先向内核证明这个指针的取值范围有界,于是「这个地址是怎么来的」这个问题被强行提到了台面上,躲不过去。
回到开头那条被拒绝的装载日志。当时它给出的理由是「R2 这个长度参数,内核算不出它的上界」。走完这一圈之后,同一句话读起来是另一个意思了:内核不肯替你保证任何它没有依据的东西——包括一个长度,也包括一个字段偏移。第一档之所以省事,是因为内核把这份依据(BTF)主动交了出来;第二档之所以危险,是因为那份依据存在于二进制里,而没有任何人负责在你升级时提醒你它变了;第三档之所以到此为止,是因为那份依据从一开始就掌握在别人手上。
那条 -EACCES 是内核在说:证明拿来。
而三个探针的全部差别,只是这份证明从哪里取。