# 引言 在工业控制、物联网网关等场景中,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 配置 ```c // 使能 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(具体映射参考参考手册)。 ```c // 以 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. 环形缓冲区实现 ```c #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 每传输一半或全部数据时触发中断。我们利用这两个中断来及时处理数据,避免缓冲区溢出。 ```c 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 中断: ```c __disable_irq(); // 读取写指针和读指针 __enable_irq(); ``` 但频繁开关中断会影响实时性,更高效的方法是使用无锁环形缓冲区,通过原子操作或内存屏障保证一致性。在 Cortex-M4 上,可使用 `__LDREXB`/`__STREXB` 实现无锁访问。 # 发送端实现 发送端使用 DMA 发送,避免 CPU 逐字节等待。 ```c // 发送缓冲区(双缓冲,避免 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; } } ``` # 完整代码示例 以下为简化但完整的框架,包含两个串口的初始化和中断处理。 ```c // main.c #include "stm32f4xx.h" #include #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 循环模式、利用半满/全满中断,并仔细处理读写指针的竞争。本文提供的框架可直接应用于实际项目,开发者可根据具体需求调整缓冲区大小和中断策略。记住,边界条件(如缓冲区满、中断嵌套)是丢包的根源,务必在设计中优先考虑。