3. PMU事件编程:事件选择与使能、计数模式配置、用户空间访问权限

性能监控单元(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));
我的习惯:每次配置事件前,先把PMEVTYPERn_EL0清零再写。因为复位后这个寄存器的值是UNKNOWN(未定义),直接写位域可能出问题。我在项目里吃过这个亏——计数器死活不涨,查了半天发现是残留位在作怪。

3.1.2 计数器使能:PMCNTENSET_EL0

事件选好了,计数器还没开始工作。你得通过PMCNTENSET_EL0寄存器把对应的计数器使能位拉高。这个寄存器是“置位型”的——写1使能,写0无效。

// 使能计数器0
asm volatile("msr PMCNTENSET_EL0, %0" : : "r" (1UL << 0));

对应的,PMCNTENCLR_EL0用来关闭计数器。写1到对应位,计数器就停了。

注意:使能计数器之前,务必确认PMCR_EL0的E位(使能位)已经置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);
我曾经踩过的坑:在某个项目里,我在用户态直接写PMEVTYPER0_EL0,结果触发了Undefined Instruction异常。查了半天才发现,PMUSERENR_EL0只允许读操作,写操作仍然需要EL1权限。所以记住:用户态只能读,不能写。配置工作必须交给内核驱动。

3.4 完整配置流程

把上面这些串起来,一个典型的PMU配置流程是这样的:

  1. 复位计数器:写PMCR_EL0的P位(复位所有计数器)和C位(复位Cycle计数器)。
  2. 选择事件:写PMEVTYPERn_EL0,指定每个计数器要监控的事件。
  3. 配置计数模式:设置PMCR_EL0的LP位(位宽)、DP位(禁止Cycle计数分频)等。
  4. 使能全局计数:设置PMCR_EL0的E位。
  5. 使能单个计数器:写PMCNTENSET_EL0,使能需要的计数器。
  6. 配置用户态访问:写PMUSERENR_EL0,允许用户态读取。
  7. 开始计数:硬件自动开始,无需额外触发。

下面这张图总结了整个流程:

PMU事件编程完整流程 1. 复位计数器 2. 选择事件 3. 配置计数模式 4. 使能全局计数 5. 使能单个计数器 6. 配置用户态访问 7. 开始计数(硬件自动运行) 关键寄存器一览 • PMCR_EL0:全局控制(使能、复位、位宽) • PMEVTYPERn_EL0:事件选择(每个计数器一个) • PMCNTENSET_EL0 / PMCNTENCLR_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;
}
我的建议:在实际项目中,不要直接在用户态嵌入汇编读计数器。更好的做法是封装成库函数,或者通过mmap把PMU寄存器映射到用户态。这样代码更干净,也更容易移植到不同平台。