导航

eBPF

发布时间:5 个月前 更新时间:a month ago
Linux bpf

概述

它有生命周期(lifecycle)、变量(Map)、循环(Loop)、计时器(Timer)。

接下来我将向你推荐几个有用的站点,用以辅助您学习和编写 BPF。

该篇文章将从 0 到 1,完整的向您介绍、指导如何使用 eBPF,以及eBPF的相关概念。

eBPF 简介

在正式开始学习 eBPF 前,你应该先弄懂下面几个问题:

  1. eBPF 的由来
  2. eBPF 是什么
  3. eBPF 可以做什么

eBPF 全称 Extended Berkeley Packet Filter,它的前身是 BPF。但是在现在大多数的地方 BPF 都代表 eBPF,而最初的 BPF 则使用 cBPF 来进行区分。eBPF 对于 Linux 内核来说是一个革命性的技术:

  1. 它能够在不进行冷重启或重编译内核的情况下,向Kernel中追加功能模块,以实现对内核功能的扩展。
  2. 相比于通过内核模块的方式去为内核扩展新的功能而言,它更加的安全、可控。
  3. 相较于其它实施观测功能的方案而言,操作系统内核一直是实施网络和安全的可观测性的最理想测场所。
  4. 提供了对操作系统上基本所有事件的观测能力。

一个不太标准的比喻是,将 eBPF 理解为现代浏览器中的 JS,它能够帮助你更好的去理解 eBPF,但是切记不要将它和 eBPF 给弄混淆了。eBPF 提供的能力和 JS 一样,它们都能够操作 “内核”,让它按照我们指定的规则来运行。就像JS出现时一样,人们发现原来网页还能这样,还能与用户交互、呈现不同的内容、动态加载数据,可以说是JS的出现造就了今天Web的繁荣,SaaS也是如此。

更多详细请查看 eBPF 官方文档

基本概念

「引用」:https://eunomia.dev/tutorials/28-detach/

eBPF 程序是 事件驱动 的,只有当一个特定的事件出现时才运行。例如,当一个数据包抵达你的 NIC 或一个应用触发了一个 Hook 点时,eBPF 程序才会运行,所以无需担心它会持续占用CPU。

编写 eBPF 程序你需要具备如下特性的基本概念:

  • Maps
  • Pinning
  • Loops
  • Timers
  • Helper Func

如果你对这些内容不清楚的话,你很难去理解 BPF 并编写一个 BPF 程序。

辅助函数

虽然eBPF程序运行在内核态中,但是为了确保操作系统内核的稳定性,eBPF不能随意调用任意内核函数,访问任意内存地址等。为了弥补这些限制,同时赋予eBPF程序与内核交互执行特定任务的能力。eBPF维护了一组预定义的、由内核核心代码提供的、eBPF程序可以调用的C函数。这些函数构成了eBPF程序与内核之间稳定且受控的接口(ABI,Application Binary Interface)。eBPF程序通过特定的指令调用这些辅助函数,以执行那些自身权限无法完成的操作。

eBPF是通过特权用户(root)去进行加载的,如果我们的用户态和内核态应用程序之间的交互存在漏洞,那么恶意利用者就可以使用该漏洞对我们的操作系统做任意的事情,所以每一个由eBPF发起的系统调用都必须是清晰的、安全的。

Maps 概述

序言:mapsmap 在文中是两种意思,Maps 一般表示 eBPF 中所有类型的 Map 集合,不特指某个类型的 Map。而 Map 是当明确说明了 Map 的类型时,特指该 Map。

Maps 是一个使运行在内核中的 eBPF 程序能够和用户态的前端程序进行一个数据交互的数据传输方式,它本身设计并不是为了保存数据,而只是为了方便内核中的eBPF程序能够和用户态的加载程序进行高效数据传输的一种方式。

Maps的其中一个能力是可以持久化Map,需要注意的是,持久化的只是这个map通道,而并非数据本身。

持久化Map时一定注意,map虚拟文件的权限控制

