一、原理讲解:为什么会出现半包与粘包?

串口空闲中断(IDLE)配合DMA是接收不定长数据的经典方案:DMA自动搬运数据到缓冲区,当总线空闲时触发IDLE中断,此时读取DMA剩余计数即可知道本帧长度。但实际应用中,以下场景会导致问题:

  • 半包:一帧数据尚未接收完整,总线出现短暂空闲(如发送方分多次发送),IDLE中断提前触发,应用层误认为帧结束。
  • 粘包:两帧数据间隔极短,DMA连续搬运,IDLE中断只触发一次,应用层将两帧合并处理。

根本原因在于:IDLE中断仅表示“当前总线空闲”,并不保证数据帧的完整性。要解决此问题,需结合协议层(如帧头帧尾、长度字段)或超时机制,并在驱动层设计合理的缓冲管理策略。

二、零拷贝环形缓冲设计

传统做法是在IDLE中断中把DMA缓冲区数据拷贝到应用缓冲区,再处理。频繁拷贝增加CPU负载,且难以应对高速数据。零拷贝环形缓冲的核心思想:

  • DMA直接写入环形缓冲区的“写区域”,应用层从“读区域”取数据,无需中间拷贝。
  • 利用环形缓冲的“写指针”和“读指针”管理数据边界,IDLE中断仅更新写指针,应用层异步处理。
  • 通过双缓冲或半满/全满中断可进一步降低延迟,但本文聚焦IDLE+环形缓冲的简洁实现。

关键点:环形缓冲区大小需大于最大帧长,且DMA配置为循环模式(Circular),使DMA在缓冲区末尾自动回绕,避免频繁重启DMA。

三、CubeMX配置步骤

  1. 使能USART1,模式为Asynchronous,波特率115200。
  2. 在NVIC Settings中使能USART1全局中断。
  3. 在DMA Settings中添加USART1_RX的DMA请求,模式设为Circular,数据宽度Byte,优先级Medium。
  4. 生成代码,HAL库会自动初始化DMA和串口。

四、完整代码示例

4.1 环形缓冲定义与初始化

#define RING_BUF_SIZE 512

typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t write_idx;   // DMA写指针(由IDLE中断更新)
    volatile uint16_t read_idx;    // 应用读指针
} ring_buffer_t;

ring_buffer_t uart_rb;

void ring_buffer_init(ring_buffer_t *rb) {
    rb->write_idx = 0;
    rb->read_idx = 0;
}

// 获取可读数据长度
uint16_t ring_buffer_available(ring_buffer_t *rb) {
    return (rb->write_idx - rb->read_idx + RING_BUF_SIZE) % RING_BUF_SIZE;
}

// 获取可写空间长度
uint16_t ring_buffer_space(ring_buffer_t *rb) {
    return RING_BUF_SIZE - 1 - ring_buffer_available(rb);
}

4.2 启动DMA接收与IDLE中断

void uart_dma_start(void) {
    ring_buffer_init(&uart_rb);
    // 启动DMA循环接收,直接写入环形缓冲区
    HAL_UART_Receive_DMA(&huart1, uart_rb.buffer, RING_BUF_SIZE);
    // 使能空闲中断
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

4.3 IDLE中断处理(在stm32f1xx_it.c中)

void USART1_IRQHandler(void) {
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        // 获取DMA剩余计数,计算本次写入长度
        uint16_t remaining = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        uint16_t write_pos = RING_BUF_SIZE - remaining;
        // 更新写指针(考虑回绕)
        uart_rb.write_idx = write_pos % RING_BUF_SIZE;
        // 可在此处设置标志通知应用层
    }
    HAL_UART_IRQHandler(&huart1);
}

4.4 应用层读取数据(零拷贝)

// 从环形缓冲读取一帧数据(需结合协议判断完整性)
uint16_t uart_read_frame(uint8_t *out, uint16_t max_len) {
    uint16_t avail = ring_buffer_available(&uart_rb);
    if (avail == 0) return 0;
    // 简单示例:直接读取所有可用数据(实际应根据协议解析)
    uint16_t len = (avail < max_len) ? avail : max_len;
    for (uint16_t i = 0; i < len; i++) {
        out[i] = uart_rb.buffer[uart_rb.read_idx];
        uart_rb.read_idx = (uart_rb.read_idx + 1) % RING_BUF_SIZE;
    }
    return len;
}

五、避免半包与粘包的策略

  • 协议层校验:在应用层解析时,检查帧头、帧尾或长度字段,只有完整帧才处理。例如定义帧格式为[0xAA][LEN][DATA...][CRC],接收后先找帧头,再根据LEN判断是否收全。
  • 超时机制:若IDLE中断后一段时间内无新数据,可认为帧结束。可在IDLE中断中启动定时器,超时后标记帧完整。
  • 双缓冲或半满中断:对于高速数据,可启用DMA半满/全满中断,配合IDLE中断更精确地管理数据边界。
  • 环形缓冲大小:确保缓冲区大于最大帧长,避免DMA回绕覆盖未处理数据。

六、注意事项

  • DMA循环模式下,__HAL_DMA_GET_COUNTER返回的是剩余传输次数,计算写指针时需用RING_BUF_SIZE - remaining,并注意回绕。
  • 中断优先级:USART1中断优先级应高于DMA中断(若使用),避免IDLE处理被延迟。
  • 内存屏障:在更新write_idx后,应用层读取前可加__DSB()确保数据可见性(Cortex-M内核)。
  • 错误处理:DMA传输错误或串口溢出时需重新初始化DMA和环形缓冲,避免死锁。
  • 零拷贝的边界:应用层直接读取环形缓冲时,需保证读指针不越过写指针,且处理回绕情况。

通过以上方案,可有效解决半包与粘包问题,同时利用零拷贝环形缓冲降低CPU负载,适用于大多数嵌入式串口通信场景。