《BPF之巅》读书笔记(一)Linux Tracing System


书中的工具实际上都是基于 Linux Tracing System,我们也听到了很多概念,例如 kprobe,uprobe,tracepoint 等,那它们到底是什么?又是怎么工作的呢?
首先让我们来上张图:

这下大家就豁然开朗了吧,Linux 跟踪系统分为数据源(【跟踪数据】的来源)、为这些源收集数据的机制(如“ftrace”)和跟踪前端工具(实际进行交互以收集/分析数据)。
Julia Evans 的博客《Linux tracing systems & how they fit togethe》【1】,也很好总结了相关内容,我们可以先学习一下作者总结的几张图:





今天我们先学习一下数据源这部分。
数据源:kprobes、tracepoints、uprobes、dtrace ...
“探针”(kprobes/uprobes)和“跟踪点”(USDT/内核跟踪点/lttng-ust)略有不同。首先我们来了解一下 proble 和 tracepoint 的定义:
A probe is when the kernel dynamically modifies your assembly program at runtime (like, it changes the instructions) in order to enable tracing. Kprobes and uprobes are examples of this pattern.
A tracepoint is something you compile into your program. USDT (“dtrace probes”), lttng-ust, and kernel tracepoints are all examples of this pattern.
tracepoint
大家写过代码的童鞋肯定做过调试,我们可以在代码中设置断点,然后通过单步调试到达断点处,此时我们就可以看到变量以及栈的信息。tracepoint 有异曲同工之处,可以让我们在函数的指定地方获得相关调用栈,参数,返回值等信息。
我们先来看看一下 tracepoint 的源码,就能比较好理解了。tracepoint 其实就是在Linux内核的一些关键函数中埋下的 hook 点,这样在 tracing 的时候,我们就可以在这些固定的点上挂载调试的函数,然后查看内核的信息。
如果我们可以使用 perf list 去列举当前系统支持哪些 tracepoint。
perf list | grep -i tracepoint | grep syscall raw_syscalls:sys_enter [Tracepoint event] raw_syscalls:sys_exit [Tracepoint event] syscalls:sys_enter_accept [Tracepoint event] syscalls:sys_enter_accept4 [Tracepoint event] syscalls:sys_enter_access [Tracepoint event] syscalls:sys_enter_acct [Tracepoint event] syscalls:sys_enter_add_key [Tracepoint event] syscalls:sys_enter_adjtimex [Tracepoint event] syscalls:sys_enter_alarm [Tracepoint event] syscalls:sys_enter_bind [Tracepoint event] syscalls:sys_enter_bpf [Tracepoint event] syscalls:sys_enter_brk [Tracepoint event] syscalls:sys_enter_capget [Tracepoint event] syscalls:sys_enter_capset [Tracepoint event] syscalls:sys_enter_chdir [Tracepoint event] syscalls:sys_enter_chmod [Tracepoint event] syscalls:sys_enter_chown [Tracepoint event] syscalls:sys_enter_chroot [Tracepoint event] syscalls:sys_enter_clock_adjtime [Tracepoint event] syscalls:sys_enter_clock_getres [Tracepoint event] syscalls:sys_enter_clock_gettime [Tracepoint event] syscalls:sys_enter_clock_nanosleep [Tracepoint event] syscalls:sys_enter_clock_settime [Tracepoint event] syscalls:sys_enter_close [Tracepoint event]  ...
至于 ftrace,我们在 tracefs 文件系统中,也会看到一样的 tracepoints:
find /sys/kernel/debug/tracing/events -type d | sort/sys/kernel/debug/tracing/events/sys/kernel/debug/tracing/events/block/sys/kernel/debug/tracing/events/block/block_bio_backmerge/sys/kernel/debug/tracing/events/block/block_bio_bounce/sys/kernel/debug/tracing/events/block/block_bio_complete/sys/kernel/debug/tracing/events/block/block_bio_frontmerge/sys/kernel/debug/tracing/events/block/block_bio_queue/sys/kernel/debug/tracing/events/block/block_bio_remap/sys/kernel/debug/tracing/events/block/block_dirty_buffer/sys/kernel/debug/tracing/events/block/block_getrq/sys/kernel/debug/tracing/events/block/block_plug/sys/kernel/debug/tracing/events/block/block_rq_abort/sys/kernel/debug/tracing/events/block/block_rq_complete/sys/kernel/debug/tracing/events/block/block_rq_insert/sys/kernel/debug/tracing/events/block/block_rq_issue/sys/kernel/debug/tracing/events/block/block_rq_remap/sys/kernel/debug/tracing/events/block/block_rq_requeue...
我们以“do_sys_open”这个 tracepoint 做例子。在内核函数 do_sys_open() 中,有一个 trace_do_sys_open() 调用,其实它这就是一个 tracepoint:
long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode){ struct open_flags op; int fd = build_open_flags(flags, mode, &op); struct filename *tmp; if (fd) return fd; tmp = getname(filename); if (IS_ERR(tmp)) return PTR_ERR(tmp); fd = get_unused_fd_flags(flags); if (fd >= 0) { struct file *f = do_filp_open(dfd, tmp, &op); if (IS_ERR(f)) { put_unused_fd(fd); fd = PTR_ERR(f); } else { fsnotify_open(f); fd_install(fd, f); // Tracepoint trace_do_sys_open(tmp->name, flags, mode); } } putname(tmp); return fd;}
我们可以看到 trace_do_sys_open 就是一个在内核函数中的 hook 的一个点。那这个tracepoint是怎么实现的呢?
在Linux中,每一个tracepoint的相关数据结构和函数,主要是通过 "DEFINE_TRACE" 和 "DECLARE_TRACE" 这两个宏来定义的。
完整的“DEFINE_TRACE”和“DECLARE_TRACE”宏里,给每个 tracepoint 都定义了一组函数。
#define __DECLARE_TRACE(name, proto, args, cond, data_proto, data_args) \ extern struct tracepoint __tracepoint_##name; \ static inline void trace_##name(proto) \ { \ if (static_key_false(&__tracepoint_##name.key)) \ __DO_TRACE(&__tracepoint_##name, \ TP_PROTO(data_proto), \ TP_ARGS(data_args), \ TP_CONDITION(cond), 0); \ if (IS_ENABLED(CONFIG_LOCKDEP) && (cond)) { \ rcu_read_lock_sched_notrace(); \ rcu_dereference_sched(__tracepoint_##name.funcs);\ rcu_read_unlock_sched_notrace(); \ } \ }   ...
首先,我们来看“trace_##name”这个函数(提示一下,这里的“##”是C语言的预编译宏,表示把两个字符串连接起来)。
对于每个命名为“name”的tracepoint,这个宏都会帮助它定一个函数。这个函数的格式是这样的,以“trace_”开头,再加上tracepoint的名字。
以“do_sys_open”这个tracepoint 为例,它生成的函数名就是trace_do_sys_open。而这个函数会被内核函数do_sys_open()调用,从而实现了一个内核的tracepoint。
在上述 tracepoint 函数里,主要的功能是这样实现的,通过__DO_TRACE来调用所有注册在这个 tracepoint 上的 probe 函数。
#define __DO_TRACE(tp, proto, args, cond, rcuidle) \… it_func_ptr = rcu_dereference_raw((tp)->funcs); \ \ if (it_func_ptr) { \ do { \ it_func = (it_func_ptr)->func; \ __data = (it_func_ptr)->data; \ ((void(*)(proto))(it_func))(args); \ } while ((++it_func_ptr)->func); \ }…
而 probe 函数的注册,它可以通过宏定义的 “registertrace##name” 函数完成。
register_trace_##name(void (*probe)(data_proto), void *data) \ { \ return tracepoint_probe_register(&__tracepoint_##name, \ (void *)probe, data); \ }
Tracepoint 简单来说就是在内核代码中需要被 trace 的地方显式地加上hook 点,然后再把自己的 probe 函数注册上去,那么在代码执行的时候,就可以执行 probe 函数。
Kprobe
Kprobes 是一个动态 tracing 机制,能够动态注入到内核的任意函数中的任意地方,采集调试信息和性能信息,并且不影响内核的运行。Kprobes 有两种类型:kprobes、kretprobes。kprobes 用于在内核函数的任意位置注入 probe handler,kretprobes 用于在函数返回位置注入 probe handler。出于安全性考虑,在内核代码中,并非所有的函数都能“插桩”,kprobe 维护了一个黑名单记录了不允许插桩的的函数,比如 kprobe 自身,防止递归调用。
让我们来看一个例子:kprobe_example.c代码。
这个例子实现了一个 kernel module,可以在内核中任意一个函数名/符号对应的代码地址上注册三个probe函数,分别是“pre_handler”、 “post_handler”和“fault_handler”。
// SPDX-License-Identifier: GPL-2.0-only/* * NOTE: This example is works on x86 and powerpc. * Here's a sample kernel module showing the use of kprobes to dump a * stack trace and selected registers when _do_fork() is called. * * For more information on theory of operation of kprobes, see * Documentation/kprobes.txt * * You will see the trace data in /var/log/messages and on the console * whenever _do_fork() is invoked to create a new process. */#include <linux/kernel.h>#include <linux/module.h>#include <linux/kprobes.h>#define MAX_SYMBOL_LEN 64static char symbol[MAX_SYMBOL_LEN] = "_do_fork";module_param_string(symbol, symbol, sizeof(symbol), 0644);/* For each probe you need to allocate a kprobe structure */static struct kprobe kp = { .symbol_name = symbol,};/* kprobe pre_handler: called just before the probed instruction is executed */static int handler_pre(struct kprobe *p, struct pt_regs *regs){#ifdef CONFIG_X86 pr_info("<%s> pre_handler: p->addr = 0x%p, ip = %lx, flags = 0x%lx", p->symbol_name, p->addr, regs->ip, regs->flags);#endif#ifdef CONFIG_PPC pr_info("<%s> pre_handler: p->addr = 0x%p, nip = 0x%lx, msr = 0x%lx", p->symbol_name, p->addr, regs->nip, regs->msr);#endif#ifdef CONFIG_MIPS pr_info("<%s> pre_handler: p->addr = 0x%p, epc = 0x%lx, status = 0x%lx", p->symbol_name, p->addr, regs->cp0_epc, regs->cp0_status);#endif#ifdef CONFIG_ARM64 pr_info("<%s> pre_handler: p->addr = 0x%p, pc = 0x%lx," " pstate = 0x%lx", p->symbol_name, p->addr, (long)regs->pc, (long)regs->pstate);#endif#ifdef CONFIG_S390 pr_info("<%s> pre_handler: p->addr, 0x%p, ip = 0x%lx, flags = 0x%lx", p->symbol_name, p->addr, regs->psw.addr, regs->flags);#endif /* A dump_stack() here will give a stack backtrace */ return 0;}/* kprobe post_handler: called after the probed instruction is executed */static void handler_post(struct kprobe *p, struct pt_regs *regs, unsigned long flags){#ifdef CONFIG_X86 pr_info("<%s> post_handler: p->addr = 0x%p, flags = 0x%lx", p->symbol_name, p->addr, regs->flags);#endif#ifdef CONFIG_PPC pr_info("<%s> post_handler: p->addr = 0x%p, msr = 0x%lx", p->symbol_name, p->addr, regs->msr);#endif#ifdef CONFIG_MIPS pr_info("<%s> post_handler: p->addr = 0x%p, status = 0x%lx", p->symbol_name, p->addr, regs->cp0_status);#endif#ifdef CONFIG_ARM64 pr_info("<%s> post_handler: p->addr = 0x%p, pstate = 0x%lx", p->symbol_name, p->addr, (long)regs->pstate);#endif#ifdef CONFIG_S390 pr_info("<%s> pre_handler: p->addr, 0x%p, flags = 0x%lx", p->symbol_name, p->addr, regs->flags);#endif}/* * fault_handler: this is called if an exception is generated for any * instruction within the pre- or post-handler, or when Kprobes * single-steps the probed instruction. */static int handler_fault(struct kprobe *p, struct pt_regs *regs, int trapnr){ pr_info("fault_handler: p->addr = 0x%p, trap #%dn", p->addr, trapnr); /* Return 0 because we don't handle the fault. */ return 0;}static int __init kprobe_init(void){ int ret; kp.pre_handler = handler_pre; kp.post_handler = handler_post; kp.fault_handler = handler_fault; ret = register_kprobe(&kp); if (ret < 0) { pr_err("register_kprobe failed, returned %d", ret); return ret; } pr_info("Planted kprobe at %p", kp.addr); return 0;}static void __exit kprobe_exit(void){ unregister_kprobe(&kp); pr_info("kprobe at %p unregistered", kp.addr);}module_init(kprobe_init)module_exit(kprobe_exit)MODULE_LICENSE("GPL");
当这个内核函数被执行的时候,已经注册的 probe 函数也会被执行 (handler_fault只有在发生异常的时候才会被调用到)。
比如加载的这个 kernel module 不带参数,那么缺省的情况就是这样的:在“_do_fork”内核函数的入口点注册了这三个 probe 函数。
当 _do_fork() 函数被调用到的时候,也就是创建新的进程时,我们通过 dmesg 就可以看到 probe 函数的输出了。
kprobe 的基本工作原理:当 kprobe 函数注册的时候,其实就是把目标地址上内核代码的指令码,替换成了“cc”,也就是 int3 指令。这样一来,当内核代码执行到这条指令的时候,就会触发一个异常而进入到 Linux int3 异常处理函数 do_int3() 里。
在 do_int3() 这个函数里,如果发现有对应的 kprobe 注册了 probe,就会依次执行注册的 pre_handler(),原来的指令,最后是 post_handler()。

kretprobe 探针很有意思,Kprobe 会在函数的入口处注册一个 kprobe,当函数执行时,这个 krpobe 会把函数的返回地址暂存下来,并把它替换为 trampoline 地址。
Kprobe 也会在 trampoline 注册一个 kprobe,函数执行返回时,cpu 控制权转移到 trampoline,此时又会触发 trampoline 上的 kprobe 探针,继续陷入中断,并执行 probe handler。
为什么有了 kprobe 还需要 kretprobe?
Kprobe 可以在函数的任意位置插入 probe,理论上他也能实现 kretprobe 的功能,但是实际上会面临几个挑战。
比如当我们在函数的最后一行代码上注入探针,试图使用 kprobe 实现 kretprobe 的效果,但是实际上这种方式并不好,函数可能会存在多个返回情况,比如不满足 if 条件,发生异常等情况,此时代码完全有可能不会执行最后一行代码,而是在某个地方就返回了,也就意味着不会触发探针执行。
kretprobe 的优势就在于它可以稳定的在函数返回时触发 probe handler 执行,无论函数是基于什么情况下返回。
另外一方面 kprobe 虽然可以在函数的任意位置插入探针,但是实际情况下都是在函数入口处插入探针,因为函数入口是有一条标准的指令序列 prologue 可以进行断点替换,而函数内部的其他位置,可能会存在跳转指令、循环指令等情况,指令序列不太规则,不方便做断点替换。
uprobe
uprobes 也分为 uprobes 和 uretprobes,和 Kprobes 从原理上来说基本上是类似的,通过断点指令替换原指令实现注入 probe handler 的能力,并且他没有 Kprobes 的黑名单限制。Uprobes 需要我们提供「探测点的偏移量」,探测点的偏移量是指从程序的起始虚拟内存地址到探测点指令的偏移量。
参考:
【1】Linux tracing systems & how they fit together:https://jvns.ca/blog/2017/07/05/linux-tracing-systems/
【2】极客时间《容器实战高手课》

【3】万字长文解读 Linux 内核追踪机制:https://www.infoq.cn/article/jh1lruqqti3gjvs5dh6b
到顶部