嵌入式

STM32 中断与 DMA 实战:让外设自己"跑"起来

主循环里既要跑业务逻辑又要轮询串口和 ADC,结果数据丢包、响应卡顿?问题往往不是主频不够,而是 CPU 被 IO 拖住了。本文从实际项目出发,用中断处理异步事件、用 DMA 实现零拷贝数据搬运。

在做裸机开发时,你可能遇到过这样的情况:主循环里既要跑业务逻辑,又要轮询串口和 ADC 的状态,结果一忙起来就丢数据,按键响应也时灵时不灵。 这时候你需要的不是盲目提高主频,而是让外设自己”跑”起来——用中断处理异步事件,用 DMA 搬运批量数据。

嵌入式系统设计的第一原则:让 CPU 只做它该做的事——运算与决策,数据搬运尽量交给 DMA。

1. 为什么要用中断和 DMA?

要选对 IO 方式,先得搞清楚 CPU 和外设之间是怎么交换数据的。 简单来说有三种方式:轮询、中断、DMA,它们在 CPU 占用和实时性上差别很大。

1.1 轮询、中断与 DMA

三种数据交换方式各有特点:

9600 波特率传输 1 字节 ≈ 10 / 9600 ≈ 1.04ms (1)

如果用轮询发送 100 个字节,CPU 就要原地等待约 104ms; 而用 DMA,配置好之后 CPU 可以继续执行其他任务,整块发完才收到一次中断。

1.2 常见性能瓶颈

根据实际项目经验,裸机程序的 IO 瓶颈通常出现在以下几个方面:

瓶颈类型典型场景优化方向
主循环轮询循环里反复查询串口标志位改用中断或 DMA
中断里做耗时操作在 ISR 里解析协议、打印日志只置标志位,处理放主循环
优先级配置错误高频中断长时间抢占导致丢数据合理分配抢占优先级
数据搬运占用 CPU内存到外设逐字节复制DMA 零拷贝搬运

2. 实战一:外部中断 EXTI 配置

按键、外部脉冲这类异步小事件,最适合用外部中断处理。以 PA0 接按键为例,配置为下降沿触发后, 按下瞬间 CPU 会自动暂停当前任务、跳转到中断服务函数,无需主循环轮询。

配置分三步:开启 GPIO 时钟并设置中断模式 → 在 NVIC 中配置优先级 → 编写中断服务函数。

// 配置 PA0 为下降沿外部中断
void EXTI0_Init(void)
{
    GPIO_InitTypeDef gpio = {0};
    __HAL_RCC_GPIOA_CLK_ENABLE();

    gpio.Pin  = GPIO_PIN_0;
    gpio.Mode = GPIO_MODE_IT_FALLING;
    gpio.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &gpio);

    HAL_NVIC_SetPriority(EXTI0_IRQn, 5, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}

// 回调中只置标志位,耗时处理放主循环
volatile uint8_t key_pressed = 0;

void EXTI0_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == GPIO_PIN_0) {
        key_pressed = 1;
    }
}

中断服务函数要尽量短:这里只把 key_pressed 置 1,消抖和动作响应都放在主循环里完成。 在中断里调用 printf、长延时或复杂协议解析,是新手最常见的性能陷阱。

逻辑分析仪观察中断触发时的 GPIO 翻转波形
图 1:用逻辑分析仪观察中断触发时的 GPIO 翻转波形

3. 实战二:DMA 串口零拷贝收发

串口接收协议帧是 DMA 最典型的用武之地。难点在于数据长度不固定,解决方案是 DMA 接收 + 串口空闲中断(IDLE):当总线持续一个字节时间没有新数据,硬件触发空闲中断, 此时用缓冲区总长度减去 DMA 剩余计数,就得到了这一帧的实际长度。

uint8_t rx_buf[256];
volatile uint16_t rx_len = 0;

// DMA + IDLE 空闲中断实现不定长接收
void UART_DMA_Init(void)
{
    __HAL_RCC_DMA1_CLK_ENABLE();

    hdma_usart1_rx.Instance = DMA1_Stream5;
    hdma_usart1_rx.Init.Channel   = DMA_CHANNEL_4;
    hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_usart1_rx.Init.MemInc    = DMA_MINC_ENABLE;
    hdma_usart1_rx.Init.Mode      = DMA_NORMAL;
    HAL_DMA_Init(&hdma_usart1_rx);
    __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx);

    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
    HAL_UART_Receive_DMA(&huart1, rx_buf, sizeof(rx_buf));
}

// 空闲中断:一帧结束,算出实际长度
void USART1_IRQHandler(void)
{
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        rx_len = sizeof(rx_buf)
               - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
        // 主循环中解析 rx_buf[0 .. rx_len)
        HAL_UART_Receive_DMA(&huart1, rx_buf,
                             sizeof(rx_buf));
    }
}

DMA 配置三要素:外设地址、内存地址、传输数据量。ADC 连续采样用循环模式,协议帧收发用正常模式。

4. 调试与测量工具

中断和 DMA 出问题时往往”看不见摸不着”,趁手的工具能省下大量时间:

排查 DMA 问题优先看三处:时钟是否使能、数据流与通道是否和数据手册对应、缓冲区地址与数据宽度是否匹配。

5. 总结

中断与 DMA 是把 CPU 从 IO 中解放出来的两大法宝。中断适合按键、外部信号这类异步小事件; DMA 适合串口、ADC、SPI 的批量数据搬运,二者配合才能让主循环专注于业务逻辑。

记住三条实战原则:中断服务函数只置标志位,越短越好; DMA 的正常模式与循环模式按场景选择;带 D-Cache 的型号要注意缓冲区的缓存一致性。