STM32 多串口高并发收发:DMA + 环形缓冲区实战与边界条件剖析
👁 2 阅读 · 2026-08-27 · 嵌入式
在嵌入式系统中,多串口高并发收发常因 CPU 忙等和缓冲区溢出导致丢包。本文深入讲解如何利用 DMA 与环形缓冲区构建高效、可靠的串口通信框架,涵盖原理、配置步骤、完整代码示例及关键边界条件(如缓冲区满、DMA 半满中断、临界区保护),帮助开发者彻底告别丢包困扰,提升系统实时性。
# 引言
在工业控制、物联网网关等场景中,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 循环模式、利用半满/全满中断,并仔细处理读写指针的竞争。本文提供的框架可直接应用于实际项目,开发者可根据具体需求调整缓冲区大小和中断策略。记住,边界条件(如缓冲区满、中断嵌套)是丢包的根源,务必在设计中优先考虑。