用DWT周期计数器量化中断延迟:从EXTI到ISR首行的真实开销

在实时嵌入式系统中,中断延迟直接影响控制环路的稳定性和响应速度。数据手册通常给出“中断延迟 = 12周期”之类的典型值,但实际系统中,由于Flash等待周期、总线仲裁、中断优先级抢占等因素,真实开销可能远高于此。本文将手把手教你使用Cortex-M内核的DWT(Data Watchpoint and Trace)周期计数器,精确测量从EXTI边沿触发到ISR第一条指令执行的周期数。

1. 原理:DWT周期计数器与中断延迟

Cortex-M3/M4/M7等内核均包含DWT单元,其中的CYCCNT寄存器是一个32位自由运行的周期计数器,每经过一个内核时钟周期递增1。它不受中断影响,精度可达纳秒级。

中断延迟的完整路径包括:

  • 外部信号到达EXTI引脚,经过同步器(2个周期)和边沿检测。
  • EXTI产生中断请求,NVIC进行优先级仲裁。
  • 内核完成当前指令,保存上下文(压栈8个寄存器),跳转到向量表。
  • 执行ISR的第一条指令。

我们无法直接测量EXTI触发瞬间,但可以通过在EXTI中断服务函数的第一行读取CYCCNT,并减去一个已知的参考点(例如在触发信号的同时翻转另一个GPIO,用DWT测量该GPIO翻转的周期数)来间接计算。更实用的方法是:使用一个GPIO作为“触发指示”,在外部信号触发EXTI的同时,该GPIO也产生一个边沿,用示波器测量GPIO边沿到ISR内翻转另一个GPIO的延迟,但本文聚焦于纯软件方法——利用DWT和EXTI的软件触发(NVIC_SetPending)来测量从“中断挂起”到“ISR首行”的延迟。

严格来说,从EXTI引脚到ISR首行的延迟包含硬件同步和仲裁,无法完全用软件测量。但我们可以测量“从NVIC中断挂起到ISR首行”的延迟,这涵盖了内核响应中断的主要开销(压栈、取向量、跳转),对评估实时性极具参考价值。

2. 配置步骤

2.1 使能DWT和CYCCNT

// 使能DWT和CYCCNT
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; // 使能跟踪
DWT->CYCCNT = 0;                                // 清零计数器
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;            // 使能周期计数器

2.2 配置EXTI和NVIC

以STM32F4为例,使用PA0作为EXTI0输入,下降沿触发:

// 使能GPIOA和SYSCFG时钟
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;

// PA0输入,上拉
GPIOA->MODER &= ~GPIO_MODER_MODER0;
GPIOA->PUPDR |= GPIO_PUPDR_PUPDR0_0;

// 连接EXTI0到PA0
SYSCFG->EXTICR[0] &= ~SYSCFG_EXTICR1_EXTI0;
SYSCFG->EXTICR[0] |= SYSCFG_EXTICR1_EXTI0_PA;

// 下降沿触发,不屏蔽
EXTI->FTSR |= EXTI_FTSR_TR0;
EXTI->IMR |= EXTI_IMR_MR0;

// NVIC使能EXTI0中断,优先级设为0(最高)
NVIC_SetPriority(EXTI0_IRQn, 0);
NVIC_EnableIRQ(EXTI0_IRQn);

2.3 测量代码

在ISR首行读取CYCCNT,并保存到全局变量。为了测量“从挂起到ISR首行”的延迟,我们在主循环中软件触发中断:

volatile uint32_t t_start, t_end, latency_cycles;

void EXTI0_IRQHandler(void) {
    t_end = DWT->CYCCNT;  // ISR首行,立即读取
    EXTI->PR = EXTI_PR_PR0; // 清除挂起位
}

int main(void) {
    // 初始化DWT、GPIO、EXTI(略)
    while (1) {
        __disable_irq();          // 关中断,避免干扰
        t_start = DWT->CYCCNT;    // 记录触发前周期
        NVIC_SetPendingIRQ(EXTI0_IRQn); // 软件触发中断
        __enable_irq();           // 开中断,立即响应
        // 等待中断完成
        while (EXTI->PR & EXTI_PR_PR0);
        latency_cycles = t_end - t_start;
        // 此处可打印latency_cycles
        for (volatile int i = 0; i < 1000000; i++); // 延时
    }
}

注意:t_start在关中断后读取,NVIC_SetPendingIRQ后开中断,此时中断立即被响应。t_end在ISR首行读取,因此latency_cycles包含了从开中断到ISR首行的周期数,即内核响应中断的纯开销(压栈、取向量、跳转)。

3. 完整代码示例(基于STM32F4,寄存器版)

#include "stm32f4xx.h"
#include <stdio.h>

volatile uint32_t t_start, t_end, latency_cycles;

void DWT_Init(void) {
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}

void EXTI0_Init(void) {
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
    GPIOA->MODER &= ~GPIO_MODER_MODER0;
    GPIOA->PUPDR |= GPIO_PUPDR_PUPDR0_0;
    SYSCFG->EXTICR[0] &= ~SYSCFG_EXTICR1_EXTI0;
    SYSCFG->EXTICR[0] |= SYSCFG_EXTICR1_EXTI0_PA;
    EXTI->FTSR |= EXTI_FTSR_TR0;
    EXTI->IMR |= EXTI_IMR_MR0;
    NVIC_SetPriority(EXTI0_IRQn, 0);
    NVIC_EnableIRQ(EXTI0_IRQn);
}

void EXTI0_IRQHandler(void) {
    t_end = DWT->CYCCNT;
    EXTI->PR = EXTI_PR_PR0;
}

int main(void) {
    DWT_Init();
    EXTI0_Init();
    SystemCoreClockUpdate();
    while (1) {
        __disable_irq();
        t_start = DWT->CYCCNT;
        NVIC_SetPendingIRQ(EXTI0_IRQn);
        __enable_irq();
        while (EXTI->PR & EXTI_PR_PR0);
        latency_cycles = t_end - t_start;
        // 打印结果(需重定向printf)
        printf("Latency: %lu cycles\n", latency_cycles);
        for (volatile int i = 0; i < 1000000; i++);
    }
}

4. 注意事项

  • DWT在低功耗模式下可能停止计数:进入Sleep或Stop模式后,CYCCNT可能不递增,测量前需确保内核处于运行状态。
  • 编译器优化可能影响测量:t_start和t_end应声明为volatile,避免被优化。同时,__disable_irq()和__enable_irq()之间的代码应尽量短。
  • 中断优先级影响:若存在更高优先级中断,可能抢占测量过程,导致延迟偏大。建议测量时关闭其他中断,或设置最高优先级。
  • Flash等待周期:如果ISR位于Flash且未使能预取/缓存,取指延迟会增加。可将ISR放到RAM中执行(通过__attribute__((section(".RamFunc"))))对比。
  • 测量值包含压栈时间:Cortex-M自动压栈8个寄存器,通常需要12个周期(取决于内存等待)。若使用FPU且开启了惰性压栈,还会增加额外周期。
  • 多次测量取平均:由于流水线和总线仲裁的随机性,单次测量可能有波动,建议连续测量100次取平均值。
  • 注意CYCCNT溢出:32位计数器在168MHz下约25.6秒溢出,测量间隔应远小于此值。

通过上述方法,你可以获得从NVIC挂起到ISR首行的真实周期数,通常为12~20周期(无等待状态)。若加上EXTI同步和仲裁,总延迟可能达到20~30周期。这些数据远比数据手册的“典型值”更有参考价值,能帮助你优化中断优先级、代码放置位置,从而满足严苛的实时性要求。