引言

在工业控制、物联网网关等场景中,STM32 常需同时管理多个 UART 外设,且数据流量大、突发性强。若采用传统中断逐字节接收或查询发送,CPU 将被大量中断淹没,导致高优先级任务延迟,甚至因缓冲溢出丢包。本文提出一种基于 DMA + 环形缓冲区(Ring Buffer)的解决方案,将 CPU 从数据搬运中解放出来,同时通过精心设计的边界处理,确保多串口高并发下的数据完整性。

原理概述

DMA 的角色

DMA(直接内存访问)允许外设(如 UART)在无需 CPU 干预的情况下,将数据直接传输到内存缓冲区。对于接收,我们配置 DMA 为循环模式(Circular Mode),持续将 UART 接收到的数据写入预分配的内存数组。当 DMA 传输过半或满时,触发中断,CPU 只需处理缓冲区中的有效数据。对于发送,DMA 从内存缓冲区搬运数据到 UART 发送寄存器,发送完成产生中断,CPU 可继续准备下一批数据。

环形缓冲区设计

环形缓冲区(Ring Buffer)是一种固定大小的 FIFO 数据结构,通过头尾指针实现高效读写。在 DMA 接收场景中,DMA 写入端(硬件)和 CPU 读取端(软件)共享缓冲区,必须处理读写指针的竞争。我们采用经典的“读指针 + 写指针”模型,并利用 DMA 的循环特性,使硬件写指针自动回绕。

关键点:

  • 缓冲区大小需为 2 的幂,便于用位与操作实现回绕。
  • 写指针由 DMA 硬件更新(通过 NDTR 寄存器或直接读取 DMA 当前地址),读指针由软件维护。
  • 当读指针追上写指针时,缓冲区为空;当写指针追上读指针时,缓冲区满(需丢弃新数据或覆盖旧数据,根据策略选择)。

硬件与软件配置

以 STM32F407 为例,使用 USART1 和 USART2 两个串口,均配置 DMA 收发。

1. 时钟与 GPIO 配置

// 使能 GPIOA, USART1, USART2, DMA2 时钟
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN;

// USART1: PA9(TX), PA10(RX) 复用功能
GPIOA->MODER |= (GPIO_MODER_MODE9_1 | GPIO_MODER_MODE10_1); // AF
GPIOA->AFR[1] |= (7 << 4) | (7 << 8); // AF7 for USART1

// USART2: PA2(TX), PA3(RX)
GPIOA->MODER |= (GPIO_MODER_MODE2_1 | GPIO_MODER_MODE3_1);
GPIOA->AFR[0] |= (7 << 8) | (7 << 12); // AF7 for USART2

2. DMA 配置

使用 DMA2 Stream7 用于 USART1_RX,Stream6 用于 USART1_TX;DMA1 Stream5 用于 USART2_RX,Stream6 用于 USART2_TX(具体映射参考参考手册)。

// 以 USART1_RX 为例:DMA2_Stream7, Channel4
void USART1_DMA_RX_Config(void) {
    // 使能 DMA2 时钟
    RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN;
    
    // 等待 DMA 流复位
    DMA2_Stream7->CR = 0;
    while (DMA2_Stream7->CR & DMA_SxCR_EN);
    
    // 配置:循环模式,外设到内存,8位,内存递增,外设地址固定
    DMA2_Stream7->PAR = (uint32_t)&USART1->DR;
    DMA2_Stream7->M0AR = (uint32_t)rx_buf1;
    DMA2_Stream7->NDTR = RX_BUF_SIZE;
    DMA2_Stream7->CR = DMA_SxCR_CHSEL_0 | // Channel 4
                        DMA_SxCR_CIRC |    // 循环模式
                        DMA_SxCR_DIR_0 |   // 外设到内存
                        DMA_SxCR_MINC |    // 内存递增
                        DMA_SxCR_TCIE |    // 传输完成中断
                        DMA_SxCR_HTIE |    // 半传输中断
                        DMA_SxCR_EN;       // 使能
    
    // 使能 DMA 中断(在 NVIC 中)
    NVIC_EnableIRQ(DMA2_Stream7_IRQn);
}

