前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >eBPF原理介绍与编程实践

eBPF原理介绍与编程实践

原创
作者头像
johnazhang
发布2022-07-18 12:50:42
2.3K1
发布2022-07-18 12:50:42
举报
文章被收录于专栏:Linux问题笔记Linux问题笔记

注:本文包括了ebpf的原理介绍、流程分析、相关资料链接、工具编写实战等,可以选择感兴趣的部分直接阅读;鉴于作者语文水平有限,很多地方描述可能不清楚,有错误或疑问欢迎指出交流

1.    初步了解

1.1 eBPF

eBPF是一个用RISC指令集设计的VM,他可以通过运行BPF程序来跟踪内核函数、内存等。

用Linux社区大牛Gregg的话来讲,“eBPF does to Linux what JavaScript does to HTML”。

1.2 BCC

BCC,全名BPF Compiler Collection,在 github.com/iovisor/bcc,  这个工具集提供了很多样例的tracing工具可以直接使用,同时也提供了可用于开发这些工具的python、lua接口。

安装步骤: https://github.com/iovisor/bcc/blob/master/INSTALL.md

BCC工具教程: https://github.com/iovisor/bcc

BCC python教程: https://github.com/iovisor/bcc/blob/master/docs/tutorial_bcc_python_developer.md

BCC reference: https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md

1.3 bpftrace

bpftrace 是一组bcc的封装工具,比起bcc是面向一些更复杂、庞大的问题而言,bpftrace进一步封装,调用了bcc接口来实现了通过一行脚本来定位一些特定场景下的问题。

地址github.com/iovisor/bpftrace

安装步骤: https://github.com/iovisor/bpftrace/blob/master/INSTALL.md

Bpftrace编程教程: https://github.com/iovisor/bpftrace/blob/master/docs/tutorial_one_liners.md

Bpftrace reference: https://github.com/iovisor/bpftrace/blob/master/docs/reference_guide.md

2.    eBPF 原理

注: 以下全部代码基于Linux 5.8

2.1 工作流程

编译器把C代码编译成eBPF字节码(这里有点像jvm机制,只不过这个ebpf的vm在内核)。

用户态程序把字节码、程序类型发给内核,以此来决定哪些内核区域(代码、内存)可以被这个程序访问(通过调用 bpf_prog_alloc 然后把字节码复制到 prog->insns, 见图2.1.1 bpf_prog struct)。

内核对字节码做一个检查,保证这个程序是安全的(没有越界、死循环等等),代码在kernel/bpf/verifier.c。

内核通过JIT编译字节码到机器码,然后通过kprobe、tracepoint来把这些代码插入到对应的位置。

这些被插入的bpf程序代码会把数据写到他们自己的ringbuffers或者key-value maps(后面会讲到,ebpf存储数据的自定义内核数据结构)。

用户态读取这些ringbuffers或者maps来获取想要的数据。

图 2.1.1 struct bpf_prog
图 2.1.1 struct bpf_prog

2.2 VM

ebpf是一个使用RISC指令集的虚拟机,他使用PC,11个64位寄存器和一个固定大小为512字节的栈。其中9个寄存器是通用寄存器,一个只读栈帧寄存器,一个PC寄存器(地址偏移是有限制的)。寄存器总是64位大小,在32位机器上会默认把前32位置零,这也为ebpf提供了交叉编译的兼容性。

2.2.1 寄存器

寄存器如下 (因为笔者比较熟悉x86,所以这里对比 x86_64 做说明):

R0: RAX, 存放函数返回值或程序退出状态码

R1: RDI,第一个实参

R2: RSI,第二个实参

R3: RDX,第三个实参

R4: RCX,第四个实参

R5: R8,第五个实参 (别问为啥没有第六个实参)

R6: RBX, callee saved

R7: R13, callee saved (别问为啥没有R12)

R8: R14, callee saved

R9: R15, callee saved

R10: RBP, 只读栈帧

通过寄存器的设计,我们可以看到,每个函数调用允许5个参数,这些参数只允许立即数或者指向自己的ebpf栈(通用内核栈是不被允许的)上的指针,所有的内存访问必须先把数据放到ebpf自己的栈上(512字节的栈),才能被ebpf程序进一步操作。

2.2.2 指令集

指令统一也都是64位大小,目前大概有100条左右(被分为八类)的指令。支持常用的访问通用内存(ebpf maps、stack等)的1-8字节的 load/store指令、向前向后的有无条件跳转指令、算数/逻辑运算指令和函数调用指令等。

ebpf指令对应的数据结构见图2.2.1:

图 2.2.1 struct bpf_insn
图 2.2.1 struct bpf_insn

指令定义在include/linux/filter.h,样例见图2.2.2:

图 2.2.2 ebpf指令示例
图 2.2.2 ebpf指令示例

2.3 BPF_CALL

