拓冰建站拓冰建站
首页 / 资讯中心 / 正文

linux ftrace 原理剖析:内核函数追踪是怎么实现的

本文是 ftrace 用法文档 的姊妹篇。ftrace 用法篇讲怎么用本文讲为什么能这样用——ftrace 到底是如何在运行中的内核里做到几乎零开销地追踪任意函数的。1. 一个核心问题如何在每个函数入口插一脚要追踪某个函数有没有被调用、被谁调用、耗时多少最朴素的想法是在每个内核函数的入口处插入一小段代码调用一个记录函数。问题在于内核有几万个函数如果每个函数入口都硬编码一次调用记录函数那么即使不追踪时这些额外指令也会拖慢整个内核。ftrace 的全部精妙之处就在于如何让这个插一脚在不用时开销趋近于零用时又能精确打开。答案分两层编译期让编译器在每个函数入口预留一个钩子这一步靠-pg运行期默认把这些钩子替换成空操作nop需要时再动态改回真正的调用这一步靠dynamic ftrace 内核自修改代码。如果要用一句话直白地理解 ftrace那就是把在每个函数入口插一脚这件事做成了自动化、可动态开关、且默认零开销。自动化编译期用-pg让编译器自动在每个函数入口埋好钩子不用你手工去改每个函数可动态开关运行期用自修改代码把钩子在nop关和call ftrace_caller开之间来回切换不用重启内核默认零开销不追踪时钩子是nopCPU 几乎零代价划过去——所以它敢默认编进发行版内核。再补一个关键词“按需”。ftrace 只对你选中的那几个函数打补丁过滤越精准扰动越小。下面几节就是把这三层魔法逐一拆开看。2. 编译期-pg与__fentry__桩2.1 gcc 的-pg本是给 gprof 用的gcc -pg原本是为用户态性能分析工具 gprof 服务的它会在每个函数的入口自动插入一次对mcount()的调用。内核借用了这个机制——CONFIG_FUNCTION_TRACER打开后内核就是用-pg编译的。早期是mcount现代 x86_64 内核用的是更高效的__fentry__-mfentry调用点在建栈帧之前参数还原更简单。编译后一个普通内核函数的开头大致长这样my_kernel_func: call __fentry__ ; ← 编译器自动插入的钩子 push %rbp mov %rsp, %rbp ... ; 函数真正的逻辑也就是说每个内核函数第一条指令都是一次call __fentry__。这是 ftrace 能追踪任意函数的物理基础。2.2 如果每次都真的 call岂不很慢是的。如果放任每个函数都无条件call __fentry__即使没人在追踪也要白白付出一次 call/ret 的代价遍布全内核累积起来很可观。这正是下一节dynamic ftrace要解决的问题。3. 运行期核心魔法dynamic ftrace 与自修改代码3.1 启动时把所有钩子改成nopCONFIG_DYNAMIC_FTRACE打开后内核在编译时会用一个叫recordmcount的脚本或链接期的 objtool扫描每个.o把所有call __fentry__的地址收集到一张表里保存在 section__mcount_loc。内核启动早期ftrace_initftrace 遍历这张表把每一处call __fentry__就地改写成一条nop5 字节 nop。于是默认状态下每个函数入口是一条nop——CPU 几乎零代价地划过去没有追踪时的额外开销可以忽略。这张地址表也就成了available_filter_functions的来源你能追踪哪些函数取决于哪些函数入口被记录进了这张表。3.2 启用追踪时把nop改回call当你echo function current_tracer并设置了set_ftrace_filter后ftrace 只对你选中的那几个函数把入口的nop再原地改写回call ftrace_caller未追踪: my_func: nop ; 零开销 追踪时: my_func: call ftrace_caller ; 只有被选中的函数才付出代价这就是 dynamic ftrace 的精髓——按需、逐函数地热补丁。你过滤得越精准被改写的函数越少对系统的扰动越小。这也解释了为什么上一篇文档反复强调一定要设set_ftrace_filter不设过滤意味着几万个函数入口全部被改成call开销和噪声都爆炸。3.3 运行中改内核代码为什么不崩在一个多核、正在运行的内核里就地修改正在被执行的指令是极其危险的其他 CPU 可能正好在执行那条指令或者 CPU 的指令流水线/i-cache 里有旧副本。ftrace 用了一套精心设计的机制来保证安全stop_machine()经典方案短暂让所有其他 CPU 停在一个安全点由一个 CPU 完成指令改写再让大家继续。改写窗口极短。text_poke/ int3 断点补丁现代 x86 方案先在目标指令首字节打上一个int3断点让任何恰好执行到这里的 CPU 陷入一个临时处理程序然后安全地改写指令剩余字节最后再把首字节换成最终指令。全程不需要停机。改完后配合 i-cache 同步确保所有 CPU 看到的都是新指令。这套内核给自己打热补丁的能力kernel self-modifying code是 ftrace、jump label、kprobe 等一系列特性的共同底座。4.ftrace_caller钩子被打开后发生了什么当一个被追踪的函数执行到入口的call ftrace_caller时控制流进入一段用汇编写的蹦床trampoline它做的事情是保存现场把参数寄存器等 CPU 状态压栈因为追踪不能破坏被追踪函数的参数/返回值调用当前生效的追踪回调ftrace_ops-func()——这就是各种 tracerfunction tracer、function_graph、栈追踪、profiler…真正挂进来的地方恢复现场并返回被追踪函数继续执行它原本的逻辑。被追踪函数入口call ftrace_callerftrace_caller 蹦床保存寄存器现场ftrace_ops-func()当前 tracer 的回调function tracer记录 函数调用者function_graph记录 进入/退出时间戳profiler累加命中次数/耗时恢复寄存器现场返回被追踪函数继续原本逻辑不同 tracer 的差异本质上就是挂在第 2 步的回调函数不一样function tracer回调里记录一行函数 调用者地址function profiler回调里对该函数的命中计数1、累加耗时function_graph见下一节它需要同时挂入口和出口两个钩子。4.1set_ftrace_filter是怎么起作用的ftrace_ops上带着一个函数地址的哈希表filter hash。你写入set_ftrace_filter的符号最终变成只对这些地址启用call、其余保持nop。所以过滤不是回调里 if 判断跳过而是从源头上就只给这些函数打了补丁——未被选中的函数入口仍然是nop根本不会进蹦床。这也是它开销极低的原因。5. function_graph 的额外一手劫持返回地址普通 function tracer 只在函数入口插桩只能知道进来了。而 function_graph 要同时报告进入和退出、并算出每层耗时它多做了一件很巧妙的事在入口处把函数的返回地址偷偷替换掉。原理入口钩子触发时function_graph 记下进入时间戳并把栈上的真实返回地址保存到一个每线程的影子栈shadow stack / ret_stack然后把栈上的返回地址替换成一个统一的跳板return_to_handler被追踪函数正常执行完ret时不会回到原调用者而是先跳到return_to_handlerreturn_to_handler记下退出时间戳相减即得该函数耗时从影子栈取回真正的返回地址再跳回去。于是你在trace里看到的那种带{ }缩进、每行标注DURATION的漂亮调用树就是入口时间戳 出口时间戳 影子栈里的调用深度三者拼出来的。这也解释了 function_graph 为什么比 function tracer 略重——它对每个被追踪函数都要操作影子栈、劫持并还原返回地址。6. 数据从哪来、到哪去ring buffer 与 tracefs6.1 每 CPU 无锁环形缓冲区追踪回调产生的每一条记录都写入 ftrace 的ring buffer。关键设计每个 CPU 一个独立缓冲区写入时基本无锁、互不争抢——这对高频路径至关重要否则追踪本身的锁竞争会严重扭曲被测系统的行为缓冲区是环形的写满后覆盖最旧的记录。所以上一篇提到的LOST EVENTS就是产生速度 你读取速度旧记录被覆盖的信号解决办法是增大buffer_size_kb或用trace_pipe边读边清。6.2 tracefs一切皆文件你在上一篇里echo/cat的那些/sys/kernel/tracing/*是一个专门的虚拟文件系统tracefs。它把 ftrace 的控制与数据都暴露成文件文件角色对应本文原理available_filter_functions可追踪函数清单§3.1 的__mcount_loc地址表current_tracer选哪个 tracer§4 挂哪个回调set_ftrace_filter只追踪哪些函数§4.1 filter hash → 只补丁这些tracing_on总开关控制回调是否真正写 ring buffertrace/trace_pipe读数据快照/流§6.1 ring buffer 的读出口set_ftrace_pid限定进程回调里按 PID 过滤trace是把环形缓冲区做一次快照格式化输出trace_pipe则是消费式读取读走即清适合长时间流式抓取。理解了它们背后都是同一个 ring buffer就明白为什么读trace不清空、读trace_pipe会清空。7. tracepoint 与 kprobe另外两种插桩origin上一篇还用到了 tracepoint 和 kprobe。它们和 function tracer 共享后端同一套 ring buffer tracefs但插桩来源不同7.1 tracepoint —— 源码里预埋的静态探针tracepoint 是内核开发者在源码里手工埋下的探针点例如trace_amdgpu_vm_update_ptes(...)。它的实现用了jump label / static key未启用时探针点是一条nop和 dynamic ftrace 异曲同工也是自修改代码一旦echo 1 events/.../enablenop被热补丁成一条跳转跳去执行探针回调把开发者显式声明的结构化参数地址、页数、flags…写进 ring buffer。所以 tracepoint 的两大特点都能从原理解释① 带结构化参数因为是源码里显式传的② 不受函数内联影响探针是独立埋点不依赖某个函数是否被内联保留符号。7.2 kprobe —— 运行期动态断点kprobe 更暴力它能在几乎任意内核地址动态下探针靠的是断点异常把目标地址第一条指令替换成断点指令x86 上是int3CPU 执行到这里触发断点异常陷入 kprobe 的处理程序在这里可以抓寄存器、参数、甚至改变行为处理程序里单步执行被替换掉的那条原始指令保存在别处再返回原流程。代价是每次命中都要走一次异常比 dynamic ftrace 的call重好处是不需要函数在__mcount_loc表里、也不需要源码预埋真正做到哪里都能插。这就是上一篇没有现成 tracepoint 就上 kprobe的底气。8. 把三种插桩机制放在一起看插桩来源函数入口钩子__fentry__ nop→calldynamic ftrace源码静态探针tracepointstatic key nop→jmp动态断点kprobeint3 异常追踪回调ftrace_ops-func / probe handler每 CPU ring buffertracefstrace / trace_pipe用户: cat / trace-cmd / kernelshark三条插桩来源殊途同归最后都汇入同一套 ring buffer tracefs 出口。理解了这张图就能解释上一篇几乎所有操作背后的为什么。9. 为什么 ftrace低侵入是有代价上限的ftrace 常被称为低开销但要理解它低在哪、以及边界不追踪时函数入口是nop接近零开销§3.1——这是它敢默认编进发行版内核的原因追踪时开销 被改写函数的数量 × 每次命中的回调成本。过滤越精准扰动越小。高频函数每秒百万次即使单次回调只有几十纳秒乘起来也可能显著扰动时序甚至压垮 ring bufferLOST EVENTSfunction_graph因为要劫持返回地址 影子栈比纯 function tracer 更重kprobe因为走异常单次命中比 dynamic ftrace 的call更贵。所以低侵入不是无条件的而是建立在精准过滤之上。这正是上一篇那套set_ftrace_filterset_ftrace_pidtracing_on时间窗组合拳的原理级动机它们本质上都是在减少被打补丁的函数数量 / 缩小回调被触发的范围。10. 小结一条echo function current_tracer背后回顾一下当你敲下最普通的那几条命令时内核内部发生了什么内核用-pg/-mfentry编译每个函数入口都有call __fentry__钩子§2启动时 dynamic ftrace 把这些钩子全部改成nop默认零开销§3.1你echo function current_tracer选定 tracer、set_ftrace_filter选定函数ftrace 用自修改代码把这几个函数的nop热补丁回call ftrace_caller§3.2、§3.3命中时进入蹦床调用当前 tracer 的回调把记录写入每 CPU ring buffer§4、§6.1你cat trace/trace_pipe通过tracefs把 ring buffer 读出来§6.2。一句话总结 ftrace 的设计哲学用编译期预留的钩子 运行期按需热补丁把追踪任意函数的常态开销压到近乎为零只在你真正需要的那几个点上、那段时间里才付出代价。理解了这套原理再回头看上一篇的每一条命令和每一个避坑建议就不再是死记硬背的操作步骤而是能从机制推导出来的自然结论。本文讲清楚了原理下文给一个应用示例用 ftrace 判定内核代码路径以 amdgpu PTE 更新走 SDMA 还是 CPU 为例。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门