用DWT周期计数器测量中断响应延迟:从GPIO翻转看NVIC抢占的实际开销

1. 为什么需要实测中断延迟

在STM32等Cortex-M MCU上,中断响应延迟由硬件自动完成:保存上下文、取向量、跳转ISR。数据手册通常给出“12个周期”之类的理论值,但实际系统中还受以下因素影响:

  • NVIC抢占优先级配置与当前执行状态
  • Flash等待周期(WS)与预取缓冲区状态
  • 总线矩阵仲裁与DMA活动
  • 编译器生成的ISR入口代码

因此,实测比查手册更有意义。DWT(Data Watchpoint and Trace)中的CYCCNT计数器以CPU时钟为基准递增,精度达1个周期,是测量短延时的理想工具。

2. DWT周期计数器原理

Cortex-M3/M4/M7内核的DWT单元包含一个32位自由运行计数器CYCCNT,使能后每个内核时钟周期加1。关键寄存器:

  • DWT->CTRL:bit0使能CYCCNT
  • DWT->CYCCNT:当前计数值
  • CoreDebug->DEMCR:bit24使能TRCENA(DWT总开关)

注意:CYCCNT在调试器连接时可能被复位,且部分低端型号(如Cortex-M0)不支持DWT。

3. 测量方案设计

思路:在外部中断引脚上产生一个边沿,同时在ISR入口翻转另一个GPIO,用逻辑分析仪或示波器观察两路信号的时间差。但更精确的方法是纯软件测量:

  1. 在主循环中等待中断标志(或使用EXTI触发)。
  2. 在ISR的第一条语句读取DWT->CYCCNT并保存。
  3. 在触发中断前读取一次CYCCNT作为起点。
  4. 两者差值即为从触发到ISR执行的总周期数。

但“触发”时刻难以精确捕获。改进:用定时器输出一个脉冲到GPIO,同时触发EXTI;在ISR中翻转另一个GPIO并记录CYCCNT。用示波器测量两个GPIO边沿的物理延迟,再与CYCCNT差值对照,可分离出GPIO翻转本身的开销。

本文采用纯软件自测法:利用一个高优先级定时器中断作为“触发源”,在定时器ISR中翻转GPIO并记录CYCCNT,然后立即软件触发一个低优先级EXTI中断(通过NVIC_SetPendingIRQ),在EXTI ISR入口读取CYCCNT。两者差值包含:中断挂起、NVIC抢占、上下文保存、取向量、跳转等全部开销。

4. 配置步骤

4.1 使能DWT

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

4.2 配置GPIO与EXTI

以STM32F4为例,PA0作为EXTI0输入,PA1作为调试输出。

void GPIO_EXTI_Init(void)
{
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    GPIOA->MODER &= ~(3U << 0); // PA0 输入
    GPIOA->MODER |=  (1U << 2); // PA1 输出

    RCC->APB2ENR |= RCC_APB2ENR_SYSCFGEN;
    SYSCFG->EXTICR[0] &= ~SYSCFG_EXTICR1_EXTI0;
    EXTI->IMR |= EXTI_IMR_MR0;
    EXTI->RTSR |= EXTI_RTSR_TR0;

    NVIC_SetPriority(EXTI0_IRQn, 2);
    NVIC_EnableIRQ(EXTI0_IRQn);
}

4.3 定时器触发源

使用TIM2更新中断,优先级设为0(高于EXTI0)。

void TIM2_Init(void)
{
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    TIM2->PSC = 84 - 1;      // 1MHz计数
    TIM2->ARR = 1000 - 1;    // 1ms周期
    TIM2->DIER |= TIM_DIER_UIE;
    TIM2->CR1 |= TIM_CR1_CEN;
    NVIC_SetPriority(TIM2_IRQn, 0);
    NVIC_EnableIRQ(TIM2_IRQn);
}

5. 测量代码

volatile uint32_t t_start, t_end, latency_cycles;
volatile uint32_t isr_entry_cycles;

void TIM2_IRQHandler(void)
{
    TIM2->SR &= ~TIM_SR_UIF;
    t_start = DWT->CYCCNT;
    GPIOA->ODR ^= (1U << 1);          // 调试翻转
    NVIC_SetPendingIRQ(EXTI0_IRQn);   // 软件触发EXTI0
}

void EXTI0_IRQHandler(void)
{
    isr_entry_cycles = DWT->CYCCNT;   // ISR第一条有效语句
    EXTI->PR = EXTI_PR_PR0;
    t_end = isr_entry_cycles;
    latency_cycles = t_end - t_start;
    GPIOA->ODR ^= (1U << 1);
}

主循环中打印latency_cycles。注意:NVIC_SetPendingIRQ本身有函数调用开销,应将其内联或直接写NVIC->STIR = EXTI0_IRQn。

NVIC->STIR = EXTI0_IRQn;  // 直接触发,减少开销

6. 结果分析与注意事项

在STM32F407@168MHz、Flash 5WS、开启预取的情况下,实测latency_cycles约为18~24周期,对应约107~143ns。其中包含:

  • NVIC->STIR写入到中断挂起:约2~3周期
  • NVIC抢占仲裁:约2周期
  • 上下文保存(8个寄存器压栈):取决于总线,约6~8周期
  • 取向量与跳转:约4~6周期
  • ISR入口压栈与第一条指令:约4周期

注意事项:

  • 测量时关闭编译器优化对DWT->CYCCNT读取的重排,使用volatile或内存屏障。
  • 确保t_start读取后到NVIC->STIR之间没有其他中断插入,可将全局中断关闭再打开,但会引入额外开销。
  • CYCCNT在调试暂停时可能停止,量产测试时不要连接调试器。
  • 不同优先级配置会改变抢占延迟,建议测试多组优先级。
  • Flash等待周期对取指影响显著,可将ISR放在RAM中运行对比。

7. 优化建议

  • 将ISR放入RAM(__attribute__((section(".ramfunc"))))可减少Flash等待。
  • 使用__attribute__((interrupt))或汇编入口减少编译器插入的额外指令。
  • 避免在ISR入口调用函数,直接操作寄存器。
  • 合理设置NVIC优先级分组,减少抢占仲裁层级。

通过DWT周期计数器,开发者可以量化中断延迟,为实时性设计提供数据支撑。