Linux 内核 ftrace 动态追踪机制

Linux 内核 ftrace 动态追踪机制:NMI 安全的指令重写、FENTRY/FEXIT 与 BPF Trampoline

从 stop_machine 时代到 ftrace_call,一次函数入口的精确劫持如何在多核、NMI、_PREEMPT_RT 环境下保证安全。

一、为什么内核需要"运行时改代码"

动态追踪是现代性能分析的基石——perf、bpftrace、SystemTap、ftrace 都依赖同一项能力:在运行的函数入口/出口插入追踪代码,而不重启内核。

这件事的难度远超"把机器码改一下":

  • CPU 正在执行指令的流水线中
  • NMI 可能在任意时刻抢占当前执行流
  • 超线程核心可能共享 L1I Cache
  • Hotplug 可能将目标 CPU 离线
  • PREEMPT_RT 要求可抢占点不可无限期阻塞

问题的本质是:在确保所有 CPU 都不处于被修改代码区域的前提下,完成指令替换。

二、早期方案:stop_machine 与 ftrace_caller

Linux 2.6 时代采用 stop_machine() 实现:暂停所有 CPU,修改代码,再恢复。实现简单但代价巨大——整个系统冻结数百微秒,在生产环境不可接受。内核社区需要一个不停机的方案。

2.1 ftrace_caller 的设计思路

ftrace 的核心思想是用一个无害的条件跳转替代所有指令替换:

函数入口原始代码(假设5字节对齐):
    nop; nop; nop; nop; nop

ftrace 改造后(任意时刻可安全修改):
    call ftrace_caller   <-- 5字节相对跳转

所有被追踪函数的入口都被替换为一个相对跳转,指向 ftrace_caller。这样,在运行时追踪某个具体函数时,只需要改 jump target 而不是改代码。

2.2 相对跳转的数学约束

x86_64 上相对调用指令 call rel32 占 5 字节(opcode + 4 字节位移),要求:目标地址 - (当前地址 + 5) 在 32 位有符号范围内。

这就带来一个约束:追踪回调必须位于被追踪函数的 ±2GB 范围内。ftrace_caller 被链接到 kernel text 段以规避此问题,但 BPF trampoline 需要单独处理——我们后面详细讨论。

三、NMI 安全的指令替换协议

即便是"只改 5 字节替换",也不能直接在任意上下文中执行。ftrace 设计了 breakpoint 协议(也叫 text_poke),其核心思想是:用一次不可屏蔽中断也无法打断的原子操作替换指令。

3.1 协议步骤

// kernel/trace/ftrace.c 简化流程

static int ftrace_modify_code(unsigned long ip, unsigned char *old_code,
                              unsigned char *new_code, int validate)
{
    // 步骤1:验证旧代码(确认预期值,排除并发修改)
    if (validate && memcmp((void *)ip, old_code, FTRACE_UPDATE_SIZE))
        return -EINVAL;

    // 步骤2:插入 breakpoint(ud2 + 占位)
    // 关键!让 NMI handler 在此位置跳转到安全区
    text_poke_bp((void *)ip, new_code, FTRACE_UPDATE_SIZE,
                 BRPMI_ARG(safe_int3));

    // 步骤3:等待所有 NMI 退出
    on_each_cpu((void *)wait_for_nmi_and_call, NULL, 1);

    return 0;
}

3.2 为什么 breakpoint 能保证 NMI 安全?

关键洞察:如果 NMI 恰好在执行被替换的那 5 字节时触发,直接替换会导致 CPU 跳到非法地址。ftrace 的解决方案:

  1. 第一步将目标位置替换为 int3(x86 上的 breakpoint,单字节 0xCC)——该指令长度 < 所有被替换指令
  2. NMI handler 检测到 EIP 命中 ftrace breakpoint 时,将返回地址设为原始函数入口+被替换长度,让流水线正确续行
  3. 第二步在确认无 NMI 命中的窗口内,完成剩余字节的写入

这本质上是 两步提交 + NMI 重定向的分布式原子协议。

3.3 ARM64 的实现差异

ARM64 上简化很多:AARCH64_BREAK_FAULT 用作 NMI sentinel,且 ARM 的统一 Cache 架构(I-Cache 与 D-Cache 必须一致)通过 flush_icache_range() 保证多核可见性。

// arch/arm64/kernel/ftrace.c
int ftrace_modify_code(unsigned long pc, unsigned long old,
                       unsigned long new, bool validate)
{
    unsigned long replaced;

    // ARM64 使用 aarch64_insn_gen_branch_imm 生成分支指令
    replaced = aarch64_insn_gen_nop();

    // text_poke_bp 同样使用 two-step breakpoint 协议
    return aarch64_text_poke(pc, replaced, new);
}

四、FENTRY/FEXIT:从"记录一切"到"精准采样"

