STM32L4 UART DMA碰撞问题解析与环形缓冲接收方案
2026/8/30 14:46:38 网站建设 项目流程

最近调试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分成了好多个bank,DMA访问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_RX,Mode选择Circular,Data Width选Byte,Memory Increment Address打开,Priority可以选High。接着添加UART1_TX,Mode选择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_poslast_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毫秒内消费完一帧数据,就不会有问题。如果波特率提高到921600,1024字节填满时间只有约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 高速率下系统卡死,或者串口完全无响应

波特率提高之后,中断触发频率变高,如果空闲中断处理里做了不合适的操作,比如调用了printfHAL_Delaymemcpy整段大块数据,系统很容易卡死。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系列没有DMAMUX,DMA请求映射是固定的,配置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说起来很吓人,但拆开看无非是总线、指针、配置三类问题。先把中断责任划分清楚,再把缓冲区的读写指针维护好,这套方案可以很稳定地跑很久。希望这些经验能让你少走一些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询