
做嵌入式这几年UART串口绝对是打交道最多的外设没有之一。从调试日志到和传感器、无线模块、上位机通信几乎每个项目里都能看到它的身影。但有个现象很有意思很多工程师谈起串口收发头头是道一旦涉及 DMA尤其是 DMA 的 Normal mode正常模式就容易卡壳。这个项目标题“UART TX/RX with DMA in Normal mode”看着简单实际里面藏了不少细节——为什么不用更热门的 Circular modeNormal mode 下怎么接收不定长数据怎么处理发送完成和接收超时这些才是真正值得写出来的东西。我先说结论在 Normal mode 下做 UART 的 DMA 收发核心难点不在发送而在接收。发送就是“把缓冲区交给 DMA发完回调”逻辑非常直接接收麻烦在“你不知道对方什么时候发、发多少”而 Normal mode 的 DMA 搬运完指定长度就会停下来这反而给了你一个清晰的边界去处理帧协议。这篇文章我就从原理、配置、代码到踩坑记录把整个链路完整拆开给正在学 STM32 或者正被串口 DMA 折腾的朋友一个能直接抄作业的方案。1. 项目概述DMA Normal mode 到底解决了什么问题1.1 这个项目的核心需求先还原一下需求场景。很多项目里串口要干的事无非两类一类是主动往外发数据比如日志、状态上报、报文回复另一类是等别人发数据过来解析之后执行命令。传统写法就是中断收发每个字节进一次中断CPU 频繁进出中断服务函数虽然代码写起来简单但在波特率高的场合比如 460800 甚至 1M中断频率会非常高主循环的处理被严重挤压。用 DMA 之后情况就变了。DMA 相当于一个独立的数据搬运工它能在外设和内存之间直接搬数据搬完才通知 CPU。这样 CPU 只需要在传输开始的时候下发指令传输结束的时候处理结果中间这段时间完全可以去跑协议解析、刷屏幕、跑控制环。标题里强调 Normal mode说明这不是用 Circular mode 做环形缓冲而是用的最原始的“搬一次、停一次”模式这正好贴合帧协议类的通信一帧数据来了收完整帧处理完再准备下一帧。这里不追求高吞吐的流式收发更看重可控性和代码的清晰度。1.2 为什么选 Normal mode与 Circular mode 的取舍我见过很多教程一上来就推荐 Circular mode 环形缓冲说这样效率高、不容易丢数据。这话本身没错但忽略了一个前提环形缓冲是一套复杂度较高的结构需要维护读写指针、处理覆盖检测如果控制不好很容易出现数据错位或者读到一半被新数据打断的情况。Normal mode 的取舍恰恰相反它的逻辑非常简单DMA 配置好源地址、目的地址、传输长度之后开始搬运搬完指定字节数自动停止再需要传输时由软件重新启动每一轮传输都有明确的完成边界不会出现“数据已经覆盖到下一圈”的情况。对比项Normal modeCircular mode传输完成行为停止通道禁用自动回卷继续传输RX 接收不定长数据配合空闲中断收到即停配合空闲中断持续缓冲TX 发送数据包发完即停适合帧协议较少使用适合连续流中断触发每次传输完成触发一次每轮循环触发代码复杂度低易理解中等需处理环形缓冲内存占用小需要一个更大的缓冲池所以如果你的项目是“一帧数据来了——收下——处理——回包”这种典型的主从问答协议Normal mode 完全够用而且比 Circular mode 好调试得多。这也是这个项目最核心的设计思路用最简单可靠的机制解决实际问题而不是为了炫技上复杂架构。1.3 适用场景与前置条件这个方法适用的场景包括RS485 半双工通信、串口 AT 指令解析、和蓝牙/WiFi 模块的指令交互、需要高速发送日志但不想吃满 CPU 的调试系统。前置条件很基础一块 STM32F1/F4/G0/G4/H7 都可以、一个串口工具、一个能看寄存器/变量的调试器。下面所有代码基于 STM32F103 HAL 库讲解但思路是通用的换成其他系列甚至 GD32、HC32 都能直接套。2. 原理拆解UART DMA 链路到底是怎么走的2.1 DMA 的搬运逻辑先打个比方。CPU 是一个人DMA 是一台自动售货机。原来你要给别人送十瓶水得自己跑十趟现在你只需要告诉售货机“从哪个仓库拿水、放到哪个门口、拿几瓶”机器就自己跑完整个流程最后叮咚一声告诉你“送完了”。DMA 的三要素就是这三个源地址、目的地址、传输长度。在寄存器层面DMA 通道的几个关键寄存器分别是CMAR存储器地址寄存器存放内存侧的地址CPAR外设地址寄存器存放外设侧地址CNDTR传输数量寄存器还剩多少字节要搬每搬一个字节自动减一CCR配置寄存器配置方向、模式、优先级、地址增量、中断使能等。传输完成时DMA 通道的传输完成标志置位如果开了中断会进 DMA 中断回调同时 CNDTR 会变成 0Normal mode 下通道使能位也会被硬件自动清除。2.2 UART 的 TX/RX 和 DMA 如何握手UART 外设在发送和接收时都会产生 DMA 请求。发送方向UART 的数据寄存器 DR 空了它会向 DMA 发出请求“我需要下一个字节了”DMA 就从内存里搬一个字节到 DR接收方向UART 收到一个字节放进 DR它会向 DMA 发出请求“这有个字节你赶紧拿走”DMA 就把 DR 里的值搬到内存缓冲区。这个握手是硬件自动完成的不需要软件干预。关键是方向别搞反TX 的 DMA 方向是 MemoryToPeripheral目标是 UART 数据寄存器RX 的 DMA 方向是 PeripheralToMemory目标是内存缓冲区。我在调试时见过有人把两个方向配反结果是发送数据完全乱套接收也永远拿不到正确数据。2.3 Normal mode 的“停”与“启”Normal mode 最容易被忽略的就是“它传输完会停下来”。这一点在接收方向尤其重要。HAL 库中你调用一次HAL_UART_Receive_DMA()实际上是在做三件事把 DMA 通道的 CNDTR 装入你指定的长度、使能 DMA 通道、使能 UART 的 DMA 接收请求。当 DMA 搬运完这么多字节通道就停了UART 再来数据的话不会再有 DMA 帮你搬数据只能挤在寄存器里一个不小心就被覆盖掉。很多初学者遇到的“串口只能收一次数据之后就再也收不到了”八成就是这个原因。而正确的做法就是在一帧数据处理完之后重新调用一次HAL_UART_Receive_DMA()启动下一轮接收。这就是标题里 Normal mode 的完整含义每一轮接收都是有限的、明确的、需要管理的。3. TX 链路实操发送数据的配置与代码3.1 CubeMX 里的配置步骤先打开 CubeMX选好芯片把 USART1 设置为异步模式波特率按项目需要选 115200 或更高数据位 8、停止位 1、无校验这是最常用的组合。然后在 DMA Settings 选项卡里添加 DMA 请求。这里要注意发送和接收是两个独立的 DMA 请求需要分别添加添加USART1_TXDirection 选 MemoryToPeripheralMode 选 Normal优先级我自己习惯选 Medium添加USART1_RXDirection 选 PeripheralToMemoryMode 同样是 Normal优先级选 MediumDMA 的内存地址增量模式Memory Increment要打开外设地址增量Peripheral Increment关闭因为 UART 的数据寄存器地址只有一个不需要递增。NVIC 部分一定要打开 USART1 全局中断。为什么接收需要开因为后面要用空闲中断。发送其实不开 UART 中断也问题不大可以用 DMA 的传输完成中断来代替但为了统一处理我更推荐先打开 USART1 全局中断和两个 DMA 的中断确保回调链完整。3.2 发送代码与回调处理CubeMX 生成工程之后代码非常干净。发送一条不定长数据只需要调用一次 HAL 函数传入缓冲区指针和长度uint8_t tx_buf[] Hello DMA UART\r\n; // 用DMA发送数据发送完成后会进入回调 HAL_UART_Transmit_DMA(huart1, tx_buf, sizeof(tx_buf) - 1);到这里CPU 已经把发送任务交给 DMA 了可以继续做别的事。发送完成的信号通过回调函数返回volatile uint8_t tx_busy 0; void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { tx_busy 0; // 标记发送完成缓冲区可以修改 } }实际项目里我会在调用发送前先把tx_busy置 1然后在回调里清零这样就能防止在数据还没发完的时候就去改缓冲区内容。// 发送前需要检查是否还有未完成的发送 while (tx_busy) { // 等待上一次发送完成超时保护可以加一个计数 if (timeout 100000) break; } tx_busy 1; HAL_UART_Transmit_DMA(huart1, tx_buf, len);3.3 发送路径的独家心得这里分享几个我在实际调试中沉淀下来的细节。第一如果发送频繁最好实现一个简单的发送队列避免 while 等待阻塞主循环。比如定义两层缓冲区一层正在发送一层保存待发送数据回调里交换。初期项目数据量不大时最简单的tx_busy标志就够了但你要清楚这个限制。第二DMA 发送完成后CNDTR寄存器是 0DMA 通道处于禁用状态。如果这时候你调试器停在 HAL_UART_Transmit_DMA 里发现 DMA 状态是 Disable别慌这是正常的。第三有些芯片在 DMA 发送过程中进入低功耗模式会出问题。如果你的系统有睡眠需求DMA 发送期间要避免进入 STOP 模式否则数据会丢在“半路”上。我踩过这个坑后来都是在发送完成后才允许进入低功耗。第四如果发送的是大块数据比如几百字节的固件升级包DMA 优势非常明显。实测在 72MHz 的 F103、115200 波特率下用 DMA 发送 1KB 数据CPU 只需要在开始和结束两个时间点做处理中间完全不用管CPU 占用几乎为零。而用传统中断方式1KB 数据要进上千次中断CPU 被切得七零八碎。4. RX 链路实操Normal mode 接收不定长数据的完整方案4.1 关键组合DMA 空闲中断接收方向比发送拗口的地方在于DMA 是按固定长度搬运的但串口数据是不定长的。假如我配置 DMA 接收 256 字节对方只发了 8 个字节那 DMA 就一直停在那既不会触发完成中断数据也拿不出来。这时候就需要 UART 的空闲中断IDLE Interrupt。空闲中断的定义是总线在接收完最后一个字节后持续一个字节时间没有再接收到新数据硬件就会置起 IDLE 标志。这个事件恰好告诉我们“这一帧数据已经收完了可以处理了”。所以整个接收策略是提前开启 DMA 接收指定 Max 长度的缓冲区同时开启 UART 的空闲中断对方发来一帧数据DMA 逐个字节搬进缓冲区总线空闲IDLE 中断触发在中断里读 DMA 的剩余计数寄存器算出实际收到多少字节处理数据重新启动 DMA 接收。这套方案在工业通信、AT 指令解析里非常常见好在它不需要知道具体帧长只要是“发完停一下”的通信模式都能覆盖。4.2 中断处理与数据长度计算启动接收的代码很简单#define RX_BUF_SIZE 256 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len 0; volatile uint8_t rx_complete 0; // 使能接收DMA缓冲256字节 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); // 使能UART空闲中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);关键逻辑全在中断服务函数里。这里我用 F103 的 USART1 举例void USART1_IRQHandler(void) { // 先处理空闲中断 if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE) ! RESET) { // 清除IDLE标志 __HAL_UART_CLEAR_IDLEFLAG(huart1); // 计算实际接收长度 uint16_t remain __HAL_DMA_GET_COUNTER(hdma_usart1_rx); rx_len RX_BUF_SIZE - remain; if (rx_len 0) { rx_complete 1; // 建议在这里把数据交给上层处理或者置标志后在主循环处理 process_frame(rx_buf, rx_len); } // 重新启动DMA接收进入下一帧接收状态 HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); } HAL_UART_IRQHandler(huart1); }几个细节我特意展开解释一下。__HAL_DMA_GET_COUNTER(hdma_usart1_rx)读取 DMA 通道当前剩余待传输的字数。启动时这个值是 RX_BUF_SIZE每收一个字节减一。所以用最大值减去当前值得到的就是已经收到的字节数这是计算接收长度最常用也是最快的方法。清除 IDLE 标志这件事在 F1 系列上有个小坑。IDLE 标志的清除方式是“先读 SR 寄存器再读 DR 寄存器”HAL 库的__HAL_UART_CLEAR_IDLEFLAG宏实际执行的是向 SR 寄存器写 1 来清零在部分芯片上可能无效。我在 F103 上遇到过写不进的情况后来改成直接操作寄存器// 兼容性最好的清IDLE方式 volatile uint32_t tmp USART1-SR; tmp USART1-DR;读完之后标志自然就清了。F4/H7 的写法稍有不同但“先读后读”这种方式是不容易出错的选择。关于在中断里调用HAL_UART_Receive_DMA()重新启动有同学担心中断里做太多事情会影响实时性。我的经验是HAL_UART_Receive_DMA()本身只是配置几个寄存器和标志执行时间在微秒级完全可以在中断里直接调用。但数据处理函数process_frame()如果比较耗时比如要解析 JSON、查询数据库那就不要放中断里只置一个标志位回主循环处理。4.3 一个完整可复用的例程把上面这些拼起来就是一个完整的 Normal mode 接收不定长数据的最小工程。我在 main 函数里一般这么组织int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); // 启动接收 rx_complete 0; HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); while (1) { // 主循环处理接收到的帧 if (rx_complete) { rx_complete 0; // 回显测试把收到的数据原样发回去 HAL_UART_Transmit_DMA(huart1, rx_buf, rx_len); // 实际项目里在这里解析协议 parse_and_execute(rx_buf, rx_len); } // 其他任务... } }这段代码跑起来的效果是上位机发一串数据板子收到后原样返回并且把收到的内容打印出来。整个过程中 CPU 几乎不参与字节搬运即使波特率拉到 921600数据也不会像纯中断方式那样频繁被撕裂。5. 常见问题与排查技巧实录5.1 现象与根因速查表我在帮别人调试这类代码时遇到的问题大概可以归纳成下表的几类。这份速查表你可以直接截图留着遇到问题先对照一遍能省下不少时间。现象根本原因解决办法只能收到第一帧后续数据全部丢失Normal mode DMA 传输完成后停止未重新启动在 IDLE 中断处理里重新调用 HAL_UART_Receive_DMAIDLE 中断进不去未使能 UART 空闲中断或 NVIC 没有打开串口中断确认 __HAL_UART_ENABLE_IT 被调用且 USARTx global interrupt 已使能接收到的数据长度不对偶尔多一两个字节IDLE 标志清除和读取 CNDTR 的顺序有问题先读标志、清标志再读 CNDTR确保本帧 DMA 计数保持稳定接收数据第一个字节丢失调用了 HAL_UART_Receive_DMA 之后立即使能 IDLE启动顺序没问题但芯片初始化异常在串口初始化后加一段延时或检查 RXNE 标志确保 UART 完全就绪发送数据偶尔错乱像是发了一半就停了发送缓冲区在 DMA 还没搬完时被修改用 tx_busy 标志做互斥等 TxCplt 回调再操作缓冲区DMA 中断回调不执行DMA 中断优先级被设置成最低而 UART 中断频繁抢占在 CubeMX 里把 DMA 中断优先级调高或者统一用 UART 已满中断回调5.2 三个真实排查过程第一个问题我印象最深的是“接收第一帧正常第二帧起全部丢死”。这个现象非常典型我当时排查了半天最后发现就是HAL_UART_Receive_DMA在 IDLE 中断里没有被重新调用。Normal mode 下 DMA 通道在完成一个完整传输周期后就会关闭而串口继续来数据时UART 的 DMA 请求已经没人响应了。那种感觉就像自动门一次只能进一个人第一个人进去之后门关了后面排队的人全部被挡在外面。解决方式就是重新“开门”。第二个问题是 IDLE 中断偶尔会误触发。排查发现UART 在初始化结束后如果线上有毛刺噪声就会产生一个虚假的空闲事件。加上 F103 的 IDLE 标志清除方式偏怪清不干净的话会在配置完 DMA 后马上再次触发。我的解决办法是在中断里除了清 IDLE 标志还加一个“接收到的字节数必须大于 0”的判断这样即使 IDLE 误触发也不会把空帧交给上层处理。第三个问题比较隐蔽发生在 DMA 发送期间数据缓冲区地址是局部变量。局部变量在栈上函数返回后栈空间可能被其他函数复用如果 DMA 还没搬完缓冲区内容已经被破坏了。这就是我前面强调“发送完成前不能动缓冲区”的典型场景。遇到这种问题最简单的对策是把发送缓冲区定义成全局数组或者加上static修饰保证内存生命周期足够长。5.3 性能实测与优化建议最后说一组实测数据方便你对这个方案的性能有直观认识。在 STM32F103C8T6、72MHz 主频、USART1 115200 波特率的条件下我用 DMA 发送 1KB 数据CPU 只用了不到 1% 的资源去启动和收尾而用中断发送同样的数据CPU 需要响应约 1000 次中断每次中断至少几十个周期累计开销能占到几毫秒。对于周期性上报数据的设备这个差距直接决定了主循环还能不能干别的活。优化建议方面如果项目后续需要处理更高速率的连续数据流可以考虑把 RX 方向升级为 Circular mode配合指针逻辑实现真正的环形缓冲既能保留 DMA 的低 CPU 占用又能应对数据量大、帧结构不固定的场景。那时候你再看这次 Normal mode 的实现会觉得它是一个非常好的跳板因为你已经把 UART、DMA、中断、缓冲这几块的核心机制都吃透了。我个人在实际操作中的体会是Normal mode 的串口 DMA 是一套“小而美”的范例。它没有环形缓冲的复杂内存模型没有高频中断的调度压力只有清晰的三段式流程启动接收、等待空闲、重启接收。先把这套流程跑通、跑稳再去尝试更高级的模式思路会顺畅很多。最后再分享一个小技巧调试 DMA 问题时与其反复打日志不如直接开调试器看 DMA 的 CNDTR 寄存器和 UART 的 SR 寄存器很多“玄学 bug”在寄存器值面前都会马上现出原形。