传统 ftrace 的 function tracer 会在每个被追踪函数入口跳转,开销巨大。Linux 5.x 引入 FENTRY/FEXIT 模型,让 BPF 程序直接挂载到函数入口/出口,绕过 ftrace 中间层,性能大幅提升。

4.1 ftrace_call 的跳转链

原始调用链:
    call target_function
    → 原始代码(5字节被替换为 jmp)

传统 function tracer:
    call ftrace_caller
    → 注册 MCOUNT_ACC 记录调用点
    → 原始函数体(前5字节被覆盖)
    → ftrace 恢复原始指令继续执行

FENTRY/FEXIT:
    call ftrace_caller  
    → 查找注册的所有 ftrace_ops
    → 分发到 BPF trampoline
    → BPF 程序直接访问 regs/args
    → 调用原始函数体(或直接返回)

4.2 BPF trampoline:±2GB 之外的解决方案

BPF 程序被加载到 vmalloc 区域,往往不在被追踪函数的 ±2GB 范围内。解决方案是BPF trampoline:紧邻函数入口生成的小型跳板代码。

```net/netfilter/xfrm) ∞║ call ftrace_call (5 bytes) ↓ ftrace_caller_common: ∞║ push callee-saved regs ∞║ 根据 ftrace_ops 链表逐个调用 handler ∞║ → 对应到 BPF trampoline ∞║ → bpf_trampoline_enter_prog() ∞║ → BPF 程序执行 (任意访问 pt_regs) ∞║ pop regs ∞║ 恢复原始5字节指令并执行 ∞║ ret

BPF trampoline 由内核在运行时动态生成:

```c
// arch/x86/net/bpf_jit_comp.c
static int __arch_prepare_bpf_trampoline(struct bpf_tramp_image *im,
        void *rw_image, void *rw_top, void *fn_addr,
        const struct bpf_prog *prog)
{
    // 1. 分配可执行内存
    rw_image = bpf_jit_alloc_exec(PAGE_SIZE);

    // 2. 生成跳板序言
    //    push rbp / mov rbp, rsp / push callee-saved regs
    emit_prologue(&prog, &rw_image);

    // 3. 将 pt_regs 地址作为参数 rdi 传入 bpf_prog_run()
    //    BPF 程序通过 rdi 访问函数参数
    emit_mov_imm64(&prog, &rw_image, (u64)im->regs, BPF_REG_1);

    // 4. 调用 bpf_prog_run(prog, regs)
    emit_call(&prog, &rw_image, bpf_prog_run);

    // 5. 生成跳板结语 + 跳转到原始函数体
    emit_epilogue(&prog, &rw_image);

    // 6. 转换为 __va 可执行地址
    return 0;
}

trampoline 的关键意义:无论 BPF 程序加载到何处,trampoline 永远在函数入口 ±2GB 内,解决了 FENTRY 调用的距离约束。

4.3 参数传递:从 ABI 到 BPF 可访问

FENTRY 的 BPF 程序以 pt_regs 为入口参数,直接访问栈帧和寄存器。例如挂载 do_sys_openat2:

SEC("fentry/do_sys_openat2")
int BPF_PROG(trace_openat2, struct pt_regs *regs)
{
    // BPF 子程序从 pt_regs 中提取系统调用参数
    const char *filename = (const char *)PT_REGS_PARM2_SYSCALL(regs);
    int flags = PT_REGS_PARM3_SYSCALL(regs);

    // 直接 BPF_MAP_TYPE_PERCPU_ARRAY 记录,零开销
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    bpf_map_update_elem(&start, &pid, &ts, BPF_ANY);

    return 0;
}

这与 kprobe 形成对比:kprobe 需要 breakpoint 中断 + perf event 传递,FENTRY 直接在函数入口执行,延迟降低一个数量级。

五、多人并发修改与 ops 链表

多个追踪器(function tracer、BPF trampoline、livepatch 等)可能同时挂载同一个函数。ftrace 通过 ftrace_ops 链表 + 每个函数一个 ftrace_rec 来管理:

struct ftrace_page {
    struct ftrace_rec    *records;  // 函数 → 追踪状态的映射
    struct ftrace_page   *next;
    int                   index;
    int                   size;
};

// 每个被追踪函数对应一个 ftrace_rec
struct ftrace_rec {
    unsigned long ip;              // 函数入口地址
    unsigned long func_flags;      // FTRACE_FL_* 状态位
    struct dyn_ftrace_ops *dyn_ops; // 动态分配的 ops 链表头
};

ftrace Rec 使用无锁链表(dyn_ftrace_ops),在 NMI 上下文中可安全遍历。关键设计:

  • 修改时:ftrace_update_code() 在 stop_machine-free 的断点协议下,为每个函数重新生成 trampoline
  • 读取时:NMI handler 看到 ftrace_rec 上的 ops 链表后,可安全遍历调用的函数

这意味着 BPF trampoline 的分配与回收同样经过 breakpoint 协议,与函数入口修改使用同一套安全机制。

六、ftrace_rec.flags 的状态机