Maps也是多个 eBPF 程序之间进行交互的媒介,当我们有多个 eBPF 程序且这些eBPF程序不是同一个用户态程序加载,它们之间涉及到数据交互时,Maps的持久化就产生了作用,我们在创建一个map时可以指定该map的持久化路径,若map存在,则不进行创建,会直接建立map文件与当前进程的FD映射。

当我们需要在同一个用户态加载器中,不同eBPF程序之间的参数的传递时,由于 eBPF Program 的特性,我们不能够定义自己的参数列表,只能够使用Probe预先定义的形参列表来接收数据。

在一般情况下,多个函数(在eBPF中,一个由eBPF宏定义的函数,称为一个eBPF程序)进行协作时,我们会使用全局变量,共享内存空间等解决方案,但 eBPF VM 规定,主机上的所有 eBPF 程序都只能够使用 512 Byte 的堆栈空间。因为内核空间的堆栈是有限的,并且内核空间堆栈不能够侵入用户空间的堆栈,所以内核对所有在内核态运行的代码(包括内核模块和 eBPF 程序)所能使用的堆栈空间都施加了严格的限制。对于像 eBPF 程序这样的特殊执行环境,这个限制通常会更加明确和严格。不同的内核组件或执行环境可能具有不同的栈空间限制,但总体原则是为了保障系统的稳定性和安全性,防止栈溢出等问题。

所以 eBPF 提供了一个解决方案,那就是 MapsMaps 是一类 Map 的集合。Map 有多种类型,就像每一个编程语言都用不同的数据类型一样,每一种 Map都拥有它自己的特性/方法以及应用场景。在现阶段,我们无需考虑 Map 的底层实现,但是您可以知道的是Maps通过vmmap技术将内核空间的内存空间映射到用户态应用程序中,大大的减少了上下文切换进行数据拷贝的开销。

你可以通过点击该锚点或者点击目录去查看下文中更加详细的说明。

Program

在 eBPF 中,所有在 C 以及其它编程语言中被称为函数(function)的东西,在 eBPF 运行时,也就是 eBPF 的VM中,都将它组织为 program 单元来进行维护。就像在操作系统中的程序和进程的关系一样,eBPF将编程语言中的函数映射为了一个程序,因为 eBPF 虚拟机的缘故,每一个程序被加载后都将作为一个进程来进行维护管理。

在 eBPF 文档中,提到了三个名词概念,分别是:

  1. function
  2. program
  3. sub-program(bpf to bpf function,bpf2bpf)

接下来我们捋一捋它们之间的关系,并学习如何区分它们。

function 在本节开头已经叙述过,这里我们就不再描述。在 eBPF 中,我们编写的所有函数都称为 program,但是这些 programs 还是有所不同,大致可以分为两类:

  1. 被Attach的program
  2. 不被Attach的program

Attach 通过bpf系统调用附着到某一个Hook点上,在某些SDK中,也称为Link。

首先,被Attach的program,也就是我们的核心函数,它负责监控、跟踪、修改内核的行为,它能够被附加到内核允许附加的 Hook 点上。但是它只有一个形参,该形参我们一般命名为 ctx,顾名思义是当某个 Hook 被触发(Trigger)时,传递给我们的程序的上下文(context, ctx)。该上下文信息是以 event 事件结构体的方式存储的,每种 Hook 的上下文都有它自己的 事件结构体,该事件结构体我们可以通过dump当前内核的 vmlinux.h 文件去获取。

其次,也就是不被 Attach 的 program,该Program主要用以实现主Hook函数的一些功能:

  • BPF-to-BPF程序:我们一般通过__always__inline__ static关键词进行声明的函数,该函数主要用以拆分主Hook函数的逻辑,以便更好的维护和团队协作。
  • Tail Program:尾调用程序,该程序一般不会被附着到某一个Hook点上,但是Tail程序的声明要求与发起Tail调用的程序一致,一般情况下发起Tail调用的程序为我们的主Hook程序。因为与主Hook程序同样的声明的缘故,该程序也可以被附加到Hook点上,进行附加时切忌注意程序名。

