bpf学习笔记

1. 基于libbpf开发bpf程序的工作流

在libbpf中的应用程序中,往往有两个部分是必要的:

app.bpf.c.对应的是内核空间的代码逻辑.其中一般有定义的断点,探针及其执行的handle,可能还有“.map”等内核数据结构.这一部分的代码是面向内核的,因为我们知道bpf技术一种可以向内核注册一系列的事件,并且对其事件定义相应的回调代码的技术,这种技术无需对内核代码进行修改或者重新编译.

app.c,对应的是用户空间的代码逻辑.将bpf代码打开并加载,并和内核事件进行交互.

其实用户空间对应的程序完全可以用C++写.

关于其编译到执行的工作流:

file(GLOB apps *.bpf.c)
foreach(app ${apps})
    get_filename_component(app_stem ${app} NAME_WE)
    # Build object skeleton and depend skeleton on libbpf build
    bpf_object(${app_stem} ${app_stem}.bpf.c)
    add_dependencies(${app_stem}_skel libbpf-build)

    add_executable(${app_stem} ${app_stem}.cc)
    target_link_libraries(${app_stem} ${app_stem}_skel Nanolog)
    install(TARGETS ${app_stem} DESTINATION lib)
endforeach()

首先根据app.bpf.c生成一个app_skel的头文件,该文件将会被用户态程序中作为头文件.之后才生成最终的可执行的程序.

其中关于生成的app_skel文件,其中模式大体如下:

/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */

/* THIS FILE IS AUTOGENERATED! */
#ifndef __MINIMAL_BPF_SKEL_H__
#define __MINIMAL_BPF_SKEL_H_
#include <stdlib.h>
#include <bpf/libbpf.h>

struct minimal_bpf {
 struct bpf_object_skeleton *skeleton;
 struct bpf_object *obj;
 struct {
  struct bpf_map *bss;
 } maps;
 struct {
  struct bpf_program *handle_tp;
 } progs;
 struct {
  struct bpf_link *handle_tp;
 } links;
 struct minimal_bpf__bss {
  int my_pid;
 } *bss;
};
static inline void minimal_bpf__destroy(struct minimal_bpf *obj) { ... }
static inline struct minimal_bpf *minimal_bpf__open_opts(const struct bpf_object_open_opts *opts) { ... }
static inline struct minimal_bpf *minimal_bpf__open(void) { ... }
static inline int minimal_bpf__load(struct minimal_bpf *obj) { ... }
static inline struct minimal_bpf *minimal_bpf__open_and_load(void) { ... }
static inline int minimal_bpf__attach(struct minimal_bpf *obj) { ... }
static inline void minimal_bpf__detach(struct minimal_bpf *obj) { ... }

#endif /* __MINIMAL_BPF_SKEL_H__ */

其中用户态空间可以通过maps中的bss来访问.bpf.c程序中定义的“.map”数据结构,比如说ring buffer,map等.关于progs可以通过它对.bpf.c中的handle进行访问,links的作用类似于此.而对于minimal_bpf__bss这一部分,则可以通过它在用户空间访问到它所定义的所有全局变量.

结合很多bpf demo的分析和上述的API,我们整理一下一个常规的bpf程序的模式:

struct minimal_bpf *skel;
 int err;

 /* Set up libbpf errors and debug info callback */
 libbpf_set_print(libbpf_print_fn);
 /* Bump RLIMIT_MEMLOCK to allow BPF sub-system to do anything */
 bump_memlock_rlimit();
 /* Open BPF application */
 skel = minimal_bpf__open();
 if (!skel) {
     fprintf(stderr, "Failed to open BPF skeleton\n");
     return 1;
 }
 /* ensure BPF program only handles write() syscalls from our process */
 skel->bss->my_pid = getpid(); // 在用户空间修改bpf程序中的全局变量
 /* Load & verify BPF programs */
 err = minimal_bpf__load(skel); // 将bpf程序载入并且通过内核的verify,返回值如果为0,就说明已经通过ok
 if (err) {
     fprintf(stderr, "Failed to load and verify BPF skeleton\n");
     goto cleanup;
 }
 /* Attach tracepoint handler */
 err = minimal_bpf__attach(skel); // 将其中的handle加入
 if (err) {
     fprintf(stderr, "Failed to attach BPF skeleton\n");
     goto cleanup;
 }
 printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
        "to see output of the BPF programs.\n");
 for (;;) {
     /* trigger our BPF program */
     fprintf(stderr, ".");
     sleep(1);
 }

 cleanup:
 minimal_bpf__destroy(skel); // 对skel进行清理
 return -err;

简而言之:

  • libbpf_set_printbump_memlock_rlimit.
  • skel = *_bpf__open(),返回一个对象,该对象对应了我们之前定义的bpf.
  • *_bpf__load(skel),将skel对象载入到载入到内核中,并且还会涉及到内核的verify操作,如果不能通过verify将会返回err(非0).
  • *_bpf__attach(skel),将其中的事件(tracepoint,probe等)及其handle载入到内核.
  • while循环.
  • clean代码段,用于程序结束或者出错情况下执行,一般有*_bpf__destroy, ring_buffer__free等API.

