ArthurChiao's Blog

[译] LLVM eBPF 汇编编程(2020)

Published at 2021-08-15 | Last Update 2021-08-17

译者序

本文翻译自 2020 年 Quentin Monnet 的一篇英文博客: eBPF assembly with LLVM。 Quentin Monnet 是 Cilium 开发者之一,此前也在从事网络、eBPF 相关的开发。

翻译已获得 Quentin Monnet 授权。

文章介绍了如何直接基于 LLVM eBPF 汇编开发 BPF 程序,虽然给出的 两个例子极其简单,但其流程对于开发更大的程序也是适用的。为什么不用 C,而用汇编 这么不友好的编程方式呢?至少有两个特殊场景:

  1. 测试特定的 eBPF 指令流
  2. 对程序的某个特定部分进行深度调优

原文历时(开头之后拖延)了好几年,因此文中存在一些(文件名等)前后不一致之处,翻译时已经改正 (交流之后,作者已经修正);另外,译文基于 clang/llvm 10.0 验证了其中的每个步骤,因此代码、输出等与原文不完全一致。

由于译者水平有限,本文不免存在遗漏或错误之处。如有疑问,请查阅原文。

以下是译文。



1 引言

1.1 主流开发方式:从 C 代码直接生成 eBPF 字节码

eBPF 相比于 cBPF(经典 BPF)的优势之一是:Clang/LLVM 为它提供了一个编译后端, 能从 C 源码直接生成 eBPF 字节码(bytecode)。(写作本文时,GCC 也提供了一个类似 的后端,但各方面都没有 Clang/LLVM 完善,因此后者仍然是生成 eBPF 字节码 的最佳参考工具)。

将 C 代码编译成 eBPF 目标文件非常有用,因为 直接用字节码编写高级程序是非常耗时的。此外,截至本文写作时, 还无法直接编写字节码程序来使用 CO-RE 等复杂特性。

(译) BPF 可移植性和 CO-RE(一次编译,到处运行)(Facebook,2020)。 译注。

因此,Clang 和 LLVM 仍然是 eBPF 工作流不可或缺的部分。

1.2 特殊场景需求:eBPF 汇编编程更合适

但是,C 方式不适用于某些特殊的场景,例如:

  1. 只是想测试特定的 eBPF 指令流
  2. 对程序的某个特定部分进行深度调优

在这些情况下,就需要直接编写或修改 eBFP 汇编程序。

1.3 几种 eBPF 汇编编程方式

  1. 直接编写 eBPF 字节码程序。也就是编写可直接加载运行的 二进制 eBPF 程序,

    • 这肯定是可行的,但过程非常冗长无聊,对开发者极其不友好。
    • 此外,为保证与 tc 等工具的兼容,还要将写好的程序转换成目标文件(object file),因此工作量又多了一些。
  2. 直接用 eBPF 汇编语言编写,然后用专门的汇编器 (例如 ebpf_asm)将其汇编(assemble)成字节码。

    • 相比字节码(二进制),汇编语言(文本)至少可读性还是好很多的。
  3. 用 LLVM 将 C 编译成 eBPF 汇编,然后手动修改生成的汇编程序, 最后再将其汇编(assemble)成字节码放到对象文件。

  4. 在 C 中插入内联汇编,然后统一用 clang/llvm 编译。

以上几种方式 Clang/LLVM 都支持!先用可读性比较好的方式写, 然后再将其汇编(assembling)成另字节码程序。此外,甚至能 dump 对象文件中包含的程序。

本文将会展示第三种和第四种方式,第二种可以认为是第三种的更加彻底版,开发的流程 、步骤等已经包括在第三种了。

2 Clang/LLVM 编译 eBPF 基础

在开始汇编编程之前,先来熟悉一下 clang/llvm 将 C 程序编译成 eBPF 程序的过程。

2.1 将 C 程序编译成 BPF 目标文件

下面是个 eBPF 程序:没做任何事情,直接返回零,

$ cat bpf.c
int func() {
    return 0;
}

