BPF 迭代器

动机

目前有几种方法可以将内核数据转储到用户空间。最常用的一种是 /proc 系统。例如,cat /proc/net/tcp6 会转储系统中所有 tcp6 套接字,而 cat /proc/net/netlink 会转储系统中所有 netlink 套接字。但是,它们的输出格式往往是固定的,如果用户想要了解有关这些套接字的更多信息,他们必须修补内核,这通常需要时间才能发布到上游并发布。对于诸如 ss 等常用工具也是如此,其中任何附加信息都需要内核补丁。

为了解决这个问题,通常使用 drgn 工具来挖掘内核数据,而无需更改内核。但是,drgn 的主要缺点是性能,因为它不能在内核内部进行指针追踪。此外,drgn 无法验证指针值,并且如果指针在内核内部失效,可能会读取无效数据。

BPF 迭代器通过为每个内核数据对象调用 BPF 程序,提供了收集哪些数据(例如,任务、bpf_maps 等)的灵活性,从而解决了上述问题。

BPF 迭代器的工作原理

BPF 迭代器是一种 BPF 程序,它允许用户迭代特定类型的内核对象。与传统的 BPF 跟踪程序允许用户定义在内核中特定执行点调用的回调不同,BPF 迭代器允许用户定义应为各种内核数据结构中的每个条目执行的回调。

例如,用户可以定义一个 BPF 迭代器,该迭代器迭代系统上的每个任务,并转储每个任务当前使用的 CPU 总运行时量。另一个 BPF 任务迭代器可以改为转储每个任务的 cgroup 信息。这种灵活性是 BPF 迭代器的核心价值。

BPF 程序始终在用户空间进程的要求下加载到内核中。用户空间进程通过打开并根据需要初始化程序框架,然后调用系统调用,使 BPF 程序由内核验证并加载,从而加载 BPF 程序。

在传统的跟踪程序中,程序通过用户空间使用 bpf_program__attach() 获取程序的 bpf_link 来激活。一旦激活,每当主内核中触发跟踪点时,就会调用程序回调。对于 BPF 迭代器程序,使用 bpf_link_create() 获取程序的 bpf_link,并通过从用户空间发出系统调用来调用程序回调。

接下来,让我们看看如何使用迭代器来迭代内核对象并读取数据。

如何使用 BPF 迭代器

BPF 自测是说明如何使用迭代器的一个很好的资源。在本节中,我们将逐步介绍一个 BPF 自测,该自测展示了如何加载和使用 BPF 迭代器程序。首先,我们将查看 bpf_iter.c,它说明了如何在用户空间端加载和触发 BPF 迭代器。稍后,我们将查看在内核空间中运行的 BPF 程序。

在内核中从用户空间加载 BPF 迭代器通常涉及以下步骤

  • BPF 程序通过 libbpf 加载到内核中。一旦内核验证并加载了该程序,它会向用户空间返回一个文件描述符 (fd)。

  • 通过调用 bpf_link_create()(使用从内核接收的 BPF 程序文件描述符指定)来获取 BPF 程序的 link_fd

  • 接下来,通过调用 bpf_iter_create()(使用从步骤 2 接收的 bpf_link 指定)来获取 BPF 迭代器文件描述符 (bpf_iter_fd)。

  • 通过调用 read(bpf_iter_fd) 直到没有数据可用为止,来触发迭代。

  • 使用 close(bpf_iter_fd) 关闭迭代器 fd。

  • 如果需要重新读取数据,请获取新的 bpf_iter_fd 并再次进行读取。

以下是一些自测 BPF 迭代器程序的示例

让我们看看在内核空间中运行的 bpf_iter_task_file.c

这是 vmlinux.hbpf_iter__task_file 的定义。在 vmlinux.h 中任何格式为 bpf_iter__<iter_name> 的结构名称都表示 BPF 迭代器。后缀 <iter_name> 表示迭代器的类型。

struct bpf_iter__task_file {
        union {
            struct bpf_iter_meta *meta;
        };
        union {
            struct task_struct *task;
        };
        u32 fd;
        union {
            struct file *file;
        };
};

