Linux eBPF

最后更新: 2026-06-09 02:54:06 +0800 +0800
tags: Bpf | Kernel | Linux
原文以英文撰写。您正在阅读AI翻译版本。

eBPF 是 Linux 内核中的一种经过验证的执行环境。应用程序加载程序和映射,将程序附加到内核定义的钩子,并通过文件描述符交换数据。网络处理仍然是重要用途,但跟踪、安全策略和性能分析不再依赖数据包捕获。

从数据包过滤器到 Linux 套接字过滤器 #

这条设计谱系始于 DEC 为 ULTRIX 和 DIGITAL UNIX 等系统提供的数据包过滤器。进程打开数据包过滤设备,选择接口,通过 ioctl() 安装 enfilter 指令序列,然后读取被接受的链路层数据包。过滤器使用栈式机器,并按优先级排列多个消费者。

Steven McCanne 和 Van Jacobson 设计 Berkeley Packet Filter(BPF),以降低数据包复制和解释的成本。他们 1993 年的论文描述了两项至今仍可辨认的变化:

  • 基于寄存器的虚拟机决定是否接受数据包,以及应复制多少字节;
  • 每个打开的 /dev/bpf 实例都有自己的过滤器和数据包缓冲区,因此数据包捕获表示为设备实例,而不是 DEC 的优先级链。

经典 BPF(现称 cBPF)具有 32 位累加器 A、索引寄存器 X、16 个暂存字、数据包加载指令、条件跳转、算术运算以及返回值。Linux 将这种指令格式用于 Linux Socket Filtering(LSF),但会将过滤器直接附加到套接字,而不是要求使用 /dev/bpf。

c
struct sock_filter code[] = {
    BPF_STMT(BPF_LD  | BPF_B | BPF_ABS, 9), /* IPv4 protocol byte */
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, IPPROTO_TCP, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, 0xffff),
    BPF_STMT(BPF_RET | BPF_K, 0),
};
struct sock_fprog program = {
    .len = sizeof(code) / sizeof(code[0]),
    .filter = code,
};

setsockopt(fd, SOL_SOCKET, SO_ATTACH_FILTER,
           &program, sizeof(program));

该示例假定套接字数据以 IPv4 头部开始,因此第 9 个字节是协议字段。数据包布局取决于套接字族和钩子。对于套接字过滤器,返回值表示要保留的数据包字节数;零表示拒绝数据包。内核会在安装指令序列前对其进行检查。SO_LOCK_FILTER 可以防止过滤器被替换或移除,这使进程能够先附加过滤器,再放弃权限。libpcap 和 tcpdump 会将高级捕获表达式编译为这种指令模型。

eBPF 执行模型 #

Linux 的现代 BPF 系统调用接口在 Linux 3.18 中引入。eBPF 使用定宽 64 位指令和 11 个 64 位寄存器:

  • R0 携带函数或程序的返回值。
  • R1 到 R5 传递参数,并由调用者保存。
  • R6 到 R9 由被调用者保存。
  • R10 是每次调用堆栈的只读帧指针。

进入程序时,R1 指向特定于程序类型的上下文,例如 xdp_md、__sk_buff 或跟踪上下文。程序返回特定于钩子的动作:XDP 程序可以放行、丢弃、重定向或发送数据包,而跟踪程序通常返回零。

解释器可以执行已接受的字节码。当架构启用 BPF JIT 时,内核会将程序转换为本机指令,并可以根据 sysctl 策略加固生成的映像。JIT 编译会改变执行成本,但不会改变验证器要求。

验证定义安全边界 #

程序附加前,验证器会符号执行其所有可能路径。它跟踪寄存器和栈槽类型、标量范围、指针来源、对齐方式、初始化状态、引用所有权和边界。因此,只有当控制流证明所请求的字节位于 data_end 之前时,类似数据包读取的代码才会被接受。

验证会强制执行以下属性:

  • 不使用未初始化的寄存器或栈数据;
  • 不将标量任意转换为内核指针;
  • 仅使用程序类型批准的上下文字段和辅助函数;
  • 内存访问有界,指针算术有效;
  • 对受跟踪引用的获取和释放保持平衡;
  • 执行有界,包括能够证明终止的循环。

