eBPF 动态观测之指令跳板

June 12, 2022

在 containerd 自定义插件 embedshim 项目里,我借助了 Linux 内核里的 trace_sched_process_exit 观测能力,并利用 eBPF Map 记录和持久化容器进程退出事件。 这类观测能力依赖内核在关键代码路径上提前定义好钩子,它属于静态观测技术,任何变化都需要重新编译 Linux 内核。 如果我们想观测内核中的某一个关键函数或者某一行关键代码时,我们可以选择 kprobe 或者 ftrace 这类动态观测技术。

kprobe - single-step

Kernel Probe(kprobe) 是一个轻量级内核指令观测的技术,用户可以指定观测内核的某一个函数,甚至可以观测函数内的某一条指令,除了 kprobe 框架自身的代码以及异常处理函数外,用户几乎可以观测内核运行的每一条指令。

当 CPU 执行到被观测指令时,也就是产生了一次 观测事件,那么 kprobe 会把当前 CPU 的寄存器信息作为输入去执行用户注册的观测程序。 然而被观测的指令由用户随机指定,考虑到性能问题,kprobe 无法在编译内核时为每一条指令预留埋点,同时我们很难在编译好的程序里动态插入指令。 基于性能和稳定性考虑,kprobe 选择了 单步调试 的通用方案。

在介绍 kprobe 方案之前,我们先简单回顾下 gdb 调试过程。为了调试某一行代码,我们先通过 breakpoint 给该行打上断点,当程序运行到该行代码时就会停下来,等待我们的下一步交互。 这个时候我们就可以通过 p 或者 info 等命令来查看当前程序的状态,甚至我们还可以通过 单步调试 来观察程序每条指令带来的变化。 我们利用断点和单步调试产生的 停顿 来观测程序,这本质上也是一种埋点,内核也正是通过这种方式来实现 kprobe,如下图所示。

x86_64 CPU 架构下的断点指令为 INT3,它是一个单字节指令 0xcc 。我们可以用 INT3 来替换任何指令的 opcode,被替换的指令(以及后续指令)都将被中断所短路掉,而 CPU 将进入 do_int3 [1] 中断处理逻辑。

如上图所示,kprobe 观测的是 Near CALL wq_worker_running 指令。在观测之前,kprobe 申请新的空间 copy-ip-addr 来存储被观测指令的内容 e8 fc 57 77 ff,然后调用 arch_arm_kprobe/text_poke [2] 将 Near CALL 0xe8 替换成 INT3 0xcc。打完断点后,一旦有 CPU 执行到这条指令上,那 CPU 必然会进入到 INT3 中断处理逻辑里的 do_int3/kprobe_int3_handler [3]。

kprobe_int3_handler 中断逻辑里,kprobe 首先会标记当前 CPU 为 KPROBE_HIT_ACTIVE 状态,表明该 CPU 正在处理 kprobe 的观测事件。 而通过内核模块或者 eBPF 系统调用注册的观测程序,它们都被聚合到 pre_handler 钩子函数内,kprobe_int3_handler 会先执行这些观测程序,再去运行观测的指令。

在执行被观测的指令时,内核并不会将 INT3 还原成 Near CALL,毕竟还会有其他 CPU 可能会触发该指令的观测事件,因此该 CPU 需要跳到 copy-ip-addr 指令地址上运行原先的指令。 copy-ip-addr 仅保存一条指令,Near CALL 返回的指令地址依然在原先指令 orig-ip-addr 地址之后;加上 Near CALL 返回指令地址以及下一条指令地址的计算都是基于当前指令地址计算出来的,该 CPU 需要一个修正指令地址和返回指令地址的机会,而这个机会由 单步调试 所产生的中断来提供。

首先 kprobe 会将 CPU 状态切换成 KPROBE_HIT_SS,表明 CPU 正在准备单步调试状态。内核会将寄存器中的 EIP 指向 copy-ip-addr,并配置 X86_EFLAGS_TF 标记,这个标记会让 CPU 执行完一条指令后产生调试中断,保证 CPU 会进入 do_debug/kprobe_debug_handler [4] 中断函数。当进入 kprobe_debug_handler 中断处理后,CPU 的下一条执行指令地址变成了 copy-ip-addr + 5 + fc 57 77 ff,其中 copy-ip-addr + 5 表示 copy-ip-addr 的下一条指令地址,因为这个 Near CALL 是五字节指令,而 fc 57 77 ff 表示指令之间的距离。但 fc 57 77 ff 是由 orig-ip-addr + 5wq_worker_running 计算出来的,因此我们需要将其修正成正确的 wq_worker_running 的位置。同理,函数栈里的返回地址也需要更正: return-eip - copy-ip-addr 可获取被观测指令的长度,仅需要将指令长度加到 orig-ip-addr 就可以拿到真正的返回指令地址了。 之后 kprobe 将移除 X86_EFFLAGS_TF 标记,取消单步调试状态,并更成 CPU 成 KPROBE_HIT_SSDONE 状态,表明单步调试结束。