如下命令可以将其编译成对象文件(目标文件)

# 注意 target 类型指定为 `bpf`
$ clang -target bpf -Wall -O2 -c bpf.c -o bpf.o

某些复杂的程序可能需要用下面的命令来编译:

$ clang -O2 -emit-llvm -c bpf.c -o - | \
	llc -march=bpf -mcpu=probe -filetype=obj -o bpf.o

以上命令会将 C 源码编译成字节码,然后生成一个 ELF 格式的目标文件。

1.2 查看 ELF 文件中的 eBPF 字节码

默认情况下,代码位于 ELF 的 .text 区域(section):

$ readelf -x .text bpf.o
Hex dump of section '.text':
  0x00000000 b7000000 00000000 95000000 00000000 ................

这就是编译生成的字节码

以上字节码包含了两条 eBPF 指令

b7 0 0 0000 00000000    # r0 = 0
95 0 0 0000 00000000    # exit and return r0

如果对 eBPF 汇编语法不熟悉,可参考:

  1. iovisor/bpf-docs 中的简洁文档
  2. 更详细的内核文档 networking/filter.txt

有了以上基础,接下来看如何开发 eBPF 汇编程序。

3 方式一:C 生成 eBPF 汇编 + 手工修改汇编

本节需要 Clang/LLVM 6.0+ 版本(clang -v)。

译文基于 10.0,结果与原文略有差异。

C 源码:

$ cat bpf.c
int func() {
	return 0;
}

3.1 将 C 编译成 eBPF 汇编(clang

其实前面已经看到了,与将普通 C 程序编译成汇编类似,只是这里指定 target 类型是 bpfbpf target 与默认 target 的不同,见 Cilium 文档 BPF and XDP Reference Guide ):

(译) Cilium:BPF 和 XDP 参考指南(2021)。 译注。

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

查看生成的汇编代码:

$ cat bpf.s
        .text
        .file   "bpf.c"
        .globl  func                    # -- Begin function func
        .p2align        3
        .type   func,@function
func:                                   # @func
# %bb.0:
        r0 = 0
        exit
.Lfunc_end0:
        .size   func, .Lfunc_end0-func
                                        # -- End function
        .addrsig

接下来就可以修改这段汇编代码了。

3.2 手工修改汇编程序

因为汇编程序是文本文件,因此编辑起来很容易。 作为练手,我们在程序最后加上一行汇编指令 r0 = 3

$ cat bpf.s
        .text
        .file   "bpf.c"
        .globl  func                    # -- Begin function func
        .p2align        3
        .type   func,@function
func:                                   # @func
# %bb.0:
        r0 = 0
        exit
        r0 = 3                          # -- 这行是我们手动加的
.Lfunc_end0:
        .size   func, .Lfunc_end0-func
                                        # -- End function
        .addrsig

这行放在了 exit 之后,因此实际上没任何作用。

