linux ftrace 原理剖析:内核函数追踪是怎么实现的
本文是 ftrace 用法文档 的姊妹篇。ftrace 用法篇讲"怎么用",本文讲"为什么能这样用"——ftrace 到底是如何在运行中的内核里,做到几乎零开销地追踪任意函数的。
1. 一个核心问题:如何在每个函数入口"插一脚"
要追踪"某个函数有没有被调用、被谁调用、耗时多少",最朴素的想法是:在每个内核函数的入口处插入一小段代码,调用一个记录函数。
问题在于:内核有几万个函数,如果每个函数入口都硬编码一次"调用记录函数",那么即使不追踪时,这些额外指令也会拖慢整个内核。ftrace 的全部精妙之处,就在于如何让这个"插一脚"在不用时开销趋近于零,用时又能精确打开。
答案分两层:
- 编译期:让编译器在每个函数入口预留一个"钩子"(这一步靠
-pg); - 运行期:默认把这些钩子替换成空操作(
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),它做的事情是:
- 保存现场:把参数寄存器等 CPU 状态压栈(因为追踪不能破坏被追踪函数的参数/返回值);
- 调用当前生效的追踪回调
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_handler; 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/.../enable,nop被热补丁成一条跳转,跳去执行探针回调,把开发者显式声明的结构化参数(地址、页数、flags…)写进 ring buffer。
所以 tracepoint 的两大特点都能从原理解释:① 带结构化参数(因为是源码里显式传的);② 不受函数内联影响(探针是独立埋点,不依赖某个函数是否被内联保留符号)。
7.2 kprobe —— 运行期动态断点
kprobe 更暴力:它能在几乎任意内核地址动态下探针,靠的是断点异常:
- 把目标地址第一条指令替换成断点指令(x86 上是
int3); - CPU 执行到这里触发断点异常,陷入 kprobe 的处理程序,在这里可以抓寄存器、参数、甚至改变行为;
- 处理程序里单步执行被替换掉的那条原始指令(保存在别处),再返回原流程。
代价是每次命中都要走一次异常,比 dynamic ftrace 的call重;好处是不需要函数在__mcount_loc表里、也不需要源码预埋,真正做到"哪里都能插"。这就是上一篇"没有现成 tracepoint 就上 kprobe"的底气。
8. 把三种插桩机制放在一起看
三条插桩来源殊途同归,最后都汇入同一套 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背后
回顾一下,当你敲下最普通的那几条命令时,内核内部发生了什么:
- 内核用
-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 为例。