STM32 不定长串口接收:DMA 环形缓冲区 + 空闲中断的边界条件深度剖析
👁 1 阅读 · 2026-08-27 · 嵌入式
在嵌入式开发中,串口接收不定长数据是常见需求,但处理不当极易丢帧。本文深入讲解如何利用 DMA 环形缓冲区配合空闲中断,实现高效、可靠的 STM32 不定长串口接收。文章不仅涵盖原理与配置步骤,更重点分析了环形缓冲区读写指针的边界条件、DMA 半传输中断与空闲中断的协同机制,以及常见陷阱的规避方法,助你彻底告别丢帧困扰。
# 用 DMA 环形缓冲区 + 空闲中断实现 STM32 不定长串口接收,避免丢帧的边界条件分析
## 一、为什么需要 DMA + 空闲中断?
传统串口接收(逐字节中断)在高速或长数据帧时,CPU 频繁进出中断,容易导致丢帧。而 DMA 可以在无需 CPU 干预的情况下,将串口接收到的数据搬运到内存缓冲区。但 DMA 本身不知道一帧数据何时结束,因此需要配合**空闲中断(IDLE)**来检测帧边界。
**核心思路**:
- DMA 持续将串口数据存入缓冲区(环形结构)。
- 当串口总线空闲(即一帧数据接收完毕)时,触发空闲中断。
- 在空闲中断中,根据 DMA 当前写入位置,计算本次接收的数据长度,并处理数据。
## 二、环形缓冲区设计
环形缓冲区(Ring Buffer)是一种先进先出的数据结构,通过读写指针循环使用固定内存。
```c
#define BUFFER_SIZE 256 // 必须是 2 的幂,便于取模
uint8_t rx_buffer[BUFFER_SIZE];
volatile uint16_t read_index = 0; // 读指针(应用层使用)
volatile uint16_t write_index = 0; // 写指针(DMA 更新)
```
**关键点**:
- `write_index` 由 DMA 传输完成或空闲中断更新,表示 DMA 已写入的位置。
- `read_index` 由应用层读取数据时更新。
- 当 `read_index == write_index` 时,缓冲区为空。
## 三、DMA 配置与中断协同
### 1. DMA 配置(以 STM32F1 为例)
```c
void UART_DMA_Config(void)
{
// 使能 DMA1 时钟
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);
DMA_InitTypeDef DMA_InitStructure;
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR;
DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)rx_buffer;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC;
DMA_InitStructure.DMA_BufferSize = BUFFER_SIZE;
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte;
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte;
DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; // 循环模式
DMA_InitStructure.DMA_Priority = DMA_Priority_High;
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable;
DMA_Init(DMA1_Channel5, &DMA_InitStructure);
// 使能 DMA 半传输中断和传输完成中断
DMA_ITConfig(DMA1_IT_TC5, ENABLE);
DMA_ITConfig(DMA1_IT_HT5, ENABLE);
// 使能 DMA 通道
DMA_Cmd(DMA1_Channel5, ENABLE);
}
```
### 2. 空闲中断配置
```c
void UART_IDLE_Config(void)
{
USART_ITConfig(USART1, USART_IT_IDLE, ENABLE);
// 注意:空闲中断标志在 USART_SR 的 IDLE 位,需要读 SR 再读 DR 清除
}
```
### 3. 中断服务函数(关键)
```c
void USART1_IRQHandler(void)
{
if (USART_GetITStatus(USART1, USART_IT_IDLE) != RESET)
{
// 清除空闲中断标志(读 SR 再读 DR)
USART_ReceiveData(USART1);
// 获取当前 DMA 写入位置(即 DMA 当前传输计数)
uint16_t dma_remaining = DMA_GetCurrDataCounter(DMA1_Channel5);
uint16_t current_write = BUFFER_SIZE - dma_remaining;
// 计算本次接收的数据长度(考虑环形回绕)
uint16_t data_len;
if (current_write >= write_index)
{
data_len = current_write - write_index;
}
else
{
data_len = BUFFER_SIZE - write_index + current_write;
}
// 注意:这里需要处理 data_len 为 0 的情况(可能只是空闲中断误触发)
if (data_len > 0)
{
// 将数据交给应用层处理(例如存入队列或直接处理)
ProcessReceivedData(rx_buffer, write_index, data_len);
// 更新写指针
write_index = current_write;
}
}
}
```
## 四、边界条件分析(重点)
### 1. 缓冲区回绕(Wrap-around)
环形缓冲区最大的陷阱是**回绕**。当 DMA 写入地址到达缓冲区末尾时,会回绕到开头。此时计算数据长度必须分段处理:
```c
// 假设 write_index = 200,current_write = 50,BUFFER_SIZE = 256
// 数据实际分为两段:200~255 和 0~50
// 长度 = (256 - 200) + 50 = 106
```
### 2. 半传输中断与空闲中断的竞争
DMA 循环模式下,当传输过半时触发半传输中断(HT),传输完成时触发传输完成中断(TC)。这两个中断可以用来处理数据,但**必须与空闲中断协调**,否则可能重复处理或漏处理。
**推荐策略**:
- 半传输中断和传输完成中断中,仅更新 `write_index`(或直接处理数据),但要注意与空闲中断的互斥。
- 更简单的方法:**只使用空闲中断**,因为空闲中断已经能确定一帧结束,且 DMA 循环模式会持续接收,不会丢失数据(只要应用层处理速度足够快)。
### 3. 数据溢出(Overrun)
如果应用层处理速度慢于 DMA 写入速度,缓冲区会被覆盖。此时需要检测溢出:
```c
// 在空闲中断中,如果 data_len 大于 BUFFER_SIZE - 1,说明溢出
if (data_len >= BUFFER_SIZE)
{
// 溢出处理:丢弃旧数据或重置缓冲区
read_index = current_write;
write_index = current_write;
}
```
### 4. 空闲中断误触发
在某些情况下(如线路噪声),空闲中断可能在没有完整帧时触发。此时 `data_len` 可能为 0 或极小,应过滤掉:
```c
if (data_len == 0) return; // 忽略空帧
```
## 五、完整代码示例(简化版)
```c
// main.c
int main(void)
{
// 初始化时钟、GPIO、USART1、DMA(略)
UART_DMA_Config();
UART_IDLE_Config();
while (1)
{
// 应用层从缓冲区读取数据
if (read_index != write_index)
{
uint8_t data = rx_buffer[read_index];
read_index = (read_index + 1) % BUFFER_SIZE;
// 处理数据...
}
}
}
// 空闲中断处理(已包含边界处理)
void ProcessReceivedData(uint8_t *buf, uint16_t start, uint16_t len)
{
// 示例:将数据打印或存储
for (uint16_t i = 0; i < len; i++)
{
uint16_t idx = (start + i) % BUFFER_SIZE;
// 处理 buf[idx]
}
}
```
## 六、注意事项与优化建议
- **缓冲区大小**:建议设为 2 的幂,这样 `(index + 1) & (BUFFER_SIZE - 1)` 比取模运算更快。
- **临界区保护**:在中断中更新 `write_index`,在主循环中读取,需考虑原子性。可使用 `__disable_irq()` 或使用 volatile 并确保读写顺序。
- **DMA 半传输中断的用途**:如果应用层需要实时处理数据,可以在半传输中断中处理前半部分,在传输完成中断中处理后半部分,但需注意与空闲中断的同步。
- **多帧连续接收**:空闲中断可以处理连续多帧,但若两帧间隔极短,可能合并为一帧。此时需要应用层自定义协议(如帧头帧尾)来区分。
- **性能考量**:空闲中断中避免耗时操作,仅更新指针和拷贝数据,实际处理放到主循环。
## 七、总结
通过 DMA 环形缓冲区 + 空闲中断,可以实现高效的 STM32 不定长串口接收。关键在于理解环形缓冲区的回绕、DMA 计数器的更新时机,以及空闲中断与 DMA 中断的协同。本文分析的边界条件(回绕、溢出、误触发)是实际项目中常见的坑,掌握后能显著提升代码的健壮性。建议读者在开发板上实际测试,观察不同数据长度和频率下的行为,加深理解。