ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux内核ftrace双nop机制深入解析:函数入口动态插桩的实现与验证

Linux内核ftrace双nop机制深入解析:函数入口动态插桩的实现与验证 做了几年内核相关的开发和性能分析我一度觉得ftrace就是个“查调用关系的神器”echo一下function_graph就能看到一串漂亮的函数调用树。但真正让我对它产生敬畏的是后来有一次在ARM64板卡上调一个诡异的内核卡死问题不得不去翻arch/arm64/kernel/ftrace.c和entry-ftrace.S的汇编代码才第一次认真面对Linux ftrace那套“双nop机制”的底细。所谓双nop简单说就是在每个可跟踪函数的入口处预留两条NOP指令槽位运行时按需把它们改写成跳转指令从而实现动态插桩。这篇文章我会把编译器如何埋槽位、内核如何改指令、function graph如何借用第二个nop、以及验证时踩过的坑全部讲清楚适合想深入理解ftrace实现或者正准备在自己的内核上做动态跟踪的朋友。1. 双nop机制解决的问题与整体思路1.1 动态插桩的“零开销”目标先说清楚一个最基础的问题为什么内核要搞一套如此复杂的入口指令改写机制而不是像早期内核那样在每个函数里直接塞一个固定的trace调用因为性能。生产环境里绝大多数时候我们不需要跟踪函数如果每个函数入口都无条件执行一次trace逻辑哪怕只是做个全局开关判断整个内核的热路径都会被拖慢几个百分点。这对服务器和嵌入式设备都是不可接受的。所以ftrace早期设计就定了一个目标关闭跟踪时函数入口的额外指令要尽可能廉价开启跟踪时才把真正的调用逻辑换上去。这个目标落实到具体实现就是“动态补丁”编译阶段留好位置运行阶段按需改写。真正巧妙的地方在于预留的位置不能是任意指令必须是对正常执行没有任何副作用的指令。在ARM64上最简单的选择就是NOP。如果只预留一条NOP理论上也能做到“关闭时零开销开启时改写成BL ftrace_caller”。但后面你会发现function tracer和function graph tracer这两个功能如果都要支持单条NOP槽位是不够用的。1.2 为什么是双nop而不是单nop我最初看ftrace文档时也有个疑惑函数入口放一个NOP改成BL跳转不就行了吗干嘛要放两个关键在于function graph tracer的实现方式。普通的function tracer只记录“进入了哪个函数”它在函数入口放一个钩子就够了。但function graph tracer还要记录“函数什么时候返回”以此计算函数执行时间、绘制完整的调用树。怎么在入口处同时钩住“返回”呢答案是入口钩子不仅要在进入函数时被调用还要偷偷修改函数的返回地址让函数真正ret的时候先跳到ftrace维护的一段返回处理代码那里再调用return回调。这段逻辑在ARM64上被拆成了两部分一部分是入口处的跳转指令另一部分是汇编跳板里的返回地址篡改逻辑。问题来了如果function tracer和function graph tracer同时开启它们都需要修改函数入口的指令。如果用同一个NOP槽位两个功能会互相覆盖、打架。x86上的解决思路比较复杂它复用同一个调用点靠ftrace_caller内部再判断当前ops和返回地址搞了一堆条件分支。而ARM64选择了更优雅的方案直接在函数入口预留两个NOP一个给function tracer用一个给function graph tracer用两条指令互不干扰谁开启就改写谁的槽位。这就是“双nop机制”的由来每条可跟踪函数入口有两个4字节的NOP第一个对应ftrace_caller第二个对应ftrace_graph_caller。关闭所有跟踪时它们都是NOP开了function trace第一条变成BL ftrace_caller开了graph第二条变成BL ftrace_graph_caller两个都开两条都是BL各管各的。1.3 双nop机制的完整生命周期理解双nop不能只看运行时的改写动作它的生命周期横跨编译、链接、启动、运行四个阶段。编译阶段GCC/Clang通过-fpatchable-function-entry2在每个非notrace函数入口放两条NOP同时由recordmcount工具扫描每个函数的入口地址记录到__mcount_loc段。链接阶段这些地址被收集成一个表内核启动时可以通过__start_mcount_loc和__stop_mcount_loc两个符号拿到表边界。启动阶段内核的ftrace初始化代码遍历这张表为每个入口地址建立一个dyn_ftrace记录并默认把入口指令设置成NOP如果编译器已经放的是NOP这一步就是确认状态。运行阶段用户通过tracefs接口echo某个traceer或filter时内核会遍历dyn_ftrace记录根据该函数是否命中过滤条件把入口第一条NOP改写为BL ftrace_caller或把第二条NOP改写为BL ftrace_graph_caller。关闭跟踪时再恢复成NOP。整个过程听起来不复杂但每一步都有很多细节值得展开。2. 编译期双nop是怎么“长”出来的2.1 -fpatchable-function-entry 在函数入口埋槽位ARM64上真正让“双nop”成为可能的关键是GCC/Clang提供的-fpatchable-function-entryN编译选项。这个选项的含义是在每个函数的序言prologue最前面插入N条NOP指令作为“可打补丁区域”。N等于几取决于目标架构和内核配置。ARM64的ftrace实现使用N2所以每个函数入口会有两条4字节的NOP。如果你反汇编一个编译了ftrace支持的内核vmlinux能看到很多函数开头都是ffff800010000000 some_function: nop // 第1个nopfunction tracer槽位 nop // 第2个nopfunction graph槽位 stp x29, x30, [sp, #-16]! ...注意这种方案和老的-pgmcount方案完全不同。老方案是编译器在函数入口生成一条BL _mcount调用指令运行时再把这条BL改写成NOP。新方案是编译器直接放NOP运行时再把NOP改写成BL。方向反过来了。为什么新方案更优因为-pg生成的BL是一条真实的跳转指令即使后来被改写成NOP这条指令在编译阶段的语义、重定位、分支范围限制都更复杂而直接放NOP编译阶段没有任何跳转开销内核只需管理“NOP-BL”和“BL-NOP”两种形态即可。这也是为什么新内核在ARM64上默认走-fpatchable-function-entry2而不是-pg。有一个细节要特别注意-fpatchable-function-entry2意味着两条NOP都放在函数的最开头而不是在栈帧建立之后。这意味着ftrace_caller被调用时当前栈上还没有当前函数的栈帧只有调用者的栈帧和LR。这直接影响后面ftrace_caller的现场保存方式我在第3章会详细讲。2.2 __mcount_loc 如何记录每个入口光在函数入口放NOP还不够内核还需要知道“哪些地方放了NOP”。编译器负责埋链接器负责收集。这一步靠的是recordmcount工具和__mcount_loc段。编译内核时scripts/recordmcount.c或scripts/recordmcount.pl会扫描每个.o文件找到所有带ftrace插桩标记patchable-function-entry生成的NOP位置的函数入口生成一个特殊的重定位段。链接进vmlinux之后这些入口地址会连续排列在__mcount_loc段里。内核侧对应代码在kernel/trace/ftrace.c里初始化时通过start (unsigned long)__start_mcount_loc; stop (unsigned long)__stop_mcount_loc;遍历这段区间对每个地址创建一个struct dyn_ftrace记录struct dyn_ftrace { unsigned long ip; unsigned long flags; struct dyn_arch_ftrace arch; ... };ip就是这个函数入口的地址flags记录当前状态是否启用、是否被过滤等。运行时的所有动态改写都是围绕这些dyn_ftrace记录展开的。这里有个容易踩的认知误区并不是所有函数都会被记录。编译器会根据notrace属性跳过显式标记的函数内核代码里很多对延迟敏感或自身会被ftrace回调路径调用的函数会加notrace。此外纯汇编写的函数如果不用ENTRY宏或者没有经过编译器的patchable选项处理也不会有NOP槽位根本无法被动态跟踪。2.3 与x86上mcount/fentry实现的对比既然说到双nop很多人会拿x86_64来做对比。x86_64上传统做法不是双nop而是单指令槽位。编译器在函数入口生成一条5字节的call __fentry__指令ftrace在运行时把这条call改写成5字节NOP或者改写成跳转到ftrace_caller的指令。开启function graph时x86并没有第二个入口槽位可用它是在ftrace_caller这个跳板内部做文章通过检查ftrace_graph_ops是否激活、以及构造特殊的返回地址让被跟踪函数返回时进入return_to_handler。x86这种“单槽位复用”方案的好处是省代码体积坏处是逻辑集中在跳板里比较绕。ARM64的双nop方案等于从编译器层面就把两个功能的空间分开了实现上清晰很多。所以你在看ARM64的entry-ftrace.S时会看到ftrace_caller和ftrace_graph_caller是两个独立的入口各自处理各自的逻辑没有x86那种“同一个入口再分流”的复杂过程。从代码维护角度讲双nop显然更友好。这也是为什么后来RISC-V等架构也开始参考类似思路用-fpatchable-function-entry预留多个NOP槽位而不是走mcount老路。3. 运行时把nop改写成跳转的实现细节3.1 核心函数 ftrace_make_call 与 ftrace_make_nop运行时改写是双nop机制的心脏。ARM64上的实现集中在arch/arm64/kernel/ftrace.c。先说数据结构。每个dyn_ftrace记录对应一个函数入口。当用户切换tracer或修改过滤规则后ftrace核心代码会调用架构相关的回调int ftrace_make_call(struct dyn_ftrace *rec, unsigned long addr); int ftrace_make_nop(struct module *mod, struct dyn_ftrace *rec, unsigned long addr);ftrace_make_call负责把rec-ip位置的NOP改成一条跳转指令跳转目标是addr通常就是ftrace_caller的地址。它内部会调用aarch64_insn_gen_branch_imm生成一条BL指令static int ftrace_make_call(struct dyn_ftrace *rec, unsigned long addr) { unsigned long pc rec-ip; u32 insn aarch64_insn_gen_branch_imm(pc, addr, AARCH64_INSN_BRANCH_LINK); return ftrace_modify_code(pc, insn, false); }ftrace_make_nop则反过来用aarch64_insn_gen_nop()生成NOP指令把入口指令恢复成NOP。这里有一个非常关键的工程点BL指令有±128MB的分支范围限制。ftrace_caller和用户函数之间如果距离超过128MB单条BL是跳不过去的。ARM64上为了解决这个问题一种做法是让内核链接时把ftrace_caller安排在合适的范围内另一种做法是在编译内核时使用长调用选项让patchable区域不再是两个NOP而是两条跳转指令的组合。不过大多数情况下内核镜像主体和ftrace跳板都在同一个128MB范围内直接BL就够了。还有一个细节aarch64_insn_gen_branch_imm生成跳转指令时会计算当前PC到目标地址的偏移所以回填的BL指令里的立即数不是固定的而是依赖最终链接地址的。这也是为什么不能用一份写死的指令字节去打补丁必须实时计算。3.2 function graph如何利用第二个nop第二条NOP的逻辑在ftrace_modify_graph_caller里。ARM64专门维护了一个graph caller入口ftrace_graph_caller。当function graph tracer被激活时ftrace会把第二条NOP改写成bl ftrace_graph_caller这一步在arch/arm64/kernel/ftrace.c的ftrace_modify_graph_caller函数中完成。它和第一条NOP的改写是独立的互不依赖。那么被跟踪函数的控制流就变成了some_function: bl ftrace_graph_caller // 原来这里是一个nop stp x29, x30, [sp, #-16]! ... 函数体 ... retftrace_graph_caller的汇编逻辑要做三件事保存当前LR到栈因为这是函数返回时要恢复的关键信息。把LR篡改成return_to_handler的地址这样函数执行完ret时不会回到真正调用者而是跳进return_to_handler。调用ftrace graph的入口回调然后跳回函数体继续执行通过修改x30或直接跳转实现。等函数真正执行到ret跳进return_to_handler后它会调用ftrace_return_to_handler找到真正的返回地址恢复LR再执行一次ret从而返回给真正的调用者。整个过程从外部看就像函数被“包”了一圈能同时记录进入和返回。这里就体现出双nop的威力了如果只有一条NOPfunction tracer和function graph tracer必须争同一个入口槽位要么实现成“两个tracer不能同时开”要么在跳板内部做复杂分流。而ARM64直接把“入口钩子”和“返回挂钩子”放在两个槽位上互不打扰。3.3 调用链现场从bl到ftrace_caller再到回调看代码的时候很多人会忽略一个细节bl ftrace_caller之后CPU的LR寄存器已经被改成some_function里BL指令的下一条地址。换句话说ftrace_caller刚进来时LR表示的是“被跟踪函数体的入口地址”而不是真正的函数调用者。这对function tracer来说是好事因为它确实想知道被跟踪函数是谁。但ftrace_caller如果想回到被跟踪函数继续执行它不能在最后简单执行ret因为LR已经被覆盖了。所以ftrace_caller的汇编实现里第一步就是把LR保存到栈上SYM_CODE_START(ftrace_caller) stp x29, x30, [sp, #-16]! ... mov x0, x30 // 把被跟踪函数址作为参数传给回调 bl ftrace_ops_func // 调用注册的回调 ... ldp x29, x30, [sp], #16 retret执行时LR已经被恢复成some_function里BL后面的那一条指令地址所以控制流能回到函数体继续执行。这套“保存LR-用LR-恢复LR”的套路是ftrace跳板的核心。除了LRftrace_caller还需要保存哪些寄存器这取决于调用约定。ARM64上x0-x18是调用者保存的x19-x29是被调用者保存的。ftrace_caller作为被调方理论上可以不保存x0-x18但回调函数可能会修改这些寄存器进而影响被跟踪函数的执行。所以CONFIG_DYNAMIC_FTRACE_WITH_REGS模式下ftrace_caller会保存一组更完整的寄存器现场包括x0-x30以及可能的浮点寄存器这样才能让回调安全地修改参数或返回值支撑kprobe和BPF trampoline那些高级功能。这也是为什么双nop的关键不只是那两条NOP配套的跳板汇编质量也直接决定整套机制的可靠性和性能。3.4 安全性stop_machine与指令改写把正在运行的内核代码段里的指令改掉这听起来就是个高危操作。很多刚开始看ftrace代码的人都会问改到一半CPU正好执行到这条指令怎么办ARM64的做法很直接改写指令时把所有CPU都停到安全点改完再放行。核心API是aarch64_insn_patch_text它底层走stop_machine机制。stop_machine会把所有在线CPU集合到一个已知的执行点确保没有CPU正在执行或者即将取指到被修改的函数入口然后再执行真正的写入。这个机制的代价是每一次“开启/关闭跟踪”都是一次全局停机操作如果函数数量特别多比如全内核function trace改写成千上万个NOP槽位的总耗时可能达到几十毫秒甚至更久。好在这是低频操作大多数用户只会在调试时切换几次。还有一个细节是代码段只读保护。内核代码段通常被映射为只读直接写会导致页错误。ARM64上改写指令前需要临时修改页属性或者在patch_map映射出一个可写的别名窗口来写入。这些逻辑被封装在aarch64_insn_patch_text里架构代码负责处理上层不需要关心。你可能会问为什么不用x86的int3 breakpoint方案非不能是选择不同。x86上为了让修改更平滑常用text_poke_bp先把目标地址改成int3等所有CPU都经过这个trap后再改成目标指令避免stop_machine那种“全局硬停”的代价。ARM64历史上选择了stop_machine实现简单、可靠代价是切换traceer时有短暂停顿。这两种思路没有绝对优劣但理解区别有助于你看不同架构代码时不迷路。4. 双nop带来的能力边界与踩坑4.1 它支撑了哪些内核功能双nop机制不只是function tracer的玩具。现代内核里很多动态跟踪能力都依赖这套入口改写框架function tracer记录函数进入事件配合set_ftrace_filter可以只跟踪指定函数。function_graph tracer记录函数进入和返回能绘制调用树、计算函数耗时这是内核性能分析非常常用的手段。stack tracer在函数入口钩子中检查栈深度用于定位内核栈溢出问题。kprobe/uprobe的某些路径虽然不一定通过双nop槽位进入但ftrace提供的动态补丁基础设施被复用。BPF trampolineeBPF的fentry/fexit程序在ARM64上依赖CONFIG_DYNAMIC_FTRACE_WITH_REGS和ftrace_caller保存的完整寄存器现场双nop机制正好提供了低成本的入口挂钩点。所以学Linux内核动态追踪双nop机制是绕不开的基础设施。搞懂这一块后面看BPF trampoline、看function graph的return_to_handler都会轻松很多。4.2 性能代价关闭时零开销开启后很高先说关闭状态。双nop在关闭时函数入口多了两条NOP指令CPU执行NOP基本没有计算代价但有两个隐性成本代码体积增加I-Cache占用变大。对嵌入式设备来说如果每个函数多8字节内核镜像整体会增大不少这需要权衡。不过现代内核默认只对配置了ftrace的内核启用这个机制发行版内核基本都会开。再说开启状态。一旦开启function tracer内核里每个被过滤函数入口都会多一条bl ftrace_caller调用。这条调用会触发完整的跳板逻辑保存寄存器、调用回调函数、检查递归保护、恢复寄存器、返回。开销可不是一条指令能打住的实际可能增加几十到几百纳秒。如果全局开启function tracer过滤条件很宽整个系统会慢到让你怀疑人生。我做过一个实验在ARM64开发板上不加过滤直接echo function current_tracer系统响应瞬间变得迟钝串口敲个命令都要好几秒才回显。这是正常现象不是死机。所以实操时一定要先用filter把范围限制住比如只跟踪do_sys_openat2这种关键函数而不是全量开启。4.3 真实的坑函数过滤、递归保护、模块与kaslr双nop机制看着简单实际使用中坑不少。我挑几个自己踩过的来说。第一个坑是递归保护。ftrace_caller被调用后它内部又会调用ftrace_ops_func这些函数自身如果也被编译进ftrace可跟踪列表就可能出现“跟踪ftrace自身”的无穷递归。内核的解决办法是把ftrace相关函数标记为notrace同时跳板里还有一层运行时递归检测通过ftrace_test_recursion_trylock判断当前CPU是否已经在ftrace回调路径内是就跳过。修改跳板或新增ftrace回调时如果忘了这层保护很容易内核栈溢出。第二个坑是模块加载。模块里的函数也有NOP槽位吗会如果模块编译时开了-fpatchable-function-entry2。模块加载时ftrace会在load_module流程中注册模块里的dyn_ftrace记录并把入口指令统一初始化成NOP。卸载时再清理。这块逻辑在kernel/module/ftrace.c里。如果你在编写需要被ftrace跟踪的模块记得保证模块代码段是可写可patch的属性否则加载时直接报错。第三个坑是KASLR和反汇编验证。ARM64默认开启内核地址随机化直接拿编译出的vmlinux地址和运行时地址对不上。做验证实验时最好在启动参数里加nokaslr或者从/proc/kallsyms读取实际运行时地址然后用objdump对vmlinux反汇编并做地址偏移换算。我第一次实验时因为忘了这个照着错误的地址看了半天还以为是ftrace没生效。第四个坑是过滤粒度。set_ftrace_filter支持通配符比如echo do_* set_ftrace_filter但通配符展开后会生成一大批函数记录改写耗时很长。如果你只想看一两个函数尽量写完整函数名别用太宽的通配。5. 在自己的内核上验证双nop5.1 内核配置与编译验证双nop机制最好的平台就是伸手可得的ARM64设备或QEMU虚拟机。内核编译时需要打开以下配置CONFIG_FUNCTION_TRACERy CONFIG_FUNCTION_GRAPH_TRACERy CONFIG_DYNAMIC_FTRACEy CONFIG_DYNAMIC_FTRACE_WITH_REGSy CONFIG_HAVE_DYNAMIC_FTRACE_WITH_ARGSy这些配置一般在kernel/trace/Kconfig里能看到依赖关系。ARM64的defconfig默认基本都开着你还可以用menuconfig搜索一下FUNCTION_TRACER确认。编译时最好把符号表保留着后面objdump要用。如果用的是QEMU建议启动参数加nokaslr方便地址对齐。我试过不开nokaslr也一样能跑只是反汇编对照时要换算偏移比较麻烦。5.2 tracefs操作演示启动内核后先挂载tracefs并查看可跟踪函数mount -t tracefs nodev /sys/kernel/tracing cd /sys/kernel/tracing cat available_filter_functions | grep do_sys_openat2如果能看到do_sys_openat2这一行说明这个函数已经被记录入口有NOP槽位。接下来开启function tracer只跟踪它echo 0 tracing_on echo function current_tracer echo do_sys_openat2 set_ftrace_filter echo 1 tracing_on # 触发几次系统调用 ls /tmp /dev/null cat trace | head -50正常情况下trace里会出现类似# tracer: function # | | | |||| | | ls-1234 [001] .... 12345.678901: do_sys_openat2 -do_sys_openat这表示do_sys_openat2被调用且调用者是do_sys_openat。如果你同时想看调用树和耗时就把current_tracer切到function_graphecho 0 tracing_on echo function_graph current_tracer echo do_sys_openat2 set_ftrace_filter echo 1 tracing_on cat trace | head -80这次会看到do_sys_openat2的入口记录和返回记录成对出现中间缩进表示它内部又调了哪些子函数。这就是第二个NOP槽位被改写成bl ftrace_graph_caller之后的直接效果。5.3 反汇编里观察那条nop的变化光看trace输出不够过瘾我建议直接看内存里的指令变化这也是验证双nop最直观的方式。先在未开启ftrace时从/proc/kallsyms拿到函数地址grep do_sys_openat2 /proc/kallsyms # 输出示例ffff800012345678 T do_sys_openat2然后用objdump反汇编vmlinux中该地址附近的内容如果开了nokaslr地址直接对应objdump -d vmlinux --start-address0xffff800012345670 --stop-address0xffff800012345690此时看到的应当是ffff800012345678: d503201f nop ffff80001234567c: d503201f nop接着开启function tracer并过滤该函数再读一次同一个地址第一条NOP会变成ffff800012345678: 940000xx bl ffff8000... ftrace_caller ffff80001234567c: d503201f nop第二条仍然是NOP因为function graph没开。如果你再同时开启function_graph第二条也会变成ffff800012345678: 940000xx bl ffff8000... ftrace_caller ffff80001234567c: 940000yy bl ffff8000... ftrace_graph_caller看到这个变化双nop机制基本就完全理解了。动手做这一步时建议先在测试环境试别直接在重要生产设备上改内核启动参数。最后分享一个实用小技巧如果你只是想确认某个函数到底有没有被ftrace覆盖与其反复反汇编不如直接看available_filter_functions里有没有它。这个文件列出了所有“当前内核里真正预留了插桩槽位”的函数是判断双nop机制的快速入口。做调试时先从这个文件确认目标再决定要不要开tracer能省掉不少折腾时间。我个人习惯是能画调用链先开function_graph加精确filter想追踪参数再上kprobe或BPF没必要一上来就把所有函数全部hook住。这套习惯配合双nop机制背后的清晰分工调试起来会顺手很多。
返回列表