3. 环形缓冲区实现

#define RX_BUF_SIZE 256  // 必须为 2 的幂
#define RX_BUF_MASK (RX_BUF_SIZE - 1)

volatile uint8_t rx_buf1[RX_BUF_SIZE];
volatile uint8_t rx_buf2[RX_BUF_SIZE];

// 读指针(软件维护),写指针通过 DMA 的 NDTR 计算
volatile uint16_t rx1_read_idx = 0;
volatile uint16_t rx2_read_idx = 0;

// 获取 DMA 当前写索引(即已接收数据量)
uint16_t get_dma_write_idx(DMA_Stream_TypeDef *DMAx_Stream) {
    return RX_BUF_SIZE - DMAx_Stream->NDTR;
}

// 读取一个字节,返回 0 成功,-1 失败(缓冲区空)
int ring_buf_read(volatile uint8_t *buf, volatile uint16_t *read_idx, DMA_Stream_TypeDef *dma_stream) {
    uint16_t write_idx = get_dma_write_idx(dma_stream);
    if (*read_idx == write_idx) {
        return -1; // 空
    }
    uint8_t data = buf[*read_idx];
    *read_idx = (*read_idx + 1) & RX_BUF_MASK;
    return data;
}

中断处理与边界条件

1. DMA 半满/全满中断

在循环模式下,DMA 每传输一半或全部数据时触发中断。我们利用这两个中断来及时处理数据,避免缓冲区溢出。

void DMA2_Stream7_IRQHandler(void) {
    if (DMA2_Stream7->ISR & DMA_LISR_HTIF7) {
        DMA2_Stream7->IFCR |= DMA_LIFCR_CTCIF7; // 清除标志
        // 处理前半缓冲区数据(0 ~ RX_BUF_SIZE/2 - 1)
        process_rx_data(1, 0, RX_BUF_SIZE/2);
    }
    if (DMA2_Stream7->ISR & DMA_LISR_TCIF7) {
        DMA2_Stream7->IFCR |= DMA_LIFCR_CTCIF7;
        // 处理后半缓冲区数据(RX_BUF_SIZE/2 ~ RX_BUF_SIZE - 1)
        process_rx_data(1, RX_BUF_SIZE/2, RX_BUF_SIZE/2);
    }
}

注意:处理数据时,必须更新读指针,以便 DMA 继续写入。但 DMA 的写指针是硬件自动更新的,我们只需确保在中断处理期间,读指针不会超过写指针。由于中断处理较快,且缓冲区较大,一般不会发生覆盖。

2. 缓冲区满与丢包策略

当读指针追上写指针时,缓冲区满。此时有两种策略:

  • 丢弃新数据(推荐):避免覆盖未处理数据,但可能丢失部分数据。
  • 覆盖旧数据:适用于实时性要求高的场景,但可能破坏数据完整性。

实现时,在写入侧(DMA 中断)检查剩余空间,若不足则丢弃。但 DMA 是硬件写入,无法直接控制,因此我们通过调整 DMA 的 NDTR 或使用双缓冲区(Double Buffer)模式来避免溢出。

3. 临界区保护

由于读指针和写指针可能被中断和主循环同时访问,必须使用临界区保护。例如,在读取数据时,暂时屏蔽 DMA 中断:

__disable_irq();
// 读取写指针和读指针
__enable_irq();

但频繁开关中断会影响实时性,更高效的方法是使用无锁环形缓冲区,通过原子操作或内存屏障保证一致性。在 Cortex-M4 上,可使用 __LDREXB/__STREXB 实现无锁访问。

发送端实现

发送端使用 DMA 发送,避免 CPU 逐字节等待。

// 发送缓冲区(双缓冲,避免 DMA 忙时修改)
uint8_t tx_buf1[TX_BUF_SIZE];
uint8_t tx_buf2[TX_BUF_SIZE];
volatile uint8_t tx_buf_sel = 0;

