STM32 UART+DMA+IDLE 接收完整实现:从“能跑“到“不丢包“

串口接收不定长数据,几乎每个 STM32 工程师都写过。最常见的解法就是「DMA + 空闲中断 IDLE」:DMA 把字节搬进内存,IDLE 一触发就知道一帧收完了。网上教程一抓一大把,但跑得起来和用得稳妥是两回事。高波特率下丢包、H7 上读到脏数据、ISR 里处理太久卡死别的任务,这些坑教程很少讲全。

这篇把"完整实现"拆开:先讲三者各自干什么,再给两套可编译方案(普通 DMA 重启版 + 循环 DMA 环形缓冲版),最后把 Cache 一致性、NDTR 竞态、RTOS 集成这几个真坑一次说清。代码基于 STM32F4 HAL,H7 差异单独标。

一、三个角色各自的活:为什么是它们仨

不定长串口接收的本质难点只有一个:你不知道一帧有多长,又不想每收一个字节就打断 CPU。

  • UART 外设 :负责把 RX 引脚上的串行位流,按波特率采样、去掉起始/停止位,拼成并行字节,送进 USARTx->DR(数据寄存器)。它只管"到字节了",不管"帧到哪了"。

  • DMA :总线主设备,被授权后可以自己发起总线事务 ,把 USARTx->DR 里的字节直接搬到你指定的 SRAM 缓冲,全程不经过 CPU 寄存器、不进流水线。CPU 在这段时间里可以去干别的,甚至进低功耗。

  • IDLE 中断 :UART 检测到总线上连续一个字节时间(1 个 frame,含停止位)没有新数据时,置 IDLE 标志并触发中断。它就是我们判断"一帧结束了"的天然信号,不用约定长度,也不用靠超时定时器轮询。

三者组合的逻辑一句话:UART 收 → DMA 搬 → 总线安静了 IDLE 喊一嗓子 → CPU 才来处理这一帧。 CPU 在整个传输过程中基本是"隐身"的。

二、核心机制:IDLE 怎么算长度

IDLE 触发后,你得知道"这次搬了多少字节"。DMA 控制器里有个寄存器 NDTR(Number of Data items To Receive,不同系列叫 CNDTR/NDT):它记录还剩多少字节没搬

假设缓冲大小 RX_BUF_SIZE,DMA 从满计数 RX_BUF_SIZE 开始递减,每搬一个字节减 1。于是:

本次接收长度 = RX_BUF_SIZE - DMA 当前剩余计数
= RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_uart_rx)

这个"剩余计数"就是解帧的关键。注意:读 NDTR 的时机必须是在 IDLE 触发、且下一字节还没到来之前,否则计数会被后续数据改掉。这正是后面普通 DMA 方案的竞态来源。

IDLE 标志的清除也有坑,不同系列不一样:

  • F1/F4 :必须先读 USARTx->SR,再读 USARTx->DR,标志才清。HAL 提供 __HAL_UART_CLEAR_IDLEFLAG(&huart)

  • 清不干净,IDLE 会一直反复进中断。

三、CubeMX / HAL 配置要点

  1. 打开目标 UART,Mode 设 Asynchronous

  2. DMA Settings 里添加 USARTx_RX 的 DMA 请求:

    Direction:Peripheral To Memory

    Mode :先选 Normal(普通模式,方案 A);要上环形缓冲再选 Circular(方案 B)

    Increment Address:Peripheral 不增、Memory 自增

    Data Width:Byte(外设和内存都 Byte)

  3. IDLE 中断不在 CubeMX 的 NVIC 勾选项里 ,需要代码手动开:

    cpp 复制代码
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

    通常在 MX_USART1_UART_Init() 之后、启动 DMA 之前调用一次。

  4. 启动接收:

    复制代码

    HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);

四、方案 A:普通 DMA + IDLE 重启(最简可编译版)

