一文看懂eBPF|eBPF实现原理

共 8297字,需浏览 17分钟

 ·

2022-04-01 21:20

在上一篇文章中,我们主要简单介绍了什么是 eBPF 和 eBPF 的简单使用,而本文重点介绍 eBPF 的实现原理。

在介绍 eBPF 的实现原理前,我们先来回顾一下 eBPF 的架构图:

dc354a6165a8caeb34adeb382486b3f2.webp


这幅图对理解 eBPF 实现原理有非常大的作用,在分析 eBPF 实现原理时,要经常参照这幅图来进行分析。

eBPF虚拟机

其实我不太想介绍 eBPF 虚拟机的,因为一般来说很少会用到 eBPF 汇编来写程序。但是,不介绍 eBPF 虚拟机的话,又不能说清 eBPF 的原理。

所以,还是先简单介绍一下 eBPF 虚拟机的原理,这样对分析 eBPF 实现有很大的帮助。

eBPF汇编

eBPF 本质上是一个虚拟机(Virtual Machine),可以执行 eBPF 字节码。

用户可以使用 eBPF 汇编或者 C 语言来编写程序,然后编译成 eBPF 字节码,再由 eBPF 虚拟机执行。

什么是虚拟机?

官方的解释是:虚拟机(VM)是一种创建于物理硬件系统(位于外部或内部)、充当虚拟计算机系统的虚拟环境,它模拟出了自己的整套硬件,包括 CPU、内存、网络接口和存储器。通过名为虚拟机监控程序的软件,用户可以将机器的资源与硬件分开并进行适当设置,以供虚拟机使用。

通俗的解释:虚拟机就是模拟计算机的运行环境,你可以把它当成是一台虚拟出来的计算机。

计算机的最本质功能就是执行代码,所以 eBPF 虚拟机也一样,可以运行 eBPF 字节码。

用户编写的 eBPF 程序最终会被编译成 eBPF 字节码,eBPF 字节码使用 bpf_insn 结构来表示,如下:

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

下面介绍一下 bpf_insn 结构各个字段的作用:

  1. code:指令操作码,如 mov、add 等。
  2. dst_reg:目标寄存器,用于指定要操作哪个寄存器。
  3. src_reg:源寄存器,用于指定数据来源于哪个寄存器。
  4. off:偏移量,用于指定某个结构体的成员。
  5. imm:立即操作数,当数据是一个常数时,直接在这里指定。

eBPF 程序会被 LLVM/Clang 编译成 bpf_insn 结构数组,当内核要执行 eBPF 字节码时,会调用 __bpf_prog_run() 函数来执行。

如果开启了 JIT(即时编译技术),内核会将 eBPF 字节码编译成本地机器码(Native Code)。这样就可以直接执行,而不需要虚拟机来执行。

关于 eBPF 汇编相关的知识点可以参考《eBPF汇编指令介绍》,这里就不作深入的分析,我们只需要记住 eBPF 程序会被编译成 eBPF 字节码即可。

eBPF虚拟机

eBPF 虚拟机的作用就是执行 eBPF 字节码,eBPF 虚拟机比较简单(只有300行代码左右),由 __bpf_prog_run() 函数实现。

通用虚拟机因为要模拟真实的计算机,所以通常来说实现比较复杂(如Qemu、Virtual Box等)。

但像 eBPF 虚拟机这种用于特定功能的虚拟机,由于只需要模拟计算机的小部分功能,所以实现通常比较简单。

eBPF 虚拟机的运行环境只有 1 个 512KB 的栈和 11 个寄存器(还有一个 PC 寄存器,用于指向当前正在执行的 eBPF 字节码)。如下图所示:

1f168c69a3118c1322dacdcc51884ebd.webp

如果内核支持 JIT(Just In Time)运行模式,那么内核将会把 eBPF 字节码编译成本地机器码,这时可以直接运行这些机器码,而不需要使用虚拟机来运行。

可以通过以下命令打开 JIT 运行模式:

echo 1 > /proc/sys/net/core/bpf_jit_enable

