ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/8/8 7:38:23 拓冰建站 浏览量
linux ftrace 原理剖析:内核函数追踪是怎么实现的

本文是 ftrace 用法文档 的姊妹篇。ftrace 用法篇讲"怎么用",本文讲"为什么能这样用"——ftrace 到底是如何在运行中的内核里,做到几乎零开销地追踪任意函数的。


1. 一个核心问题:如何在每个函数入口"插一脚"

要追踪"某个函数有没有被调用、被谁调用、耗时多少",最朴素的想法是:在每个内核函数的入口处插入一小段代码,调用一个记录函数。

问题在于:内核有几万个函数,如果每个函数入口都硬编码一次"调用记录函数",那么即使不追踪时,这些额外指令也会拖慢整个内核。ftrace 的全部精妙之处,就在于如何让这个"插一脚"在不用时开销趋近于零,用时又能精确打开

答案分两层:

  1. 编译期:让编译器在每个函数入口预留一个"钩子"(这一步靠-pg);
  2. 运行期:默认把这些钩子替换成空操作(nop),需要时再动态改回真正的调用(这一步靠dynamic ftrace+ 内核自修改代码)。

如果要用一句话直白地理解 ftrace,那就是:把"在每个函数入口插一脚"这件事,做成了自动化、可动态开关、且默认零开销

  • 自动化:编译期用-pg让编译器自动在每个函数入口埋好钩子,不用你手工去改每个函数;
  • 可动态开关:运行期用自修改代码,把钩子在nop(关)和call ftrace_caller(开)之间来回切换,不用重启内核;
  • 默认零开销:不追踪时钩子是nop,CPU 几乎零代价划过去——所以它敢默认编进发行版内核。

再补一个关键词:“按需”。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 启动时:把所有钩子改成nop

CONFIG_DYNAMIC_FTRACE打开后,内核在编译时会用一个叫recordmcount的脚本(或链接期的 objtool)扫描每个.o,把所有call __fentry__地址收集到一张表里(保存在 section__mcount_loc)。

内核启动早期(ftrace_init),ftrace 遍历这张表,把每一处call __fentry__就地改写成一条nop(5 字节 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),它做的事情是:

  1. 保存现场:把参数寄存器等 CPU 状态压栈(因为追踪不能破坏被追踪函数的参数/返回值);
  2. 调用当前生效的追踪回调ftrace_ops->func()——这就是各种 tracer(function tracer、function_graph、栈追踪、profiler…)真正挂进来的地方;
  3. 恢复现场并返回被追踪函数,继续执行它原本的逻辑。

被追踪函数入口
call ftrace_caller

ftrace_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 要同时报告进入和退出、并算出每层耗时,它多做了一件很巧妙的事:在入口处把函数的返回地址偷偷替换掉

原理:

  1. 入口钩子触发时,function_graph 记下"进入时间戳",并把栈上的真实返回地址保存到一个每线程的影子栈(shadow stack / ret_stack),然后把栈上的返回地址替换成一个统一的跳板return_to_handler
  2. 被追踪函数正常执行完,ret时不会回到原调用者,而是先跳到return_to_handler
  3. return_to_handler记下"退出时间戳"(相减即得该函数耗时),从影子栈取回真正的返回地址,再跳回去。

于是你在trace里看到的那种带{ }缩进、每行标注DURATION的漂亮调用树,就是"入口时间戳 + 出口时间戳 + 影子栈里的调用深度"三者拼出来的。这也解释了 function_graph 为什么比 function tracer 略重——它对每个被追踪函数都要操作影子栈、劫持并还原返回地址。


6. 数据从哪来、到哪去:ring buffer 与 tracefs

6.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 buffer
trace/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 更暴力:它能在几乎任意内核地址动态下探针,靠的是断点异常

  1. 把目标地址第一条指令替换成断点指令(x86 上是int3);
  2. CPU 执行到这里触发断点异常,陷入 kprobe 的处理程序,在这里可以抓寄存器、参数、甚至改变行为;
  3. 处理程序里单步执行被替换掉的那条原始指令(保存在别处),再返回原流程。

代价是每次命中都要走一次异常,比 dynamic ftrace 的call重;好处是不需要函数在__mcount_loc表里、也不需要源码预埋,真正做到"哪里都能插"。这就是上一篇"没有现成 tracepoint 就上 kprobe"的底气。


8. 把三种插桩机制放在一起看

插桩来源

函数入口钩子
__fentry__ nop→call
dynamic ftrace

源码静态探针
tracepoint
static key nop→jmp

动态断点
kprobe
int3 异常

追踪回调
ftrace_ops->func / probe handler

每 CPU ring buffer

tracefs
trace / trace_pipe

用户: cat / trace-cmd / kernelshark

三条插桩来源殊途同归,最后都汇入同一套 ring buffer + tracefs 出口。理解了这张图,就能解释上一篇几乎所有操作背后的"为什么"。


9. 为什么 ftrace"低侵入"是有代价上限的

ftrace 常被称为"低开销",但要理解它低在哪、以及边界:

  • 不追踪时:函数入口是nop接近零开销(§3.1)——这是它敢默认编进发行版内核的原因;
  • 追踪时:开销 = 被改写函数的数量 × 每次命中的回调成本。过滤越精准,扰动越小。高频函数(每秒百万次)即使单次回调只有几十纳秒,乘起来也可能显著扰动时序,甚至压垮 ring buffer(LOST EVENTS);
  • function_graph因为要劫持返回地址 + 影子栈,比纯 function tracer 更重;
  • kprobe因为走异常,单次命中比 dynamic ftrace 的call更贵。

所以"低侵入"不是无条件的,而是建立在精准过滤之上。这正是上一篇那套"set_ftrace_filter+set_ftrace_pid+tracing_on时间窗"组合拳的原理级动机:它们本质上都是在减少被打补丁的函数数量 / 缩小回调被触发的范围


10. 小结:一条echo function > current_tracer背后

回顾一下,当你敲下最普通的那几条命令时,内核内部发生了什么:

  1. 内核用-pg/-mfentry编译,每个函数入口都有call __fentry__钩子(§2);
  2. 启动时 dynamic ftrace 把这些钩子全部改成nop,默认零开销(§3.1);
  3. echo function > current_tracer选定 tracer、set_ftrace_filter选定函数;
  4. ftrace 用自修改代码把这几个函数的nop热补丁回call ftrace_caller(§3.2、§3.3);
  5. 命中时进入蹦床,调用当前 tracer 的回调,把记录写入每 CPU ring buffer(§4、§6.1);
  6. cat trace/trace_pipe,通过tracefs把 ring buffer 读出来(§6.2)。

一句话总结 ftrace 的设计哲学:

用编译期预留的钩子 + 运行期按需热补丁,把"追踪任意函数"的常态开销压到近乎为零,只在你真正需要的那几个点上、那段时间里,才付出代价。

理解了这套原理,再回头看上一篇的每一条命令和每一个"避坑建议",就不再是死记硬背的操作步骤,而是能从机制推导出来的自然结论。


本文讲清楚了原理,下文给一个应用示例:用 ftrace 判定内核代码路径:以 amdgpu PTE 更新走 SDMA 还是 CPU 为例。