C++ `kprobes` / `uprobes`:动态追踪内核与用户态函数执行

哈喽,各位好!今天咱们聊点刺激的,关于C++和内核追踪的那些事儿。都说C++是程序员的瑞士军刀,锋利无比,但要让它跟内核,甚至是用户态程序的内部运作“亲密接触”,那可得借助一些“黑科技”了。别怕,今天咱就来揭开kprobesuprobes的神秘面纱,看看如何用它们追踪内核和用户态函数的执行。

什么是动态追踪?

想象一下,你的程序像一辆跑车,在高速公路上飞驰。突然,引擎出了点问题,但你又不想停车拆开发动机(重启程序),怎么办?动态追踪就像一个随身携带的诊断仪,可以实时监测发动机的各项参数(函数调用、变量值等),帮你找到问题的根源,而且不需要停车!

主角登场:kprobesuprobes

kprobesuprobes 就是内核提供的两种动态追踪技术,它们就像两把钥匙,一把打开内核的大门,一把打开用户态程序的大门。

  • kprobes (Kernel Probes): 用于追踪内核函数的执行。它可以让你在内核函数的入口、出口,甚至中间的任何地方插入“探针”(probe),收集信息,执行自定义代码。
  • uprobes (Userspace Probes): 用于追踪用户态程序的函数执行。用法和kprobes类似,只不过目标变成了用户空间的函数。

为什么要用 kprobesuprobes

  • 无需修改代码: 不需要重新编译内核或用户态程序,就可以进行追踪。这对于生产环境至关重要。
  • 实时性: 动态追踪是实时的,可以观察程序在运行时的行为。
  • 灵活性: 可以自定义探针的行为,收集特定信息,执行特定操作。
  • 排错神器: 帮助定位性能瓶颈、死锁、内存泄漏等问题。

C++ 如何与 kprobesuprobes 配合?

虽然 kprobesuprobes 是内核提供的技术,但我们可以利用C++来编写用户态程序,与它们进行交互,实现更强大的追踪功能。通常,我们会编写一个C++程序,使用 perf_event_open 系统调用来设置探针,并处理探针触发后的事件。

kprobes 实战:追踪内核函数调用

让我们来写一个简单的例子,追踪内核函数 do_sys_open 的调用。这个函数是所有 open 系统调用的底层实现,追踪它可以帮助我们了解文件操作的细节。

1. 创建一个BCC (BPF Compiler Collection) 程序

BCC是一个 Python 框架,它简化了 BPF 程序的编写和部署。BPF (Berkeley Packet Filter) 是一种强大的内核技术,可以用来过滤、分析和修改网络数据包和其他内核事件。BCC 使用 LLVM 将高级语言(如 Python, C/C++)编译成 BPF 字节码,然后在内核中执行。

from bcc import BPF
import time

# 定义 BPF 程序
program = """
#include <linux/sched.h>

// 定义一个结构体,用于传递数据到用户空间
struct data_t {
    u32 pid;
    u64 ts;
    char comm[TASK_COMM_LEN];
    char filename[256];
};

// 定义一个 BPF 环形缓冲区,用于将数据传递到用户空间
BPF_PERF_OUTPUT(events);

// 定义一个 kprobe 探针函数
int kprobe__do_sys_open(struct pt_regs *ctx, const char *filename, int flags, umode_t mode) {
    // 获取当前进程的 PID
    u32 pid = bpf_get_current_pid_tgid();

    // 获取当前进程的名称
    char comm[TASK_COMM_LEN];
    bpf_get_current_comm(&comm, sizeof(comm));

    // 创建一个 data_t 结构体
    struct data_t data = {};
    data.pid = pid;
    data.ts = bpf_ktime_get_ns(); // 获取当前时间戳
    strcpy(data.comm, comm);
    bpf_probe_read_user_str(data.filename, sizeof(data.filename), (void *)filename);

    // 将数据发送到用户空间
    events.perf_submit(ctx, &data, sizeof(data));

    return 0;
}
"""

# 创建 BPF 对象
b = BPF(text=program)

# 附加 kprobe 探针到 do_sys_open 函数
# b.attach_kprobe(event="do_sys_open", fn_name="kprobe__do_sys_open") #老的版本
#上面的代码在较新的内核中已经不再适用,建议改成下面的方式
b.attach_kprobe(event="__x64_sys_open", fn_name="kprobe__do_sys_open")

