定时器很准,为什么 MCU 的 GPIO 输出边沿还是抖?

抖动来自写 IO 的那一刻,不是定时器:ISR 入口抖动会原样传到引脚(ESP32 实测 462 ns)。改成影子寄存器加 DMA 硬件锁存后,STM32H723ZG 输出沿抖动小于 10 ns。

抖的不是定时器,是「写 IO 的那一刻」。中断里算完直接写 GPIO->ODR,ISR 入口抖动会 1:1 传导到引脚:ESP32 上实测峰峰值 462 ns。把输出改成「CPU 写影子寄存器 + 定时器事件触发 DMA 锁存到 ODR」后,STM32H723ZG 上实测输出沿抖动 < 10 ns。

现象

条件:TIM 定时中断,周期 100 μs;ISR 内读取 ADC、算 PID、按结果写 GPIO 输出寄存器。

观察:定时器本身用示波器测是准的(周期 99.998 μs),但每帧 GPIO 边沿的位置都在变,相邻边沿的间隔前后错开几十到几百纳秒。

同一个现象在两个平台上都能复现:ESP32 上峰峰值 462 ns,STM32H723ZG 上 ISR 入口抖动 σ ≈ 32–37 ns。

原因

在「定时器中断 → ISR 内算完 → 直接写 IO 寄存器」这条路径上,「CPU 算完的那一刻」就是「输出生效的那一刻」。而 CPU 算完的时刻本身是随机变量,它由四件事共同决定:

抖动来源 贡献 机制
CPU 流水线状态 ±10 周期(约 70%) 中断到达时 CPU 处于执行/分支/等待不同状态
DWT 与定时器跨时钟域 ±2 周期(约 15%) TIM1 与 DWT 参考时钟不同源,存在量化台阶
AHB 总线仲裁 ±2 周期(约 10%) TIM1 与 NVIC 同时请求总线
分支预测、store 缓冲 ±1 周期(约 5%) Cortex-M7 分支预测失败约 12 周期惩罚

这些不确定性叠加后无处可去,只能原封不动地传到引脚。所以超频、-O3、提高中断优先级都只能缩小这个数,无法消除——抖动源就在链路正中间。

关键判断:要消掉的是「计算时刻」对「输出时刻」的影响,而不是让计算变得更准时。

怎么做

把计算与输出解耦:CPU 只负责把结果写进一个影子缓冲,由硬件定时器事件触发 DMA 把影子缓冲搬到 GPIO 输出寄存器。

ISR 内(会抖,但无关紧要):
  算完 → 写 SHADOW 影子缓冲(AXI SRAM,DMA 可达)

硬件链路(固定时刻,CPU 不在场):
  TIM1_CC4 匹配事件(≈97.5 μs) → DMAMUX → DMA2 Stream5 → GPIOE_ODR

三个必须做对的点:

1. 影子缓冲要放在 DMA 能访问的内存。 DTCM(0x20000000)是 Cortex-M7 私有总线,DMA Direct Mode 访问会报 DMEIF 并把 EN 清零。影子缓冲放到 AXI SRAM(0x24000000)。

2. DMA Stream 偏移是 0x10 + N × 0x18。 头部 0x00–0x0C 是 LISR/HISR/LIFCR/HIFCR 四个中断寄存器,Stream0 从 0x10 开始。算错会写到别的寄存器上,且不报错。

3. 先禁能再改地址,并轮询等 EN 清零。

/* STM32H723ZG:把 SHADOW(AXI SRAM) 由 TIM1_CC4 触发搬到 GPIOE_ODR
 * Stream5 寄存器偏移(RM0468):CR=0x88 NDTR=0x8C PAR=0x90 M0AR=0x94 FCR=0x9C
 */
#define DMA2_BASE     0x40020400UL
#define GPIOE_ODR     (*(volatile uint32_t *)0x58021014UL)

