在 eBPF 中使用 kprobe 捕获 unlink 系统调用.
和前面说使用跟踪点 tracepoint 的技术不同,这次将使用 kprobe 来捕获 unlink 系统调用.
kprobe 是一种用于在内核中插入探测点的技术,它允许在内核中的任何函数中插入探测点,并在函数调用前后执行自定义的回调函数.
开发人员在内核或者模块的调试过程中,往往会需要要知道其中的一些函数有无被调用、何时被调用、执行是否正确以及函数的入参和返回值是什么等等. 比较简单的做法是在内核代码对应的函数中添加日志打印信息,但这种方式往往需要重新编译内核或模块,重新启动设备之类的,操作较为复杂甚至可能会破坏原有的代码执行过程.
而利用 kprobes 技术,用户可以定义自己的回调函数,然后在内核或者模块中几乎所有的函数中(有些函数是不可探测的,例如 kprobes 自身的相关实现函数,后文会有详细说明)动态地插入探测点,当内核执行流程执行到指定的探测函数时,会调用该回调函数,用户即可收集所需的信息了,同时内核最后还会回到原本的正常执行流程. 如果用户已经收集足够的信息,不再需要继续探测,则同样可以动态地移除探测点. 因此 kprobes 技术具有对内核执行流程影响小和操作方便的优点.
kprobes 技术包括的 3 种探测手段分别时 kprobe、jprobe 和 kretprobe.
-
kprobe是最基本的探测方式,是实现后两种的基础,它可以在任意的位置放置探测点(就连函数内部的某条指令处也可以),它提供了探测点的调用前、调用后和内存访问出错3种回调方式,分别是pre_handler、post_handler和fault_handler,其中pre_handler函数将在被探测指令被执行前回调,post_handler会在被探测指令执行完毕后回调(注意不是被探测函数),fault_handler会在内存访问出错时被调用; -
jprobe基于kprobe实现,它用于获取被探测函数的入参值; -
kretprobe从名字中就可以看出其用途了,它同样基于kprobe实现,用于获取被探测函数的返回值.
下面是一个简单的例子,展示如何使用 kprobe 技术捕获 unlink 系统调用.
unlink系统调用用于在文件系统中删除一个文件的目录项,即移除文件的链接. 在Linux或Unix系统中,文件通过目录项来引用它们的实际存储数据,unlink不直接删除文件内容,而是删除指向文件的链接.
下面是相关的 eBPF 内核态代码 kprobe_unlink.c 的内容:
// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
/* 版权所有 (c) 2021 Sartura */
#define BPF_NO_GLOBAL_DATA
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_endian.h>
// 声明许可证类型为 "Dual BSD/GPL"
char LICENSE[] SEC("license") = "Dual BSD/GPL";
// 定义一个 eBPF 程序,附加到 do_unlinkat 函数的入口点
SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name)
{
pid_t pid;
const char *filename;
// 获取当前进程的 PID
pid = bpf_get_current_pid_tgid() >> 32;
// 读取文件名
filename = BPF_CORE_READ(name, name);
// 输出调试信息(进入 kprobe)
bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
return 0;
}
// 定义一个 eBPF 程序,附加到 do_unlinkat 函数的退出点
SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret)
{
pid_t pid;
// 获取当前进程的 PID
pid = bpf_get_current_pid_tgid() >> 32;
// 输出调试信息(退出 kprobe)
bpf_printk("KPROBE EXIT: pid = %d, ret = %ld\n", pid, ret);
return 0;
}在上面的代码中,我们定义了两个 kprobe 探测点,分别是 do_unlinkat 函数的入口和出口. 在 do_unlinkat 函数的入口,我们获取了当前进程的 pid 和文件名,然后打印出来;在 do_unlinkat 函数的出口,我们获取了当前进程的 pid 和返回值,然后打印出来.
首先,我们导入必要的头文件,如 vmlinux.h,bpf_helpers.h,bpf_tracing.h 和 bpf_core_read.h. 接着,我们定义许可证,以允许程序在内核中运行:
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_endian.h>
char LICENSE[] SEC("license") = "Dual BSD/GPL";
vmlinux.h文件是Linux内核的头文件,它包含了从内核映像vmlinux中提取的结构定义和常量. 它通常在编写与内核交互的 >eBPF程序或其他内核态程序时使用. 如果机器上没有这个头文件,需要想办法生成这个文件. 这里能显示出eunomia-bpf,它能自动生成这个文件编译eBPF程序,所以使用它编译的时候没有阻塞. 但是如果使用clang或者culium/ebpf编译的时候,可能就需要自己生成这个文件.
使用 bpftool 从当前运行的内核生成 vmlinux.h 文件:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h这将在当前目录下生成一个 vmlinux.h 文件. 将生成的 vmlinux.h 文件放在 eBPF 程序所在的目录中. 然后可以使用这个文件来编译 eBPF 程序.
注意这里包含的头文件,注意别漏了,否则编译的时候会报错,甚至运行的时候出错. 如果一开始的时候没有包含
bpf_endian.h,就会导致运行的时候出错.
接下来,定义一个名为 BPF_KPROBE(do_unlinkat) 的 kprobe,当进入 do_unlinkat 函数时,它会被触发. 该函数接受两个参数:dfd(文件描述符)和 name(文件名结构体指针). 在这个 kprobe 中,我们获取当前进程的 PID(进程标识符),然后读取文件名. 最后,我们使用 bpf_printk 函数在内核日志中打印 PID 和文件名.
接下来,再定义一个名为 BPF_KRETPROBE(do_unlinkat_exit)的 kretprobe,当从do_unlinkat 函数退出时,它会被触发. 这个 kretprobe 的目的是捕获函数的返回值(ret). 我们再次获取当前进程的 PID,并使用 bpf_printk 函数在内核日志中打印 PID 和返回值.
通过这个例子你就学会了如何定义 kprobe 和 kretprobe,在内核中捕获某个函数的入口和出口.
注意这里我们使用 CO-RE 技术,通过 BPF_CORE_READ 宏来读取内核数据结构,使用 BPF_KPROBE 和 BPF_KRETPROBE 宏来定义 kprobe 和 kretprobe.
要编译这个程序,请使用 ecc 工具:
./ecc ./eBPF/kprobe/kprobe_unlink.c
INFO [ecc_rs::bpf_compiler] Compiling bpf object...
INFO [ecc_rs::bpf_compiler] Generating package json..
INFO [ecc_rs::bpf_compiler] Packing ebpf object and config into ./eBPF/kprobe/package.json...然后,使用 eunomia-bpf 的工具 ecli加载 eBPF 程序:
sudo ./ecli ./eBPF/kprobe/package.jso和前面例子一样,在 /sys/kernel/debug/tracing/trace_pipe 文件中,你应该能看到类似下面的 kprobe 演示输出:
sudo cat /sys/kernel/debug/tracing/trace_pipe | grep "KPROBE "
......
Chrome_ChildIOT-20073 [006] ...21 19209.991609: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.QARMcJ
Chrome_ChildIOT-20073 [006] ...21 19209.991617: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
Chrome_ChildIOT-20073 [006] ...21 19210.999385: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.iYE0A0
Chrome_ChildIOT-20073 [006] ...21 19210.999391: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
Chrome_ChildIOT-20073 [006] ...21 19212.007375: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.lyjiqf
Chrome_ChildIOT-20073 [006] ...21 19212.007392: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
Chrome_ChildIOT-20073 [006] ...21 19213.015314: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.lc18pQ
Chrome_ChildIOT-20073 [006] ...21 19213.015323: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
Chrome_ChildIOT-20073 [007] ...21 19214.022435: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.WH1wlE
......文件删除时,打印出了进程的 PID 和文件名,以及返回值.
使用 cilium/ebpf 就有点麻烦了.
首先可能会遇到 vmlinux.h 文件缺失的问题时,通常是因为该文件未提供或未生成. 不过这个已经说明了如何生成的方法,问题不大.
其次还需要包含<bpf/bpf_endian.h>头文件.
最后还要 go generate 时候指定 -D__TARGET_ARCH_x86.
同时还要执行 cc 编译器为 clang.
执行 go generate 命令, 它会生成绑定的文件,并且支持大端和小端的操作系统.
主要逻辑实现 main.go 如下:
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -D__TARGET_ARCH_x86" --go-package minimal -output-dir minimal Minimal kprobe_unlink.c -- -I/usr/include/bpf -I/usr/include/linux
package main
import (
"os"
"os/signal"
"syscall"
"C"
"log"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
minimal "github.com/767829413/advanced-go/eBPF/kprobe/minimal"
)
func main() {
// 允许锁定内存
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatalf("Removing memlock limit: %v", err)
}
// 加载编译的 eBPF 程序
objs := minimal.MinimalObjects{}
if err := minimal.LoadMinimalObjects(&objs, nil); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
// 附加 kprobe 和 kretprobe
kp, err := link.Kprobe("do_unlinkat", objs.DoUnlinkat, nil)
if err != nil {
log.Fatalf("attaching kprobe: %v", err)
}
defer kp.Close()
krp, err := link.Kretprobe("do_unlinkat", objs.DoUnlinkatExit, nil)
if err != nil {
log.Fatalf("attaching kretprobe: %v", err)
}
defer krp.Close()
log.Println("eBPF program successfully attached, press Ctrl+C to exit...")
// 创建一个通道,用于接收信号
sigs := make(chan os.Signal, 1)
// 注册信号处理器,监听 SIGINT 和 SIGTERM 信号
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
}这个加载 eBPF 程序的代码是几乎都是一样的的套路. 剩下的就是想做的其他逻辑了.
整个目录结构如下
.
├── kprobe_unlink.c
├── main.go
├── minimal
│ ├── minimal_bpfeb.go
│ ├── minimal_bpfeb.o
│ ├── minimal_bpfel.go
│ └── minimal_bpfel.o
└── vmlinux.h运行这个程序,会和上一个例子一样,打印出进程的 PID 和文件名,以及返回值.
sudo go run main.go
2024/11/01 15:36:22 eBPF program successfully attached, press Ctrl+C to exit...
sudo cat /sys/kernel/debug/tracing/trace_pipe | grep "KPROBE "
......
Chrome_IOThread-20035 [002] ...21 23508.343776: bpf_trace_printk: KPROBE ENTRY pid = 20020, filename = /dev/shm/.org.chromium.Chromium.9Plr1L
Chrome_IOThread-20035 [002] ...21 23508.343890: bpf_trace_printk: KPROBE EXIT: pid = 20020, ret = 0
Chrome_IOThread-20035 [007] ...21 23508.362259: bpf_trace_printk: KPROBE ENTRY pid = 20020, filename = /dev/shm/.org.chromium.Chromium.Bf7dRj
Chrome_IOThread-20035 [007] ...21 23508.362282: bpf_trace_printk: KPROBE EXIT: pid = 20020, ret = 0
Chrome_ChildIOT-20073 [004] ...21 23508.530124: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.QJchoc
Chrome_ChildIOT-20073 [004] ...21 23508.530133: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
code-20043 [005] ...21 23508.540117: bpf_trace_printk: KPROBE ENTRY pid = 20020, filename = /home/fangyuan/.config/Code/User/workspaceStorage/de6c0b23d9bc427a424af2770809fb6c/state.vscdb-journal
code-20043 [005] ...21 23508.540200: bpf_trace_printk: KPROBE EXIT: pid = 20020, ret = 0
Chrome_ChildIOT-20073 [004] ...21 23509.055616: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.HSUiXd
Chrome_ChildIOT-20073 [004] ...21 23509.055628: bpf_trace_printk: KPROBE EXIT: pid = 20069, ret = 0
Chrome_ChildIOT-20073 [002] ...21 23510.063102: bpf_trace_printk: KPROBE ENTRY pid = 20069, filename = /dev/shm/.org.chromium.Chromium.wZHhD0
......总结 kprobes 的特点与使用限制(都是抄的,我不懂):
-
kprobes允许在同一个被探测位置注册多个kprobe,但是目前jprobe不可以;同时也不允许以其他的jprobe回调函数和kprobe的post_handler回调函数作为被探测点. -
一般情况下,可以探测内核中的任何函数,包括中断处理函数. 不过在
kernel/kprobes.c和arch/*/kernel/kprobes.c程序中用于实现kprobes自身的函数是不允许被探测的,另外还有do_page_fault和notifier_call_chain; -
如果以一个内联函数为探测点,则
kprobes可能无法保证对该函数的所有实例都注册探测点. 由于gcc可能会自动将某些函数优化为内联函数,因此可能无法达到用户预期的探测效果; -
一个探测点的回调函数可能会修改被探测函数的运行上下文,例如通过修改内核的数据结构或者保存与
struct pt_regs结构体中的触发探测器之前寄存器信息. 因此kprobes可以被用来安装bug修复代码或者注入故障测试代码; -
kprobes会避免在处理探测点函数时再次调用另一个探测点的回调函数,例如在printk()函数上注册了探测点,而在它的回调函数中可能会再次调用printk函数,此时将不再触发printk探测点的回调,仅仅是增加了kprobe结构体中nmissed字段的数值; -
在
kprobes的注册和注销过程中不会使用mutex锁和动态的申请内存; -
kprobes回调函数的运行期间是关闭内核抢占的,同时也可能在关闭中断的情况下执行,具体要视CPU架构而定. 因此不论在何种情况下,在回调函数中不要调用会放弃CPU的函数(如信号量、mutex锁等); -
kretprobe通过替换返回地址为预定义的trampoline的地址来实现,因此栈回溯和gcc内嵌函数__builtin_return_address()调用将返回trampoline的地址而不是真正的被探测函数的返回地址; -
如果一个函数的调用次数和返回次数不相等,则在类似这样的函数上注册
kretprobe将可能不会达到预期的效果,例如do_exit()函数会存在问题,而do_execve()函数和do_fork()函数不会; -
当在进入和退出一个函数时,如果
CPU运行在非当前任务所有的栈上,那么往该函数上注册kretprobe可能会导致不可预料的后果,因此,kprobes不支持在X86_64的结构下为__switch_to()函数注册kretprobe,将直接返回-EINVAL.
kprobe 和 tracepoint 是 Linux 内核中的两种不同的探测机制,用于性能监控、调试和分析. 它们的不同之处如下:
-
kprobe:
kprobe是一种允许开发者动态插入探针(probe)到任意内核函数的机制. 它可以在运行时指定要探测的函数地址,允许你在任何内核函数的入口或出口处插入钩子代码. -
tracepoint:
tracepoint是内核中事先定义好的探测点,开发者可以在编写内核代码时插入这些探点. 它们在内核特定位置手动放置,通常用于监控关键路径或性能敏感的代码段.
-
kprobe:
kprobe提供极大的灵活性,可以监控几乎任何内核函数. 因为探针是动态插入的,所以不需要修改内核源码即可使用. -
tracepoint:
tracepoint是静态定义的探测点,只能在内核开发者预定义的点上使用,灵活性相对较低.
-
kprobe:
kprobe在插入探针时涉及修改指令的流程(例如替换为中断指令),这可能会引入额外的开销,特别是在高频触发的情况下. -
tracepoint:
tracepoint由于是静态编译到内核中的,开销较低,特别适合高性能场景下的探测.
-
kprobe:适合需要对内核函数进行细粒度监控或调试的场景,例如调试未知问题或分析具体函数的性能.
-
tracepoint:适合性能监控和分析已经被识别为关键路径的内核代码,它们在开发内核时就已被考虑,比较适合大规模的性能分析或监控系统的运行状态.
-
kprobe:由于可以任意插入探针,使用不当可能导致系统不稳定,尤其是在插入错误探针或探测频繁调用的函数时.
-
tracepoint:由于是内核开发者预定义的点,通常经过优化和验证,因此在使用上更加安全可靠.
总结来说,kprobe 提供更高的灵活性和动态性,而 tracepoint 则更适合高性能的静态监控和分析场景. 如果需要对任意函数进行深入分析,kprobe 是更好的选择;如果只需要监控内核中重要的性能关键点,tracepoint 会是更安全且高效的工具.