ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

mmiotrace 实战:内核 MMIO 访问追踪与调试

mmiotrace 实战:内核 MMIO 访问追踪与调试 简介针对Linux环境下内存映射I/OMMIO行为的观测需求该资源提供一个基于ftrace框架的追踪工具源码面向需要定位驱动读写异常、开展系统级性能分析和排查中断上下文问题的中高级Linux开发者。MMIO机制让CPU直接访问设备内存省去传统总线I/O转发但访问密度与时机往往成为瓶颈这份源码的价值正是将这类底层访问以事件形式精确记录下来。压缩包结构极为精简仅含1个C文件大小约3KB内容却覆盖MMIO追踪的关键链路包括mmiotrace_register/unregister回调注册与注销、环形缓冲区管理、中断上下文中的同步原语以及带时间戳、CPU ID、读写类型和地址的事件格式非常便于逐行研读。通过分析trace_mmiotrace.c可理解Linux内核如何将ftrace与自定义追踪函数结合还能借助trace-cmd等用户态工具完成数据采集与可视化为优化驱动性能、排查硬件资源争用提供直接参考。已有71人学习适合想在内核层快速搭建观测体系、深入理解mmiotrace执行流程的开发者使用。1. 一个叫trace_mmiotrace.rar_trace的文件到底在说什么解压缩旧驱动包时常能看到trace_mmiotrace.rar_trace这种命名怪异的文件——它并不是压缩包而是内核 Ftrace 的mmiotracetracer 导出的一份文本记录。文件名里的trace表示作者把一次 MMIO 访问跟踪的结果单独归档保存供驱动调试或故障分析使用。MMIOMemory-Mapped I/O是 CPU 通过 load/store 指令访问外设寄存器的方式而 mmiotrace 可以把每一次 readl、writel 乃至裸指针访问都记录下来包括操作类型、寄存器地址、数据值、时间戳和调用栈。对内核驱动开发者、性能调优工程师和需要维护十年以上老设备驱动的人来说这份 trace 的价值在于把「驱动到底碰了哪些寄存器、以什么顺序碰」变成可回放的数据而不只是 dmesg 里难以定位的报错。后面的分析会从机制讲起再落到采集、解析和验证。2. mmiotrace 的机制怎么记录一次 MMIO 读写的全过程先解释普通代码访问 RAM 和访问 MMIO 在指令上很难区分readl(addr)在 x86 上就是一次 32 位 loadwritel(val, addr)是一次 32 位 store编译后通常没有明显标记表明目标地址属于外设。因此 mmiotrace 只能借助外部手段它把被跟踪的内存区在页表中标记为不可访问让首次访问触发缺页异常再在异常处理中拿到当时 CPU 要访问的虚拟地址、内核对应该地址的物理地址、访问宽度和指令指针。记录完成后再让异常返回并重新执行原指令。这样每次 MMIO 访问都变成了一次「fault 记录 恢复」虽然慢但能完整捕获所有绕过标准封装函数的裸访问。2.1 从寄存器指针到 trace 事件MMIO 与普通内存访问的区别驱动拿到ioremap()返回的void __iomem *指针后常见操作是readl、writel、ioread32等但也有不少老驱动直接*(volatile u32 *)addr赋值。mmiotrace 之所以能连后一种也抓下来是因为它工作在整个地址空间的硬件访问层而不是某个函数的探针。这里有一个关键区别普通内存访问可以由 CPU 缓存合并或重排而 MMIO 访问尤其是计算型设备的门铃寄存器doorbell每一次 store 都可能是一条独立命令驱动的正确性和性能高度依赖「多少次、什么顺序」。因此 trace 事件本身就代表 CPU 实际发出的访问序列分析这个序列能够看到驱动设计中的冗余操作和非法访问。如果只是抓性能也许可以用 perf 或 PMU 计数器但要还原访问顺序和调用者mmiotrace 是最直观的样本。2.2 内核里 mmiotrace 的实现路径从 CONFIG_MMIOTRACE 到 current_tracer启用 mmiotrace 的内核配置有三个CONFIG_MMIOTRACE提供 tracer 本体CONFIG_MMIOTRACE_TEST提供自测模块可选CONFIG_FTRACE提供 trace 框架。在 5.10 之前的内核上编译完成后它出现在/sys/kernel/tracing/current_tracer的可选列表里。切换 tracer 有个容易踩的坑必须先写0关闭 tracing否则echo mmiotrace current_tracer会失败。下面是采集某个驱动初始化到运行期间全部 MMIO 访问的最小命令序列mount -t tracefs nodev /sys/kernel/tracing 2/dev/null || true cd /sys/kernel/tracing echo 0 tracing_on echo mmiotrace current_tracer echo 1 tracing_on # 触发被测驱动 modprobe my_driver ./run_test_case.sh # 停止并导出 echo 0 tracing_on cp trace trace_mmiotrace.txt echo nop current_tracer命令的意图是明确的先挂载 tracefs更老的内核路径是/sys/kernel/debug/tracing把 tracer 设置成 mmiotrace 后再打开开关业务代码要完整落在tracing_on1和0之间导出用cp而不是cat因为此时 trace 文件可能已经很大cat到终端会花大量时间在终端渲染上。最后把 tracer 恢复为nop非常重要如果忘了这一步之后每次加载驱动都会因为 MMIO 缺页异常付出性能代价甚至让某些要求实时响应的设备出现超时。由于 trace 框架在不同内核版本上文件位置和名称几乎一致我通常把上面的命令封装成一个collect_mmiotrace.sh脚本参数是测试程序和归档文件名。下面列出采集过程中需要关注的控制节点节点/文件作用推荐做法current_tracer当前激活的 tracer切换前先echo 0 tracing_ontracing_on总开关采集结束先关再导出trace累积的文本用cp导出避免终端渲染trace_pipe流式读取后台消费适合长时间采集buffer_size_kb每 CPU 环形缓冲大小默认 1408KB高频设备调到 64MB 以上这里顺便回答一个很多人问的问题既然内核有 function tracer能不能用functiontracer 直接跟踪readl调用可以但 function tracer 只能看到调用了readl这个符号看不到内联展开、事件类型和数据值而且函数结束后的返回值需要额外抓取。mmiotrace 是在更低一层捕获 load/store 指令连编译器优化后的直接访址都躲不掉。所以我分析寄存器访问序列时总是先用 mmiotrace只有在新内核无法启用时才退化到 kprobe。2.3 trace 输出的字段格式与一个最小样例mmiotrace 沿用了 Ftrace 的通用行头会在文件开头写# tracer: mmiotrace后面每一行记录一次访问。由于不同内核的对齐和列名有差异我一般不从固定列号出发而是先看前几行再写分析脚本。下面是典型的输出# tracer: mmiotrace # 任务名- PID CPU# 时间戳 类型 地址 值 swapper-0 0 0.001234 MMIO WRITE 0xfed00000 0x00000001 mydriver-1234 1 0.002341 MMIO READ 0xfed00004 0xffff0000这个样例里第二行表示 CPU0 上的 swapperPID 0往地址0xfed00000写了一个 32 位值 1时间戳 0.001234 秒。第三行表示 CPU1 上的mydriver进程在地址0xfed00004处读到一个值。如果访问的是 x86 IO 端口类型会成为PORTIN或PORTOUT地址变成端口号。拿到这样的文本后分析流程固定在三个动作过滤类型、按地址分组、把调用者地址转成源码行。注意mmiotrace 记录的是「访问地址」不是模块名或设备名。要把地址关联到具体驱动必须在采集时同步保存/proc/iomem、/proc/modules以及各模块.text段地址否则后续只能靠猜。3. 把 mmiotrace 采集成可作为证据的 trace 文件前面那组命令能跑通但面对真实设备时多半会遇到两个问题trace 缓冲被写满丢事件以及输出文件太大难以归档。真实网卡或 GPU 在满负荷下每秒可能有几十万次 MMIO 访问如果 driver 的轮询循环写得差甚至单核就能打爆 4MB 的环形缓冲。把 mmiotrace 采集成一份能留档、能复现、能交给别人分析的 trace 文件需要针对这两个问题做处理。3.1 老内核上启用 mmiotrace 的最小命令序列实战版下面这个脚本是实际采集时我常用的形态它用后台trace_pipe消费来避免缓冲溢出并把归档动作放在里面#!/bin/bash TRACEDIR/sys/kernel/tracing cd $TRACEDIR || exit 1 echo 0 tracing_on echo nop current_tracer echo 4096 buffer_size_kb echo mmiotrace current_tracer echo 1 tracing_on cat trace_pipe trace_raw.txt CONSUMER_PID$! insmod my_driver.ko ./benchmark --duration 10 sleep 1 kill $CONSUMER_PID 2/dev/null wait $CONSUMER_PID 2/dev/null echo 0 tracing_on echo nop current_tracer wc -l trace_raw.txt这段逻辑里最关键的是cat trace_pipetrace_pipe是流式接口每次读操作都会取出并删除缓冲中的一条记录因此不会像trace那样因为缓冲满而覆盖早期数据。消费者进程退出前可能丢掉最后几行所以业务结束之后sleep 1用来留出时间让最后一批 trace 事件被 flush 到管道。buffer_size_kb设置为 4096 在这里只是兜底因为正常情况下消费者足够快如果目标设备 MMIO 频率极高可以临时调到 131072128MB但要注意每个 CPU 各有一份内存占用会成倍增加。如果采集过程中发现trace_pipe的输出里出现「TRACE HITS MAX」之类的警告说明你的消费者速度跟不上产生速度。此时优先优化消费者比如把输出重定向到裸分区或 SSD 上的预分配文件而不是花时间调大缓冲。3.2 trace 文件太大怎么办约定要抓哪些地址区间mmiotrace 没有内置的地址过滤器抓下来的数据里会包含系统里所有被跟踪的 MMIO 访问不只是你关心的设备。当文件增长到 GB 级别时先用head和grep缩小范围而不是直接加载进文本编辑器。下面这段命令统计 trace 文件里出现过的所有地址awk /MMIO/{print $(NF-1), $NF} trace_file.txt | sort | uniq -c | sort -rn | head这段awk处理假设地址在倒数第二列、类型在最后一列具体列需要根据文件头调整。如果发现某个地址区间占了绝大多数访问但那个地址属于其他驱动比如声卡 DMA 引擎可以用grep -v把这个地址前缀从分析中排除只保留你关心的资源区间。归档时按业务操作分片也很有用一次modprobe、一次lspci -v、一次完整测试各存一份后续定位阶段只需要针对单个分片做深度解析。压缩归档用 gzip 即可trace 文本重复度高压到十分之一以下很正常。3.3 新内核没有 mmiotrace 了用什么替代抓 traceLinux 5.10 之后 mmiotrace 从主线内核移除原因是缺少维护者且缺页方案开销过大。我在新内核上需要类似数据时默认用kprobe_events钩住readl、writel。以 x86_64 为例先写入探针定义再开启cat /sys/kernel/tracing/kprobe_events EOF p:my_readl readl $arg1 p:my_writel writel $arg1 $arg2 EOF echo 1 /sys/kernel/tracing/events/my_probes/enable echo 1 /sys/kernel/tracing/tracing_on这里的探针点readl和writel是内核导出的通用 MMIO 访问函数。$arg1在 x86_64 SysV ABI 里是第一个参数对readl是地址对writel$arg1是数据值$arg2是地址所以上面定义里我故意把writel的地址放在$arg2采集脚本要按这个顺序解析。kprobe 的好处是任意内核版本都可用代价是只能看到通过这两个符号的访问如果驱动用了memcpy_fromio或内联展开需要把探针加到__ioread32_copy、__iowrite32_copy等函数上。两种路线的取舍可以看这张表维度mmiotracekprobe 挂 readl/writel捕获点缺页异常覆盖全部 MMIO 访问函数入口只覆盖约定符号性能开销每次访问先 fault 再恢复函数调用级跳转开销小得多可用内核5.10 之前主流内核长期存在分析成本需要解析 trace 事件需要自行拼接字段如果必须在新内核上复现旧分析流程一个可行路径是写一个约 200 行的 eBPF 程序在probe_kernel_write或函数返回点读取readl的返回值再把事件送到perf_event_open环形缓冲。这样能得到更低的采样开销但实现成本明显高于 kprobe更适合在性能敏感的生产环境使用。4. 分析trace_mmiotrace.rar_trace先抓异常再造热力图拿到解压后的 trace 文件后第一步永远是「看头尾、数行数」。我用head -n 30确认格式用wc -l看体量再用file trace_mmiotrace.rar_trace确认是纯文本还是已经被压缩工具处理过。如果file输出显示 RAR 压缩数据先解压如果显示 ASCII 或 UTF-8 文本就可以直接交给下面这些命令。4.1 用 awk 快速找出访问次数最高的寄存器地址为了在几百万行记录中找热点我习惯先把每行的事件类型和地址取出来做频率统计。假设地址在第 3 列类型在第 2 列最直接的是grep -E MMIO (READ|WRITE) trace.txt \ | awk {print $2, $3} \ | sort \ | uniq -c \ | sort -rn \ | head -20grep过滤事件awk只留类型和地址sort|uniq -c得到每个组合的次数最后的sort -rn让次数最大的排在第一。输出里你会看到某个地址的写次数比第二名高一两个数量级这种「写放大」往往对应驱动里一个没有正确关闭的轮询循环或调试残留代码。如果需要更复杂的逻辑比如排除某些 IO 端口区间或只统计特定时间段用 Python 更顺手from collections import Counter import sys counter Counter() with open(sys.argv[1]) as f: for ln in f: ln ln.strip() if not ln or ln.startswith(#): continue parts ln.split() if len(parts) 4: continue typ, addr parts[1], parts[2] if typ not in (MMIO_READ, MMIO_WRITE, READ, WRITE): continue counter[(typ, addr)] 1 for (typ, addr), n in counter.most_common(30): print(f{typ:12s} {addr:20s} {n})这段脚本把读和写分开计数并兼容可能带下划线的类型名。most_common返回频率最高的 30 组正好一屏能看完。分析频率时还要注意一点地址相同但值不同的写前者关心频率后者要关心值序列所以频率表只能定位问题不能解释问题。另一个常用操作是只看某个时间段的记录。驱动初始化通常在前几百 ms业务爆发在后几秒用awk $3 1.0 $3 2.5按时间戳过滤可以避开启动噪声直击故障窗口。不过不同版本的时间戳列存在偏移需要先看一眼head再写列号。4.2 把 trace 行映射到驱动代码和物理地址mmiotrace 会尽力记录调用者地址PC。这个地址在 trace 里通常表现为十六进制数对应内核模块加载后的运行时地址。把它转成源码行需要三个信息trace 里的 PC、模块.text段基址、模块文件本身。假设 trace 里 PC 是0xffffffffc0001030模块基址是0xffffffffc0000000那么偏移是0x1030用addr2line解析addr2line -e my_driver.ko -f -C 0x1030输出会类似init_module /build/my_driver.c:110。这里有个细节addr2line需要的是模块内偏移不是完整绝对地址如果直接传0xffffffffc0001030会得到错误结果或乱行。获取模块基址的正确途径是/sys/module/my_driver/sections/.text这个虚拟地址就是模块 text 段的运行时起点。另外如果驱动被编译成 vmlinux 的一部分就不能用addr2line处理.ko而要用vmlinux文件加-e偏移计算方式也不一样。4.3 常见 MMIO 反模式不该出现在 trace 里的三种姿势在多次解过真实mmiotrace数据后我总结了三种高频出现的反模式它们的共同点是 trace 里能看到非常明显的「重复」和「多余」特征。反模式trace 特征典型后果轮询等待里反复读状态寄存器同一地址 READ 以固定间隔连续出现数量几千到几万CPU 空转浪费可能干扰设备中断写完寄存器立即读回同地址WRITE 后紧跟 READ地址相同且两者之间无分支指令多余总线往返放慢热路径对非对齐地址执行 32 位操作事件类型是 32 位但地址低两位非 0如 0xfed00003部分平台抛异常或产生不正确访问轮询等待的修复方向不是简单加udelay而是先看设备是否提供了完成中断如果必须轮询用cpu_relax()让流水线更友好。写后读回通常是因为驱动作者想「确认写进去了」但在强内存模型下写和读共享同一总线读回没有任何可靠性帮助。非对齐访问需要修复ioremap时的资源大小声明或驱动内的指针运算逻辑这类问题最容易被 trace 数据暴露也最容易被代码审查遗漏。5. 用两次 trace 的 diff 验证驱动修复是否生效给一个轮询驱动加中断后怎么证明「修复真的把多余访问去掉了」功能性测试过了只能说明设备还能工作但无法证明热路径上的 MMIO 次数减少。最直接的办法是分别在修复前后各采一份trace用同样的测试参数跑一遍然后把两份 trace 按「地址类型」聚合比较访问次数。for tag in before after; do grep -E MMIO (READ|WRITE) trace_${tag}.txt \ | awk {print $2, $3} \ | sort \ | uniq -c \ | sed s/^ *// \ | sort -k2,2 -k3,3 cnt_${tag}.txt done join cnt_before.txt cnt_after.txt \ | awk $2 ! $3 {print $1, :, $2, -, $3}这段脚本先对每份 trace 统计「类型 地址 次数」再用join把相同「类型 地址」的两边次数放到同一行最后只打印次数不同的项。刷屏的通常是时间噪声但只要差异项稳定出现就值得去对应的调用栈里确认。如果某个地址在after里从几千次降到几十次说明热路径已经被改对如果完全没变多半是改动没进同一个执行路径或者 trace 采样的起点和终点没有对齐。聚合次数只能发现数量变化顺序变化需要用另一种技巧抓取目标寄存器在两次 trace 中的访问序列然后按顺序对比。可以用grep抽取并去掉时间戳grep MMIO WRITE 0xfed00004 trace_after.txt | awk {$4; print} seq_after.txt接着diff seq_before.txt seq_after.txt观察第几行开始出现差异。顺序错的修复往往比次数多更难发现比如设备要求先写控制寄存器、再写数据寄存器如果驱动把两步调换功能上可能只是偶发失败。经过这么一轮 diff 后我还会做一次手工复核把wc -l得到的总 MMIO 事件数和被测业务量对应起来确保不是 trace 本身丢行造成的假相似。真正干净的验证不是只看一次 diff而是连续抓三次修复后 trace观察同一地址计数是否稳定在一个数值上下。最后再用grep -c MMIO READ trace_after.txt和硬件中断计数器的值做一次交叉验证确认这份 trace 数据没有缺行。稳定这才算把 trace 的价值用到位。本文还有配套的精品资源点击获取
返回列表