
做嵌入式串口通信这么多年我最大的感受是UART这玩意儿硬件上就是一根TX一根RX但真正把它用好尤其是在带DMA的Normal mode下把收发链路调稳里面藏着的门道远比你想象的多。很多工程师第一次接触UART DMA都是从被中断接收搞烦了开始的。一字节进一次中断波特率一高CPU全耗在处理中断上了更难受的是数据一多还会丢。后来改用DMA搬运数据CPU终于被解放出来结果又在Normal mode和Circular mode之间反复踩坑。我这篇文章就把UART TX/RX在DMA Normal mode下的完整实现讲清楚从原理、配置到代码再到我实际项目中踩过的坑一次性聊透。适合谁看用STM32、GD32、HC32这类带DMA的MCU做串口通信的开发者尤其是刚接触HAL库、对DMA模式选择比较迷糊的工程师。不管你是做传感器数据采集、上位机通信、还是模块控制只要涉及串口收发这篇都能给你省下不少调试时间。1. 为什么要把串口收发交给DMA还要选Normal mode1.1 轮询与中断接收的天花板串口接收最简单粗暴的方式是轮询。主循环里死等HAL_UART_Receive来一个字节取一个字节。这在数据量小、波特率低、单任务场景下倒是能用但波特率一旦上到115200甚至更高主循环稍微被其他任务耽搁数据就漏了。更关键的是轮询会让CPU全程盯在串口状态上干不了别的活。后来大家普遍改用接收中断也就是每个字节触发一次RXNE中断在中断里把数据搬进自己的缓冲区。这样CPU总算不用空等了但新的问题又来了高频中断对CPU的冲击非常大。比如115200波特率理论上每秒能来11520个字节也就是每秒至少11520次中断。如果系统里还有定时器、ADC、SPI、外部中断在跑CPU的很大一部分时间都耗在进栈出栈和中断处理上了实时性怎么保证1.2 DMA在串口收发里的角色DMADirect Memory Access直接存储器访问解决的就是数据搬运不占用CPU的问题。拿串口接收举例开启DMA后串口外设每收到一个字节硬件会自动把数据从USART的数据寄存器搬到内存缓冲区这个搬运过程完全不需要CPU参与。CPU只需要在DMA搬完一段数据之后去处理一下就行了。用一句话概括CPU从搬运工变成了管理者。它不再亲手搬每一块砖而是看一眼图纸让DMA去搬搬完了报告一声。发送方向也是一样的道理。普通的HAL_UART_Transmit是CPU把数据一个字节一个字节写进发送寄存器开启TX的DMA后CPU只需要指定发哪块数据、发多长DMA会自己把内存里的数据搬到USART发送寄存器然后硬件自动把数据发出去。等DMA搬完再通过中断通知CPU发完了。1.3 Normal mode一个容易上手但常被误解的模式DMA有两种工作模式Normal mode普通模式和Circular mode循环模式。很多人的第一反应是接收数据当然用Circular mode啊这样可以一直收不断流。这个想法本身没错但Circular模式有个明显的坑——它不会告诉你一帧数据什么时候结束数据满了还会覆盖旧数据你需要自己维护读位置和写位置处理半传输中断、传输完成中断逻辑复杂度一下子就上去了。Normal mode就简单直接得多DMA搬完你指定的字节数就停下来然后触发一次传输完成中断如果你需要再次使用就得重新调用启动函数。对接收方向来说Normal mode天然适合一帧一帧的数据尤其是配合串口的空闲中断IDLE可以非常优雅地判断出一帧数据的边界。我为什么推荐先搞懂Normal mode因为它是理解DMA工作的基石也是很多项目里最实用的方案。把这个搞明白再去用Circular mode做流式接收你才知道自己到底在配置什么、为什么这么配。2. Normal mode和Circular mode先看懂DMA的单次搬运和循环搬运2.1 寄存器层面的本质区别DMA的Normal mode和Circular mode本质区别在DMA控制寄存器里那个循环模式位比如F4系列DMA_SxCR寄存器的CIRC位。Normal mode下的工作流程是配置好源地址、目的地址、传输方向、数据宽度、传输长度NDTR计数器的初始值使能DMA通道DMA开始搬运数据每搬一次NDTR计数器减一NDTR减到0传输完成DMA通道自动禁用EN位被硬件清0需要再次传输时必须由软件重新配置NDTR并重新使能通道或者直接重新调用启动函数Circular mode下则多了一个自动回绕的动作NDTR计数到0后自动重新加载初始值内存地址也自动回到起始地址DMA通道一直保持使能状态数据源源不断地搬CPU需要配合半传输中断和传输完成中断去处理已经搬好的半区或全区数据用一个不算特别准确的类比Normal mode就像一次性的配送任务送完这一单就下班Circular mode则是一条不断循环的传送带永远在转你要及时从传送带上取走货物不然就会被新来的货物挤掉。特性Normal modeCircular modeNDTR减到0后通道自动禁用停止传输自动重载初始值继续传输内存地址回绕不回绕停在结束位置自动回到起始地址是否需要重新启动需要不需要数据边界判断配合IDLE中断效果极佳需要自行维护读/写位置CPU开销低较低但逻辑处理复杂适用场景帧数据、应答式通信持续数据流、大缓冲接收2.2 Normal mode适合什么样通信场景Normal mode最适合的是一次发一包发完即停的通信方式。典型场景包括传感器模块的上报帧比如每次上报12字节的温湿度数据帧间隔几十毫秒甚至几百毫秒上位机与MCU的命令应答上位机发一帧指令MCU解析后回一帧结果一问一答与蓝牙模块、4G模组的AT指令交互每条指令带换行符作为结束标志数据量小这些场景有个共同点数据是分帧的帧与帧之间有明显的时间间隔。这个间隔就是串口空闲时间正好可以被IDLE中断捕获。我用Normal mode配合IDLE中断一帧数据到了就触发一次处理干净利落。而且Normal mode在发送方向几乎是天然匹配的。发数据本来就是一次性行为——把一段内存里的数据发出去就完事用Circular mode反而要找地方存数据、维护循环位置纯属自找麻烦。2.3 接收侧为什么必须搭配空闲中断Normal mode本身有个短板它虽然能在搬完指定字节数后停住但它不知道一帧数据什么时候算结束。比如你设置缓冲区256字节可实际一帧数据只有20字节DMA会一直在那等着凑满256字节这显然不行。这时候就需要串口的空闲中断IDLE来补位。串口线路上只要出现一个字节时间的空闲即RX线上没有新数据进来硬件就会置起IDLE标志触发中断。这个标志一亮就说明对方这一帧发完了可以取数据了。所以典型的Normal mode接收方案是DMA设置成Normal模式指定接收缓冲区大小比如256字节使能串口的IDLE中断DMA平时一直在待命来一个字节搬一个字节当线路空闲了IDLE中断触发我们在中断里暂停DMA通过读取DMA剩余计数器的值算出这一帧实际收到了多少字节处理完数据重新启动DMA接收等下一帧这套组合拳就是UART RX in DMA Normal mode最经典、最实用的打开方式。3. TX方向把发送也交给DMA写完就撤3.1 CubeMX里的TX DMA配置要点说实话发送方向的DMA配置比接收简单太多但有几个容易被忽略的细节我一个个说。在STM32CubeMX里你给USART1添加TX方向的DMA请求配置界面会出现几个关键参数Direction方向选择Memory To Peripheral内存到外设这个方向决定了DMA源的地址是内存、目的地址是外设数据寄存器Mode模式选择Normal。发送本来就是要发一次就停除非你在持续循环发送否则别碰CircularIncrement Address地址递增内存地址递增必须打开Increment Memory Address外设地址保持固定Non-Increment Peripheral Address。因为发送数据在内存里是连续存放的每搬一个字节内存地址要往后挪而外设数据寄存器地址始终就那一个Data Width数据宽度按Byte8位设置。串口寄存器本身就是8位数据别自作主张设成Half WordPriority优先级根据工程实际情况选一般High或Very High都行但要注意和接收DMA的优先级搭配别让发送把接收挤掉了有些新系列MCU比如H7、G4的CubeMX里还多了一个Continuous Requests连续请求选项。这里建议Normal模式下保持Disable。这个选项勾上后DMA会在完成一次传输后下一次外设请求到来时立刻重新触发通道实际效果上会把Normal模式变成类循环模式很多项目莫名其妙的数据重复发送就是在这里埋下的。CubeMX生成代码后你会在MX_USART1_UART_Init之后看到类似这样的配置以F4 HAL库为例hdma_usart1_tx.Instance DMA1_Stream4; hdma_usart1_tx.Init.Channel DMA_CHANNEL_4; hdma_usart1_tx.Init.Direction DMA_MEMORY_TO_PERIPH; hdma_usart1_tx.Init.PeriphInc DMA_PINC_DISABLE; hdma_usart1_tx.Init.MemInc DMA_MINC_ENABLE; hdma_usart1_tx.Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma_usart1_tx.Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma_usart1_tx.Init.Mode DMA_NORMAL; hdma_usart1_tx.Init.Priority DMA_PRIORITY_HIGH;这一串配置的灵魂就在DMA_NORMAL它保证了发送完一段数据后DMA自动停下来不会自己二次触发。3.2 HAL库的发送API与完成回调配置好了发送就一个函数的事HAL_UART_Transmit_DMA(huart1, (uint8_t *)tx_buffer, len);这个函数的执行逻辑是先检查UART的发送状态空闲然后把DMA通道根据之前CubeMX生成的配置初始化好最后启动DMA搬运。数据真正发完是异步的函数返回不代表数据已经出现在串口线上了。如果想确认数据真的发完了有两种方式第一种轮询状态// 返回HAL_OK表示启动成功 HAL_StatusTypeDef status HAL_UART_Transmit_DMA(huart1, tx_buffer, len); // 后续可以通过hustate判断发送是否结束 while (HAL_UART_GetState(huart1) HAL_UART_STATE_BUSY_TX);第二种传输完成中断回调这也是推荐的方式void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { tx_complete_flag 1; // 发送完成标志置1后可以安全复用tx_buffer } }HAL_UART_TxCpltCallback是HAL库里的弱函数你在自己的代码里重写它就行。DMA把缓冲区里的数据全部搬到USART发送寄存器后DMA会触发传输完成中断HAL库在中断处理流程中自动调用这个回调。3.3 发送缓冲区不能是会被释放的局部变量这是我见过最多的一个新手翻车现场。在函数里定义了局部数组当发送缓冲区然后直接调HAL_UART_Transmit_DMA函数一退出局部变量占用的栈空间被释放了。可DMA还在往USART寄存器里搬数据搬着搬着突然发现内存地址上的内容已经被别的变量覆盖了发出去的就是一堆乱码。// 错误示范tx_buffer是局部变量函数退出后数据可能被改掉 void send_data(uint8_t *data, uint16_t len) { uint8_t tx_buffer[256]; memcpy(tx_buffer, data, len); HAL_UART_Transmit_DMA(huart1, tx_buffer, len); // 危险 }正确的做法把发送缓冲区定义为全局数组或者定义成static局部变量确保内存空间在发送期间一直有效发送期间不要让其他函数写入这块缓冲区需要等HAL_UART_TxCpltCallback里的标志置位后再复用如果工程里需要频繁发送不同内容建议用一块固定的全局缓冲区发送前先填充内容再启动DMA另外还有个细节HAL_UART_Transmit_DMA启动的时候如果上一次发送还没结束UART状态还处于BUSY_TX函数会直接返回HAL_BUSY数据不会启动发送。所以项目里要约定好要么每次发送之间留有足够间隔要么在发送回调里设置标志只有标志清除时才允许发起下一次发送。4. RX方向Normal mode 空闲中断完整接收链路4.1 初始化使能DMA接收和IDLE中断接收方向的DMA配置和发送方向最核心的区别是方向变成DMA_PERIPH_TO_MEMORY外设到内存内存地址递增打开。模式依然选DMA_NORMAL。CubeMX里生成代码后HAL库的启动函数是HAL_UART_Receive_DMA(huart1, (uint8_t *)uart1_rx_buf, UART1_RX_BUF_SIZE);这个函数把DMA通道初始化好、把NDTR设置成UART1_RX_BUF_SIZE然后使能DMA通道和USART的DMA接收请求DMAR位。之后串口每收到一个字节硬件自动把它搬到uart1_rx_buf不需要CPU干预。但仅仅启动DMA还不够我们前面说要用IDLE中断来截帧。所以启动DMA接收后还要显式使能UART的IDLE中断__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);这里有个坑很多人踩了IDLE中断和串口全局中断的关系。IDLE中断属于USART的中断源之一它的前提是USART1_IRQn这个全局中断已经使能。CubeMX里你要在NVIC设置中勾选USART1全局中断同时在stm32f4xx_it.c里要有对应的USART1_IRQHandler函数并调用HAL_UART_IRQHandler(huart1)。IDLE中断标志置位后会统一进入这个入口由HAL库分发到空闲中断回调。还有一个容易忽略的点启用IDLE中断后如果在接收一帧数据的过程中DMA已经因到达缓冲区大小而停止而串口线路上出现了空闲IDLE标志依然会置位并触发中断。所以处理IDLE回调时一定要判断当前DMA是否还在活跃状态或者说要确认缓冲区里是否真的有数据避免处理到空帧。4.2 空闲中断回调暂停DMA、取长度、重启这是整个接收链路最核心的部分。当一帧数据发完串口线路上出现空闲硬件置起IDLE标志随后进入HAL_UART_IdleCallbackvoid HAL_UART_IdleCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint16_t len; // 第一步清除IDLE标志 __HAL_UART_CLEAR_IDLEFLAG(huart1); // 第二步暂停DMA接收防止新的数据继续写入缓冲区 HAL_UART_DMAStop(huart1); // 第三步读取DMA剩余计数计算实际接收长度 len UART1_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (len 0) { // 处理这一帧数据拷贝、解析、丢给协议栈 process_uart_frame(uart1_rx_buf, len); } // 第四步重新启动DMA接收等待下一帧 HAL_UART_Receive_DMA(huart1, (uint8_t *)uart1_rx_buf, UART1_RX_BUF_SIZE); } }这里每一步都有讲究。__HAL_UART_CLEAR_IDLEFLAG必须放在最前面。IDLE标志不会由硬件自动清除如果不人为清除会产生一种很迷惑的现象每次中断进来标志还是置位的于是连续触发空闲中断一帧数据会被重复处理好几次。HAL_UART_DMAStop这个函数做的事情是禁用DMA通道、禁用USART的DMA接收请求、把UART的接收状态重置。它保证在取出数据之前缓冲区不会再被写入新数据我们读到的长度是准确的。__HAL_DMA_GET_COUNTER读的是DMA内部计数器NDTR的当前值表示还剩多少个字节没搬。用缓冲区总大小减去剩余数就是实际搬进来的字节数。至于process_uart_frame里做什么处理取决于你的业务。可以拷贝到另一个协议缓冲区也可以直接在原缓冲区里解析。但要注意处理完数据后缓冲区会被重新启动的DMA覆盖如果解析过程很慢或者数据需要在主循环里慢慢处理一定要先把数据拷贝出来或者用双缓冲区交替使用。4.3 一帧数据恰好满缓冲区的边界处理有一种边界情况容易被忽视如果一帧数据的长度恰好等于缓冲区大小比如缓冲区256字节、一帧正好256字节会发生什么DMA搬满256字节后触发传输完成中断TC此时DMA通道自动禁用USART的DMA接收请求还在但DMA已经停下来了。紧接着串口线路出现空闲IDLE中断触发。在IDLE回调里__HAL_DMA_GET_COUNTER返回0算出来的len等于256看起来一切正常。但如果DMA的TC中断和IDLE中断同时产生HAL库的处理顺序很关键。如果你在HAL_UART_RxCpltCallback里也做了同样的数据处理那这一帧数据会被处理两次。我遇到过这个问题表现在协议层就是偶发性的数据重复、校验错误。解决方式在IDLE回调里多一个判断。如果DMA已经停了也就是NDTR读到0、数据刚好满并且HAL_UART_RxCpltCallback已经处理过这一整帧数据IDLE回调里就不要再处理了只需要重新启动接收。void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { process_uart_frame(uart1_rx_buf, UART1_RX_BUF_SIZE); rx_data_handled 1; // 标记已处理 HAL_UART_Receive_DMA(huart1, (uint8_t *)uart1_rx_buf, UART1_RX_BUF_SIZE); } } void HAL_UART_IdleCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); uint16_t len UART1_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (rx_data_handled) { rx_data_handled 0; // TC回调已经处理过了这里跳过 } else if (len 0) { process_uart_frame(uart1_rx_buf, len); } HAL_UART_Receive_DMA(huart1, (uint8_t *)uart1_rx_buf, UART1_RX_BUF_SIZE); } }这种边界情况不常见但一旦出现就是疑难杂症。建议在写代码之初就考虑到省得后面排查半天。还有一个和缓冲区有关的设计问题缓冲区开多大合适这取决于你最大的协议帧长度通常取最大帧长的1.5倍到2倍比较安全。如果缓冲区开小了大于缓冲区的一帧数据根本存不下Normal mode的DMA搬满缓冲区就停后面的数据不入缓冲区也不产生IDLE中断因为字节一直在流线路没空闲整个接收就卡死了。我在一个项目里把缓冲区设成128字节结果上位机偶尔发一包150字节的报文直接导致后续通信全部错乱排查了整整一天才定位到问题。5. 可移植的UART DMA收发示例代码代码这块我直接给一份可以拿走的基于STM32F4系列HAL库GD32/HC32的HAL库用法类似只需要替换函数名和寄存器名。5.1 数据结构与全局变量/* uart_dma.h */ #ifndef __UART_DMA_H #define __UART_DMA_H #include main.h #define UART1_RX_BUF_SIZE 256 // 接收缓冲区大小建议大于最大协议帧长 extern UART_HandleTypeDef huart1; extern DMA_HandleTypeDef hdma_usart1_tx; extern DMA_HandleTypeDef hdma_usart1_rx; void uart_dma_init(void); void uart_dma_send(uint8_t *data, uint16_t len); #endif/* uart_dma.c */ #include uart_dma.h uint8_t uart1_rx_buf[UART1_RX_BUF_SIZE]; // DMA接收缓冲区 volatile uint8_t uart1_rx_data_handled 0; // 边界去重标志 volatile uint8_t uart1_tx_complete 1; // 发送完成标志 void uart_dma_init(void) { // 启动DMA接收 HAL_UART_Receive_DMA(huart1, (uint8_t *)uart1_rx_buf, UART1_RX_BUF_SIZE); // 使能IDLE空闲中断用于判断一帧结束 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); } void uart_dma_send(uint8_t *data, uint16_t len) { // 上一次发送未完成时直接返回失败提示 if (HAL_UART_GetState(huart1) HAL_UART_STATE_BUSY_TX) { return; } uart1_tx_complete 0; HAL_UART_Transmit_DMA(huart1, data, len); }5.2 中断服务函数与回调在stm32f4xx_it.c中已经有CubeMX生成的串口中断服务函数确保它调用HAL库的处理函数即可void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); } void DMA1_Stream3_IRQHandler(void) { HAL_DMA_IRQHandler(hdma_usart1_tx); } void DMA1_Stream5_IRQHandler(void) { HAL_DMA_IRQHandler(hdma_usart1_rx); }如果用的是DMA1_Stream4和DMA1_Stream2中断服务函数名要对应修改。CubeMX会自动生成别手残删掉就行。然后是三个回调函数的实现// 发送完成回调 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uart1_tx_complete 1; } } // DMA接收完成回调一帧数据恰好填满缓冲区时触发 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { process_uart_frame(uart1_rx_buf, UART1_RX_BUF_SIZE); uart1_rx_data_handled 1; // 重新启动DMA接收 HAL_UART_Receive_DMA(huart1, (uint8_t *)uart1_rx_buf, UART1_RX_BUF_SIZE); } } // 空闲中断回调一帧数据未满缓冲区但线路空闲时触发 void HAL_UART_IdleCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint16_t len; // 清除IDLE标志避免重复进入 __HAL_UART_CLEAR_IDLEFLAG(huart1); // 暂停DMA接收 HAL_UART_DMAStop(huart1); // 计算实际接收长度 len UART1_RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); if (uart1_rx_data_handled) { // 数据已经在RxCpltCallback里处理过跳过 uart1_rx_data_handled 0; } else if (len 0) { process_uart_frame(uart1_rx_buf, len); } // 重新启动DMA接收等待下一帧 HAL_UART_Receive_DMA(huart1, (uint8_t *)uart1_rx_buf, UART1_RX_BUF_SIZE); } }5.3 业务处理函数的占位// 业务层解析收到的数据帧 void process_uart_frame(uint8_t *buf, uint16_t len) { // 拷贝到协议缓冲区或者直接解析 // 例如如果协议帧固定以0xAA 0x55开头这里做帧头校验 // 注意处理时间不要过长否则可能错过下一帧的IDLE中断 // 如果需要慢慢处理把数据拷贝到另一个队列里由主循环消化 }这套代码的逻辑链路是DMA一直在后台收数据数据来了不打断CPU只有两种情况会通知CPU——要么是数据把缓冲区填满了RxCpltCallback要么是串口线路出现了空闲IdleCallback。前者对应数据量恰好等于缓冲区大小的边界后者对应一帧数据不足缓冲区大小的常规情况。在实际项目中我还习惯在process_uart_frame外面加一个简单的帧超时保护如果一帧数据长度超过一个设定阈值还没有空闲中断出现就强制丢弃缓冲区数据并重启DMA。这能防止某些异常情况下接收链路卡死。6. 实测踩坑记录标志位、时序顺序与调试道具6.1 IDLE中断反复触发真正原因不是你配置错我第一次用IDLE中断的时候遇到的情况是收到一帧数据后程序莫名其妙地反复进入HAL_UART_IdleCallback数据被重复处理了五六次。我一度以为是中断优先级配错了查了半天最后发现就是没清IDLE标志导致的。USART的IDLE标志比较特殊它不是读寄存器就能自动清除的必须软件写清除。在HAL库里清除方式是直接调用__HAL_UART_CLEAR_IDLEFLAG(huart1)这个宏内部会执行一个读寄存器再写寄存器的操作通常是清USART_SR的IDLE位。如果你用的是标准外设库直接USART_ClearITPendingBit(USART1, USART_IT_IDLE)也是一样的效果。还有一个坑清除IDLE标志的时机。一定是在进入回调函数的第一时间清除不要在数据处理完之后再清。数据处理的耗时可能不固定这个过程中如果又来了新的数据、新的空闲老标志会给新数据盖棺定论造成数据粘连。先清标志再取数据这个顺序别搞反。6.2 DMA通道在Normal模式下罢工后的恢复顺序Normal模式下DMA搬完数据后会自动禁用通道。这时候如果你只是简单地再次调用HAL_UART_Receive_DMA启动接收有时候发现DMA就是不动数据来了也不搬。这个问题我在F4和GD32上都遇到过根源在于DMA通道的配置状态没有被完全重置。HAL_UART_Receive_DMA内部其实会先调用HAL_DMA_Abort将DMA通道复位再重新配置并使能。但是如果你在IDLE回调里已经调用过HAL_UART_DMAStop这个函数内部也会触发DMA的中断处理流程。如果这两者在中断嵌套中互相干扰就可能导致DMA通道状态异常。我的习惯是在业务层确保完整的恢复顺序先暂停HAL_UART_DMAStop读取数据、处理数据处理完毕后重新启动HAL_UART_Receive_DMA在一个完整的IDLE回调流程内不要穿插其他可能操作同一个DMA通道的代码另外DMA中断服务函数必须存在。很多人只在CubeMX里勾选了串口中断和DMA中断但忘了在stm32f4xx_it.c里保留DMA中断对应的HAL_DMA_IRQHandler调用或者被CubeMX重新生成代码时覆盖了。少了DMA中断处理DMA传输完成的标志就永远没人清除下次启动DMA时状态错乱表现就是DMA罢工。6.3 调试串口线缆和USB转串口模块的常见问题项目调试时USB转串口模块几乎是标配。但很多接收端奇怪的问题根源根本不在MCU代码而在调试链路。最经典的接线规则USB转串口模块的TXD要接MCU的RXD模块的RXD接MCU的TXDGND必须共地。反接的后果就是永远收不到数据。STM32F103的PA9是USART1_TXPA10是USART1_RX这两个引脚我在无数项目里使用闭着眼都能背出来但接错线的情况依然时有发生。FT232R、CP2102N、FT231X这些常见的USB转UART芯片Windows下一般需要安装驱动。如果设备管理器里显示黄色感叹号优先去芯片厂商官网下载对应驱动别乱装第三方驱动。驱动装不对现象也很奇葩电脑端发送正常收不到数据或者收发都乱码。还有一个容易被忽略的点逻辑电平。MCU的UART引脚是3.3V TTL电平而某些USB转串口模块支持5V/3.3V切换如果板子上的USB转串口模块模式切错了电平不匹配会带来间歇性通信异常。这种问题排查起来极耗时间建议在调试串口硬件时先用示波器或逻辑分析仪看一下波形确认RXD线上确实有数据下来再回头查代码。6.4 从STM32换到GD32/HC32时这套代码怎么改最近国产MCU的替代需求越来越多我手上好几个项目都从STM32换到了GD32、HC32。好消息是这套UART DMA Normal mode的收发框架在国产MCU上几乎可以原样迁移。以GD32为例GD32的DMA架构和STM32F1很像标准外设库提供的函数名有差异但逻辑完全一致发送dma_transfer_config(DMA0, DMA_CH2, ...)之类的配置流程接收同样设置DMA普通模式、外设到内存方向、内存地址递增空闲中断GD32串口同样有IDLE标志位中断标志处理和STM32方式相同HC32系列比如HC32L136的DMA支持的传输模式会稍有不同但Normal mode单次传输基本都是标配。重点还是那三件事方向、地址递增、模式。移植时只要守住三个抽象接口代码很容易复用uart_dma_send——发送接口底层换成目标MCU的DMA发送函数HAL_UART_RxCpltCallback或等价回调——缓冲区满处理HAL_UART_IdleCallback或等价回调——空闲中断处理把这三个接口的底层实现替换掉上层的协议解析、数据处理完全不用动。我在迁移过程中体会最深的一点是DMA寄存器名、库函数名可以不同但DMA设备把外设数据搬到内存搬完触发完成中断配合空闲中断判帧这套设计思想是通用的。理解了这套思想换MCU平台就跟换工具一样拿起来就能用。最后再分享一个经验在调试DMA收发的时候别急着上正式协议先用一个最简单的回环测试验证链路。把PC上位机发送的数据通过RS485/TTL接到MCUMCU收到后再原样发回去上位机对比收发是否一致。链路通了再叠加协议解析排查问题的范围会小很多。这套方法帮我排查掉过不少莫名其妙的通信故障希望也能帮你少走弯路。