需要注意的是,当我们同时使用 BPF-to-BPF 和 Tail Program时,我们的主Tail程序的堆栈空间会被消减到256byte。你可以点击这里并查看Limitations节

此外,BPF-to-BPF程序的参数可以为 0 - 5 个,并且参数的类型是自定义的。但是Tail Program的参数要求与发起tail调用的程序相同,也就是1个被要求的形参。

开发环境部署

在刚开始学习 eBPF 时,开发环境的搭建让我不知所措。搜索到的许多文章都在指导我如何编译安装,起初我也照着一步步操作。但是,你是否认为这些文章向你介绍了如何通过编译安装来启用相关基础设施的高级功能?很遗憾,并没有,因为每个基础设施的编译安装都需要耗费大量时间。在此期间我查阅了相关权威文档,发现像Debian、Ubuntu、Fedora等发行版都提供了通过包管理方式进行安装的选项,这些通过包管理安装的基础设施在功能上与编译安装的版本相差无几,完全满足我们的学习使用。

你或许又会说,编译安装的基础设施总是最新的,我们可以享受到最新版本带来的速度和功能性提升。再次遗憾地告诉你,仅仅基础设施的支持是完全不够的,你需要注意,你是在面向你的 Linux 内核组件进行编程,你的基础设施提供的功能需要 Linux 内核组件的支持。然而,每个发行版的软件源都会存储当前发行版内核版本最稳定、最适配的软件。所以,在你希望研究新功能的场景之前,我仍然建议你完全没有必要进行编译安装。

许多高级编程语言都拥有关于 eBPF 的库,其中最常用的有:

  • libbpf:原生的、权威的由 eBPF 贡献者维护的基于 C 语言的库。
  • BCC:Python 开发的 BPF 库,重度依赖 BCC 指定的运行环境,但用于开发本地小工具还是不错的。
  • cilium/go:提供了 Link 和 Load 功能,它可以将编写的C内核态源码,转换为Golang,输出一个类似于框架的东西。
  • bpfgo:通过 Go 语言实现的,像 libbpf 一样的API,能够让熟悉 Go 但是不熟悉C的人,更简单的编写 bpf 程序。

现在许多的库,基本上都是对 libbpf库的一个语言层面的重写,像 cilium 的库,则是提供了一个框架的功能,它将我们在编写时,会重复编写的一些方法,通过 Go 提供的generate,将它转换为 Go 语言对象、方法。

sdasd bcc libbpf cilium ebpf
Official Site Guide Overview Doc
优势 1. 开发活跃 2. 示例丰富 3. 敏捷开发 1. Linux 官方提供,可靠 2. 支持 CO-RE 1. 纯 Go 库 (不需要额外的组件) 2. 开发活跃 3. 部分支持 CO-RE
缺点 需要在目标机器编译,不支持交叉编译(需要 BCC 编译环境支撑) eBPF 的原生 C 封装库,主要提供了 C 加载和管理 eBPF 对象的功能 对 CO-RE 支持不足,因为 CO-RE 需要用到 BTF,但它没有提供相关支持

其中大多数 eBPF 库实现的功能都是对 eBPF 对象的管理,它们提供了丰富的 API,使得开发者可以将精力专注于 eBPF 程序的开发逻辑上。

本教程演示的是通过cilium/ebpf库进行的,所以这里之演示该库开发环境搭建的一个教程共。我建议大家使用内核版本至少为 5.15 的发行版,请至少选择 ubuntu 23.04 LTS,这里我使用最新版本的 ubuntu 24.04 LTS

alt text

当你想要实现某个功能时,你应该先去查询eBPF Docs,许多新特性都会标注版本信息。你可以通过阅读这篇博客,获得如何查询特性支持的技能。

需要注意的是,上文中提到了 Cilium/eBPFCO-RE 的支持较差,所以当我们使用该库时不必太过在意发行版的内核是否支持BTF,如果需要编写完全可移植的 eBPF 程序,那么可以使用 libbpf 库+ btfhub 既可编写全可移植的 eBPF 程序。