NOTE: KPROBE_HIT_XYZ 状态是用来记录当前 kprobe 运行状态。用户自定义程序可能会调用到一个被 kprobe 观测的指令上了,因此在执行 pre/post_handler 时会再次触发 INT3 中断。而 per-CPU 的 kprobe 状态记录可以用来判断当前 INT3 触发是第一次触发 KPROBE_HIT_ACTIVE 还是再次触发 KPROBE_HIT_REENTER。目前 kprobe 仅允许发生一次 KPROBE_HIT_REENTER。

虽然单步执行模式可以让用户观测任何指令,但每条指令都要产生两次中断;如果观测在关键的代码路径上,这种模式势必会影响到内核的性能。 kprobe 针对两次中断共有两个优化方案,我们先来看看 2021 年初的一个优化方案。

kprobe - x86 Insn Emulation

2021 年优化提交名叫 x86/kprobes: Use int3 instead of debug trap for single-step [5],它是通过离线模拟被观测指令来去除单步调试中断,如下图所示。

我们还是使用 Near CALL 指令的例子来解释。根据 x86_64 IA-32 架构开发文档对 Near CALL 的描述,我们可以将用两条指令来模拟它:

换言之,Near CALL 指令产生的结果完全可以在 do_int3/kprobe_int3_handler 中断处理中离线模拟出来。 在上文提到的单步调试里,do_debug/kprobe_debug_handler 调试中断处理需要通过 resume_execution [6] 里面修正 CPU 的寄存器信息,在我看来,这本质上和模拟没有区别。 所以这个方案通过模拟指令的方式来移除了不必要的单步调试中断,这对性能有极大的提升。不过需要说明的是,该方案并非支持所有指令的模拟,根据邮件里的讨论来看,prepare_emulation [7] 模拟的指令足以覆盖大部分场景。

指令模拟优化掉了单步调试中断,但每次观测指令达到时,kprobe 还是会触发 INT3 中断,对关键代码执行路径还是存在影响。

kprobe - detour buffer

Linux 内核社区在 2009 年提出了 kprobe jump optimization [8] 方案,该方案的思路是通过 Near JMP 指令来模拟 do_int3/kprobe_int3_handler 中断处理。 该方案在各大发行版里目前默认开启,在当时的优化结果比单步调试快了近 10 倍。虽然它存在一定的局限性,但不妨碍我们了解它,这个方案和后面提到的 ftrace 设计理念一致。

该优化方案通过修改被观测指令成 Near JMP 指令,一旦产生观测事件,CPU 将跳到预定义好的一个代码片段上模拟 kprobe_int3_handler 处理逻辑。 但 Near JMP (Rel32) 是一个 五字节 的指令,如果在指令更新过程中,中间结果被其他 CPU 读取执行了,那么将产生不可预知的行为,这甚至会造成内核崩溃。 为了保证能安全更新被观测指令,kprobe 还是需要依赖 INT3 中断来协助处理指令更新。

假设我们已经有了模拟 kprobe_int3_handler 的代码片段,为了方便解释,我们将其简称为 跳板 (虽然方案和文档都称之为 detour buffer,但我觉得 跳板 更容易理解些)。 在这里,我们继续拿之前 Near CALL 的观测指令来举例子。被观测指令 e8 fc 57 77 ff 被 kprobe 更新成 INT3[cc] fc 57 77 ff; 那么任何时刻 CPU 执行这条指令时,它们都会触发 INT3 中断,即使 fc 57 77 ff 这个值被改成非法的值,CPU 也不会使用这个值,INT3 给我们形成了天然的屏障,如下图所示。