# 定义一个回调函数,用于处理从内核空间传递过来的数据
def print_event(cpu, data, size):
    event = b["events"].event(data)
    print(f"PID: {event.pid}, Command: {event.comm.decode()}, Filename: {event.filename.decode()}, Timestamp: {event.ts}")

# 设置回调函数
b["events"].open_perf_buffer(print_event)

# 循环读取数据
while True:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

代码解释:

  • BPF(text=program): 创建一个 BPF 对象,将 C 代码编译成 BPF 字节码。
  • BPF_PERF_OUTPUT(events): 定义一个环形缓冲区,用于将数据从内核空间传递到用户空间。
  • kprobe__do_sys_open: 这是一个 kprobe 探针函数,会在 do_sys_open 函数被调用时执行。
    • bpf_get_current_pid_tgid(): 获取当前进程的 PID。
    • bpf_get_current_comm(): 获取当前进程的名称。
    • bpf_probe_read_user_str(): 从用户空间读取字符串(文件名)。
    • events.perf_submit(): 将数据发送到用户空间。
  • b.attach_kprobe(event="__x64_sys_open", fn_name="kprobe__do_sys_open"): 将 kprobe 探针附加到 __x64_sys_open 函数。注意,这里使用了__x64_sys_open而不是do_sys_open。在不同的内核版本中,do_sys_open 可能被内联或优化掉,导致无法追踪。__x64_sys_open 是系统调用的入口点,通常更稳定。
  • print_event: 这是一个回调函数,用于处理从内核空间传递过来的数据。
  • b["events"].open_perf_buffer(print_event): 设置回调函数。
  • b.perf_buffer_poll(): 循环读取环形缓冲区中的数据。

运行结果:

运行这个脚本,然后随便打开一些文件,你就可以看到类似下面的输出:

PID: 1234, Command: bash, Filename: /etc/passwd, Timestamp: 1678886400000000
PID: 5678, Command: vim, Filename: /tmp/test.txt, Timestamp: 1678886401000000
...

uprobes 实战:追踪用户态函数调用

接下来,我们用 uprobes 来追踪用户态程序的函数调用。假设我们有一个简单的 C++ 程序 my_app

// my_app.cpp
#include <iostream>

int add(int a, int b) {
    std::cout << "Adding " << a << " and " << b << std::endl;
    return a + b;
}

int main() {
    int x = 10;
    int y = 20;
    int result = add(x, y);
    std::cout << "Result: " << result << std::endl;
    return 0;
}

编译它: g++ my_app.cpp -o my_app

现在,我们要追踪 add 函数的调用。

1. 创建一个BCC 程序

from bcc import BPF
import time
import sys

# 获取要追踪的程序的路径
program_path = sys.argv[1] if len(sys.argv) > 1 else "./my_app"

# 定义 BPF 程序
program = """
#include <linux/sched.h>

struct data_t {
    u32 pid;
    u64 ts;
    char comm[TASK_COMM_LEN];
    int arg1;
    int arg2;
    int result;
};

BPF_PERF_OUTPUT(events);

int uprobe__add(struct pt_regs *ctx) {
    struct data_t data = {};
    data.pid = bpf_get_current_pid_tgid();
    data.ts = bpf_ktime_get_ns();
    bpf_get_current_comm(&data.comm, sizeof(data.comm));

    // 读取函数参数
    data.arg1 = PT_REGS_PARM1(ctx);
    data.arg2 = PT_REGS_PARM2(ctx);

    events.perf_submit(ctx, &data, sizeof(data));
    return 0;
}

int uretprobe__add(struct pt_regs *ctx) {
    struct data_t data = {};
    data.pid = bpf_get_current_pid_tgid();
    data.ts = bpf_ktime_get_ns();
    bpf_get_current_comm(&data.comm, sizeof(data.comm));

    // 读取函数返回值
    data.result = PT_REGS_RC(ctx);

    events.perf_submit(ctx, &data, sizeof(data));
    return 0;
}
"""

# 创建 BPF 对象
b = BPF(text=program)

# 附加 uprobe 探针到 add 函数的入口和出口
b.attach_uprobe(name=program_path, sym="add", fn_name="uprobe__add")
b.attach_uretprobe(name=program_path, sym="add", fn_name="uretprobe__add")