思路:DMA 设为 Normal,收满 RX_BUF_SIZE 或 IDLE 触发时,在回调里算出长度、拷走数据、再重新启动 DMA。

cpp 复制代码
#define RX_BUF_SIZE  128
uint8_t rx_buf[RX_BUF_SIZE];
volatile uint8_t rx_done = 0;
volatile uint16_t rx_len = 0;

void MX_USART1_UART_Init(void)
{
    // ... HAL 自动生成 ...
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);   // 手动开 IDLE
    HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
}

// IDLE 中断统一入口:UART 全局中断里判断
void USART1_IRQHandler(void)
{
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
    {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);         // 先清标志
        rx_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&huart1.hdmarx);
        rx_done = 1;                                // 通知主循环/任务
        // 重启 DMA(Normal 模式收完就停了,必须重开)
        HAL_UART_DMAStop(&huart1);
        HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
    }
    HAL_UART_IRQHandler(&huart1);                   // 交给 HAL 处理其他标志
}

主循环里:

复制代码
cpp 复制代码
if (rx_done)
{
    rx_done = 0;
    process_frame(rx_buf, rx_len);   // 真正解析一帧
}

方案 A 的两个硬伤

  1. NDTR 竞态HAL_UART_DMAStop 之前如果又来一字节,长度算错;更糟的是 Normal 模式下 DMA 收满 RX_BUF_SIZE自动停止 ,此时若帧还没结束、字节继续进来就会直接丢

  2. 溢出即丢 :一旦一帧超过 RX_BUF_SIZE,普通模式无能为力。

适合:波特率不高(≤115200)、帧长可控、对偶发丢包不敏感的场景。

五、方案 B:循环 DMA + 环形缓冲(高吞吐不丢包)

把 DMA 设成 Circular 模式,它永不停止 ,在 rx_buf 里循环写;我们在上层维护一个读指针 rd,配合一个"已被 DMA 写到哪了"的写指针(由 NDTR 反推),构成一个单生产者(DMA)/单消费者(CPU)环形队列。只要消费速度跟得上,理论上不丢包。

复制代码
cpp 复制代码
#define RX_BUF_SIZE  256          // 必须是 2 的幂,便于掩码回绕
uint8_t rx_buf[RX_BUF_SIZE];
volatile uint16_t rd = 0;         // 应用层读指针

// 取当前 DMA 已写到的位置(写指针由剩余计数反推)
static inline uint16_t dma_wr(void)
{
    uint16_t cnt = __HAL_DMA_GET_COUNTER(&huart1.hdmarx); // 剩余
    return (RX_BUF_SIZE - cnt) & (RX_BUF_SIZE - 1);       // 已写位置,掩码回绕
}

// 从环形缓冲读出所有可用字节,返回个数(处理回绕拷贝)
uint16_t uart_ring_read(uint8_t *out, uint16_t max)
{
    uint16_t wr = dma_wr();
    uint16_t avail = (wr - rd) & (RX_BUF_SIZE - 1);
    uint16_t n = avail < max ? avail : max;
    for (uint16_t i = 0; i < n; i++)
    {
        out[i] = rx_buf[(rd + i) & (RX_BUF_SIZE - 1)];
    }
    rd = (rd + n) & (RX_BUF_SIZE - 1);
    return n;
}

什么时候"收割"?两个入口都行:

  • IDLE 触发时 :一帧大概率结束了,调 uart_ring_read 把积攒的字节交给解析器;

  • DMA 半满/全满中断(HT/TC):高吞吐下 IDLE 可能来得很晚,配合 HT/TC 中断周期性收割,进一步降低缓冲压力。

环形缓冲的关键在 掩码回绕& (SIZE-1)),前提是 RX_BUF_SIZE 是 2 的幂。这样 wr 越过末尾会自动绕回开头,无需 if 判断。

六、两个真坑,教程很少一次讲全

坑 1:H7/F7 的 D-Cache 一致性

