eBPF学习笔记4

上一篇文章记录了如何使用bpftrace来编写bpf程序,这篇就来说说如何使用BCC(BPF Compiler Collection)。正如前一篇笔记说的,bpftrace简单是简单,但是对于某些复杂场景功能还略有不足。比如,很多的Linux工具都支持各种各样的参数,这时bpftrace就不能满足需求了。

如图所示,BCC已经提供了大量的二进制工具可以直接使用,这篇笔记主要侧重于如何使用BCC编写eBPF程序。

bcc

举个例子,来源于官网。首先建立一个hello.c:

#include <linux/sched.h>

// 定义一个结构体来存储数据
struct data_t {
    u32 pid;
    u64 ts;
    char comm[TASK_COMM_LEN];
};

// 辅助函数,用于定义一个名字叫做events的table用于存放自定义事件到用户空间
BPF_PERF_OUTPUT(events);

int hello(struct pt_regs *ctx) {
    struct data_t data = {};
	
	// 辅助函数,获取TGID和PID,由于data_t的pid定义为u32,高32位舍弃后刚好是PID
    data.pid = bpf_get_current_pid_tgid();
    // 辅助函数,获取系统启动以来的时间,纳秒
	data.ts = bpf_ktime_get_ns(); 
	// 辅助函数,获取进程名称并且复制到对应的缓冲区
    bpf_get_current_comm(&data.comm, sizeof(data.comm));
	// 辅助函数,向用户空间提交自定义事件数据
    events.perf_submit(ctx, &data, sizeof(data));
    return 0;
}

具体含义可以看注释,然后再创建一个hello.py:

from bcc import BPF

# 加载BPF程序
b = BPF(src_file="hello.c")

# 将程序插桩到`clone`系统调用,fn_name参数就是上面C程序函数名称。
b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello")
# 也可以直接写需要插桩的函数名称
# b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")


# 打印表头
print("%-18s %-16s %-6s" % ("TIME(s)", "COMM", "PID"))

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" % (time_s, event.comm, event.pid))

# 打开从上面C代码中使用`BPF_PERF_OUTPUT`定义的table,并且和回调函数建立关联
b["events"].open_perf_buffer(print_event)

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

然后执行python3 hello.py会看到如下输出:

In file included from <built-in>:2:
In file included from /virtual/include/bcc/bpf.h:12:
In file included from include/linux/types.h:6:
In file included from include/uapi/linux/types.h:14:
In file included from include/uapi/linux/posix_types.h:5:
In file included from include/linux/stddef.h:5:
In file included from include/uapi/linux/stddef.h:2:
In file included from include/linux/compiler_types.h:80:
include/linux/compiler-clang.h:41:9: warning: '__HAVE_BUILTIN_BSWAP32__' macro redefined [-Wmacro-redefined]
#define __HAVE_BUILTIN_BSWAP32__
        ^
<command line>:4:9: note: previous definition is here

3 warnings generated.
TIME(s)            COMM             PID   
0.000000000        b'node'          38928 
0.002119888        b'node'          38928 
0.008244036        b'node'          38928 
0.009920322        b'cpuUsage.sh'   43984 
0.010913998        b'cpuUsage.sh'   43984 
0.011550577        b'cpuUsage.sh'   43984 
0.012182933        b'cpuUsage.sh'   43984 
0.512549552        b'node'          39443 
0.518012445        b'sh'            43989 
0.518069208        b'sh'            43989 
1.012800058        b'cpuUsage.sh'   43984 
1.013940943        b'cpuUsage.sh'   43984

可以看出执行python程序时候自动进行了编译、挂载行为。

eBPF程序的编译、加载、运行?

当编写完成BPF程序后,首先会通过clang生成LLVM IR文件,然后再经过LLVM生成BPF字节码。字节码经过加载器通过bpf()系统调用(注意从这里开始由用户态进入了内核态)进入验证器验证,最后通过JIT执行。

eBPF程序指令都是在内核的特定插桩点执行,将指令加载到内核时,内核会创建bpf_prog存储指令,然后将bpf_prog和内核中的插桩点进行关联,当插桩点被访问到时执行这些指令。

举个例子,以kprobe为例,首先kprobe会复制保存原来的目标地址,然后使用int3或者jmp指令替换掉它,当程序控制流执行到断点时,检查如果由kprobe替换的,则执行kprobe相关指令,然后再执行原来的程序。当kprobe程序停止时,再把原来的目标地址拷贝回去,来恢复现场。

如何查找BPF辅助函数?

  1. reference_guide,常用的、推荐的都列在里面了,必读。
  2. bpf.h
  3. bpftool feature probe,这个命令也可以查看所有支持的辅助函数。

如何查找系统调用和插桩点?

这部分主要作为小白的知识点补充,熟悉内核的大佬可以略过。

首先推荐的肯定是各类经典Unix/Linux书籍,常用的系统调用都会在里面有相关介绍。比如《UNIX环境高级编程》、《UNIX网络编程》、《深入理解Linux内核》、《Linux系统编程手册》……最大的挑战就是每一本都是大砖头书,没事就翻翻吧……

其次是推荐几个网站:

目前系统调用一般会使用宏SYSCALL_DEFINEx来创建,位于syscalls.h:

#ifndef SYSCALL_DEFINE0
#define SYSCALL_DEFINE0(sname)					\
	SYSCALL_METADATA(_##sname, 0);				\
	asmlinkage long sys_##sname(void);			\
	ALLOW_ERROR_INJECTION(sys_##sname, ERRNO);		\
	asmlinkage long sys_##sname(void)
#endif /* SYSCALL_DEFINE0 */

#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)

其中:

  • 反斜杠\:当宏定义过长需要换行时,在行尾要加上换行标志“\”
  • …:省略号代表可变的部分,下面用__VA_AEGS__ 代表省略的变长部分
  • ##:分隔连接方式,它的作用是先分隔,然后进行强制连接

SYSCALL_DEFINE后面的数字表示函数调用需要的参数个数,比如对于openat2这个系统调用,可以在elixir里查询到声明和定义:

asmlinkage long sys_openat2(int dfd, const char __user *filename,
			    struct open_how *how, size_t size);
SYSCALL_DEFINE4(openat2, int, dfd, const char __user *, filename,
		struct open_how __user *, how, size_t, usize)
{
	int err;
	struct open_how tmp;

	BUILD_BUG_ON(sizeof(struct open_how) < OPEN_HOW_SIZE_VER0);
	BUILD_BUG_ON(sizeof(struct open_how) != OPEN_HOW_SIZE_LATEST);

	if (unlikely(usize < OPEN_HOW_SIZE_VER0))
		return -EINVAL;

	err = copy_struct_from_user(&tmp, sizeof(tmp), how, usize);
	if (err)
		return err;

	audit_openat2_how(&tmp);

	/* O_LARGEFILE is only allowed for non-O_PATH. */
	if (!(tmp.flags & O_PATH) && force_o_largefile())
		tmp.flags |= O_LARGEFILE;

	return do_sys_openat2(dfd, filename, &tmp);
}

然后这个do_sys_openat2就是openat2()在内核的实现了,也就是BPF程序的插桩点。

所以流程就是:

  1. 确认系统调用的名称和参数数量
  2. 构造SYSCALL_DEFINEx(syscall_func_name,比如SYSCALL_DEFINE2(creat,
  3. 去livegrep搜索上面的字符串位于哪个文件
  4. 去elixir找到对应的文件查看对应的代码
  5. 在BPF内插桩,比如do_sys_xxx

参考链接