一、引言

在STM32开发中,使用HAL库的DMA+空闲中断(IDLE)接收不定长串口数据是一种高效方案。但许多开发者在使用时遭遇数据丢失、缓冲区溢出等问题。本文将分析溢出风险根源,并给出环形缓冲区改造方案。

二、传统DMA+空闲中断方案原理

2.1 基本工作流程

  • 初始化DMA,将串口接收数据寄存器(DR)映射到内存缓冲区。
  • 使能串口空闲中断。
  • 当一帧数据接收完成,总线空闲,触发IDLE中断。
  • 在中断中计算接收长度,处理数据,重新启动DMA。

典型代码片段:

uint8_t rx_buf[256];

void HAL_UART_IDLECallback(UART_HandleTypeDef *huart) {
    if (huart == &huart1) {
        // 清除空闲标志
        __HAL_UART_CLEAR_IDLEFLAG(huart);
        // 停止DMA获取已接收长度
        HAL_UART_DMAStop(huart);
        uint16_t len = 256 - __HAL_DMA_GET_COUNTER(huart->hdmarx);
        // 处理数据...
        // 重新启动DMA
        HAL_UART_Receive_DMA(huart, rx_buf, 256);
    }
}

2.2 潜在溢出风险

  • DMA传输完成与空闲中断竞争:若一帧数据长度恰好等于缓冲区大小,DMA传输完成中断与空闲中断可能同时触发,导致重复处理或数据覆盖。
  • 处理不及时:在中断中处理数据耗时过长,期间新数据到达会覆盖缓冲区。
  • 重新启动DMA的间隙:停止DMA到重新启动之间,串口仍可能接收数据,造成丢失。
  • 多帧连续到达:若两帧数据间隔极短,空闲中断可能来不及响应,第二帧数据覆盖第一帧。

三、环形缓冲区改造方案

3.1 设计思路

  • 使用环形缓冲区(FIFO)作为数据暂存区,DMA始终向环形缓冲区写入,避免停止/重启DMA。
  • 利用DMA的半传输(HT)和传输完成(TC)中断动态更新写指针。
  • 空闲中断仅用于标记一帧结束,通知应用层处理。
  • 应用层从环形缓冲区读取数据,实现生产者-消费者模型。

3.2 环形缓冲区结构

typedef struct {
    uint8_t *buffer;      // 数据区
    uint16_t size;        // 总大小(2的幂次)
    volatile uint16_t head; // 写指针(DMA写入位置)
    volatile uint16_t tail; // 读指针(应用读取位置)
} ring_buffer_t;

3.3 关键实现

3.3.1 初始化与DMA配置

  • 分配环形缓冲区内存(建议大小256/512/1024,2的幂次便于取模)。
  • 配置DMA为循环模式(Circular),使能半传输和传输完成中断。
  • 启动DMA接收,指向环形缓冲区起始地址。
#define RB_SIZE 512
static uint8_t rb_data[RB_SIZE];
static ring_buffer_t rb = {rb_data, RB_SIZE, 0, 0};

void uart_dma_init(UART_HandleTypeDef *huart) {
    // 使能空闲中断
    __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE);
    // 启动DMA循环接收
    HAL_UART_Receive_DMA(huart, rb.buffer, rb.size);
}

3.3.2 DMA中断处理

在DMA半传输和传输完成中断中更新写指针(head)。由于DMA循环写入,head始终指向DMA下一个要写入的位置。

void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) {
    // 半传输:已接收size/2字节
    rb.head = rb.size / 2;
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    // 传输完成:已接收size字节,回绕
    rb.head = 0;
}

3.3.3 空闲中断处理

空闲中断表示一帧结束,此时DMA当前写入位置可通过__HAL_DMA_GET_COUNTER计算。更新head为实际写入位置,并通知应用层。

void HAL_UART_IDLECallback(UART_HandleTypeDef *huart) {
    if (huart == &huart1) {
        __HAL_UART_CLEAR_IDLEFLAG(huart);
        // 计算当前DMA写入位置
        uint16_t remain = __HAL_DMA_GET_COUNTER(huart->hdmarx);
        uint16_t pos = rb.size - remain;
        // 更新head(考虑回绕)
        rb.head = pos;
        // 设置帧结束标志,通知应用层
        frame_ready = 1;
    }
}