接受程序证明的是其符合验证器模型,而不是其功能正确性。有效程序仍可能丢弃每个数据包、聚合错误的键,或增加过高的开销。当路径探索无法建立所需不变量时,复杂度限制也可能导致逻辑上安全的源代码被拒绝。

映射和辅助函数 #

映射是具有特定类型的键值存储和并发语义的内核对象。哈希表、数组、每 CPU 映射、环形缓冲区、LRU 映射、最长前缀匹配树、程序数组和存储映射服务于不同的通信模式。用户空间通过 bpf() 命令创建和操作映射,通常由 libbpf 封装:

c
int map_fd = bpf_map_create(BPF_MAP_TYPE_HASH, "counts",
                            sizeof(__u32), sizeof(__u64),
                            1024, NULL);
bpf_map_update_elem(map_fd, &key, &value, BPF_ANY);

程序使用获准的辅助函数或 BPF kfunc 来查找映射元素、读取时间和进程标识、重定向数据包、发出环形缓冲区事件,或访问特定于钩子的数据。辅助函数的可用性和参数类型取决于程序类型。映射查找可能返回 NULL;验证器要求在解引用前进行 null 检查。

映射是共享状态,因此竞争条件仍然是应用程序需要处理的问题。每 CPU 映射可以避免计数器的跨 CPU 更新。只有在文档规定的约束允许时,才能使用原子操作或 struct bpf_spin_lock;用户空间必须显式聚合每 CPU 的值。

程序类型和附加点 #

程序类型选择上下文、返回约定、验证器操作以及允许使用的辅助函数。附加类型进一步标识钩子。常见组合包括:

领域程序和附加示例
早期数据包路径网络设备上的 XDP
流量控制TC ingress/egress 上的 SCHED_CLS 或 SCHED_ACT
套接字套接字过滤器、reuseport 选择、套接字查找、sockops、cgroup 套接字钩子
跟踪tracepoint、raw tracepoint、kprobe/kretprobe、fentry/fexit、perf 事件
安全LSM 钩子和 cgroup 策略钩子

钩子选择同时决定语义和成本。在受支持的驱动程序上,XDP 会在普通网络栈的套接字缓冲区分配之前运行,而 TC 程序可以看到 skb。fentry/fexit 使用由 BTF 描述的函数边界,而 kprobe 以指令地址为目标,因此更容易受到实现变化的影响。

权限、生命周期和所有权 #

除非内核和系统策略允许所请求的操作,否则加载需要特权。现代内核可以通过 CAP_BPF、CAP_PERFMON 和 CAP_NET_ADMIN 分离权限;较旧的路径和某些操作仍使用 CAP_SYS_ADMIN。发行版内核可以通过 kernel.unprivileged_bpf_disabled 禁用非特权加载,而 lockdown、LSM、命名空间、令牌或委托策略还可以施加额外限制。应检查正在运行的内核,不要将某一种 capability 配置视为通用方案。

程序、映射、BTF 对象和链接都是通过文件描述符暴露的引用计数内核对象。关闭最后一个引用会销毁对象。在 BPF 文件系统(bpffs)中固定对象会为其提供持久路径和引用:

bash
mount -t bpf bpf /sys/fs/bpf
bpftool prog show
bpftool map show
bpftool link show

bpf_link 将附加关系独立于原始程序 FD 表示出来,并为受支持的钩子提供更安全的生命周期:关闭链接会解除程序附加,除非其他引用或固定项仍保持链接存活。在当前引导周期内,固定项会在创建进程退出后继续存在。重启后,启动代码必须重新创建内核对象;无法重新打开上一个内核实例中的对象。

不依赖无限访问的可观测性 #

跟踪程序可以测量延迟、统计事件、采样调用栈、关联调度器活动,并通过环形缓冲区导出结构化记录。CO-RE 重定位使用 BTF,使字段访问能够适配兼容的内核类型布局。运维工具仍应考虑以下问题:

  • 消费者无法排空缓冲区时的事件丢失;
  • 高频钩子上的单事件开销;
  • 缺少适用 BTF 的内核中的符号和类型变化;
  • 暴露内核或进程数据带来的隐私与安全影响。

使用 bpftool prog show、bpftool prog dump xlated、验证器日志和程序运行时统计信息,检查已加载的内容。可观测性代码运行在生产内核路径中,因此设计必须包含有界开销和明确的解除附加方案。

参考资料 #

评论

在 GitHub 上阅读或评论(发表评论需要登录)