news 2026/10/24 1:06:30

Linux内核追踪利器ftrace:动态插桩原理与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux内核追踪利器ftrace:动态插桩原理与实战

如果想要给Linux内核“拍CT”,ftrace可以说是最顺手的X光机。它是内核自带的一个追踪框架,最初只是用来跟踪内核函数的调用关系,后来一步步扩展成了集函数追踪、静态事件、动态插桩于一体的调试利器。很多系统卡顿、进程调度异常、驱动中断抖动之类的问题,用perf或strace看半天只能看到表象,真正想定位到是哪段内核代码耗时高,最后都得落到ftrace上面来。

这篇文章是这个系列的第3篇,前面我们已经把trace机制的整体轮廓和常用排查手段聊过了,这一篇专门拆ftrace的原理。适合正在做内核驱动开发、系统性能调优,或者单纯想搞明白“内核到底是怎么被追踪的”的工程师。即使你是刚接触内核的新手,跟着文章亲手操作一遍,也能很快建立起对ftrace的完整认知。

1. 内容整体设计与思路拆解

1.1 ftrace到底在解决什么问题

内核是一个大黑盒,业务进程一旦陷入系统调用,执行流就进入了内核态。这时候你想知道它内部到底走了哪些函数、每个函数耗了多少时间,普通调试器基本插不进手,因为内核的地址空间和用户态完全隔离。传统的做法是在怀疑的函数里手动加printk,但改代码重新编译内核模块,代价太高,而且频繁打印本身就会改变时序,掩盖问题现场。

ftrace的思路完全不一样。它把追踪能力直接内建到内核中,你的内核镜像在编译时就已经给每个函数预留了一个“挂钩位”。运行的时候你再决定要不要挂上钩子、挂什么钩子,不用重新编译,甚至可以只跟踪某个子系统的特定函数。这就好比电路板设计阶段就给每个测试点留好了焊盘,平时不焊任何东西,需要诊断时才用跳线连上示波器。

经过这么多年的演进,ftrace现在能做的事情远不止函数跟踪。它下面挂了三个层次的能力:最底层是动态函数插桩,也就是给函数入口打补丁;中间一层是各种tracepoint事件,内核开发者已经预埋好的“观测点”;再往上是kprobe/uprobe,可以让你在任意函数地址或用户态地址插入探测逻辑。写这篇文章的时候,我想的就是把这些层次拆开讲透,而不是只给一堆命令。

1.2 静态插桩与动态插桩的取舍

很多初学者第一反应是:ftrace是不是靠调试符号或者模拟器在截获函数调用?不是。真实机制涉及一种“静态编译 + 动态改写”的组合策略。

内核在编译时,会通过-pg或-fpatchable-function-entry等编译器选项,在每个函数的入口处预留一小段“空指令”位置。传统-pg选项会产生对mcount的调用,而专门适配ftrace的方式是生成几个字节的NOP指令,这几个NOP就是将来打补丁的位置。这段空间位于函数序言之前,不属于任何单独函数,却是所有函数都经过的共同入口。

等设备运行时,如果你启动了function tracer,ftrace会把这些NOP指令原地改写为一条跳转指令,跳到它自己的钩子函数ftrace_caller上。钩子函数保存现场后读取你要的记录项,写入内核的环形缓冲区,然后恢复现场跳回原函数继续执行。不用的时候,它再把跳转指令改回NOP,让函数走最原始的路径,接近零开销。

这种“动态插桩”的好处是巨大的。你可以在生产环境里随时开始追踪,不用重启、不用带额外的调试内核,而且可以按函数名精确过滤,把观测范围收敛到最小。相对的,tracepoint属于静态插桩,编译时就确定了位置,好处是代码路径非常稳定,缺点是你只能观察预埋好的位置。动态插桩和静态事件两者是互补的关系,后面会详细说明。

1.3 ftrace的核心调度链路

前面说的钩子函数只是入口,整个追踪过程还有一条完整的调度链路。以function tracer为例,当一个被跟踪的函数刚要执行时,跳转指令把控制权交给ftrace的专用入口,这里会做一次轻量级判断:当前CPU是否打开了trace开关?当前进程是否需要被过滤掉?如果都不满足就直接原路返回,开销只多几条指令。

如果决定记录,钩子函数会拿到当前函数地址和调用者返回地址,从地址反查函数名,连同时间戳、进程信息一起写入per-CPU的环形缓冲区。这里有个很巧的设计:每个CPU都有自己的独立缓冲区,避免多核竞争锁,这也是ftrace在压力环境下仍然可用的关键。