开发有几个必要的东西:

  1. bpftools:用以调试 eBPF 程序、查看Hook路径、生成 vmlinux.h 文件等功能,你可以通过查看bpftools文档获取详细的帮助信息。
  2. clang&llvm:这两个其实是一起的,clang 是 LLVM 设施的前端部分。
  3. libbpf-dev:我们编写eBPF的程序时,需要用到 helper函数。
  4. linux-header:Linux内核头部文件,其实也没啥需要的,用 vmlinux.h 一样的。

使用下面的命令进行安装:

sudo apt install -y git build-essential libelf-dev clang llvm bpfcc-tools python3-bpfcc linux-headers-$(uname -r) linux-tools-common strace jq bpftrace libbpf-dev 
  • git:版本控制工具。
  • build-essential:构建辅助工具列表,包含 libc-devgccmakiedpkg-dev等。
  • libelf-dev:提供了一个在高级编程语言上读写 ELF 文件的库。
  • clang:LLVM 的前端编译器,用以生成编译过程中的中间代码。
  • llvm:LLVM 基础设施组,用以将中间代码编译为指定的构建目标。
  • linux-header-*:该Linux内核版本的所有头文件,用以使用Linux内置函数。
  • linux-tools-common:bpftool 软件源包。
  • strace:跟踪系统调用工具。
  • jq:用以配合 bptftool 工具使用。
  • bpftrace:查看内核上所有的Hook点。

bpftool 可以使用编译安装也可以使用包管理工具「如果你上用上文的CLI那么你已经安装了bpftool」安装。

安装Golang

cd ~ && wget https://go.dev/dl/go1.24.3.linux-amd64.tar.gz 
mkdir -p .env && tar -zxf go1.24.3.linux-amd64.tar.gz -C .env/
SHELL_RC="${HOME}/.$(echo $SHELL | awk -F'/' '{print $NF}')rc"
cat >> $SHELL_RC  << EOF
export GOROOT=${HOME}/.env/go
export GOBIN=\${GOROOT}/bin
export PATH=\$PATH:\$GOBIN
EOF
source $SHELL_RC
go env -w GO111MODULE=on  GOPROXY=https://goproxy.cn,direct

上面的命令可以直接复制执行,不过当您阅读这篇博客的时间与我发布的日期较远,那么请修改相关内容。

  • GOROOT:存放 Go 语言标准库、编译器、标准工具等。
  • GOPATH:存放我们在开发过程中,下载的第三方库,不建议将该变量的路径设置与GOROOT一样,会在升级 Go 的同时导致第三方库的内容被删除和覆盖。
  • GOBIN:指向了 $GOROOT/bin,该变量有两个功能,因为我们有时候下载第三方库时,库会提供命令行工具,这些命令行工具将会放置在该变量指定处。
  • GOPROXY:存储了使用 getdownload 子命令下载时,使用的代理url。
  • GO111MODULE:启动模块化包管理模式,因为传统的 GOPATH 管理方式,只能在 $GOPATH/src 中进行开发。

基础知识

  1. 开发工具链(Clang 和 LLVM):可以将我们通过C或其它编程语言编写的源代码编译为 eBPF 字节码。BCC 也是一个编译工具,它可以将你编写 eBPF 程序及时编译为 eBPF 字节码。同时,也作为 eBPF 对象生命周期的管理工具。
  2. eBPF verifier:主要的功能是验证 eBPF 程序的安全性和正确性,包括访问控制检查、边界检查、循环检查和类型检查等,以确保 eBPF 程序不会引起系统崩溃和安全漏洞。
  3. eBPF JIT Compiler:eBPF 即时编译器,将 eBPF 字节码编译为本机机器码,以提高 eBPF 程序的执行效率和性能。
  4. eBPF map:eBPF 映射,是一种数据结构,用于实现之Linux内核中的高效数据交换。它允许用户空间程序与内核空间程序之间共享数据,实现灵活的数据传输处理。

alt text

eBPF 的挂载与执行

