为什么需要 DMA + 空闲中断?

传统串口接收通常采用逐字节中断(RXNE),每收到一个字节就触发一次中断,CPU 频繁进出中断上下文,在高波特率或大数据量时极易导致丢包。而 DMA(直接内存访问)可以在无需 CPU 干预的情况下将串口接收数据搬运到内存缓冲区,配合空闲中断(IDLE)——当总线空闲超过一个字节时间时触发——即可实现“一次中断接收一帧完整数据”,极大降低 CPU 负载。

核心原理:空闲中断与 DMA 的协同

  • 空闲中断(IDLE):串口检测到 RX 线上持续空闲(即一个字节的传输时间)时,硬件置位 IDLE 标志。注意,它不同于“空闲总线”检测,而是基于字节间隙。
  • DMA 循环模式:将 DMA 配置为循环模式,持续将串口 RX 数据写入内存环形缓冲区。当空闲中断发生时,我们只需计算当前 DMA 写入位置与上次处理位置的差值,即可得到本次帧长度。
  • 关键寄存器USART_ISRIDLE 位,DMA_NDTR(剩余传输计数)用于计算已接收字节数。

基础配置步骤(以 STM32F1/F4 为例)

1. 串口与 DMA 时钟使能

__HAL_RCC_USART2_CLK_ENABLE();
__HAL_RCC_DMA1_CLK_ENABLE();

2. 配置串口参数(115200-8-N-1)

UART_HandleTypeDef huart2;
huart2.Instance = USART2;
huart2.Init.BaudRate = 115200;
huart2.Init.WordLength = UART_WORDLENGTH_8B;
huart2.Init.StopBits = UART_STOPBITS_1;
huart2.Init.Parity = UART_PARITY_NONE;
huart2.Init.Mode = UART_MODE_TX_RX;
huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE;
HAL_UART_Init(&huart2);

3. 配置 DMA 接收(循环模式)

DMA_HandleTypeDef hdma_usart2_rx;
hdma_usart2_rx.Instance = DMA1_Channel6;  // 根据芯片映射
hdma_usart2_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_usart2_rx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_usart2_rx.Init.MemInc = DMA_MINC_ENABLE;
hdma_usart2_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_usart2_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_usart2_rx.Init.Mode = DMA_CIRCULAR;  // 循环模式关键
hdma_usart2_rx.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_usart2_rx);
__HAL_LINKDMA(&huart2, hdmarx, hdma_usart2_rx);

4. 使能空闲中断并启动 DMA 接收

__HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);  // 使能空闲中断
HAL_UART_Receive_DMA(&huart2, rx_buffer, RX_BUFFER_SIZE);  // 启动 DMA

5. 中断服务函数处理

void USART2_IRQHandler(void) {
    if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart2);  // 必须先清除标志
        uint16_t len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart2_rx);
        // 此处 len 即为本次接收到的字节数(从上次处理位置到当前)
        process_frame(rx_buffer, len);  // 用户处理函数
    }
}

深度优化策略

1. 处理环形缓冲区边界

当 DMA 写入位置跨越缓冲区末尾时,数据会被分成两段。优化方案:使用双缓冲区交替内存拷贝拼接。推荐双缓冲:

#define BUF_SIZE 256
uint8_t rx_buf[2][BUF_SIZE];
volatile uint8_t active_buf = 0;
// 在空闲中断中切换 active_buf,并重新配置 DMA 目标地址

2. 防止 DMA 溢出

当数据帧长度超过缓冲区大小时,DMA 会覆盖未处理数据。优化:在 DMA 传输完成中断(TC)中及时处理或扩大缓冲区,并设置帧长度上限检查。

3. 低功耗与中断优先级

  • 在空闲中断中尽快处理数据,避免长时间占用中断;
  • 将串口中断优先级设为最高(NVIC),确保实时性;
  • 若进入低功耗模式,需在唤醒后重新初始化 DMA 和空闲中断。

4. 使用 HAL 库的扩展函数

HAL 库提供 HAL_UARTEx_ReceiveToIdle_DMA(),它自动处理空闲中断和回调,简化代码:

HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buffer, RX_BUFFER_SIZE);
// 回调函数:HAL_UARTEx_RxEventCallback
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
    // Size 为本次接收长度
}

完整代码示例(基于 HAL 库,STM32F4)

// 全局变量
#define RX_BUF_SIZE 512
uint8_t rx_buffer[RX_BUF_SIZE];
volatile uint16_t rx_len = 0;

// 初始化函数
void UART_Init(void) {
    // 1. 使能时钟
    __HAL_RCC_USART2_CLK_ENABLE();
    __HAL_RCC_DMA1_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();

    // 2. GPIO 配置(PA2 TX, PA3 RX)
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_2 | GPIO_PIN_3;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF7_USART2;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 3. 串口配置(略,见上文)
    // 4. DMA 配置(略,见上文)
    // 5. 使能空闲中断
    __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE);
    // 6. 启动 DMA 接收
    HAL_UART_Receive_DMA(&huart2, rx_buffer, RX_BUF_SIZE);
    // 7. 设置中断优先级
    HAL_NVIC_SetPriority(USART2_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(USART2_IRQn);
}

// 中断处理
void USART2_IRQHandler(void) {
    if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart2);
        // 计算接收长度
        rx_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart2_rx);
        // 注意:若使用循环模式,需要记录上次位置,这里简化处理
        // 实际应用中应使用环形索引
        process_data(rx_buffer, rx_len);
        // 重新启动 DMA(循环模式无需重启,但若用普通模式需重新调用)
    }
}

// 数据处理示例
void process_data(uint8_t *data, uint16_t len) {
    // 例如:回显
    HAL_UART_Transmit(&huart2, data, len, 1000);
}

注意事项

  • 清除 IDLE 标志:必须先读 USART_ISR 再写 USART_ICR,或直接调用 __HAL_UART_CLEAR_IDLEFLAG(),否则会反复进入中断。
  • DMA 计数器是递减的__HAL_DMA_GET_COUNTER 返回剩余未传输数量,因此已接收长度 = 缓冲区大小 - 计数器值。
  • 环形缓冲区索引:在循环模式下,每次接收后需要记录当前 DMA 位置(通过计数器计算),下次接收时用新位置减去旧位置,并处理回绕。
  • 中断与主循环竞争:若主循环也访问接收缓冲区,需加临界区保护或使用无锁环形队列。
  • 调试技巧:在中断中临时翻转 GPIO 或使用逻辑分析仪观察时序,确保帧间隔正确。

总结

通过 DMA + 空闲中断,STM32 串口接收不定长数据变得高效且可靠。本文从原理到实现,再到优化,给出了完整方案。实际项目中,建议结合具体芯片型号(如 F1/F4/H7)查阅参考手册,并灵活运用 HAL 库的扩展 API。掌握这一技术,你将能轻松应对各种串口通信场景。