news 2026/8/31 23:02:01

STM32 UART DMA Normal模式收发详解:从原理到实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 UART DMA Normal模式收发详解:从原理到实战

做嵌入式这几年,UART串口绝对是打交道最多的外设,没有之一。从调试日志到和传感器、无线模块、上位机通信,几乎每个项目里都能看到它的身影。但有个现象很有意思:很多工程师谈起串口收发头头是道,一旦涉及 DMA,尤其是 DMA 的 Normal mode(正常模式),就容易卡壳。这个项目标题“UART TX/RX with DMA in Normal mode”看着简单,实际里面藏了不少细节——为什么不用更热门的 Circular mode?Normal 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 的调试系统。前置条件很基础:一块 STM32(F1/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 会变成 0,Normal 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_TX,Direction 选 MemoryToPeripheral,Mode 选 Normal,优先级我自己习惯选 Medium;
  • 添加USART1_RX,Direction 选 PeripheralToMemory,Mode 同样是 Normal,优先级选 Medium;
  • DMA 的内存地址增量模式(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寄存器是 0,DMA 通道处于禁用状态。如果这时候你调试器停在 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 标志。这个事件恰好告诉我们“这一帧数据已经收完了,可以处理了”。

所以整个接收策略是:

  1. 提前开启 DMA 接收,指定 Max 长度的缓冲区;
  2. 同时开启 UART 的空闲中断;
  3. 对方发来一帧数据,DMA 逐个字节搬进缓冲区;
  4. 总线空闲,IDLE 中断触发;
  5. 在中断里读 DMA 的剩余计数寄存器,算出实际收到多少字节;
  6. 处理数据,重新启动 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_DMA
IDLE 中断进不去未使能 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”在寄存器值面前都会马上现出原形。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 23:01:40

存储系统服务异常时如何分层降级

存储系统服务异常时如何分层降级一、AI 智能路由失效引发的集群震荡 在现代分布式存储系统中,为了提高数据读写效率和负载均衡度,逐渐引入了基于机器学习模型的智能 IO 调度与动态数据切片路由算法。模型根据节点 CPU 占用率、磁盘 I/O 延迟、网络 RTT 以…

作者头像 李华
网站建设 2026/8/31 22:54:12

佳博打印机安卓SDK集成指南:蓝牙WiFi连接与ESC/POS打印开发实践

简介:本资源是面向Android应用开发者的技术集成包,专为快速对接佳博品牌小票打印机而设计,适用于餐饮点餐、零售收银、物流面单等需本地热敏打印的商用场景。包内共70个文件,涵盖15个Java核心接口类、17个XML布局与配置文件、4个A…

作者头像 李华
网站建设 2026/8/31 22:53:58

Artix-7 FPGA DDR3硬件参考设计解析:从时序约束到上板调试

简介:面向FPGA开发者的Artix-7 XC7A35T DDR3开发板硬件参考设计资料包,聚焦赛灵思低功耗FPGA与DDR3存储接口的工程实现,适用于嵌入式与数字信号处理场景。压缩包共7个文件,含5份PDF用户手册、1个HTML使用说明和1个TXT下载说明&…

作者头像 李华
网站建设 2026/8/31 22:53:52

基于知识图谱与RAG的法务智能问答系统实践

简介:本资源是一个面向法律科技领域开发者的法务智能知识图谱实践项目,聚焦NLP与知识图谱技术在法律问答场景的落地应用,适用于具备Python、图数据库及自然语言处理基础的中级以上学习者。项目构建了覆盖20万条法务问答与法律资讯的知识库&am…

作者头像 李华
网站建设 2026/8/31 22:53:41

如何控制YouTube视频主题

https://riat-.blog.csdn.net/article/details/164224166 国家机密,,,无可奉告

作者头像 李华
网站建设 2026/8/31 22:53:29

STM32G431外部时钟50MHz上限详解:PLL配置直达170MHz实战

做 STM32G431 项目的人,基本都会被同一个问题卡过:这颗芯片的外部时钟频率,最高到底能喂多少?我最早是给一块数字电源控制板做时钟方案时撞上这个问题的。当时想用一颗有源外部时钟直接进 HSE,把板上的无源晶振位置省掉…

作者头像 李华