再往上一层,所有tracepoint事件最终也是把记录写到这个环形缓冲区,只是入口不一样。而perf工具打开内核事件时,其实也是从ftrace的这套基础设施上取数据。所以你看,理解了ftrace的环形缓冲区和钩子机制,很多工具的原理都能串起来了。

2. 核心细节解析与实操要点

2.1 tracefs文件系统逐个看

现代内核里,ftrace的对外接口主要在tracefs文件系统,挂载点通常是/sys/kernel/tracing。如果你在旧文档里看到/sys/kernel/debug/tracing,那是老内核把它挂在了debugfs下面,新内核推荐独立挂载:

mount -t tracefs nodev /sys/kernel/tracing

许多发行版开机已经挂好了。进入这个目录,你会看到一大堆文件,初次接触容易懵。我按用途把它们归纳一下,你查的时候就有数了。

文件/目录作用典型操作
available_tracers列出当前内核支持的tracer类型cat查看,包括function、function_graph等
current_tracer当前正在使用的tracer写入function或nop来切换
trace静态读取缓冲区内容cat trace查看已记录的追踪结果
trace_pipe实时流式读取cat trace_pipe持续输出新记录
tracing_on全局开关,0停止,1恢复echo 0 > tracing_on冻结当前现场
set_ftrace_filter设置要跟踪的函数白名单echo 'tcp_sendmsg' > set_ftrace_filter
set_ftrace_notrace设置黑名单把不想看的高频函数排除在外
available_filter_functions列出所有可被动态插桩的函数用grep过滤出你关心的子系统
events/所有tracepoint事件所在目录echo 1 > events/sched/sched_switch/enable
buffer_size_kb设置per-CPU环形缓冲区大小echo 2048 > buffer_size_kb
max_graph_depthfunction_graph最大递归深度echo 3 > max_graph_depth
current_stack_trace导出当前栈回溯(配合特定tracer)排查栈溢出时有用

current_tracer的设计有点反直觉,它一次只能启用一个主追踪器。新手最容易犯的错是先写了function,然后又想去开事件,发现事件没反应。这时候不是因为事件坏了,而是当前tracer占用着主路径,事件要等tracer切回nop后才能真正输出。

2.2 function与function_graph到底怎么选

function是基础的函数调用记录器,它只记录“哪些函数被调用了”,不显示调用关系。打开后的输出大致长这样:

# tracer: function # TASK-PID CPU# TIMESTAMP FUNCTION # | | | | | kworker-1462 [001] 34.192837 tcp_sendmsg kworker-1462 [001] 34.192838 tcp_sendmsg kworker-1462 [001] 34.192839 tcp_write_xmit

注意FUNCTION列只是被调用的函数名,你看不出它是由谁调用的。function_graph则不一样,它记录的是函数的进入和退出,并用缩进展示层级关系。对于读懂一条调用链来说,function_graph直观得多:

2) | tcp_sendmsg() { 2) 0.200 us | tcp_sendmsg_locked(); 2) 0.100 us | tcp_rate_check_app_limited(); 2) 0.900 us | }

但代价也明显:图形化记录需要同时处理进入和退出两个事件,记录数量几乎翻倍,追加的时间戳和缩进处理也会抬高CPU开销。所以我的建议是:先通过function粗筛出可疑函数,再用function_graph精读调用链,不要一上来就开全量图形追踪。

实操里要特别注意set_ftrace_filter的使用。你不想把整个内核都跟踪下来,否则环形缓冲区几秒就塞满,系统会变得极其卡顿。较好的方式是先看available_filter_functions里有没有你怀疑的函数,如果有就精确到函数名,没有才考虑通配符:

grep tcp_ /sys/kernel/tracing/available_filter_functions echo 'tcp_sendmsg tcp_write_xmit tcp_recvmsg' > set_ftrace_filter echo function_graph > current_tracer

用空格分隔多个函数,比用通配符安全得多。*tcp*这种写法很容易误伤一票无关函数,CPU直接被拖垮。

2.3 动态补丁机制是如何安全落地的

把NOP改成跳转指令听起来简单,在内核里做却要考虑到一个残酷的现实:同一时刻可能有几十个核在并发执行这些函数入口。如果你只改了指令还没准备好跳转目标,某个CPU跳到半路上就会发现目标地址不对,直接崩溃。

