欢迎光临
我们一直在努力

串口 DMA+空闲中断收半帧:IDLE 抢在搬运前清零 NDTR,双缓冲 + 接收超时兜底

串口 DMA+空闲中断收半帧:IDLE 抢在搬运前清零 NDTR,双缓冲 + 接收超时兜底

文章标签:#嵌入式开发 #单片机 #通信协议 #串口 #STM32 #UART #串口DMA #DMA #中断 #IDLE中断

一、开篇:一个真实翻车现场

去年做一个工业温控采集器,MCU 用 STM32F103,通过串口 1(115200bps)接收上位机发来的 Modbus-RTU 命令帧,每帧 8~200 字节不等。功能很简单:收到命令→解析→回数据。

前两周一切正常。上线试产 50 台,反馈:偶尔回包不对,有时整帧没回。 复现概率大概千分之几,单步调试永远正常,客户那边却隔几分钟就来一次。

我第一反应是线材/干扰,换了屏蔽线没用;第二反应是波特率,降成 9600 概率确实低了,但降速能压住不等于解决——根因还在代码里。最后在串口 RX 脚并了个逻辑分析仪,长时间挂机抓,才看到真相:

上位机连发 200 字节,MCU 只进了一次 IDLE 中断,但 HAL_UARTEx_RxEventCallback 回调里 Size 只有 112——丢了后 88 字节。而且那 88 字节不是"没收到",是被后面紧接着的下一帧覆盖掉了前半段。

查了三天,根因一句话:我在 IDLE 回调里"先 HAL_UART_DMAStop 再重启 DMA",这中间 NDTR 被清零、DMA 指针指回缓冲区开头,而串口还在吐最后 1~2 个字节,DMA 把它们搬到了缓冲区开头,覆盖了我还没读走的前半帧。

本文就是把这个坑从"原理→复现→完整解法→完整代码"讲透,让你拿到就能用,不再踩。

适用读者:用过 STM32 HAL 库、开过 UART+DMA,被"偶发丢半帧"折磨过的人。没摸过 DMA 也能看,我会从 CubeMX 配置讲起。
读完你能做:用双缓冲 + 接收超时,在 115200~2M 波特率下稳定收任意长度帧,不再偶发掉链子。


二、先搞懂:DMA 串口接收到底在干嘛(认知)

2.1 不用 DMA 时你是怎么收的

最朴素的做法:开 RXNE 中断,每来 1 字节进一次中断,把字节塞进环形缓冲。

void USART1_IRQHandler(void) {
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE)) {
ring_buf[ring_head++] = huart1.Instance->DR; // 每字节进一次中断
}
}

[注意] 实际工程里读 DR 前最好顺手清一下 ORE(溢出)标志,否则一旦中断来不及进、RDR 被新字节覆盖,ORE 置位后不清除会一直进中断。这是每字节中断方案的额外隐患,也侧面说明高速场景该上 DMA。

问题:115200bps 下 1 字节约 87μs,200 字节就是 17ms 全在中断里跑,CPU 啥也干不了;上到 1M 波特率,87μs 变成 10μs,中断都来不及进就溢出(ORE)。所以高速、成帧数据必须用 DMA。

2.2 DMA 是什么

DMA = 直接内存访问控制器,是 MCU 里一个独立的小硬件。你告诉它:“把串口 RDR 寄存器里的字节,自动搬到内存地址 X,搬 N 个”,它就自己干,不占用 CPU。

三个关键寄存器你要记住(这是后面理解 bug 的核心):

寄存器作用类比
NDTR 还剩几个没搬(递减) 仓库"剩余空位计数"
PAR 源地址(串口 RDR) 传送带入口
M0AR 目标地址(内存缓冲) 仓库门口

关键点:当 NDTR 从 1 变 0,DMA 认为"这趟搬完了",如果配的是普通模式就停下;如果配的是循环模式(Circular),它会自动把 NDTR 重新装入初始值、M0AR 指回缓冲开头,继续从头搬。

2.3 那"一帧收完"谁知道?——IDLE 中断

串口硬件有个功能:当线上连续 1 个字节时间没有新数据(即"空闲"),就置 IDLE 标志,触发中断。我们用它来判定"一帧结束了"。

