0%

BPF之巅的学习--追踪系统历史与相关技术

BPF之巅的学习–追踪系统历史与相关技术

早期与发展

最初的BPF是被开发来在BSD系统上使用的. 通过将BPF虚拟机的指令集定义的过滤器表达式传递给内核的解释器执行从而避免网络数据包从内核态到用户态的复制. tcpdump就是这一技术的用户态程序. 扩展版的eBPF是在其基础上发展的, 提供了更多的寄存器和映射型存储. 现在大部分人说的BPF就是指eBPF.

相比perf以及内核模块

相比perf在内核态就可以做过滤等统计操作, 减少了内核态向用户态的拷贝数据量.
相比内核模块要安全,且开发成本低, 不依赖特定的内核编译过程. 但是相比少了些任意使用内核内部函数的灵活性.

BPF的前端程序

低级语言到高级语言: LLVM->BCC->bpftrace

BCC与bpftrace在内部都使用LLVM中间表示形式和LLVM库来实现BPF的编译.

Linux源码中有BPF指令的文档. 也可参考LLVM IR文档和Cilium BPF与XDP文档

Linux4.15后可以通过bpftool来查看和操作BPF对象.
Linux4.17以后BCC和bpftrace都会使用perf_event_open()进行BPF程序的挂载.

BPF的API

bpf_probe_read() 用于访问BPF之外的内存空间, 这个函数会进行安全检查并且禁止缺页中断的发生以保证probe上文中不会引发内核错误. x86上内核空间和用户空间没有重叠, 故通过地址读取不会存在问题. 而在SPARC上, 则必须通过bpf_probe_read_kernel()bpf_probe_read_user()来区别使用.

BPF并发控制

Linux5.1中增加了spin lock(bpf_spin_lock(), bpf_spin_unlock())来确保并发一致性. 而之前的版本则需要通过per-CPU的映射表来绕过并发问题. 其并发读写映射表的问题被成为”丢失的更新”问题.

BPF_XADD(互斥加操作), 映射中的映射机制等都可保证原子操作.
bpf_map_update_elem()对常规的hash和LRU map的操作也是原子的.

BTF和BPF CO-RE

BPF Type Format, 元数据格式, 可以内嵌到vmlinux的二进制中, 使得可以方便获得被跟踪的源代码信息.
BPF CO-RE是一次编译到处运行的意思, 旨在将BPF一次性编译位字节码分发执行, 避免在嵌入式环境需要安装LLVM和Clang的问题.

BPF限制

内核中无限循环是不允许的. 解决办法包括循环展开, 增加特定辅助函数等. Linux5.3支持受限的循环.
BPF栈大小不能超过MAX_BPF_STACK限制, 值位512.
BPF指令的总数据量早期为4096, 5.2以后限制为100万.

调用栈回溯

  1. 基于帧指针的回溯, 惯例: 调用返回地址永远位于RBP执行+偏移8的位置. gcc默认不启用, 需要通过-fno-omit-frame-pointer开启. 默认不启用的原因: i386中引入的, 为了将rbp寄存器释放出来使用以提升性能.编译器gcc vs icc性能比拼. 以及可以通过debug info来获得栈回溯等. x86_64体系结构上寄存器有16个了, 所以提升性能的收益并不明显了.
  2. 调试信息debug info. DWARF格式的ELF调试信息, 通过.eh_frame.debug_frame的ELF文件段提供. 缺点是文件过大, BPF也不支持(处理器消耗很大且可能需要读取没有加载到内存中的ELF信息, 使得在禁用中断的BPF上下文中实现不可能) BPF的前端BCC和bpftrace则支持使用调试信息.
  3. 最后分支记录LBR. Intel处理器的特性, 支持有限深度的回溯4-32个, 通过硬件缓冲区记录, 没有额外开销. BPF不支持.
  4. ORC调试格式信息, Oops回滚能力. 相比DWARF对处理器要求低, 使用.orc_unwind.orc_unwind_ip的ELF段. Linux内核已经支持. 可通过perf_callchain_kernel()获取.用户态还未支持.

调用栈信息在内核中是以地址数值的形式记录的, 这些地址需要在用户态通过翻译成为对应的符号.

火焰图

查看调用栈以及热点代码最方便. 其他变体: 冰柱图(Y轴反转), 火焰时序图, 差分火焰图(diff图, 对比两个跟踪结果)

kprobes