ftrace的处理方式是典型的RCU式安全切换。它先把跳转目标指向一个统一入口,然后通过stop_machine机制让所有CPU短暂同步到安全点,在安全点更新所有要修改的调用点,最后恢复运行。这么做的细节是,即使某个CPU已经执行到了旧代码,它也只会走到同一套统一的入口,而不会访问半修改的中间状态。新版内核还用了patchable-function-entry选项配合跳转范围检查,确保每条跳转指令都不要跨过过长的地址偏移,避免因远跳转占用过多指令槽。

另一个安全设计是黑名单机制。某些函数不能被动态修改,例如用于修改这段代码本身的函数、一部分RCU和调度原子的相关路径。内核在初始化时就会把它们列入available_filter_functions之外,你在文件里根本看不到它们。这一点你不用手动规避,内核已经做了。

2.4 事件追踪与环形缓冲区的配合逻辑

如果只靠动态函数插桩,追踪的粒度还是偏大。内核里大量关键动作发生得非常快,比如一次调度切换、一次中断的进入退出,如果只记录函数调用,你要从海量的函数名里自己演绎上下文,效率很低。于是就有了tracepoint事件。

events/目录下按子系统组织了上千个事件,比较常用的有sched、irq、kmem、block、syscalls、net等。每个事件都有固定的格式,记录的是结构化字段,比如sched_switch事件会记录上一个进程、下一个进程、切换原因等。开启方式:

echo 1 > /sys/kernel/tracing/events/sched/sched_switch/enable echo 1 > /sys/kernel/tracing/events/irq/irq_handler_entry/enable cat /sys/kernel/tracing/trace_pipe

事件数据和函数追踪数据最终都进同一个per-CPU环形缓冲区。这个缓冲区用的是无锁化的定长记录,每个CPU一份,读取时按CPU顺序导出。你可以利用buffer_size_kb把单个CPU的缓冲调大,但不要无脑调太大,因为缓冲区也是内核内存,太大会挤压page cache。遇到高并发追踪需求,更推荐用trace_pipe实时消费,而不是攒在缓冲区里事后一次性读。

3. 实操过程与核心环节实现

3.1 五分钟跑通一次函数调用链观察

下面完整演示一次从挂载到观察的流程,目标是想看看某个TCP数据发送函数内部大概经过了哪些调用。真机环境可能函数名有差异,但流程是通用的。

# 1. 确保tracefs已经挂载 mount -t tracefs nodev /sys/kernel/tracing cd /sys/kernel/tracing # 2. 先停掉已有的追踪,从干净状态开始 echo 0 > tracing_on echo nop > current_tracer echo > trace # 3. 设置过滤函数,并切换到function_graph echo 'tcp_sendmsg tcp_write_xmit tcp_recvmsg' > set_ftrace_filter echo function_graph > current_tracer # 4. 打开开关并触发一次网络收发(可以用任意客户端请求本机服务) echo 1 > tracing_on curl -s http://127.0.0.1/ > /dev/null sleep 1 # 5. 冻结现场并查看 echo 0 > tracing_on head -80 trace

我故意在流程里加入了多次开关设计。如果你一上来就打开追踪,很难保证看到的记录和你想定位的动作严格对应。先关掉再清空,操作完再开,动作结束后立刻关掉,这样缓冲区里留下的几乎就是你关注的那段时间。

看输出的时候重点观察缩进和耗时。同一行缩进层级表示嵌套深度,括号闭合表示函数退出。如果某个函数下方很长时间没有闭合括号,可能它内部等待了锁或者睡眠了,这就是排查延迟的线索。另外要记住function_graph显示的时间是该函数自身执行时间加子函数时间的累计值。

3.2 用事件追踪做一次调度延迟快照

函数级追踪信息量大,但在排查“为什么我的实时线程没有及时调度”这类问题时,事件模型更合适。调度延迟涉及进程状态切换、CPU负载均衡、唤醒方式等多个维度,函数级调用你很难看出因果。

假设你有一个周期性任务,理想是每10ms运行一次,但实际有几次延迟到了50ms。这时可以只开调度关键事件:

cd /sys/kernel/tracing echo 0 > tracing_on echo nop > current_tracer echo > trace # 开启关键的调度和中断相关事件 echo 1 > events/sched/sched_wakeup/enable echo 1 > events/sched/sched_switch/enable echo 1 > events/irq/irq_handler_entry/enable echo 1 > events/irq/irq_handler_exit/enable echo 1 > tracing_on # 运行你的任务几秒钟 sleep 5 echo 0 > tracing_on cat trace > /tmp/sched_trace.txt