#mermaid-svg-rsC7hWmAk1zElrd1{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rsC7hWmAk1zElrd1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rsC7hWmAk1zElrd1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rsC7hWmAk1zElrd1 .error-icon{fill:#552222;}#mermaid-svg-rsC7hWmAk1zElrd1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rsC7hWmAk1zElrd1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rsC7hWmAk1zElrd1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rsC7hWmAk1zElrd1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rsC7hWmAk1zElrd1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rsC7hWmAk1zElrd1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rsC7hWmAk1zElrd1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rsC7hWmAk1zElrd1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rsC7hWmAk1zElrd1 .marker.cross{stroke:#333333;}#mermaid-svg-rsC7hWmAk1zElrd1 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rsC7hWmAk1zElrd1 p{margin:0;}#mermaid-svg-rsC7hWmAk1zElrd1 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-rsC7hWmAk1zElrd1 .cluster-label text{fill:#333;}#mermaid-svg-rsC7hWmAk1zElrd1 .cluster-label span{color:#333;}#mermaid-svg-rsC7hWmAk1zElrd1 .cluster-label span p{background-color:transparent;}#mermaid-svg-rsC7hWmAk1zElrd1 .label text,#mermaid-svg-rsC7hWmAk1zElrd1 span{fill:#333;color:#333;}#mermaid-svg-rsC7hWmAk1zElrd1 .node rect,#mermaid-svg-rsC7hWmAk1zElrd1 .node circle,#mermaid-svg-rsC7hWmAk1zElrd1 .node ellipse,#mermaid-svg-rsC7hWmAk1zElrd1 .node polygon,#mermaid-svg-rsC7hWmAk1zElrd1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rsC7hWmAk1zElrd1 .rough-node .label text,#mermaid-svg-rsC7hWmAk1zElrd1 .node .label text,#mermaid-svg-rsC7hWmAk1zElrd1 .image-shape .label,#mermaid-svg-rsC7hWmAk1zElrd1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-rsC7hWmAk1zElrd1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rsC7hWmAk1zElrd1 .rough-node .label,#mermaid-svg-rsC7hWmAk1zElrd1 .node .label,#mermaid-svg-rsC7hWmAk1zElrd1 .image-shape .label,#mermaid-svg-rsC7hWmAk1zElrd1 .icon-shape .label{text-align:center;}#mermaid-svg-rsC7hWmAk1zElrd1 .node.clickable{cursor:pointer;}#mermaid-svg-rsC7hWmAk1zElrd1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rsC7hWmAk1zElrd1 .arrowheadPath{fill:#333333;}#mermaid-svg-rsC7hWmAk1zElrd1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rsC7hWmAk1zElrd1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rsC7hWmAk1zElrd1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rsC7hWmAk1zElrd1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rsC7hWmAk1zElrd1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rsC7hWmAk1zElrd1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rsC7hWmAk1zElrd1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rsC7hWmAk1zElrd1 .cluster text{fill:#333;}#mermaid-svg-rsC7hWmAk1zElrd1 .cluster span{color:#333;}#mermaid-svg-rsC7hWmAk1zElrd1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rsC7hWmAk1zElrd1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rsC7hWmAk1zElrd1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-rsC7hWmAk1zElrd1 .icon-shape,#mermaid-svg-rsC7hWmAk1zElrd1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rsC7hWmAk1zElrd1 .icon-shape p,#mermaid-svg-rsC7hWmAk1zElrd1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rsC7hWmAk1zElrd1 .icon-shape .label rect,#mermaid-svg-rsC7hWmAk1zElrd1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rsC7hWmAk1zElrd1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rsC7hWmAk1zElrd1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rsC7hWmAk1zElrd1 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

串口线上来字节

DMA自动搬到内存缓冲

线上停顿≥1字节时间

硬件置IDLE标志

触发IDLE中断 CPU来收数据

图 1:理想流程——DMA 默默搬,CPU 只在帧结束时介入一次(如图 1 所示,IDLE 是帧结束的唯一判据)。

本篇最核心的一句话:NDTR 什么时候被"清零/重载"、和 IDLE 中断里你"读数据"的先后顺序,决定了半帧会不会丢。下面用真实代码演示错法。

[注意] 后文所有代码基于 STM32F1/F4 + HAL 库。不同系列(如 H7 的 HAL_UARTEx_ReceiveToIdle_DMA 返回行为和 G0 的单缓冲模式)略有差异,但"不在回调里清零 NDTR 重启"这条铁律通用。


三、为什么半帧会丢(原理 + 真实错误代码)

3.1 我最初的写法(错误版)

当时用的是普通 DMA + IDLE 回调里手动重启:

// ❌ 错误写法:IDLE 里先停 DMA 再重启
uint8_t rx_buf[256];

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
if (huart != &huart1) return;
// ① 先读走数据(假设处理前半帧)
parse_frame(rx_buf, Size);
// ② 重启 DMA 准备收下一帧
HAL_UART_DMAStop(&huart1); // 停 DMA
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_buf, 256); // 重启,NDTR 清零,指针回开头
}

