头图

image.png

经典 libbpf 范例: uprobe 分析 - eBPF基础知识 Part4

《eBPF基础知识》 系列简介:

《eBPF基础知识》系列目标是整理一下 BPF 相关的基础知识。主要聚焦程序与内核互动接口部分。文章使用了 libbpf,但如果你不直接使用 libbpf,看本系列还是有一定意义的,因为它聚焦于程序与内核互动接口部分,而非 libbpf 封装本身。而所有 bpf 开发框架,都要以相似的方式跟内核互动。甚至框架本身就是基于 libbpf。哪怕是 golang/rust/python/BCC/bpftrace。

  1. 《ELF 格式简述 - eBPF基础知识 Part1》
  2. 《BPF 系统接口 与 libbpf 示例分析 - eBPF基础知识 Part2》
  3. 《经典 libbpf 范例: bootstrap 分析 - eBPF基础知识 Part3》

国际习惯:尽量多图少文字。以下假设读者已经对 BPF 有一定的了解,或者阅读过之前的 《eBPF基础知识》系列文章。

libbpf 提供了一个使用 libbpf 的示例:https://github.com/libbpf/libbpf-bootstrap。其中的 uprobe 程序示范了一个最简单的 BPF uprobe 程序加载、绑定到 user space ELF 函数、与内核互动的过程。下面将图解分析这个程序与内核的互动过程。

动机:为何我想学习 BPF uprobe

开始分析前,我想说几句废话:为何我想学习 BPF uprobe?

  1. 大部分应用的行为,都以函数为单元来划分,跟踪应用的函数是跟踪应用很好的切入点

    例如我之前写的:

  2. stack 性能分析

    火焰图数据之源

  3. Troubleshooting

    生产上遇到问题,不太可能用 gdb 对分析,但有的情况下可以用 BPF uprobe 去拦截函数调用和获取入参出参。

uprobe 示例程序功能

uprobe 程序是一个用户空间(user-space)函数进入(entry)和退出(exit) 探针示例,在 libbpf 术语中称为 uprobeuretprobe。 它将 uprobeuretprobe 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 内核字节码程序部分:

uprobe.bpf.c

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_adduprobe_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_suburetprobe//proc/self/exe:uprobed_sub section 的名字指明了相应 BPF program 要绑定的目标用户态 ELF 与函数名。

用户态 bpf 程序

uprobe.c

// 计算本进程的一个函数在 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   

程序架构

image.png

uprobe 与内核互动概述

如上图排版有问题,请点这里用 Draw.io 打开。部分带互动链接和 hover tips

图中是我跟踪的结果。用 Draw.io 打开后,每一步均有 link,点击可看到代码。鼠标放到连接线上,会 hover 出 stack(调用堆栈)。

图中的说明已经比较详细。其中包括重要的数据结构和步骤。

上图 file descriptor 之间的连线,反映了它们之间的关联。这里简单列一下上图的流程:

  • 1 ~ 5 建立 libbpf 用到的数据结构。
  • 6 ~ 12 加载 BPF program 与 BPF Map
  • 13 ~ 16 开启动态 uprobe

      1. 计算动态 int uprobed_add(int a, int b) 的 offset
      2. 创建函数 perf event probe
      3. 函数 perf event probe 绑定到 BPF program
      4. 启动 函数 perf event probe
    1. 开启静态 uprobe
    • 利用 libbpf 根据 uprobe.bpf.o ELF 文件的 section 信息,自动计算 int uprobed_sub(int a, int b) offset。之后完成类似上面 开启动态 uprobe 的过程。最终 函数 perf event probe 绑定到 BPF program BPF_KPROBE(uprobe_sub, int a, int b)
    1. 用户态应用定时调用函数 uprobed_add(int a, int b)uprobed_sub(int a, int b) 触发了 BPF progam

我 fork 了项目到这里:

https://github.com/labilezhu/libbpf-bootstrap/tree/20230226

后记

这个后记和本文没什么相关了,不喜可跳过。最近看了一部 1994 年的电影。

image.png


MarkZhu
83 声望21 粉丝

Blog: [链接]