一、为什么DMA+空闲中断如此流行

在STM32开发中,接收不定长串口数据是高频需求。传统逐字节中断方式CPU开销大,而HAL库提供的HAL_UARTEx_ReceiveToIdle_DMA()(或早期手动组合DMA+IDLE中断)能一次性把数据搬进内存,空闲中断再通知“一帧结束”,兼顾效率与实时性。

典型用法如下:

uint8_t rx_buf[256];
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf));

在回调中处理:

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance == USART1) {
        process_frame(rx_buf, Size);
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, sizeof(rx_buf));
    }
}

看起来很美,但隐患就藏在“重新启动接收”与“处理数据”的时序里。

二、溢出风险从何而来

2.1 单缓冲区的致命窗口

上述代码中,rx_buf只有一份。回调里先process_frame()再重启DMA。若处理耗时较长,而对方持续发数据,DMA此时处于停止状态,新数据直接丢失(ORE溢出)。

更隐蔽的是:若在回调中先重启DMA、后处理数据,DMA会立刻向同一块rx_buf写入新数据,覆盖尚未处理完的旧帧,造成数据错乱。

2.2 半传输与空闲中断的竞争

当一帧长度超过缓冲区一半时,DMA半传输中断与空闲中断可能交错触发。若回调中未区分事件类型,容易重复处理或漏处理。

2.3 高波特率下的“来不及”

115200bps下每字节约87µs,1KB数据仅约87ms。若主循环或回调中有阻塞操作(如打印日志、Flash擦写),溢出几乎必然发生。

结论:单缓冲区方案只适合“低速、短帧、处理快”的场景,工程中必须改造。

三、环形缓冲改造思路

核心思想:DMA只负责把数据搬进一个足够大的环形缓冲区,空闲中断只更新写指针,数据处理与接收解耦。

  • DMA目标地址始终指向环形缓冲区中的“当前写位置”,避免覆盖未读数据。
  • 空闲中断到来时,根据DMA剩余计数(__HAL_DMA_GET_COUNTER)计算本帧长度,推进写指针。
  • 应用层从读指针取数据,读写指针分离,天然支持生产者-消费者模型。

为简化实现,这里采用“双缓冲+环形队列”的折中方案:DMA在两个半区交替接收,空闲中断将整帧拷贝进软件环形队列,再立即重启DMA。这样既避免覆盖,又保留HAL的易用性。

四、CubeMX配置步骤

  1. 使能USART1,模式Asynchronous,波特率115200。
  2. 在NVIC中使能USART1全局中断。
  3. 在DMA Settings中添加USART1_RX,模式Circular(循环模式),优先级Medium。
  4. 生成代码后,在main.c中调用初始化接收函数。

注意:使用HAL_UARTEx_ReceiveToIdle_DMA时,DMA建议配置为Circular,HAL会自动处理半传输/传输完成事件。

五、完整代码示例

5.1 环形队列定义

#define RING_SIZE 1024

typedef struct {
    uint8_t  buf[RING_SIZE];
    volatile uint16_t head;   // 写指针
    volatile uint16_t tail;   // 读指针
} ring_t;

static ring_t g_ring;
static uint8_t g_dma_buf[256];

static inline uint16_t ring_used(void)
{
    return (g_ring.head - g_ring.tail) & (RING_SIZE - 1);
}

static bool ring_push(const uint8_t *data, uint16_t len)
{
    if (len > RING_SIZE - 1 - ring_used()) return false;
    for (uint16_t i = 0; i < len; i++) {
        g_ring.buf[g_ring.head] = data[i];
        g_ring.head = (g_ring.head + 1) & (RING_SIZE - 1);
    }
    return true;
}

uint16_t ring_pop(uint8_t *out, uint16_t max_len)
{
    uint16_t n = 0;
    while (n < max_len && g_ring.tail != g_ring.head) {
        out[n++] = g_ring.buf[g_ring.tail];
        g_ring.tail = (g_ring.tail + 1) & (RING_SIZE - 1);
    }
    return n;
}

5.2 启动接收与回调

void uart_rx_start(void)
{
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_dma_buf, sizeof(g_dma_buf));
}

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart->Instance != USART1) return;

    /* 先入队,再重启DMA,避免覆盖 */
    if (!ring_push(g_dma_buf, Size)) {
        /* 队列满:记录溢出计数,丢弃本帧 */
        g_overflow_cnt++;
    }
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_dma_buf, sizeof(g_dma_buf));
}

5.3 应用层读取

void app_poll(void)
{
    uint8_t tmp[64];
    uint16_t n;
    while ((n = ring_pop(tmp, sizeof(tmp))) > 0) {
        parse_protocol(tmp, n);   // 非阻塞解析
    }
}

六、关键注意事项

  • 回调中不做耗时操作:只做入队和重启DMA,解析放到主循环。
  • 队列容量要留余量:按“最大帧长 × 突发帧数”估算,建议≥2倍。
  • 读写指针用volatile:回调与主循环共享,编译器优化可能出错。
  • 单生产者单消费者无需关中断:若多任务访问,需加临界区保护。
  • 溢出要可观测:增加g_overflow_cnt,便于现场定位。
  • 半传输事件:HAL_UARTEx_RxEventCallback会区分HAL_UART_RXEVENT_HT与IDLE,本方案统一按Size入队即可,但需确认HAL版本行为一致。
  • DMA循环模式与重启:循环模式下重复调用ReceiveToIdle_DMA是安全的,HAL会重置计数器。

七、总结

DMA+空闲中断是STM32串口接收的利器,但单缓冲区方案在突发数据下存在覆盖与丢包风险。通过引入环形队列,把“接收”与“处理”解耦,并让DMA只写入临时缓冲、回调快速入队,可显著提升系统鲁棒性。改造后代码结构清晰、易于扩展,适合作为产品级串口框架的基础。