eBPF 的挂载与执行是将 eBPF 程序挂载在指定的内核位置,比如带有 SEC(kprobe/tcp_sendmsg)标记的 eBPF 程序将被挂载在内核的 tcp_sendmsg() 函数的位置。内核运行到此函数时会先执行我们挂载的 eBPF 程序。

常见的 eBPF 程序类型

程序类型 ELF段名 可挂载位置
BPF_PROG_TYPE_KPROBE kprobekretprobeuprobeuretprobeusdt 任意的内核和用户函数
BPF_PROG_TYPE_TRACEPOINT tptracepoint 任意的内核静态探测点
BPF_PROG_TYPE_XDP xdp 网卡驱动收包点
BPF_PROG_TYPE_RAW_TRACE_POINT raw_tpraw_tracepoint 任意的内核静态探测点
BPF_PROG_TYPE_TRACING fmod_retfentryfexititertp_btf fmod_ret fentryfexit 可挂载在任意的内核函数;iter可挂载在内核固定位置;tp_brf 可挂载在任意的内核静态探测点

eBPF 指令架构

理解 eBPF 的指令架构,能够使你更容易的理解 eBPF VM 的运行机制。

与 cBPF 相比,eBPF 拥有更多的寄存器、更复杂的指令类型、以及更强的编程功能,这为在内核空间执行复杂的数据处理和监控任务提供了可能。

对比维度 cBPF eBPF
内核版本 Linux 2.1.75(1997年) Linux 3.18(2014年)
寄存器数目 2个:A 和 X 11个:r0~r10
寄存器宽度 32位 64 位
存储 16 个内存位 512 字节堆栈,未限制大小的 map 存储
内核函数调用 不能调用内核函数 只能调用特定函数,包括 helper(辅助函数)、kfunc(内核函数) 等
目标事件 数据包、seccomp-BPF 数据包、内核函数、用户函数、跟踪点等

指令集

寄存器和调用规约

  1. R0:保存 函数调用的返回值,以及 eBPF 程序退出值
  2. R1~R5:函数调用入参
  3. R6~R9:调用者保存寄存器
  4. R10:只读的,栈帧寄存器

指令编码

eBPF 有两种指令:

  1. 基础指令编码
  2. 宽指令编码
字段 操作码 目的寄存器 源寄存器 偏移 立即数
长度 8 位 4 位 4 位 16 位 32 位
  • 操作码:指令的具体操作,如BPF_ADDBPF_LD
  • 目的寄存器:R0~R9 中的一个
  • 源寄存器:R0~R9 中的一个
  • 偏移:16 位,主要用于进行指针类型的数学运算,可记为 off16
  • 立即数:32位有符号的立即数,可计为 imm32

Linux内核使用 struct bpf_insn 结构体标识eBPF的指令格式:

struct bpf_insn {
    __u8 code;      /* 操作码 */
    __u8 dst_reg:4; /* 目的寄存器 */
    __u8 src_reg:4; /* 源寄存器  */
    __u16 off;      /* 偏移 */
    __s32 imm;      /* 立即数 */
}

操作码格式:

操作码 编码 标识位 指令类型
长度 | 4 位(MSB) 1 位(LSB) 3 位(LSB)
  • 编码: 细分的操作码,比如运算指令 BPF_ALU 下面有相加(BPF_ADD)、相减(BPF_SUB)等细分指令。
  • 标识位:包含 BPF_KBPF_X
    • BPF_K:32位的立即数作为源操作数
    • BPF_X:使用源寄存器作为源操作数
  • 指令类型:包含三大类指令
    • 加载与存储指令
    • 运算指令
    • 跳转指令
cBPF eBPF
BPF_LD BPF_LD 0x00

BTF(BPF Type Format)

BTF(BPF 类型格式) 是一个元数据格式,被设计用来编码表示 BPF programs 和 Maps 信息。它侧重于描述数据类型、定义子例程的函数信息。这些调试信息用于各种目的, 提供调试信息,用于 Maps 可视化、增强 BPF 程序的功能签名、生成带注释的源代码/JIT 代码/验证器日志。