F4 及以下是单总线、无独立 D-Cache,DMA 写的内存 CPU 立刻能看到。但 STM32F7/H7 带 D-Cache(且默认开启):DMA 把数据写进 SRAM,CPU 却可能从 Cache 里读到旧值,表现出来的就是"数据偶尔错位、偶发全 0、时好时坏"。

解决:在 CPU 读缓冲之前,把这块内存的 Cache 行作废,强制从 SRAM 重新加载:

复制代码
cpp 复制代码
// 读之前(H7/F7)
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, RX_BUF_SIZE);
process_frame(rx_buf, rx_len);

若缓冲地址/长度不是 32 字节对齐,Invalidate 可能误伤相邻 Cache 行,稳妥做法是把 rx_buf__ALIGN(32) 对齐,或只 Invalidate 实际接收到的那一段。这是 F7/H7 上"DMA 收数不对"的头号原因。

坑 2:ISR 里别干重活

IDLE 中断是硬中断 ,在里面调 printf、解协议、甚至 malloc 都会拖死系统,还可能破坏 RTOS 临界区。正确姿势是ISR 只置标志/发信号量,解析放到任务里

复制代码
cpp 复制代码
// 在 IDLE 回调里(RTOS 环境)
void HAL_UART_IdleCpltCallback(UART_HandleTypeDef *huart)
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(uart_rx_sem, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

任务侧阻塞等信号量,拿到后 uart_ring_read + 解析,干净且不影响其他任务调度。

七、选型建议

维度 方案 A 普通 DMA 重启 方案 B 循环 DMA 环形缓冲
实现复杂度 低,几十行 中,需维护双指针
高波特率吞吐 一般,有暂停窗口 强,DMA 不停车
超长帧/突发 易丢 不丢(消费跟得上)
中断频率 每帧一次 每帧一次 + 可选 HT/TC
适用 调试口、低速指令 模块通信、GPS/4G 上云

工程实践:只要不是纯调试口,直接上方案 B。它不比 A 难多少,却省掉了"偶发丢包查三天"的苦。

八、小结

UART+DMA+IDLE 不是新把戏,但"完整实现"和"demo"之间隔着三道坎:是否处理了 NDTR 竞态与溢出、是否考虑了 Cache 一致性、ISR 是否只做最小动作。把环形缓冲 + 掩码回绕 + Cache 作废 + 信号量这四件套吃透,这套接收机就能直接进产品。

你现在的项目里串口接收用的是哪种?有没有遇到过 H7 上"数据偶尔错"的灵异事件?评论区聊聊。

相关推荐
louis.D.5 小时前
Git 本地单线速查
单片机
小李不想当小白6 小时前
PCB结构与组成(PCB设计入门学习笔记)
经验分享·笔记·单片机·嵌入式硬件·学习·pcb工艺·pcb
新晨单片机设计6 小时前
S004R-基于STM32单片机智能门禁系统(密码、指纹、人脸、刷卡、蓝牙)【Proteus仿真+Keil程序+原理图】
stm32·单片机·嵌入式硬件·proteus
至为芯6 小时前
PY32F071至为芯支持32位ARM内核的高主频MCU单片机
单片机·集成电路·芯片
桀人7 小时前
MCU系统架构——总线——统一编址
stm32·单片机·嵌入式硬件
朝发如雪16 小时前
快速掌握单片机基础(3)(PWM)(通信协议基础)(串口通信时序与参数)(电气特性与电平标准)(51单片机UART寄存器配置)(串口应用层协议)
单片机
天空'之城19 小时前
单片机基础核心知识点汇总(四十)
单片机·嵌入式硬件
smartpi_ai19 小时前
离线语音模块误识别问题全面解决方案:命令词设计、防误触与灵敏度调优指南
人工智能·单片机·语音识别
LCG元19 小时前
STM32F103 驱动 HX711 称重实战:DOUT 就绪时序、增益切换陷阱与两点标定精度实测
stm32·单片机·嵌入式硬件