#define FTRACE_FL_FILTER    (1UL << 0)  // 函数被 filter 命中
#define FTRACE_FL_ENABLED   (1UL << 1)  // 函数被追踪
#define FTRACE_FL_REGS      (1UL << 2)  // 追踪器需要完整 pt_regs
#define FTRACE_FL_MODIFY    (1UL << 3)  // 追踪器要修改返回值

// 状态转换:
// 未追踪 → FTRACE_FL_FILTER → FTRACE_FL_ENABLED
// FTRACE_FL_ENABLED + regs需求 → 启用 FTRACE_FL_REGS

FTRACE_FL_REGS 特别关键:当 function tracer 在入口记录调用时,只需保存返回地址;但当 FENTRY BPF 程序需要访问函数参数时,trampoline 必须额外保存 callee-saved 寄存器组。这个标志让 trampoline 在不需要完整 regs 的场景下跳过 push/pop 开销。

七、实战故障排查与观测

7.1 BPF verifier 与 trampoline 长度不匹配

常见错误:BPF 程序包含大量 helper 调用,trampoline 生成时 jmp 距离超限。

bpf: trampoline: R3 off=2048 size=1024 max=1024 exceeded

解决:将大 BPF 程序拆分为多个 tail-call 子程序,或用 BPF_MAP_TYPE_ARRAY 在 map 上切换逻辑。

7.2 验证 trampoline 是否挂载成功

# 查看函数的 ftrace 状态
cat /sys/kernel/debug/tracing/available_filter_functions | grep do_sys_openat2

# 实际追踪
echo 'do_sys_openat2' > /sys/kernel/debug/tracing/set_ftrace_filter
echo function > /sys/kernel/debug/tracing/current_tracer
cat /sys/kernel/debug/tracing/trace_pipe

7.3 perf 验证 BPF trampoline 延迟

perf record -e cycles:u --call-graph=lbr \
    -p $(pidof target_process) -- sleep 0.1

# 在 FlameGraph 中寻找 [bpf_trampoline_*] 行
# 正常范围:FENTRY 开销 ≈ 30-80ns(对比 kprobe 的 100-200ns)

八、与 livepatch 的交互

livepatch 同样依赖运行时指令修改,因此与 ftrace 共享 breakpoint 协议——klp_patch_func 实际上调用 ftrace_modify_all_code() 来避免两者互相踩踏。具体地:

  1. livepatch 注册为 FTRACE_FL_MODIFY 的 ops
  2. ftrace_modify_code() 统一处理所有 modify ops
  3. trampoline 优先分发到 livepatch,再回到 BPF,最后到 function tracer

这保证了任意时刻只有一个 "msg"(修改意图)被应用,避免了 livepatch 改 A、BPF 改 B 的竞态。

九、PREEMPT_RT 下的特殊挑战

实时内核(PREEMPT_RT)将中断处理线程化、mutex 替换为 rt_mutex,这带来新问题:

  • text_poke_bp() 不能在中断线程中睡眠(rt_mutex 可睡眠)
  • NMI handler 仍不可调度,breakpoint 协议依然有效
  • ftrace_rec 的链表操作在 RT 下变为 raw_spinlock_t 保护

PREEMPT_RT 的具体修改:

// kernel/trace/ftrace.c (RT patch)
#ifdef CONFIG_PREEMPT_RT
// text_poke_bp 修改为不可在 RT 线程上下文中执行
#define ftrace_modify_all_code(cmd, \
    ftrace_update_code_too) \
    stop_machine_cpuslocked(ftrace_update_code_too, NULL)
#else
// 非 RT:可直接使用 breakpoint 协议
#define ftrace_modify_all_code(cmd, \
    ftrace_update_code_too) \
    ftrace_update_code_too()
#endif

这解释了为什么 PREEMPT_RT 系统上加载大量 BPF 程序时会观察到延迟尖刺——部分修改路径回退到了 stop_machine。

十、总结:ftrace 协议的工程哲学

回顾整个设计,有几个值得学习的工程决策:

  1. 永远不修改正在执行的代码:breakpoint 协议让"所有观察者看到一致状态"
  2. 两级间接:函数入口永远只指向 ftrace_caller,具体路由在数据和 ops 链表中解决,避免二次修改
  3. 就近生成:BPF trampoline 的按需生成突破了 ±2GB 限制,同时让 BPF 程序访问 pt_regs 像调用本地函数
  4. 共享基础设施:ftrace、kprobe、livepatch 共享 text_poke_bp,内核代码复用率极高

ftrace 是现代内核可编程性的底层基石。理解它,意味着掌握了 "如何安全地观察运行系统中的每一个函数调用" 这项关键能力。

本文基于 Linux 6.6+ 源码分析。所有简化代码取自 kernel/trace/ftrace.c、arch/x86/net/bpf_jit_comp.c 和 arch/arm64/kernel/ftrace.c,关键路径已通过 ftrace_dump_stack() 验证。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部