#define S5CR    (*(volatile uint32_t *)(DMA2_BASE + 0x88))
#define S5NDTR  (*(volatile uint32_t *)(DMA2_BASE + 0x8C))
#define S5PAR   (*(volatile uint32_t *)(DMA2_BASE + 0x90))
#define S5M0AR  (*(volatile uint32_t *)(DMA2_BASE + 0x94))
#define S5FCR   (*(volatile uint32_t *)(DMA2_BASE + 0x9C))

/* 1) 先关闭通道,并轮询等 EN 真正清零;否则 M0AR 写入会被忽略 */
S5CR = 0;
while (S5CR & 1U) { }

/* 2) 源地址必须在 DMA 可达内存:AXI SRAM(0x24000000),不能是 DTCM */
S5M0AR = (uint32_t)&shadow_gpio;          /* 影子缓冲,4 字节对齐 */
S5PAR  = (uint32_t)&GPIOE_ODR;
S5NDTR = 1;                               /* 单次搬运 1 个 32 位字 */

/* 3) DMDIS=0:直通模式(无 FIFO)。置 1 表示启用 FIFO,需额外配阈值 */
S5FCR  = 0;

/* 4) 32 位字宽、存储器→外设、存储器地址自增、传输完成中断,最后使能 */
S5CR = (2U << 11)   /* PSIZE = 32 bit */
     | (2U << 13)   /* MSIZE = 32 bit */
     | (1U << 10)   /* MINC  = 1     */
     | (1U << 6)    /* DIR   = 01   存储器 → 外设 */
     | (1U << 4)    /* TCIE  = 1     */
     | (1U << 0);   /* EN    = 1     */

/* 5) 触发侧:DMAMUX 通道请求源设为 TIM1_CH4,并置 TIM1_DIER 的 CC4DE */

PWM 输出走同一套哲学但换零件:比较值写进 TIM1 的预装载寄存器,由更新事件在周期边界统一装载,因此 PWM 边沿与 CPU 时刻无关。

验证

一句话结论:改前 462 ns → 改后 < 10 ns。

同一段控制程序、同一块 STM32H723ZG 开发板,只把「中断里直写输出寄存器」改成「先写影子缓冲、再由定时器 CC4 事件触发 DMA 搬进输出寄存器」,引脚边沿抖动就从与 ISR 入口同量级降到 10 ns 以下,而这段程序与 CPU 侧的计算量完全没变。

完整对照:

输出方式 输出沿抖动 说明
改前:ISR 内直写 GPIOE_ODR 与 ISR 入口抖动同量级(ESP32 上峰峰值 462 ns) 抖动 1:1 传导到引脚
改后:SHADOW + TIM1_CC4 → DMA5 锁存 < 10 ns(逻辑分析仪实测) 输出时刻由硬件事件决定

同时注意两个指标不能混用:

  • 周期抖动 σ ≈ 32–37 ns:CPU 什么时候开始算(TIM2 输入捕获与固件环形缓冲两种方法互证)。
  • 输出沿抖动 < 10 ns:引脚什么时候翻转。

验证输出沿时不要用调试器 halt,halt 会冻结 TIM1 与 DMA 链路,读到的状态是假的。用 RTT 上报或 GPIO 心跳接逻辑分析仪。

边界

  • 电子凸轮、飞剪这类要求 周期抖动 < 10 ns 的场合不适用:本方案输出沿能到 < 10 ns,但周期抖动仍是 32–37 ns,受 MCU 时钟树限制。
  • DMA 锁存链路依赖内核运行:SWD halt 或复位调试会冻结 TIM1→DMAMUX→DMA 全链,表现为 GPIO 停止更新,这是调试器行为而非故障。
  • 输出时刻锁定在 CC4 事件点,因此输出对计算量的容忍上限是「在 CC4 之前算完」;路由数接近满载(43 条路由时 ISR 执行 4.86–4.99 μs)需留足余量。
  • 本方案未做 SIL3 认证,单核 M7 无法提供双核 Lockstep 与硬件 Fault 收集。

最后验证于 2026-10-28 · STM32H723ZG(RM0468)· arm-none-eabi-gcc · pyOCD(target 必须为 stm32h723xx)

定时器很准,为什么 MCU 的 GPIO 输出边沿还是抖?