插桩过程:

  1. 将插入的目标地址的字节内容复制并保存
  2. 以单步中断覆盖目标地址(x86_64是int3,如果优化则是jmp),
  3. 执行到断点后跳转到处理函数执行,
  4. 原始指令会接着执行.
  5. 当不再需要时复制回原始的字节内容.
    如果kprobe是一个Ftrace的已经插桩的地址, 则将kprobe注册为对应Ftrace处理器, 通过入口函数(x86上是gcc4.6是fentry)调用Ftrace跳到处理函数. 不再使用则会移移除Ftrace的注册.
    如果是kretprobe, 则会对函数入口进行kprobe插桩, 通过kprobe将函数的返回地址保存后替换为一个trampoline函数, 当函数执行ret时会跳到对应的函数处理, 完成后再跳回来到之前的保存的地址. 不再使用则同样移除入口的kprobe.

kprobe的处理过程可能需要禁止抢占或禁止中断.. 内核设计以些不允许kprobes的函数黑名单, 包括kprobes自己, 可以防止递归.

kprobes使用jmp指令时, 会先调用stop_machine()函数来保证修改代码时其他CPU核心不会执行指令.

最大的开销时对频繁调用的函数执行插桩是每次函数调用的小开销会叠加最终产生性能影响.

kprobes在某些ARM64上不能正常工作, 因为其内核代码不允许修改.

3种接口可访问kprobes:

  1. API, register_kprobes()等
  2. 基于Ftrace的, 通过/sys/kernel/debug/tracing/kprobe_events写入字符串可起停kprobes
  3. 通过perf_event_open(), 与perf工具一样

uprobes

uprobes是基于文件的, 当一个可执行文件中的函数被跟踪时, 所有用到该文件的进程都会被插桩, 包括未启动的进程. 工作原理与kprobes类似.

接口:

  1. 基于Ftrace的. 通过/sys/kernel/debug/tracing/uprobe_events写入字符串可起停uprobes
  2. 通过perf_event_open(), 与perf工具一样, 4.17以上版本支持.
    内核中也有register_uprobe_events(), 但是不以API形式暴露.

在频繁调用的函数上开销会很大, 如malloc/free.

tracepoints

内核的静态跟踪点 大约100+, 禁用开销为NOP开销, 且很稳定.

跟踪点格式: “子系统:事件名”, 如sched:context_switch.

原理:

  1. 内核编译阶段插入5字节的nop指令(确保可以被替换为5字节的jmp)
  2. 函数尾部会插入一个跟踪点处理函数, 也叫蹦床函数. 其会遍历存储跟踪点探针回调函数的数组.
  3. 执行时, 当跟踪点被启用, 会在回调函数数组中插入一条新的跟踪回调函数, 以RCU形式同步更新.如果之前处于tracepoints处于禁用状态, nop指令会被重写为到蹦床函数的跳转指令.
  4. 禁用时会以RCU形式删掉回调函数数组, 如果时最后一个则会写回nop.

回调函数数组中可以依次插入多个回调函数, 这样可以支持多个回调函数在同一个追踪点上进行追踪处理, 其会被依次执行.

BCC中可以通过BPF.tracepoint_exists来测试是否存在某个追踪点.

原始跟踪点: BPF_RAW_TRACEPOINT, 向跟踪点暴露原始参数, 避免创建跟踪点参数的开销. 其性能要好于kprobes

USDT

许多应用使用--enable-dtrace-probe--with-dtrace来开启. 通过systemtap-sdt-dev包提供头文件.或使用Facebook的Folly C++库.

可通过readelf -n 来查看二进制的USDT探针以及获得二进制的偏移量. 通过段位置的起始地址+偏移量找到位置.

编译时使用nop, 激活后被修改为int3. 触发后内核会执行中断响应触发BPF程序.

PMC

性能监控计数器(又称性能观测计数器PIC, CPU性能计数器CPC, 性能监控单元事件PMU events), 都指处理器上的硬件可编程计数器.

任一时刻CPU中只允许固定数量的寄存器<6个读取 PMC. 可通过循环采用的方式读取.

PMC支持模式:

  1. 计数
  2. 溢出采样, 发生一定次数后通知内核

PEBC

由于存在中断延时或乱序执行, 溢出采样不能精确记录数据. Intel的精确事件采样方案(PEBC)就是使用硬件缓冲区来记录PMC事件发生的正确指令指针, perf支持PEBC.