BTF规范主要分为两个部分:

  • BTF Kernel API:定义用户空间和内核之间的接口,内核负责验证 BTF 信息。
  • BTF ELF File Format:建立用户空间 ELF 文件和 libbpf 加载器之间的约定。

BTF 解决了移植问题,通过Linux内核的 BTF 模块去执行该格式,我们无需为特定的系统生成一个 ELF,只需要通过 Clang 生成一个 BTF 格式的文件即可在支持 BTF 的系统中运行我们的 BPF 程序。

在学习中,我们无需过度关注 BTF,我们无需像学习 ELF 一样去学习它,只有当我们能够使用常见的库去开发自己的 eBPF 程序后,逐渐晋升到大型、复杂调用流程的 eBPF 程序时,我们才需要深入的去学习 BTF。

eBPF 开发框架

eBPF 程序包含两部分:

  1. 内核态 eBPF 程序:是直接运行在内核中的,通常负责数据包处理、系统调用审计、性能追踪等核心功能
  2. 用户态管理程序:负责加载 eBPF 字节码程序到内核、传递参数、获取结果以及管理 eBPF 程序的生命周期。

libbpf

libbpf 是一个应用非常广泛的C语言 eBPF库,伴随内核版本分发,用于辅助 eBPF 程序的加载和运行。它提供了用于与 eBPF 系统交互的一组C语言API,使开发者能够更轻松地编写用户态程序来加载和管理 eBPF 程序。这些用户态程序通常用于分析、监控或优化系统性能。

使用 libbpf 有以下优势:

  • 简化了 eBPF 程序的加载、更新和运行过程
  • 提供了一组易于使用的 API,使开发者能够专注于编写核心逻辑、而不是处理底层细节。
  • 能够确保与内核中的 eBPF 子系统兼容,降低维护成本。

集合 libbpf 和 BTF(eBPF Type Format),eBPF 程序可以在各种不同版本的内核上运行,而无须为每个内核版本单独编译。这极大地提高了 eBPF 生态系统的可移植性和兼容性,降低了开发和维护的难度。

libbpf 开发eBPF程序

#include "vmlinux.h"
#include <bpf/bpf_headers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

Pinning(固定对象)

pinning 是一个技术,使我们能够在 BPF 文件系统下创建一个指向 BPF 对象的伪文件。在上文中,我们说过了所有的 eBPF 对象都是有一个引用统计的,这意味着如果指向一个 eBPF 对象的全部引用都消失了,内核就会 unload/kill/free 这个 BPF 对象。

pin 可以由任何具有BPF对象文件描述符的进程创建(BPF 对象都由一个 fd 来标识),但是要将其与BPF文件系统中的有效路径一起传递到BPF_OBJ_PIN sycall命令中,BPF文件系统通常挂载在/sys/fs/bpf

如果你的 Linux 发行版没有自动挂载 BPF 文件系统的话,你可以作为 root 用户执行 mout -t bpf bpffs /sys/fs/bpf 手动去挂载它。

进程能够调用 BPF_OBJ_GET 系统调用去获取BPF对象的 fd,并将其传递给 pin 的有效路径。

Pins 一般作为一种简单的方式在两个进程或应用之间去共享或者传输 BPF 对象。具有短运行生命周期的命令行工具可以使用它们去 BPF 对象进行操作。长运行周期的 daemon 进程能够使用 pins 去确保资源在重启时不会丢失。像iproute2/tc这样的工具可以代表用户加载一个程序,然后另一个程序可以在之后修改Maps。

Pins 可以通过 rm 命令行工具和unlink系统掉哟给去移除。Pins 在系统重启后就会丢失。

大多数的 loader library 都会提供API去pinning和打开一个pins资源。这通常是一个需要显式执行的操作。然而,对于BTF Style Maps,有一个名为pinning的字段,它被设置为宏值LIBBPF_PIN_BY_NAME/1,然后大多数加载器库将在默认情况下尝试固定映射(有时给定一个目录的路径)。如果一个pin已经存在,库将打开该pin,而不是创建一个新的map。如果不存在pin,库将创建一个新的map并将其pinning,使用map的名称作为文件名(/sys/fs/bpf/map_name)。

