BPF 迭代器

概述

BPF 支持两种独立的实体,统称为“BPF 迭代器”:BPF 迭代器程序类型开放式编码BPF 迭代器。前者是一种独立的 BPF 程序类型,当用户附加并激活时,它将对每个被迭代的实体(如 task_struct、cgroup 等)调用一次。后者是一组实现迭代器功能并在多种 BPF 程序类型中可用的 BPF 端 API。开放式编码的迭代器提供了与 BPF 迭代器程序类似的功能,但为所有其他 BPF 程序类型提供了更大的灵活性和控制。另一方面,BPF 迭代器程序可以用于实现匿名或 BPF 文件系统挂载的特殊文件,其内容由附加的 BPF 迭代器程序生成,并由 seq_file 功能支持。两者都根据具体需求而有用。

当添加新的 BPF 迭代器程序时,为了最大程度的灵活性,预期会以开放式编码迭代器的方式添加类似的功能。同时,迭代逻辑和代码预期在两种迭代器 API 接口之间实现最大限度的共享和重用。

开放式编码 BPF 迭代器

开放式编码的 BPF 迭代器被实现为紧密耦合的 kfunc 三元组(构造函数、下一个元素获取、析构函数),以及描述栈上迭代器状态的迭代器特定类型,BPF 校验器保证该状态不会在相应的构造函数/析构函数/next API 之外被篡改。

每种开放式编码的 BPF 迭代器都有其关联的 struct bpf_iter_<type>,其中 <type> 表示特定类型的迭代器。bpf_iter_<type> 状态需要存在于 BPF 程序栈上,因此请确保它足够小以便适应 BPF 栈。出于性能原因,最好避免为迭代器状态进行动态内存分配,并将状态结构体大小设计得足够大以容纳所有必要内容。但如果需要,动态内存分配是绕过 BPF 栈限制的一种方法。请注意,状态结构体的大小是迭代器用户可见 API 的一部分,因此更改它会破坏向后兼容性,因此在设计时务必慎重考虑。

所有 kfunc(构造函数、next、析构函数)必须分别一致地命名为 bpf_iter_<type>_{new,next,destroy}()。<type> 表示迭代器类型,迭代器状态应表示为匹配的 struct bpf_iter_<type> 状态类型。此外,所有迭代器 kfunc 的第一个参数都应是指向此 struct bpf_iter_<type> 的指针。

此外
  • 构造函数,即 bpf_iter_<type>_new(),可以有任意数量的额外参数。返回值类型也没有强制要求。

  • next 方法,即 bpf_iter_<type>_next(),必须返回指针类型,并且应该只有一个参数:struct bpf_iter_<type> *(const/volatile/restrict 和 typedef 会被忽略)。

  • 析构函数,即 bpf_iter_<type>_destroy(),应返回 void,并且应该只有一个参数,类似于 next 方法。

  • struct bpf_iter_<type> 的大小被强制为正数且是 8 字节的倍数(以正确适应栈槽)。

这种严格性和一致性允许构建通用辅助函数,抽象出重要但重复的细节,从而能够有效且符合人体工程学地使用开放式编码迭代器(参见 libbpf 的 bpf_for_each() 宏)。这在内核的 kfunc 注册点强制执行。

构造函数/next/析构函数的实现约定如下:
  • 构造函数 bpf_iter_<type>_new() 总是初始化栈上的迭代器状态。如果任何输入参数无效,构造函数应确保仍然对其进行初始化,以便后续的 next() 调用将返回 NULL。即,在错误时,返回错误并构造一个空迭代器。构造函数 kfunc 标有 KF_ITER_NEW 标志。

  • next 方法 bpf_iter_<type>_next() 接受指向迭代器状态的指针并生成一个元素。next 方法应始终返回一个指针。BPF 校验器之间的约定是 next 方法保证当元素耗尽时最终会返回 NULL。一旦返回 NULL,后续的 next 调用应该继续返回 NULL。next 方法标有 KF_ITER_NEXT(当然,也应具有 KF_RET_NULL 作为返回 NULL 的 kfunc)。

  • 析构函数 bpf_iter_<type>_destroy() 总是被调用一次。即使构造函数失败或 next 什么都没返回。析构函数会释放所有资源,并将 struct bpf_iter_<type> 使用的栈空间标记为可用于其他用途。析构函数标有 KF_ITER_DESTROY 标志。

任何开放式编码的 BPF 迭代器实现都必须至少实现这三种方法。对于任何给定类型的迭代器,只允许调用适用的构造函数/析构函数/next。即,校验器确保您不能将数字迭代器状态传递给,比如说,cgroup 迭代器的 next 方法。

从 BPF 校验的高层视角来看,next 方法是分叉校验状态的点,其概念上类似于校验器在验证条件跳转时所做的事情。校验器会对 call bpf_iter_<type>_next 指令进行分支,并模拟两种结果:NULL(迭代完成)和非 NULL(返回新元素)。首先模拟 NULL 情况,预期会不进入循环而直接达到退出。之后,验证非 NULL 情况,它要么达到退出(对于没有实际循环的简单示例),要么达到另一个 call bpf_iter_<type>_next 指令,其状态与已(部分)验证的状态等效。此时的状态等效性意味着我们理论上会无限循环,而不会“跳出”已建立的“状态包络”(即,后续迭代不会给校验器状态添加任何新知识或约束,因此运行 1 次、2 次、10 次或一百万次都没有关系)。但考虑到迭代器 next 方法最终必须返回 NULL 的约定,我们可以得出结论,循环体是安全的,并且最终会终止。鉴于我们验证了循环外的逻辑(NULL 情况),并得出循环体是安全的结论(尽管可能循环多次),校验器可以声明整个程序逻辑的安全性。

BPF 迭代器的动机

有几种现有方法可以将内核数据转储到用户空间。最流行的是 /proc 系统。例如,cat /proc/net/tcp6 转储系统中所有 tcp6 套接字,cat /proc/net/netlink 转储系统中所有 netlink 套接字。然而,它们的输出格式往往是固定的,如果用户想获取这些套接字的更多信息,他们必须修补内核,这通常需要时间才能向上游发布和发行。对于 ss 等流行工具也是如此,任何额外信息都需要内核补丁。

为了解决这个问题,drgn 工具常用于在不更改内核的情况下挖掘内核数据。然而,drgn 的主要缺点是性能,因为它无法在内核内部进行指针追踪。此外,drgn 无法验证指针值,如果指针在内核内部变得无效,它可能会读取到无效数据。

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

BPF 迭代器的工作原理

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

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

BPF 程序总是在用户空间进程的请求下加载到内核中。用户空间进程通过打开并根据需要初始化程序骨架,然后调用系统调用让内核验证并加载 BPF 程序来加载它。

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

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

如何使用 BPF 迭代器

BPF 自测(selftests)是说明如何使用迭代器的一个很好的资源。在本节中,我们将介绍一个 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

以下是 bpf_iter__task_filevmlinux.h 中的定义。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() 的数据。对于格式化数据,在将 BPF 迭代器固定到 bpffs 挂载点后,您可以使用 cat <path> 打印结果,类似于 cat /proc/net/netlink。稍后,使用 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 迭代器子系统。

以下是 struct bpf_iter_reg 中每个字段的详细说明。

字段

描述

target

指定 BPF 迭代器的名称。例如:bpf_map, bpf_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_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.tidlinfo.task.pid 不同仅当线程具有单独的文件描述符表时。在大多数情况下,所有进程线程共享一个文件描述符表。

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

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

如果 tidpid 都为零,则从此 struct 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 匹配的任务。