kprobe jump 优化方案是一个异步的操作,该方案会触发一个 kworker [9] 来执行被观测指令的更新。那么在指令更新前,CPU 还是可以会触发观测事件,这个时候的处理链路还是会走到 kprobe_int3_handler/setup_singlestep [10]。但它并非使用前面提到的指令模拟,而是直接通过 setup_detour_execution [11] 将寄存器的 EIP 转化成 跳板 代码指令地址上,相当于模拟了一次更新后的 Near JMP 指令。如果 kworker 开始调用 text_poke_bp [12] ,那么内核会告知所有 CPU,有一个 CPU 当前正在处理指令升级。如果其他 CPU 触发了该指令的 INT3 中断,那么 CPU 将会进入到 poke_int3_handler [13] 中断处理逻辑,同样的它会根据指令来调用不同的模拟逻辑:在 kprobe 指令优化场景下,poke_int3_handler 将会调用 int3_emulate_jmp 模拟逻辑,效果和前面提到的 setup_detour_execution 一致。那么有了 INT3 中断这一屏障后,text_poke_bp 就可以放心更新指令了,其中 text_poke_bp 更新有三步:

  1. 指令首地址 opcode 更新成 INT3,确保 poke_int3_handler 能模拟预期的行为;
  2. 将指令后半部分更新成预期的值;
  3. 将指令的首地址 opcode 更新成预期的值。

text_poke_bp 每一步更新都会同步给所有 CPU,确保它们读到的都是最新的指令内容。

在 kprobe 场景下,最终被观测指令将会变成跳到 跳板 的指令,而这跳板里的指令内容如上图所示。 跳板上的指令由 arch_prepare_optimized_kprobe [14] 代码生成,其中 optimized_callback [15] 用来执行 kprobe 的 pre_handler。 跳板指令最后为被观测指令的 副本 以及跳回到被观测指令的下一指令,其中这个副本并非直接拷贝原来的指令。 对于 Near CALL 或者 Near JMP 等具有相对位置的指令,我们需要根据当前跳板和被观测指令之间的差值来更新指令,这样才可以确保被观测指令可以离线运行,原理和单步调试里的 resume_execution 类似。

这个优化效果十分显著,但它对被观测指令有一定的要求。在前面我举的例子里,被观测指令的长度和 Near JMP 指令长度一致,所以被修改的指令仅一条。 但在 x86_64 架构里,指令长度是变长编码,常用的指令编码需要的字节少,而不常用的字节多。如果被观测的指令短于五字节,那么指令修改必定涉及到多条。 而 text_poke_bp 更新的指令如果跨越了多个指令,那么 INT3 中断将无法保证中间修改的状态不被访问。假设某一条指令可以跳过 INT3 指令访问正在修改的值, 那么 CPU 执行时必然会出现不可预知的情况。因此 kprobe 会通过 can_optimize [16] 来扫描被观测指令所在函数的每一条指令,以确保可使用跳板模式来优化。

总的来说,这个优化方案要求被观测指令不能涉及到异常处理、不能出现跳跃到被修改指令的中间位置以及被观测指令是可以脱离原上下文离线运行的。 有一定的局限性,但如果我们想要观测某一个函数入口时,我们还是可以使用上这个优化。

kretprobe = kprobe + rethook

kretprobe 提供了观测函数返回时刻 CPU 上下文的能力。

以常用的 Near CALL 为例,我们先回顾下 x86_64 下的函数调用和返回的指令:在函数调用时,CPU 会将返回的指令地址压入栈顶,再执行函数入口处的指令; 当函数返回 (RET 指令) 时,CPU 会将栈顶的返回指令地址更新到 EIP 寄存器上,这样 CPU 就可以按照原来的分支继续执行。从出栈入栈的角度看,个人能想到观测函数返回的上下文总有两种方式:

第二个方案有一个明显的弊端,注册 kretprobe 的时候需要扫描整个函数的指令。一般函数入口的第一条指令都是可以优化成 Near JMP 指令,无需 INT3 中断。基于这样的假设,kretprobe 选择第一种方式会比较合适,如下图所示(为了方便解释,我使用 x86 kprobe 离线模拟指令的方式来说明)。

首先 kretprobe 会注册一个 kprobe 到函数入口处。这个 kprobe 用来记录当前线程 current 的栈帧 EBP 以及真实返回指令地址 Return-EIP,并修改返回指令地址成 arch_rethook_trampoline 函数。被观测的函数可能没有返回值,也可能递归调用多次,kretprobe 并不会无限制地调用申请 rethook node 来存储上下文,因此用户需要通过配置 maxactive 来决定可以同时处理返回。