在上面的代码中,字段“meta”包含元数据,这对于所有 BPF 迭代器程序都是相同的。其余字段特定于不同的迭代器。例如,对于 task_file 迭代器,内核层提供“task”、“fd”和“file”字段值。“task”和“file”是引用计数的,因此它们在 BPF 程序运行时不会消失。

以下是 bpf_iter_task_file.c 文件中的代码片段

SEC("iter/task_file")
int dump_task_file(struct bpf_iter__task_file *ctx)
{
  struct seq_file *seq = ctx->meta->seq;
  struct task_struct *task = ctx->task;
  struct file *file = ctx->file;
  __u32 fd = ctx->fd;

  if (task == NULL || file == NULL)
    return 0;

  if (ctx->meta->seq_num == 0) {
    count = 0;
    BPF_SEQ_PRINTF(seq, "    tgid      gid       fd      file\n");
  }

  if (tgid == task->tgid && task->tgid != task->pid)
    count++;

  if (last_tgid != task->tgid) {
    last_tgid = task->tgid;
    unique_tgid_count++;
  }

  BPF_SEQ_PRINTF(seq, "%8d %8d %8d %lx\n", task->tgid, task->pid, fd,
          (long)file->f_op);
  return 0;
}

在上面的示例中,节名称 SEC(iter/task_file) 表示该程序是一个 BPF 迭代器程序,用于迭代来自所有任务的所有文件。该程序的上下文是 bpf_iter__task_file 结构。

用户空间程序通过发出 read() 系统调用来调用在内核中运行的 BPF 迭代器程序。一旦调用,BPF 程序可以使用各种 BPF 辅助函数将数据导出到用户空间。您可以根据需要格式化输出还是仅需要二进制数据,分别使用 bpf_seq_printf() (和 BPF_SEQ_PRINTF 辅助宏)或 bpf_seq_write() 函数。对于二进制编码的数据,用户空间应用程序可以根据需要处理来自 bpf_seq_write() 的数据。对于格式化的数据,您可以像 cat /proc/net/netlink 一样,在将 BPF 迭代器固定到 bpffs 挂载后,使用 cat <path> 打印结果。稍后,使用 rm -f <path> 删除固定的迭代器。

例如,您可以使用以下命令从 bpf_iter_ipv6_route.o 对象文件创建一个 BPF 迭代器,并将其固定到 /sys/fs/bpf/my_route 路径

$ bpftool iter pin ./bpf_iter_ipv6_route.o  /sys/fs/bpf/my_route

然后使用以下命令打印出结果

$ cat /sys/fs/bpf/my_route

实现对 BPF 迭代器程序类型的内核支持

为了在内核中实现 BPF 迭代器,开发人员必须对 bpf.h 文件中定义的以下关键数据结构进行一次性更改。

struct bpf_iter_reg {
          const char *target;
          bpf_iter_attach_target_t attach_target;
          bpf_iter_detach_target_t detach_target;
          bpf_iter_show_fdinfo_t show_fdinfo;
          bpf_iter_fill_link_info_t fill_link_info;
          bpf_iter_get_func_proto_t get_func_proto;
          u32 ctx_arg_info_size;
          u32 feature;
          struct bpf_ctx_arg_aux ctx_arg_info[BPF_ITER_CTX_ARG_MAX];
          const struct bpf_iter_seq_info *seq_info;
};

在填写数据结构字段后,调用 bpf_iter_reg_target() 将迭代器注册到主 BPF 迭代器子系统。

以下是结构 bpf_iter_reg 中每个字段的细分。

字段

描述

target

指定 BPF 迭代器的名称。例如:bpf_mapbpf_map_elem。该名称应与内核中的其他 bpf_iter 目标名称不同。

attach_target 和 detach_target

允许针对目标特定的 link_create 操作,因为某些目标可能需要特殊处理。在用户空间 link_create 阶段调用。

show_fdinfo 和 fill_link_info

当用户尝试获取与迭代器关联的链接信息时,调用以填充目标特定信息。

get_func_proto

