
做嵌入式这些年UART DMA这个组合我调试过很多次每次觉得稳了总会在新的芯片型号上翻车。最近在STM32L4上做电表通讯模块遇到了一个非常典型的UART DMA collision问题DMA接收看起来正常但跑一段时间后数据流里会莫名其妙跳出一两个错字节甚至偶发整个缓冲区的数据位移。查遍寄存器才发现这不是单纯的数据错误而是DMA请求、UART状态位和缓冲区读写三方撞在一起。这篇文章就围绕这个collision问题把STM32L4上UART DMA的原理、配置、排查方法完整拆一遍。说清楚了你至少能在选型、配置、调试三个阶段各少踩一个坑。适合正用STM32L4 DMA做串口收发的开发者也适合从F1/F4迁到L4后发现串口行为有差异的朋友。1. 问题背景UART DMA为什么会在STM32L4上“撞车”1.1 一个典型的“看起来正常但偶尔丢数据”的场景先讲我实际遇到的场景。设备主控是STM32L431UART2负责接收从机主动上报的数据波特率115200单帧长度不定最长256字节。最初我用的是最朴素的逻辑CubeMX里使能UART2的DMA接收DMA模式选Circular然后在主循环里直接读接收缓冲区。跑短时测试一切正常可是放在老化测试台上跑半个小时后数据开始出现一帧错位有时是帧头找不到有时是CRC校验失败。用示波器抓UART RX引脚波形电平完全正常说明问题是MCU内部处理没跟上而不是物理链路有问题。后来我把问题一步步缩小发现根子在于DMA接收和CPU读缓冲区之间发生了竞争而且DMA中断与UART空闲事件也存在处理顺序冲突。这个现象在F103时代也偶有发生但STM32L4给我印象更深因为L4的DMA控制器结构变了还多了DMAMUX很多从F1迁移过来的老代码并没有正确适配。这不是芯片质量的问题而是对STM32L4的DMA机制理解不够导致的。1.2 UART DMA工作的基本流程先把流程拆开。UART每收到一个字节硬件会先把数据从接收移位寄存器放到RDR接收数据寄存器同时置位RXNE标志。如果此时对应的DMA通道已经使能RXNE会作为DMA请求信号传递给DMA控制器。DMA控制器收到请求后从外设数据地址也就是USART_RDR寄存器读取一个字节再写入你指定的内存地址。每一次搬运DMA的NDTR计数器减1内存地址是否递增由MINC位决定。这里有一个容易被忽视的细节DMA读取RDR的过程本身就会清除RXNE标志不需要软件再去手动清零。很多人不理解为什么DMA模式不需要读RDR就是这个原因。如果代码里同时又用中断方式去读RDR就可能在DMA之前把数据抢走造成两个外设都认为“自己拿到了数据”实际只有一个能拿到这本身就是一种collision。1.3 三种容易混淆的collision我在调试中把“collision”拆成了三种完全不同的情况外设事件冲突UART同时产生RXNE、ORE、IDLE等事件如果处理顺序不对DMA请求被ORE顶掉导致接收卡死。总线访问冲突DMA与CPU同时访问同一存储区域AHB仲裁会优先DMA但CPU会被插入等待周期。多个DMA通道同时工作时UART的请求可能排队变慢。数据竞争CPU在某个时刻读缓冲区而DMA恰好写入相邻字节尤其是主循环查询和DMA传输完成中断同时发生时读到的帧是半新半旧的混血数据。这三种情况往往同时出现所以排查时一定要分开对待否则很容易把原因错怪到UART配置或外部干扰上。2. STM32L4的DMA控制器特性与配置选型2.1 先看懂DMA1/DMA2和DMAMUXSTM32L4和F1/F4最明显的差别是DMA请求不是“写死”在通道上的。L4内部有一个DMAMUX它允许你把外设的DMA请求映射到任意一个DMA通道上。这就带来一个理解上的跳跃在CubeMX里配置DMA不仅要选通道还要明确选择Request也就是请求源。比如UART2_RX可以映射到DMA1的Channel5也可以映射到DMA2的某个通道但你必须告诉DMA“我的请求来自UART2_RX”。很多从F1迁移到L4的代码以为只要使能DMA就完事结果数据完全乱掉多半就是DMAMUX的请求源没有配对。CubeMX里生成代码时会在MX_DMA_Init之后配置对应的hdma_uart2_rx.Init.Request DMA_REQUEST_UART2_RX这个字段不是可有可无的。另外STM32L4有DMA1和DMA2两个控制器合理的分配原则是不要让所有高速外设挤在同一个DMA控制器上。比如UART和SPI同时在跑可以把UART放在DMA1SPI放在DMA2减少内部仲裁压力。2.2 UART请求与数据寄存器访问到底发生了什么从时序角度说UART接收移位寄存器每收满8位硬件会把数据放到RDR同时RXNE置位。DMA响应该请求后读取RDR然后写入内存地址。表面看是一套流水线但实际有一定时间窗口。如果RDR里的数据没有被及时取走而下一个字节已经完整收到UART就会置位ORE此时RXNE请求被“压住”DMA后续搬运就可能中断。所以在DMA接收模式下软件最容易犯的错误就是同时在UART中断处理函数里手动读RDR。比如有人为了防丢数据在RXNE中断里顺手读了一下RDR结果把数据从DMA嘴里抢走了。这是非常典型的“CPU与DMA碰撞”。另外要留意STM32L4系列在默认情况下没有UART硬件FIFORDR里只有一个字节的缓冲。这意味着从RXNE置位到DMA响应之间留给系统的裕量很小尤其是高速率和高负载时UART DMA通道的优先级不能太低。2.3 例说CubeMX配置中决策点CubeMX里配置UART DMA时我习惯把RX和TX分别添加然后逐个看参数DirectionRX必须选择PeripheralToMemoryTX选MemoryToPeripheral这个一般不会选错。PeriphInc必须关闭。外设地址固定是USART_RDR不是一组连续寄存器。MemInc必须打开。DMA要把数据连续写入用户缓冲区如果关闭就会一直写同一地址。PeriphDataAlignment和MemDataAlignment全部选Byte。UART数据寄存器只有低8位有效选HalfWord会一次搬16位选Word会一次搬32位都会造成数据错乱。Mode如果你用Normal模式在缓冲区收满后DMA会停止用Circular模式则自动重载。这个选择直接关系到接收策略。PriorityRX通道建议HighTX可以Medium。如果UART、SPI、ADC共用同一个DMA控制器Priority太低会导致RX请求被持续压住。很多文档只强调数据宽度却忽略了Priority。我实测下来在高负载多外设场景下UART RX优先级为Low时丢数据概率明显增加改成High之后恢复正常。这个“撞”不是数据撞了而是DMA通道之间互相抢时间。2.4 数据宽度、优先级等参数对碰撞的影响数据宽度错误是“最冤枉”的碰撞问题。UART的RDR寄存器是32位地址空间但有效数据只有8位。如果DMA被配置成Word宽度一次搬运会从RDR地址读出32位数据也就是说把相邻寄存器内容一并读进了缓冲区。在STM32L4上这个现象表现为数据每隔几个字节就多出几个0xFF或者0x00非常难排查。优先级的作用则可以理解为排队插队。同一个DMA控制器上如果ADC配置了VeryHigh并持续进行扫描转换UART的RX请求Priority是Low那么UART RXNE事件可能需要多等好几个周期才能被DMA响应。这种响应延迟在高波特率下直接导致RDR被新数据覆盖最终ORE置位DMA停止。所以不要把优先级只当摆设。3. 实测方案Modbus帧接收场景下的DMA接收设计3.1 选用Normal模式还是Circular模式我在项目里收到的是类似Modbus的帧数据每帧有起始符、长度字段、数据体和CRC。帧与帧之间有几十毫秒的空闲长度不固定。这种场景我最终选了Normal模式加UART IDLE中断而不是一开始想的Circular模式。Circular模式的优点是缓冲区可以被DMA连续写入适合音频、数据流这类不需要判断帧边界的场景。但它要求软件维护读写指针并且要处理半传输中断和传输完成中断否则你无法判断DMA到底写到了哪里。如果缓冲区管理和DMA写入不同步读到的数据就可能是新旧交错的这种错误在调试时极具迷惑性。Normal模式则简单得多DMA收到指定字节数后自动停止只要在IDLE中断里判断当前DMA计数器的剩余值就能算出实际收到多少字节然后处理完再重新启动下一次接收。对于“帧”型协议这比Circular模式更利于排查数据碰撞。3.2 代码框架IDLE中断DMA接收下面给出一段在我项目中实际使用的精简框架使用STM32CubeMX生成的HAL库工程。代码里假设已经配置好UART2的DMA接收通道。#define RX_BUF_SIZE 128 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len 0; volatile uint8_t rx_complete 0; // 初始化时启动一次接收 HAL_UARTEx_ReceiveToIdle_DMA(huart2, rx_buf, RX_BUF_SIZE);如果HAL库版本较新可以在回调函数中处理帧到达事件void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart huart2) { rx_len Size; rx_complete 1; // 重新启动接收注意这里要结合缓冲区使用情况决定 HAL_UARTEx_ReceiveToIdle_DMA(huart2, rx_buf, RX_BUF_SIZE); } }主循环中处理while (1) { if (rx_complete) { rx_complete 0; process_frame(rx_buf, rx_len); } }这里有个坑如果你在上一帧数据还没处理完时就重新调用HAL_UARTEx_ReceiveToIdle_DMADMA可能很快就会写入同一片缓冲区把尚未处理的数据覆盖掉。要避免这种碰撞主循环里应该在复制完有效数据之后再重新启动DMA接收或者用双缓冲区交替运行。3.3 环形缓冲/乒乓缓冲与读写指针设计如果一定要用Circular模式建议采用乒乓缓冲或者环形缓冲而不是在一条缓冲区上裸奔。乒乓缓冲的思路是准备两个大小相同的缓冲区A和B。DMA先写A半传输中断说明A写满此时CPU处理A中数据DMA继续写B全传输中断说明B写满CPU处理BDMA循环复用A。这样CPU和DMA永远不碰同一块区域逻辑清晰代价是多占一份内存。如果只能用一块缓冲区那么环形缓冲需要知道DMA当前写到了哪里。常用手段是通过__HAL_DMA_GET_COUNTER读取DMA的NDTR寄存器uint32_t remain __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint32_t write_pos (RX_BUF_SIZE - remain) (RX_BUF_SIZE - 1);前提是RX_BUF_SIZE为2的幂次并且DMA从rx_buf起始地址开始写。在IDLE中断或半传输/全传输中断里读取这个值可以得到DMA写入位置的快照。但要注意这个值只是一个瞬间状态不能保证和某次DMA写操作完全同步。最稳妥的方法还是只在半传输/全传输中断回调里更新写索引不要在主循环里随时去读NDTR。读索引和写索引必须声明为volatile并且不要在表达式中混用非原子操作。主循环消费数据时先算出可读长度然后逐字节复制到临时数组最后再更新读索引。如果在读到一半时DMA覆盖了未读区域数据就变成“一半新一半旧”这是典型的UART DMA collision。3.4 启动、复位、超时处理的完整状态机为了管理接收流程我建议设计一个简单状态机。状态可以定义成IDLE等待空闲事件DMA已经启动RECEIVINGDMA正在接收可能收到一帧的一部分FRAME_READYIDLE事件发生DMA停止或数据可读PROCESSING主循环正在处理帧数据整个流程的核心是IDLE中断只负责设置标志和记录长度不直接处理数据主循环检测到FRAME_READY后把数据搬走再重新启动DMA。如果接收过程中出现ORE错误需要立刻清错误标志并且重新启动DMA否则后续数据永远不会进来。如果协议允许帧与帧之间空闲时间非常短建议使用UART的超时中断或者系统tick超时机制防止一帧数据一直收不完导致DMA长期占用。我遇到过的情况是从机断电瞬间只发送了半个字节UART没有触发IDLE事件DMA就一直挂着直到看门狗复位。所以超时处理不能省。4. 常见Collision问题与排查实录4.1 问题一DMA接收一段时间后停止UART ORE置位这是最常见的“假死”问题。现象是系统运行一段时间后串口彻底停止接收数据但程序本身还在运行。读取UART状态寄存器ORE位为1。原因通常是UART RDR中的数据没有被及时搬走新数据又来了触发溢出。在DMA模式下最常见的原因是DMA通道优先级太低或者主循环长时间关中断导致RXNE请求得不到响应。解决方法是使能UART错误中断在错误回调里清掉ORE同时重启DMA接收void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart huart2) { // 避免DMA停留在错误状态 HAL_UART_AbortReceive(huart2); __HAL_UART_CLEAR_FLAG(huart2, UART_CLEAR_OREF); HAL_UARTEx_ReceiveToIdle_DMA(huart2, rx_buf, RX_BUF_SIZE); } }注意不要只在错误回调里清标志不重新启动DMA。那样标志清了DMA通道还是停止状态下一次接收依然不会来。4.2 问题二接收缓冲区出现错位数据或“叠字”如果收到的数据总是“整体错位”比如你发0x01 0x02 0x03 0x04收到的却是0x02 0x03 0x04 0x05那么大概率不是DMA碰撞而是DMA配置问题。检查方向是数据宽度是否设成了HalfWord或WordDMA请求源是否选错比如把UART2_TX请求配给了UART2_RX通道UART的校验位、停止位和实际波形是否匹配如果数据是“偶尔错位”并且错位点在帧中间那通常就是CPU和DMA竞争引起的。可以尝试在接收缓冲区中填充固定的模式比如0xAA、0x55然后长时间跑观察错位规律。最隐蔽的是Circular模式下读写指针更新问题DMA一直在写CPU读的速度跟不上读索引追不上写索引导致缓冲区回绕时出现一整段重复数据。遇到这种情况优先检查是否用了半/全传输中断同步读写指针。4.3 问题三主循环访问缓冲区时DMA恰好写入这个问题在单缓冲模式下特别容易出现。主循环判断到rx_complete标志后开始读取rx_buf里的数据。如果此时DMA已经重新启动并且新一帧数据已经写入那么读到的一帧就是新旧数据的混合体。解决办法有三个方向处理完数据再启动新接收而不是在回调里立即重启使用双缓冲处理A时DMA写B先把rx_buf的数据memcpy到另一块临时缓冲区再标志处理完成从实际经验看memcpy方案虽然多消耗一点时间但逻辑最简单也不容易引入指针问题。数据量不大时完全够用。4.4 问题四多外设DMA同时开启UART响应变慢如果你同时运行UART、SPI、ADC等外设DMAUART接收对实时性的要求最高。DMA仲裁原则是优先级高的请求先响应同优先级则通道编号小的先响应。UART RX通道Priority如果设成了Low在高负载下很容易被其他VeryHigh通道抢占。我实测过一次SPI DMA搬运和UART同时开启UART每100帧就会丢1到2字节。把UART RX DMA优先级从Low改成High后丢字节问题消失。同时我把UART的RX请求映射到了DMA1SPI的DMA映射到了DMA2总线负载也明显降低。另外使用DMAMUX时同一请求不能同时映射到两个通道。如果工程里重复初始化了DMA请求源也会出现奇怪的“偶发不工作”。检查CubeMX生成的代码确认UART的RX和TX请求没有被同一个通道抢占。4.5 在线调试与寄存器检查技巧遇到UART DMA碰撞问题不要急着打一堆printf先学会看寄存器。在STM32CubeIDE的Live Expressions窗口里可以添加以下变量观察huart2.hdmarx-Instance-NDTRDMA剩余计数器huart2.Instance-ISRUART中断状态寄存器重点看RXNE、ORE、IDLEhuart2.hdmarx-StateDMA状态huart2.gState和huart2.RxStateHAL层状态调试DMA问题时断点不要打在DMA中断回调函数里也不要打在UART中断处理函数里。因为断点会冻结DMA时钟一旦停在中断中间DMA状态就不再变化你看到的其实是被“冻结”后的假象不是真实运行现场。更好的方式是用逻辑分析仪抓UART RX引脚波形再配合一组固定的串口测试帧反复触发问题。我还习惯在接收缓冲区的头部和尾部各放一个特定的魔数比如0x5A和0xA5每次处理数据前检查这两个位置。如果魔数被覆盖说明DMA写入越界了如果魔数完好但数据错位则要优先怀疑地址递进或者索引更新逻辑。5. 关于稳定性的一些个人体会最后聊一点我自己的习惯。吃过几次UART DMA的亏以后我现在上手STM32L4的串口DMA项目第一步永远是打开参考手册的DMA请求映射表把UART RX/TX实际用到的DMAMUX通道号写下来而不是直接依赖CubeMX生成的代码。第二步是明确接收策略连续数据流用Circular加半/全传输中断帧协议用Normal加IDLE中断。不要想着一套代码兼容所有场景接受逻辑不同就会少踩很多坑。第三步是给UART DMA接收通道设置一个比主循环更可靠的中断路径。我一般会把错误中断打开并在错误回调里简单处理好ORE而不是等UART彻底卡死之后再去查原因。这个小改动省了我不少现场问题。再分享一个小技巧。我写测试用例时会特意构造0x00、0xFF、0x55、0xAA交替出现的帧内容本身没有实际意义但能快速暴露数据宽度、地址递进和DMA优先级带来的问题。每次改完DMA相关代码用这份测试跑半小时基本就能判断配置是否稳定。UART DMA的collision问题看上去像玄学其实背后都有清晰的硬件逻辑。把DMA请求、UART状态位、缓冲区读写顺序这三件事拆开逐项核对多数问题都能定位到具体那一步。希望这篇内容能帮你少走一段弯路。