2. libbpf中的常用API

libbpf_set_print(libbpf_output),设置libbpf中的日志输出函数,libbpf 执行过程中的日志会通过 libbpf_output 函数进行输出.bpf_get_current_pid_tgid该函数通常在bpf中的内核态代码中执行,该程序往往用来获取触发某种事件的进程的pid.比如说,如下的方式:

int pid = bpf_get_current_pid_tgid() >> 32;

bpf_ktime_get_ns该API用来获取系统中的时间戳,其单位为ns.

bpf_get_current_task获取触发该事件的进程其对应的task,一种内核中表示进程的数据结构.其中示例代码如下:

struct task_struct *task;
task = (struct task_struct *) bpf_get_current_task();

BPF_CORE_READ,这个API和下面所要说的bpf_probe_read_str的API都涉及到内核数据字段的拷贝.比如说从内核中读取某个字段的值.比如说bpf_core_read(dst, sz, src).其中有代码如下:

struct task_struct *task = (void *)bpf_get_current_task();
struct task_struct *parent_task;
int err = bpf_core_read(&parent_task, sizeof(void *), &task->parent);
if (err) {
    /* handle error */
}

bpf_probe_read_str,用于拷贝字符串,将内容拷贝到指定的缓冲区中.

bpf_probe_read_str(&e->filename, sizeof(e->filename), (void *)ctx + fname_off);

bpf_get_current_comm,用来获取当前该进程的进程名字,其中的参数是一个缓冲区,及其size.比如说如下所示:

bpf_get_current_comm(&e->comm, sizeof(e->comm));

bpf_printk

3. 其他要点

1) BPF maps

这个东西的作用十分类似于STL,是一种可以在内核中使用的抽象数据容器.有BPF_MAP_TYPE_HASH,BPF_MAP_TYPE_RINGBUF等.下面是关于BPF_MAP_TYPE_HASH.这个东西和C++中的map颇为相似.其中实例如下:

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 8192);
    __type(key, pid_t);
    __type(value, u64);
} exec_start SEC(".maps");

其中分别对应的了.maps的类型,最大条目数,key,value,其中关于max_entries的设置,一般必须得是内核页size的大小,知道了基本的定义方式之后,我们需要搞明白的就是对于操作这么一个数据结构都需要哪些API.其中主要有bpf_map_update_elem,bpf_map_lookup_elem,bpf_map_delete_elem.其中详细的使用情况如下:

bpf_map_update_elem(&exec_start, &pid, &ts, BPF_ANY); // BPF_ANY表示的更新现有这对map,或者增加新的map.
bpf_map_delete_elem(&exec_start, &pid); // 移除exec_start中pid对应的map
start_ts = bpf_map_lookup_elem(&exec_start, &pid); // 其返回值是一个value

2) BPF Ring Buffer

buffer这种数据结构的使用时常被用于做数据传递,尤其是一些“生产者-消费者”的场景,Ring buffer就是这么一种数据结构,可以用来给内核空间和用户空间做数据交换.其中相比之前perf buffer来说,有更高的效率使用内存,并且可以保证事件顺序.

其中对其定义一半如下:

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);
} rb SEC(".maps");  // 这个buf往往是作为内核态到用户态的桥梁的.

其中关于ring buffer,最常用的API是bpf_ringbuf_reserve()/pf_ringbuf_commit().其中使用情况如下:

e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
bpf_ringbuf_submit(e, 0);

通过reserve的好处在于可以通过返回结果判断是否还有足够的空间加入数据.一旦返回是一个非NULL,就表示这个数据成功地在buffer中分配了空间,后续的commit也保证了一定会成功.当执行commit的时候,用户空间可以看到传递过来的数据,这个放置于ring buffer中的数据往往是用户自己定义的,比如说像下面这样子.

struct event {
    int pid;
    int ppid;
    unsigned exit_code; 
    unsigned long long duration_ns;
    char comm[TASK_COMM_LEN]; // 进程的name
    char filename[MAX_FILENAME_LEN];
    bool exit_event;
};

如此之外,还有一个ring_buffer__new.这表示的是对于一个ring buffer的初始化操作,其中的第一个参数对应的是我们在内核空间中定义的ring buffer,第二参数对应了commit事件的回调函数,这个函数往往是我们在用户态空间定义的,在用户态运行的.其返回值是ring buffer的指针.

rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);

此外关于ring_buffer__poll这一个函数,这个函数时常被用在用户态程序的主循环之中的.比如说:

while (!exiting) {
    err = ring_buffer__poll(rb, 100 /* timeout, ms */);
    if (err == -EINTR) {
        err = 0;
        break;
    }
    if (err < 0) {
        printf("Error polling perf buffer: %d\n", err);
        break;
    }
}

4.Reference

BPF ring buffer

BCC to libbpf conversion guide

ebpf:基于 uprobe 的自定义例程

[译] BPF CO-RE 参考指南