允许 BPF 迭代器访问特定于该迭代器的 BPF 辅助函数。

ctx_arg_info_size 和 ctx_arg_info

指定与 bpf 迭代器关联的 BPF 程序参数的验证器状态。

feature

指定内核 BPF 迭代器基础设施中的某些操作请求。目前,仅支持 BPF_ITER_RESCHED。这意味着调用内核函数 cond_resched() 以避免其他内核子系统(例如 rcu)行为异常。

seq_info

指定 BPF 迭代器和辅助函数的一组 seq 操作,用于初始化/释放相应 seq_file 的私有数据。

点击此处查看内核中 task_vma BPF 迭代器的实现。

参数化 BPF 任务迭代器

默认情况下,BPF 迭代器会遍历整个系统中指定类型的所有对象(进程、cgroup、映射等),以读取相关的内核数据。但通常情况下,我们只关心可迭代内核对象的一个小得多的子集,例如只迭代特定进程中的任务。因此,BPF 迭代器程序支持通过允许用户空间在附加迭代器程序时对其进行配置来过滤掉迭代的对象。

BPF 任务迭代器程序

以下代码是一个 BPF 迭代器程序,用于通过迭代器的 seq_file 打印文件和任务信息。这是一个标准的 BPF 迭代器程序,它会访问迭代器的每个文件。我们将在后面的示例中使用这个 BPF 程序。

#include <vmlinux.h>
#include <bpf/bpf_helpers.h>

char _license[] SEC("license") = "GPL";

SEC("iter/task_file")
int dump_task_file(struct bpf_iter__task_file *ctx)
{
      struct seq_file *seq = ctx->meta->seq;
      struct task_struct *task = ctx->task;
      struct file *file = ctx->file;
      __u32 fd = ctx->fd;
      if (task == NULL || file == NULL)
              return 0;
      if (ctx->meta->seq_num == 0) {
              BPF_SEQ_PRINTF(seq, "    tgid      pid       fd      file\n");
      }
      BPF_SEQ_PRINTF(seq, "%8d %8d %8d %lx\n", task->tgid, task->pid, fd,
                      (long)file->f_op);
      return 0;
}

创建带有参数的文件迭代器

现在,让我们看看如何创建一个仅包含进程文件的迭代器。

首先,填写 bpf_iter_attach_opts 结构,如下所示

LIBBPF_OPTS(bpf_iter_attach_opts, opts);
union bpf_iter_link_info linfo;
memset(&linfo, 0, sizeof(linfo));
linfo.task.pid = getpid();
opts.link_info = &linfo;
opts.link_info_len = sizeof(linfo);

如果 linfo.task.pid 非零,则指示内核创建一个仅包含具有指定 pid 的进程的打开文件的迭代器。在此示例中,我们将仅迭代我们进程的文件。如果 linfo.task.pid 为零,则迭代器将访问每个进程的每个打开的文件。类似地,linfo.task.tid 指示内核创建一个迭代器,该迭代器访问特定线程(而不是进程)的打开文件。在此示例中,仅当线程具有单独的文件描述符表时,linfo.task.tid 才与 linfo.task.pid 不同。在大多数情况下,所有进程线程共享一个文件描述符表。

现在,在用户空间程序中,将结构的指针传递给 bpf_program__attach_iter()

link = bpf_program__attach_iter(prog, &opts); iter_fd =
bpf_iter_create(bpf_link__fd(link));

如果 tidpid 都为零,则从此结构 bpf_iter_attach_opts 创建的迭代器将包含系统中(实际上是在命名空间中)每个任务的每个打开的文件。这与将 NULL 作为第二个参数传递给 bpf_program__attach_iter() 相同。

整个程序如下所示

#include <stdio.h>
#include <unistd.h>
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include "bpf_iter_task_ex.skel.h"