3.3 将汇编程序 assemble 成 ELF 对象文件(llvm-mc

接下来将 bpf.s 汇编(assemble)成包含字节码的 ELF 对象文件。这 里需要用到 LLVM 自带的与机器码(machine code,mc)打交道的工具 llvm-mc

$ llvm-mc -triple bpf -filetype=obj -o bpf.o bpf.s

bpf.o 就是生成的 ELF 文件!

3.4 查看对象文件中的 eBPF 字节码(readelf

查看 bpf.o 中的字节码

$ readelf -x .text bpf.o

Hex dump of section '.text':
  0x00000000 b7000000 00000000 95000000 00000000 ................
  0x00000010 b7000000 03000000                   ........

看到和之前相比,

  • 第一行(包含前两条指令)一样,
  • 第二行是新多出来的(对应的正是我们新加的一行汇编指令),作用:将常量 3 load 到寄存器 r0 中。

至此,我们已经成功地修改了指令流。接下来就可以用 bpftool 之 类的工具将这个程序加载到内核,任务完成!

3.5 以更加人类可读的方式查看 eBPF 字节码(llvm-objdump -d

LLVM 还能以人类可读的方式 dump eBPF 对象文件中的指令,这里就要用到 llvm-objdump

# -d           : alias for --disassemble
# --disassemble: display assembler mnemonics for the machine instructions
$ llvm-objdump -d bpf.o
bpf.o:  file format ELF64-BPF

Disassembly of section .text:

0000000000000000 func:
       0:       b7 00 00 00 00 00 00 00 r0 = 0
       1:       95 00 00 00 00 00 00 00 exit
       2:       b7 00 00 00 03 00 00 00 r0 = 3

最后一列显示了对应的 LLVM 使用的汇编指令(也是前面我们手工编辑时使用的 eBPF 指令)。

3.6 编译时嵌入调试符号或 C 源码(clang -g + llvm-objdump -S

除了字节码和汇编指令,LLVM 还能将调试信息(debug symbols)嵌入到对象文件, 更具体说就是能在字节码旁边同时显示对应的 C 源码,对调试非常有用,也是 观察 C 指令如何映射到 eBPF 指令的好机会。

在 clang 编译时加上 -g 参数:

# -g: generate debug information.
$ clang -target bpf -g -S -o bpf.s bpf.c
$ llvm-mc -triple bpf -filetype=obj -o bpf.o bpf.s
# -S      : alias for --source
# --source: display source inlined with disassembly. Implies disassemble object
$ llvm-objdump -S bpf.o
Disassembly of section .text:

0000000000000000 func:
; int func() {
       0:       b7 00 00 00 00 00 00 00 r0 = 0
;     return 0;
       1:       95 00 00 00 00 00 00 00 exit

注意这里用的是 -S(显示源码),不是 -d(反汇编)

4 方式二:内联汇编(inline assembly)

接下来看另一种生成和编译 eBPF 汇编的方式:直接在 C 程序中嵌入 eBPF 汇编

4.1 C 内联汇编示例

下面是个非常简单的例子,受 Cilium 文档 BPF and XDP Reference Guide 的启发:

(译) Cilium:BPF 和 XDP 参考指南(2021)。 译注。

$ cat inline_asm.c
int func() {
    unsigned long long foobar = 2, r3 = 3, *foobar_addr = &foobar;

    asm volatile("lock *(u64 *)(%0+0) += %1" : // 等价于:foobar += r3
         "=r"(foobar_addr) :
         "r"(r3), "0"(foobar_addr));

    return foobar;
}

关键字 asm 用于插入汇编代码。

4.2 编译及查看生成的字节码

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

反汇编:

$ llvm-objdump -d inline_asm.o
Disassembly of section .text:

0000000000000000 func:
       0:       b7 01 00 00 02 00 00 00 r1 = 2
       1:       7b 1a f8 ff 00 00 00 00 *(u64 *)(r10 - 8) = r1
       2:       b7 01 00 00 03 00 00 00 r1 = 3
       3:       bf a2 00 00 00 00 00 00 r2 = r10
       4:       07 02 00 00 f8 ff ff ff r2 += -8
       5:       db 12 00 00 00 00 00 00 lock *(u64 *)(r2 + 0) += r1
       6:       79 a0 f8 ff 00 00 00 00 r0 = *(u64 *)(r10 - 8)
       7:       95 00 00 00 00 00 00 00 exit

对应到最后一列的汇编,大家应该大致能看懂。

4.3 小结

这种方式的好处是:源码仍然是 C,因此无需像前一种方式那样必须手动执行编译( compile)和汇编(assemble)两个分开的过程

5 结束语

本文通过两个极简的例子展示了两种 eBPF 汇编编程方式:

  1. 手动生成并修改一段特定的指令流
  2. 在 C 中插入内联汇编

这两种方式我认为都是有用的,比如在 Netronome,我们经常用前一种方式做单元测试, 检查 nfp 驱动中的 eBPF hw offload 特性。

LLVM 支持编写任意的 eBPF 汇编程序(但提醒一下:编译能通过是一回事,能不能通过校验器是另一回事)。 有兴趣自己试试吧!