上一节,我们使用了bpftrace 开发eBPF程序跟踪内核和用户态的程序,bpftrace 简单易用,非常适合入门,可以带初学者轻松体验 eBPF 的各种跟踪特性。但是,bpftrace 并不适用于所有的 eBPF 应用,它本身的限制导致我们无法在需要复杂 eBPF 程序的场景中使用它。在复杂的应用中,还是推荐使用 BCC 或者 libbpf 进行开发。现在讲一下BCC 的开发,有问题可以看官方文档。
现在我们试试使用BCC开发一个eBPF程序,分以下3个步骤,
- 使用 C 开发一个 eBPF 程序
- 使用 Python 和 BCC 库开发一个用户态程序
- 执行 eBPF 程序
现在先来一个最简单的程序玩玩吧!
一、开发一个最简单的eBPF程序
- 使用 C 开发一个 eBPF 程序
新建一个 hello.c 文件,并输入下面的内容:
int hello_world(void *ctx)
{
bpf_trace_printk("Hello, World!");
return 0;
}
就像所有编程语言的“ Hello World ”示例一样,这段代码的含义就是打印一句 “Hello, World!” 字符串。其中, bpf_trace_printk() 是一个最常用的 BPF 辅助函数,它的作用是输出一段字符串。不过,由于 eBPF 运行在内核中,它的输出并不是通常的标准输出(stdout),而是内核调试文件 /sys/kernel/debug/tracing/trace_pipe ,你可以直接使用 cat 命令来查看这个文件的内容。
- 使用 Python 和 BCC 库开发一个用户态程序
新建一个 hello.py 文件,并输入下面的内容:
#!/usr/bin/env python3
# 1) import bcc library
from bcc import BPF
# 2) load BPF program
b = BPF(src_file="hello.c")
# 3) attach kprobe
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
# 4) read and print /sys/kernel/debug/tracing/trace_pipe
b.trace_print()
让我们来看看每一处的具体含义:
-
第 1) 处导入了 BCC 库的 BPF 模块,以便接下来调用;
-
第 2) 处调用 BPF() 加载第一步开发的 BPF 源代码;
-
第 3) 处将 BPF 程序挂载到内核探针(简称 kprobe),其中 do_sys_openat2() 是系统调用 openat() 在内核中的实现;
-
第 4) 处则是读取内核调试文件 /sys/kernel/debug/tracing/trace_pipe的内容,并打印到标准输出中。
-
执行 eBPF 程序
用户态程序开发完成之后,最后一步就是执行它了。需要注意的是, eBPF 程序需要以 root 用户来运行,非 root 用户需要加上 sudo 来执行:
sudo python3 hello.py
稍等一会,你就可以看到如下的输出:
b' python3-9659 [000] d... 67871.172937: bpf_trace_printk: Hello, World!'
b' python3-9659 [000] d... 67871.173107: bpf_trace_printk: Hello, World!'
b' python3-9659 [000] d... 67871.173302: bpf_trace_printk: Hello, World!'
b' <...>-9661 [000] d... 67911.701439: bpf_trace_printk: Hello, World!'
b' gnome-shell-1758 [001] d... 67912.214565: bpf_trace_printk: Hello, World!'
b' gnome-shell-1758 [001] d... 67914.622574: bpf_trace_printk: Hello, World!'
b' gnome-shell-1758 [001] d... 67914.622619: bpf_trace_printk: Hello, World!'
输出字符串含义解析:
- python3-9659 表示进程的名字和 PID;
- [000] 表示 CPU 编号;
- d… 表示一系列的选项;
- 67871.172937表示时间戳;
- bpf_trace_printk 表示函数名;
- 最后的 “Hello, World!” 就是调用bpf_trace_printk() 传入的字符串。
到了这里,我们已经成功开发并运行了第一个 eBPF 程序。
二、开发 BPF 事件映射程序
刚刚开发的第一个程序是使用bpf_trace_printk()输出,他输出格式不够灵活,输出的内容不太符合我们平常的需要。现在我们尝试一下使用时间映射把我们需要的数据映射到用户态,然后在python中选择我们需要的信息打印到控制台中,这里以追踪我们打开文件为例子尝试一下吧。
先看 eBPF 程序open.c的代码:
// 包含头文件
#include <uapi/linux/openat2.h>
#include <linux/sched.h>
// 定义数据结构
struct data_t {
u32 pid;
u64 ts;
char comm[TASK_COMM_LEN];
char fname[NAME_MAX];
};
// 定义性能事件映射
BPF_PERF_OUTPUT(events);
// 定义kprobe处理函数
int bcc_do_sys_openat2(struct pt_regs *ctx, int dfd, const char __user * filename, struct open_how *how)
{
struct data_t data = { };
// 获取PID和时间
data.pid = bpf_get_current_pid_tgid();
data.ts = bpf_ktime_get_ns();
// 获取进程名
if (bpf_get_current_comm(&data.comm, sizeof(data.comm)) == 0)
{
bpf_probe_read(&data.fname, sizeof(data.fname), (void *)filename);
}
// 提交性能事件
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
我们使用了BCC 定义的一系列的库函数和辅助宏定义,下面讲解一下:
- BPF_PERF_OUTPUT:定义一个 Perf 事件类型的 BPF 映射,需要调用 perf_submit() 把数据提交到 BPF 映射中;
- bpf_get_current_pid_tgid 用于获取进程的 TGID 和 PID;
- bpf_ktime_get_ns 用于获取系统自启动以来的时间,单位是纳秒;
- bpf_get_current_comm 用于获取进程名,并把进程名复制到预定义的缓冲区中;
- bpf_probe_read 用于从指定指针处读取固定大小的数据,这里则用于读取进程打开的文件名。
现在写一个用户态程序open.py,用来读取 BPF 映射内容并输出到标准输出:
from bcc import BPF
# 1) 加载eBPF代码
b = BPF(src_file="open.c")
b.attach_kprobe(event="do_sys_openat2", fn_name="bcc_do_sys_openat2")
# 2) 输出头
print("%-18s %-16s %-6s %-16s" % ("TIME(s)", "COMM", "PID", "FILE"))
# 3) 定义性能事件打印函数
start = 0
def print_event(cpu, data, size):
global start
event = b["events"].event(data)
if start == 0:
start = event.ts
time_s = (float(event.ts - start)) / 1000000000
print("%-18.9f %-16s %-6d %-16s" % (time_s, event.comm, event.pid, event.fname))
# 4) 绑定性能事件映射和输出函数,并从映射中循环读取数据
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
- 第 1) 处加载 eBPF 程序并挂载到内核探针上;
- 第 2) 处输出一行 Header 字符串表示数据的格式;
- 第 3) 处print_event 定义一个数据处理的回调函数,打印进程的名字、PID 以及它调用 openat 时打开的文件;
- 第 4) 处则是使用poll读取内核调试文件 /sys/kernel/debug/tracing/trace_pipe的内容,并回调print_event打印到标准输出中。
jian@ubuntu:~/Desktop/bpf/sec$ sudo python3 open.py
...
TIME(s) COMM PID FILE
0.000000000 b'vmtoolsd' 802 b'/proc/meminfo'
0.000071315 b'vmtoolsd' 802 b'/proc/vmstat'
0.000133430 b'vmtoolsd' 802 b'/proc/stat'
0.000187572 b'vmtoolsd' 802 b'/proc/zoneinfo'
0.000282536 b'vmtoolsd' 802 b'/proc/uptime'
0.000294861 b'vmtoolsd' 802 b'/proc/diskstats'
相对于前面的 Hello World,它的输出不仅格式更为清晰,还把进程打开的文件名输出出来了,这在调试的时候尤其有用。
三、开发hash映射程序
刚刚使用事件映射已经满足我们平常的需要,但是如果我们需要同时跟踪几个函数,还想让跟踪点之间可以访问相互想要提交的数据,仅仅事件映射是不行的,这时候就需要使用哈希映射了。我们还是来个例子吧,我们追踪系统调用execve吧,那么他的进入追踪函数和返回函数分别是sys_enter_execve,sys_exit_execve;我们让他们共享data_t 数据,并且各自把数据填充进去,最后再一起提交。
同样,先看 eBPF 程序execsnoop.c的代码:
// 引入内核头文件
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#include <linux/fs.h>
// consts for arguments (ensure below stack size limit 512)
#define ARGSIZE 64
#define TOTAL_MAX_ARGS 5
#define FULL_MAX_ARGS_ARR (TOTAL_MAX_ARGS * ARGSIZE)
#define LAST_ARG (FULL_MAX_ARGS_ARR - ARGSIZE)
// perf event map (sharing data to userspace) and hash map (sharing data between tracepoints)
struct data_t {
u32 pid;
char comm[TASK_COMM_LEN];
int retval;
unsigned int args_size;
char argv[FULL_MAX_ARGS_ARR];
};
BPF_PERF_OUTPUT(events);
BPF_HASH(tasks, u32, struct data_t);
// helper function to read string from userspace.
static int __bpf_read_arg_str(struct data_t *data, const char *ptr)
{
if (data->args_size > LAST_ARG) {
return -1;
}
int ret = bpf_probe_read_user_str(&data->argv[data->args_size], ARGSIZE,
(void *)ptr);
if (ret > ARGSIZE || ret < 0) {
return -1;
}
// increase the args size. the first tailing '\0' is not counted and hence it
// would be overwritten by the next call.
data->args_size += (ret - 1);
return 0;
}
//定义sys_enter_execve跟踪点处理函数.
TRACEPOINT_PROBE(syscalls, sys_enter_execve)
{
// 变量定义
unsigned int ret = 0;
const char **argv = (const char **)(args->argv);
// 获取进程PID和进程名称
struct data_t data = { };
u32 pid = bpf_get_current_pid_tgid();
data.pid = pid;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
// 获取第一个参数(即可执行文件的名字)
if (__bpf_read_arg_str(&data, (const char *)argv[0]) < 0) {
goto out;
}
// 获取其他参数(限定最多5个)
for (int i = 1; i < TOTAL_MAX_ARGS; i++) {
if (__bpf_read_arg_str(&data, (const char *)argv[i]) < 0) {
goto out;
}
}
out:
// 存储到哈希映射中
tasks.update(&pid, &data);
return 0;
}
// 定义sys_exit_execve跟踪点处理函数.
TRACEPOINT_PROBE(syscalls, sys_exit_execve)
{
// 从哈希映射中查询进程基本信息
u32 pid = bpf_get_current_pid_tgid();
struct data_t *data = tasks.lookup(&pid);
// 填充返回值并提交到性能事件映射中
if (data != NULL) {
data->retval = args->ret;
events.perf_submit(args, data, sizeof(struct data_t));
// 最后清理进程信息
tasks.delete(&pid);
}
return 0;
}
- struct data_t:定义了一个包含进程基本信息的数据结构,它将用在哈希映射的值中(其中的参数大小 args_size
会在读取参数内容的时候用到); - BPF_PERF_OUTPUT(events) : 定义了一个性能事件映射;
- BPF_HASH(tasks,u32, struct data_t) : 定义了一个哈希映射,其键(tasks)为 32 位的进程PID,而值则是进程基本信息 data_t。
- tasks.update(&pid, &data):把进程的基本信息存储到哈希映射中。
- tasks.lookup(&pid):从哈希映射中通过进程ID获取进程基本信息 data_t。
- tasks.delete(&pid):从哈希映射中通过进程ID删除这个映射。
再看execsnoop.py:
# 引入库函数
from bcc import BPF
from bcc.utils import printb
# 1) 加载eBPF代码
b = BPF(src_file="execsnoop.c")
# 2) print header
print("%-6s %-16s %-3s %s" % ("PID", "COMM", "RET", "ARGS"))
# 3) 定义性能事件打印函数
def print_event(cpu, data, size):
# BCC自动根据"struct data_t"生成数据结构
event = b["events"].event(data)
printb(b"%-6d %-16s %-3d %-16s" % (event.pid, event.comm, event.retval, event.argv))
# 4) 绑定性能事件映射和输出函数,并从映射中循环读取数据
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
最后通过 Python 运行,并在另一个终端中执行 ls 命令,你就可以得到如下的输出:
jian@ubuntu:~/share/bpf/third$ sudo python3 execsnoop.py
PID COMM RET ARGS
6178 bash 0 ls--color=auto
使用BCC开发eBPF程序就讲到这里了,其实BCC中自带了很多eBPF程序,我们也可以直接那来使用的。