一、为什么需要DMA+空闲中断+环形缓冲区

在嵌入式开发中,串口接收不定长数据是常见需求。传统轮询或单字节中断方式会占用大量CPU资源,且难以应对高速数据流。DMA(直接存储器访问)可以自动搬运数据,空闲中断(IDLE)则能在总线空闲时通知CPU,两者结合可实现高效的不定长数据接收。然而,DMA接收的数据需要及时取出处理,否则新数据会覆盖旧数据。环形缓冲区(Ring Buffer)作为经典的数据结构,能有效解耦生产(DMA搬运)与消费(应用处理)速度,并支持溢出检测与处理。

二、原理讲解

2.1 DMA与空闲中断协作机制

  • 配置DMA为循环模式(Circular)或普通模式(Normal),将串口数据寄存器(DR)自动搬运到指定内存数组。
  • 使能串口空闲中断:当串口总线在接收完一帧数据后出现空闲(超过一个字节时间无新数据),硬件置位IDLE标志并触发中断。
  • 在空闲中断中,通过计算DMA剩余传输数量(__HAL_DMA_GET_COUNTER)得到本次接收的数据长度,然后将数据从DMA缓冲区搬运到环形缓冲区。

2.2 环形缓冲区设计

环形缓冲区由固定大小的数组和读、写指针构成。写指针由DMA数据搬运更新,读指针由应用任务更新。关键操作:

  • 写入:将新数据从DMA缓冲区复制到环形缓冲区写指针处,并更新写指针(模缓冲区大小)。
  • 读取:从读指针处取出数据,更新读指针。
  • 溢出判断:当写指针追上读指针时表示缓冲区满,新数据将覆盖旧数据或丢弃,需根据策略处理。

2.3 溢出处理策略

  • 覆盖模式:新数据覆盖最旧数据,保证最新数据可用,但会丢失历史数据。
  • 丢弃模式:拒绝新数据,保护已有数据完整性,但可能丢失最新数据。
  • 动态扩容:不推荐在资源受限的MCU中频繁使用。

本文采用覆盖模式,并在溢出时置位错误标志,供应用层决策。

三、配置步骤(以STM32CubeMX+HAL库为例)

  1. 使能USART1,配置波特率、字长、停止位等参数。
  2. 使能USART1全局中断(NVIC中勾选USART1 global interrupt)。
  3. 配置DMA:选择USART1_RX,模式为Circular(循环模式),数据宽度Byte,优先级中。
  4. 生成代码,HAL库会自动初始化DMA和串口。
  5. 在main.c中调用自定义的初始化函数,启动DMA接收和空闲中断。

四、完整代码示例

4.1 环形缓冲区定义与操作

#define RING_BUF_SIZE 512

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t write_idx;
    volatile uint16_t read_idx;
    volatile uint8_t overflow;  // 溢出标志
} RingBuffer_t;

RingBuffer_t ring_buf;

// 初始化环形缓冲区
void RingBuffer_Init(RingBuffer_t *rb) {
    rb->write_idx = 0;
    rb->read_idx = 0;
    rb->overflow = 0;
}

// 向环形缓冲区写入数据(覆盖模式)
uint16_t RingBuffer_Write(RingBuffer_t *rb, const uint8_t *data, uint16_t len) {
    uint16_t written = 0;
    for (uint16_t i = 0; i < len; i++) {
        uint16_t next_write = (rb->write_idx + 1) % RING_BUF_SIZE;
        if (next_write == rb->read_idx) {
            // 缓冲区满,覆盖最旧数据(读指针后移)
            rb->read_idx = (rb->read_idx + 1) % RING_BUF_SIZE;
            rb->overflow = 1;
        }
        rb->buffer[rb->write_idx] = data[i];
        rb->write_idx = next_write;
        written++;
    }
    return written;
}

// 从环形缓冲区读取数据
uint16_t RingBuffer_Read(RingBuffer_t *rb, uint8_t *data, uint16_t len) {
    uint16_t read = 0;
    while (read < len && rb->read_idx != rb->write_idx) {
        data[read++] = rb->buffer[rb->read_idx];
        rb->read_idx = (rb->read_idx + 1) % RING_BUF_SIZE;
    }
    return read;
}

// 获取当前缓冲区中数据长度
uint16_t RingBuffer_Available(RingBuffer_t *rb) {
    if (rb->write_idx >= rb->read_idx) {
        return rb->write_idx - rb->read_idx;
    } else {
        return RING_BUF_SIZE - (rb->read_idx - rb->write_idx);
    }
}

4.2 DMA与空闲中断初始化

#define DMA_BUF_SIZE 256

uint8_t dma_buf[DMA_BUF_SIZE];  // DMA接收缓冲区

void UART_DMA_IDLE_Init(void) {
    RingBuffer_Init(&ring_buf);
    // 启动DMA接收(循环模式)
    HAL_UART_Receive_DMA(&huart1, dma_buf, DMA_BUF_SIZE);
    // 使能空闲中断
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

4.3 空闲中断处理函数

void USART1_IRQHandler(void) {
    HAL_UART_IRQHandler(&huart1);
    // 检查空闲中断标志
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        // 停止DMA以获取准确剩余计数(可选,但推荐)
        HAL_UART_DMAStop(&huart1);
        uint16_t remaining = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        uint16_t received = DMA_BUF_SIZE - remaining;
        if (received > 0) {
            // 将DMA缓冲区数据写入环形缓冲区
            RingBuffer_Write(&ring_buf, dma_buf, received);
        }
        // 重新启动DMA接收
        HAL_UART_Receive_DMA(&huart1, dma_buf, DMA_BUF_SIZE);
    }
}

4.4 应用层读取示例

void Process_UART_Data(void) {
    uint8_t temp[64];
    uint16_t len;
    while ((len = RingBuffer_Read(&ring_buf, temp, sizeof(temp))) > 0) {
        // 处理接收到的数据,例如解析协议
        // ...
    }
    if (ring_buf.overflow) {
        // 处理溢出事件,如记录日志、重置缓冲区等
        ring_buf.overflow = 0;
    }
}

五、注意事项

  • DMA模式选择:循环模式下DMA自动重装,但空闲中断中需停止DMA再获取计数器,否则计数器可能已重装导致长度计算错误。普通模式则需每次重新启动DMA。
  • 中断优先级:空闲中断优先级应合理设置,避免被高优先级中断长时间阻塞,导致数据丢失。
  • 缓冲区大小:环形缓冲区大小应大于单次最大可能接收的数据量,并留有余量。DMA缓冲区大小建议为2的幂次,便于计算。
  • 临界区保护:环形缓冲区的读写指针在中断和主循环中均被修改,需使用__disable_irq()/__enable_irq()或原子操作保护关键段。
  • 溢出处理:覆盖模式会丢失数据,应用层应通过溢出标志感知并采取相应措施(如请求重传)。
  • 内存对齐:DMA缓冲区建议使用__attribute__((aligned(4)))确保对齐,提高传输效率。
  • 调试技巧:可利用GPIO翻转或打印调试信息观察中断触发频率和数据流。

通过以上方案,可构建一个高效、可靠的不定长串口数据接收框架,适用于大多数嵌入式应用场景。