本文目的是给大家建立一个对 Linux 跟踪系统和 BPF 的整体认知.
系统和软件的可观测性是系统和应用性能分析和故障排查的基础.最小化生产环境中性能观测带来的额外负担是一件很有挑战性的工作, 但其回报是丰厚的.在 Linux 系统上, 有一些很有用的跟踪工具, 如 strace 和 ltrace 分别可用来跟踪哪些系统调用和哪些动态库被调用, 这些工具能提供一些有用的信息但是有限, 同时使用这些工具也会给性能带来不小的额外影响, 这使得它们不是非常适合用于生产环境中的调试和观测.
BPF 提供了一种全新的跟踪技术方案, 它与其它的 Linux 跟踪技术最大的不同之处在于 1) 它是可编程的:BPF 程序被链接到内核作为内核的一部分运行;2) 它同时具备高效率和生产环境安全性的特点, 我们可以在生产环境中直接运行 BPF 程序而无须增加新的内核组件.
在开始正式介绍 BPF 之间我们首先来看一下 Linux 跟踪技术的的整体图景.概括地来说,Linux 上的跟踪系统由三层构成: 前端, 跟踪框架和事件源
图 1.1. 跟踪系统框架
事件源是跟踪数据的来源, 它们是内核提供的事件跟踪最底层接口, 由跟踪框架使用,Linux 提供了丰富的事件源.跟踪框架运行于内核, 根据前端提供的参数注册要跟踪的事件, 负责事件发生时的数据采集和计数, 如果跟踪框架支持可编程的跟踪器, 那么它还是直接在内核态对数据进行聚合、汇总、过滤、统计等处理.前端运行在用户态, 它读取跟踪框架收集的数据进行处理和展示.用户根据前端展示的结果来进行性能分析和故障排查.
可观测性 (observability) 是指通过全面观测来理解一个系统, 可以实现这一目标的工具就可以归类为可观测性工具.这其中包括跟踪工具、采样工具和基于固定计数器的工具.但不包括基准测量(benchmark)工具, 基准测量工具在系统上模拟业务负载, 会更改系统的状态.BPF 工具就属于可观测性工具, 它们使用 BPF 技术进行可编程型跟踪分析.
跟踪 (tracing) 是基于事件的记录—-这也是 BPF 所使用的监测方式.例如 Linux下的 strace(1)就是一个跟踪工具, 它可以记录和打印系统调用事件的信息.有许多监测工具并不跟踪事件, 而是使用固定的计数器统计监测事件的频次, 然后打印出摘要信息:Linux top(1)便是这样的例子.跟踪工具的一个显著标志是, 它具备记录原始事件和事件元数据的能力.但这类数据的数量不少, 因此可能需要经过后续处理生成摘要信息.BPF 技术催生了可编程的跟踪工具的实现, 这些工具可以在事件发生时, 通过运行一段小程序(一个或几个函数)来进行定制化的实时统计摘要生成或其他动作.
采样 (sampling) 工具通过获取全部观测量的子集来描绘目标的大致图像; 这也被称作生成性能剖析样本或 profiling.有一个 BPF 工具就叫 profile(8), 它基于计时器来对运行中的代码定时采样.采样工具的一个优点是, 其性能开销比跟踪工具小, 因为值对大量事件的一部分进行测量.采样的缺点是, 它只提供了一个大致的画像, 或遗漏事件.
探针 (probe) 软件或者硬件中的探测点, 它产生一个事件, 该事件将导致内核中的一段程序被运行.
静态跟踪 (static tracing) 跟踪的事件由硬编码的探针产生, 这类探针的位置在编译时就已经写死比如 tracepoint.因为这些探针是固定的, 所以它们的 API 比较稳定, 可以对它们编写文档, 但同时这也意味着这类事件源是不灵活的.
动态跟踪 (dynamic tracing) 跟踪的事件可以在运行时动态的创建和撤销, 这使得我们可以跟踪软件中的任何事件比如任意函数的调用和返回, 具有极大的灵活性, 但是软件接口是发展变化的, 这类事件接口是不稳定, 因此和也很难为其编写文档.
事件 (event) 直接描述什么是事件可能是一件很困难的事情, 因为事件可能由硬件、内核和用户程序触发, 事件产生时的硬件和软件环境也很复杂.不过好在用户从来都不需要去直接处理事件, 对事件的直接处理是由内核自动完成的.因此我们可以抖机灵的把事件定义为: 内核在特定的条件下执行的一段特定的程序, 这个程序会收集该条件发生时系统硬件和软件环境的一些信息(“事件上下文”), 并将这些信息保存在内核缓冲区或者更新计数器.所以从用户的角度看, 发生一个事件等效于内核产生了一个“事件上下文”或者更新了计数器, 如果跟踪框架支持可编程的事件跟踪(如 BPF,systemtap)那么事件发生时内核还会执行由用户注入到内核中关联到该事件的程序.
图 1.2. Linux 跟踪技术发展时间线
图 1.3. Linux 跟踪技术栈
kernel tracepoint
tracepoint 是内核预先定义的的静态跟踪事件源.tracepoint 可以用来对内核进行静态插桩.内核开发着在内核函数中的特定逻辑位置出, 有意放置了这些插桩点, 对于内核开发者来说,tracepoint 有一定的维护成本, 而且它的使用范围比 kprobe 要窄得多.使用tracepoint 的主要优势是它的 API 稳定; 基于 tracepoint 的工具, 在内核版本升级后一般仍然可以正常工作.同时它带来的额外负担也较轻.在能满足探测需要时应优先考虑使用 tracepoint.Linux tracepoint 事件名的格式是“子系统: 事件名”.
tracepoint 出于不启用状态时, 性能开销要尽可能小, 这是为了避免对不使用的东西“交性能税”, 为此 Linux 使用了一项叫做“静态跳转补丁(static jump patching)”的技
术.其工作原理如下:
在内核编译阶段会在 tracepoint 所在的位置插入不做任何具体工作的指令, 在 x86架构下就是有 5 个字节的 nop 指令, 这个长度的选择是为了确保之后可以将它替换为一个 5 字节的 jmp 指令.
在函数尾部插入一个 tracepoint 处理函数, 也叫做蹦床函数, 这个函数会遍历一个存储 tracepoint 回调函数的数组.这会导致函数编译结果稍稍变大.(之所以称之为蹦床函数, 是因为在执行过程中函数会跳入, 然后再跳出这个处理函数), 这很可能会对指令缓存会有一些小影响.
在执行过程中, 当某个跟踪器启动 tracepoint 时(该 tracepoint 可能已经被其他跟踪器启用):
a. 在 tracepoint 回调函数数组中插入一条新的 tracepoint 回调函数, 以 RCU 的形式进行同步更新
b. 如果 tracepoint 之前出于禁用状态,nop 指令的地址会被重写为跳转到蹦床函数的指令.
当跟踪器禁用某个 tracepoint 时:
a. 在 tracepoint 回调函数数组中删除该跟踪器的回调函数, 以 RCU 的形式进行
同步更新
b. 如果最后一个回调函数也被删除了, 那么将 jmp 再重写为 nop 指令.
这样可以最小化出于禁用状态的 tracepoint 的性能开销, 几乎可以忽略不计.
tracepoint 有以下两个接口:
基于 Ftrace 的接口, 通过/sys/kernel/debug/tracing/events: 每个 traceppoint 子系统有一个子目录, 每个 tracepoint 对应该子目录下的一个文件(通过向这些文件中写入内容来开启或者关闭跟踪点).
perf_event_open(): 这是 perf(1) 工具一直以来使用的接口, 近来 BPF 跟踪也开始
用(通过 perf_tracepoint PMU).
kprobes
kprobes 提供了针对内核的动态插桩支持.kprobes 可以对任何内核函数进行插桩, 它还可以对函数内部的指令进行插桩.它可以实时在生产环境系统中启用, 不需要重启系统,也不需要以特殊方式重启内核.这是一项令人惊叹的能力, 这意味着我们可以对 Linux 中数以万计的内核函数任意插桩.根据需要生成指标.
kprobes 技术还有另外一个接口, 即 kretprobes(其实还有一个 jprobes 接口, 但已经废弃不再维护), 用来对 Linux 内核函数返回时进行插桩以获取返回值.当用 kprobes 和kretprobes 同时对同一个内核函数进行插桩时, 可以使用时间戳来记录函数执行的时长,这在性能分析中是一个重要的指标.
使用 kprobes 对内核进行动态插桩的过程如下:
A. 对于一个 kprobe 插桩来说:
把要插桩的目标地址中的字节内容复制并保存(为的是给单步断点指令腾出
位置).
以单步中断指令覆盖目标地址: 在 x86_64 上是 int3 指令.(如果 kprobes 开
了优化, 则使用 jmp 指令).
当指令流执行到断点时, 断点处理函数会检查这个断点是否是由 kprobes 注册的, 如果是, 就会执行 kprobes 处理函数.
kprobes 处理函数执行完后原始的指令会接着执行, 指令流继续.
当不再需要(deactivate)kprobes 时, 原始的字节内容会被复制回目标地址上,
这样这些指令就回到了它们的原始状态.
B. 如果这个 kprobe 是一个 Ftrace 已经做过插桩的地址(一般位于函数的入口处),
那么可以基于 Ftrace 进行 kprobe 优化, 过程如下:
将一个 Ftrace kprobe 处理函数注册为对应函数的 Ftrace 处理器
当在函数起始处执行内建入口函数时 (在 x86 架构 gcc 4.6 下是 fentry),
该函数会调用 Ftrace,Ftrace 接下来会调用 kprobe 处理函数.
当 kprobe 不在会被调用时, 从 Ftrace 中移除 Ftrace-kprobe 处理函数.
C. 如果是一个 kretprobe:
对函数入口进行 kprobe 插桩.
当函数入口被 kprobe 命中是, 将函数返回地址保存并替换为一个“蹦床”(tram-
poline)函数地址.
当函数最终返回时,CPU 将控制权交给蹦床函数处理.
在 kretprobe 处理完成之后在返回到之前保存的地址.
当不再需要 kretprobe 时, 函数入口的 krobe 和 kretprobe 处理函数就被移除了.
根据当前系统和体系结构的一些其它的因素,kprobe 的处理过程可能需要禁止抢占和中断.另外在线修改内核函数体是风险极大的操作, 但是 kprobe 从设计上就已经保证了自身的安全性.在设计中包括了一个不允许 kprobe 动态插桩的函数黑名单, 其中 kprobe自身就在名单之列, 可防止出现递归陷阱的情形.kprobe 同时利用的是安全的断电插入技术, 比如使用 x86 内置的 int3 指令.当使用 jmp 指令时, 也会先调用 stop_machine()函数, 来保证在修改代码的时候其它 CPU 核不会执行指令.在实践中, 最大的风险是, 在需要对一个执行频率非常高的函数插桩时, 每次对函数调用小的开销都会叠加, 这会对系统的性能产生一定的影响.
kprobe 在某些 ARM 64 位系统上不能正常工作, 出于安全性的考虑, 这些平台上的内核代码区不允许被修改.
有以下三种接口可以访问 kprobe:
kprobe API: 如 register_kprobe() 等
基于 Ftrace 的, 通过/sys/kernel/debug/tracing/kprobe_events: 通过向这个文件写入字符串, 可以配置开启和停止 kprobe
perf_event_open(): 与 perf(1) 工具所使用的一样, 近来 BPF 跟踪工具也开始使用
这些函数.在 Linux 内核 4.17 中加入了相关的支持(perf_kprobe PMU).
uprobe
uprobe 提供了用户态程序的动态插桩, 与 kprobe 相似, 只是在用户态程序使用.up- robe 可以在用户态程序的以下位置插桩: 函数入口, 特定偏移处, 以及函数返回处.uprobe也是基于文件的, 当一个可执行文件中的一个函数被跟踪时, 所有使用到这个文件的进程都会被插桩, 包括奈雪儿尚未启动的进程.这样就可以在全系统范围内跟踪系统库调用.
uprobe 的工作方式和 kprobe 相似: 将一个快速断点指令插入目标指令处, 该指令将控制权转交给 uprobe 处理函数.当不再需要 uprobe 时, 目标指令被恢复成原来的样子.对于 uretprobe, 也是在函数入口处使用 uprobe 进行插桩, 而在函数返回之前则使用一个蹦床函数对返回地址进行劫持, 和 kretprobe 类似.gdb 就使用了这种方式来调试应用程序.
uprobe 有以下两个可以使用的接口:
基于 Ftrace 的, 通过/sys/kernel/debug/tracing/uprobe_events: 可以通过向这个配
置文件中写入特定的字符串来打开或者关闭 uprobe.
perf_event_open(): 和 perf(1) 工具的用法一样,BPF 跟踪工具也开始频繁地这样使
用了.相关的支持已经加入 Linux 内核 4.17 版本(perf_uprobe PMU).
在内核中同时包含了 register_uprobe_event() 函数, 和 register_kprobe() 函数类似, 但是并没有以 API 地形式显露.
USDT
用户态预定义静态跟踪 (user-level statically defined tracing, USDT) 提供了一个用户空间版本地 tracepoint 机制.USDT 的与众不同之处在于, 它依赖于外部的系统跟踪器来唤起.如果没有外部跟踪器, 应用中的 USDT 点不会做任何事情, 也不会开启.给应用程序添加 USDT 探针有两种可选方式: 通过 systemtap-sdt-dev 包提供的头文件和工具或者使用自定义的头文件.这些探针定义了可以被放置在代码中各个逻辑位置上的宏, 以此生成 USDT 的探针.
当编译应用程序时, 在 USDT 探针的地址放置了一个 nop 指令.当 USDT 探针被激活时, 这个地址会由内核使用 uprobe 动态地将器修改位一个断点指令.当该断点被触发时, 内核会执行相应的 BPF 程序.BPF 跟踪器前端 BCC 和 bpftrace 均支持 USDT, 如果不使用这些前端, 则不能直接使用 USDT, 需要前端自己实现 USDT 支持.
动态 USDT
上一节介绍的 USDT 探针技术是需要添加到源代码并编译到最终的二进制文件中的, 在插桩点留下 nop 指令, 在 ELF notes 段中保存元数据.然而有一些编程语言, 比如Java, 是在运行是的时候解释或者编译的.动态 USDT 可以用来给 Java 代码添加插桩点.
JVM 已经在内置的 C++ 代码中包含了许多 USDT 探针—-比如对 GC 事件、类加载, 以及其它高级行为.这些 USDT 探针会对 JVM 的函数进行插桩.但是 USDT 探针不能被添加到动态进行编译的 Java 代码中.USDT 需要一个提前编译好的、带一个包含了探针描述的 notes 段的 ELF 文件, 着对于以 JIT(just-in-time)方式编译的 Java 代码来说是不存在的.
动态 USDT 以如下方式解决该问题:
预编译一个共享库, 带着想要内置在函数中的 USDT 探针.这个共享库可以使用C/C++ 语言编写, 其中有一个针对 USDT 探针的 ELF notes 区域.它可以像其它USDT 探针一样被插桩.
在需要时, 使用 dlopen(3) 加载该动态库.
针对目标语言增加对该共享库的调用.这些可以使用一个合适该语言的 API, 以便
隐藏底层的共享库调用.
Matheus Marchini 已经为 Node.js 和 Python 实现了一个叫做 libstapsdt 的库(一个新的 libusdt 库正在开发中), 一提供这些语言中定义和呼叫 USDT 探针的方法.对其他语言的支持可以通过封装这个库实现.libstapsdt 会在运行时自动创建包含 USDT 探针和 ELF notes 区域的共享库, 而且它会将这些区域映射到运行着的程序的地址空间.
BPF raw tracepoint
BPF raw tracepoint 是一种新的 tracepoint, 相比于内核的 tracepoint,BPF raw tra- cepoint 接口向 tracepoint 线路原始参数, 这样可以避免需要创建稳定的 tracepoint 参数从而导致的开销, 因为这些参数可能压根不被使用.
PMC
性能监控计数器(Performance monitoring counter, PMC)还有其它的一些名字, 比如性能观测计数器(Performance instrumentation counter, PIC), 性能监控单元事件(Per- formance monitoring unit event,PMU event).这些名字指的都是同一个东西: 处理器上的硬件可编程计数器.
PMC 数量众多,Intel 从中选择了 7 个作为“架构集合”, 这些 PMC 会对一些核心功能提供全局预览.
图 1.4. Intel 架构上的 PMC
PMC 是性能分析领域的至关重要的资源.只有通过 PMC 才能测量 CPU 指令执行的效率、CPU 缓存的命中率、内存/数据互联和设备总线的利用率, 以及阻塞的指令周期等.尽管有数百个可用的 PMC 可用, 但在任一时刻, 在 CPU 中只允许固定数量(可能只有 6 个)的寄存器进行读取.在实现中需要选择通过这 6 个寄存器来读取哪些 PMC, 过着可以以循环采样的方式覆盖多个 PMC 集合(Linux perf(1) 工具可以自动支持这种循环采样).
PMC 可以工作在下面两种模式中.
计数: 在此模式下,PMC 能跟踪事件发生的频率, 只要内核有需要, 就可以随时读取,
比如每秒读取一次.这种模式的开销几乎为零.
溢出采样: 此模式下,PMC 在所监控的事件发生一定次数时通知内核, 这样内核可以获取额外的状态.监控的事件可能会以每秒百万、亿级别的频率发生, 如果每次事件都进行中断会导致系统性能下降到不可用.解决方案是利用一个也可编程的计数器进行采样, 具体来说是当计数器溢出时就像内核发信号.
由于存在中断延迟或者乱序执行, 溢出采样可能不能正确地记录触发事件发生时的指令指针.对于 CPU 周期性能分析来说, 这类“打滑”可能不是什么问题, 但是对于测量另外一些事件, 比如缓存未命中率, 这些采样的指令指针就必须是精确的.
Intel 开发了一种解决方案, 叫做精确事件采样(precise event-based sampling, PEBS).PEBS 使用硬件缓冲区来记录 PMC 事件发生时正确的指令指针.Linux 的 perf events 机制支持 PEBS.
perf_events
perf_events 是 perf(1) 命令所以来的采样和跟踪机制.BPF 跟踪工具可以调用 perf_events来使用它的特性.BCC 和 bpftrace 先是使用 perf_events 作为它们的唤醒缓冲区, 然后
又增加了对 PMC 的支持, 现在又通过 perf_event_open() 来对所有的 perf_events 事件进行观测.同时 perf(1) 也开发了一个使用 BPF 的接口, 这让 perf(1) 成为了一个 BPF前端也是唯一内置在 linux 中的 BPF 前端.perf(1) 的 BPF 功能还在开发中, 目前在使用上还有一些不方便的地方.
Ftrace
图 1.5. Ftrace 跟踪框架
Ftrace 是内核 hacker 的最佳搭档, 它是内核内置的, 支持内核 tracepoint,kprobe,uprobe事件.提供事件跟踪, 可选的过滤器和参数, 事件计数和定时, 在内核空间中的数据汇总.它通过/sys 文件系统访问, 是专门针对 root 用户的.有一个专门的 Ftrace 前端 trace-cmd.不足之处是 Ftrace 不是可编程的, 用户只能从内核缓冲区读取事件原始数据然后在用户态进行后续处理.
perf_event
图 1.6. perf-event 跟踪框架
perf_event 是 Linux 用户使用的主要跟踪框架, 其源代码位于内核源代码中, 其跟踪器前端 perf(1)Linux 用户最常用的性能分析工具.Ftrace 能做的事情 perf_ 几乎都能做到.同时 perf_event 具有更强的安全检查, 这也使得 perf_event 不容易被 hack.它可以用于采样,CPU 性能计数器, 用户态回栈.它还支持多用户的并发使用.
perf_event 支持的事件源
图 1.7. perf_event 支持的事件源
SystemTap
SystemTap 是最强大的跟踪器.它几乎无所不能: 采样(剖析),tracepoints,kprobes,uprobes,USDT,可编程等.它通过把程序编译成模块加载进内核来支持可编程的事件跟踪, 这是一种极其
安全的可编程事件跟踪实现方案.以使用 SystemTap 跟踪一个 kprobe 事件为例, 其基本步骤如下:
决定要跟踪的事件类型:kprobe 事件
编写“systemtap 程序”并编译成内核模块
内核模块被插入内核以后会创建 kprobe 探针, 当事件触发时内核调用模块* 内核模块使用 relayfs 或者其它的方式将结果打印到用户空间
图 1.8. systemtap 原理
跟踪器前端往往不与跟踪框架一一对应, 一个跟踪器前端可能会使用多个跟踪框架的接口, 常用的跟踪其前端有:perf、trace-cmd、perf-tools 等
BPF是 Berkeley Packet Filter 的缩写, 这项技术诞生与 1992 年, 其作用是提升网络包过滤工具的性能.2013 年,Alexei Starrovoitov 向 Linux 社区提交了重新实现 BPF 的内核补丁, 经过他和 Daniel Borkmann 的共同完善, 相关工作在 2014 年正式并入 Linux 内核主线, 此举将 BPF 变成了一个更通用的执行引擎.拓展后的 BPF 通常缩写为 eBPF,但官方的缩写仍是 BPF, 事实上内核只有一个执行引擎, 即 BPF(拓展后的 BPF), 它同时支持“经典”的 BPF 程序也支持拓展后的 BPF, 若无特殊说明, 我们所说的 BPF 就是指内核中的 BPF 执行引擎.
BPF 目标文件本质上就是 ELF 文件, 据我所知目前只有使用 LLVM 指定 target 为BPF 可以编译出 BPF 目标文件.它与通常的目标文件的差别在于编译后的目标文件是BPF 虚拟机的字节码程序, 同时它还包含了以 BTF 格式保存的调试信息以及重定位信息等.
BTF(BPF Type Format) 是编译成 BPF 目标文件的 BPF 程序中用记录 BPF 程序的调试、链接、重定位以及 BPF 映射表信息的元数据格式.其记录的信息的作用类似于ELF 文件中的 DWARF 段、notes 段和重定位相关的段.通常一个编译成 BPF 目标文件的 BPF 程序中会有多个以.btf 为前缀命名的段用于记录以上信息.有关 BTF 的详细内容请参考BTF 文档.
BPF 程序通常是指用户编写的要被注入内核的跟踪器程序.内核态程序直接在内核态对内核收集的事件信息进行汇总统计等处理之后保存在 BPF 映射表,BPF 用户态程序直接读取 BPF 映射表中已经处理好的数据就可以直接或者稍加处理之后进行展示.有时BPF 程序也指 BPF 内核态程序和用户态程序, 把二者视为一个整体.用户态程序负责将内核态程序加载链接到内核并附着(“attach”)到指定的事件上, 读取内核态程序处理好保存在 BPF 映射表中的数据并进行展示之类的上层操作以及退出前的清理操作.BPF内核态程序通常由多个函数定义和 BPF 映射表定义构成.每一个函数属于一个特定的BPF 程序类型,BPF 程序类型是 BPF 函数和该函数所关联的事件类型之间的接口.也就是说函数的程序类型和它所关联的事件类型之间有一种对应关系, 不过这种对应关系并不非常严格, 一种程序类型可以附着(“attach”)到多种事件之上, 一种事件也可以被多种不同 BPF 程序类型的函数附着.同时 BPF 程序类型也限制了函数能够使用的 BPF虚拟机字节码指令集, 这有利于将函数功能限制在其类型所定义的功能范围之内, 提高安全性.关于 BPF 程序类型的详细介绍请参考Linux 手册.
BPF 映射表 (BPF Map)BPF 映射表是 BPF 程序使用的内核缓冲区, 用于保存事件数据, 类似于 MySQL 的表.BPF 映射表的数据类型有很多种, 实现对用户都是是透明的.描述 BPF 映射表的信息记录在 BTF 格式的元数据中.BPF 程序只能使用 BPF 执行引擎提供的内核接口(这些内核接口是 BPF 系统调用的一部分)来创建、访问、修改和删除 BPF 映射表.关于 BPF 映射表的详细介绍请参考Linux 手册
BPF 系统调用是 BPF 执行引擎提供的一套内核接口, 用于 BPF 用户态程序与 BPF执行引擎之间的交互以及创建、访问、修改和删除 BPF 映射表.关于 BPF 系统调用的详细介绍请参考Linux 手册和Linux 内核文档.
BPF 帮助函数(BPF helpers)也是一套内核接口, 大部分 BPF 帮助函数作用和BPF 系统调用相同,BPF 帮助函数和 BPF 系统调用有很多同名且功能也完全相同的接口.区别在于参数不同, 而且 BPF 帮助函数仅由 BPF 内核态程序调用.有关 BPF 帮助函数的详细介绍请参考Linux 手册
如图 1.2所示:BPF 是一种最新的 Linux 跟踪技术, 可用于网络、可观测性和安全三个领域, 我们将主要关心其在可观测性领域的运用.
如图 2.1所示, 作为可观测性工具,BPF 支持上一章所述的全部事件源, 并且它是可编程的.
图 2.1. BPF 支持的事件源
BPF 跟踪可以在整个软件范围内提供能见度, 允许我们随时根据需要开发新的工具和监测功能.在生产环境中可以立刻部署 BPF 跟踪程序, 不需要重启系统, 也不需要以特殊方式重启应用软件.图 2.2展示了一个通用的系统软件栈的各部分及相应的 BPF 跟踪工具.
图 2.2. BPF 跟踪工具提供的能见度
表 2.1列出了传统工具, 同时也列出了 BPF 工具是否支持对这些组件进行监测.传统工具提供的信息可以作为性能分析的起点, 后续则可以通过 BPF 跟踪工具做更见深入的调查.
表 2.1. 传统分析工具 VS BPF 工具
图 2.3是 perf_event 和 BPF 采样流程的对比.可以看到相比 perf_event,BPF 大大精简了流程, 避免了内核与用户空间之间大量不必要的数据拷贝以及磁盘 IO.
图 2.3 BPF vs perf_event
简单来说 BPF 提供了一种在各种内核事件和应用程序事件发生时运行一段小程序的机制.类似 JavaScript 允许网站在浏览器中发生某事件时运行一段小程序,BPF 则允许内核在系统和应用程序事件(如磁盘 IO 事件)发生时运行一段小程序(后面我们将看到这是如何实现的), 这就催生了新的编程技术, 该技术将内核变得可编程, 允许用户(包括非专业内核开发人员)定制和控制他们的系统, 以解决现实问题.
BPF 是一项灵活为高效的技术, 由指令集(有时也称 BPF 字节码)、存储对象和辅助函数等及部分组成.由于它采用了虚拟指令集规范, 因此也可以将它视作一种虚拟机实现.
BPF 指令由 Linux 内核的 BPF 运行时模块执行, 具体来说, 该运行时模块提供两种执行机制: 一个解释器和一个将 BPF 指令动态转换为本地化指令的即时编译器(JIT,just- in-time).注意只有当 BPF 程序通过解释器执行时其实现才是一种虚拟机.当 JIT 启用之后 BPF 指令将通过 JIT 编译后像任何其它本地内核代码一样, 直接在处理器上运.如果没有启用 JIT 则经由解释器执行.相比于解释器执行,JIT 执行的性能更能好, 一些发行版在 x86 架构上 JIT 是默认开启的, 完全移除了内核中解释器的实现.
这里暂停一下继续啰嗦几句, 对编译原理不是很了解的读者可能不能快速地理解上一段阐述.无论是解释器解释执行还是编译执行, 源代码都需要被翻译成机器代码然后由CPU 执行, 二者地区别在于:1)从用户地角度看, 解释执行把用户输入和源代码作为解释器的输入, 解释器解释运行之后直接给出在用户的输入下, 源代码的执行结果.请注意解释器的输入是源代码本身和源代码的输入.而输出则是源代码的输出.而编译执行则至少分为两个阶段, 第一个阶段编译的输入是源代码, 输出是可执行文件, 可执行文件是可以保存在磁盘中被重复读取执行的文件.只要源代码没有修改, 第一阶段就只需要执行一次.第二阶段才是运行可执行程序.2)解释器在解释执行源代码是也需要编译源代码得到机器代码, 但是其编译结果是作为以一种中间数据保存在内存中, 这意味着每次解释执行一个源代码, 解释器都要编译一次源代码, 因为解释器不输出机器代码到磁盘.回到上一段,JIT 会把加载进内核的 BPF 字节码程序编译成机器代码并保存下来, 每一次事件触发运行 BPF 程序时直接运行机器代码即可, 而 BPF 解释器保存的则是 BPF 字节码程序, 每次事件触发都需要先将字节码编译成机器代码然后再运行, 因此 JIT 比解释器性能更好.了解 Java 的读者或许已经想到 BPF 运行时和 Java 运行时的原理完全相同,Java同样存在解释执行和 JIT 编译执行两种执行方式.
在实际执行之前,BPF 指令必须先通过验证器的安全性检查, 以确保 BPF 程序自身不会崩溃或者损坏内核.Linux BPF 运行时的各模块的建构如图 2.4所示, 它展示了 BPF指令如果通过 BPF 验证器验证, 再有 BPF 虚拟机执行.BPF 虚拟机的实现既包括一个解释器又包括一个 JIT 编译器:JIT 编译器负责生成处理器可直接执行的机器指令.验证其会拒绝那些不安全的操作, 这包括对无界循环的检查:BPF 必须在有限的时间内完成.同时 BPF 程序还有大小限制, 最初的 BPF 总指令数限制是 4096, 不过 Linux 5.2 内核极大地提升了这个值的上限, 因此在 Linux 5.2 以上的内核中这不再是一个需要关心的问题.
图 2.4. BPF 运行时的内部结构
BPF 可以利用辅助函数获取内核状态, 利用 BPF 映射表进行存储.BPF 程序在特定事件发生时执行, 包括 kprobes、uprobes、内核跟踪点(tracepoint)、PMC 和 perf events事件.
BPF 程序的主要运行过程如下:
BPF 用户态程序调用 BPF_PROG_LOAD 系统调用加载已经编译成 BPF 字节码的 BPF 内核态程序.根据 BPF 程序中的 BPF 映射表定义为 BPF 映射表分配内存
BPF 用户态程序调用 BPF_PROG_ATTACH 系统调用将 BPF 内核态程序和事件关联起来并向内核注册事件
事件被触发, 内核收集“事件上下文”传递给关联到该事件的 BPF 内核态程序并执行该 BPF 程序.BPF 内核态程序处理内核收集的数据将处理结果保存在 BPF 映射表
用户态程序调用 BPF 系统调用读取映射表中的数据并做进一步的处理.
用户提程序逻辑结束以后卸载 BPF 内核态程序、注销事件并释放 BPF 映射表内存.
图 2.5. BPF 程序执行流程
BPF 程序的可移植性是指在某个内核版本中可以成功加载、验证、编译、执行的BPF 程序也能够在不加修改的前提下在其它的内核版本中成功地加载、验证、编译和执行.BPF 程序运行于内核态可以直接访问内核的内存和内核数据结构使得 BPF 成为一个强大而灵活的工具, 但同时它也导致了 BPF 与生俱来的可移植问题.因为不同版本的内核, 内核数据结构的定义是发展变化的, 成员顺序变化、重命名、从一个结构体移动到另一个结构体等均会导致内存访问出错, 这种错误是致命的.举一个例子假设我们在 BPF内核态程序中访问了 struct task_struct 的成员 pid, 目标平台的内核版本在 pid 成员前面插入了一个新的成员, 当我们编译 BPF 内核态程序后, 对 pid 的成员访问将被翻译成task_struct 结构体的起始地址加上成员偏移量.而目标平台在 pid 之前插入了新的成员因此目标平台的 pid 成员偏移量发生了变化, 很显然如果次程序在目标平台运行将导致内存访问出错.
解决 BPF 程序的可移植性问题的一种解决方案是依赖 BCC(BPF Compiler Collec- tion).在 BCC 中,BPF 内核态源代码被当作一个字符串, 这个字符串被 BPF 用户态程序当作普通的数据保存.当 BPF 用户态程序被编译之后在目标平台上被 BCC 运行时,BCC调用目标平台的 Clang/LLVM 来编译 BPF 内核态程序源代码, 因为 BPF 内核态程序源代码是在目标平台上编译成字节码的, 因此 BPF 内核态程序可以正确地访问内核数据结构.这个方案的本质是延迟 BPF 内核态程序的编译, 直到内核相关的信息全部已知之后才开始编译.不过这种解决方案不够好,Clang/LLVM 是非常庞大地库, 而且也需要大量地系统资源, 同时还需要目标平台安装了内核头文件, 而且使用这种方式开发的 BPF 程序的调试和开发迭代过程也是十分的繁琐.尤其是在嵌入式这种资源有限的系统, 几乎不会有软件是在目标平台上直接编译的.
一种优美的解决方式是 CO-RE(Code Once,Run Everywhere).BPF CO-RE 依赖于以下组件之间的协作:
BTF 记录了内核、BPF 程序类型和源代码重定位的关键信息.
编译器(Clang/LLVM)提供了生成和记录 BPF C 源代码的重定位信息的方法
BPF 加载器(libbpf)将内核的 BTF 信息和 BPF 程序捆绑在一起对 BPF 字节码
程序进行重定位以适配目标主机的内核版本
简单来说 CO-RE 是使用 BTF 记录源代码的重定位信息和内核数据结构的相关信息, 由libbpf 在加载时进行重定位来解决 BPF 程序的可移植问题的.仍然以前面 task_struct结构体的 pid 成员访问为例, 在 CO-RE 的解决方案中,BPF 字节码中对 pid 的访问被记录为一个重定位点.当 libbpf 加载 BPF 字节码程序时, 会根据目标平台的内核数据结构信息进行重定位.
更多关于 CO-RE 的知识可以参考博客
BPF 目标文件时 BPF 字节码程序,BPF 字节码是 BPF 虚拟机的“机器指令”.类似于 GCC 编译工具链中的 objdump,addr2line 和 readelf 等处理真是硬件平台的 ELF 文件的工具,BPF 目标文件这种 ELF 文件也有功能与 objdump,addr2line 和 readelf 相同的工具, 这个工具就是 bpftool, 它位于 Linux 源代码的 tools/bpf/bpftool 中.它可以用来查看可操作 BPF 对象, 即 BPF 程序和 BPF 映射表.bpftool 是一个很强大的工具, 也是唯一可以用查看和操作 BPF 程序的工具, 关于其用法读者可参考内核文档, 工具自身的帮助文档以及网络上其它的学习资料.
前面我们已经概述了 BPF 相关的基本概念, 工作原理以及它能做什么.现在我们来看看具体怎么开发 BPF 程序.开发 BPF 程序的方案有很多种, 可使用的编程语言原则上也没有限制, 只要有相应的编译器能将源代码编译成 BPF 目标文件(BPF 虚拟机上的字节码指令可执行 ELF 文件)就可以了, 其它的与一般的软件开发并没有什么不同.如果你对 BPF 字节码和虚拟机足够了解, 甚至可以直接使用 BPF 字节码来开发 BPF 程序, 这无异于使用机器代码来开发软件.
BPF 的开发者提供了 BCC 和 bpftrace 两个 BPF 开发前端, 它们都提供了把高级语言编译成 BPF 字节码并自动加载的功能.BCC 支持 C/C++、python、lua 等高级语言,bpftrace 则自己提供了一种脚本语言.不过这两个前端都不适合用于开发嵌入式 BPF程序,BCC 的运行时依赖过重,bpftrace 只适合开发非常简单的, 短小精悍的 BPF 程序.另外 GoLang 也有不少可用的 BPF 开发库, 不过它们大多都依赖于 BCC.这里就不过多赘述
本文主要介绍一下基于 libbpf 的 BPF 程序开发.libbpf 是一个 C 语言库, 它封装BPF 系统调用, 并提供了大量的使用的函数, 把 BPF 虚拟机比作运行 BPF 程序的操作系统的话,libbpf 就好比是 libc.
libbpf 依赖于 zlib 和 libelf, 在正式开发之前需要先安装好这三个库.下面以我自己写的用 fp 回栈获取特定系统调用发生时的调用链的 BPF 程序为例, 讲述 BPF 程序的代码结构和开发流程.
编码
内核态程序
#include // linux 内核版本头文件
#include 11 linux / vmlinux.h11 // linux内核数据结构头文件
//用到的libbpf头文件
#include "bpf_helpers.h" // BPF 帮助函数
#include "bpf_tracing.h"
#include nbpf_core_read•h "
#include "common.h”
struct
{
__uint(type, BPF_MAP_TYPE_STACK_TRACE);
__uint(key_size, sizeof(u32));
__uint(value_size, MAX_STACK_DEPTH * sizeof(u64));
__uint(max_entries, 1000);
} kstack_map SEC(".maps");
struct
{
__uint(type, BPF_MAP_TYPE_STACK_TRACE);
__uint(key_size, sizeof(u32));
__uint(value_size, MAX_STACK_DEPTH * sizeof(u64));
__uint(max_entries, 1000);
} ustack_map SEC(".maps");
#define KERN_STACKID_FLAGS (0 I BPF_F_FAST_STACK_CMP)
#define USER_STACKID_FLAGS (0 I BPF_F_FAST_STACK_CMP I BPF_F_USER_STACK)
SEC("kprobe/do_sys_open")
int bpg_open(struct pt_regs *ctx)
{
const char target_comm[] = "static_demo";
char curr_comm[MAX_COMM_LEN] = "";
long ret = bpf_get_current_comm(curr_comm, sizeof(curr_comm));
if (ret == 0)
{
bool is_target = false;
for (size_t j = 0; j <= sizeof(target_conim); ++j)
{
if (j == sizeof(target_comm))
{
char fmt[] = "current comm is %s";
bpf_trace_printk(fmt, sizeof(fmt), curr_comm);
is_target = true;
break;
}
if (target_comm[j] != curr_comm[j])
{
break;
}
}
if (is_target)
{
long kstack_id = bpf_get_stackid(ctx, &kstack_map, KERN_STACKID_FLAGS);
if (kstack_id < 0)
{
char fmt[] = "get kern stack id failed with kstack_id = %ld";
bpf_trace_printk(fmt, sizeof(fmt), kstack_id);
return 0;
}
else
{
char fmt[] = "get kern stack id success with kstack_id = Xld";
bpf_trace_printk(fmt, sizeof(fmt), kstack_id);
}
long ustack_id = bpf_get_stackid(ctx, &ustack_map, USER_STACKID_FLAGS);
if (ustack_id < 0)
{
char fmt[] = "get user stack id failed with ustack_id = %ld";
bpf_trace_printk(fmt, sizeof(fmt), ustack_id);
return 0;
}
else
{
char fmt[] = "get user stack id success with ustack_id = %ld";
bpf_trace_printk(fmt, sizeof(fmt), ustack_id);
}
char filename[64];
bpf_core_read(filename, sizeof(filename), (void *)PT_REGS_PARM2(ctx));
char msg[] = "file %s is opened";
bpf_trace_printk(msg, sizeof(msg), filename);
}
}
return 0;
}
char _license[] SECClicense ") = " GPL ";
u32 version SEC("version") = LINUX VERSION CODE;
内核版本头文件和 Linux 数据结构头文件总是必须的.其中 vmlinux.h 需要手动从目标平台的内核生成.也可不使用 vmlinux.h, 直接手动添加内核头文件(同样必须是目标平台的内核头文件), 不过这样的话需要开发者自己管理内核头文件依赖, 个人觉得这种方式容易出错, 推荐使用 vmlinux.h.之所以内核态程序需要依赖 Linux 内核头文件, 是因为内核态程序往往需要访问内核的数据结构.接下来的四个头文件都是 libbpf 的头文件, 是代码中用到的接口所依赖的头文件, 这四个头文件是最常用的, 大部分情况下都需要.
kstack_map 和 ustack_map 是两个 BPF 映射表的定义, 每个 BPF 映射表的定义方式都是一样的: 需要指定映射表类型, 键的大小, 值的大小, 键值对的个数.
第 26 行则定义了一个 BPF 程序, 其关联的是一个 kprobe 探针, 探针位于 do_sys_open内核函数的入口.该 BPF 程序首先获取了当前进程的运行命令(bpf_get_current_comm()), 将当前进程的运行命令与我们感兴趣的进程命令进行比较, 如果一致说明当前运行的是我们感兴趣的进程, 于是我们首先获取其内核堆栈并保存于 kstack_map(bpf_get_stackid()使用 FP 回栈获取堆栈), 然后获取其用户态堆栈并保存于 ustack_map.其中 bpf_trace_printk()用于打印内核调试信息, 因为 BPF 程序运行于内核态, 这可能是我们唯一可用的调试方式了.注意一个源文件可以定义多个 BPF 程序.
最后两行是指定内核证书和版本,LINUX_VERION_CODE 是一个定义在 vmlinux.h中的宏.这两行在任何 BPF 程序中都是一样的.
用户态程序
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include "bpf.h"
#include "libbpf.h"
#include "perf-sys.h"
#include "uapi/linux/trace_helpers.h"
#include "common.h"
// declare variables
static int err = 0;
static struct bpf_object *obj = NULL;
static const int NR_BPF_PROGS = 1;
static const char *bpf_prog_names[NR_BPF_PROGS] = {
"bpg_open",
};
static struct bpf_program *bpf_progs[NR_BPF_PROGS] = {};
static struct bpf_link *bpf_prog_links[NR_BPF_PROGS] = {};
static const int NR_MAPS = 2;
static const char *map_names[NR_MAPS] = {
"kstack_map",
"ustack_map"};
static int map_fds[NR_MAPS] = {};
void print_ksym(__u64 addr)
{
struct ksym *sym;
if (!addr)
return;
sym = ksym_search(addr);
if (!sym)
{
printf("ksym not found. Is kallsyms loaded?
");
return;
}
printf("ip = %11u sym = %s;
", addr, sym->name);
}
long get_base_addr()
{
size_t start, offset;
char buf[256];
FILE *f;
f = fopen("/proc/self/maps",
"r");
if (!f)
return -errno;
while (fscanf(f, "%zx-%*x %s %zx %*[^
]
", &start, buf, &offset) == 3)
{
if (strcmp(buf, "r-xp") == 0)
{
fclose(f);
return start - offset;
}
}
fclose(f);
return -1;
}
int setupprobes(const char *filename)
{
// set up libbpf deubug level
libbpf_set_strict_mode(LIBBPF_STRICT_ALL);
// set RLIMIT MEMLOCK
struct rlimit r = {RLIM_INFINITY, RLIM INFINITY};
setrlimit(RLIMIT_MEMLOCK, & r);
// open kern object file
obj = bpf_object__open_file(filename, NULL);
err = 0;
err = libbpf_get_error(obj);
if (err)
{
fprintf(stderr, "opening BPF object file %s fialed: %s
", filename, strerror(err));
return -1;
}
// find bpf progs
for (int j = 0; j < NR_BPF_PROGS; ++j)
{
bpf_progs[j] = bpf_object__find_program_by_name(obj, bpf_prog_names[j]);
err = 0;
err = libbpf_get_error(bpf_progs[j]);
if (err)
{
fprintf(stderr, "finding BPF prog %s failed: %s
", bpf_prog_names[j],strerror(err));
return -1;
}
// load bpf programs
if (bpf_object load(obj))
{
fprintf(stderr, "loading BPF object file %s failed: %s
", filename,
strerror(errno));
return -1;
}
// attach bpf progs
for (int j = 0; j < NR_BPF_PROGS; ++j)
{
bpf_prog_links[j] = bpf_program__attach(bpf_progs[j]);
err = 0;
err = libbpf_get_error(bpf_prog_links[j]);
if (err)
{
fprintf(stderr, "attaching BPF program %s failed: %s", bpf_prog_names[j], strerror(err));
return -1;
}
// find map fds
for (intj = 0; j < NR_MAPS; ++j)
{
map_fds[j] = bpf_object__find_map_fd_by_name(obj,map_names[j]);
if (map_fds[j] < 0)
{
printf("find map fd by name %s failed
", map_names[j]);
return -1;
}
return 0;
}
void cleanup_probes()
{
for (int j = 0; j < NR_BPF_PROGS; ++j)
{
bpf_link__destroy(bpf_prog_links[j]);
}
bpf_object__close(obj);
}
int main(int argc, char *argv[])
{
char filename[256] = {};
snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);
if (setup_probes(filename))
{
printf("ER0OR: setup_probes failed
");
cleanup_probes();
return 0;
}
printf("setup_probes() done, press any key to continue
");
char str[32] = "";
scanf("%s",str);
printf("%s received, continue processing data
", str);
// process data
load_kallsyms();
__u32 key = 0;
__u32 next_key = 0;
__u64 ips[MAX_STACK_DEPTH] = {};
printf("---------kernel stacks---------
");
while (bpf_map_get_next_key(map_fds[0], &key, &next_key) == 0)
{
if (bpf_map_lookup_elem(map_fds[0],& next_key,ips) == 0)
{
printf("kern stacks with kstack_id = %u:
", next_key);
for (int j = 0; j < MAX_STACK_DEPTH; ++j)
{
print_ksym(ips[j]);
}
key = next_key;
}
}
key = 0;
next_key = 0;
printf("---------user stacks---------
");
while (bpf_map_get_next_key(map_fds[1], &key, &next_key) == 0)
{
if (bpf_map_lookup_elem(map_fds[1],& next_key,ips) == 0)
{
printf("user stacks with ustack_id = %u:
", next_key);
for (int j = 0; j < MAX_STACK DEPTH; ++j)
{
if (ips[j] == 0)
{
break;
}
printf("ip = %l1x
",ips[j]);
}
}
key = next_key;
}
cleanup_probes();
return 0;
}
用户态程序的流程分为三部分: 准备阶段(在 setup_probes() 函数), 业务处理(在main() 函数), 清理 BPF 程序和映射表(在 cleanup_probes() 函数).准备阶段的逻辑分为: 设置 libbpf 调试级别(可选), 设置 RLIMIT_MEMLOCK(大部分情况下都需要做此设置, 否则 BPF 内核态程序太小限制太小将导致内核态程序加载失败), 打开 BPF 目标文件, 获取 BPF 程序对象句柄, 加载 BPF 程序, 附着(attach)BPF 程序, 获取 BPF映射表对象句柄.这些步骤在任何用户态程序中都是固定不变的.
用户态程序的业务逻辑都在 main() 函数中, 它首先调用了 setup_probes() 函数设置好 BPF 程序和映射表, 然后分别读取 BPF 映射表 kstack_map 和 ustack_map, 在内核态程序中, 这两个映射表被写入了目标进程调用链的 ip 指针.读取内核和用户态调用链的 ip 值以后将它们打印了出来.
编译和运行
内核态程序必须使用 Clang/LLVM 编译并指定“-target bpf”选项, 这样我们将得到一个 BPF 目标文件, 用户态程序则像通常的程序一样交叉编译即可.在上述例子中, 我将用户态程序命名为 fs_user.c, 用户态程序打开 BPF 目标文件是假设了目标文件的名字是fs_kern.o, 运行时只需将编译好用户态可执行文件和内核态 BPF 目标文件放在同一目录下, 然后执行用户态程序, 用户态的准备阶段就会读取 BPF 目标文件跟 BPF 目标文件的内容加载并附着 BPF 程序并为 BPF 映射表分配内存, 这些都成功以后每次事件触发, 相关联的 BPF 程序就会被自动执行.
审核编辑 :李倩
全部0条评论
快来发表一下你的评论吧 !