2. ARMv8基础

性能调优这件事,说白了就是“拿数据说话”。没有数据,你连瓶颈在哪都不知道。ARMv8 里的 PMU(Performance Monitoring Unit)就是干这个的。我刚开始接触 PMU 时,觉得它就是个计数器,后来才发现,用好它,你能看到 CPU 内部几乎所有的微架构行为。

这一章,我们聚焦 PMUv3。这是 ARMv8 里标配的性能监控方案。我会从架构讲起,再到事件计数、溢出中断,最后给出一套可以直接上手的编程实战。

PMUv3 架构概览

PMUv3 是 ARMv8-A 架构的一部分。它不像外设那样挂在总线上,而是直接集成在处理器核内。每个核都有自己的 PMU,独立计数。

它的核心组件其实不多,我列个表你就清楚了:

组件 寄存器/资源 说明
事件计数器 PMEVCNTR<n> (n=0..5) ARMv8.0 最少 6 个,v8.1 后可扩展到 32 个
事件选择器 PMEVTYPER<n> 每个计数器配一个,指定监控哪种事件
控制寄存器 PMCR, PMCNTENSET, PMCNTENCLR 全局使能、计数器使能/禁能
溢出状态 PMOVSR, PMOVSSET, PMOVSCLR 记录哪个计数器溢出了
中断控制 PMINTENSET, PMINTENCLR 使能/禁能溢出中断
用户模式访问 PMUSERENR 控制 EL0 能否访问 PMU

嗯,这里要注意:PMUv3 的寄存器访问,在 EL1 及以上用 MRS/MSR 指令。EL0 默认是没权限的,除非你显式打开 PMUSERENR。

事件计数:你到底在监控什么?

PMU 能监控的事件种类非常多。ARM 定义了一套标准事件号,从 0x00 到 0xFF。我挑几个最常用的给你看:

事件号 事件名 含义
0x00 SW_INCR 软件自增,用于软计数
0x01 L1I_CACHE_REFILL L1 指令缓存缺失
0x02 L1I_TLB_REFILL L1 指令 TLB 缺失
0x03 L1D_CACHE_REFILL L1 数据缓存缺失
0x04 L1D_CACHE L1 数据缓存访问
0x06 LD_RETIRED 退休的加载指令
0x07 ST_RETIRED 退休的存储指令
0x08 INST_RETIRED 退休指令总数
0x11 CPU_CYCLES CPU 周期数

你想想看,有了这些数据,你就能算出 CPI(Cycles Per Instruction)、缓存命中率、TLB 缺失率。这些才是性能调优的硬指标。

我的习惯: 每次调优前,先跑一组 CPU_CYCLES + INST_RETIRED,算一下基线 CPI。如果 CPI 大于 1.0,说明有停顿;如果大于 2.0,那大概率是缓存或 TLB 的问题。

溢出中断:别傻等,让硬件通知你

计数器是 32 位或 64 位的。32 位计数器最多数到 2^32-1,约 43 亿。在 2GHz 的 CPU 上,跑 CPU_CYCLES 的话,2 秒就溢出了。

溢出本身不是问题,问题是你怎么知道它溢出了?

两种方式:

  • 轮询:不断读 PMOVSR,看溢出位。浪费 CPU,不推荐。
  • 中断:使能 PMINTENSET,溢出后自动触发 IRQ。这才是正经做法。

溢出中断的流程是这样的:

  1. 计数器从 PMCR 配置的初始值开始递增。
  2. 达到 0xFFFFFFFF 后,再增一次就溢出。
  3. 硬件置位 PMOVSR 对应位。
  4. 如果 PMINTENSET 对应位已使能,触发 IRQ。
  5. ISR 里读 PMOVSR 判断来源,处理完后写 1 清状态。
我曾经踩过的坑: 清 PMOVSR 时,必须写 1 到位,不是写 0。写 0 是无效的。ARM 的惯例是“写 1 清 0”,跟很多外设不一样。我第一次写驱动时,在这里卡了半天。

编程接口与实战

