用户态程序出问题可以 strace、gdb,内核出问题靠什么?答案是动态追踪三件套:ftrace 看调用路径、kprobe 在任意指令打点、perf 做采样统计。本文先讲清三者的机制差异与适用场景,再用一个完整可运行的模型模拟器复现函数图追踪、延迟直方图和热点统计的真实输出形态,让你读到工具输出时不再陌生。
一、三件套的分工哲学
- ftrace:内核自带的追踪框架,回答『执行路径是什么』。核心能力是 function tracer(每个函数入口打点)与 function graph tracer(连调用树带耗时画出来)。几乎所有内核函数都被编译器插了桩(-pg 选项生成的 mcount/fentry 调用),ftrace 只是在运行时用 nop 替换掉它们、需要时再动态换回——这就是它低开销的来源。
- kprobe:回答『某个内核函数被调了多少次、参数是什么、返回了什么』。它在任意内核指令处动态替换为断点指令(int3),命中后执行注册的回调再恢复原指令。kprobe 是机制,ftrace 的 kprobe_events、perf probe、eBPF 的 kprobe 挂载点都是它的使用者。
- perf:回答『CPU 时间花在哪』。基于 PMU 硬件计数器或定时器做周期采样,把指令指针聚合成热点函数/热点行。perf record/report、火焰图的原始数据就来自它。
一句话记忆:ftrace 看路,kprobe 看点,perf 看面。三者底层共享 tracepoint 和环形缓冲(relay/perf buffer)等基础设施。
二、完整可运行模型模拟器(Python 3,约 100 行)
下面程序用装饰器式的打点复现三种工具的输出形态:函数图调用树、kprobe 延迟直方图、perf 热点统计:
#!/usr/bin/env python3
"""ftrace/kprobe/perf 模型模拟:函数追踪、延迟直方图、调用栈、热点统计"""
import random, time
random.seed(42)
# ---------- ftrace 函数图追踪 ----------
class FTrace:
def __init__(self):
self.trace = []
self.stack = []
def enter(self, fn):
ts = time.perf_counter()
self.stack.append((fn, ts))
self.trace.append(('enter', fn, ts, len(self.stack) - 1))
def exit(self):
fn, ts0 = self.stack.pop()
ts = time.perf_counter()
self.trace.append(('exit', fn, ts, len(self.stack),
(ts - ts0) * 1e6))
ft = FTrace()
def vfs_read():
ft.enter('vfs_read'); do_read(); ft.exit()
def do_read():
ft.enter('do_read')
page_cache_lookup()
if random.random() < 0.3:
do_page_fault()
ft.exit()
def page_cache_lookup():
ft.enter('page_cache_lookup'); time.sleep(0.0002); ft.exit()
def do_page_fault():
ft.enter('do_page_fault'); time.sleep(0.001); alloc_page(); ft.exit()
def alloc_page():
ft.enter('alloc_page'); time.sleep(0.0003); ft.exit()
for _ in range(3):
vfs_read()
print('=== ftrace 函数图追踪模拟(3 次 vfs_read 调用) ===')
for rec in ft.trace:
if rec[0] == 'enter':
print(' ' * rec[3] + '-> %s()' % rec[1])
else:
print(' ' * rec[3] + '<- %s() 耗时 %.0fus' % (rec[1], rec[4]))
# ---------- kprobe 延迟直方图 ----------
print()
print('=== kprobe 延迟统计模拟(vfs_read 延迟直方图,100 次采样) ===')
lats = []
for _ in range(100):
base = random.gauss(50, 15)
if random.random() < 0.08:
base += random.gauss(200, 50) # 8% 慢路径(缺页)
lats.append(max(1, base))
buckets = {'1-32us': 0, '32-64us': 0, '64-128us': 0,
'128-256us': 0, '>256us': 0}
for l in lats:
if l < 32: buckets['1-32us'] += 1
elif l < 64: buckets['32-64us'] += 1
elif l < 128: buckets['64-128us'] += 1
elif l < 256: buckets['128-256us'] += 1
else: buckets['>256us'] += 1
for k, v in buckets.items():
print(' %-10s %3d %s' % (k, v, '#' * (v // 2)))
lats_sorted = sorted(lats)
print('p50=%.0fus p95=%.0fus p99=%.0fus max=%.0fus' % (
lats_sorted[49], lats_sorted[94], lats_sorted[98],
lats_sorted[-1]))
# ---------- perf 热点统计 ----------
print()
print('=== perf 热点函数统计模拟(1000 次采样) ===')
funcs = ['page_cache_lookup', 'do_page_fault', 'vfs_read',
'schedule', 'idle', 'copy_to_user', 'alloc_page']
weights = [35, 20, 12, 10, 8, 10, 5]
samples = random.choices(funcs, weights=weights, k=1000)
from collections import Counter
cnt = Counter(samples)
print(' 函数 采样数 占比')
for name, c in cnt.most_common():
print(' %-20s %5d %5.1f%%' % (name, c, c / 10))
三、真实运行输出与解读
以上代码在 Python 3 下原样运行,输出如下:
=== ftrace 函数图追踪模拟(3 次 vfs_read 调用) ===
-> vfs_read()
-> do_read()
-> page_cache_lookup()
<- page_cache_lookup() 耗时 4669us
<- do_read() 耗时 4672us
<- vfs_read() 耗时 4675us
-> vfs_read()
-> do_read()
-> page_cache_lookup()
<- page_cache_lookup() 耗时 245us
-> do_page_fault()
-> alloc_page()
<- alloc_page() 耗时 358us
<- do_page_fault() 耗时 1420us
<- do_read() 耗时 1667us
<- vfs_read() 耗时 1670us
(第 3 次形态与第 2 次相同,约 1674us)
=== kprobe 延迟统计模拟(vfs_read 延迟直方图,100 次采样) ===
1-32us 9 ####
32-64us 62 ###############################
64-128us 18 #########
128-256us 5 ##
>256us 6 ###
p50=55us p95=261us p99=318us max=356us
=== perf 热点函数统计模拟(1000 次采样) ===
函数 采样数 占比
page_cache_lookup 345 34.5%
do_page_fault 186 18.6%
vfs_read 130 13.0%
schedule 114 11.4%
copy_to_user 97 9.7%
idle 77 7.7%
alloc_page 51 5.1%
三种输出形态各自讲了一个故事:
- 函数图追踪回答『为什么慢』:第一次 vfs_read 耗时 4675us,调用树立刻暴露问题不在主路径(page_cache_lookup 正常)——但注意第 2、3 次多出了 do_page_fault→alloc_page 分支,1420us 的缺页处理占了全程 85%。真实的 ftrace function_graph 输出格式与此一模一样,只是函数名换成内核符号。
- 延迟直方图回答『慢是个例还是普遍』:62% 的调用落在 32-64us 正常区间,但 8% 掉到 128us 以外——双峰分布。p50=55us 看着健康,p99=318us 却暴露了慢路径。只看平均值的监控会漏掉这 8%,这就是为什么延迟排查必须用直方图/分位数(bpftrace 的 hist() 一行生成同款输出)。
- 热点统计回答『该优化谁』:page_cache_lookup 占 34.5% 采样数,do_page_fault 占 18.6%。优化收益按占比排序,先啃 34.5% 的大头——perf report 的默认视图就是这个表,火焰图只是把同样的数据按调用栈展开成图。
四、真实工具速查表
# ftrace:追踪 vfs_read 的函数图(tracefs)
cd /sys/kernel/tracing
echo function_graph > current_tracer
echo vfs_read > set_ftrace_filter
echo 1 > tracing_on; sleep 1; echo 0 > tracing_on
cat trace
# kprobe:统计 vfs_read 调用次数与参数
cd /sys/kernel/tracing
echo 'p:myprobe vfs_read file=%di' > kprobe_events
echo 1 > events/kprobes/myprobe/enable
cat trace_pipe
# perf:采样 10 秒热点 + 延迟直方图思路
perf record -F 99 -g sleep 10 # 99Hz 采样带调用栈
perf report --stdio | head -30
# 直方图用 eBPF/bpftrace 更方便:
bpftrace -e 'kprobe:vfs_read { @start[tid] = nsecs }
kretprobe:vfs_read /@start[tid]/ {
@lat = hist(nsecs - @start[tid]); delete(@start[tid]) }'
五、理论分析
5.1 fentry 插桩与运行时替换
编译器 -pg 选项让每个内核函数开头多一条 call fentry 指令。ftrace 启动时把全内核几十万条这样的 call 全部换成 5 字节 nop(运行时热 patching,靠 text_poke 安全修改代码段);开启某个 tracer 时再把目标函数的 nop 换回 call。这个『平时是 nop、用时才激活』的设计让 ftrace 关闭时的开销精确为零——动态追踪与静态埋点的本质差异就在这里。
5.2 采样的统计学
perf 的 99Hz 采样是有意选的:避开与 100Hz/1000Hz 的定时器中断共振(锁步采样会系统性偏倚)。采样数 N 时占比 p 的 95% 置信区间约 p±2√(p(1-p)/N)——1000 次采样下 34.5% 的占比置信区间约 ±3%,这解释了为什么 perf 官方建议至少跑几百毫秒、别拿 10 次采样下结论。
5.3 观察者效应
所有追踪都有开销:function graph tracer 每次进出让函数贵几十纳秒,kprobe 断点命中是微秒级。追踪高频函数(如 schedule)时开销可能扭曲被测系统本身。工程纪律:能用 tracepoint(静态埋点,关闭时零开销)就不用 kprobe,能用 kprobe 就不用全量 function tracer。
六、工程应用
- 三板斧排障流程:先 perf top/record 找到热点函数(面)→ 再 ftrace function_graph 或 kprobe 看该函数的调用路径与参数(点和路)→ 最后用 bpftrace 定制直方图量化延迟分布。三个工具各管一段,串起来就是完整的内核排障动线。
- tracepoint 优先:/sys/kernel/tracing/events 下内核预置了数百个静态 tracepoint(sched、irq、block、net 各子系统),开销极低且语义稳定,永远是首选数据源。
- 生产环境安全线:function tracer 全开在高负载机器上可能造成性能骤降;生产环境只追踪目标函数(set_ftrace_filter)、限制 trace 缓冲大小、采样频率控制在 99Hz 量级。
- eBPF 是殊途同归:本专栏 eBPF 一文详述的 BPF 程序,其 kprobe/tracepoint 挂载点与本文三件套完全同源——学会 ftrace 和 kprobe 的概念模型,bpftrace/bcc 的脚本就能直接读懂。
七、常见误区
- 误区 1:『perf 显示的占比就是精确 CPU 占用』。采样统计有置信区间,占比小的函数(<1%)误差可能超过其值本身;结论性优化前先用更长采样窗口复测。
- 误区 2:『function_graph 里耗时长的函数就是瓶颈』。含睡眠的函数(如等待 I/O)耗时高是正常等待而非消耗 CPU;要看『不含子调用的自身耗时』(self time)才准。
- 误区 3:『kprobe 可以挂在任何指令上』。内联函数没有独立符号、某些路径被 lockdep 标记不可探测(kprobe 黑名单),硬挂会被拒绝或行为异常;available_filter_functions 是安全清单。
- 误区 4:『平均值正常 = 延迟正常』。本文直方图的 62% vs 8% 双峰就是反例:均值被多数派拉低,尾延迟(p99)才是用户体验。延迟类指标永远看分位数。
- 误区 5:『追踪数据越多越全越好』。全量 function tracer 每微秒产生数条记录,trace 缓冲秒级溢出、数据被冲掉一半。带过滤条件的最小数据集比全量数据更有分析价值。
八、参考文献
- S. Rostedt (2008-2010). ftrace: Function Tracer 系列文档与 LWN 文章(ftrace 作者的一手阐述)。
- Linux kernel documentation: Documentation/trace/ftrace.rst、kprobes.rst、tracepoints.rst。
- B. Gregg (2020). Systems Performance (2nd ed.) 第 2、13 章:观测工具与内核分析方法论。Addison-Wesley.
- B. Gregg (2019). BPF Performance Tools. Addison-Wesley.(bpftrace 直方图与动态追踪的实战手册)
- perf 官方文档与源码: tools/perf/Documentation 目录。