将 C 程序编译成 eBPF 字节码

由于使用 eBPF 汇编编写程序比较麻烦,所以 eBPF 提供了功能受限的 C 语言来编写 eBPF 程序,并且可以使用 Clang/LLVM 将 C 程序编译成 eBPF 字节码。

使用 Clang 编译 eBPF 程序时,需要加上 -target bpf 参数才能编译成功。

下面我们用一个简单的例子来介绍怎么使用 Clang 编译 eBPF 程序,我们新建一个文件 hello.c 并且输入以下代码:

#include 

static int (*bpf_trace_printk)(const char *fmt, int fmtsize, ...)
        
= (void *)BPF_FUNC_trace_printk;

int hello_world(void *ctx)
{
    char msg[] = "Hello World\n";
    bpf_trace_printk(msg, sizeof(msg)-1);
    return 0;
}

然后我们使用以下命令编译程序:

$ clang -target bpf -Wall -O2 -c hello.c -o hello.o

编译后会得到一个名为 hello.o 的文件,我们可以通过下面命令来看到编译后的字节码:

$ readelf -x .text hello.o
Hex dump of section '.text':

  0x00000000 18010000 00000000 00000000 00000000 ................
  0x00000010 b7020000 0c000000 85000000 06000000 ................
  0x00000020 b7000000 00000000 95000000 00000000 ................

由于编译出来的字节码是二进制的,不利于人类查阅。所以,可以通过以下命令将 eBPF 程序编译成 eBPF 汇编代码:

$ clang -target bpf -S -o hello.s hello.c

编译后会得到一个名为 hello.s 的文件,我们可以使用文本编辑器来查看其汇编代码:

...
hello_world:
    *(u64 *)(r10 - 8) = r1   # 把r1的值保存到栈
    r1 = bpf_trace_printk ll #
    r1 = *(u64 *)(r1 + 0)    # r1赋值为 bpf_trace_printk 函数地址
    r2 = .L.str ll           # r2赋值为 "Hello World\n"
    r3 = 12                  # r3赋值为12
    *(u64 *)(r10 - 16) = r1  # 把r1的值保存到栈
    r1 = r2                  # 调用 bpf_trace_printk 函数的参数1
    r2 = r3                  # 调用 bpf_trace_printk 函数的参数2
    r3 = *(u64 *)(r10 - 16)  # 获取 bpf_trace_printk 函数地址
    callx r3                 # 调用 bpf_trace_printk 函数
    r1 = 0                   # r1赋值为0
    *(u64 *)(r10 - 24) = r0  # 把r0的值保存到栈
    r0 = r1                  # 返回0
    exit                     # 退出eBPF程序
...

eBPF 虚拟机的规范:

  1. 寄存器 r1-r5:作为函数调用参数使用。在 eBPF 程序启动时,寄存器 r1 包含 "上下文" 参数指针。

  2. 寄存器 r0:存储函数的返回值,包括函数调用和当前程序退出。

  3. 寄存器 r10:eBPF程序的栈指针。

eBPF 加载器

eBPF 程序是由用户编写的,编译成 eBPF 字节码后,需要加载到内核才能被内核使用。

用户态可以通过调用 sys_bpf() 系统调用把 eBPF 程序加载到内核,而 sys_bpf() 系统调用会通过调用 bpf_prog_load() 内核函数加载 eBPF 程序。

我们来看看 bpf_prog_load() 函数的实现(经过精简后):