static int do_read_opts(struct bpf_program *prog, struct bpf_iter_attach_opts *opts)
{
      struct bpf_link *link;
      char buf[16] = {};
      int iter_fd = -1, len;
      int ret = 0;

      link = bpf_program__attach_iter(prog, opts);
      if (!link) {
              fprintf(stderr, "bpf_program__attach_iter() fails\n");
              return -1;
      }
      iter_fd = bpf_iter_create(bpf_link__fd(link));
      if (iter_fd < 0) {
              fprintf(stderr, "bpf_iter_create() fails\n");
              ret = -1;
              goto free_link;
      }
      /* not check contents, but ensure read() ends without error */
      while ((len = read(iter_fd, buf, sizeof(buf) - 1)) > 0) {
              buf[len] = 0;
              printf("%s", buf);
      }
      printf("\n");
free_link:
      if (iter_fd >= 0)
              close(iter_fd);
      bpf_link__destroy(link);
      return 0;
}

static void test_task_file(void)
{
      LIBBPF_OPTS(bpf_iter_attach_opts, opts);
      struct bpf_iter_task_ex *skel;
      union bpf_iter_link_info linfo;
      skel = bpf_iter_task_ex__open_and_load();
      if (skel == NULL)
              return;
      memset(&linfo, 0, sizeof(linfo));
      linfo.task.pid = getpid();
      opts.link_info = &linfo;
      opts.link_info_len = sizeof(linfo);
      printf("PID %d\n", getpid());
      do_read_opts(skel->progs.dump_task_file, &opts);
      bpf_iter_task_ex__destroy(skel);
}

int main(int argc, const char * const * argv)
{
      test_task_file();
      return 0;
}

以下是程序的输出。

PID 1859

   tgid      pid       fd      file
   1859     1859        0 ffffffff82270aa0
   1859     1859        1 ffffffff82270aa0
   1859     1859        2 ffffffff82270aa0
   1859     1859        3 ffffffff82272980
   1859     1859        4 ffffffff8225e120
   1859     1859        5 ffffffff82255120
   1859     1859        6 ffffffff82254f00
   1859     1859        7 ffffffff82254d80
   1859     1859        8 ffffffff8225abe0

没有参数

让我们看看没有参数的 BPF 迭代器如何跳过系统中其他进程的文件。在这种情况下,BPF 程序必须检查任务的 pid 或 tid,否则它将接收系统中(实际上是在当前 pid 命名空间中)的每个打开的文件。因此,我们通常在 BPF 程序中添加一个全局变量,以将 pid 传递给 BPF 程序。

BPF 程序如下所示。

......
int target_pid = 0;

SEC("iter/task_file")
int dump_task_file(struct bpf_iter__task_file *ctx)
{
      ......
      if (task->tgid != target_pid) /* Check task->pid instead to check thread IDs */
              return 0;
      BPF_SEQ_PRINTF(seq, "%8d %8d %8d %lx\n", task->tgid, task->pid, fd,
                      (long)file->f_op);
      return 0;
}

用户空间程序如下所示

......
static void test_task_file(void)
{
      ......
      skel = bpf_iter_task_ex__open_and_load();
      if (skel == NULL)
              return;
      skel->bss->target_pid = getpid(); /* process ID.  For thread id, use gettid() */
      memset(&linfo, 0, sizeof(linfo));
      linfo.task.pid = getpid();
      opts.link_info = &linfo;
      opts.link_info_len = sizeof(linfo);
      ......
}

target_pid 是 BPF 程序中的全局变量。用户空间程序应使用进程 ID 初始化该变量,以跳过 BPF 程序中其他进程的打开文件。当您参数化 BPF 迭代器时,迭代器调用 BPF 程序的次数更少,这可以节省大量资源。

参数化 VMA 迭代器

默认情况下,BPF VMA 迭代器包含每个进程中的每个 VMA。但是,您仍然可以指定一个进程或线程,使其仅包含其 VMA。与文件不同,线程不能有单独的地址空间(自 Linux 2.6.0-test6 起)。在这里,使用 tid 与使用 pid 没有区别。

参数化任务迭代器

带有 pid 的 BPF 任务迭代器包含进程的所有任务(线程)。BPF 程序会一个接一个地接收这些任务。您可以指定带有 tid 参数的 BPF 任务迭代器,使其仅包含与给定 tid 匹配的任务。