void USART1_DMA_TX_Start(uint8_t *data, uint16_t len) {
    // 等待上一次发送完成
    while (DMA2_Stream6->CR & DMA_SxCR_EN);
    
    // 配置 DMA 发送
    DMA2_Stream6->PAR = (uint32_t)&USART1->DR;
    DMA2_Stream6->M0AR = (uint32_t)data;
    DMA2_Stream6->NDTR = len;
    DMA2_Stream6->CR = DMA_SxCR_CHSEL_0 | DMA_SxCR_DIR_1 | DMA_SxCR_MINC | DMA_SxCR_TCIE | DMA_SxCR_EN;
    
    // 使能 UART 发送 DMA 请求
    USART1->CR3 |= USART_CR3_DMAT;
}

// 发送完成中断
void DMA2_Stream6_IRQHandler(void) {
    if (DMA2_Stream6->ISR & DMA_LISR_TCIF6) {
        DMA2_Stream6->IFCR |= DMA_LIFCR_CTCIF6;
        // 发送完成,可释放缓冲区或通知应用
        tx_buf_sel ^= 1;
    }
}

完整代码示例

以下为简化但完整的框架,包含两个串口的初始化和中断处理。

// main.c
#include "stm32f4xx.h"
#include <string.h>

#define RX_BUF_SIZE 256
#define TX_BUF_SIZE 128

volatile uint8_t rx_buf1[RX_BUF_SIZE];
volatile uint8_t rx_buf2[RX_BUF_SIZE];
volatile uint16_t rx1_read_idx = 0;
volatile uint16_t rx2_read_idx = 0;

void process_rx_data(uint8_t port, uint16_t start, uint16_t len) {
    // 处理接收到的数据,例如解析协议
    for (uint16_t i = 0; i < len; i++) {
        uint8_t byte = (port == 1) ? rx_buf1[(start + i) & (RX_BUF_SIZE-1)] : rx_buf2[(start + i) & (RX_BUF_SIZE-1)];
        // 存入应用缓冲区或直接处理
    }
    // 更新读指针
    if (port == 1) {
        rx1_read_idx = (start + len) & (RX_BUF_SIZE-1);
    } else {
        rx2_read_idx = (start + len) & (RX_BUF_SIZE-1);
    }
}

void USART1_Init(void) {
    // 时钟、GPIO、USART 配置(略)
    // 使能 DMA 接收
    USART1->CR3 |= USART_CR3_DMAR;
    // 配置 DMA2_Stream7 接收(见上文)
}

void USART2_Init(void) {
    // 类似 USART1
}

int main(void) {
    USART1_Init();
    USART2_Init();
    
    while (1) {
        // 主循环处理其他任务
        // 可通过轮询读取 rx_buf1 和 rx_buf2 中的数据
    }
}

// 中断处理函数(略,见上文)

注意事项与常见陷阱

  • 缓冲区大小:必须为 2 的幂,否则位与操作失效。
  • DMA 中断优先级:应设置为较高优先级,避免数据覆盖。但不要高于系统节拍中断,以免影响实时性。
  • 内存对齐:DMA 缓冲区建议 4 字节对齐,避免某些 DMA 控制器要求。
  • 临界区:在读取写指针时,若使用 NDTR,需注意 NDTR 可能被 DMA 更新,应使用 volatile 并考虑内存屏障。
  • 多串口扩展:每个串口独立配置 DMA 通道,但中断处理函数需区分,避免冲突。
  • 调试:使用逻辑分析仪或串口助手验证数据完整性,特别是高波特率(如 921600)下。

总结

通过 DMA + 环形缓冲区,STM32 可以轻松应对多串口高并发收发,CPU 占用率极低。关键在于合理配置 DMA 循环模式、利用半满/全满中断,并仔细处理读写指针的竞争。本文提供的框架可直接应用于实际项目,开发者可根据具体需求调整缓冲区大小和中断策略。记住,边界条件(如缓冲区满、中断嵌套)是丢包的根源,务必在设计中优先考虑。