内核观测工具开发实战:从kprobe、eBPF到命名策略的十版迭代经验

发布时间:2026/10/11 23:35:07
内核观测工具开发实战:从kprobe、eBPF到命名策略的十版迭代经验 1. 一个被改了十版的名字到底藏着什么门道做内核开发这些年我见过太多项目在命名上反复折腾。有个朋友做了一套内核模块的调试工具链前前后后改了十版名字最后一版被要求彻底换掉重来。他当时跟我吐槽说功能都跑通了结果卡在起名上。这事儿听起来像个段子但做过底层开发的人都懂——名字不只是名字它牵扯到项目定位、受众认知、技术边界甚至合规审查。“内核漫游之旅”这个标题本身就很有意思。它暗示的是一段深入操作系统内核的探索过程而“他改了十版被要求改名”则点出了一个非常真实的困境技术项目在迭代过程中命名往往滞后于功能演进最终导致名字和实际能力严重脱节。这篇文章我想聊的就是围绕内核层面的调试、观测、追踪类工具从项目定位、技术选型、核心实现到命名策略把这一整套东西拆开来讲清楚。适合谁看如果你正在做内核模块开发、系统级性能分析、或者任何需要深入操作系统底层去“漫游”的项目这篇内容应该能帮你少走一些弯路。如果你只是对内核感兴趣但还没动手也可以把它当作一份从零到一的实践参考。我会尽量把复杂的东西讲得直白该给命令给命令该说原理说原理不绕弯子。2. 内核观测类项目的整体设计与思路拆解2.1 为什么这类项目总是从“小工具”长成“大系统”几乎所有内核观测工具一开始都是奔着解决一个具体问题去的。比如你想知道某个系统调用被谁频繁触发或者某个内核函数的执行耗时分布是什么样的。最开始可能就是一个几百行的模块挂一个钩子打印一些信息到环形缓冲区用户态再读出来。这个阶段的名字通常很朴素比如叫“kprobe_demo”或者“trace_util”。但问题在于内核观测的需求天然是发散的。你今天想看调度延迟明天想看内存分配热点后天又想追踪文件系统的IO路径。每加一个功能代码量翻倍依赖关系变复杂原来的名字就越来越不准确。我见过一个项目从“kprobe_tool”一路改名到“kernel_observer”再到“sys_insight”最后被要求改成完全中性的名字。这个过程本质上反映的是项目定位的漂移它从一个点工具变成了一个平台。这里面的核心矛盾是内核观测工具的能力边界很难在早期界定清楚。因为内核本身是一个高度耦合的系统你观测一个点必然会牵扯到周边的调用链、数据结构、锁竞争。所以设计之初就要想好这个项目到底是做“单点深挖”还是“面状覆盖”。单点深挖的工具名字可以很具体比如“sched_latency_probe”面状覆盖的工具就需要一个更抽象的名字但抽象名字又容易失去辨识度。我的建议是在项目初期就明确一个“能力宣言”——用一句话说清楚这个工具能做什么、不能做什么。这句话不一定要成为项目名但它能帮你在后续迭代中判断哪些功能该加、哪些该砍。比如“本工具用于在Linux内核中追踪指定函数的调用频次与耗时分布不涉及用户态进程行为分析”。有了这条线命名就不会跑偏。2.2 技术选型的几个关键岔路口内核观测的技术路线大致分这么几条kprobe/kretprobe、tracepoint、perf_event、eBPF、以及直接改内核源码加printk。每一条路都有它的适用场景和代价选错了后面改起来非常痛苦。kprobe是最灵活的方式可以在几乎任何内核函数上动态插桩。但它的开销也最大因为每次触发都要走异常处理流程而且在高频路径上容易把系统拖垮。tracepoint是内核开发者预埋的静态钩子开销小、稳定但覆盖范围有限你只能观测那些已经埋了tracepoint的地方。perf_event更偏向硬件性能计数器适合做CPU周期、缓存命中率这类底层指标采集。eBPF是这几年的热门它结合了kprobe的灵活性和接近原生的执行效率但需要较新的内核版本支持而且编写和调试的门槛不低。直接改内核源码加printk是最粗暴的方式适合临时排查问题但绝对不能用于生产环境。我见过有人在生产机器上加了十几个printk结果日志把磁盘写满了系统直接挂掉。这个坑一定要避开。选型的时候我通常会问三个问题第一观测点是否已知且固定如果是优先用tracepoint。第二是否需要动态追踪任意函数如果是考虑kprobe或eBPF。第三对性能开销的容忍度是多少如果要求开销低于1%那基本只能选tracepoint或精心优化的eBPF程序。还有一个容易被忽略的点是内核版本兼容性。kprobe的API在不同内核版本之间有过变化eBPF的helper函数也在不断增删。如果你的项目需要支持多个内核版本那就要在代码里做大量的条件编译或者抽象一层兼容层。这个工作量在项目初期往往被低估等到要适配新内核时才发现到处都是坑。2.3 用户态与内核态的通信设计内核观测工具绕不开的一个问题是怎么把数据从内核态传到用户态。常见的方式有relayfs、debugfs、procfs、以及perf ring buffer。每种方式在吞吐量、延迟、易用性上都有差异。debugfs最简单创建一个文件内核模块往里面写用户态读。但它的缺点是单向的而且读写语义不太适合高频数据流。procfs类似但更偏向配置和状态查询。relayfs是专门为大量数据传输设计的支持mmap吞吐量高但API相对复杂。perf ring buffer是perf_event框架提供的适合和perf工具链集成但需要理解perf_event的编程模型。我个人的经验是如果数据量不大比如每秒几千条事件debugfs就够了开发快、调试方便。如果数据量很大比如每秒几十万条那就必须用relayfs或perf ring buffer否则内核缓冲区会丢事件。丢事件这个问题很隐蔽因为用户态读到的数据看起来是连续的但实际上中间可能已经丢了好几批。排查的时候可以在内核模块里加一个计数器记录写入失败或缓冲区满的次数用户态定期读取这个计数器来监控丢包情况。还有一个细节是数据格式的设计。内核态和用户态之间的数据结构要尽量简单避免指针和变长字段。因为内核态和用户态的地址空间是隔离的指针传过去也没法用。变长字段处理起来也麻烦容易出边界错误。我通常会用固定长度的结构体里面放一个类型字段和一个联合体这样解析起来最省事。3. 核心细节解析与实操要点3.1 kprobe的注册与回调函数编写kprobe的使用看起来简单但细节很多。先看一个最基本的注册流程#include linux/kprobes.h #include linux/module.h static struct kprobe kp { .symbol_name do_sys_open, }; static int handler_pre(struct kprobe *p, struct pt_regs *regs) { pr_info(do_sys_open called, ip %lx\n, regs-ip); return 0; } static int __init kprobe_init(void) { int ret; kp.pre_handler handler_pre; ret register_kprobe(kp); if (ret 0) { pr_err(register_kprobe failed, ret %d\n, ret); return ret; } pr_info(kprobe registered at %p\n, kp.addr); return 0; } static void __exit kprobe_exit(void) { unregister_kprobe(kp); } module_init(kprobe_init); module_exit(kprobe_exit); MODULE_LICENSE(GPL);这段代码看起来没什么问题但实际跑起来有几个坑。第一do_sys_open这个符号在某些内核版本里可能被内联了导致kprobe注册失败。这时候需要换一个不会被内联的函数或者用kprobe_addr手动指定地址。第二pre_handler里不能做任何可能睡眠的操作比如kmalloc(GFP_KERNEL)或者mutex_lock。因为kprobe的回调是在中断上下文里执行的睡眠会导致内核崩溃。第三regs-ip在不同架构上的字段名可能不一样x86上是ipARM64上是pc写跨平台代码的时候要注意。还有一个更隐蔽的问题kprobe的回调函数里如果访问了用户态指针必须用copy_from_user不能直接解引用。因为内核态和用户态的地址空间是隔离的直接访问会触发页错误。我见过有人在kprobe回调里直接读regs-di指向的字符串结果系统直接panic。这个错误在开发阶段很容易犯因为测试的时候可能刚好那个地址是有效的但到了生产环境就炸了。3.2 tracepoint的启用与数据采集tracepoint的使用比kprobe简单因为它是内核预埋的不需要动态注册。但前提是你想观测的点正好有tracepoint。查看系统支持的tracepoint列表ls /sys/kernel/debug/tracing/events/这个目录下按子系统分类比如sched/、irq/、syscalls/等。每个子目录下又有具体的事件比如sched/sched_switch、irq/irq_handler_entry等。启用一个tracepointecho 1 /sys/kernel/debug/tracing/events/sched/sched_switch/enable cat /sys/kernel/debug/tracing/trace_pipetrace_pipe会实时输出事件但它的格式是文本的解析起来比较麻烦。如果要在程序里用更好的方式是通过perf_event_open系统调用把tracepoint当作perf事件来采集。这样可以用mmap的环形缓冲区效率高很多。tracepoint的优点是稳定因为它是内核开发者维护的不会因为内核版本变化而消失。但缺点是覆盖范围有限很多你关心的函数并没有对应的tracepoint。这时候就只能回到kprobe或者eBPF。还有一个细节是tracepoint的使能状态是全局的一旦启用所有进程的相关事件都会被记录。如果只想观测特定进程需要在用户态做过滤或者用perf的--pid选项。但过滤是在用户态做的内核态还是会采集所有事件所以开销并没有减少。如果对性能敏感可以考虑用eBPF程序在内核态做过滤只把符合条件的事件送到用户态。3.3 eBPF程序的编写与加载eBPF是这几年的热点但它不是银弹。写eBPF程序需要熟悉BPF指令集、map类型、helper函数调试起来也比内核模块麻烦。不过它的优势也很明显不需要编译内核模块加载速度快而且有 verifier 做安全检查不容易把内核搞崩。一个最简单的eBPF程序长这样#include linux/bpf.h #include bpf/bpf_helpers.h SEC(kprobe/do_sys_open) int bpf_prog(struct pt_regs *ctx) { char fmt[] do_sys_open called\n; bpf_trace_printk(fmt, sizeof(fmt)); return 0; } char _license[] SEC(license) GPL;编译用clangclang -O2 -target bpf -c prog.c -o prog.o加载用bpftool或者自己写加载器。bpf_trace_printk的输出会出现在/sys/kernel/debug/tracing/trace_pipe里但它的性能很差只适合调试。生产环境应该用perf_event_output或者ring buffer。eBPF的坑主要集中在verifier上。verifier会检查你的程序是否有越界访问、是否有可能死循环、是否使用了不允许的helper函数。有时候一段看起来没问题的代码会被verifier拒绝报错信息还很晦涩。我的经验是尽量用简单的控制流避免复杂的指针运算循环要有明确的边界。如果verifier报错可以先用bpftool prog load的-d选项打印详细日志慢慢排查。还有一个版本兼容性问题。eBPF的helper函数和map类型在不同内核版本之间有差异比如bpf_probe_read在5.5之后被bpf_probe_read_kernel和bpf_probe_read_user取代了。如果你的程序要支持多个内核版本要么用宏做条件编译要么用libbpf的CO-RECompile Once, Run Everywhere特性。CO-RE需要内核开启BTF支持编译的时候用-g生成调试信息加载的时候libbpf会自动做重定位。这个方案目前来看是最优雅的但对内核版本有要求一般需要5.2以上。4. 实操过程与核心环节实现4.1 从零搭建一个内核函数追踪模块假设我们要做一个工具追踪指定内核函数的调用次数和平均耗时。这个需求很典型很多性能分析场景都会用到。我把它拆成几个步骤来实现。第一步确定要追踪的函数。这个函数不能是内联的也不能在中断上下文里被频繁调用否则开销太大。假设我们选vfs_read它是文件读取的入口调用频次适中适合做示例。第二步设计数据结构。我们需要在内核态维护一个计数器和一个总耗时用户态定期读取。可以用一个简单的结构体struct trace_stats { u64 call_count; u64 total_ns; u64 min_ns; u64 max_ns; };这个结构体放在debugfs文件里用户态读出来就是二进制数据解析很方便。第三步实现kprobe的pre_handler和post_handler。pre_handler记录进入时间post_handler计算耗时并更新统计static struct kprobe kp; static struct trace_stats stats; static DEFINE_SPINLOCK(stats_lock); static int handler_pre(struct kprobe *p, struct pt_regs *regs) { regs-ax ktime_get_ns(); // 临时保存进入时间 return 0; } static void handler_post(struct kprobe *p, struct pt_regs *regs, unsigned long flags) { u64 enter_ns regs-ax; u64 delta ktime_get_ns() - enter_ns; unsigned long irq_flags; spin_lock_irqsave(stats_lock, irq_flags); stats.call_count; stats.total_ns delta; if (delta stats.min_ns || stats.min_ns 0) stats.min_ns delta; if (delta stats.max_ns) stats.max_ns delta; spin_unlock_irqrestore(stats_lock, irq_flags); }这里用regs-ax来传递进入时间是因为kprobe的pre_handler和post_handler之间没有直接的参数传递机制。用寄存器临时保存是一个常见的技巧但要注意不要覆盖了原本有用的寄存器值。在x86上ax通常用于返回值在函数入口处一般不会被使用所以相对安全。但如果是其他架构需要查一下ABI文档选一个安全的寄存器。第四步创建debugfs文件。在模块初始化的时候static struct dentry *debugfs_dir; static struct dentry *debugfs_file; static int stats_show(struct seq_file *m, void *v) { unsigned long irq_flags; struct trace_stats snapshot; spin_lock_irqsave(stats_lock, irq_flags); snapshot stats; spin_unlock_irqrestore(stats_lock, irq_flags); seq_printf(m, call_count: %llu\n, snapshot.call_count); seq_printf(m, total_ns: %llu\n, snapshot.total_ns); seq_printf(m, avg_ns: %llu\n, snapshot.call_count ? snapshot.total_ns / snapshot.call_count : 0); seq_printf(m, min_ns: %llu\n, snapshot.min_ns); seq_printf(m, max_ns: %llu\n, snapshot.max_ns); return 0; } static int __init trace_init(void) { int ret; kp.symbol_name vfs_read; kp.pre_handler handler_pre; kp.post_handler handler_post; ret register_kprobe(kp); if (ret 0) { pr_err(register_kprobe failed: %d\n, ret); return ret; } debugfs_dir debugfs_create_dir(vfs_read_tracer, NULL); if (!debugfs_dir) { unregister_kprobe(kp); return -ENOMEM; } debugfs_file debugfs_create_file(stats, 0444, debugfs_dir, NULL, stats_fops); if (!debugfs_file) { debugfs_remove_recursive(debugfs_dir); unregister_kprobe(kp); return -ENOMEM; } return 0; }第五步编译和加载。Makefile大概长这样obj-m vfs_read_tracer.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean编译之后insmod vfs_read_tracer.ko然后cat /sys/kernel/debug/vfs_read_tracer/stats就能看到统计结果。4.2 性能开销的测量与优化上面这个模块跑起来之后第一件事是测开销。方法很简单找一个基准测试比如用dd读一个大文件分别在加载模块和不加载模块的情况下跑对比耗时。我实测下来在vfs_read上加kprobe开销大概在3%到5%之间。这个数字对于生产环境来说偏高需要优化。优化的方向有几个。第一减少pre_handler和post_handler里的操作。比如ktime_get_ns本身是有开销的如果不需要纳秒级精度可以用ktime_get_mono_fast_ns它读的是硬件时钟更快。第二把统计逻辑从post_handler里移出来改成在用户态做。内核态只负责把原始事件写入环形缓冲区用户态读取后再计算统计值。这样内核态的开销可以降到最低。第三如果只是想知道调用次数不需要耗时那可以只用pre_handler省掉post_handler的开销。还有一个容易被忽略的开销来源是pr_info。很多人在开发阶段习惯用pr_info打印调试信息但pr_info会写内核日志缓冲区在高频路径上开销很大。生产版本一定要把调试打印去掉或者改成条件编译。4.3 数据从内核态到用户态的完整链路如果数据量比较大debugfs就不够用了。这时候需要上relayfs或者perf ring buffer。我以relayfs为例讲一下完整的链路。relayfs的核心是创建一个channel每个CPU一个缓冲区。内核态写数据用relay_write用户态通过mmap读取。创建channel#include linux/relay.h static struct rchan *chan; static struct dentry *create_buf_file(const char *filename, struct dentry *parent, umode_t mode, struct rchan_buf *buf, int *is_global) { return debugfs_create_file(filename, mode, parent, buf, relay_file_operations); } static int remove_buf_file(struct dentry *dentry) { debugfs_remove(dentry); return 0; } static struct rchan_callbacks relay_callbacks { .create_buf_file create_buf_file, .remove_buf_file remove_buf_file, }; chan relay_open(trace_data, NULL, 4096, 8, relay_callbacks, NULL);写入数据struct event { u64 timestamp; u64 duration; u32 pid; char comm[16]; }; struct event ev { .timestamp ktime_get_ns(), .duration delta, .pid current-pid, }; memcpy(ev.comm, current-comm, 16); relay_write(chan, ev, sizeof(ev));用户态读取int fd open(/sys/kernel/debug/trace_data0, O_RDONLY); void *buf mmap(NULL, 4096 * 8, PROT_READ, MAP_SHARED, fd, 0); // 解析buf里的event结构体relayfs的坑在于缓冲区满的时候会覆盖旧数据而且没有通知机制。用户态需要自己轮询或者通过其他方式同步。另外每个CPU的缓冲区是独立的用户态读取的时候要注意合并多个CPU的数据。5. 常见问题与排查技巧实录5.1 kprobe注册失败的各种原因kprobe注册失败是最常见的问题报错信息通常很模糊。我整理了一个排查表现象可能原因排查方法register_kprobe返回-ENOENT符号不存在或被内联用/proc/kallsyms确认符号是否存在register_kprobe返回-EINVAL地址无效或架构不支持检查CONFIG_KPROBES是否开启注册成功但回调不触发函数没有被调用或优化掉了用ftrace确认函数是否被调用回调触发但系统崩溃回调里睡眠或访问非法地址检查是否有kmalloc(GFP_KERNEL)或直接解引用用户指针还有一个特殊情况是函数被static修饰且被内联了。这种情况下/proc/kallsyms里可能找不到符号或者找到的地址是错的。解决办法是换一个不会被内联的函数或者用kprobe_addr手动指定地址。手动指定地址需要先反汇编内核镜像找到函数的实际入口比较麻烦一般不推荐。5.2 数据丢失与缓冲区溢出数据丢失是高频场景下的典型问题。表现是用户态读到的数据不连续或者统计值和预期不符。排查思路是先在内核态加一个丢包计数器每次写入失败就加一。用户态定期读取这个计数器如果发现增长说明缓冲区不够大。缓冲区大小的计算需要考虑几个因素事件产生速率、用户态读取频率、单条事件的大小。假设事件产生速率是每秒10万条单条事件64字节用户态每秒读取一次那缓冲区至少需要6.4MB。但实际中还要留余量因为用户态读取可能被调度延迟。我通常会把缓冲区设成理论值的2到3倍。另一个丢数据的原因是CPU缓冲区不均衡。如果事件集中在某个CPU上那个CPU的缓冲区会先满而其他CPU的缓冲区还是空的。解决办法是增大每个CPU的缓冲区或者在用户态做负载均衡。但负载均衡在内核态做不了因为事件产生的位置是固定的。5.3 内核版本兼容性问题的处理内核版本兼容性是长期维护的最大痛点。kprobe的API在2.6到5.x之间有过几次变化eBPF的变化更频繁。处理兼容性问题的常见做法是用宏做条件编译#if LINUX_VERSION_CODE KERNEL_VERSION(5, 5, 0) bpf_probe_read_kernel(dst, size, src); #else bpf_probe_read(dst, size, src); #endif但条件编译多了之后代码会变得很难看。更好的方式是用一个兼容层把不同版本的API封装成统一的接口。比如定义一个my_probe_read在里面根据版本选择正确的helper函数。这样上层代码不需要关心版本差异。还有一个技巧是用LINUX_VERSION_CODE和KERNEL_VERSION宏来判断版本但要注意有些发行版会 backport 新特性到旧内核上导致版本号不能完全反映实际能力。这种情况下只能靠运行时探测比如尝试加载一个使用了新helper的eBPF程序如果失败就回退到旧方案。5.4 命名策略与项目定位的匹配回到标题里的那个问题为什么改了十版名字还被要求改名我的理解是名字的演变反映了项目定位的漂移而最终被要求改名往往是因为名字触碰了某些边界或者和实际功能严重不符。内核观测类项目的命名我总结了几条经验。第一名字要能体现技术路线。比如带“probe”的通常是kprobe类工具带“trace”的通常是tracepoint或ftrace类工具带“bpf”的通常是eBPF类工具。这样用户一看名字就知道底层用的是什么技术预期管理比较到位。第二名字要能体现观测对象。比如“sched”开头的是调度相关“mem”开头的是内存相关“fs”开头的是文件系统相关。第三名字要避免过于宽泛的词汇比如“insight”、“observer”、“monitor”这类词听起来高大上但实际上没有传达任何具体信息。如果项目功能已经超出了最初的名字范围我的建议是不要硬改名字而是把项目拆成多个子工具每个子工具用一个具体的名字。这样既保持了名字的准确性又避免了用户认知混乱。比如一个最初叫“kprobe_demo”的项目后来长成了包含调度、内存、IO三个模块的平台那可以拆成“sched_probe”、“mem_probe”、“io_probe”三个独立工具共享底层库。这样每个名字都准确维护起来也清晰。6. 一些实操中攒下来的经验内核观测工具的开发说到底是在“观测能力”和“系统稳定性”之间找平衡。观测得越细开销越大风险越高。我自己的原则是能在用户态做的不要放到内核态能用tracepoint的不要用kprobe能用eBPF的不要写内核模块。这个优先级顺序能帮你避开大部分坑。还有一点是关于测试环境的。内核模块的bug往往是致命的一个空指针解引用就能让整台机器挂掉。所以测试一定要在虚拟机或者独立的测试机上做不要在生产环境上直接加载。我见过有人在生产数据库服务器上直接insmod一个没测试过的模块结果系统panic数据丢失后果很严重。这个教训值得每个人记住。最后说一个调试技巧。内核模块的printk输出有时候会因为日志级别不够而看不到。可以在加载模块之前先调整控制台日志级别echo 8 /proc/sys/kernel/printk这样所有级别的printk都会输出到控制台。调试完之后记得改回去否则日志会刷屏。另外dmesg的输出有环形缓冲区大小限制如果日志太多旧的会被覆盖。可以用dmesg -s 1048576增大缓冲区或者把日志重定向到文件。这些经验都是我在实际项目中一点点攒下来的有些是踩了坑才明白的。内核开发没有捷径多动手、多观察、多总结慢慢就能找到感觉。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询