static int bpf_prog_load(union bpf_attr *attr)
{
    enum bpf_prog_type type = attr->prog_type;
    struct bpf_prog *prog;
    int err;
    ...

    // 创建 bpf_prog 对象,用于保存 eBPF 字节码和相关信息
    prog = bpf_prog_alloc(bpf_prog_size(attr->insn_cnt), GFP_USER);

    ...
    prog->len = attr->insn_cnt; // eBPF 字节码长度(也就是有多少条 eBPF 字节码)

    err = -EFAULT;
    // 把 eBPF 字节码从用户态复制到 bpf_prog 对象中
    if (copy_from_user(prog->insns, u64_to_ptr(attr->insns),
                       prog->len * sizeof(struct bpf_insn)) != 0)
        goto free_prog;

    ...
    // 这里主要找到特定模块的相关处理函数(如修正helper函数)
    err = find_prog_type(type, prog);

    // 检查 eBPF 字节码是否合法
    err = bpf_check(&prog, attr);

    // 修正helper函数的偏移量
    fixup_bpf_calls(prog);

    // 尝试将 eBPF 字节码编译成本地机器码(JIT模式)
    err = bpf_prog_select_runtime(prog);

    // 申请一个文件句柄用于与 bpf_prog 对象关联
    err = bpf_prog_new_fd(prog);

    return err;
    ...
}

bpf_prog_load() 函数主要完成以下几个工作:

  1. 创建一个 bpf_prog 对象,用于保存 eBPF 字节码和 eBPF 程序的相关信息。
  2. 把 eBPF 字节码从用户态复制到 bpf_prog 对象的 insns 成员中,insns 成员是一个类型为 bpf_insn 结构的数组。
  3. 根据 eBPF 程序所属的类型(如 socketkprobes 或 xdp 等),找到其相关处理函数(如 helper 函数对应的修正函数,下面会介绍)。
  4. 检查 eBPF 字节码是否合法。由于 eBPF 程序运行在内核态,所以要保证其安全性,否则将会导致内核崩溃。
  5. 修正 helper 函数的偏移量(下面会介绍)。
  6. 尝试将 eBPF 字节码编译成本地机器码,主要为了提高 eBPF 程序的执行效率。
  7. 申请一个文件句柄用于与 bpf_prog 对象关联,这个文件句柄将会返回给用户态,用户态可以通过这个文件句柄来读取内核中的 eBPF 程序。

修正 helper 函数

helper 函数是 eBPF 提供给用户使用的一些辅助函数。

由于 eBPF 程序运行在内核态,所为了安全,eBPF 程序中不能随意调用内核函数,只能调用 eBPF 提供的辅助函数(helper functions)。

调用 eBPF 的 helper 函数与调用普通的函数并不一样,调用 helper 函数时并不是直接调用的,而是通过 helper 函数的编号来进行调用。

每个 eBPF 的 helper 函数都有一个编号(通过枚举类型 bpf_func_id 来定义),定义在 include/uapi/linux/bpf.h 文件中,定义如下(只列出一部分):

enum bpf_func_id {
    BPF_FUNC_unspec,               // 0
    BPF_FUNC_map_lookup_elem,      // 1
    BPF_FUNC_map_update_elem,      // 2
    BPF_FUNC_map_delete_elem,      // 3
    BPF_FUNC_probe_read,           // 4
    BPF_FUNC_ktime_get_ns,         // 5
    BPF_FUNC_trace_printk,         // 6
    BPF_FUNC_get_prandom_u32,      // 7
    BPF_FUNC_get_smp_processor_id, // 8
    BPF_FUNC_skb_store_bytes,      // 9
    BPF_FUNC_l3_csum_replace,      // 10
    BPF_FUNC_l4_csum_replace,      // 11
    BPF_FUNC_tail_call,            // 12
    BPF_FUNC_clone_redirect,       // 13
    BPF_FUNC_get_current_pid_tgid, // 14
    BPF_FUNC_get_current_uid_gid,  // 15
    ...
    __BPF_FUNC_MAX_ID,
};

下面我们来看看在 eBPF 程序中怎么调用 helper 函数:

#include 

// 声明要调用的helper函数为:BPF_FUNC_trace_printk
static int (*bpf_trace_printk)(const char *fmt, int fmtsize, ...)
        
= (void *)BPF_FUNC_trace_printk;

int hello_world(void *ctx)
{
    char msg[] = "Hello World\n";
    
    // 调用helper函数
    bpf_trace_printk(msg, sizeof(msg)-1);
    return 0;
}