Maps

当内核和用户空间访问相同的 maps 时,他们将需要对这个在内存中的 key 和 value 的结构体有一个通用的理解。像结构体的大小、字段的偏移量、字段大小,这些都是访问一个 maps 需要条件。

如果这两个程序都使用C编写的话,他们共享一个 header 文件,则可以很方便的去使用。否则,用户空间的语言和内核空间的数据结构必须逐字节的理解key/value结构。

用户态程序和内核态程序都使用 FD 去标识一个 Maps 数据,每一个 Maps 都有一个引用计数器,当该计数器为 0 时,操作系统会释放该 Maps。

Maps 有很多种类型,每一种类型的工作方式都略有不同,就像不同的数据

Map 分类

在 eBPF 的官方文档中,根据使用场景不同将 Map 分为了几大类:

  • 通用场景
  • 嵌套Map
  • 流场景
  • 包重定向场景
  • Tail 调用
  • 对象附加存储
  • cGroup
  • 堆栈跟踪
  • Struct 操作
  • CPU Map

这里不详细讲每一种类型,如果放在这里来说,那么内容太多了,可以通过上面的官方文档地址自行去查阅。当你在某个场景下需要进行数据交互时,可以根据使用场景选择何时的 Map 类型。结构一样。

定义一个 Maps

有两种方式去定义一个 Maps :

  1. Legacy Maps
  2. BTF Style Maps

BTF 你可以在目录中点击查看

Legacy

传统的方式定义一个 maps 是使用的 struct bpf_map_def 结构体类型,该结构体来自 libbpf 或内核头文件中。结构体原型如下:

struct bpf_map_def {
    unsigned int type;       // Map 类型(如数组、哈希表)
    unsigned int key_size;   // 键的大小
    unsigned int value_size; // 值的大小
    unsigned int max_entries;// 最大容量
};

例如我们声明一个 BPF_MAP_TYPE_HASH

struct bpf_map_def SEC("maps") my_map = {
    .type = BPF_MAP_TYPE_HASH,
    .key_size = sizeof(int),
    .value_size = sizeof(long),
    .max_entries = 100,
};

它有如下的缺点:

  • 类型信息丢失:编译器只知道 keyvalue 是二进制数据,不知道具体什么类型比如(int还是struct)。
  • 难维护:如果修改了 keyvalue 的类型,必须手动同步所有相关代码,否则容易出错。

综上所述,传统的 Maps 定义方法被 BTF Style 所替代,这里说明是因为,还有许多旧 BPF 程序仍在使用上述方式进行声明。

BTF Style

上文中,已经概述了 BTF ,这里不再描述。这里只讲解了 BTF Style 去声明 maps,你可以点击该链接去查看实现。

libbpf 中,通过 SEC() 宏去告知编译器,应该将该对象放于哪一个section中,以便加载器更好的去识别它们。

struct my_value { int x, y, z; };

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __type(key, int);
    __type(value, struct my_value); 
    __uint(max_entries, 16);
} icmpcnt SEC(".maps");

__uint__type__array__ulong这一类宏通常用于使定义更易阅读。

#define __uint(name, val) int (*name)[val]
#define __type(name, val) typeof(val) *name
#define __array(name, val) typeof(val) *name[]
#define __ulong(name, val) enum { ___bpf_concat(__unique_value, __COUNTER__) = val } name

