STM32 HAL库DMA+空闲中断接收不定长串口数据的环形缓冲区实现与溢出防护
一、为什么需要DMA+空闲中断+环形缓冲区
传统HAL_UART_Receive()是阻塞式接收,必须预先知道数据长度,且会长时间占用CPU。常见改进方案有三种:
- 逐字节中断接收:每来一个字节进一次中断,高波特率下中断开销大,易丢数据。
- DMA+固定长度:长度固定,无法处理不定长帧。
- DMA+空闲中断:DMA自动搬运数据,总线空闲时触发IDLE中断,一次性取走整帧,CPU占用极低。
再叠加环形缓冲区,可把"接收"与"处理"解耦:中断只负责把数据写入环形区,主循环按需读取,天然适配生产者-消费者模型。
二、原理讲解
2.1 空闲中断(IDLE)
USART在检测到接收引脚持续一个字节时间的高电平(即总线空闲)后置位IDLE标志。此时DMA已把本帧数据搬入内存,我们在中断中读取NDTR寄存器得到剩余计数,即可算出本帧长度:
本帧长度 = 缓冲区总长 - __HAL_DMA_GET_COUNTER(hdma)
2.2 环形缓冲区
用head(写指针,仅中断写)和tail(读指针,仅主循环写)两个索引管理固定大小数组,通过取模实现回绕。单生产者单消费者场景下,无需加锁,只需保证读写指针的原子性(32位MCU上对uint16_t的读写是原子的)。
2.3 溢出防护要点
- 写溢出:剩余空间不足时丢弃新数据或覆盖最旧数据,并置溢出标志。
- DMA半满/全满中断:作为IDLE的补充,防止一帧数据超过DMA缓冲长度时丢包。
- 错误恢复:ORE(溢出错误)、FE、NE等错误发生后需清除标志并重启DMA,否则接收会永久停止。
三、CubeMX配置步骤
- 使能USART1,模式设为Asynchronous,波特率115200。
- 在USART1的NVIC中勾选
USART1 global interrupt。 - 在DMA Settings中添加USART1_RX,模式选
Circular(循环模式),优先级Medium。 - 使能DMA对应通道中断(用于半满/全满回调)。
- 生成代码,确认
MX_DMA_Init()在MX_USART1_UART_Init()之前调用。
四、完整代码示例
4.1 环形缓冲区定义
#define RB_SIZE 512
typedef struct {
uint8_t buf[RB_SIZE];
volatile uint16_t head; // 写指针,仅中断修改
volatile uint16_t tail; // 读指针,仅主循环修改
volatile uint8_t overflow;
} ringbuf_t;
static ringbuf_t s_rb;
static uint8_t s_dma_buf[64]; // DMA 搬运缓冲
4.2 写入与读取接口
/* 中断上下文调用:写入 len 字节,返回实际写入数 */
uint16_t rb_write(ringbuf_t *rb, const uint8_t *data, uint16_t len)
{
uint16_t free = RB_SIZE - ((rb->head - rb->tail + RB_SIZE) % RB_SIZE) - 1;
if (len > free) {
rb->overflow = 1;
len = free; // 丢弃超出部分,也可改为覆盖最旧数据
}
for (uint16_t i = 0; i < len; i++) {
rb->buf[rb->head] = data[i];
rb->head = (rb->head + 1) % RB_SIZE;
}
return len;
}
/* 主循环调用:读取最多 len 字节 */
uint16_t rb_read(ringbuf_t *rb, uint8_t *data, uint16_t len)
{
uint16_t avail = (rb->head - rb->tail + RB_SIZE) % RB_SIZE;
if (len > avail) len = avail;
for (uint16_t i = 0; i < len; i++) {
data[i] = rb->buf[rb->tail];
rb->tail = (rb->tail + 1) % RB_SIZE;
}
return len;
}
4.3 启动接收与空闲中断处理
void uart_rx_start(void)
{
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
HAL_UART_Receive_DMA(&huart1, s_dma_buf, sizeof(s_dma_buf));
}
/* 在 stm32f1xx_it.c 的 USART1_IRQHandler 中调用 */
void uart_idle_handler(void)
{
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
uint16_t remain = __HAL_DMA_GET_COUNTER(huart1.hdmarx);
uint16_t len = sizeof(s_dma_buf) - remain;
if (len > 0) {
rb_write(&s_rb, s_dma_buf, len);
}
/* 循环模式下 DMA 自动重装,无需重启;若为 Normal 模式需重新启动 */
}
}
4.4 DMA半满/全满回调(防超长帧丢包)
void HAL_UART_RxHalfCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
rb_write(&s_rb, s_dma_buf, sizeof(s_dma_buf) / 2);
}
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
rb_write(&s_rb, s_dma_buf + sizeof(s_dma_buf) / 2,
sizeof(s_dma_buf) / 2);
}
注意:半满/全满回调与IDLE处理存在数据重叠风险。工程上常用两种做法:一是只用IDLE,DMA缓冲足够大;二是只用半满/全满,按块处理。二者混用时需在IDLE中记录已消费偏移,避免重复写入。
4.5 错误恢复
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
__HAL_UART_CLEAR_OREFLAG(huart);
__HAL_UART_CLEAR_FEFLAG(huart);
__HAL_UART_CLEAR_NEFLAG(huart);
HAL_UART_AbortReceive(huart);
uart_rx_start(); // 重新挂载 DMA
}
}
4.6 主循环消费
uint8_t frame[128];
while (1) {
uint16_t n = rb_read(&s_rb, frame, sizeof(frame));
if (n > 0) {
protocol_parse(frame, n); // 交给协议层处理
}
if (s_rb.overflow) {
s_rb.overflow = 0;
log_warn("uart rx overflow");
}
}
五、注意事项
- DMA模式选择:循环模式免去每帧重启DMA,但IDLE与半满/全满回调会重复搬运同一段数据,务必二选一或做偏移管理。
-
指针原子性:
head/tail用volatile修饰;若在Cortex-M0等不支持字节对齐原子访问的平台,需关中断保护。 - 缓冲区大小:环形区应大于"最大帧长×最大待处理帧数",DMA缓冲建议不小于最大单帧长度。
- IDLE标志清除顺序:先读SR再读DR,HAL宏已封装,勿手动乱序。
- 错误标志必须清除:ORE不清会导致后续接收全部失效,这是现场最常见故障。
-
调试观察:可用
__HAL_DMA_GET_COUNTER在调试器中实时查看剩余计数,快速定位丢包位置。
六、小结
DMA负责"搬",空闲中断负责"切帧",环形缓冲区负责"解耦",三者组合是STM32串口不定长接收的经典高效方案。真正决定工程稳定性的,往往是溢出判断、错误恢复和回调重叠这些细节。把上述防护补齐,即可在115200乃至更高波特率下稳定接收不定长数据。
暂无评论,来抢沙发~