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位,后面配置再多事件选择也是白搭——计数器根本不跑。
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接口来完成这些操作。
我个人习惯的流程是:
- 打开事件,得到fd。
- 调用
ioctl(fd, PERF_EVENT_IOC_RESET, 0)清零计数器。 - 调用
ioctl(fd, PERF_EVENT_IOC_ENABLE, 0)使能计数。 - 执行目标代码段。
- 调用
ioctl(fd, PERF_EVENT_IOC_DISABLE, 0)停止计数。 - 用
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。
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系统调用接口,再到用户态使能/重置/读取的完整链路。
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。
PERF_TYPE_HARDWARE的PERF_COUNT_HW_CPU_CYCLES,结果读回来的值始终是0。查了半天才发现,那个内核版本对硬件事件的映射有bug,换成PERF_TYPE_RAW + 事件ID 0x11才正常。所以,遇到诡异问题时,试试原始事件编码。