查看结果时,按时间轴搜索那几个异常延迟点前后的sched_wakeup记录,看唤醒发生在哪个CPU上,目标进程之前在什么状态下阻塞,中间是否有中断长时间占用了CPU。如果发现irq_handler_entry和irq_handler_exit之间时间跨度特别大,问题很可能就在这个中断处理程序里。

事件追踪的干扰比函数图追踪小很多,因为事件只在预定义点触发,不像函数追踪那样每个函数都要过一道判断。这也提醒我们,能先精确定位到“是调度问题还是中断问题”这个层面,再决定是否深入函数追踪。

3.3 用kprobe造一个专属于你的探针

有时候内核预置的tracepoint不够用,你怀疑某个驱动函数异常,但内核里没有对应事件。kprobe就是为这种情况准备的:你可以指定一个要探测的内核函数地址,在它执行前或返回后插入自定义逻辑。

和tracepoint不同,kprobe不一定要求函数名在某个“白名单”里,只要内核符号表里存在就行。查看符号和实现探测:

# 先确认函数符号存在 grep mysub_drv /proc/kallsyms cd /sys/kernel/tracing # 创建一个kprobe事件,获取函数入参的指令指针寄存器值 echo 'p:myprobe mysub_drv ip=$arg1' > kprobe_events echo 'r:myret mysub_drv ret=$retval' >> kprobe_events echo 1 > events/kprobes/myprobe/enable echo 1 > events/kprobes/myret/enable cat trace_pipe

p:前缀表示在函数入口探测,r:前缀表示在函数返回处探测。参数可以用$arg1、$arg2这种抽象形式,内核会尽量根据函数栈帧找到对应的寄存器或内存位置。$retval是返回值。

用kprobe有两个常见坑。一是函数名写错或符号地址不存在,创建事件后kprobe_events会静默失败或返回错误,你可以通过cat kprobe_events确认有没有生成对应行。二是某些被标记为NOKPROBE的函数不能探测,比如产生kprobe缺页异常的处理路径,强行探测会导致递归。这种情况内核会直接拒绝,不用太担心。

3.4 如何结合perf和ftrace缩小排查半径

讲了这么多ftrace的细节,并不意味着你以后所有问题都直接上ftrace。实际工作中更高效的路径是先用perf或者分析工具圈定大致方向,再用ftrace去定点放大。

比如你用perf top发现某个CPU内核态占比很高,但看不清具体执行路径。这种场景下可以先用ftrace的function衰减频率做一个热点函数粗筛:按调用次数频率过滤掉高频细节,保留时间占比大的函数。或者直接在该CPU上开启function_graph并限制追踪窗口,时间缩到1~2秒内,观察热点函数内部到底卡在哪。

简单说就是把perf当作望远镜,先定位问题在哪个范围;ftrace当作显微镜,再深入看具体路径。两者结合使用,比单用任何一个都高效,也能避免一开始就被ftrace的细节淹没。

4. 常见问题与排查技巧实录

4.1 定位一次真实的内核延迟事件

我处理过一类很典型的“卡顿反馈”问题。某开发者负责一个用户态高频采集程序,时不时报告单次采集耗时从正常几毫秒跳到几百毫秒。用perf抓用户态栈,只能看到程序在read()系统调用里阻塞,但是read内部为什么慢,perf显得无能为力。

当时我在内核侧开了这样一组追踪:先切到function_graph,过滤掉无关函数,只留do_sys_openat2、vfs_read、filemap_fault这几个疑似路径;同时开启了events/irq/irq_handler_entry和events/irq/irq_handler_exit。跑了一轮之后看到irq_handler_entry到irq_handler_exit单次耗时竟然有200ms,问题根本不在文件系统,而是在某块raid卡的中断处理里。后来针对那部分驱动代码做了优化,单次采集耗时降回了正常范围。

这个案例说明一个很宝贵的经验:遇到耗时峰值,不要先急着怀疑业务代码,先把“CPU时间是不是被内核中断抢占了”这种底层因素排除掉。中断事件虽然看起来不起眼,但通常都是延迟异常的元凶。

4.2 追踪没生效、数据为空时怎么自查

用ftrace的时候最常遇到的就是“我明明echo了,怎么trace里什么都没有”。这种问题大多出在几个可控点上。这里给一个排查顺序:

现象先检查什么备注
开了function没有记录current_tracer是不是被覆盖先echo nop > current_tracer再重开
事件enable后看不到数据主tracer占用着输出路径切回nop再观察事件
过滤函数生效了但没有调用记录某个高频函数确实没被调用用available_filter_functions确认名字
有数据但很快被冲掉环形缓冲区太小调大buffer_size_kb
开启function后系统卡死或极慢过滤范围太宽停掉追踪,缩小过滤范围
trace_pipe读取一直卡住等待新数据阻塞Ctrl+C退出即停,不影响后续