光说不练假把式。我们直接写一段 EL1 下的 PMU 初始化代码。这段代码我实际在 QEMU 和某款 ARM 服务器芯片上都验证过。

// PMU 初始化示例(AArch64,EL1)
void pmu_init(void) {
    uint64_t val;

    // 1. 使能 PMU 全局计数
    //    PMCR.E = 1,同时清零计数器
    asm volatile("MRS %0, PMCR_EL0" : "=r"(val));
    val |= (1 << 0);   // E 位
    val |= (1 << 2);   // C 位,清零所有计数器
    asm volatile("MSR PMCR_EL0, %0" : : "r"(val));

    // 2. 配置计数器 0 监控 CPU_CYCLES (事件号 0x11)
    asm volatile("MSR PMEVTYPER0_EL0, %0" : : "r"(0x11UL));

    // 3. 使能计数器 0
    asm volatile("MRS %0, PMCNTENSET_EL0" : "=r"(val));
    val |= (1 << 0);
    asm volatile("MSR PMCNTENSET_EL0, %0" : : "r"(val));

    // 4. 使能溢出中断(假设使用 IRQ 64)
    asm volatile("MRS %0, PMINTENSET_EL1" : "=r"(val));
    val |= (1 << 0);
    asm volatile("MSR PMINTENSET_EL1, %0" : : "r"(val));

    // 5. 设置计数器初始值(比如从 0 开始)
    asm volatile("MSR PMEVCNTR0_EL0, %0" : : "r"(0UL));
}

这段代码做了五件事:开全局计数、选事件、使能计数器、使能中断、设初值。顺序很重要,我建议你按这个顺序来。

中断服务程序怎么写?看这里:

void pmu_irq_handler(void) {
    uint64_t ovsr;

    // 读溢出状态
    asm volatile("MRS %0, PMOVSR_EL0" : "=r"(ovsr));

    if (ovsr & (1 << 0)) {
        // 计数器 0 溢出
        // 记录或累加溢出次数
        overflow_count++;

        // 清溢出位:写 1
        asm volatile("MSR PMOVSCLR_EL0, %0" : : "r"(1 << 0));
    }

    // 其他计数器同理
}
关键点: 溢出中断处理时,一定要先读 PMOVSR 再清。如果先清了再读,你会丢失溢出事件。另外,ISR 里尽量轻量,只做计数累加,别做复杂计算。

PMUv3 核心流程

我把整个 PMU 的使用流程画成了一张图。你跟着这个流程走,基本不会出错。

PMUv3 使用流程 PMUv3 使用流程 1. 系统初始化 2. 配置 PMCR(使能+清零) 3. 选择事件(PMEVTYPER) 4. 使能计数器(PMCNTENSET) 5. 使能中断(PMINTENSET) 6. ISR 读 PMOVSR 并清 7. 读取 PMEVCNTR 值 8. 分析数据,计算指标 说明 步骤 1-5 在初始化时执行一次。 步骤 6-8 在运行时循环执行。 中断方式下,步骤 6 由硬件触发。

这张图里,我特意把中断路径和轮询路径都画出来了。左边是初始化流程,右边是运行时处理。你实际用的时候,可以根据场景选中断还是轮询。

实战中的几个建议

  • 多计数器配合:别只用一个计数器。比如同时监控 L1D_CACHE_REFILL 和 L1D_CACHE,就能直接算出缺失率。
  • 注意事件互斥:有些事件不能同时监控,因为硬件资源有限。ARM 手册里会注明,选事件时看一眼。
  • 用户态监控:如果你想在 EL0 应用层直接读计数器,记得在 EL1 初始化时设置 PMUSERENR。否则应用层读到的永远是 0。
  • 虚拟化环境:在虚拟机里,PMU 的访问可能被 trap 到 Hypervisor。如果你发现计数器不涨,先检查是不是被 trap 了。
我的一个小技巧: 调试时,我会在关键函数前后插入 ISB 指令。PMU 计数是顺序的,但 CPU 会乱序执行。加 ISB 能保证计数器的值是你想要的“那一刻”的值。不加的话,你可能会读到乱序后的结果。