ebpf函数是通过 BPF_CALL_* 宏来定义的,*代表形参数量。这个设计思路是仿照Linux系统调用的设计来实现的。比如说bpf函数bpf_probe_read_user 有三个参数,其定义见图2.3.1:

图 2.3.1 bpf_probe_read_user defined by BPF_CALL_3
图 2.3.1 bpf_probe_read_user defined by BPF_CALL_3

2.4 Verifier

verifier是保证ebpf程序可以安全的在内核中运行的一个机制。她不允许ebpf程序中存在循环,向前的跳转指令(导致循环),并且会做栈大小检查。

当指令在内核数据结构中完成初始化后(prog->insn)。VM会调用 bpf_check (定义在kernel/bpf/verifier.c)来对ebpf的合法性做一个检查,该函数主要执行以下几个步骤(为了简略,此处省略了一些细节):

1. 为对应的数据结构分配内存

2. 拿取相应的锁

3. 调用 replace_map_fd_with_map_ptr 来对用户定义的map(最终存储我们感兴趣信息的数据结构)做解析

4. 调用 check_subprogs 来保证jmp族的指令都是合法的偏移量(在每个ebpf子程序内部,ebpf可以包含多个子程序)

5. 调用 check_cfg 检测循环以及无法达到的跳转(通过DFS算法实现)

6. 调用 do_check_* 检查寄存器和参数的访问权限,同时更新对应位置的ebpf栈

7. 调用 check_max_stack_depth 检查栈的内存消耗不超过512字节,同时调用栈深度不超过8层

8. 优化,干掉一些dead code(不会走到的分支等)

9. 调用 fixup_bpf_calls 验证 BPF_CALL 指令

10. 调用 fixup_call_args 对函数进行jit,可以参考后面的jit相关小节

11. 如果走到这里,ebpf程序已经通过了验证,maps等相关信息数据结构会被成功更新(可以被后续用户态拿取)

do_check 函数通过struct bpf_func_state, 对寄存器、访问权限和其他相关的函数数据进行控制,见图2.3.1:

图 2.3.1 struct bpf_func_state
图 2.3.1 struct bpf_func_state

bpf_reg_state是枚举类型 bpf_reg_type, 见图 2.3.2:

图 2.3.2 enum bpf_reg_type
图 2.3.2 enum bpf_reg_type

2.5 JIT

从上一小节我们了解到 fixup_call_args 会调用 jit_subprog,而后者会对ebpf程序函数进行jit。

jit_subprog 主要做了如下步骤:

1. 如果程序只有一个bpf函数,则直接返回

2. 过滤掉所有的内部函数调用指令(可以理解为inline展开)