追踪过程中如果遇到系统明显变慢,最快的止损手段是直接echo 0 > tracing_on,这会立刻冻结所有追踪逻辑,让函数走回NOP路径,系统基本能马上恢复。注意即使tracing_on关闭,之前累积在缓冲区里的数据仍然在trace文件里,还能继续读取。

实践上我习惯在打开追踪之前先写一个很小的Shell脚本,包含清空、设过滤、开事件、记录、关开关的完整序列,这样出了问题能快速重跑。手动一条条敲命令,特别容易输错文件名。

4.3 控制追踪开销的三板斧

ftrace本身尽量做了轻量化,但能记录的路径一旦铺开,开销依然可观。如果你是在生产环境做短时诊断,有几个关键的调参手段必须掌握。

第一板斧是控制过滤范围。set_ftrace_filter里写几个函数和写一整类函数,开销差距是数量级的。优先精确到函数名,必要时用set_ftrace_notrace把已知高频函数剔除。

第二板斧是缩小采样窗口。很多人习惯开着追踪跑半天,再去翻日志找蛛丝马迹。其实ftrace更适合“预埋+触发”的模式:先在需要观测的路径附近设置探测,关着追踪跑业务,出现问题后立刻打开追踪再复现一次,问题复现完马上关掉。窗口越短,数据越精准,开销也越小。

第三板斧是关注buffer_size_kb和tracing_cpumask。如果你只关心单核行为,可以用tracing_cpumask把追踪限制到特定CPU,减少一半以上的缓冲和输出压力。因为环形缓冲区是per-CPU独立的,限制CPU还可以避免多核数据交叉,看时间线更容易对齐。

4.4 容器与多环境下的注意事项

在容器里面跑ftrace,首先要确认宿主机内核是否开启了相关配置。常见的几个配置项包括CONFIG_FTRACE、CONFIG_FUNCTION_TRACER、CONFIG_KPROBE_EVENTS、CONFIG_TRACEPOINTS。即便容器里面看不了/boot/config-*,也可以查看宿主机或确实读取到挂载路径时试跑一下,能出数据就说明内核是支持的。

另一个容易被忽略的是权限问题。tracefs默认trace等文件可能是root可写,有些发行版会通过tracefs的挂载选项做限制。为了安全,生产环境建议不要长期保留tracefs挂载权限。需要追踪时挂载,用完及时卸载,避免被他人滥用。

如果你需要在开机启动阶段就自动做追踪,可以把要执行的echo命令放到rc.local或系统服务里,但要注意设置tracing_on为0,等到目标服务窗口你再手动打开。很多工程师把追踪打开了忘关,结果几天后回来发现内核缓冲区写满了,性能一直受影响,这个“追完就关”的习惯一定要养成。

5. 从原理到实践的一些个人体会

前面写了很多拆解和命令,最后聊聊我在实际工作中怎么看待ftrace。它在Linux调试工具链里的地位,有点像外科医生手里的内窥镜:你不用把整个身体切开,但可以伸进一根带摄像头的细管,精准看到病灶区域。而它和perf这类采样式工具的区别在于,ftrace更擅长回答“这段代码到底怎么走的”,perf更擅长回答“整段时间哪里最忙”。

我个人的建议是,遇到一个典型的性能问题,先记录好正常的基线数据,再考虑开ftrace。否则一旦追踪窗口打开,系统的行为已经发生改变,你看到的数据可能不是真实状态下的表现。

最后再分享一个我自己用下来的小技巧:在排查完之后不要急着删掉trace文件,可以把事件定义和过滤名单保存下来,写成一个小脚本放到项目的doc目录里。下次再遇到类似问题,一键复现同样的追踪环境,不用从头回忆当时用的哪几个事件、哪几个函数,能省下很多时间。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 1:05:10

三渠道App支付集成实践:统一下单、回调幂等与掉单补偿

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:04:30

LabWindows/CVI基础教程:用C语言轻松开发上位机界面与波形显示

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:04:30

Java笔试高频考点全解析:从重载、InnoDB到多线程与算法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:03:45

前端调用本地exe的四种方案与安全实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:03:44

STM32F215RE与PCA9422低功耗电源管理方案设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 1:03:32

Games101作业3:从Phong光照到法线贴图的Shader渲染实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华