3. 性能计数器配置实战:ARM PMU配置、Linux perf_event_open接口、计数器使能与重置

性能计数器这东西,说白了就是CPU内部的一堆硬件寄存器。它们能记录各种微架构事件——缓存命中、分支预测失败、指令退休数量等等。我刚开始接触嵌入式性能调优时,总觉得这些计数器是玄学,直到有一次定位一个诡异的性能抖动问题,靠PMU数据才找到真凶。嗯,从那以后我就养成了习惯:调优之前,先看计数器。

这一章我们聚焦三个核心点:ARM PMU的硬件配置、Linux用户态怎么通过perf_event_open接口访问计数器,以及计数器使能与重置的实操细节。

3.1 ARM PMU硬件基础

ARM Cortex-A系列处理器基本都内置了PMU(Performance Monitoring Unit)。它提供一组性能计数寄存器,每个都可以独立配置为监控不同的事件。

我习惯把PMU的关键寄存器分成三类:

寄存器类型 典型名称 作用
事件选择寄存器 PMXEVTYPER_n 指定第n个计数器监控哪个事件
计数寄存器 PMXEVCNTR_n 存储实际计数值,32位或64位
控制/状态寄存器 PMCR, PMCNTENSET, PMOVSR 全局使能、溢出标志、中断控制

举个例子,ARMv8架构下,PMCR的第0位是E位,置1才能全局使能计数器。PMCNTENSET则用来逐个使能每个计数器。你想想看,如果忘了置E位,后面配置再多事件选择也是白搭——计数器根本不跑。

我的经验: 调试PMU时,第一步永远先读PMCR寄存器,确认E位是否真的置上了。我在项目中遇到过好几次,内核驱动初始化顺序有问题,导致E位被意外清零,计数器死活不增长。

3.2 Linux perf_event_open接口

在用户态直接操作PMU寄存器?不现实。ARM架构下访问PMU需要EL1或更高特权级。所以Linux提供了perf_event_open系统调用,帮我们封装了这一切。

这个接口的核心是一个结构体struct perf_event_attr。我每次配置时,重点关注这几个字段:

  • type:指定事件类型,比如PERF_TYPE_HARDWARE、PERF_TYPE_RAW。
  • config:具体事件编码。硬件事件用PERF_COUNT_HW_CPU_CYCLES这类宏,原始事件直接填ARM PMU的事件ID。
  • config1:有些PMU需要额外参数,比如监控哪个CPU核心。
  • disabled:初始是否禁用计数器。我习惯先置1,等所有配置完成后再手动使能。
  • exclude_kernel:是否排除内核态计数。调应用性能时,我通常设为1。

来看一个实际代码片段。我写了一个简单的函数,用来打开一个硬件事件计数器:

#include <linux/perf_event.h>
#include <sys/syscall.h>
#include <unistd.h>

static int perf_event_open(struct perf_event_attr *hw_event,
                           pid_t pid, int cpu, int group_fd,
                           unsigned long flags) {
    return syscall(__NR_perf_event_open, hw_event, pid, cpu, group_fd, flags);
}

int setup_perf_counter(uint32_t event_id) {
    struct perf_event_attr pe;
    memset(&pe, 0, sizeof(pe));
    pe.type = PERF_TYPE_RAW;          // 使用原始事件编码
    pe.size = sizeof(pe);
    pe.config = event_id;             // ARM PMU事件ID
    pe.disabled = 1;                  // 先禁用
    pe.exclude_kernel = 1;            // 只统计用户态
    pe.exclude_hv = 1;                // 排除虚拟机管理程序

    int fd = perf_event_open(&pe, 0, -1, -1, 0);
    if (fd == -1) {
        // 错误处理
        return -1;
    }
    return fd;
}

这里有个细节:pid传0表示监控当前进程,cpu传-1表示监控所有CPU。如果你只想看某个核心,就指定核心编号。

注意: perf_event_open返回的文件描述符,后续的read、ioctl操作都基于它。我曾经犯过一个错:忘记检查返回值,直接拿-1去读计数器,结果程序段错误。嗯,基础检查不能省。

3.3 计数器使能与重置

拿到文件描述符之后,下一步就是使能和重置计数器。Linux提供了ioctl接口来完成这些操作。

我个人习惯的流程是:

  1. 打开事件,得到fd。
  2. 调用ioctl(fd, PERF_EVENT_IOC_RESET, 0)清零计数器。
  3. 调用ioctl(fd, PERF_EVENT_IOC_ENABLE, 0)使能计数。
  4. 执行目标代码段。
  5. 调用ioctl(fd, PERF_EVENT_IOC_DISABLE, 0)停止计数。
  6. 用read(fd, &count, sizeof(count))读取结果。

看一个完整的示例:

#include <linux/perf_event.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdio.h>

uint64_t measure_event(uint32_t event_id) {
    int fd = setup_perf_counter(event_id);
    if (fd < 0) return 0;

    // 重置计数器
    ioctl(fd, PERF_EVENT_IOC_RESET, 0);
    // 使能计数
    ioctl(fd, PERF_EVENT_IOC_ENABLE, 0);

    // 这里放你要测量的代码
    volatile int i;
    for (i = 0; i < 1000000; i++);

    // 停止计数
    ioctl(fd, PERF_EVENT_IOC_DISABLE, 0);

    // 读取结果
    uint64_t count;
    read(fd, &count, sizeof(count));
    close(fd);

    return count;
}

你可能会问:为什么一定要先RESET再ENABLE?其实ENABLE本身不会清零计数器。如果你之前已经跑过一段代码,计数器里残留了旧值,直接ENABLE会导致新旧数据混在一起。我刚开始做性能分析时就被这个坑过——测出来的数值比预期大了一倍,排查了半天才发现是忘了RESET。

关键点: 每次测量前都做RESET+ENABLE两步,这是保证数据干净的基本功。如果你需要连续采样,可以在每次采样周期结束时先DISABLE,读值,再RESET+ENABLE开启下一轮。

3.4 多事件同时监控

ARM PMU通常有6-8个硬件计数器。如果你想同时监控多个事件,比如CPU周期、指令数、缓存未命中、分支预测失败,就需要打开多个perf事件。

这里有个技巧:使用group_fd参数。把第一个事件作为group leader,后续事件作为group成员。这样内核可以保证这些计数器在同一个时间窗口内同时使能和停止。

int group_leader_fd = setup_perf_counter(0x11);  // CPU周期
ioctl(group_leader_fd, PERF_EVENT_IOC_RESET, 0);

// 后续事件加入同一个group
int member_fd = perf_event_open(&pe, 0, -1, group_leader_fd, 0);
// 配置member_fd监控指令数事件0x08

ioctl(group_leader_fd, PERF_EVENT_IOC_ENABLE, 0);
// 执行目标代码
ioctl(group_leader_fd, PERF_EVENT_IOC_DISABLE, 0);

// 分别读取每个fd的值

这样做的好处是:所有计数器的使能/禁用是原子操作,不会出现一个计数器已经开始计数而另一个还没启动的情况。我在做CPI(每指令周期数)分析时,必须用group模式,否则算出来的CPI值偏差很大。

3.5 知识结构总览

下面这张图总结了本章的核心逻辑:从硬件PMU寄存器,到Linux系统调用接口,再到用户态使能/重置/读取的完整链路。

性能计数器配置实战:知识结构图 ARM PMU硬件 PMCR / PMXEVTYPER PMXEVCNTR / PMOVSR Linux内核接口 perf_event_open struct perf_event_attr 用户态操作 ioctl / read / close RESET / ENABLE / DISABLE 核心流程:打开 → 配置attr → RESET → ENABLE → 执行 → DISABLE → READ perf_event_attr关键字段 • type: PERF_TYPE_HARDWARE / RAW • config: 事件编码 • disabled: 初始禁用标志 • exclude_kernel: 排除内核态 • exclude_hv: 排除Hypervisor • size: 结构体大小 常见踩坑点 ⚠ 忘记RESET导致数据污染 ⚠ 未检查fd返回值直接使用 ⚠ 多事件未用group导致时间不同步 ⚠ 忘记置PMCR.E位(内核态) ⚠ 32位计数器溢出未处理

3.6 避坑指南与实用技巧

最后,分享几个我在实际项目中积累的经验:

  • 计数器溢出处理:32位计数器在GHz频率下几秒就溢出了。我一般用PERF_EVENT_IOC_PERIOD设置采样周期,或者用64位计数器(ARMv8.5+支持)。
  • 多核场景:如果你绑定到某个CPU,记得用sched_setaffinity固定线程,否则计数器统计的是随机核心的数据。
  • 事件编码查表:ARM PMU的事件ID不是随便填的。我每次都要翻ARM Architecture Reference Manual,确认事件编码。比如Cortex-A72的L1数据缓存未命中事件是0x03。
  • 性能开销:perf_event_open本身有开销,不适合放在热点路径里频繁调用。我通常是在测量区间外打开,测量区间内只做ENABLE/DISABLE/READ。
我曾经踩过的坑: 有一次在ARMv8平台上,我配置了PERF_TYPE_HARDWARE的PERF_COUNT_HW_CPU_CYCLES,结果读回来的值始终是0。查了半天才发现,那个内核版本对硬件事件的映射有bug,换成PERF_TYPE_RAW + 事件ID 0x11才正常。所以,遇到诡异问题时,试试原始事件编码。