# 定义一个回调函数,用于处理从内核空间传递过来的数据
def print_event(cpu, data, size):
    event = b["events"].event(data)
    print(f"PID: {event.pid}, Command: {event.comm.decode()}, Timestamp: {event.ts}, arg1: {event.arg1}, arg2: {event.arg2}, result: {event.result}")

# 设置回调函数
b["events"].open_perf_buffer(print_event)

# 循环读取数据
while True:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

代码解释:

  • b.attach_uprobe(name=program_path, sym="add", fn_name="uprobe__add"): 将 uprobe 探针附加到 my_app 程序的 add 函数的入口。
  • b.attach_uretprobe(name=program_path, sym="add", fn_name="uretprobe__add"): 将 uretprobe 探针附加到 my_app 程序的 add 函数的出口。uretprobe 用于追踪函数的返回值。
  • PT_REGS_PARM1(ctx)PT_REGS_PARM2(ctx): 用于读取函数的前两个参数。
  • PT_REGS_RC(ctx): 用于读取函数的返回值。

运行结果:

运行这个脚本: sudo python your_script.py ./my_app (需要 sudo 权限,因为涉及到内核操作)

然后运行 my_app 程序,你就可以看到类似下面的输出:

PID: 1234, Command: my_app, Timestamp: 1678886400000000, arg1: 10, arg2: 20, result: 0
PID: 1234, Command: my_app, Timestamp: 1678886401000000, arg1: 0, arg2: 0, result: 30

一些注意事项:

  • 内核版本兼容性: kprobesuprobes 的行为在不同的内核版本中可能会有所不同。你需要根据你使用的内核版本调整你的代码。
  • 性能影响: 动态追踪会对性能产生一定的影响。不要在生产环境中使用过于频繁的探针。
  • 安全问题: 不正确的探针代码可能会导致内核崩溃。请谨慎编写探针代码,并进行充分的测试。
  • 符号表: uprobes 需要程序有符号表才能正常工作。编译时请确保包含调试信息(例如,使用 -g 选项)。
  • root 权限: 通常需要 root 权限才能使用 kprobesuprobes

更高级的用法:

  • 条件探针: 只有在满足特定条件时才触发探针。
  • 多重探针: 在一个函数上设置多个探针。
  • 动态创建探针: 在程序运行时动态地创建和删除探针。
  • 与 perf 工具集成:kprobesuprobesperf 工具集成,进行更深入的性能分析。

总结:

kprobesuprobes 是强大的内核追踪技术,可以帮助我们深入了解内核和用户态程序的内部运作。虽然学习曲线比较陡峭,但掌握它们绝对可以让你成为一名更优秀的程序员。希望今天的讲解能让你对 kprobesuprobes 有一个初步的了解,并激发你探索更多可能性的兴趣。

常见问题:

问题 答案
为什么我的 kprobe 无法工作? 1. 确认内核版本是否支持该 kprobe。2. 确认函数名是否正确。3. 确认内核符号表已启用。4. 检查探针代码是否有错误。
为什么我的 uprobe 无法工作? 1. 确认程序是否包含调试信息(例如,使用 -g 编译)。2. 确认函数名是否正确。3. 确认程序路径是否正确。4. 检查探针代码是否有错误。5. 某些编译器优化可能会导致函数内联,从而使 uprobe 无法工作。
如何减少 kprobes/uprobes 的性能影响? 1. 只在必要时启用探针。2. 尽量减少探针代码的执行时间。3. 使用条件探针,只在满足特定条件时才触发探针。4. 避免在关键路径上使用探针。
如何在生产环境中使用 kprobes/uprobes? 1. 进行充分的测试,确保探针代码不会导致系统崩溃。2. 使用条件探针,只在必要时启用探针。3. 监控探针的性能影响,并根据需要进行调整。4. 使用安全的 BPF 程序,避免恶意代码注入。
如何调试 kprobes/uprobes 程序? 1. 使用 printk 在内核中打印调试信息(kprobes)。2. 使用 perf recordperf script 记录和分析探针事件。3. 使用 bpftool 查看 BPF 程序的运行状态。4. 使用 gdb 调试用户态程序(uprobes)。
如何获取函数的参数和返回值? kprobes:使用 PT_REGS_* 宏读取寄存器中的参数。uprobes:使用 PT_REGS_* 宏读取寄存器中的参数和返回值。注意:不同架构的寄存器定义可能不同。

希望这些信息能帮助你更好地理解和使用 kprobesuprobes。祝你编程愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注