LLVM ebpf 汇编学习

LLVM ebpf 汇编学习

henry Lv4

Reference

https://arthurchiao.art/blog/ebpf-assembly-with-llvm-zh/

LLVM eBPF 汇编编程学习

介绍

学习基于 LLVM eBPF 汇编开发 BPF 程序,之所以不用C,而用汇编的理由如下:

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

由于直接从字节码写程序非常耗时,所以Clang/LLVM 为 ebpf 提供了一个编译后端,能从C 源码直接生成 ebpf 字节码(GCC 也可以但是没有 clang/llvm 完善)。

几种 eBPF 汇编编程的方式:

  1. 直接编写 eBPF 字节码程序。也就是编写可直接加载运行的 二进制 eBPF 程序(对开发者不友好)
  2. 直接用 eBPF 汇编语言编写,然后用专门的汇编器 (例如 ebpf_asm)将其汇编(assemble)成字节码。
  3. 用 LLVM 将 C 编译成 eBPF 汇编,然后手动修改生成的汇编程序, 最后再将其汇编(assemble)成字节码放到对象文件。
  4. 在 C 中插入内联汇编,然后统一用 clang/llvm 编译。

上述四种方式 clang/llvm 都支持,这里介绍第三种和第四种

Clang/LLVM 编译 eBPF

bpf.c 内容如下:

1
2
3
4
cat bpf.c
int func() {
return 0;
}

编译成为 eBPF 程序

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

复杂的程序使用下面的命令编译

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

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

查看elf文件中的eBPF字节码

nipaste_2024-07-29_11-14-1

0xb7000000:r0=0

0x95000000:exit return r0

ebpf 汇编语法参考文档

C 生成 eBPF 汇编 + 手工修改

还是使用前面的 bpf.c 文件

将 C 编译成 eBPF 汇编(clang

注意这里 -target 目标需要设置为 bpf,不然就会按默认方式生成汇编

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

nipaste_2024-07-29_11-19-0

从上图可以清晰的看到两种编译方式,生成的汇编代码语法是不相同的。

手动修改 ebpf 汇编

我们在生成的 ebpf 汇编程序中加一行指令r0 = 3

nipaste_2024-07-29_11-21-2

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

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

这一步将 ebpf 汇编文件生成了 ebpf 字节码文件,可以看到相较于前面的一行,这里多出来一行,即是我们添加的r0 =3 这一行命令

1
2
3
4
5
henry@henry:~/Documents/test$ readelf -x .text bpf.o

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

变换成为可读的方式查看 ebpf 字节码

当然上面的16进制的字节码并不好看,llvm_objdump 支持查看 ebpf 字节码程序

1
2
3
# -d           : alias for --disassemble
# --disassemble: display assembler mnemonics for the machine instructions
llvm-objdump -d bpf.o

nipaste_2024-07-29_11-26-2

编译时嵌入调试符号或C源码

LLVM 还可以将调试信息嵌入到字节码文件当中,需要在 clang 编译时加上 -g 参数

1
2
3
# -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

nipaste_2024-07-29_11-29-2

注意事项:用的是 -S(显示源码),不是 -d(反汇编)。

内联汇编

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

示例

1
2
3
4
5
6
7
8
9
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;
}

对于上述内联汇编的解释如下(GPT回答):

在 GCC 的内联汇编语法中,输出操作数、输入操作数和修饰符(即约束)的位置和顺序是固定的,这使得我们可以确定 %0%1 等占位符所对应的具体操作数。以下是这些部分的具体规则:

  1. 输出操作数(Output Operands):位于冒号 : 之后的第一组,括在双引号 "" 中,并以等号 = 开头的表示输出操作数。输出操作数的约束通常以 = 开头,表示这是一个输出(即目标)操作数。
  2. 输入操作数(Input Operands):位于输出操作数之后的第二组,括在双引号 "" 中,不以等号 = 开头的表示输入操作数。

在内联汇编代码中,操作数列表中的位置决定了占位符 %0%1 等所引用的具体操作数。下面是对应关系的一个例子:

1
asm ("<汇编代码>" : <输出操作数列表> : <输入操作数列表>);
  • %0 对应输出操作数列表中的第一个操作数。
  • %1 对应输入操作数列表中的第一个操作数。
  • %2 对应输入操作数列表中的第二个操作数,依此类推。

在上面 c 代码中:

1
2
3
asm ("lock *(u64 *)(%0+0) += %1"
: "=r"(foobar_addr) // 输出操作数
: "r"(r3), "0"(foobar_addr)); // 输入操作数
  • %0 对应第一个输出操作数 "=r"(foobar_addr)
  • %1 对应第一个输入操作数 "r"(r3)

因此,%0 引用了第一个输出操作数 foobar_addr,因为它是在输出操作数列表中的第一个位置上。

编译生成的字节码文件

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

nipaste_2024-07-29_11-34-0

这种方式无需像前一种需要将编译和汇编步骤分开

总结

前面两种方式最后都可以成功生成 ebpf 字节码文件,但不一定能通过 ebpf verifier 的检查。

  • Title: LLVM ebpf 汇编学习
  • Author: henry
  • Created at : 2024-07-29 11:37:35
  • Updated at : 2024-07-29 11:45:48
  • Link: https://henrymartin262.github.io/2024/07/29/llvm_ebpf/
  • License: This work is licensed under CC BY-NC-SA 4.0.
 Comments