UART 不定长帧接收:DMA 环形缓冲区中半满中断与空闲中断的竞争处理
👁 2 阅读 · 2026-08-27 · 嵌入式
在嵌入式开发中,使用 DMA 环形缓冲区接收 UART 不定长帧是常见需求,但半满中断与空闲中断的竞争常导致数据错乱或丢失。本文深入分析两者触发机制与竞争场景,给出基于状态机的精准处理策略,并附完整 STM32 代码示例,助你构建稳定可靠的接收方案。
# 用 DMA 环形缓冲区实现 UART 不定长帧接收:精准处理半满中断与空闲中断的竞争
## 一、背景与挑战
在 STM32 等 MCU 上,UART 接收不定长帧时,常见方案是 DMA + 环形缓冲区。DMA 负责将数据从外设搬运到内存,CPU 通过中断感知数据到达。但 DMA 的半满中断(Half Transfer)和 UART 的空闲中断(IDLE)可能同时发生,若处理不当,会导致数据重复读取、丢失或帧错位。
## 二、原理剖析
### 1. DMA 半满中断与传输完成中断
- 当 DMA 配置为循环模式(Circular Mode)时,缓冲区被分为两半:上半区和下半区。
- 半满中断(HT)在 DMA 传输到缓冲区中点时触发,传输完成中断(TC)在传输到缓冲区末尾时触发。
- 这两个中断用于通知 CPU 可以安全处理已接收的数据,而不会与 DMA 写入冲突。
### 2. UART 空闲中断
- 空闲中断在 UART 接收线上检测到空闲(即无数据)时触发,通常用于判断一帧数据结束。
- 空闲中断与 DMA 配合时,CPU 需要从 DMA 缓冲区中读取当前已接收的数据,但此时 DMA 可能仍在写入。
### 3. 竞争场景
假设缓冲区大小为 256 字节,DMA 循环模式。
- **场景 A**:接收数据长度超过 128 字节,DMA 触发半满中断,CPU 处理上半区数据。此时若数据流短暂停顿,UART 触发空闲中断,CPU 又去处理整个缓冲区,导致上半区数据被重复处理。
- **场景 B**:接收数据长度小于 128 字节,DMA 未触发半满中断,空闲中断触发,CPU 需要读取当前 DMA 位置之前的数据,但 DMA 可能正在写入,导致读取不一致。
## 三、处理策略
核心思想:**用状态机管理缓冲区读写位置,并利用 DMA 的当前数据指针(NDTR)来精确计算有效数据长度。**
### 1. 缓冲区设计
- 定义环形缓冲区,使用两个指针:读指针(CPU 使用)和写指针(DMA 更新)。
- 在 DMA 中断(HT/TC)中更新写指针,在空闲中断中根据当前 DMA 位置更新写指针并触发帧处理。
### 2. 中断优先级与嵌套
- 将 DMA 中断优先级设为高于 UART 空闲中断,确保 DMA 中断能及时处理,避免数据覆盖。
- 在空闲中断中,先关闭 DMA 中断(或暂停 DMA),再读取数据,处理完后再恢复。
### 3. 状态机实现
定义以下状态:
- `IDLE`:等待数据。
- `RECEIVING`:正在接收数据。
- `FRAME_READY`:一帧数据完整,待处理。
在中断中更新状态,在主循环中处理帧数据。
## 四、代码实现(基于 STM32 HAL 库)
### 1. 配置 DMA 和 UART
```c
// 缓冲区定义
#define BUFFER_SIZE 256
uint8_t dma_buffer[BUFFER_SIZE];
// 环形缓冲区结构
typedef struct {
uint16_t head; // 读指针
uint16_t tail; // 写指针(由中断更新)
uint8_t buffer[BUFFER_SIZE];
} ring_buffer_t;
ring_buffer_t rx_ring;
// 初始化 DMA 接收
void uart_dma_init(void) {
// 使能 UART 空闲中断
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
// 启动 DMA 循环接收
HAL_UART_Receive_DMA(&huart1, dma_buffer, BUFFER_SIZE);
// 初始化环形缓冲区
rx_ring.head = 0;
rx_ring.tail = 0;
}
```
### 2. DMA 中断处理(半满和完成)
```c
void DMA1_Channel5_IRQHandler(void) {
if (__HAL_DMA_GET_FLAG(&hdma_usart1_rx, DMA_FLAG_HT)) {
__HAL_DMA_CLEAR_FLAG(&hdma_usart1_rx, DMA_FLAG_HT);
// 更新写指针到缓冲区中点
rx_ring.tail = BUFFER_SIZE / 2;
// 处理上半区数据
process_data(0, BUFFER_SIZE / 2);
}
if (__HAL_DMA_GET_FLAG(&hdma_usart1_rx, DMA_FLAG_TC)) {
__HAL_DMA_CLEAR_FLAG(&hdma_usart1_rx, DMA_FLAG_TC);
// 更新写指针到缓冲区末尾
rx_ring.tail = BUFFER_SIZE;
// 处理下半区数据
process_data(BUFFER_SIZE / 2, BUFFER_SIZE / 2);
}
}
```
### 3. UART 空闲中断处理
```c
void USART1_IRQHandler(void) {
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_IDLE);
// 暂停 DMA,防止写入冲突
HAL_DMA_Abort(&hdma_usart1_rx);
// 获取当前 DMA 剩余计数
uint16_t remaining = __HAL_DMA_GET_COUNTER(&hdma_usart1_rx);
uint16_t current_pos = BUFFER_SIZE - remaining;
// 更新写指针到当前位置
rx_ring.tail = current_pos;
// 处理从上次读指针到当前写指针的数据
process_data(rx_ring.head, (rx_ring.tail - rx_ring.head + BUFFER_SIZE) % BUFFER_SIZE);
// 重新启动 DMA 接收
HAL_UART_Receive_DMA(&huart1, dma_buffer, BUFFER_SIZE);
// 重置读指针
rx_ring.head = rx_ring.tail;
}
}
```
### 4. 数据解析函数
```c
void process_data(uint16_t start, uint16_t len) {
// 从环形缓冲区中读取数据,进行帧解析
for (uint16_t i = 0; i < len; i++) {
uint8_t byte = dma_buffer[(start + i) % BUFFER_SIZE];
// 处理字节,例如存入帧缓冲区
}
// 更新读指针
rx_ring.head = (rx_ring.head + len) % BUFFER_SIZE;
}
```
## 五、注意事项
- **缓冲区大小**:应大于最大帧长,且为 2 的幂次方,便于取模运算。
- **临界区保护**:在中断中更新 `rx_ring.tail` 时,主循环可能正在读取,需使用临界区或原子操作。
- **DMA 暂停与恢复**:在空闲中断中暂停 DMA 后,必须确保在重新启动前,DMA 已经完全停止,否则可能丢失数据。
- **半满中断与空闲中断同时触发**:若两者同时发生,先处理半满中断(更新 tail),再处理空闲中断(根据当前 DMA 位置更新 tail),避免重复处理。
- **测试验证**:使用不同长度的数据帧(小于半满、等于半满、大于半满)进行压力测试,确保逻辑正确。
## 六、总结
通过合理设计环形缓冲区、利用 DMA 的 HT/TC 中断和 UART 空闲中断,并采用状态机管理,可以有效解决半满中断与空闲中断的竞争问题。关键在于精确控制读写指针,并确保在中断处理中数据的一致性。此方案适用于大部分嵌入式平台,具有较高的可靠性和实时性。