STM32L4 UART DMA碰撞问题解析与环形缓冲接收方案

发布时间:2026/8/30 14:47:35
STM32L4 UART DMA碰撞问题解析与环形缓冲接收方案 最近调试STM32L4的一个串口项目又被UART DMA collision折腾了一整天。这个说法听起来有点玄其实指的是用DMA搬运串口数据时DMA、UART和CPU在共享资源上“撞车”具体表现就是偶发丢字节、数据错位、一帧数据里夹着坏字节严重的时候整个系统直接卡死。网上搜“UART DMA collision STM32L4”ST社区和论坛里相关提问特别多说明这不是个例而是很多嵌入式工程师都会踩的深坑。这篇文章我会把碰撞的底层原因、STM32L4的DMA特性、一套基于环形缓冲的接收方案以及实际调试中的排查经验一次讲清楚。适合正在用STM32L4做串口通信、遇到DMA接收不稳定或者想从头搭建一套可靠DMA串口框架的开发者。1. 先搞清楚UART DMA collision到底撞的是什么1.1 总线仲裁层面的碰撞DMA和CPU抢着访问内存先说一个容易误解的点很多人以为DMA是“独立搬运数据”不会和CPU争东西。实际上DMA控制器也要通过总线矩阵访问SRAM、Flash外设寄存器而CPU也有自己的取指、读写数据操作。两边同时访问同一个SRAM bank或者同一个外设时总线仲裁器会让其中一个等待。STM32L4的SRAM分成了好多个bankDMA访问SRAM2、CPU访问SRAM1的时候可以并行但如果DMA和CPU访问的是同一个bank必然存在等待。那这和UART丢数据有什么关系UART收到一个字节后硬件会置起RXNE标志等待DMA把数据从数据寄存器DR搬到内存。如果DMA响应慢RXNE一直没被清除下一个字节又到了硬件就会置溢出错误ORE新数据直接丢掉。也就是说总线仲裁延迟本身不会让DMA出错但延迟过长会导致UART侧溢出最终表现就是丢字节。这对实际项目的影响有多大如果只开一路UART DMA波特率也不高基本感觉不到。但如果你同时启用了SPI DMA、ADC多通道扫描DMA、串口DMA总线负载一高UART这边就可能随机丢数据。我在一个3路串口ADC扫描的项目里低负载时跑一天都没问题把CPU空转改成大量内存拷贝后第二路串口就开始偶发丢字节最后查下来就是这个原因。1.2 读写指针追赶导致的逻辑碰撞比总线冲突更常见真正占了UART DMA collision问题里八成的是应用层的“逻辑碰撞”。典型场景用DMA的循环模式接收串口数据DMA不断往缓冲区里写同时你的代码需要把缓冲区里的数据读出来做协议解析。如果缓冲区只有一个裸数组没有维护好读写指针可能会出现两种情况读指针追上了写指针把同一段数据读了两次解析出黏包或重复帧写指针绕回来覆盖了还没读走的数据协议解析直接错乱。很多人一上来就开一个1024字节的buf然后HAL_UART_Receive_DMA启动后发现数据一多就乱第一反应是加大缓冲区。但缓冲区只是把问题延后并没有解决指针碰撞。只要DMA写数据的速度长期大于消费速度任何有限大小的缓冲区都会炸。这个道理和环形缓冲、消息队列是相通的我们需要的是可复用的环形结构而不是一片不会越界的死内存。1.3 配置不一致带来的“隐形碰撞”还有一种碰撞既不在总线层也不在逻辑层而是配置层面。最常见的是DMA用的是Normal模式传输长度是64字节但串口协议是变长的一帧可能只有20字节也可能有100字节。当DMA传满64字节后自动停止后续的数据再也没人搬UART溢出系统表现为“有时候一直收不到数据”。又比如在CubeMX里配置DMA时选择了Circular模式却没有注意到Continuous Requests选项。这个参数一旦没有真正生效循环模式就不再循环DMA一轮结束后通道被禁用结果和你用Normal模式一模一样。这类问题表面上是“DMA不工作”本质上就是DMA配置和UART持续接收的需求不一致属于一种隐藏的collision。2. STM32L4的DMA资源盘点别一上来就写代码2.1 DMAMUX让通道分配更灵活但也更容易配错STM32L4和早期STM32F1/F4最大的区别之一就是多了DMAMUX外设。F4时代的DMA通道是固定映射UART1_RX只能对应DMA1的某个固定通道配置错了就得改代码。而L4通过DMAMUX可以把UART的DMA请求映射到DMA控制器里几乎任意一个通道灵活度很高。这个灵活是把双刃剑。好处是你不需要为了两个外设抢同一个通道而大改设计坏处是如果手动初始化DMA漏配或者错配DMAMUX请求号DMA就完全收不到请求数据一动不动。用CubeMX自动生成代码会省很多事但你要理解生成出来的HAL_DMA_Init里那个Request参数就是指DMAMUX的请求映射不是随便填的。我曾经在一次板卡调试中发现UART2的RX DMA始终不触发逻辑分析仪显示TX口数据没问题RX口也有波形但DMA计数就是不动。排查了半天发现是DMA通道的request映射到了UART3_RX。在L4上这种错误很容易犯多看参考手册里的DMAMUX请求映射表核对CubeMX生成的代码比瞎猜快得多。2.2 接收模式选型Normal、Circular还是Double Buffer串口DMA接收时有三种常用模式但很多人在一开始就选错了。Normal模式适合长度固定的单次接收。比如每次从传感器读取固定32字节或者你明确知道要收多少字节就调用一次HAL_UART_Receive_DMA收满长度后DMA停止并触发完成中断。这种模式实现最简单但遇到变长协议就很痛苦因为你不知道何时该停止接收。如果收到的帧比DMA配置长度长数据就会溢出不完整如果比配置长度短DMA又会一直挂在那边等剩余字节。Circular模式是串口变长接收的主流选择。DMA会持续响应UART的RXNE请求自动循环搬运数据配合UART空闲中断可以做到“收到一帧并空闲时取走数据”。难点在于维护读位置和DMA当前写位置之间的关系但只要处理好这个方案非常稳定。Double Buffer模式是DMA双缓冲两个地址来回切换适合“乒乓”处理在音频采集、ADC连续采样里用得多。串口如果用双缓冲通常会和半满中断配合一个缓冲在接收另一个缓冲在处理。但串口帧边界往往不是半满边界数据会被拆碎处理起来反而更麻烦所以我个人更推荐Circular模式加环形缓冲。2.3 发送DMA的碰撞点上一帧没传完就开下一帧发送方向上的DMA碰撞很多人会忽略。发送DMA是内存到UART DR的搬运和接收DMA在总线上也会共享带宽但真正的坑往往是逻辑上的上一帧数据DMA还没搬完你又调用了一次发送函数。HAL库的行为是如果上一次DMA传输还没完成HAL_UART_Transmit_DMA会返回HAL_BUSY但很多人在调用时没有检查返回值导致第二次发送请求被忽略或者更糟糕的直接复用同一个DMA缓冲区把还没发完的数据覆盖了。最终表现出来就是串口发出奇怪的数据或者一帧数据里混着上一帧的残留。解决思路很简单定义自己的发送状态机或者使用发送完成回调只有等上一帧发送完成后才允许发起下一次发送。如果需要连续发送大量帧更好的做法是使用发送环形队列而不是反复调用HAL_UART_Transmit_DMA。3. 实操搭一套不打架的UART DMA接收3.1 CubeMX里的关键配置与continuous requests我以STM32L4系列为例在CubeMX里配置UART1的DMA接收关键步骤如下先配置UART1为异步通信模式波特率根据项目定。然后在DMA Settings里添加UART1_RXMode选择CircularData Width选ByteMemory Increment Address打开Priority可以选High。接着添加UART1_TXMode选择Normal其他类似。NVIC设置里要把USART1全局中断打开DMA通道中断如果要用就一并打开。这里要特别强调Continuous Requests这个选项。CubeMX里选择Circular模式后DMA Request Settings下方会出现一个Continuous Requests的开关在STM32L4的HAL库支持里这个选项对应的是DMA通道是否在计数器到0后继续响应外设请求。如果这个选项没有真正使能UDMA在传完一轮后会停下来串口接收也就停了。有时候CubeMX版本有bug生成的代码里这个配置看起来是enable但实际初始化时被忽略所以最好在生成的HAL_DMA_Init后面检查一下DMA通道控制寄存器的对应位。如果你的方案只用空闲中断不需要DMA传输完成中断可以不勾选DMA中断。但如果数据流可能长时间不间断建议把DMA中断也打开用于半满/全满处理避免数据在空闲中断到来之前被覆盖。3.2 环形缓冲数据结构和DMA位置计算接收的核心数据结构我用一个环形缓冲来管理代码很简洁#define UART_RX_BUF_SIZE 1024 typedef struct { UART_HandleTypeDef *huart; uint8_t buf[UART_RX_BUF_SIZE]; volatile uint16_t last_pos; } uart_dma_ring_t;这里last_pos记录上一次已经处理完的位置也就是读指针的边界。DMA写指针并不是一个固定变量而是通过DMA当前计数器实时推算出来的。使用__HAL_DMA_GET_COUNTER可以读取DMA还剩余多少次传输也就是NDTR寄存器的值。在循环模式下DMA每搬运一个字节NDTR减1减到0后自动重载为UART_RX_BUF_SIZE并继续递减。因此在空闲中断里DMA当前写位置可以近似认为是uint16_t ndtr __HAL_DMA_GET_COUNTER(huart-hdmarx); uint16_t dma_pos UART_RX_BUF_SIZE - ndtr;这个公式成立的前提是两次处理之间DMA最多完整绕缓冲区一圈。只要缓冲区大小大于最大协议帧长度并且中断处理足够及时这个前提是成立的。如果缓冲区设得比最大帧还小DMA绕了一圈后你可能还有数据没处理完这时候dma_pos和last_pos的差值就无法准确表示真实的可读字节数了。3.3 空闲中断与DMA位置计算的联动代码初始化时启动DMA接收并使能UART空闲中断void uart_dma_ring_init(uart_dma_ring_t *ring, UART_HandleTypeDef *huart) { ring-huart huart; ring-last_pos 0; __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_DMA(huart, ring-buf, UART_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); }在UART中断处理函数中先调用HAL库的公共处理函数再判断空闲标志void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_IDLE); uart_dma_ring_idle_handler(g_uart1_ring); } }空闲中断处理函数就是计算可读数据并交给协议解析void uart_dma_ring_idle_handler(uart_dma_ring_t *ring) { uint16_t ndtr __HAL_DMA_GET_COUNTER(ring-huart-hdmarx); uint16_t dma_pos UART_RX_BUF_SIZE - ndtr; uint16_t available (dma_pos - ring-last_pos UART_RX_BUF_SIZE) % UART_RX_BUF_SIZE; if (available 0) { return; } uint16_t first_chunk UART_RX_BUF_SIZE - ring-last_pos; if (available first_chunk) { process_rx_frame(ring-buf[ring-last_pos], available); } else { process_rx_frame(ring-buf[ring-last_pos], first_chunk); process_rx_frame(ring-buf[0], available - first_chunk); } ring-last_pos (ring-last_pos available) % UART_RX_BUF_SIZE; }注意这段代码里process_rx_frame应该尽量短只做拷贝数据到协议缓冲区的操作真正的协议解析放到主循环或者任务里。如果你在中断里直接调用复杂的解析逻辑一旦解析耗时超过两帧之间的间隔下一个空闲中断到来时last_pos还没更新数据就会乱。3.4 缓冲区大小的工程经验缓冲区大小怎么定我一般遵循两个原则。第一缓冲区必须大于最大协议帧长度。如果协议帧最长256字节缓冲区至少512留出余量。第二要保证一次中断处理期间串口不会快速把缓冲区写满。以115200波特率为例1个字节大约87微秒1024字节缓冲区填满约89毫秒。只要你的解析代码能在89毫秒内消费完一帧数据就不会有问题。如果波特率提高到9216001024字节填满时间只有约11毫秒就需要重新评估处理逻辑。有些项目会遇到持续不断的数据流比如GPS输出、传感器连续上报这类数据没有明显的帧间隔空闲中断可能长时间不触发。这种情况只靠空闲中断不够还需要配合DMA的半满/全满中断在缓冲区写满一半或全满时把数据取走。半满中断触发时DMA还在继续写后半段你处理前半段这样就能做到“几乎实时”的流水消费。但处理起来要小心半满中断和空闲中断可能同时触发要保证同一个数据区间只被处理一次。4. 我踩过的典型坑与排查方法4.1 第一字节丢失或者偶发丢字节这个问题非常经典。症状是上电后第一次发送数据接收端总是少第一个字节后面就正常或者运行一会儿后偶尔丢一个字节。第一个字节丢失大概率是DMA启动前UART已经收到了数据RXNE被置位甚至已经产生了溢出错误ORE导致DMA启动后第一字节被硬件丢弃。解决办法很简单在启动DMA前清除UART溢出标志__HAL_UART_CLEAR_OREFLAG(huart1); HAL_UART_Receive_DMA(huart1, rx_buf, UART_RX_BUF_SIZE);如果用的是Normal模式丢字节还有另一个原因DMA一轮结束后通道自动禁用后续数据没人搬运UART溢出。解决办法是改用Circular模式或者每传完一包重新启动DMA。4.2 数据错位、黏包、帧内夹着0x00这种问题一般出现在你开始用多个中断源处理接收数据的时候。比如空闲中断里取了一次数据DMA半满中断里又取了一次两边没有做互斥同一个区间的数据被处理了两次协议层自然就乱了。解决办法是明确分工。我比较推荐的做法是只用空闲中断作为帧边界触发半满中断只负责“搬运”相当于把DMA缓冲区的前半段搬到一个更大的应用缓冲里。每次半满中断后把last_pos同步到半满位置空闲中断里根据last_pos计算剩余可读区间这样两边就不会重复处理。另外如果在空闲中断处理中调用了HAL_UART_Receive_DMA或者其他阻塞操作也可能导致中断处理时间过长期间新数据到达下一帧的数据和当前帧被合并。我自己踩过这个坑当时想在空闲中断里顺便重新启动DMA结果每次启动DMA都要关闭再打开反而把RXNE标志搞乱了最后数据黏得一塌糊涂。解决方法是Circular模式下根本不需要重新启动DMA空闲中断里只处理数据不要碰DMA的启停。4.3 高速率下系统卡死或者串口完全无响应波特率提高之后中断触发频率变高如果空闲中断处理里做了不合适的操作比如调用了printf、HAL_Delay、memcpy整段大块数据系统很容易卡死。printf默认会阻塞等待串口发送完成而你在串口中断里调printf等于把UART再次堵死。正确做法是中断里只记录数据和标志位解析放到主循环extern uint8_t g_rx_frame_ready; extern uint8_t g_rx_frame_buf[512]; extern uint16_t g_rx_frame_len;空闲中断里把可读数据拷贝到g_rx_frame_buf置位g_rx_frame_ready然后立刻退出。主循环检测到标志后再做真正的协议解析和业务处理。这样即使某次解析花费几毫秒也不会堵塞中断。还有一个细节NVIC优先级设置。UART中断和DMA中断的抢占优先级如果设置一样HAL库在中断中回调时可能嵌套触发造成无法预料的执行顺序。建议UART中断优先级高于普通外设中断但DMA中断优先级可以比UART低因为DMA中断在我们这套方案里不是核心路径。4.4 NDTR回读的竞态问题__HAL_DMA_GET_COUNTER也不是随便用的。NDTR寄存器在DMA传输过程中一直在递减你读到的值可能落在两次递减之间产生轻微的偏差。UART空闲中断触发时串口线上已经空闲DMA大概率处于等待状态NDTR相对稳定但极端情况下还是可能读到正在边界变化的瞬间。如果担心可以连续读两次直到两次值一致uint16_t ndtr; do { ndtr __HAL_DMA_GET_COUNTER(huart-hdmarx); } while (ndtr ! __HAL_DMA_GET_COUNTER(huart-hdmarx));但这在连续高速传输场景下可能死循环因为NDTR一直在变。更稳妥的做法是限制读取次数比如最多读5次取最后一次。我个人在实际项目中空闲中断里直接读一次就够了因为空闲期间串口没有新数据到达NDTR不会因UART请求而变化。只有在通过DMA搬运持续数据流且同时读NDTR时才需要做稳定处理。5. 把方案沉淀成模块顺便谈点通用经验5.1 封装一个可复用的UART DMA环形缓冲模块调试稳定后建议把这套逻辑封装成独立模块方便多个串口复用。我为项目封装的大致接口如下typedef struct { UART_HandleTypeDef *huart; uint8_t *buf; uint16_t buf_size; volatile uint16_t last_pos; void (*frame_callback)(uint8_t *data, uint16_t len); } uart_dma_ring_t; uint8_t uart_dma_ring_init(uart_dma_ring_t *ring, UART_HandleTypeDef *huart, uint8_t *buf, uint16_t buf_size, void (*frame_callback)(uint8_t *, uint16_t)); void uart_dma_ring_idle_irq(uart_dma_ring_t *ring);这样每个串口只需要定义一个uart_dma_ring_t实例在各自的UART中断里调用uart_dma_ring_idle_irq即可。协议解析通过回调函数注入不会和驱动层耦合在一起。需要注意如果多个串口使用同一个解析回调函数回调里要能区分数据来自哪个串口否则以后加第二路串口时代码会越改越乱。5.2 从STM32L4迁移到其他系列的注意事项这套方案的核心思路不依赖特定型号迁移到STM32F4、G0、GD32等平台也适用但有几个差异要留意。STM32F4系列没有DMAMUXDMA请求映射是固定的配置DMA时不能随便选通道必须查对应型号的DMA请求映射表。比如同一个DMA通道被多个外设占用时就需要调整外设或者分时使用。GD32系列虽然总体兼容ST的库但DMA寄存器和中断标志位可能有细微差别。我之前在GD32F470上做串口DMA时发现它的DMA传输完成的清除方式和ST不太一样直接照搬ST代码会导致中断一直触发。迁移时不要只看HAL层接口要下到寄存器层面核对。还有一个共通点无论哪个系列DMA缓冲区都建议放在普通SRAM中不要放在带cache的区域。STM32L4内部没有D-Cache问题不大但如果是STM32H7这类带D-Cache的芯片DMA缓冲区和CPU缓存之间必须做好一致性处理否则数据时对时错比UART DMA collision还难查。5.3 最后一个调优建议也当作个人经验最后分享一个我自己积累的调试习惯。调试UART DMA时不要只盯串口助手上的数据一定要在关键中断里用GPIO翻转来测时序。比如在空闲中断处理函数进入时把某个测试引脚拉高函数退出时拉低用示波器同时观察UART RX引脚和这个GPIO就能直观看到一帧数据到达后你的处理代码花了多长时间是否会在下一帧到来前完成。我曾经靠这个办法定位过一个很隐蔽的问题串口表面上看数据全对但偶尔整个系统会卡几百毫秒。用GPIO翻转才发现数据解析函数里有一次哈希计算在最坏情况下耗时4毫秒而这个时间正好卡住了另一路高优先级任务。把解析函数挪到低优先级处理后问题立刻消失。UART DMA collision说起来很吓人但拆开看无非是总线、指针、配置三类问题。先把中断责任划分清楚再把缓冲区的读写指针维护好这套方案可以很稳定地跑很久。希望这些经验能让你少走一些弯路。