2. ARMv8体系结构基础
性能调优这件事,说白了就是「你得先知道瓶颈在哪」。ARMv8 的 PMU(Performance Monitors Unit)就是干这个的——它是一套硬件计数器,能帮你精确测量 CPU 内部的各种行为。我在做手机 SoC 调优时,经常靠它定位「为什么这个场景掉帧」或者「为什么功耗比竞品高」。今天我们就把它掰开揉碎,看看 PMU 到底怎么用。
PMU 寄存器概览
ARMv8 的 PMU 寄存器体系,我习惯把它分成三类:控制类、计数类、状态类。控制类决定「监控什么」,计数类记录「发生了多少次」,状态类告诉你「有没有溢出」。下面这张图能帮你快速建立整体认知:
事件计数器(PMEVCNTRn)
事件计数器是 PMU 的核心。ARMv8 通常提供 6 个通用事件计数器(PMEVCNTR0~PMEVCNTR5),每个都可以独立配置监控不同的事件。比如你可以让计数器 0 监控 L1 缓存缺失,计数器 1 监控分支预测错误,计数器 2 监控指令退休数。
关键点:每个事件计数器都是 32 位或 64 位(取决于实现),从 0 向上计数。计数到最大值后,如果使能了溢出中断,就会触发中断。
配置事件计数器的步骤,我总结为三步走:
- 选择计数器:通过 PMSELR 寄存器选择你要配置的计数器编号(0~5)。
- 设置事件类型:通过 PMXEVTYPER 寄存器写入事件编号。比如 0x11 代表 L1 数据缓存缺失,0x1B 代表指令退休。
- 使能计数器:通过 PMCNTENSET 寄存器置位对应 bit,计数器开始工作。
举个例子,我想监控 L1 数据缓存缺失:
// 选择计数器 0
MRS x0, PMSELR_EL0
AND x0, x0, #0xFFFFFFF8 // 清除低 3 位
ORR x0, x0, #0 // 选择计数器 0
MSR PMSELR_EL0, x0
// 设置事件类型为 L1D_CACHE_REFILL (0x11)
MOV x0, #0x11
MSR PMXEVTYPER_EL0, x0
// 使能计数器 0
MRS x0, PMCNTENSET_EL0
ORR x0, x0, #1
MSR PMCNTENSET_EL0, x0
我的经验:事件编号在不同微架构下可能不同。Cortex-A76 和 Cortex-A78 的事件定义就有差异。我建议你每次换平台时,先查一下 TRM(Technical Reference Manual) 确认事件编号,别想当然。
周期计数器(PMCCNTR)
周期计数器是个特殊的存在。它只有一个,专门用来统计 CPU 运行的时钟周期数。为什么需要它?因为事件计数器只能告诉你「发生了多少次」,但不知道「花了多少时间」。有了周期计数器,你就能算比率——比如每周期执行了多少条指令(IPC)。
周期计数器默认是 64 位的,从 0 开始向上计数。它可以通过 PMCR 寄存器的 DP 位(Disable Cycle Counter)来控制是否在非调试状态下计数。嗯,这里要注意:如果你在调试器里单步执行,周期计数器默认是停止的,除非你设置了相应的调试权限。
避坑指南:我曾经在一个项目中,发现周期计数器的值比预期大了好几倍。排查了半天,原来是内核在 idle 时没有关闭周期计数器,导致统计了空闲时间。正确的做法是:在测量代码段前后读取 PMCCNTR,做差值计算,而不是直接读一个绝对值。
读取周期计数器的代码很简单:
// 读取周期计数器
MRS x0, PMCCNTR_EL0
// 此时 x0 中就是当前的周期数
结合事件计数器,你可以算出很多有意义的指标:
| 指标 | 计算公式 | 说明 |
|---|---|---|
| IPC(每周期指令数) | PMEVCNTR0(指令) / PMCCNTR | 越高越好,反映流水线效率 |
| CPI(每指令周期数) | PMCCNTR / PMEVCNTR0(指令) | 越低越好,IPC 的倒数 |
| 缓存缺失率 | PMEVCNTR1(缺失) / PMEVCNTR2(访问) | 反映缓存命中情况 |
| 分支预测错误率 | PMEVCNTR3(预测错误) / PMEVCNTR4(分支) | 反映分支预测器效果 |
溢出中断(Overflow Interrupt)
计数器是 32 位或 64 位的,但总会计满。计满之后怎么办?ARMv8 提供了溢出中断机制。当计数器从最大值回绕到 0 时,会置位 PMOVSR(Overflow Status Register)中对应的 bit。如果你提前在 PMINTENSET 中使能了中断,就会触发 IRQ。
溢出中断的典型应用场景有两个:
- 长时间采样:你想统计一段长时间内的总事件数,但计数器可能溢出多次。每次溢出时记录一次,最后累加。
- 性能采样(Perf Sampling):比如每溢出 10000 次 L2 缓存缺失,就记录一次 PC 和调用栈,用来分析热点。
处理溢出中断的流程,我建议你这样写:
// 中断服务例程(伪代码)
void pmu_irq_handler() {
uint64_t ovsr;
// 1. 读取溢出状态
MRS ovsr, PMOVSR_EL0;
// 2. 检查是哪个计数器溢出
if (ovsr & (1 << 0)) {
// 计数器 0 溢出,记录或累加
total_events += (1ULL << 32); // 假设是 32 位计数器
// 3. 清除溢出标志
MSR PMOVSR_EL0, (1 << 0);
}
if (ovsr & (1 << 31)) {
// 周期计数器溢出(bit 31 是周期计数器的溢出位)
total_cycles += (1ULL << 64); // 64 位计数器
MSR PMOVSR_EL0, (1 << 31);
}
// 4. 写 EOI(End of Interrupt)到中断控制器
// 具体写法取决于 GIC 版本
}
避坑指南:我曾经在调试一个网络加速卡时,发现 PMU 中断频繁触发,但读 PMOVSR 总是 0。后来发现是中断控制器没有正确配置——PMU 中断是 PPI(Private Peripheral Interrupt),需要在 GIC 中单独使能。另外,别忘了在中断处理完后写 EOI,否则系统会一直卡在中断里。
PMU 的使能与配置流程
最后,我把 PMU 的完整使能流程整理成一个清单,方便你对照操作:
- 使能 PMU 本身:设置 PMCR_EL0 的 E 位(bit 0)为 1。
- 配置事件计数器:通过 PMSELR + PMXEVTYPER 设置每个计数器监控的事件。
- 使能计数器:通过 PMCNTENSET 置位对应 bit。
- 使能周期计数器:通过 PMCNTENSET 置位 bit 31(周期计数器使能位)。
- 使能溢出中断(可选):通过 PMINTENSET 置位对应 bit。
- 配置中断控制器:在 GIC 中使能 PMU 的 PPI 中断(中断 ID 通常为 23)。
- 开始测量:读取 PMCCNTR 和 PMEVCNTRn 的初始值。
- 结束测量:再次读取计数器值,做差值计算。
我的习惯:我会在测量开始前先清零所有计数器(通过 PMCR 的 C 位和 P 位),确保初始状态一致。这样算出来的差值就是精确的测量结果,不用考虑计数器之前的残留值。