从上面的代码可以知道,当要调用 helper 函数时,需要先定义一个函数指针,并且将函数指针赋值为 helper 函数的编号,然后才能调用这个 helper 函数。

定义函数指针的原因是:指定调用函数时的参数。

所以,调用的 helper 函数其实并不是真实的函数地址。那么内核是怎么找到真实的 helper 函数地址呢?

这里就是通过上面说的修正 helper 函数来实现的。

在介绍加载 eBPF 程序时说过,加载器会通过调用 fixup_bpf_calls() 函数来修正 helper 函数的地址。我们来看看 fixup_bpf_calls() 函数的实现:

static void fixup_bpf_calls(struct bpf_prog *prog)
{
    const struct bpf_func_proto *fn;
    int i;

    // 遍历所有的 eBPF 字节码
    for (i = 0; i < prog->len; i++) {
        struct bpf_insn *insn = &prog->insnsi[i];

        // 如果是函数调用指令
        if (insn->code == (BPF_JMP | BPF_CALL)) {
            ...
            // 通过 helper 函数的编号获取其真实地址
            fn = prog->aux->ops->get_func_proto(insn->imm);
            
            ...
            // 由于 bpf_insn 结构的 imm 字段类型为 int,
            // 为了能够将 helper 函数的地址(64位)保存到一个 int 中,
            // 所以减去一个基础函数地址,调用的时候加上这个基础函数地址即可。
            insn->imm = fn->func - __bpf_call_base;
        }
    }
}

fixup_bpf_calls() 函数主要完成修正 helper 函数的地址,其工作原理如下:

  1. 遍历 eBPF 程序的所有字节码。
  2. 如果字节码指令是一个函数调用,那么将进行函数地址修正,修正过程如下:
  • 根据 helper 函数的编号获取其真实的函数地址。
  • 将 helper 函数的真实地址减去 __bpf_call_base 函数的地址,并且保存到字节码的 imm 字段中。

从上面修正 helper 函数地址的过程可知,当调用 helper 函数时需要加上 __bpf_call_base 函数的地址。

eBPF 程序运行时机

上面介绍了 eBPF 程序的运行机制,现在来说说内核什么时候执行 eBPF 程序。

在《eBPF的简单使用》一文中介绍过,eBPF 程序需要挂载到某个内核路径(挂在点)才能被执行。

根据挂载点功能的不同,大概可以分为以下几个模块:

  • 性能跟踪(kprobes/uprobes/tracepoints)
  • 网络(socket/xdp)
  • 容器(cgroup)
  • 安全(seccomp)

比如要将 eBPF 程序挂载在 socket(套接字) 上,可以使用 setsockopt() 函数来实现,代码如下:

setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd));

下面说说 setsockopt() 函数各个参数的意义:

  • sock:要挂载 eBPF 程序的 socket 句柄。
  • SOL_SOCKET:设置的选项的级别,如果想要在套接字级别上设置选项,就必须设置为 SOL_SOCKET
  • SO_ATTACH_BPF:表示挂载 eBPF 程序到 socket 上。
  • prog_fd:通过调用 bpf() 系统调用加载 eBPF 程序到内核后返回的文件句柄。

通过上面的代码,就能将 eBPF 程序挂载到 socket 上,当 socket 接收到数据包时,将会执行这个 eBPF 程序对数据包进行过滤。

我们看看当 socket 接收到数据包时的操作:

// file: net/packet/af_packet.c

static int 
packet_rcv(struct sk_buff *skb, 
           struct net_device *dev, 
           struct packet_type *pt, 
           struct net_device *orig_dev)

{
    ...
    // 执行 eBPF 程序
    res = run_filter(skb, sk, snaplen);
    if (!res)
        goto drop_n_restore;
    ...
}

当 socket 接收到数据包时,会调用 run_filter() 函数执行 eBPF 程序。

总结

本文主要介绍了 eBPF 的实现原理,当然本文只是按大体思路去分析,有很多细节需要读者自己阅读源码来了解。

下篇文章将会介绍 kprobes 是怎么结合 eBPF 进行内核函数追踪的。


浏览 46
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报