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 的解决方案:
- 第一步将目标位置替换为
int3(x86 上的 breakpoint,单字节0xCC)——该指令长度 < 所有被替换指令 - NMI handler 检测到 EIP 命中 ftrace breakpoint 时,将返回地址设为原始函数入口+被替换长度,让流水线正确续行
- 第二步在确认无 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() 来避免两者互相踩踏。具体地:
- livepatch 注册为
FTRACE_FL_MODIFY的 ops ftrace_modify_code()统一处理所有 modify ops- 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 协议的工程哲学
回顾整个设计,有几个值得学习的工程决策:
- 永远不修改正在执行的代码:breakpoint 协议让"所有观察者看到一致状态"
- 两级间接:函数入口永远只指向 ftrace_caller,具体路由在数据和 ops 链表中解决,避免二次修改
- 就近生成:BPF trampoline 的按需生成突破了 ±2GB 限制,同时让 BPF 程序访问 pt_regs 像调用本地函数
- 共享基础设施: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()验证。

发表评论 取消回复