在上面宏的name部分,指明了被创建结构体的字段名。并不是任意的字段名都能被 libbpf 和 兼容库 识别。只有以下的字段可以被使用:

  • type (__uint) - enum:type 字段,用以表示该 maps 的数据存储方式。可以查看官网的 map types index 查看所有的可选项。
  • max_entries(__uint) - int :该字段指明了最大的条目数量,也就是我们通过 key 可以去查找的最大数量。
  • map_flags(__uint) - 位域标识符:可以查看 map load syscall command,去查看有效标志位。
  • numa_node(__uint):放置 Map 到 NUMA 节点的节点ID。
  • key_size(__uint) - 键的大小(单位为 byte)。该字段与 key 字段互斥,即只能有一个存在。使用该字段及表明索引 map 的数据时,将使用 int 类型的下标来进行。
  • key(__type) :该字段必须使用 __type 宏,__type宏会根据传入的 name 即为keyval来生成字段,该方式可以使我们的map索引能够为任意原始类型。与上面的key_size一样,互斥。
  • value_size(__uint) - 值的大小(单位为 byte):这个字段与valuevalues字段互斥。
  • value(__type):与上面的 key 字段一样,可以使我们的map存储任意类型的值,并且拥有更好的调试体验。与value_sizevalues互斥。
  • values(__array) :该字段的使用方式查看下面的静态值节,它与 value_sizevalue互斥。
  • pinning(__uint):是否持久化map的动作。
    • LIBBPF_PIN_BY_NAME:通过map的名字自动进行 pinning
    • LIBBPF_PIN_NONE:不自动进行 pinning
  • map_extra(__uint):额外的字段。目前只有布隆过滤器使用,它使用了最低的 4 bit来指示布隆过滤器中使用的哈希量。

通常定义一个 map,只需要 typekey/key_sizevalue/values/value_sizemax_enteries 字段。

静态值

当我们使用 values map 字段时,有一个语法,它是唯一使用 __array 宏并且需要我们通过一个值去初始化我们的map常量。它的目的是在加载期间填充Map的内容,而不必通过用户空间应用程序手动完成。这对于使用ip、tc或bpftool加载程序的用户来说尤其方便。

__array形参的val部分应该包含描述单个数组元素的类型。我们想要预填充的值应该放到结构初始化的值部分。

下面的案例展示了如何去预填充一个 map-in-map

struct inner_map {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, INNER_MAX_ENTRIES);
    __type(key, __u32);
    __type(value, __u32);
} inner_map SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY_OF_MAPS);
    __uint(max_entries, MAX_ENTRIES);
    __type(key, __u32);
    __type(value, __u32);
    __array(values, struct {
        __uint(type, BPF_MAP_TYPE_ARRAY);
        __uint(max_entries, INNER_MAX_ENTRIES);
        __type(key, __u32);
        __type(value, __u32);
    });
} m_array_of_maps SEC(".maps") = {
    .values = { (void *)&inner_map, 0, 0, 0, 0, 0, 0, 0, 0 },
};

另一个常见的用法是用以预先填充一个 tail-call map

struct {
    __uint(type, BPF_MAP_TYPE_PROG_ARRAY);
    __uint(max_entries, 2);
    __uint(key_size, sizeof(__u32));
    __array(values, int (void *));
} prog_array_init SEC(".maps") = {
    .values = {
        [1] = (void *)&tailcall_1,
    },
};

BPF 对象的生命周期

生命周期 -> 涉及到eBPF对象的管理,如果你不明白 BPF 对象的生命周期的话,那么你在使用SDK时就会感到迷茫。

你可以查看这篇翻译文章去学习 BPF 对象的生命周期

CO-RE

CO-RE(Complie-Once Run-Everywhere),通过 LLVM 后端将一个.c源码程序编译为一个 BTF 格式的文件格式,

vmlinux.h

获得:

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

vmlinux.h 是当前 Linux 内核中符号表的所有内容,包含内核结构体/变量/内核函数。我们编写 ebpf 程序就避免不了与内核的数据结构打交道。

bpftool

查看所有的 printk 调试信息:

bpftool prog tracelog

eBPF开发规范

Verfifier

验证器规定,BPF程序无法直接向任意指针处写入数据,一个有缺陷或恶意的BPF程序就可能轻易的破坏内核内存,导致系统崩溃或验证的安全漏洞。验证器无法静态地证明这种直接写操作是安全的,因此会直接拒绝这类程序。

例如下面的案例:

准备使用 eunomia-bpf 来进行演示,还在学习中

我们试图直接向一个已经获取了指针的Map元素写入内容。。。