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上是ip,ARM64上是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等。启用一个tracepoint:
echo 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";编译用clang:
clang -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-RE(Compile 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增大缓冲区,或者把日志重定向到文件。
这些经验都是我在实际项目中一点点攒下来的,有些是踩了坑才明白的。内核开发没有捷径,多动手、多观察、多总结,慢慢就能找到感觉。