ARM Profiling
本文介绍 ARM profiling 的基本概念与 ARM 平台上的工具链。
1. 什么是 Profiling
假设你有一段程序跑得太慢——图像处理要 2 秒,你希望降到 0.5 秒。你打开源码,面对几千行 C 代码。先优化哪里?你会猜”排序函数太慢”还是”那个嵌套循环太多了”?
如果猜错了,你花一周优化了一个只占总运行时间 2% 的函数,最终性能提升不到 1%。
Profiling 解决的就是这个问题:它告诉你程序的时间花在了什么地方,用数据代替猜测。
一个 profiler 采集程序运行时的数据,最终输出类似下面这样的报告:
1 | Each sample counts as 0.01 seconds. |
这就是 flat profile——profiler 以 0.01 秒为间隔反复采样程序,看每一次”此刻 CPU 在执行哪个函数”,然后统计成表。逐列解释:
| 列 | 含义 |
|---|---|
% time |
该函数占总 CPU 时间的百分比。33.34 就是 1/3 |
cumulative seconds |
从上往下累加的时间 — 第一行 0.02s,第二行累加后 0.03s,以此类推 |
self seconds |
该函数自身代码消耗的时间,不包括它调用的子函数 |
calls |
函数被调用了多少次 |
self ms/call |
self seconds ÷ calls — 每次调用该函数自身的平均耗时 |
total ms/call |
(self + 子函数耗时) ÷ calls — 每次调用从进入到返回的全部耗时 |
逐行看:
start(第 1 行):占了全部 CPU 时间的三分之一。被调用了 6275 次——极其高频。但self ms/call = 0.00,说明每次调用自身的耗时不到 0.01ms。如果它只是一段调度逻辑,把调用频率降下来或把它内联到调用者中,就是优化的方向。func1(第 2 行):self ms/call = 0.07ms,但total ms/call = 0.21ms——差了 3 倍。说明func1自己只花了 0.07ms,剩余 0.14ms 花在了它调用的子函数上。如果优化,应该去看func1的子函数,而不是func1自身。memcpy(第 3 行):self = total = 1.20ms——不调用其他耗时子函数,全部时间都在自己身上。每次调用 1.2ms,用 NEON 批量搬运替换逐字节拷贝,效果立竿见影。write(第 4 行):同样self = total——瓶颈在自身,大概率在等 I/O。
速读口诀:self ≈ total → 瓶颈就在这个函数内部,直接优化它;total ≫ self → 瓶颈在其调用的子函数,顺着调用链往下找。
Profiler 还会输出 call graph(调用图),记录每个函数被谁调用、调用了谁——用来发现意外的调用路径(”error_handler 被调了 3000 次?”)。
了解了 profiler 产出什么之后,下一个问题是:这些数据是怎么采集来的?
2. Profiler 的数据采集方式
采集方式分为两大类:插桩 (instrumentation) 和采样 (sampling)。
插桩:编译器在每个函数的入口和出口插入计数代码。程序运行时,每次调用都会被记录——数据精确,但程序跑得比平时慢很多。Gprof 是最典型的插桩 profiler。
采样:程序照常运行,profiler 以固定时间间隔(或硬件事件触发)暂停程序、记录当前正在执行哪条指令。统计上,一个函数被采样到的概率等于它消耗 CPU 时间的比例——如果 func1 被采样到了 50% 的次数,那么它就占用了大约 50% 的 CPU 时间。这不完全精确,但开销低得多。OProfile 和 perf 都属于这类。
采样又分为两个子类:
2.1 基于时间的采样
以固定时间间隔抓取处理器的当前状态。间隔越小,数据越详细,但对程序的干扰也越大。
1 | Time: |---S---|---S---|---S---|---S---|---S---|---S---| |
这是一种概率性方法:如果 func1 在采样时刻被”抓到”的概率是 50%,那么它消耗的 CPU 时间也大约是 50%。采样频率与准确度之间的 trade-off 是选型的关键参数。
2.2 基于事件的采样 (Event-based Sampling)
与时间采样不同,事件采样由特定的硬件事件触发(而非固定时间间隔)。典型事件包括:
- 缓存未命中 (cache miss)
- TLB 未命中
- 分支预测失败
- 特定指令退休 (instruction retired)
ARM 的 Performance Monitor Unit(PMU) 可以对这些硬件事件进行计数,并在计数器达到预设采样周期(通常表现为计数器溢出)时触发中断。Profiler 会在中断发生时记录当前的程序计数器(PC)及相关上下文,通过大量采样统计分析,即可找出哪些代码最频繁地伴随着 Cache Miss、TLB Miss 或分支预测失败等事件,从而定位性能瓶颈。
很多资料会把 Event-based Sampling 和 Event Counting 混在一起,但它们其实是两种不同的 PMU 使用方式:
| 模式 | PMU 做什么 | 是否记录 PC | 用途 |
|---|---|---|---|
| Event Counting | 统计事件总数 | ❌ | “总共发生了多少次 Cache Miss?” |
| Event-based Sampling | 事件达到采样周期时中断 | ✅ | “哪些代码最容易产生 Cache Miss?” |
下面这张图对比了时间采样与事件采样的核心差异,以及 OoO 核心中的 skid 效应:

2.3 统计本质
Profilers typically operate on a statistical basis, they might not necessarily produce absolute counts of events.
在复杂的现代处理器中,某些计数无法绝对精确。例如在超标量乱序核心中,当事件计数器更新时,无法保证同时被统计的指令执行数完全精确——因为多条指令可能在同一周期内处于不同的流水线阶段。
理解这一统计本质很重要:profile 数据是指南针,不是尺子。它告诉你”往哪儿看”,但不提供”该拧几圈螺丝”的精度。
下面这张时序图总结了三种 profiler 的完整工作流——插桩(Gprof)、操作系统定时器统计采样(OProfile 定时器模式)和硬件 PMU(perf events)——注意后两者的采样介质不同,但同属统计采样这一大类:

3. Profiler 输出解读
前面 §1 已经展示了一份 flat profile 的示例。这里补充各列含义和阅读技巧——无论使用哪种 profiler 工具,输出的解读逻辑是一致的。
3.1 Flat Profile 各列含义
| 列 | 含义 |
|---|---|
% time |
该函数消耗的 CPU 时间百分比 |
cumulative seconds |
累积时间,该行及之前所有行的总时间 |
self seconds |
函数自身代码消耗的时间 |
calls |
函数被调用次数 |
self ms/call |
每次调用平均耗时(不含被调用子函数) |
total ms/call |
每次调用平均耗时(包含被调用子函数) |
关键阅读技巧:当 self ms/call ≈ total ms/call 时,说明函数的全部时间花在自身逻辑上(如 memcpy 在搬运数据、write 在等待 I/O)。两者差距很大时,瓶颈在其调用的子函数中——优化应指向子函数,而非该函数本身。
3.2 Call Graph 的附加价值
调用图记录函数间的调用关系和次数。两点实用价值:
- 发现意料之外的调用路径——“
error_handler()被调了 3000 次?可能是隐藏 bug” - 评估内联候选——“
get_bit()被调了 10 万次但只有 3 条指令?内联它”
调用图通常需要特殊的编译选项(如 -g -fno-omit-frame-pointer)才能生成。
接下来逐一介绍 ARM Cortex-A 平台上的 profiling 工具。
4. Gprof:插桩式用户态 Profiler
从本节开始,我们逐一拆解 ARM Cortex-A 平台上的 profiling 工具。Gprof 是 GNU 工具链的标准 profiler,也是最容易上手的入门工具——其简单的插桩机制恰好展示了 profiling 的最基本形态。
4.1 工作原理
使用 GCC 的 -pg 编译选项后,编译器在每个函数的入口和出口插入计数代码。程序运行时,这些插桩代码记录函数调用关系和每个函数的累计耗时,程序退出时写入 gmon.out 文件。
1 | # 编译时加 -pg(如需逐行分析再加 -g) |
4.2 关键局限
Gprof 有一个根本性的盲区:它只能看到用 -pg 编译的用户代码。
Gprof profiles only the user application code and not anything that happens in code that has not been built for profiling (for example, libc) or the kernel.
这意味着:如果程序的性能瓶颈在内核 I/O、文件系统或未重新编译的第三方库中,Gprof 会给出误导性的结论——由于 Gprof 无法分析内核和未插桩代码,它只能把观察到的耗时归结到调用这些代码的用户函数,因此容易让人误以为这些用户函数本身就是性能瓶颈。
4.3 插桩的副作用
- 运行开销:
-pg会在每个函数入口插入统计代码(如mcount()),增加函数调用开销,因此程序运行速度会下降。在实时系统中,这种额外开销可能改变任务时序,使某些时序相关的 Bug 难以复现。 - 优化选项的取舍:
-O2生成的 profile 更接近生产环境,但函数内联会消除部分函数边界,导致内联函数不再作为独立条目出现在 profile 中,其耗时会合并到调用者。如果分析重点是函数调用关系,可适当降低优化等级;如果关注真实运行性能,则应保持与发布版本一致的优化配置。 - 核心原则:始终明确自己要测量的是真实运行性能,还是函数级调用关系,再选择合适的编译选项。
4.4 适用场景
Gprof 适合于用户态计算密集型程序,性能瓶颈主要位于应用代码中。不适合:分析内核代码、未插桩的第三方库,以及系统调用、I/O 等内核态热点。
4.5 与 perf 的区别
- Gprof:GNU Binutils 的标准组件,跨架构通用,但依赖编译时插桩,只能分析插桩后的用户态代码。
- perf:Linux
perf_events框架提供的性能分析工具(Linux 2.6.31 引入,2.6.34 基本成熟),支持 PMU 硬件事件,可同时分析用户态、内核态以及 Cache Miss、TLB Miss、Branch Misprediction 等事件,因此在现代 ARM Linux 平台上的分析能力远强于 Gprof。
5. OProfile:系统级统计采样
Gprof 在仅需分析用户态代码时足够简单,但一旦性能瓶颈深入到内核或第三方库,它的盲区就暴露了。OProfile 是一个解决这一问题的方案——它不依赖插桩,而是通过对整个系统进行统计采样来覆盖从内核到用户态的全部代码路径。OProfile 是 Linux 上的全系统 profiler,与 Gprof 的定位完全不同。
5.1 核心对比
| 特性 | Gprof | OProfile |
|---|---|---|
| 数据采集方式 | 插桩(函数入口/出口) | 统计采样(时间 + 硬件事件) |
| 覆盖范围 | 仅用户代码(-pg 编译) |
全系统:用户态 + 内核 + 库 |
| 是否需重编译 | 必须 -pg |
不需要(有符号即可) |
| 调用图 | 精确(调用关系来自插桩,耗时来自统计采样) | 统计生成(可能不完全精确) |
| 硬件事件支持 | 无 | 支持(时钟周期、缓存未命中等) |
5.2 工作方式
OProfile 以固定间隔(或硬件事件触发)检查当前正在执行的代码,并递增对应的计数器。采集足够长的 profile 即可获得统计上显著的结果。
5.3 中断屏蔽导致的统计偏差
因为 OProfile 依赖中断来触发采样,当中断被 spinlock_irq_save() 屏蔽时,采样本身被推迟。当中断在 spinlock_irq_restore() 处恢复时,积累的采样事件集中触发,导致该函数被过度采样——看起来像一个瓶颈,实际上只是”被借用了”别人禁中断期间的 CPU 时间。
这个现象在锁竞争密集型代码中尤为明显。解决方案:使用硬件 PMU 直接读取计数器,绕过中断采样路径。
6. ARM Performance Monitor (PMU):硬件性能计数器
这是 ARM Cortex-A 处理器内置的非侵入式性能分析硬件——不改变处理器行为,不插入额外指令,直接通过 CP15 协处理器寄存器提供计数功能。
6.1 PMU 架构
1 | +---------------------------------------------------+ |
PMU 包含一个可配置的周期计数器和多个 32 位事件计数器。周期计数器可配置为每个周期递增或每 64 周期递增(后者用于长时间采样而不溢出)。事件计数器可分别配置为统计不同的事件类型。
6.2 可监控事件一览
ARMv7-A 定义了一套标准可计数事件:
| Event ID | Event | 中文解释 |
|---|---|---|
| 0x00 | Software increment (SW_INCR register write) | 软件递增事件:向 SW_INCR 寄存器写入时,事件计数器加一,主要用于软件测试或校准 PMU。 |
| 0x01 | L1 instruction cache refill | L1 指令缓存填充(Refill):L1 I-Cache 未命中,从 L2 Cache 或主存重新加载指令缓存。 |
| 0x02 | L1 instruction TLB refill | L1 指令 TLB 填充:指令地址转换未命中,需要重新填充指令 TLB。 |
| 0x03 | L1 data cache refill | L1 数据缓存填充:L1 D-Cache 未命中,从更低层缓存或主存加载数据。 |
| 0x04 | L1 data cache access | L1 数据缓存访问:发生一次对 L1 数据缓存的访问(命中或未命中均计数)。 |
| 0x05 | L1 data TLB refill | L1 数据 TLB 填充:数据地址转换未命中,需要重新填充数据 TLB。 |
| 0x06 | Memory-reading instruction executed (load) | Load 指令执行次数:执行了一条读取内存(Load)指令。 |
| 0x07 | Memory-writing instruction executed (store) | Store 指令执行次数:执行了一条写入内存(Store)指令。 |
| 0x09 | Exception taken | 异常进入次数:处理器进入异常(IRQ、FIQ、Data Abort、Undefined 等)时计数。 |
| 0x0C | Software change of PC (branch) | 软件修改 PC(分支):执行导致 PC 改变的控制流指令(如跳转、调用、返回等)。 |
| 0x0D | Immediate branch instruction executed | 立即数分支指令执行次数:执行 B、BL 等立即数分支指令。 |
| 0x10 | Branch mispredicted or not predicted | 分支预测失败或未预测:分支预测器预测错误,或该分支未进行预测。 |
| 0x11 | Cycle count | CPU 周期数:统计处理器经历的时钟周期数(Cycle Count)。 |
| 0x12 | Predictable branch speculatively executed | 预测分支推测执行次数:处理器根据分支预测提前执行了可预测分支后的指令。 |
| 0x13 | Data memory access | 数据内存访问次数:发生一次数据访问(Load 或 Store)。 |
| 0x14 | L1 instruction cache access | L1 指令缓存访问次数:发生一次对 L1 I-Cache 的访问(命中或未命中均计数)。 |
| 0x15 | L1 data cache write-back | L1 数据缓存回写:脏 Cache Line 从 L1 D-Cache 回写到下一级缓存或主存。 |
| 0x17 | L2 data cache refill | L2 数据缓存填充:L2 C1ache 未命中后,从主存重新填充 L2 Cache。 |
| 0x19 | Bus access | 总线访问次数:处理器发起一次外部总线访问(通常包括访问内存或外设)。 |
| 0x1B | Instruction speculatively executed | 推测执行指令数:统计所有进入流水线并被推测执行的指令,包括最终可能被取消(Squash)的指令。 |
这些事件的使用通常不是孤立的——你将多个计数器组合起来推导有意义的指标:
| 公式 | 含义 | 应用场景 |
|---|---|---|
Event 0x11 ÷ Event 0x1B |
Cycles Per Instruction (CPI) | 衡量核心效率,越低越好 |
Event 0x01 ÷ Event 0x14 |
L1 I-cache miss rate | 指令缓存优化效果 |
Event 0x03 ÷ Event 0x04 |
L1 D-cache miss rate | 数据布局/访存模式优化 |
Event 0x05 ÷ Event 0x04 |
L1 D-TLB miss rate | 大页或 TLB 优化效果 |
Event 0x10 ÷ Event 0x0D |
Branch misprediction rate | 分支预测友好性 |
Event 0x19 ÷ Event 0x11 |
Bus accesses per cycle | 总线压力 |
实例:假设你优化了一段热点循环,想验证效果。修改前收集 CPI = 2.1(每指令 2.1 周期),缓存缺失率 12%;修改后 CPI = 1.3,缺失率降至 3%。即便没有精确的时序测量,这些硬件计数器也能明确告诉你——优化是有效的。
6.3 多核扩展事件
SMP 集群的 PMU 额外提供:
- 屏障指令(DMB/DSB)执行次数
- 一致性缓存活动(coherent cache traffic)计数
- 独占访问(LDREX/STREX)失败次数
后者在评估多核锁竞争时特别有用:STREX 失败次数过高说明多个核心在争抢同一个锁。
6.4 精度说明
乱序执行导致事件计数之间存在”skid”——触发采样的事件(比如第 100 次缓存缺失)可能对应到实际发生该事件的指令之后若干条指令。在分析逐指令级别的数据时需要注意这个偏斜。
6.5 PMU 与 ETM 的互补关系
PMU 提供的是统计聚合数据(”哪个函数花了多少时间”),而 ARM 的 ETM(Embedded Trace Macrocell) 提供精确的指令级执行追踪——它可以完整重建程序的执行路径,包括每个分支的走向、每条指令的周期数,无需统计近似。
在调试间歇性的性能异常(如偶尔出现的”长延迟”帧)时,PMU 采样可能完全漏掉偶发事件,而 ETM 的 trace 数据可以精确捕获那个具体时刻处理器的完整状态。两者互补:PMU 用于发现宏观瓶颈,ETM 用于定位微观异常。
7. Linux 内核 Profiling 框架
ARM PMU 提供了底层的硬件计数能力,但在实际开发中,直接操作 CP15 寄存器既不便捷也不能跨平台复用。Linux 内核将 PMU 硬件、软件事件和内核追踪点统一到两个框架中——perf events 和 Ftrace——向上层工具暴露一致的接口。
7.1 Linux perf events
自 Linux 2.6.34 起,内核集成了 Ingo Molnar 的 perf events 框架。它将 ARM PMU 硬件计数器和软件事件统一到平台无关的接口下。
1 | perf_event_open() syscall |
Perf events 支持的能力远超硬件 PMU:
- 按核心、进程、线程粒度分别 profile
- 记录执行追踪 (execution trace)
- 生成函数调用图
- 实时统计汇总
对比 Gprof 和 OProfile,perf events 是更现代的统一方案——它不需要插桩,可以同时追踪硬件和软件事件,且支持调用图。以下是最常用的 perf 命令:
1 | # 统计模式:运行程序并汇总硬件事件计数 |
7.2 ARM Streamline 性能分析器
ARM DS-5 Streamline 是 ARM 官方提供的图形化性能分析工具,基于 Linux perf events 和 PMU 硬件计数器。它与 perf 命令行工具的数据源相同,但提供了更丰富的可视化界面:
- Timeline 视图:以时间轴方式展示 CPU 使用率、中断频率、功耗状态等指标的实时变化
- Call Path 视图:按调用路径聚合热点函数,直接定位最耗时的调用链
- Code 视图:在反汇编/源码级别标注每条指令的采样计数和缓存缺失次数
- CPU/GPU 联合分析:在 Mali GPU 的异构 SoC 上,可同时采集 CPU 和 GPU 的性能数据
Streamline 不需要 -pg 编译——它与 perf 一样基于 PMU 采样。对于嵌入式 ARM 平台,Streamline 通过 TCP/IP 或 USB Gadget 与目标板的 gator 守护进程通信,适合无屏幕的开发板场景。
7.3 Ftrace
Ftrace 专注于内核函数调用流的可视化——它追踪内核中的每个函数调用,揭示各部分之间的交互。
在 ARM Cortex-A 平台上的典型用途:
- 排查内核路径中长时间禁用中断或抢占的位置
- 验证驱动程序的中断处理路径是否如预期执行
- 理解调度器在不同负载下的行为
Ftrace 与 perf events 互补:perf 回答”时间花在哪”,ftrace 回答”代码走了哪条路径”。
上述所有工具——Gprof、OProfile、PMU、perf、Ftrace——都工作在真实硬件上,依赖真实的计数器和真实的内核行为。但有时我们需要比硬件更精细的信息:比如每条访存指令命中哪个缓存行,或者两个数据结构是否在共享同一个缓存组。这就需要模拟——Valgrind/Cachegrind 填补了这块空白。
8. Valgrind / Cachegrind:模拟式剖析
上述所有工具——Gprof、OProfile、PMU、perf、Ftrace——都工作在真实硬件上,依赖真实的计数器和内核行为。但有时我们需要比硬件更精细的信息:每条访存指令命中哪个缓存行,或者两个数据结构是否因地址冲突而在同一个缓存组中互相驱逐。Valgrind 通过软件模拟填补了这块空白。
Valgrind 通常以”内存泄漏检测工具”的身份知名(Memcheck),但它对 ARM 性能分析的贡献不止于此。
8.1 工作原理
Valgrind 将程序翻译为核心无关的中间表示 (IR),在 IR 上执行分析后,再翻译回原生机器码。这个翻译-分析-再翻译的流程使程序运行慢大约一个数量级——但它能提供其他 profiler 无法获得的精细信息。
8.2 Cachegrind:缓存模拟
Cachegrind 利用 Valgrind 的 IR 层,模拟目标处理器的 L1/L2 缓存行为,记录所有的内存访问。原文特别指出:
Some care is required — this is a simulation of the cache and it is possible that it does not represent the real cache hardware completely accurately.
模拟的缓存模型(如替换策略、关联度配置)可能与实际硬件不完全一致。具体差异取决于是否模拟了处理器的确切缓存参数:
- 缓存行大小(通常 32 或 64 字节)
- 关联度(通常 4 路组关联)
- 替换策略(通常伪 LRU 或随机)
但即使存在偏差,Cachegrind 的宏观结论(”这段代码导致了大量的 L2 缺失”)通常仍是正确的。
8.3 内存访问剖析的必要性
在现代 ARM 系统中,开发者对内存地址的控制力越来越弱:
- 链接器决定映像内的虚拟地址布局
- 运行时加载器控制共享库的位置
- 内核负责物理内存的分配
当缓存性能出现问题时,往往不清楚哪些数据结构在互相”打架”。Cachegrind 通过模拟整个地址空间的内存访问模式,帮助定位伪共享 (false sharing)、缓存抖动 (cache thrashing) 和次优的数据布局。
8.4 工具选型决策矩阵
1 | Gprof OProfile PMU perf Ftrace Cachegrind |
9. 关键要点
从 Gprof 的简单插桩到 Cachegrind 的完整缓存模拟,工具形态差异巨大,但背后的方法论是统一的:用数据说话,先定位瓶颈,再选择策略。以下是最值得记住的八条原则:
Profiling 先于优化 —— Knuth 的名言不是反对优化,而是反对没有数据支撑的优化。任何性能工作的第一步永远是 profile。
先换算法,再调实现 —— 如果时间花在链表遍历上,换成哈希表比加速链表遍历有效得多。Profiler 告诉你瓶颈在哪,但不等同于告诉你如何解决。
时间采样 ≠ 事件采样 —— 时间采样回答”CPU 在跑什么”,事件采样回答”什么触发了性能问题”。后者对于特定优化(如减少缓存缺失)更有针对性。
ARM PMU 是零开销的 profiler —— 硬件性能计数器不改变程序行为,可以提供 CPI、缓存命中率、分支预测精度等指标。但乱序执行导致事件间存在 skid,不适合逐指令级别的精确调试。
OProfile 的
spinlock_irq_restore陷阱 —— 中断屏蔽期间暂停采样,恢复后集中触发,导致该函数被过度采样。这不是 bug,是统计偏差——换用硬件 PMU 直接读数可以规避。Gprof 是最简单的,也是最受限的 —— 插桩式、仅用户态、需重编译。适合”刚写完的纯计算模块”,不适合定位内核 I/O 瓶颈。
perf events 是 Linux 上的现代统一方案 —— 平台无关、硬件+软件事件、按线程/进程/核心粒度。是 ARM Cortex-A 平台上最推荐的通用 profiling 入口。
Cachegrind 的模拟结论需要验证 —— 处理器的确切替换策略和缓存参数可能与模拟器假设不一致。但宏观的缺失模式分析通常是可信的。对数据布局优化尤其有价值。