一、原理讲解:为什么会有竞态?

在STM32中,使用DMA接收串口数据并配合空闲中断(IDLE)是处理不定长数据的经典方案。但很多开发者忽略了DMA的半满中断(HT)和全满中断(TC)与空闲中断之间的竞态问题。

1.1 DMA传输机制

  • DMA配置为循环模式(Circular)时,接收缓冲区被分为两半:前半区和后半区。
  • 当DMA写指针到达缓冲区一半时,触发半满中断(HT);到达末尾时,触发全满中断(TC);然后自动回绕。
  • 串口空闲中断在总线空闲(一个字节时间无数据)时触发,此时DMA可能正处于任意位置。

1.2 竞态场景

假设缓冲区大小为N,DMA当前写指针为pos。当空闲中断发生时,CPU需要读取从上次读取位置到pos之间的数据。但如果此时DMA恰好跨越了半满或全满边界,就会产生以下问题:

  • 数据覆盖:CPU尚未处理完前半区数据,DMA已开始写后半区,若处理速度慢,可能回绕覆盖未读数据。
  • 指针不一致:在读取DMA剩余传输计数(NDTR)时,DMA可能正在更新该寄存器,导致读到的值不准确。
  • 中断嵌套:空闲中断与DMA中断同时挂起,若优先级配置不当,可能打断关键操作。

二、缓冲区设计:双缓冲+读写指针

为解决竞态,推荐使用“双缓冲+读写指针”方案:

  • 定义两个缓冲区:bufA和bufB,每个大小相同(如256字节)。
  • DMA循环填充bufA,当半满中断触发时,表示bufA前半区已满,此时将DMA目标切换到bufB(通过修改DMA内存地址),同时通知应用处理bufA前半区数据。
  • 当全满中断触发时,表示bufA后半区已满,再次切换回bufA(或使用第三个缓冲区),并处理bufA后半区。
  • 空闲中断发生时,根据当前DMA目标缓冲区和NDTR计算有效数据长度,并拷贝到应用缓冲区。

关键点:在切换缓冲区时,必须短暂关闭DMA或使用临界区保护,防止DMA在切换过程中写入错误地址。

三、CubeMX配置步骤

  1. 使能USART1,模式为Asynchronous,波特率115200。
  2. 在DMA Settings中添加USART1_RX的DMA请求,模式为Circular,数据宽度Byte,优先级High。
  3. 使能USART1全局中断(NVIC),并设置合适的优先级(建议高于DMA中断)。
  4. 在NVIC中使能DMA通道中断(如DMA1_Channel5),用于半满/全满中断。
  5. 生成代码,HAL库会自动初始化DMA和串口。

四、完整代码示例

4.1 定义缓冲区与状态

#define BUF_SIZE 256
uint8_t dma_buf[2][BUF_SIZE]; // 双缓冲
volatile uint8_t active_buf = 0; // 当前DMA目标缓冲区索引
volatile uint16_t last_pos = 0; // 上次读取位置(在active_buf中)
volatile uint8_t data_ready[2] = {0}; // 标记缓冲区是否有待处理数据
uint8_t app_buf[BUF_SIZE * 2]; // 应用层拼接缓冲区
uint16_t app_len = 0;

4.2 启动DMA接收

void uart_dma_start(void) {
    HAL_UART_Receive_DMA(&huart1, dma_buf[0], BUF_SIZE);
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

4.3 空闲中断处理(在USART1_IRQHandler中调用)

void uart_idle_handler(void) {
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        // 获取当前DMA剩余计数
        uint16_t ndtr = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        uint16_t pos = BUF_SIZE - ndtr; // 当前写指针位置
        uint8_t buf_idx = active_buf;
        uint16_t start = last_pos;
        uint16_t len = 0;

        // 计算有效数据长度(考虑回绕)
        if (pos >= start) {
            len = pos - start;
        } else {
            len = BUF_SIZE - start + pos;
        }

        if (len > 0) {
            // 拷贝数据到应用缓冲区(注意临界区保护)
            __disable_irq();
            memcpy(&app_buf[app_len], &dma_buf[buf_idx][start], len);
            app_len += len;
            last_pos = pos;
            __enable_irq();
            // 通知应用任务处理app_buf
            data_ready[buf_idx] = 1;
        }
    }
}

4.4 DMA半满/全满中断处理

void DMA1_Channel5_IRQHandler(void) {
    if (__HAL_DMA_GET_FLAG(huart1.hdmarx, DMA_FLAG_HT5)) {
        __HAL_DMA_CLEAR_FLAG(huart1.hdmarx, DMA_FLAG_HT5);
        // 半满:当前缓冲区前半区已满,切换缓冲区
        dma_switch_buffer();
    }
    if (__HAL_DMA_GET_FLAG(huart1.hdmarx, DMA_FLAG_TC5)) {
        __HAL_DMA_CLEAR_FLAG(huart1.hdmarx, DMA_FLAG_TC5);
        // 全满:当前缓冲区后半区已满,切换缓冲区
        dma_switch_buffer();
    }
}

void dma_switch_buffer(void) {
    __disable_irq();
    uint8_t old_buf = active_buf;
    active_buf = 1 - active_buf; // 切换
    // 重新配置DMA目标地址
    huart1.hdmarx->Instance->CMAR = (uint32_t)dma_buf[active_buf];
    // 重置NDTR(可选,但循环模式下自动重载)
    __enable_irq();
    // 标记旧缓冲区数据待处理
    data_ready[old_buf] = 1;
}

4.5 应用层处理

void process_data(void) {
    for (int i = 0; i < 2; i++) {
        if (data_ready[i]) {
            // 处理dma_buf[i]中的数据(长度需根据last_pos计算)
            // 这里简化:假设每次切换时整个缓冲区有效
            // 实际需结合last_pos和当前pos
            data_ready[i] = 0;
        }
    }
    if (app_len > 0) {
        // 解析app_buf
        app_len = 0;
    }
}

五、注意事项

  • 临界区保护:在修改DMA目标地址和读取NDTR时,务必使用__disable_irq()/__enable_irq()或配置中断优先级,防止DMA中断打断空闲中断。
  • 中断优先级:建议USART1空闲中断优先级高于DMA中断,确保空闲事件及时响应。
  • 缓冲区大小:根据最大帧长和系统处理能力选择,通常256~1024字节。
  • 数据一致性:在拷贝数据前,先读取NDTR并计算长度,再拷贝,避免DMA在拷贝过程中写入新数据。
  • 错误处理:使能DMA传输错误中断,并在错误时重启DMA。
  • 内存对齐:DMA缓冲区建议使用__attribute__((aligned(4)))对齐,提高访问效率。

通过上述双缓冲设计和临界区保护,可有效解决半满中断与空闲中断的竞态问题,实现稳定可靠的不定长串口收包。