3. 对于每个函数调用,构造一个子程序的数据结构并初始化(struct bpf_prog

4. 对每个子程序调用 bpf_int_jit_compile

5. 修正jmp指令的偏移量, 将他们保存到 insn->imm 

6. 调用bpf_int_jit_compile再次修正上述的jmp偏移量(笔者也不明白这里为啥要修正两次,结合内核其他地方的喜好来看,内核很多这种东西都会做两次来做一个双重保障,暂且这么理解吧)

7. 调用bpf_prog_lock_ro将各个函数的代码段处的访问权限改为RO

8. 调用bpf_prog_kallsyms_add将各个函数的符号信息添加到kallsyms里去

当程序通过验证后,调用bpf_prog_select_runtime然后调用do_jit来做真正的jit工作,后者可以被理解为是编译器的后端,把ebpf程序指令(中间码)到体系结构相关机器吗(目标码)。需要注意以下几点:

1. ebpf栈需要在最早就被初始化,并通过调用emit_prologue匹配体系结构的ABI

2. 对于绝大部分指令,只需要按照对应关系转换对应的寄存器和opcode(寄存器关系可以参考2.2.1节)

3. 对于BPF_CALL指令,偏移量需要被确定(作为立即数)

4. 对于BPF_EXIT指令,callee saved相关的寄存器会先被弹出,栈空间恢复,然后调用ret指令

do_jit调用之后,prog->bpf_func 就已经是可以被直接执行的体系结构相关指令了。

2.6 kprobe

eBPF追踪器是用kprobe和perf实现的。 比如以 kprobe_* 开头的eBPF函数是通过create_local_trace_kprobe实现的,其最终调用了 trace_call_bpf (这里还没有进行详细的源码分析,后续补充细节)。

2.7 map

eBPF map的实现是同时有用户态和内核态的。 在用户态中,他是在可执行文件格式ELF中加一个字段来专门存放map相关的信息;在内核态,当解析完ELF后,内核将map字段里的信息拷贝到内核地址空间来做进一步的操作(这里还没有进行详细的源码分析,后续补充细节)。

3 ebpf程序样例分析

这一部分会深入研究一些bcc工具的实现。

关于编写bcc程序的教程可以参考1.2小节的python教程链接。

3.1 opensnoop

opensnoop (源码在 bcc/tools/opensnoop) 是一个用于跟踪open() 系统调用的bcc工具, 使用python实现,使用样例在 https://github.com/iovisor/bcc/blob/master/tools/opensnoop_example.txt

参数解析部分, 调用argparse实现, 见图 3.1.1:

图 3.1.1 argparse in opensnoop
图 3.1.1 argparse in opensnoop

然后对ebpf程序用字符串直接定义。

这里定义了多个代码段,用于追踪不同的函数,见图3.1.2. 同样这里支持了kfunc(静态probes而不是动态追踪)实现,因为在支持静态probes的机器上,肯定是静态执行的效率更高,见图3.1.3. 这种设计让这个工具可以支持跨平台,实现平台无关(其实这里主要针对的是不同版本的Linux的接口差异)。以上这些过于细节,就先不展开介绍了。

图 3.1.2 针对不同接口设计的不同ebpf代码段
图 3.1.2 针对不同接口设计的不同ebpf代码段
图 3.1.3 在支持kfunc的内核上使用kfunc
图 3.1.3 在支持kfunc的内核上使用kfunc

现在看下真正的ebpf程序。

首先,定义自己的map值(注意这里是value!也就是自定义最后你想要ebpf反馈给你什么。而key的话这里的数据类型则是u64。)数据结构,见图3.1.4:

图 3.1.4 struct val_t for map’s value type
图 3.1.4 struct val_t for map’s value type

然后定义perf输出的数据结构(这不是必须的,看你想用什么来输出,这个工具肯定是试图做到最完善,但理论上只要知道想用什么输出定义什么就好了), 见图 3.1.5:

图 3.1.5 struct data_t for perf outputs
图 3.1.5 struct data_t for perf outputs

再通过 BPF_PERF_OUTPUT 定义perf输出事件。通过BPF_HASH 来定义map, 见图3.1.4。

当使用kprobe动态追踪时,我们还需要定义需要注入的hook函数(在目标函数前后)。这里trace_return (见图 3.1.6) 就是要在 open()后执行的函数, 通过在python程序中添加 b.attach_kretprobe 来定义上述规则。

图 3.1.6 trace_return
图 3.1.6 trace_return

注意ebpf函数的第一个参数总是 struct pt_regs *.

通过调用 bpf_get_current_pid_tgid 获取当前pid,调用 bpf_ktime_get_ns 获取当前timestamp。

然后通过lookup方法在map中搜索当前pid(map中的key)是否存在,如果没有找到则直接返回0。

然后再调用 bpf_probe_read_* 去获取map中想要的值,保存到 data 并用于perf输出。

data 没问题后, 调用 perf_submit 提交perf poll事件来让perf输出。

最后将当前pid从map中删除。

对于基于kfunc的实现而言,函数定义上会稍有区别,见图 3.1.7, 其中省略了一些细节。 如果想了解详细的写法,教程获取地址https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md#9-kfuncs

图 3.1.7 kfunc 风格的定义方式
图 3.1.7 kfunc 风格的定义方式

在kfunc,可以通过 PT_REGS_PARM* 来获取目标函数的第 *th 个参数, 见图 3.1.8。

图 3.1.8 用 PT_REGS_PARM1 获取open()的第一个参数
图 3.1.8 用 PT_REGS_PARM1 获取open()的第一个参数

kprobe部分(在目标函数执行前的hook代码段), 见图 3.1.9:

图 3.1.9 kprobe
图 3.1.9 kprobe

通过调用 bpf_get_current_pid_tgid 获取tid,然后右移32位获取 pid 。

通过调用 bpf_get_current_pid_gid 获取gid。

通过调用 bpf_get_current_comm 来获取当前进程名到 val_t val.comm 中, 然后调用map的update 方法来 填充 key-value pair。

可以通过在ebpf程序中设置tag(见图3.1.9中的PID_TID_FILTER等),然后在后续用对应的代码段进行查找替换, 是在python中直接通过replace实现的,见图 3.1.10.

图 3.1.10 filter tags replacement
图 3.1.10 filter tags replacement

其余的python代码比较杂,主要包含了初始化BPF,附加probes,格式化字符串和调用perf函数poll输出(见图 3.1.11).

图 3.1.11 python用perf打印输出的部分
图 3.1.11 python用perf打印输出的部分

3.2 实战:动手修改优化 disksnoop.py

disksnoop.py 在 bcc根目录的 examples/tracing/ 目录下, 源码见图3.2.1. 他用于统计IO层的block request的时间消耗。

从代码中可以看到,他仅仅记录了每一次request的耗时,粒度太粗,很多时候无法更精确的定位问题,我们希望他的粒度可以更细一点,或者记录更多的东西,比如记录读写的分别耗时或者每次操作的bytes数。

另外值得注意的是,他将probes分别注入到了内核block request相关的函数(blk_start_request等)的前后,并且是动态hook的,这种动态hook的方式基本能做到hook所有的有符号信息的内核函数,但是效率会比较低下。

因此一个优化思路就是做静态埋点,将probes放到一些会被经常trace的地方(比如网络栈上收发包,io请求,常见系统调用等)。

新版的内核引入为这种优化思路提供的接口(做了静态埋点),接口为 TRACEPOINT, 详细说明可以在这里查看 https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md#3-tracepoints

因此在本例中,我们可以针对高版本内核进行优化,通过attach_kprobe 到 TRACEPOINT ,将trace方式由动态改为静态的。同时我们添加一个功能去分别记录每一次request的bytes数 。

开始动手,首先,我们将probe函数的定义改为高版本支持的TRACEPOINT_PROBE(module_name, trace_point), 见图3.2.2.

图 3.2.2 静态 tracepoint 定义
图 3.2.2 静态 tracepoint 定义

通过查看 /sys/kernel/debug/tracing/events/*moudle_name*/*tracepoint_name* 去看有哪些支持的tracepoint。

因此在本例中,需要查看block相关的tracepoint,可以找到tracepoint block_rq_issue,而而且我们可以通过/sys/kernel/debug/tracing/events/block/block_rq_issue/format 了解参数格式,见图3.2.3。

图 3.2.3 arguments format of block_rq_issue
图 3.2.3 arguments format of block_rq_issue

在图3.2.3中可以看到一个bytes域,如果需要记录bytes,可以直接在我们的程序中去使用它。

在此之前,需要先想办法保存他,最简单直接的方法是自己定义一个map去存储,但是从前面ebpf原理的分析我们知道map是分离的,如果新搞一个会很影响性能,因此我们需要重写当前的map, 见图3.2.4.

图 3.2.4 修改data type同时存储ts和bytes
图 3.2.4 修改data type同时存储ts和bytes

现在我们需要获取bytes(在 TRACEPOINT_PROBE 中是通过 args->*在format文件中的参数名* 获取) 来更新map,还有一点需要注意的是我们使用 dev(id) + sector(id) 来作为 key (这个是来自Gregg’s 对写IO requests相关工具开发者的建议), 见图 3.2.5.

图 3.2.5 probe block_rq_issue body代码
图 3.2.5 probe block_rq_issue body代码

对于 block_rq_complete 写法非常类似,见图 3.2.6.

图 3.2.6 probe block_rq_complete body代码
图 3.2.6 probe block_rq_complete body代码

现在ebpf部分程序已经改完了,最后我们需要修改一下python部分来增加一下新增的输出,这里我们仅需要将输出的trace_point的最后一个域调用split,第一部分打印我们刚加的bytes,第二部分打印原来的耗时。(因为这里是顺序打印, 可以参考图3.2.6重的bpf_trace_printk ), python部分见图 3.2.7.

图 3.2.7 python 输出格式
图 3.2.7 python 输出格式

这里对 disksnoop.py的改动已经完成了,在本例中其实改动非常简单,仅起到抛砖引玉的作用,对于更多的其他功能,需要研究有没有相关的tracepoint,如果没有就只能做动态trace了。

4 参考文献

http://www.brendangregg.com/blog/2019-01-01/learn-ebpf-tracing.html

http://www.brendangregg.com/ebpf.html

https://github.com/iovisor/bcc/blob/master/docs/tutorial.md

https://github.com/iovisor/bcc/blob/master/docs/reference_guide

https://github.com/iovisor/bcc/blob/master/docs/tutorial_bcc_python_developer.md

https://www.collabora.com/news-and-blog/blog/2019/04/05/an-ebpf-overview-part-1-introduction/

https://www.collabora.com/news-and-blog/blog/2019/04/15/an-ebpf-overview-part-2-machine-and-bytecode/

https://blog.csdn.net/hjkfcz/article/details/104916719

https://blog.yadutaf.fr/2016/03/30/turn-any-syscall-into-event-introducing-ebpf-kernel-probes/

https://blog.aquasec.com/intro-ebpf-tracing-containers

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1.    初步了解
    • 1.1 eBPF
      • 1.2 BCC
        • 1.3 bpftrace
        • 2.    eBPF 原理
          • 2.1 工作流程
            • 2.2 VM
              • 2.2.1 寄存器
              • 2.2.2 指令集
            • 2.3 BPF_CALL
              • 2.4 Verifier
                • 2.5 JIT
                  • 2.6 kprobe
                    • 2.7 map
                    • 3 ebpf程序样例分析
                      • 3.1 opensnoop
                        • 3.2 实战:动手修改优化 disksnoop.py
                        • 4 参考文献
                        领券
                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档