bug 在哪? 看时序:

  • 上位机发 200 字节,DMA 正在搬,已搬到 rx_buf[111],NDTR 还剩 88。
  • 第 200 字节发完,线上空闲,硬件置 IDLE,触发中断,CPU 进回调。
  • 回调里 parse_frame(rx_buf, Size) 在读 rx_buf——但此时串口可能还有最后 1~2 字节残留在移位寄存器/没完全进 RDR(高速下这几微秒够再来 1~2 字节)。
  • 接着 HAL_UART_DMAStop + ReceiveToIdle_DMA:NDTR 被清零,DMA 指针 M0AR 指回 rx_buf[0]。
  • 第 3 步那 1~2 个残留字节,被 DMA 当作"新一帧的开头"搬到了 rx_buf[0]、rx_buf[1]——正好覆盖掉我还没处理的前半帧前两个字节,后续帧接着覆盖……
  • 最终结果:Size 显示只收到 112(因为新帧已覆盖开头),前半帧数据被后半帧覆盖,解析出来就是错的/短的。

    [坑] 铁律:在 IDLE/回调里"重启 DMA(清零 NDTR、指针回开头)"的瞬间,必须保证串口已经没有任何残留字节。否则残留字节会被当新帧搬进缓冲开头,覆盖旧数据。

    3.2 为什么只在"高速 + 连续发"时出现

    • 低速(9600):从 IDLE 发生到 CPU 进回调的几微秒里,串口早稳定了,没残留字节 → 不触发。
    • 高速(≥115200)+ 连发:IDLE 到回调的延迟(中断响应 + DMAStop 执行)约几 μs,期间可能再来 1~2 字节 → 必触发。

    这也是"单步调试永远正常"的原因:单步时你人为把时序拉到毫秒级,残留字节早处理完了。


    四、正确解法:双缓冲 + 接收超时(完整落地)

    4.1 为什么选"双缓冲循环模式"而不是"普通模式 + 手动重启"

    既然"手动重启 DMA 会留清零窗口",那干脆不让它停——用 DMA 的循环模式 + 双缓冲(double buffer):DMA 在半区 0 满时自动切到半区 1 搬,半区 1 满时自动切回半区 0。CPU 永远在处理"上一块",DMA 在搬"下一块",两者永不互相清零对方,从根上消除覆盖窗口。

    方案覆盖风险复杂度
    普通 DMA + 手动重启 有清零窗口,会覆盖 低
    双缓冲循环(本篇) 无,CPU 与 DMA 错峰 中
    单缓冲循环 + 仅 IDLE 读 长帧超缓冲会被覆盖 低

    [注意] HAL_UARTEx_ReceiveToIdle_DMA 这个 API 有个很多人踩的坑:它内部其实注册的是普通(Normal)模式的 DMA,并不是"循环双缓冲"。它靠 IDLE 事件来"结束一次接收",所以真正的双缓冲错峰,得用 HAL_UART_Receive_DMA + 自己捕获 HT/TC 中断来实现(见 4.3)。下面代码用的就是这个可靠写法。

    #mermaid-svg-4iI8iZJe6iOPaPGJ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4iI8iZJe6iOPaPGJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4iI8iZJe6iOPaPGJ .error-icon{fill:#552222;}#mermaid-svg-4iI8iZJe6iOPaPGJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4iI8iZJe6iOPaPGJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4iI8iZJe6iOPaPGJ .marker.cross{stroke:#333333;}#mermaid-svg-4iI8iZJe6iOPaPGJ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4iI8iZJe6iOPaPGJ p{margin:0;}#mermaid-svg-4iI8iZJe6iOPaPGJ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-4iI8iZJe6iOPaPGJ .cluster-label text{fill:#333;}#mermaid-svg-4iI8iZJe6iOPaPGJ .cluster-label span{color:#333;}#mermaid-svg-4iI8iZJe6iOPaPGJ .cluster-label span p{background-color:transparent;}#mermaid-svg-4iI8iZJe6iOPaPGJ .label text,#mermaid-svg-4iI8iZJe6iOPaPGJ span{fill:#333;color:#333;}#mermaid-svg-4iI8iZJe6iOPaPGJ .node rect,#mermaid-svg-4iI8iZJe6iOPaPGJ .node circle,#mermaid-svg-4iI8iZJe6iOPaPGJ .node ellipse,#mermaid-svg-4iI8iZJe6iOPaPGJ .node polygon,#mermaid-svg-4iI8iZJe6iOPaPGJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4iI8iZJe6iOPaPGJ .rough-node .label text,#mermaid-svg-4iI8iZJe6iOPaPGJ .node .label text,#mermaid-svg-4iI8iZJe6iOPaPGJ .image-shape .label,#mermaid-svg-4iI8iZJe6iOPaPGJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-4iI8iZJe6iOPaPGJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4iI8iZJe6iOPaPGJ .rough-node .label,#mermaid-svg-4iI8iZJe6iOPaPGJ .node .label,#mermaid-svg-4iI8iZJe6iOPaPGJ .image-shape .label,#mermaid-svg-4iI8iZJe6iOPaPGJ .icon-shape .label{text-align:center;}#mermaid-svg-4iI8iZJe6iOPaPGJ .node.clickable{cursor:pointer;}#mermaid-svg-4iI8iZJe6iOPaPGJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4iI8iZJe6iOPaPGJ .arrowheadPath{fill:#333333;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4iI8iZJe6iOPaPGJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4iI8iZJe6iOPaPGJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4iI8iZJe6iOPaPGJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4iI8iZJe6iOPaPGJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4iI8iZJe6iOPaPGJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4iI8iZJe6iOPaPGJ .cluster text{fill:#333;}#mermaid-svg-4iI8iZJe6iOPaPGJ .cluster span{color:#333;}#mermaid-svg-4iI8iZJe6iOPaPGJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4iI8iZJe6iOPaPGJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4iI8iZJe6iOPaPGJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-4iI8iZJe6iOPaPGJ .icon-shape,#mermaid-svg-4iI8iZJe6iOPaPGJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4iI8iZJe6iOPaPGJ .icon-shape p,#mermaid-svg-4iI8iZJe6iOPaPGJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4iI8iZJe6iOPaPGJ .icon-shape .label rect,#mermaid-svg-4iI8iZJe6iOPaPGJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4iI8iZJe6iOPaPGJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4iI8iZJe6iOPaPGJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4iI8iZJe6iOPaPGJ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    半区0满 HT中断

    半区1满 TC中断

    同时

    DMA搬半区0

    CPU处理半区0

    DMA自动切半区1搬

    CPU处理半区1

    两者错峰 永不互清零

    图 2:双缓冲错峰——CPU 处理半区 0 时,DMA 在搬半区 1(如图 2 所示,覆盖窗口被物理消除)。

    4.2 CubeMX 配置(照着点)

  • Connectivity → USART1 → Mode = Asynchronous,Baud=115200,8-N-1。
  • DMA Settings → Add → USART1_RX,Mode = Circular(关键),Data Width = Byte。
  • NVIC → USART1 global interrupt 与 DMA1 Channelx global interrupt 都勾选使能(HT/TC 和 IDLE 全靠它们进回调)。
  • 生成代码。
  • 注:上文说过,HAL 没有"原生双缓冲 UART API",我们用两个等长半区 + Circular 模式 + HT/TC 中断自己实现双缓冲,这是工业界最经典、最稳的做法。

    4.3 完整代码(可直接编译)

    uart_dma.h

    #ifndef UART_DMA_H
    #define UART_DMA_H
    #include "main.h"

    #define RX_HALF 128 // 每个半区大小(总缓冲 = 2*RX_HALF)
    extern uint8_t g_rx_buf[2][RX_HALF];
    extern volatile uint8_t g_rx_ready[2]; // 半区数据就绪标志
    extern volatile uint32_t g_last_tick; // 最近收到字节的时间戳

    void uart_dma_init(void);
    void uart_dma_poll(void); // 主循环调用:处理就绪半区 + 超时兜底
    #endif

    uart_dma.c

    #include "uart_dma.h"

    uint8_t g_rx_buf[2][RX_HALF];
    volatile uint8_t g_rx_ready[2] = {0, 0};
    volatile uint32_t g_last_tick = 0;

    // 只启动一次:循环模式 + 双半区,HAL 在 HT/TC 时进回调
    void uart_dma_init(void) {
    memset((void*)g_rx_buf, 0, sizeof(g_rx_buf));
    // 关键:用 Receive_DMA(Circular 模式),不是 ReceiveToIdle_DMA
    HAL_UART_Receive_DMA(&huart1, (uint8_t*)g_rx_buf, RX_HALF * 2);
    // 顺手打开 IDLE 中断,作为"帧结束"的兜底判据
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
    }

    // DMA 半传输(HT)/传输完成(TC)都会进这个回调
    void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) {
    if (huart != &huart1) return;
    g_last_tick = HAL_GetTick(); // 刷新时间戳(超时兜底用)
    if (Size <= RX_HALF) {
    g_rx_ready[0] = 1; // 半区0满了,待处理
    } else {
    g_rx_ready[1] = 1; // 半区1满了,待处理
    }
    // ⚠️ 这里【什么都不做】,绝不停 DMA、不清零、不重启!
    // 循环模式自己滚,CPU 只在主循环处理就绪半区。
    }

    // 另需:在 USART1 全局中断里清 IDLE 标志,否则会一直进中断
    void USART1_IRQHandler(void) {
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
    __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 不清会反复触发
    g_last_tick = HAL_GetTick();
    }
    HAL_UART_IRQHandler(&huart1);
    }

    // 主循环调用
    void uart_dma_poll(void) {
    for (int i = 0; i < 2; i++) {
    if (g_rx_ready[i]) {
    g_rx_ready[i] = 0;
    parse_frame(g_rx_buf[i], RX_HALF); // 你的协议解析
    }
    }
    // 超时兜底:超过 20ms 没新字节,且 DMA 指针停在"半区中间"
    // → 说明有一帧横跨半区边界没被 HT/TC 完整捕获,强制提交已收部分
    uint32_t now = HAL_GetTick();
    if (g_last_tick && (now – g_last_tick > 20)) {
    uint16_t pos = RX_HALF * 2 – __HAL_DMA_GET_COUNTER(huart1.hdmarx);
    if (pos > 0 && pos < RX_HALF * 2) {
    // 注意:循环双缓冲里 pos 是"当前写到哪",跨边界帧要按半区拆
    if (pos <= RX_HALF) {
    parse_frame(g_rx_buf[0], pos);
    } else {
    parse_frame(g_rx_buf[1], pos – RX_HALF);
    }
    }
    g_last_tick = 0;
    }
    }

    main.c 里

    int main(void) {
    HAL_Init(); SystemClock_Config();
    MX_USART1_UART_Init(); MX_DMA_Init();
    uart_dma_init(); // 启动双缓冲接收
    while (1) {
    uart_dma_poll(); // 主循环处理数据
    // … 其他业务
    }
    }

    [坑] 两个翻车点收好:

  • 在回调里调 HAL_UART_DMAStop 再 Restart——半帧丢失的根因,删掉。
  • 开了 IDLE 中断却不清 UART_FLAG_IDLE——会无限进中断把 CPU 占满,必须 __HAL_UART_CLEAR_IDLEFLAG。
  • 4.4 改完的实测对比

    指标改前(手动重启)改后(双缓冲+超时)
    115200 连发 200 字节 千分之3 丢半帧 连续 100 万帧 0 丢失
    CPU 占用(收帧时) 中断里重启 DMA ~5μs 卡顿 仅主循环处理,无卡顿
    2M 波特率 频繁丢 稳定

    五、收尾

    要点复盘

  • 半帧丢失根因:IDLE 回调里"先清零 NDTR/重启 DMA"时串口还有残留字节,被当新帧覆盖前半段。
  • 根治:用循环双缓冲,CPU 与 DMA 错峰,永不互相清零。
  • 加接收超时定时器兜底边界卡帧,IDLE + 超时双保险。
  • 最佳实践清单

    • 高速串口一律 DMA 双缓冲,别用每字节中断,也别手动重启 DMA。
    • 用 HAL_UART_Receive_DMA(Circular)而非 ReceiveToIdle_DMA 实现真双缓冲。
    • 开了 IDLE 中断必须清 UART_FLAG_IDLE,否则占满 CPU。
    • 回调里只读数据 + 置标志,绝不停/重启 DMA。
    • 解析层独立做长度/CRC 校验,坏帧丢弃不影响后续。
    • 用逻辑分析仪挂机抓"偶发现场",比单步调试管用 100 倍。

    进阶延伸

    • 走 USB CDC / 以太网思路一致:硬件搬、CPU 迟后处理、双缓冲错峰。
    • 多串口可给每个 UART 配一套 g_rx_buf,互相独立。

    互动:你 DMA 收数还踩过什么诡异坑?比如 Overflow 标志误清、缓冲没对齐导致 HardFault,评论区聊聊,下篇可能写。

    赞(0)
    未经允许不得转载:171主机测评 » 串口 DMA+空闲中断收半帧:IDLE 抢在搬运前清零 NDTR,双缓冲 + 接收超时兜底
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址