在保存 rethook node 信息时,kretprobe 采用入栈的形式保存数据。举一个例子,假设 schedule 函数调用了 wq_worker_running 函数,同时它两都属于 kretprobe 的观测对象。当一个线程调用了 schedule 函数,也走到了 schedule 函数内的 wq_worker_running 函数,那么 kretprobe 在处理返回事件时,第一个处理的应该是 wq_worker_running 的返回时。kretprobe 采用了栈的形式保存 rethook node 信息,有利于 arch_rethook_trampoline 快速地找到正确的 rethook node

arch_rethook_trampoline [17] 是由一段汇编代码拼成,它主要是用来处理用户注册的观测程序,处理完毕后释放掉 rethook node,并还原成正确的 Return-EIP。

rethook 的概念是近期 x86,rethook,kprobes: Replace kretprobe with rethook on x86 [18] 引入, 和早期的 kretprobe-instance 概念作用类似。对于我而言,rethook 更容易理解 kretprobe,毕竟 kretprobe 是通过 kprobe 加入的一种回调钩子,等价于 kprobe + rethook。

fentry/fexit - bpf_trampoline

fentry/fexit 和 kprobe/kretprobe 功能类似,其中 f 表示的是函数,fentry/fexit 分别用来观测函数入口和函数返回的事件。相比于 kprobe,它具有静态观测技术的特点。在高版本的 GCC 里,GCC 提供了 -mentry 选项来为每一个函数入口生成一个埋点函数。为了实现 ftrace 技术,在编译内核会带上这个选项。由于可观测的函数很多,关键路径上频繁调用埋点函数会造成 13% 的性能损失,因此内核在 Link 阶段会将这条 Near CALL 指令替换成 NOP5 指令

NOP5 指令长度为五个字节,所以对于那些可观测的函数来说,kprobe jump 优化方案肯定是适用的。 但 fentry/fexit 采用的并不是 Near JMP,而是 Near CALL。下图展示的是 fentry eBPF 观测程序关联到 do_unlinkat 函数入口处的过程。

首先 bpf_tramp_image_alloc [19] 函数会申请一个页大小的指令内存空间,而这个指令内存空间的地址将有一个符号标记,符号的命名规则为 bpf_trampoline_$key_$indexkey 是通过 BTF ID 来生成,只要关联的函数固定,这个 key 值是固定的; 而 index 是一个自增的 ID,每一次调用 bpf_tracing_prog_attach 函数来关联 eBPF 程序时,它会都 +1。eBPF fentry 并没有 kprobe 那样的 pre_handler 聚合函数,每次关联观测程序都需要重新生成汇编指令,而重新生成的 bpf_tramp_image 将通过新的 index 来做区分。我们可以通过 /proc/kallsyms 来查看这个符号,当然也可以通过符号后的 index 来查看被观测函数的关联次数。

fentry 会通过 arch_prepare_bpf_trampoline [20] 函数在新申请的 bpf_tramp_image 上添加必要的指令:将 do_unlinkat 的函数参数压入栈,然后生成调用已关联的 eBPF 观测程序指令,当然中间还会记录 eBPF 程序调用的耗时,最后会调用 RET 来结束该过程。最后这个 bpf_tramp_image 跳板函数会替换掉 do_unlinkat 函数入口处 NOP5 的指令。替换过程和 kprobe jump 优化一样,同样是通过 text_poke_bp 三步更新模式来替换,最后这个 NOP5 会变成 Near CALL <bpf_tramopline_$key_1>。当然,如果再次添加一个新的 fentry/do_unlinkat 观测程序,那么该指令将从 Near CALL <bpf_trampoline_$key_1> 变成 Near CALL <bpf_tramopline_$key_2>

对于 fexit 而言,arch_prepare_bpf_trampoline 生成的指令稍微复杂些,但也不难理解,其中唯一的区别在于, do_unlinkat 的调用是发生在 bpf_trampoline_$key_$index 函数体内,而 bpf_trampoline_$key_$index 函数返回后将直接返回到调用 do_unlinkat 的地方。在这里,就不再贴图说明啦,更多细节可以查看 arch_prepare_bpf_trampoline 函数体。

Summary

kprobe 和 fentry/fexit 使用的动态观测技术大同小异,都是通过跳板形式来完成对指令或者函数的观测,都是在开着飞机换引擎。就目前了解的情况来看,fentry eBPF 跳板模式的代码结构和内存使用更简单,没有额外的 kprobe perf_event 数据结构介入。如果内核支持 fentry 的话,我倾向使用 fentry :)。