ARTICLE DETAIL

资讯详情

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

ftrace双nop机制深度解析:从动态插桩原理到内核实战验证

ftrace双nop机制深度解析:从动态插桩原理到内核实战验证 如果你在Linux内核里排查过问题大概率听过ftrace这个名字如果你真正读过ftrace的源码那一定绕不开“双nop机制”这几个字。我第一次看到这个说法时也是一头雾水——nop就是空指令一个就够了为什么要搞两个后来把x86_64上从编译到运行时的整条链啃了一遍才明白这背后其实是ftrace对“零开销”和“灵活切换”这两个目标的极致追求。这篇文章就把ftrace双nop机制的完整设计思路、底层原理、实现路径讲透并且给出一套可以直接在本地内核上复现的验证方法。适合正在学习Linux内核追踪机制的人、做嵌入式性能分析的同学以及所有对“动态插桩到底怎么做到”充满好奇的读者。你不需要一开始就懂汇编我会把指令编码、调用约定这些前置知识一并补上。1. ftrace与双nop机制先搞清楚这个设计要解决什么问题1.1 ftrace到底是什么一句话版本ftrace是Linux内核自带的动态追踪框架它可以让你在不重新编译内核、不重启机器的情况下动态地收集内核函数的调用情况、调用栈、耗时、甚至某个函数被调用时的参数和返回值。它不像gdb那样把整个系统停下来而是以极低的代价在函数入口处做“手脚”基本不影响系统正常运行。很多人在碰到性能问题时第一反应是上perf但perf的采样是统计性质的而ftrace可以给出精确的函数调用序列。比如你想知道一次文件打开操作到底经过了哪些内核函数每个函数花了多少时间用ftrace的function_graph追踪器基本是最快路径。它和kprobe、eBPF并称内核动态观测的三驾马车但ftrace胜在内核自带、配置简单、开销极小。1.2 双nop机制的精髓零开销与可切换双nop机制是ftrace在x86_64平台上实现动态插桩的核心手段。所谓双nop简单说就是内核对每个支持追踪的内核函数入口预留了两段5字节的nop指令区域。默认情况下这个函数不追踪时入口处就是两条实打实的空指令CPU执行过去毫无负担当你启动追踪时内核会把其中一段nop改写成跳转指令让CPU执行到函数入口时先跳到ftrace的回调例程。这个设计最打动我的地方在于“零开销”这三个字。Linux内核里被编译进追踪框架的函数成千上万如果默认状态下每个函数入口都要多执行几次条件判断那对整体性能就是一笔巨大的隐形开销。而nop指令本身在流水线上几乎不占用额外时间等于在不追踪时这些函数入口和原生状态基本没区别。另一个关键词是“动态”。你不需要改一行代码、不需要重新编译、不需要重启运行中随时可以通过tracefs接口把nop改写成跳转也可以把跳转再恢复成nop。这种能力让生产环境的故障排查变得异常高效——发现问题时临时开追踪收集完数据再关上整个过程中业务进程几乎无感。2. 为什么需要双nopmcount/fentry和动态插桩的底层逻辑2.1 从call mcount到callfentry编译器插入的探针要理解双nop得先知道函数入口那两条nop是怎么来的。答案在编译阶段。早期i386时代ftrace依赖编译器选项-pg。开启这个选项后编译器会在每个函数的入口处自动插入一条call mcount指令。mcount是一个桩函数它在编译产物里只是占个位置真正的逻辑在运行时被ftrace动态替换。这个方案能工作但有个问题call mcount是5字节的调用指令如果函数本身很小这个调用点就会显得非常笨重更麻烦的是mcount调用发生在栈帧建立之前还是之后不同的编译器版本行为不一致给后续处理带来很多麻烦。到了x86_64平台GCC引入了-mfentry选项配合-pg使用时编译器会在函数的最开始位置也就是在建立任何栈帧之前插入一条call __fentry__。这里的关键差异是函数入口从一开始就固定了ftrace的动态修改区域也就固定了。-mfentry方案成了x86_64上ftrace的主流选择也让“在函数入口做文章”这件事变得干净利落。2.2 x86_64入口处的10字节两个5字节nop的来历现在进入核心问题为什么入口处是10字节而不是5字节x86_64架构下一条近跳转指令jmp rel32的编码是5字节一条call rel32也是5字节。编译器默认插入的call __fentry__就是5字节。如果ftrace只维护这5字节的区域那就只能在“nop”和“jmp到某个回调”之间二选一。但ftrace面对的追踪需求不止一种。普通function tracer需要在函数进入时调用回调函数function_graph tracer除了进入时需要回调还要能够拦截函数返回。如果入口处只有一个5字节槽位function tracer和function_graph tracer的回调入口就得共用同一个跳转目标然后在回调内部再去判断当前注册的是哪个tracer再做分发。这样当然也能工作但灵活性差、耦合度高每次切换tracer都要处理更复杂的组合状态。内核最终选择的方案是在函数入口处预留10字节也就是两个连续的5字节nop。看机器码就是这样的两段0f 1f 44 00 00 nop dword ptr [rax rax*1] 0f 1f 44 00 00 nop dword ptr [rax rax*1]第一个nop可以改写成指向ftrace_caller的跳转指令第二个nop可以独立改写成指向ftrace_graph_caller的跳转指令。两者互不干扰各自负责一类追踪逻辑。这是双nop机制最直观的来历。2.3 function tracer和function_graph tracer如何共享入口有了两个独立槽位内核在设计追踪器时就从容多了。开启function tracer时入口的第一个nop被替换成jmp ftrace_caller。ftrace_caller是内核汇编层面的一段入口例程它会保存现场、调用当前注册的回调函数比如function_trace_call然后恢复现场再jmp回原函数真正执行的指令。整个过程对原函数来说是透明的。开启function_graph tracer时入口的两个nop可能都会被利用。它的实现思路非常巧妙不在每个函数的ret指令处插桩而是在函数入口处修改栈上的返回地址——把真实的返回地址替换成return_to_handler的地址同时把原返回地址保存到一张哈希表里。这样当函数执行完ret时CPU跳进return_to_handler它再去查表找回真实的返回地址顺便记录“这个函数退出了”的事件。整条链路只需要在函数入口做一次跳转就能同时捕捉进入和退出两个事件。如果同时开启多个tracer呢两个槽位的价值就体现出来了。第一个nop跳去处理普通function回调第二个nop跳去处理graph相关的入口逻辑互不覆盖。这也解释了为什么单个5字节槽位不够用——它没法同时容纳两条跳转指令而双nop确保了function tracer和function_graph tracer可以独立挂载、独立切换。3. 双nop的实现拆解从nop到跳转指令的动态改写路径3.1 动态patch的软件路径函数地址怎么被管理理解了为什么需要双nop接下来看内核是怎么把它实现的。这一块需要打开源码来对照。在内核初始化阶段ftrace会扫描整个内核的代码段找到所有被-pg -mfentry插桩过的函数入口。这个过程在kernel/trace/ftrace.c里完成它遍历__mcount_loc段这个段是编译器生成的里面记录了所有fentry调用点的地址。ftrace为每个调用点创建一个struct dyn_ftrace结构记录函数地址、调用点地址、当前指令状态等信息。初始化完成后ftrace会调用架构相关的ftrace_update_code把每个函数入口的call __fentry__改写成nop。注意这一步非常关键系统刚启动时代码段里还是编译器生成的call指令ftrace需要把它们全部变成nop状态。这就是“默认零开销”的来源——从此刻起所有被追踪框架覆盖的函数入口都是纯nop指令。当用户通过tracefs接口注册追踪器时流程是这样的echo function /sys/kernel/tracing/current_tracer用户态写入会触发ftrace_set_global_filter、register_ftrace_function等一系列操作最终走到ftrace_run_update_code它会遍历需要修改的dyn_ftrace列表调用ftrace_modify_all_code再由架构相关代码完成真正的指令改写。如果你追踪的是特定函数比如只追踪do_sys_openat2那需要被patch的点就只有一个其他函数入口保持nop不动。3.2 指令编码细节nop、jmp和占位符的机器码x86_64下双nop机制涉及几种固定的指令编码。我把常用对照整理成了表格方便你在用工具看机器码时直接对照指令语义机器码长度说明5字节nop0f 1f 44 00 005现代CPU上开销接近零近跳转e9 xx xx xx xx5偏移是相对于下一条指令的rel32近调用e8 xx xx xx xx5编译器fentry默认格式5字节int3cc cc cc cc cc5某些异常路径的填充占位双nop布局里第一段nop主要被改写成e9开头的跳转指令跳转到ftrace_caller第二段nop被改写成跳转到ftrace_graph_caller。如果你开启了function tracer后去读目标函数入口的机器码大概率会看到类似这样的布局e9 3a 8f 3d c2 jmp ftrace_caller 0f 1f 44 00 00 nop dword ptr [rax rax*1]当然具体偏移值跟你内核版本、是否开启KASLR有关但结构就是“一段跳转一段nop”。这里有个容易踩坑的点不是所有函数入口都有双nop区域。只有用-pg -mfentry编译的C函数才具备这个条件。纯汇编写的函数、被notrace标记的函数、内联函数、以及部分noinstr区域的函数入口处不会有fentry调用点自然也就不会有双nop。做动态追踪时如果发现某个函数追踪不到先别急着怀疑ftrace坏了要先确认它是否真的在available_filter_functions列表里。3.3 text_poke与内存权限为什么安全改写不容易动态改写指令看着简单实际上有个关键工程难点内核代码段默认是只读的。现代内核开启了CONFIG_STRICT_KERNEL_RWX之后代码段、只读数据段都有严格的页权限保护直接往代码段里写数据会直接触发page fault。ftrace并没有简单粗暴地关掉写保护而是使用了一个叫text_poke的机制。它的核心思路是临时把目标页面的映射改为可写写入新指令马上刷新TLB和指令缓存再恢复只读权限。整个过程非常短并且通过IPI核间中断确保其他CPU核心同步更新。x86_64上还会调用sync_core来刷新前端指令缓存防止CPU还缓存着旧指令。这就是为什么动态patch能做到多核安全。理解了这一层你就明白为什么双nop机制如此依赖架构相关代码了。arch/x86/kernel/ftrace.c里的ftrace_modify_code_direct函数本质上就是在做“读旧指令、比对、写新指令、刷新缓存”这套动作。也包括arch/x86/kernel/ftrace_64.S里的ftrace_caller和ftrace_graph_caller。如果你在移植ftrace到新架构这部分通常是工作量最大的地方。4. 实操验证用tracefs和内核模块亲手看双nop被改写4.1 实验环境准备与内核配置确认理论讲了这么多不亲手验证一遍等于白看。我会用一台x86_64虚拟机内核版本5.15来做演示。你自己的环境只要是x86_64架构、内核版本不要太老4.x以上都没问题基本都能复现。先确认内核的ftrace配置是否开启查看这个文件cat /boot/config-$(uname -r) | grep -E CONFIG_(FUNCTION_TRACER|DYNAMIC_FTRACE|HAVE_FENTRY)输出里这三个配置都应该为y尤其是CONFIG_DYNAMIC_FTRACE它是双nop动态改写的总开关。之后挂载tracefssudo mkdir -p /sys/kernel/tracing sudo mount -t tracefs nodev /sys/kernel/tracing # 如果内核版本较老tracefs可能挂在debugfs下面 sudo mount -t debugfs nodev /sys/kernel/debug ls /sys/kernel/tracing如果挂载成功你会看到current_tracer、available_filter_functions、set_ftrace_filter这些文件。顺手确认一下目标函数在追踪列表中grep do_sys_openat2 /sys/kernel/tracing/available_filter_functions有输出就说明这个函数的入口具备fentry调用点可以被动态patch。4.2 用tracefs追踪一个真实函数下面做一次完整的function tracer实验。先设置过滤函数只追踪do_sys_openat2避免日志被海量数据淹没echo do_sys_openat2 /sys/kernel/tracing/set_ftrace_filter echo function /sys/kernel/tracing/current_tracer然后在另一个终端随便执行几次文件操作ls /etc/hostname cat /etc/hostname再切回来查看追踪结果cat /sys/kernel/tracing/trace正常情况下你会看到类似下面的输出# tracer: function # entries-in-buffer/entries-written: 12/12 # | | | |||| | | ls-1234 [001] .... 123.456789: do_sys_openat2 - do_sys_open看到do_sys_openat2被调用、调用者是do_sys_open说明function tracer已经生效。看完数据后记得把追踪器关闭让入口恢复成nopecho nop /sys/kernel/tracing/current_tracer这里有个小知识点tracefs里的echo nop和双nop的nop不是同一个东西。前者是指“当前不启用任何追踪器”后者是指具体的空指令机器码。第一次看到时我也混淆过特此提醒。4.3 写个内核模块注册自定义ftrace回调tracefs本身已经能验证双nop的“动态切换”能力但如果你想更深入地理解“回调函数如何被调用”我建议写一个最小内核模块用register_ftrace_function注册自己的回调。这个操作能让你从“使用ftrace”进阶到“理解ftrace”。模块代码我放在这里可以直接抄// ftrace_demo.c #include linux/ftrace.h #include linux/kernel.h #include linux/module.h #include linux/sched.h static void demo_ftrace_func(unsigned long ip, unsigned long parent_ip, struct ftrace_ops *op, struct ftrace_regs *fregs) { if (strncmp(current-comm, ls, 2) 0) printk(KERN_INFO ftrace-demo: ip%ps parent%ps pid%d comm%s\n, (void *)ip, (void *)parent_ip, current-pid, current-comm); } static struct ftrace_ops demo_ops { .func demo_ftrace_func, .flags TRACE_OPS_FL_RECURSION_SAFE, }; static int __init demo_init(void) { int ret; ret register_ftrace_function(demo_ops); if (ret) { pr_err(register_ftrace_function failed: %d\n, ret); return ret; } ret ftrace_set_filter(demo_ops, do_sys_openat2, 0, 0); if (ret) { pr_err(ftrace_set_filter failed: %d\n, ret); unregister_ftrace_function(demo_ops); return ret; } pr_info(ftrace-demo registered\n); return 0; } static void __exit demo_exit(void) { unregister_ftrace_function(demo_ops); pr_info(ftrace-demo unregistered\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL);配套的Makefile也一并给出# Makefile obj-m ftrace_demo.o KDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) clean编译并加载make sudo insmod ftrace_demo.ko然后执行几次ls /再用dmesg | tail看输出。在模块的回调里我做了小过滤只打印进程名为ls的调用这样日志会很干净。你能看到do_sys_openat2被调用时你的回调函数在内核上下文里被执行了。这里要注意模块默认编译时不会开启-pg -mfentry也就是回调函数自身不会被ftrace追踪因此不需要担心回调函数里调用ftrace导致递归。但如果你用了一些发行版提供的“追踪一切”的配置基本原则是回调函数内不要做复杂操作否则会影响被追踪函数的真实行为。4.4 读取入口指令肉眼观察双nop变为跳转这一节是全文最有“实验感”的部分直接在内存里读出目标函数入口的机器码看它从双nop变成跳转指令的全过程。先在模块里加一个读内存的函数我用copy_from_kernel_nofault老内核叫probe_kernel_read来安全读取static void dump_entry_bytes(unsigned long addr) { unsigned char buf[10]; int ret; ret copy_from_kernel_nofault(buf, (void *)addr, sizeof(buf)); if (ret 0) { pr_err(read addr %px failed\n, (void *)addr); return; } pr_info(entry bytes at %px:, (void *)addr); for (int i 0; i sizeof(buf); i) pr_cont( %02x, buf[i]); pr_cont(\n); }然后在demo_init里加上对do_sys_openat2地址的读取unsigned long sym_addr; if (kallsyms_lookup_name(do_sys_openat2, sym_addr) 0) dump_entry_bytes(sym_addr);不过要提醒一下新内核5.7之后把kallsyms_lookup_name的导出隐藏了模块里直接调用会报错。一个绕过的办法是用kprobes拿到符号地址但这会让样例变复杂。如果你在5.15及以下内核可以用老办法如果你内核太新建议直接在tracefs里看available_filter_functions或者用下面的方式# 在/proc/kallsyms里拿到真实地址 sudo grep do_sys_openat2 /proc/kallsyms然后回到模块里硬编码这个地址去dump。无论哪种方式最终你要验证的效果是不开启追踪器时echo nop current_tracer入口的10字节是0f 1f 44 00 00 0f 1f 44 00 00。开启function tracer后入口第一段变成e9 xx xx xx xx第二段还是nop。开启function_graph后可能两段都被改写为跳转指令。这个实验会让你非常直观地理解“动态”两个字同一段内存在不同状态下呈现出完全不同的指令编码而且是运行中实时变化的。我第一次跑通这个实验时对ftrace整个架构的理解level直接上了个台阶。5. 常见问题与排查技巧我在实际调试中踩过的坑5.1 function tracer没有输出怎么排查这是新手最容易碰到的问题。最典型的原因有三个函数名不对、过滤没生效、以及追踪器虽然设置了但被其他机制抢占。遇到没有输出时第一步先用grep确认目标函数确实在available_filter_functions里。这个文件只列出“可被动态追踪”的函数不在列表里的函数无论怎么设置都不会有输出。第二步确认你写入set_ftrace_filter的函数名和列表里的名字完全一致注意区分do_sys_openat2和do_sys_open这种同名不同后缀的情况。第三步检查current_tracer是否真的变成了你想用的追踪器。有时候你echo进去了但因为格式错误或权限问题实际没生效。用cat current_tracer确认一下。如果看到nop说明写入失败了仔细检查有没有多余空格或换行。另外还有一类隐藏得很深的情况你追踪的函数可能确实被调用了但调用发生在中断上下文或非常短的时间里日志被后续大量事件冲掉了。建议先用echo 1 options/function-fork或适当关闭其他CPU的事件减少干扰。5.2 动态patch失败或内核直接崩溃如果你在内核日志里看到类似ftrace: failed to modify code的报错通常指向三种可能。第一种是代码段的写权限问题。理论上text_poke机制会处理但如果你用的是非标准内核配置比如手动关掉了CONFIG_STRICT_KERNEL_RWX但又没有正确地处理代码缓存刷新就会出现奇怪的崩溃。我排查过一台ARM板卡上的类似问题最后定位到是内核patch了代码之后没有正确执行flush_icache_range导致CPU执行老指令。第二种是函数入口已经被别的机制改写了。eBPF的kprobe、kprobe本身、以及其他的ftrace ops都可能已经修改了入口指令。ftrace在改写前会做“旧指令比对”如果发现当前指令和它预期的不一致就会拒绝修改并报错。这种情况在同时用多个动态追踪工具时特别常见。第三种是KASLR相关的地址错乱。开启KASLR后函数符号地址在每次启动时都不一样ftrace内部通过符号名解析地址一般没问题但如果你的工具链里用了硬编码地址或者直接拿/proc/kallsyms里的地址去patch很可能因为偏移计算错误写坏代码段。遇到这种问题先把KASLR关掉内核启动参数加nokaslr再试能排除掉很多干扰。5.3 function_graph看不到返回记录function_graph追踪器最让人困惑的现象是能看到某个函数的进入记录但看不到对应的返回记录整个调用图看起来断了一截。一个常见原因是尾调用优化。比如函数A的最后一条语句是return funcB()编译器可能把它优化成jmp funcB这样从汇编层面看函数A没有自己的ret指令返回事件自然不会被捕获。这不是ftrace的问题而是编译优化带来的正常现象。想减轻这个影响可以在编译内核时减少优化级别或者接受这个限制。另一个原因是函数在叶节点且没有实际返回事件。function_graph的逻辑是如果函数入口处把返回地址替换成了return_to_handler那么函数ret时会触发返回事件但如果你是直接在中断上下文或者NMI上下文追踪某些函数可能因为栈状态特殊导致返回地址替换失败。遇到这种情况可以试试过滤掉中断相关的函数只追踪进程上下文里的路径。还有一类情况是多个tracer混用。function tracer和function_graph tracer同时开启时两个回调对栈的处理逻辑完全不同容易相互干扰。我见过有人在生产环境同时挂了各种钩子最后function_graph输出全乱。建议尽量保持单一tracer多用set_ftrace_notrace排除不关心的函数少混用。5.4 与其他动态机制kprobe/eBPF的冲突kprobe和ftrace虽然机制不同但它们可能会在同一个函数入口附近做手脚。kprobe是在指令上设置断点int3实现静态探测而ftrace是动态改写指令。如果kprobe先在一个函数入口处下了断点ftrace再去改写这个入口的nop区域就会发生指令比对不一致导致patch失败。反过来也一样如果ftrace已经把入口改成了跳转指令kprobe再去下断点也有可能踩到跳转指令的中间位置造成不可预测的结果。现代内核里对这两种机制的兼容性做了很多工作但依然建议不要同时追踪同一个函数尤其在调试阶段。如果你要深挖某个函数最好在同一时间里只开一种追踪手段。epbf程序如果使用了fentry/fexit类型的BPF程序它实际上也在复用ftrace的插桩机制。这时候你的bpftrace脚本和内核ftrace接口之间会共享函数入口槽位使用上要特别留意。一个常见的排错技巧是先echo nop current_tracer、卸载所有kprobe、再用bpftrace -l列出已挂载的程序确认没有互相抢占。5.5 嵌入式平台上的注意事项双nop机制在x86_64上实现得最规整但在ARM64、RISC-V这些架构上细节有所不同。ARM64的指令定长4字节没法直接放一条5字节的跳转所以它的ftrace入口处理方式是通过-fpatchable-function-entry编译器选项预留8字节或16字节区域原理类似但指令编码和patch逻辑完全不同。如果你在嵌入式平台做这个实验有几个经验值得记住先查架构支持的patch大小。ARM64一般是8字节部分配置用16字节RISC-V也有自己的约定。嵌入式平台的开机时间短日志缓冲区更小开function tracer时一定要做好过滤否则很容易刷屏导致系统卡顿。注意flash驱动的函数往往被标记为notrace因为ftrace本身可能依赖这些驱动追这些函数容易死锁。如果目标板的RAM很小开function_graph时要谨慎它保存返回地址的哈希表是要占用内存的。部分RT内核PREEMPT_RT对ftrace的延迟和原子性要求更严patch失败率会更高必要时调整/sys/kernel/tracing/buffer_size_kb或关闭中断追踪。6. 最后聊几句个人体会双nop机制看起来只是两个空指令占位符但把它的前因后果捋清楚之后你会对“Linux内核如何平衡功能与开销”有一个非常具体的认知。内核在函数入口预留10字节用最少的硬件代价换来了运行时的无限可能不追踪时零开销、追踪时一条跳转搞定、需要更复杂逻辑时再来一条跳转。这种“以空间换灵活、以nop换时间”的思路在很多系统软件设计里都能看到影子。我在实际项目中用这套机制最多的地方是排查嵌入式Linux驱动初始化超时的问题。以前遇到一个外设驱动启动慢一开始各种猜后来直接开function_graph追踪那几条初始化函数几百条函数调用关系一目了然不到半小时就定位到一个GPIO操作函数意外阻塞。那种效率提升是非常直观的。希望你读完这篇文章后也能亲自跑一遍上面的实验把ftrace双nop机制从概念变成真正的工具箱里的一件趁手工具。
返回列表