3. 性能调优核心概念
性能监控单元(PMU)是ARMv8架构中我最常打交道的模块之一。说白了,它就是芯片内部的一组硬件计数器,能帮你数清楚CPU到底在干什么——Cache命中率多少、分支预测错了多少次、指令发射了几条。没有它,性能调优就像蒙着眼睛开车。
这一节,我们重点讲PMU的编程方法。我会从事件选择开始,一步步带你走到用户态访问权限配置。嗯,这部分坑不少,我踩过,所以会重点提醒你。
3.1 PMU事件选择与使能
ARMv8的PMU支持的事件种类非常多。以Cortex-A72为例,可监控的事件超过200种。但硬件计数器只有6个(通常)。所以你得先选好要监控哪些事件,再告诉PMU去数。
3.1.1 事件选择寄存器:PMEVTYPERn_EL0
每个计数器对应一个事件类型寄存器。比如计数器0对应PMEVTYPER0_EL0,计数器1对应PMEVTYPER1_EL0,以此类推。写入这个寄存器,就是告诉硬件:“你帮我数这个事件。”
寄存器布局很简单:
| 位域 | 名称 | 描述 |
|---|---|---|
| [15:0] | evtCount | 事件编号(ARM架构定义的事件ID) |
| [26] | MT | 多线程过滤(1=仅计数当前线程) |
| [31] | NSH | 非安全空间过滤(1=仅计数非安全状态) |
举个例子,我想让计数器0监控L1数据Cache的缺失次数。ARM架构给这个事件分配的编号是0x03。代码这样写:
// 选择事件:L1数据Cache缺失(事件ID = 0x03)
uint64_t val = 0x03; // evtCount = 3
// 如果需要只统计当前线程,可以设置MT位
// val |= (1UL << 26);
// 如果需要只统计非安全世界,可以设置NSH位
// val |= (1UL << 31);
asm volatile("msr PMEVTYPER0_EL0, %0" : : "r" (val));
3.1.2 计数器使能:PMCNTENSET_EL0
事件选好了,计数器还没开始工作。你得通过PMCNTENSET_EL0寄存器把对应的计数器使能位拉高。这个寄存器是“置位型”的——写1使能,写0无效。
// 使能计数器0
asm volatile("msr PMCNTENSET_EL0, %0" : : "r" (1UL << 0));
对应的,PMCNTENCLR_EL0用来关闭计数器。写1到对应位,计数器就停了。
3.2 计数模式配置
ARMv8 PMU支持两种计数模式:溢出计数和精确采样。理解它们的区别,是配置PMU的关键。
3.2.1 溢出计数模式
这是最常用的模式。计数器从0开始,每发生一次事件就加1。加到最大值(对于64位计数器是2^64-1)后,下一次计数会溢出,同时触发PMU中断(如果使能了)。
配置要点:
- 通过PMCR_EL0的LP位选择计数器位宽:0=32位,1=64位。我个人习惯用64位,省得频繁处理溢出。
- 通过PMINTENSET_EL1使能溢出中断(需要在EL1或更高特权级配置)。
- 溢出后,计数器值会回绕到0,继续计数。你需要在中断处理函数里记录溢出次数。
// 配置PMCR_EL0:64位计数器,全局使能
uint64_t pmcr = 0;
pmcr |= (1UL << 0); // E位:全局使能
pmcr |= (1UL << 7); // LP位:64位计数
asm volatile("msr PMCR_EL0, %0" : : "r" (pmcr));
3.2.2 精确采样模式
这个模式更高级。它不光计数,还能记录事件发生时CPU的精确状态——比如PC指针、当前指令的虚拟地址。这对分析热点代码非常有用。
配置精确采样需要用到PMEVTYPERn_EL0的P位(第31位)。置1后,计数器会在事件发生时,把现场信息存入一个专门的采样缓冲区。
3.3 用户空间访问权限
这是很多工程师容易忽略的地方。默认情况下,PMU寄存器只能在EL1(内核态)访问。但性能分析工具(比如perf)需要在用户态读取计数器。怎么办?
3.3.1 PMUSERENR_EL0寄存器
ARMv8提供了一个专门的寄存器——PMUSERENR_EL0,用来控制用户态对PMU的访问权限。它只能在EL1写入,但可以在EL0读取。
| 位域 | 名称 | 描述 |
|---|---|---|
| [0] | EN | 允许EL0访问PMU寄存器(PMCR_EL0、PMEVTYPERn_EL0等) |
| [1] | CR | 允许EL0通过PMCR_EL0复位计数器 |
| [2] | ER | 允许EL0通过PMCR_EL0使能计数器 |
| [3] | SW | 允许EL0通过PMCCFILTR_EL0配置Cycle计数器过滤 |
你想想看,如果只设EN位,用户态可以读计数器,但不能复位也不能使能。这其实是最安全的做法——内核负责配置,用户态只负责读。
// 内核态代码:允许用户态读取PMU计数器
uint64_t val = 0;
val |= (1UL << 0); // EN位:允许EL0访问
asm volatile("msr PMUSERENR_EL0, %0" : : "r" (val));
3.3.2 用户态读取计数器
一旦PMUSERENR_EL0的EN位置1,用户态程序就可以直接读取PMU计数器了。比如读Cycle计数器:
// 用户态代码:读取Cycle计数器
uint64_t cycles;
asm volatile("mrs %0, PMCCNTR_EL0" : "=r" (cycles));
printf("CPU cycles: %lu\n", cycles);
3.4 完整配置流程
把上面这些串起来,一个典型的PMU配置流程是这样的:
- 复位计数器:写PMCR_EL0的P位(复位所有计数器)和C位(复位Cycle计数器)。
- 选择事件:写PMEVTYPERn_EL0,指定每个计数器要监控的事件。
- 配置计数模式:设置PMCR_EL0的LP位(位宽)、DP位(禁止Cycle计数分频)等。
- 使能全局计数:设置PMCR_EL0的E位。
- 使能单个计数器:写PMCNTENSET_EL0,使能需要的计数器。
- 配置用户态访问:写PMUSERENR_EL0,允许用户态读取。
- 开始计数:硬件自动开始,无需额外触发。
下面这张图总结了整个流程:
3.5 实战:一个简单的性能计数工具
最后,我给你一个完整的例子。假设我们要在用户态监控L1数据Cache缺失次数。内核驱动已经配置好了PMU并开放了用户态访问权限。用户态程序只需要:
#include <stdio.h>
#include <stdint.h>
static inline uint64_t read_pmu_counter(int n) {
uint64_t val;
switch (n) {
case 0: asm volatile("mrs %0, PMEVCNTR0_EL0" : "=r" (val)); break;
case 1: asm volatile("mrs %0, PMEVCNTR1_EL0" : "=r" (val)); break;
// ... 更多计数器
default: val = 0;
}
return val;
}
int main() {
uint64_t start, end;
// 读取计数前值
start = read_pmu_counter(0);
// 执行被测代码
volatile int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += i;
}
// 读取计数后值
end = read_pmu_counter(0);
printf("L1 data cache misses: %lu\n", end - start);
return 0;
}