Linux内核ftrace双nop机制深度解析:函数入口的动态插桩与安全切换 我 debug 过很多次内核函数被反复钩住的问题最后都绕回到同一个地方ftrace 的双 nop 机制。如果不理解它你看到的只是“某函数突然多了几个十六进制字节”但不知道为什么改的是这几个字节而不是别的也不知道为什么关了追踪之后代码还能恢复原样。这篇文章把双 nop 机制的实现逻辑、状态切换路径和实际观测方法完整拆开讲一遍适合对内核动态追踪已经有基本概念、但想深入到底层到底怎么改指令的读者。1. 双nop机制到底是“两个NOP指令”还是“两次NOP状态”先说结论双 nop 机制在 Linux ftrace 的实现里不是一个单一概念它同时存在于两个层面。一个是编译产物层面部分架构确实会在函数入口预留两个 NOP 指令槽位另一个是运行时状态层面任何架构在切换追踪开关时都会经过“从 NOP 状态到 NOP 状态”的两次稳定点。这两个层面经常被混在一起讲导致很多人理解偏了。我在刚接触 ftrace 时也以为双 nop 就是所有函数入口都有两个nop字节挨在一起。后来翻 arm、mips 的实现才发现部分架构下这样理解没有错——它们在用-pg编译时由于跳转指令长度、分支范围和链接器重定位的要求确实会生成两个占位 NOP但在 x86_64 上绝大多数情况你看到的其实是一个 5 字节的nopl指令它等价于一个长 NOP。也就是说x86 在“指令形态”上是一个 NOP 覆盖整个槽位而不是两个小 NOP 并排。那为什么题目里还叫“双 nop”因为 ftrace 的核心逻辑其实是这样的编译器插入了一个调用点运行时先把这个调用点改成 NOP让这段代码处于“安静”状态当你打开某个 tracer 时再把 NOP 改成一条跳转或者调用指令。如果你仔细追踪这个修改过程会发现修改并不是一步到位的——它会在目标地址上先写一个 NOP等待同步点然后再写入真正的跳转指令。整个流程里NOP 出现了两次一次是初始稳定态一次是切换中间态。所以我建议把双 nop 机制理解成两件事的组合空间上的双槽位某些架构和某些编译选项下函数入口保留两个可被替换的 NOP 指令位分别给主跳板和次跳板使用时间上的双 NOP 状态代码补丁过程需要经过“全 NOP 稳定态”再进入“跳转稳定态”防止 CPU 在修改窗口内执行到半截指令。如果你只盯 x86 的objdump输出可能觉得“双 nop”名不副实。但如果你看的是 ARM 架构的动态 ftrace 代码或者你看的是 ftrace 状态机本身的两次切换逻辑这个名字就非常贴切。1.1 为什么需要“NOP 状态”而不是直接打补丁动态插桩的第一个冲突就是性能和修改安全的矛盾。如果不做 NOP 化每个函数入口都保留一条真实的调用指令那么哪怕你完全没开任何 tracer每次调用函数都要多执行一次call这在函数调用密集的内核里是不可接受的损耗。尤其是网络收包路径、系统调用入口这种每秒上百万次的热点路径一次多余的间接跳转都会让吞吐掉几个百分点。于是设计者想到一个办法让编译器先把调用点放好然后在系统初始化阶段把这些调用点改成 NOP。NOP 在 CPU 执行时几乎不产生额外开销大部分现代 CPU 甚至能在解码阶段直接把它吞掉。问题是当你想启用追踪的时候必须把 NOP 改回调用指令这个改写不能把正在执行的指令弄坏。如果是单线程环境直接改写内存没问题但内核里所有 CPU 可能同时执行同一段函数代码这就需要一个安全的中间状态。双 nop 机制的本质就是通过一个“无副作用”的 NOP 中间态让其他 CPU 无论什么时候执行到这段代码看到的都是完整且合法的指令。NOP 是最安全的填充物它不改变任何寄存器、不跳转、不访问内存就算被重复执行也不会有副作用。有了这个中间态补丁代码就可以分步完成而不是冒险一次写入多条指令。1.2 不同架构对“双 nop”的不同翻译x86_64 的编译选项-mfentry会在每个函数入口生成一条对__fentry__的调用长度 5 字节。ftrace 初始化时用一条 5 字节nopl把调用覆盖掉这 5 字节一共对应了指令流里的一个逻辑 NOP 槽位。当启用 ftrace 时再将nopl改写成call ftrace_caller或者jmp ftrace_caller。ARM64 的情况不太一样。它使用-fpatchable-function-entry或者特殊 ftrace 入口时会在函数入口预留两个 NOP一个放在真正入口点之前一个放在入口点之后。第一个 NOP 用于被替换成对 ftrace 入口的跳转第二个 NOP 用于调整跳转距离或者容纳追加的寄存器保存指令。MIPS 上早期实现也是把move at, ra; jal _mcount这两条指令替换成两条 NOP然后在需要的时候只把第一条改成跳转第二条保持 NOP 充当对齐和返回地址保护。这解释了为什么概念本身在不同架构看起来差异很大x86 用户觉得“双 NOP 就是 5 字节大 NOP”ARM 用户看到的是“真的两个 nop 指令”MIPS 用户看到的又是“两条 nop 起跳的组合”。它们都是双 nop 机制在不同指令集约束下的具体形态。2. 函数入口的两个槽位一个给主跳板一个给次跳板把“两个槽位”细看之前先明确一个基础事实动态 ftrace 并不是把所有函数都无条件挂上探针而是靠函数入口的指令占位符来预留可能被修改的位置。这个占位符就是 NOP 槽位。槽位数量取决于架构和编译参数但设计思想上往往需要两个。第一个槽位叫主跳板槽位它的作用很直接当 ftrace 子系统判断某个函数需要被追踪时就把这个槽位从 NOP 修改为跳转到统一入口ftrace_caller的指令。第二个槽位叫次跳板槽位它在不同场景下承担不同任务。最常见的一个任务是为 function_graph函数图追踪模式服务。function_graph 不仅要在函数进入时拦截还要在函数返回时拦截。要实现返回拦截hook 必须修改栈上的返回地址把这个返回地址指向 ftrace 自己的返回桩函数。这个修改过程如果只有一个槽位就会出现“主 hook 已经生效、返回 hook 还没来得及安装”的窗口期可能导致部分调用没有被正确配对。第二个槽位在启用CONFIG_DYNAMIC_FTRACE_WITH_REGS时还有一个作用它给ftrace_regs_caller预留了保存完整寄存器上下文的指令空间。我们知道普通函数调用遵循 ABI但 ftrace 在进入钩子函数时为了保证回调能拿到完整的寄存器现场需要把更多寄存器压栈。保存这些寄存器的指令长度可能超出第一个槽位原本容纳的跳转指令长度于是第二个 NOP 就变成扩展补丁区。2.1 从指令布局看两个槽位的分工下面我用一段示意性汇编展示双槽位布局。实际指令编码因架构而异这里关注的是位置关系不要把具体机器码当作所有平台通用结论。sym_do_work: nop ; 槽位1主跳板。 ; 关闭ftrace时是nop ; 开启function跟踪时被改写为 ; call ftrace_caller 或 jmp ftrace_caller nop ; 槽位2次跳板/扩展区。 ; function_graph模式下可能被改写为 ; 对返回桩的短跳转 ; regs模式下用于保存额外寄存器 push %rbp ; 函数真正的开场指令 mov %rsp, %rbp ...如果把这份布局对照到 x86_64 的真实场景会发现很多时候槽位 1 是一个 5 字节nopl槽位 2 并没有独立出现而是被合并在函数入口的调度间隙里。不过抽象逻辑仍然成立ftrace 关心的不是“几个 NOP 字节”而是“几个可安全改写的指令槽”。2.2 为什么次跳板不能省有人会问如果只是实现进入时的 function tracer一个槽位不就够了吗没错只追进入钩子的话一个槽位足够。但 ftrace 的历史演进中function_graph 和 regs 模式是后来加入的一等公民。为了不让这两种模式影响普通 function tracer 的入口布局设计者选择保留多个槽位让不同模式可以共享一个函数入口点而不互相覆盖。更关键的是如果没有次跳板槽位当从“普通 function tracer”切换到“function_graph tracer”时就必须把主跳板从 A 形态完整改写到 B 形态。而这段改写过程如果出现半个指令窗口正在执行该函数的 CPU 会崩给你看。有了一个额外 NOP 槽位就可以把新模式的指令先写到次跳板槽位再通过一条原子跳转切换过去把崩溃窗口压缩到最小。这是双 nop 机制在工程上的核心价值用空间换时间、用空间换安全。2.3 调用约定里的“返回地址保护”次跳板还有一个容易被忽略的作用保护返回地址。x86 的call指令会把返回地址压栈ret再从栈上弹回。如果入口 hook 在修改栈内容时把返回地址弄丢函数一执行ret就飞到不知道哪里去了。双 nop 机制里的次跳板常常配合一段专门的返回地址搬运代码进入函数时先把返回地址保存到 per-cpu 变量或寄存器里然后安装新的返回地址指向 ftrace 的返回桩函数返回时返回桩拿到控制权再通过保存的信息恢复原始返回路径。整个过程如果只有一个槽位保存和安装两个动作很难紧凑地交织在短线跳范围里因此第二个 NOP 就提供了这个容身之地。3. ftrace运行时在双nop端点之间的切换路径了解了双 nop 的布局和分工接下来看运行时是怎么在“NOP 状态”和“跳转状态”之间来回切换的。这是理解实现细节最关键的一步。ftrace 的动态修改并不在每次开关 tracer 时对整个内核函数表做扫描而是维护了一个函数引用计数和模式状态。每个可能被追踪的函数都对应一个跟踪标志位。当没有任何 tracer 或 filter 需要挂钩它时这个函数入口保持 NOP一旦有 tracer 订阅该函数状态切换逻辑就开始工作。3.1 函数状态机的两段 NOP 稳定点从 ftrace 的视角看每个函数有几种状态初始状态编译器插入的调用指令在启动早期被替换为 NOP此时函数整体处于“冷态”活跃状态NOP 被替换为对ftrace_caller的跳转每次函数调用都会进入 ftrace 的公共入口特殊状态根据 tracer 类型ftrace_caller内部会再通过一个“第二跳转表”决定具体调用哪些回调函数。第一段 NOP 稳定点就是“冷态”。这段状态的特点是所有函数入口都是 NOP但每个函数的地址和槽位信息已经被记录在__mcount_loc节区里。内核通过这个节区知道哪些地方可以改、改回去之后应该恢复成什么样。第二段 NOP 稳定点出现在“从活跃态回到冷态”的过程中。当最后一个 tracer 被移除时ftrace 需要把跳转指令还原为 NOP。它不会直接从一个跳转变回另一个跳转而是先写回 NOP等待所有 CPU 都执行过 safe point再清理与 tracer 相关的引用资源。这第二段 NOP 状态虽然存在时间很短却是保证追踪功能可以反复启停的关键。3.2 核心接口ftrace_make_nop 与 ftrace_make_call每个支持动态 ftrace 的架构都需要实现一组回调其中最重要的是ftrace_make_nop和ftrace_make_call。它们分别负责把调用点变成 NOP、把 NOP 变成调用点。ftrace_make_nop的输入是原始指令的指针、修改点的地址和一个说明如何恢复的记录结构。它会根据架构的指令编码规则把这段位置写成 NOP。对于 x86这一步基本是用text_poke写入 5 字节的nopl。对于 ARM32则是写入两条nop对应前面说的双 NOP 布局。ftrace_make_call做的事情正好相反。它根据函数地址和ftrace_caller入口地址计算偏移量然后生成一条调用或跳转指令写入对应槽位。不同架构对跳转偏移的范围要求不一样比如 ARM 的bl指令只有 24 位偏移范围有限。这就解释了为什么部分架构需要把一个跳转拆到两个槽位上或者通过次跳板中转一个槽位放不下足够远的目标地址时就需要两个 NOP 协同工作。3.3 text_poke为什么修改指令不担心并发在内核里动态修改正在执行的代码最怕的是 CPU 已经在取指阶段读了一半指令你这边改了另一半于是它执行出一条拼凑出来的非法指令。Linux 的解决方案是text_poke它本质上是一个在线的代码修改机制。text_poke的核心思路是把要修改的内存页设置为可写改完之后再恢复只读属性。但这还不够安全因为指令 cache 的问题不能靠页权限解决。于是内核会让修改操作在 CPU 的某些同步点上完成修改过程先通过 IPI 让所有 CPU 停到一个安全位置或者利用stop_machine机制确保同一时刻只有一个 CPU 在热点代码上运行改完再释放。这种同步机制非常重所以 ftrace 不会在每次回调时都做这件事而是在 tracer 启停、函数 filter 变更的“大动作”里才触发。双 nop 机制的意义在这里体现得很清楚它把一次大改动拆成了两个小步。先从小跳转/调用写到 NOP再在下一个同步窗口从 NOP 写到新跳转。每一步的改动都是单指令单指令在text_poke的同步保护下是安全的。如果试图一次从 A 跳转直接改成 B 跳转一旦两个跳转目标的编码长度不一致就要写多个指令字节中间态就无法保证安全。3.4 引用计数和过滤器的联动实际使用中不是所有函数都会同时进入活跃状态。ftrace 通过过滤器规则例如available_filter_functions中的函数名、通配符维护一个“需要挂钩”的集合只有集合内的函数才从 NOP 切换到跳转态。加上每一个 tracer 自己又会维护一个回调数组真正决定某个函数是否要进入活跃态的是引用计数是否大于零。这个引用计数还影响双 nop 槽位的分配。当 function tracer 和 function_graph tracer 同时存在时两个槽位分别被不同模式占用。如果你用一组 filter 只追踪部分函数那这些函数会进入“双槽位都被改写的状态”其他函数继续保留双 NOP。这样精细的粒度控制是 ftrace 在生产环境可用而不拖垮全系统的关键。4. function、function_graph、regs三种模式如何共用入口从用户视角看ftrace 有几种常见可用模式。这里把它们放在双 nop 机制的背景下逐个拆开你会发现它们其实共享了同一个物理入口只是利用槽位的方式不同。4.1 function tracer只用主跳板最普通的函数追踪模式打开后内核会把你选中的每个函数入口槽位从 NOP 改成对ftrace_caller的调用。ftrace_caller是 ftrace 在内核里安放的统一入口代码它会把当前函数的返回地址和函数地址保存下来然后依次调用该函数上注册的回调函数。因为普通 function tracer 只需要知道“进入了哪个函数”不需要完整寄存器上下文所以它对第二个槽位的诉求很低。在 ARM 双 NOP 架构下第二个槽位在这种模式下通常保持 NOP用于对齐。在 x86_64 下由于nopl本身就覆盖了 5 字节整个入口看起来只是从一个 NOP 形态切到 call 形态复杂度很低。4.2 function_graph tracer返回路径依赖次跳板function_graph 是另一个端点难题。它希望画出函数的调用树所以必须知道函数什么时候返回。为了拦截返回ftrace 会修改函数栈上的返回地址让函数执行ret时跳回 ftrace 的返回桩。这个安装过程需要做的额外操作很多比如读取旧返回地址、压入新地址、可能还要调整栈布局。为了不让这些额外操作污染普通 function tracer 的性能双 nop 机制提供了第二块补丁空间。在支持分支覆盖的架构上函数入口的两个槽位可能被这样使用主槽位跳向ftrace_graph_caller次槽位则配合保存返回地址的指令。启动 function_graph 时内核不仅改写了主槽位也在次槽位里安装了配合代码从而保证返回链路完整。4.3 ftrace_regs_caller寄存器现场扩展CONFIG_DYNAMIC_FTRACE_WITH_REGS编译选项开启后ftrace 会提供ftrace_regs_caller入口它能在进入回调函数时保存比普通 ABI 更全的寄存器现场。这对调试类工具、内核性能剖析工具很重要因为需要知道调用现场的寄存器值。保存更多寄存器意味着入口处需要的指令长度更长。此时双 nop 槽位中的第二个槽位经常被用作“副指令区”存放扩展的压栈指令。这一点在 ARM64 上很明显它通过-fpatchable-function-entry2在函数入口生成两个 NOP 指令前一个 NOP 用于跳转后一个 NOP 作为功能扩展区ftrace_regs_caller的额外指令可以无缝融入这个区域。4.4 多 tracer 同时挂载时的共享调度一个函数同时被 function tracer 和 function_graph tracer 追踪的情况并不少见。ftrace 并没有为每个 tracer 维护一套独立的函数入口补丁而是在ftrace_caller之后的公共路径里做统一分发。入口的 NOP 只需要改成一次跳转剩下的“该调哪个回调、要不要保存 regs、要不要转 graph”都由公共代码决定。双 nop 的布局在多 tracer 场景下的优势就出来了入口永远是稳定的单条跳转多变的部分被安排在次槽位和公共入口内部不会发生两个 tracer 互相覆盖补丁的冲突。如果你在 tracefs 里同时打开function和function_graph仔细看内核日志你会发现它并没有把函数入口改来改去而是把两个回调同时挂进了同一套分发链。追踪模式主跳板槽位次跳板槽位入口指令典型形态未开启NOPNOPnop / nopfunctioncall ftrace_callerNOPcall / nopfunction_graphcall ftrace_graph_caller返回地址辅助指令call / subregscall ftrace_regs_caller额外寄存器保存区call / push 指令组这张表是简化后的逻辑示意实际不同架构会把部分操作挪到 trampoline 里但双槽位的分工思想是通用的。5. 实测在一个能跑ftrace的内核上观察双nop的效果原理讲再多不如动手看一次。这里我给出一套可复现的观测方法前提是你有一台开启了CONFIG_DYNAMIC_FTRACE、CONFIG_FUNCTION_TRACER、CONFIG_FUNCTION_GRAPH_TRACER的 Linux 系统。大多数发行版内核都默认开了这些选项不用担心。5.1 确认内核已启用动态 ftrace先看启动参数和配置cat /proc/cmdline zgrep CONFIG_DYNAMIC_FTRACE /proc/config.gz zgrep CONFIG_FUNCTION_TRACER /proc/config.gz如果CONFIG_DYNAMIC_FTRACEy说明内核使用了动态补丁机制。如果输出里显示CONFIG_DYNAMIC_FTRACE_WITH_REGSy那还说明 regs 模式可用。接着挂载 tracefsmount -t tracefs nodev /sys/kernel/tracing # 老版本路径是 /sys/kernel/debug/tracing5.2 对比函数入口指令的修改前后选择一个你要观察的函数比如内核里稳定存在的do_sys_open或者某个你模块里导出的非内联函数。用grep在/proc/kallsyms里确认地址grep do_sys_open$ /proc/kallsyms开启 ftrace 之前用objdump或者gdb查看该地址附近的指令。一般会看到类似ffffffff81234560 do_sys_open: ffffffff81234560: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)这就是 x86_64 上双 nop 机制里“逻辑单 NOP、物理 5 字节”的形态。如果你在 ARM64 机器上验证则可能看到两个连续的nop占位。现在打开 function tracerecho function /sys/kernel/tracing/current_tracer echo do_sys_open /sys/kernel/tracing/set_ftrace_filter再查看同一地址指令可能变成ffffffff81234560: e8 ab 43 21 00 call ffffffff81238910 ftrace_caller这就是双 nop 机制里的第一槽位从 NOP 切换到跳转的完整过程。对比前后差异很快能理解“入口指令变成了 ftrace 的桥”。5.3 观察 function_graph 模式下的入口变化继续在 tracefs 里切换echo function_graph /sys/kernel/tracing/current_tracer此时入口指令可能在主槽位跳转到ftrace_graph_caller次槽位附近也会多出配合指令。用cat trace能看到调用树2) 0.080 us | do_sys_open() 2) 0.040 us | do_filp_open() 2) 0.030 us | path_openat()回到双 nop 机制本身你不需要逐字节理解这些指令只要明白每次切换 current_tracer内核其实是在背后对大量函数入口做了一次安全的 NOP/跳转改写而这套改写正是靠双 nop 机制中的若干稳定状态支撑起来的。5.4 踩过的实测坑别在追热函数时开全量 filter我实际验证时遇到一个坑把set_ftrace_filter设为*或者打开function_graph且不对调度器函数做过滤系统会变得非常卡甚至出现明显的 soft lockup。这不是双 nop 机制本身的问题而是 tracefs 的环形缓冲和回调链表在极高频函数上消耗了太多 CPU。建议做实验时尽量把过滤范围缩小到某个目标函数避免在内核的调度热路径上挂全局探针。如果只是想验证入口修改还有一个更直接的旁观者/sys/kernel/tracing/available_filter_functions列出的函数都是可以被动态修改入口的对象。数一数它的数量就能知道你内核里到底有多少个 NOP 槽位正在陪着 ftrace 待命。6. 基于该机制做内核模块拦截时的三个具体坑深入理解双 nop 机制之后很多人会想用它来做自己的模块级 hook比如拦截read、write等文件操作函数。基于 ftrace 的 hook 确实比直接修改 syscall table 更稳但在实操中容易踩坑。我总结三个最常见的基本都能从双 nop 机制的原理推导出来。6.1 坑一函数被 inline 后根本没有 NOP 槽位ftrace 只对非内联函数有入口槽位。如果你试图用 ftrace 挂一个被编译器内联的函数available_filter_functions里根本看不到它。这时候你以为函数被追踪了实际上回调永远不会触发。解决方案是改用kprobe或者kprobe与 ftrace 混合方案。kprobe 不依赖编译期的 NOP 槽位它是运行时在指定指令地址下断点的机制。缺点是没有双 nop 那种常态零开销但只要不放热路径问题不大。同理想拦截read/write最稳的位置往往不是vfs_read、vfs_write本身而是它们内部调用的文件操作结构体里的函数指针。拦截这些指针需要改内存那就又涉及 RCU 和并发问题与 ftrace 的双 nop 思路完全无关。搞混这两者的边界是内核模块 hook 最常见的错误。6.2 坑二多个机制争抢同一个入口指令kprobe 和 ftrace 有时会作用于同一地址附近。ftrace 的入口槽位是编译期预留的kprobe 的断点则是运行时写入的。如果两个机制在同一段指令上打架可能会出现其中一个把另一个覆盖掉或者text_poke重试导致栈回溯错乱。我的建议是如果能用 ftrace 就优先用 ftrace因为它有完整的引用计数和回调管理机制只有在 ftrace 够不到的指令地址上才用 kprobe。不要在同一个函数的入口处既注册 ftrace 回调又下 kprobe 断点那是制造竞态的经典姿势。6.3 坑三跳转范围不够时的“第二跳板”当你自己写寄存器级 ftrace 回调时可能遇到一种诡异情况模块加载后发现 hook 只对部分函数生效另一些函数明明在过滤器里却没有任何调用记录。常见原因是你的回调函数地址离被 hook 函数太远超出了单条跳转指令可表达的范围。双 nop 机制里的第二个槽位其实也承担着“中转跳板”的角色如果主跳板的相对偏移放不下可以通过第二个槽位引入一段 trampoline。内核模块一般加载在模块内存区和被 hook 的核心内核函数距离可能很远。这时候需要保证回调代码放在与 ftrace_ops 关联的 trampoline 内或者显式启用FTRACE_OPS_FL_SAVE_REGS等标志让 ftrace 帮你选择合适的分发路径。直接写一个简单的ftrace_ops然后期望所有函数能直接跳到你的模块地址很容易踩到范围限制。6.4 设计建议把“槽位”想成资源而不是字节如果你在为自己的跟踪框架设计挂钩层不要把双 nop 机制简单理解为“在函数入口塞两个 nop”。它真正教会我们的是任何动态插桩都应当预留一个无副作用的稳定状态让所有 CPU 在任何时刻看到的是完整指令应当通过引用计数管理多个消费者的共享应当把修改动作分解成可原子的单指令步长。这套设计思路完全可以直接迁移到用户态动态插桩甚至 eBPF 的 trampoline 设计里也能看到类似原则。理解了双 nop 机制再去看 eBPF 的fentry你会有一种“原来如此”的通透感。我实际写内核工具时另一个体会是不要试图自己手动修改函数入口字节去复刻 ftrace 的双 nop 流程。stop_machine和text_poke的同步细节太多靠模块代码很难做对整个生命周期。老老实实调用 ftrace 框架让它在双 nop 状态机里管理你的回调这比什么都稳妥。双 nop 机制是理解整个 Linux 动态追踪体系的钥匙之一也是我见过的“用最少的代码改动换取最强的运行时灵活性”的经典设计。如果之后你在排查追踪失效或者 hook 冲突时能第一时间想到“这个问题不符不回到入口指令的稳定状态上”那这篇文章就是有意义的。