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

传统串口接收采用单字节中断,每收到一个字节就进一次中断,在高波特率下CPU开销巨大,且容易丢数据。DMA+空闲中断方案利用DMA自动搬运数据,仅在总线空闲时触发一次中断,极大降低CPU负载,特别适合不定长帧的接收场景。

  • DMA:自动将串口数据寄存器(DR)的数据搬运到内存缓冲区,无需CPU干预。
  • 空闲中断:当串口总线检测到空闲(即一个字节时间无新数据)时触发,标志一帧数据接收完成。

二、半包与粘包的成因分析

2.1 半包现象

半包指一帧数据被拆分成多次接收。常见原因:

  • 发送方分多次发送(如先发帧头,再发数据)。
  • 总线干扰导致字节间出现短暂空闲。
  • 高优先级中断打断DMA传输,导致空闲中断提前触发。

2.2 粘包现象

粘包指多帧数据被合并成一次接收。常见原因:

  • 发送方连续发送多帧,帧间无足够空闲时间。
  • 空闲中断响应延迟,DMA已接收后续帧数据。

三、边界处理核心策略

3.1 双缓冲与长度记录

使用两个缓冲区交替接收,每次空闲中断时记录当前DMA剩余计数,计算已接收长度,并切换缓冲区。避免处理数据时被新数据覆盖。

3.2 帧头帧尾校验

在应用层协议中定义明确的帧头和帧尾(如0xAA 0x55 ... 0x0D 0x0A),接收后根据帧头帧尾提取完整帧,丢弃不完整或无效数据。

3.3 超时补帧机制

对于半包,可结合定时器实现超时补帧:若空闲中断后一段时间内未收到后续数据,则强制结束当前帧。

四、完整代码实现

4.1 硬件与初始化配置

以STM32F4系列为例,使用USART1(PA9/PA10),DMA2 Stream2 Channel4。

#define RX_BUF_SIZE 256

uint8_t rx_buf[2][RX_BUF_SIZE];  // 双缓冲
volatile uint16_t rx_len[2] = {0};
volatile uint8_t buf_idx = 0;    // 当前缓冲区索引
volatile uint8_t frame_ready = 0;

void USART1_Init(void) {
    // 使能时钟
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
    RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN;

    // GPIO配置 PA9 TX, PA10 RX 复用
    GPIOA->MODER &= ~(GPIO_MODER_MODER9 | GPIO_MODER_MODER10);
    GPIOA->MODER |= (2 << GPIO_MODER_MODER9_Pos) | (2 << GPIO_MODER_MODER10_Pos);
    GPIOA->AFR[1] |= (7 << GPIO_AFRH_AFSEL9_Pos) | (7 << GPIO_AFRH_AFSEL10_Pos);

    // USART配置 115200 8N1
    USART1->BRR = 84000000 / 115200;  // 假设APB2时钟84MHz
    USART1->CR1 |= USART_CR1_TE | USART_CR1_RE | USART_CR1_IDLEIE;
    USART1->CR3 |= USART_CR3_DMAR;
    USART1->CR1 |= USART_CR1_UE;

    // DMA配置
    DMA2_Stream2->CR &= ~DMA_SxCR_EN;
    while (DMA2_Stream2->CR & DMA_SxCR_EN);
    DMA2_Stream2->PAR = (uint32_t)&USART1->DR;
    DMA2_Stream2->M0AR = (uint32_t)rx_buf[0];
    DMA2_Stream2->NDTR = RX_BUF_SIZE;
    DMA2_Stream2->CR = (4 << DMA_SxCR_CHSEL_Pos) | DMA_SxCR_MINC | DMA_SxCR_CIRC;
    DMA2_Stream2->CR |= DMA_SxCR_EN;

    // NVIC配置
    NVIC_EnableIRQ(USART1_IRQn);
    NVIC_SetPriority(USART1_IRQn, 1);
}

4.2 空闲中断处理

void USART1_IRQHandler(void) {
    if (USART1->SR & USART_SR_IDLE) {
        // 清除空闲标志(先读SR再读DR)
        volatile uint32_t tmp = USART1->SR;
        tmp = USART1->DR;
        (void)tmp;

        // 计算已接收长度
        uint16_t remain = DMA2_Stream2->NDTR;
        rx_len[buf_idx] = RX_BUF_SIZE - remain;

        // 切换缓冲区
        buf_idx ^= 1;
        DMA2_Stream2->CR &= ~DMA_SxCR_EN;
        while (DMA2_Stream2->CR & DMA_SxCR_EN);
        DMA2_Stream2->M0AR = (uint32_t)rx_buf[buf_idx];
        DMA2_Stream2->NDTR = RX_BUF_SIZE;
        DMA2_Stream2->CR |= DMA_SxCR_EN;

        frame_ready = 1;
    }
}

4.3 应用层帧解析

#define FRAME_HEAD1 0xAA
#define FRAME_HEAD2 0x55
#define FRAME_TAIL1 0x0D
#define FRAME_TAIL2 0x0A

void ProcessFrame(uint8_t *buf, uint16_t len) {
    uint16_t i = 0;
    while (i < len) {
        // 查找帧头
        if (buf[i] == FRAME_HEAD1 && i + 1 < len && buf[i+1] == FRAME_HEAD2) {
            uint16_t start = i;
            uint16_t j = i + 2;
            // 查找帧尾
            while (j + 1 < len) {
                if (buf[j] == FRAME_TAIL1 && buf[j+1] == FRAME_TAIL2) {
                    uint16_t frame_len = j + 2 - start;
                    // 处理完整帧
                    HandleCompleteFrame(&buf[start], frame_len);
                    i = j + 2;
                    break;
                }
                j++;
            }
            if (j + 1 >= len) {
                // 未找到帧尾,半包,暂存
                SavePartialFrame(&buf[start], len - start);
                break;
            }
        } else {
            i++;
        }
    }
}

4.4 主循环处理

int main(void) {
    USART1_Init();
    while (1) {
        if (frame_ready) {
            frame_ready = 0;
            uint8_t idx = buf_idx ^ 1;  // 刚接收完的缓冲区
            ProcessFrame(rx_buf[idx], rx_len[idx]);
        }
    }
}

五、注意事项与调试技巧

  • 空闲中断清除顺序:必须先读SR再读DR,否则空闲标志无法清除,导致中断反复触发。
  • DMA循环模式:使用循环模式时,NDTR自动重装,但切换缓冲区需先关闭DMA再重新配置,避免数据错位。
  • 缓冲区大小:根据最大帧长和波特率合理设置,建议为最大帧长的2倍以上,防止DMA溢出。
  • 半包暂存:应用层需维护一个拼接缓冲区,将半包数据与后续数据合并后再解析。
  • 粘包处理:解析时严格按帧头帧尾提取,丢弃帧间无效字节,避免多帧粘连导致解析错误。
  • 中断优先级:空闲中断优先级不宜过高,避免打断关键时序;但需高于DMA传输完成中断(若使用)。
  • 内存屏障:在切换缓冲区时,确保DMA已停止再修改M0AR和NDTR,必要时加入__DSB()。

通过上述双缓冲+帧头帧尾校验+半包暂存的组合策略,可稳定处理绝大多数半包与粘包场景,已在多个工业项目中验证可靠。