3.3.4 应用层读取

应用层从tail读取到head的数据,处理完后更新tail。注意处理回绕。

uint16_t rb_read(ring_buffer_t *rb, uint8_t *data, uint16_t len) {
    uint16_t available = (rb->head - rb->tail) & (rb->size - 1);
    if (len > available) len = available;
    for (uint16_t i = 0; i < len; i++) {
        data[i] = rb->buffer[rb->tail];
        rb->tail = (rb->tail + 1) & (rb->size - 1);
    }
    return len;
}

四、完整代码示例

以下为基于STM32 HAL库的环形缓冲区串口接收完整实现(以UART1为例)。

// ring_buffer.h
typedef struct {
    uint8_t *buffer;
    uint16_t size;
    volatile uint16_t head;
    volatile uint16_t tail;
} ring_buffer_t;

void rb_init(ring_buffer_t *rb, uint8_t *buf, uint16_t size);
uint16_t rb_read(ring_buffer_t *rb, uint8_t *data, uint16_t len);
uint16_t rb_available(ring_buffer_t *rb);

// ring_buffer.c
void rb_init(ring_buffer_t *rb, uint8_t *buf, uint16_t size) {
    rb->buffer = buf;
    rb->size = size;
    rb->head = 0;
    rb->tail = 0;
}

uint16_t rb_available(ring_buffer_t *rb) {
    return (rb->head - rb->tail) & (rb->size - 1);
}

uint16_t rb_read(ring_buffer_t *rb, uint8_t *data, uint16_t len) {
    uint16_t avail = rb_available(rb);
    if (len > avail) len = avail;
    for (uint16_t i = 0; i < len; i++) {
        data[i] = rb->buffer[rb->tail];
        rb->tail = (rb->tail + 1) & (rb->size - 1);
    }
    return len;
}

// main.c
#define RB_SIZE 512
static uint8_t rb_data[RB_SIZE];
static ring_buffer_t rb;
volatile uint8_t frame_ready = 0;

void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart) {
    if (huart == &huart1) {
        rb.head = rb.size / 2;
    }
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart == &huart1) {
        rb.head = 0;
    }
}

void HAL_UART_IDLECallback(UART_HandleTypeDef *huart) {
    if (huart == &huart1) {
        __HAL_UART_CLEAR_IDLEFLAG(huart);
        uint16_t remain = __HAL_DMA_GET_COUNTER(huart->hdmarx);
        uint16_t pos = rb.size - remain;
        rb.head = pos;
        frame_ready = 1;
    }
}

int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    MX_DMA_Init();
    MX_USART1_UART_Init();

    rb_init(&rb, rb_data, RB_SIZE);
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
    HAL_UART_Receive_DMA(&huart1, rb.buffer, rb.size);

    while (1) {
        if (frame_ready) {
            frame_ready = 0;
            uint8_t data[RB_SIZE];
            uint16_t len = rb_read(&rb, data, RB_SIZE);
            // 处理数据
        }
    }
}

五、注意事项

  • 缓冲区大小:建议为2的幂次,便于使用位与操作取模。大小需根据数据速率和处理能力权衡。
  • 中断优先级:DMA中断和串口中断优先级需合理设置,避免长时间阻塞。
  • 临界区保护:head和tail的读写可能被中断打断,必要时使用关中断或原子操作。
  • 内存对齐:DMA缓冲区需对齐,避免未定义行为。
  • 错误处理:添加DMA错误中断处理,防止死锁。
  • 性能考量:应用层处理速度应大于数据到达速度,否则环形缓冲区仍会溢出。

六、总结

通过环形缓冲区改造,DMA+空闲中断方案可有效避免数据覆盖和丢失,实现稳定可靠的不定长串口接收。本文所述方法已在实际项目中验证,开发者可根据需求调整缓冲区大小和中断策略。