经典 libbpf 范例: uprobe 分析 - eBPF基础知识 Part4
《eBPF基础知识》 系列简介:
《eBPF基础知识》系列目标是整理一下 BPF 相关的基础知识。主要聚焦程序与内核互动接口部分。文章使用了 libbpf,但如果你不直接使用 libbpf,看本系列还是有一定意义的,因为它聚焦于程序与内核互动接口部分,而非 libbpf 封装本身。而所有 bpf 开发框架,都要以相似的方式跟内核互动。甚至框架本身就是基于 libbpf。哪怕是 golang/rust/python/BCC/bpftrace。
国际习惯:尽量多图少文字。以下假设读者已经对 BPF 有一定的了解,或者阅读过之前的 《eBPF基础知识》系列文章。
libbpf 提供了一个使用 libbpf 的示例:https://github.com/libbpf/libbpf-bootstrap。其中的 uprobe 程序示范了一个最简单的 BPF uprobe 程序加载、绑定到 user space ELF 函数、与内核互动的过程。下面将图解分析这个程序与内核的互动过程。
动机:为何我想学习 BPF uprobe
开始分析前,我想说几句废话:为何我想学习 BPF uprobe?
大部分应用的行为,都以函数为单元来划分,跟踪应用的函数是跟踪应用很好的切入点
例如我之前写的:
stack 性能分析
火焰图数据之源
Troubleshooting
生产上遇到问题,不太可能用 gdb 对分析,但有的情况下可以用 BPF uprobe 去拦截函数调用和获取入参出参。
uprobe 示例程序功能
uprobe 程序是一个用户空间(user-space)函数进入(entry)和退出(exit) 探针示例,在 libbpf 术语中称为 uprobe
和 uretprobe
。 它将 uprobe
和 uretprobe
BPF 程序绑定到它自己的函数(uprobed_add()
和 uprobed_sub()
),并使用 bpf_printk()
宏记录输入参数和返回值。 用户空间函数每秒触发一次:
$ sudo ./uprobe
libbpf: loading object 'uprobe_bpf' from buffer
...
Successfully started!
...........
你可以这样监视程序的输出:
$ sudo cat /sys/kernel/debug/tracing/trace_pipe
uprobe-1809291 [007] .... 4017233.106596: 0: uprobed_add ENTRY: a = 0, b = 1
uprobe-1809291 [007] .... 4017233.106605: 0: uprobed_add EXIT: return = 1
uprobe-1809291 [007] .... 4017233.106606: 0: uprobed_sub ENTRY: a = 0, b = 0
uprobe-1809291 [007] .... 4017233.106607: 0: uprobed_sub EXIT: return = 0
uprobe-1809291 [007] .... 4017234.106694: 0: uprobed_add ENTRY: a = 1, b = 2
uprobe-1809291 [007] .... 4017234.106697: 0: uprobed_add EXIT: return = 3
uprobe-1809291 [007] .... 4017234.106700: 0: uprobed_sub ENTRY: a = 1, b = 1
uprobe-1809291 [007] .... 4017234.106701: 0: uprobed_sub EXIT: return = 0
程序主流程
<mark>我一直努力避免在文章直接上代码。原因是,我自己的体验是,在文章中读代码太难了…… </mark>不过有时还是要贴。目标不是让读者完全一次看懂代码,而是对主要逻辑和命名符号有个感性的了解。我尽量精简一下吧。不要被这纸老虎吓跑。后面有图解的。
内核态 BPF 字节码程序
先看 BPF 内核字节码程序部分:
SEC("uprobe")
int BPF_KPROBE(uprobe_add, int a, int b)
{
bpf_printk("uprobed_add ENTRY: a = %d, b = %d", a, b);
return 0;
}
SEC("uretprobe")
int BPF_KRETPROBE(uretprobe_add, int ret)
{
bpf_printk("uprobed_add EXIT: return = %d", ret);
return 0;
}
SEC("uprobe//proc/self/exe:uprobed_sub")
int BPF_KPROBE(uprobe_sub, int a, int b)
{
bpf_printk("uprobed_sub ENTRY: a = %d, b = %d", a, b);
return 0;
}
SEC("uretprobe//proc/self/exe:uprobed_sub")
int BPF_KRETPROBE(uretprobe_sub, int ret)
{
bpf_printk("uprobed_sub EXIT: return = %d", ret);
return 0;
}
可见,这里包括 uprobe_add
与 uprobe_sub
两个 user space 函数的入口与出口探针。这个示例是用户态进程自己探测自己(这个样的示例其实不太好,不现实)。进程自己探测自己,所以可以用 /proc/self/exe
。熟识 Linux proc
目录的同学都知道,这个文件是指向访问这个文件的进程本身的 symbol link:
$ ls -l /proc/self/exe
lrwxrwxrwx 1 labile labile 0 Apr 2 22:40 /proc/self/exe -> /usr/bin/ls
同是 uprobe 实现函数 的 section 定义,上面代码有两种表达方法:
SEC("uprobe")
这种没指定目标函数。由用户态 bpf 程序加载和动态绑定到探测目标函数。上层用户态 bpf 程序需要自行计算函数在 ELF 文件中的 offset。
SEC("uprobe//proc/self/exe:uprobed_sub")
这种指定了目标 elf 路径和函数。可由用户态 libbpf 自动加载和绑定到探测目标函数。上层用户态 bpf 程序不需要计算函数在 ELF 文件中的 offset。由 libbpf 自动计算。
在 make 的过程中,实际上是执行了:
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I.output -I../../libbpf/include/uapi -I../../vmlinux/x86/
-idirafter /usr/lib/llvm-14/lib/clang/14.0.0/include -idirafter /usr/local/include -idirafter
/usr/include/x86_64-linux-gnu -idirafter /usr/include -c uprobe.bpf.c -o .output/uprobe.bpf.o
llvm-strip -g .output/uprobe.bpf.o
最后一行就是重点。输入是 uprobe.bpf.c
。输出是 uprobe.bpf.o
。这是一个 ELF 格式的文件。这个文件将会嵌入到应用中。uprobe.bpf.o
section 如下:
$ readelf -aW examples/c/.output/uprobe.bpf.o
Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .strtab STRTAB 0000000000000000 000c41 00014f 00 0 0 1
[ 2] .text PROGBITS 0000000000000000 000040 000000 00 AX 0 0 4
[ 3] uprobe PROGBITS 0000000000000000 000040 000040 00 AX 0 0 8
[ 4] .reluprobe REL 0000000000000000 000aa8 000010 10 I 18 3 8
[ 5] uretprobe PROGBITS 0000000000000000 000080 000038 00 AX 0 0 8
[ 6] .reluretprobe REL 0000000000000000 000ab8 000010 10 I 18 5 8
[ 7] PROGBITS 0000000000000000 0000b8 000040 00 AX 0 0 8
[ 8] .reluprobe//proc/self/exe:uprobed_sub REL 0000000000000000 000ac8 000010 10 I 18 7 8
[ 9] uretprobe//proc/self/exe:uprobed_sub PROGBITS 0000000000000000 0000f8 000038 00 AX 0 0 8
[10] .reluretprobe//proc/self/exe:uprobed_sub REL 0000000000000000 000ad8 000010 10 I 18 9 8
[11] license PROGBITS 0000000000000000 000130 00000d 00 WA 0 0 1
[12] .rodata PROGBITS 0000000000000000 00013d 000080 00 A 0 0 1
[13] .BTF PROGBITS 0000000000000000 0001c0 000635 00 0 0 4
[14] .rel.BTF REL 0000000000000000 000ae8 000050 10 I 18 13 8
[15] .BTF.ext PROGBITS 0000000000000000 0007f8 000148 00 0 0 4
[16] .rel.BTF.ext REL 0000000000000000 000b38 000100 10 I 18 15 8
[17] .llvm_addrsig LOOS+0xfff4c03 0000000000000000 000c38 000009 00 E 0 0 1
[18] .symtab SYMTAB 0000000000000000 000940 000168 18 1 10 8
如果你不太了解 ELF 格式,建议先看看,因为理解这个格式很重要。可以参考我的《ELF 格式简述 - eBPF 基础知识》
上面可见,uprobe//proc/self/exe:uprobed_sub
与 uretprobe//proc/self/exe:uprobed_sub
section 的名字指明了相应 BPF program 要绑定的目标用户态 ELF 与函数名。
用户态 bpf 程序
// 计算本进程的一个函数在 ELF 文件的 offset
ssize_t get_uprobe_offset(const void *addr)
{
size_t start, end, base;
char buf[256];
bool found = false;
FILE *f;
f = fopen("/proc/self/maps", "r");
...
while (fscanf(f, "%zx-%zx %s %zx %*[^\n]\n", &start, &end, buf, &base) == 4) {
if (buf[2] == 'x' && (uintptr_t)addr >= start && (uintptr_t)addr < end) {
found = true;
break;
}
}
fclose(f);
...
return (uintptr_t)addr - start + base;
}
//探测目标函数
int uprobed_add(int a, int b)
{
return a + b;
}
//探测目标函数
int uprobed_sub(int a, int b)
{
return a - b;
}
int main(int argc, char **argv)
{
struct uprobe_bpf *skel;
long uprobe_offset;
int err, i;
...
/* 1. 加载内核态 BPF 程序*/
skel = uprobe_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "Failed to open and load BPF skeleton\n");
return 1;
}
/* uprobe/uretprobe expects relative offset of the function to attach
* to. This offset is relateve to the process's base load address. So
* easy way to do this is to take an absolute address of the desired
* function and substract base load address from it. If we were to
* parse ELF to calculate this function, we'd need to add .text
* section offset and function's offset within .text ELF section.
* 计算 uprobed_add 函数在本进程的 ELF 中的 offset
*/
uprobe_offset = get_uprobe_offset(&uprobed_add);
/* 2. 绑定 BPF 程序 BPF_KPROBE(uprobe_add, int a, int b) 到函数 uprobed_add */
skel->links.uprobe_add = bpf_program__attach_uprobe(skel->progs.uprobe_add,
false /* not uretprobe */,
0 /* self pid */,
"/proc/self/exe",
uprobe_offset);
...
/* Let libbpf perform auto-attach for uprobe_sub/uretprobe_sub
* NOTICE: we provide path and symbol info in SEC for BPF programs
* 3. 让 libbpf 自动根据 uprobe.bpf.c 的 section 定义(如:SEC("uprobe//proc/self/exe:uprobed_sub")),去加载和绑定 bpf 程序到进程用户态函数
*/
err = uprobe_bpf__attach(skel);
...
printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
"to see output of the BPF programs.\n");
for (i = 0; ; i++) {
/* trigger our BPF programs */
fprintf(stderr, ".");
uprobed_add(i, i + 1);
uprobed_sub(i * i, i);
sleep(1);
}
...
}
看看输出的 ELF 内容:
$ readelf -aW uprobe
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8
INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x004828 0x004828 R 0x1000
LOAD 0x005000 0x0000000000005000 0x0000000000005000 0x02ba29 0x02ba29 R E 0x1000
# 关注上面的 Offset: 0x005000
# 只关注 .symtab 部分 的 uprobed_sub
Symbol table '.symtab' contains 720 entries:
Num: Value Size Type Bind Vis Ndx Name
470: 000000000000646b 24 FUNC GLOBAL DEFAULT 16 uprobed_add
523: 0000000000006483 22 FUNC GLOBAL DEFAULT 16 uprobed_sub
程序架构
uprobe 与内核互动概述
如上图排版有问题,请点这里用 Draw.io 打开。部分带互动链接和 hover tips
图中是我跟踪的结果。用 Draw.io 打开后,每一步均有 link,点击可看到代码。鼠标放到连接线上,会 hover 出 stack(调用堆栈)。
图中的说明已经比较详细。其中包括重要的数据结构和步骤。
上图 file descriptor
之间的连线,反映了它们之间的关联。这里简单列一下上图的流程:
- 1 ~ 5 建立 libbpf 用到的数据结构。
- 6 ~ 12 加载 BPF program 与 BPF Map
13 ~ 16 开启动态 uprobe
- 计算动态
int uprobed_add(int a, int b)
的 offset - 创建
函数 perf event probe
- 函数 perf event probe 绑定到 BPF program
- 启动
函数 perf event probe
- 计算动态
- 开启静态 uprobe
- 利用 libbpf 根据 uprobe.bpf.o ELF 文件的 section 信息,自动计算
int uprobed_sub(int a, int b)
offset。之后完成类似上面开启动态 uprobe
的过程。最终 函数 perf event probe 绑定到 BPF programBPF_KPROBE(uprobe_sub, int a, int b)
- 用户态应用定时调用函数
uprobed_add(int a, int b)
和uprobed_sub(int a, int b)
触发了 BPF progam
- 用户态应用定时调用函数
我 fork 了项目到这里:
https://github.com/labilezhu/libbpf-bootstrap/tree/20230226
后记
这个后记和本文没什么相关了,不喜可跳过。最近看了一部 1994 年的电影。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。