news 2026/9/9 21:23:17

STM32 USART3 DMA收发详解:通道配置、发送判断与不定长接收

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 USART3 DMA收发详解:通道配置、发送判断与不定长接收

简介:面向嵌入式开发者的STM32F103标准库工程,专注于利用DMA方式实现USART3的接收与发送,解决传统串口中断频繁占用CPU的问题,适用于工业通信、数据采集等实时性较高的场景。压缩包共171个文件、约3.41MB,包含C源码、H头文件、S启动文件等核心代码,以及uvproj/uvopt工程文件、o/axf/hex/map等编译链接产物,另有htm/doc/txt格式的说明文档与批处理工具,便于直接打开工程对照学习。目前已有3295人学习下载,作为入门DMA串口通信的参考资料具有一定热度。资源提供完整的USART3+DMA初始化、传输配置、中断处理与软件流程示例,可帮助开发者快速理解DMA通道选择、传输方向设置、循环传输模式等关键细节,并迁移到实际项目中的多串口或高波特率数据收发场景。 前阵子接手一个环境监测小项目,主控是 STM32F103C8T6。传感器采集的数据要经过 USART3 持续往外发,上位机还要通过同一路串口下发参数。最初图省事,收发都用传统中断方式,结果 115200 波特率下数据一密,CPU 被频繁打断,ADC 采样和 LED 状态刷新明显卡顿。后来把收发任务全部切到 DMA,这个问题才算彻底解决。

这篇内容主要讲 STM32F103 在标准库 v3.5 环境下的 USART3 DMA 收发实现,重点是 DMA 通道怎么选、发送怎么判断完成、接收怎么做不定长数据处理。如果你正在做串口通信、Modbus RTU 从站、数据采集上报,或者只是被 DMA 的通道映射搞晕了,这篇应该能帮你少走点弯路。

1. 为什么USART3收发要交给DMA

1.1 传统中断收发在高负载下的瓶颈

以前做串口,最常见的方式是 USART 开 RXNE 中断,来一个字节进一次中断,把数据从USART3->DR读走。这个方法在小数据量下没问题,但一旦波特率拉高、数据帧变长,问题就很明显。

以 115200 波特率为例,一个字节大约要 86.8us 传完,按 F103 主频 72MHz 算,相当于 6000 多个主频周期。看着不少,但中断进入、压栈、读 DR、判断标志、退出中断这一套下来,几十个周期就没了。如果一帧是几十上百个字节,每帧都要打断主循环几十次,主循环里的显示刷新、按键扫描、逻辑处理全都会被拖慢。

我当时的现象是:数据一频密,用示波器看 GPIO 翻转波形,主循环周期从几百微秒被拉长到几毫秒,ADC 连续采样也出现明显波动。这就是典型的“中断风暴”。

1.2 引入DMA后到底解决了什么

DMA 的全称是 Direct Memory Access,本质是一个“数据搬运工”。只要配置好源地址、目的地址、搬运长度,DMA 控制器就自动把数据从串口数据寄存器搬到内存数组,或者反过来,整个过程中 CPU 完全不参与。搬完了,DMA 可以产生一个完成中断,或者在配置成循环模式时一直干活。

打个比方,中断方式是你每次收快递都要亲自下楼取;DMA 方式是雇一个管家在楼下等,攒满一车再告诉你“货齐了”。对应到串口接收,需求从“每个字节都要管”变成“一帧结束再管”,CPU 的负担自然大幅降下来。把串口 DMA 和 USART 的空闲中断(IDLE)搭配使用,还能实现很优雅的不定长接收,这在 Modbus RTU、私有协议帧解析里非常实用。

2. USART3的DMA通道映射与硬件准备

2.1 DMA通道映射表,改代码前先看清楚

F103 的 DMA1 一共有 7 个通道,每个通道关联的外设请求不是随便配的。USART3 的发送和接收分别固定在 DMA1_Channel2 和 DMA1_Channel3 上,这一点很关键,也是最容易踩坑的地方。

外设请求DMA1通道典型用途
USART3_TXDMA1_Channel2串口发送
USART3_RXDMA1_Channel3串口接收
USART2_TXDMA1_Channel7其他串口
USART2_RXDMA1_Channel6其他串口

看这张表要特别小心:USART2 的 TX 在通道 7、RX 在通道 6,USART3 则是 TX 在通道 2、RX 在通道 3,它们的通道关系刚好是反的。平时看别人例程是 USART1 的 DMA 还有 USART2、USART3 的,复制过来一定要把通道号同步改掉。最稳的办法是打开 STM32F103 中文参考手册里的 DMA 请求映射表,对着自己的外设逐个确认。

2.2 引脚接线与最小系统实验环境

USART3 的默认复用引脚是 PB10(TX)和 PB11(RX)。如果这两个引脚被其他外设占用,可以重映射到 PD8/PD9,但要留意重映射后会不会和 FSMC 等外设冲突。顺便说一句,经常有人问 PA8 能不能当 USART3 的发送脚用,答案是不能,PA8 一般是 TIM1_CH1 或 MCO 时钟输出,跟 USART3 没关系。

我在调试时用的是 STM32F103 最小系统板加一个 USB 转 TTL 模块,接线非常标准:

  • 模块 TXD -> MCU PB11(USART3_RX)
  • 模块 RXD -> MCU PB10(USART3_TX)
  • GND 与 MCU GND 共地

注意:USB 转 TTL 模块的 TXD 接 MCU 的 RX,RXD 接 MCU 的 TX,别接反。GND 不共地的话,串口数据要么全是乱码,要么根本没有反应。

3. USART3 DMA初始化:代码一步步拆解

3.1 时钟、GPIO与串口初始化

先贴基础初始化代码,这部分没什么花活,但时钟总线千万别漏:

GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOB, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART3, &USART_InitStructure);

USART3 挂在 APB1 总线上,APB1 最高 36MHz,所以它的时钟源是RCC_APB1Periph_USART3,和挂在 APB2 上的 USART1 不一样。GPIOB 的时钟在 APB2,DMA1 的时钟在 AHB,三类时钟必须各自打开。很多人复制例程后忘了开 AHB 的 DMA 时钟,DMA 寄存器写进去根本没反应。

3.2 发送DMA通道初始化参数解析

发送缓冲区先定义好,然后初始化 DMA1_Channel2:

#define TX_BUF_SIZE 256 uint8_t TxBuffer[TX_BUF_SIZE]; DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel2); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&USART3->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)TxBuffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 0; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel2, &DMA_InitStructure);

几个关键点拆开讲:

  • DMA_DIR_PeripheralDST表示数据从内存写到外设,也就是发送方向。
  • DMA_Mode_Normal很关键,发送时必须用 Normal。如果误配成 Circular,DMA 搬完指定长度后不会停,而是又从缓冲区开头继续搬,同一段数据会被反复发送,往下位机灌数据灌到怀疑人生。
  • 外设地址必须是&USART3->DR,而且 PeripheralInc 要 Disable,因为串口数据寄存器只有一个,不能自动递增。
  • 内存地址要递增,不然每一字节都写到缓冲区同一个位置,MemoryInc 必须是 Enable。

3.3 接收DMA通道初始化与空闲中断使能

接收通道是 DMA1_Channel3,方向和发送反过来了:

#define RX_BUF_SIZE 256 uint8_t RxBuffer[RX_BUF_SIZE]; DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&USART3->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)RxBuffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = RX_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_VeryHigh; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel3, &DMA_InitStructure); USART_DMACmd(USART3, USART_DMAReq_RX, ENABLE); DMA_Cmd(DMA1_Channel3, ENABLE);

接收方向必须用 Circular 循环模式。只有这样,DMA 才会自动把串口收到的数据一路写进 RxBuffer,写满 256 字节后绕回缓冲区头部继续覆盖,CPU 全程不用管。优先级方面,我把接收通道设为 VeryHigh,发送通道设为 High。F103 的 USART 没有硬件 FIFO,数据寄存器如果没及时读走就可能溢出,接收优先级高一点更稳妥。

串口和 DMA 都配好之后,开启 USART3 的空闲中断。IDLE 中断会在串口线路空闲时触发一次,是我们判断“一帧数据收完”的关键:

USART_ITConfig(USART3, USART_IT_IDLE, ENABLE); NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = USART3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);

4. 发送端落地:发送函数与完成判断

4.1 一个不会卡死的DMA发送函数

先看实际调用的发送函数:

void USART3_DMA_Send(uint8_t *buf, uint16_t len) { while(DMA_GetCurrDataCounter(DMA1_Channel2) > 0); // 等待上一次发送完成 DMA_Cmd(DMA1_Channel2, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel2, len); DMA_Cmd(DMA1_Channel2, ENABLE); }

核心逻辑是先等上一次传输结束,再重新设置要发送的长度,最后使能 DMA 通道开始搬运。

这里说一下为什么用DMA_GetCurrDataCounter而不是用DMA_GetFlagStatus(DMA1_FLAG_TC2)。DMA 传输完成标志 TCIF2 在复位后默认是 0,如果第一次调用发送函数就用 while 等 TC 标志,程序会直接卡死在等待循环里。而DMA_GetCurrDataCounter读的是 NDTR 寄存器,代表“当前还剩多少字节没搬”,没发过时 NDTR 为 0,不会被卡住。

如果你想用完成中断方式,可以在DMA1_Channel2_IRQHandler里清DMA1_IT_TC2,再用一个标志位告诉业务层“上次发送结束了”。但最简单的同步发送场景,用上面的函数就够了。

4.2 DMA的TC标志不等于串口发送完成

这里有一个容易被忽略的细节:DMA 传输完成标志置位,只代表 DMA 已经把数据搬到了USART3->DR,并不代表最后一个字节已经从串口 TX 引脚上完整发出去了。USART 内部还有移位寄存器,串行移位需要时间。

如果业务紧接着关闭串口、切换 GPIO 复用或者进入低功耗,最后这个字节很可能就丢了。对这种要求严格的场景,建议在 DMA 完成后补一个等待:

while(USART_GetFlagStatus(USART3, USART_FLAG_TC) == RESET);

这个细节在做 433MHz 透传、LoRa 等需要切换射频收发状态的项目里特别重要。我遇到过一帧数据总是少最后几个字节的情况,排查到最后就是数据还没完全发完,发送通路就被切掉了。

4.3 大缓冲区分批发送

NDTR 是 16 位寄存器,单次 DMA 传输最大长度是 65535 字节。实际串口一帧很少超过这个值,但如果你要发送的是几百字节的日志或升级固件数据,还是要保证 len 不超过缓冲区大小和 NDTR 范围。

更大的问题是,如果数据量超过缓冲区长度,需要分批发送。每次调用发送函数前,主逻辑要确认上一批已经发完,再填充新数据。我一直建议在同一个线程里做发送请求,避免一个中断里调用发送函数、主循环也在发送,两个地方同时操作 DMA 导致数据错乱。

5. 接收不定长数据的核心:空闲中断 + DMA

5.1 IDLE中断原理与清标志的“先读后读”

USART 的空闲中断(IDLE)触发条件是:接收线上收到一个字节后,线路又空闲下来。也就是说,它是在“从忙到闲”的时刻产生的,天然适合判断一帧数据结束。对不定长帧来说,不需要提前知道帧有多长,只需要等线路空闲,就知道这帧收完了。

IDLE 标志的清除方式非常讲究,必须“先读 SR,再读 DR”:

(void)USART3->SR; (void)USART3->DR;

很多新手在中断里只判断USART_GetITStatus,却没有正确清标志,结果一次空闲进来后再也出不去,或者下一次空闲不触发。标准库的USART_ReceiveData(USART3)内部本质也是读 DR,但为了明确这个顺序,我习惯直接操作寄存器。

5.2 一个完整的中断服务函数

下面是我实际项目里在用的接收处理逻辑:

void USART3_IRQHandler(void) { if(USART_GetITStatus(USART3, USART_IT_IDLE) != RESET) { (void)USART3->SR; (void)USART3->DR; // 清IDLE标志 uint16_t remain = DMA_GetCurrDataCounter(DMA1_Channel3); uint16_t len = RX_BUF_SIZE - remain; if(len > 0) { ProcessFrame(RxBuffer, len); } } }

当 IDLE 中断触发时,DMA 已经把数据写进了 RxBuffer,DMA_GetCurrDataCounter返回的是剩下的空间,用缓冲区总大小减去剩余空间,就是一帧数据的长度。整个过程只有一次减法和一次协议处理,效率很高。

这个方案对 Modbus RTU 特别合适。Modbus RTU 本身就是靠帧间隔来判断一帧结束,协议栈里通常用定时器实现 3.5 个字符时间的超时判断。使用 DMA 接收后,IDLE 中断把数据交给ProcessFrame,再由 Modbus 协议栈去校验地址、CRC、功能码,主循环只需要周期调用协议处理函数,配合起来非常稳。

5.3 循环模式下数据跨边界的处理

DMA 循环接收是持续往 RxBuffer 里写数据,写满后自动绕回头部继续写。假如缓冲区是 RxBuffer[0] 到 RxBuffer[255],数据写到中间某处时一帧还没结束,继续写下去就会从缓冲区头部开始覆盖。这种情况下,len = RX_BUF_SIZE - remain虽然能算出总字节数,但数据并不在一段连续内存里,直接交给协议栈解析很可能出错。

解决思路有两个:

  1. 缓冲区大小设成最大帧长的两倍以上,让一帧数据跨边界的概率大大降低。
  2. 在 IDLE 中断里先把数据搬到一个连续的应用缓冲区,再做协议解析。搬运时可以记录上次的剩余计数器位置,判断数据的写入起始位置。

我个人的习惯是:对 Modbus RTU 这类场景,RX_BUF_SIZE 设 256 字节,协议最大帧也就几十字节,跨边界概率很低;接收中断里再把 RxBuffer 数据拷贝到协议栈收包缓冲区,由 FreeModbus 或自研协议栈自行判断帧间隔。代码简单,稳定性也好。

6. 常见问题与调试经验

6.1 接收不到数据或长度恒为0

碰到这种情况,先用串口助手随便发几个字节,再看DMA_GetCurrDataCounter(DMA1_Channel3)有没有变化。如果计数器纹丝不动,那基本可以锁定问题:

  • DMA 通道配置错了,比如把 USART3_RX 写到了 DMA1_Channel2;
  • 忘记调用USART_DMACmd(USART3, USART_DMAReq_RX, ENABLE)
  • USART3 的 NVIC 中断没使能,IDLE 中断永远不会进入。

我调试时习惯在 IDLE 中断里加一个 GPIO 翻转,用示波器看触发是否正常。如果 GPIO 没翻,说明中断都没进来,问题在配置;如果 GPIO 翻了但数据不对,再往下查 DMA 的通道和缓冲区。

6.2 连续发送时数据错乱或只发一次

这个问题的答案,就是热词里有人问的“DMA串口发送需要等待上一轮数据发送完吗”——需要。

Normal 模式下,DMA 搬完指定字节后,通道的 EN 位其实还开着,但 NDTR 已经变成 0。如果下次调用发送函数时不等待上一次结束,重新设置 NDTR 就可能覆盖当前正在进行的传输,导致字节数不对、数据乱掉。这也是很多人写串口 DMA 发送时“只发一次正常,连续发就乱”的典型原因。

推荐的发送函数写法我已经在上文给出,核心就是先通过 NDTR 判断上一次是否结束,再关闭 DMA、重设长度、重新使能。

6.3 最后几个字节偶尔丢失

第一个怀疑点,就是我前面说的只等了 DMA 的 TC 标志,没等 USART 的 TC 标志。DMA 把最后一个字节搬进 DR 后,串口移位寄存器还需要时间把数据移出去,此时立刻关串口或者改 GPIO,尾字节就没了。要等USART_FLAG_TC

第二个怀疑点,是接收缓冲区和发送缓冲区指向了同一块内存,DMA 发送还没完成,接收 DMA 又把新数据写进来了,两边互相覆盖。把收发缓冲区分开定义,问题通常就消失了。

第三个怀疑点,是 USART3 的中断服务函数和启动文件里的向量表对不上。函数名写错的话,编译不会报错,但中断永远进不来。这时候可以打开编译生成的 map 文件,搜索USART3_IRQHandler确认符号是不是真的存在,如果发现没有,大概率就是拼写和启动文件不一致。

6.4 与其他外设DMA协同的优先级策略

F103 的 DMA1 只有 7 个通道,项目里经常要同时给 ADC、USART、定时器分配 DMA。通道本身是按请求源绑定的,不会直接冲突,但 DMA 总线带宽是共享的。如果 ADC 采样率高、串口波特率也高,收发频繁,建议把串口接收通道的优先级尽量设高,避免因为总线仲裁导致 USART 数据寄存器来不及读走而触发 overrun。USART 没有硬件 FIFO,数据寄存器读慢了就会丢字节。

用这套方案调完之后,我的一个明显感受是:CPU 占用大幅下降,Modbus RTU 从站轮询变得很稳定,主循环里的显示刷新和按键响应也恢复到了正常水平。后面我还试过把串口 DMA 发送的缓冲区和 WS2812B 灯带数据结合,利用 DMA 连续发送颜色值,整体效果也不错。只要通道映射、Normal/Circular 模式选择、IDLE 标志清除这几处基本概念不出错,USART3 + DMA 这套组合在 F103 上跑起来是很省心的。

本文还有配套的精品资源,点击获取

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

ha_xiaomi_home 米家集成指南:10 分钟把小米设备搬进 Home Assistant

ha_xiaomi_home 米家集成指南:10 分钟把小米设备搬进 Home Assistant 【免费下载链接】ha_xiaomi_home Xiaomi Home Integration for Home Assistant 项目地址: https://gitcode.com/GitHub_Trending/ha/ha_xiaomi_home 你手头有小米的空气净化器、智能灯、扫…

作者头像 李华
网站建设 2026/9/9 21:20:06

Python学生成绩管理系统:入门作业的函数与数据结构实战

1. 作业内容与题目拆解1.1 这道题到底在考什么第三次Python作业,在很多入门课程里是一道分水岭。前两次作业大家还在和print、变量、if/else较劲,到了第三次,就突然要求你"组织代码"了——函数、列表、字典、字符串处理&#xff0c…

作者头像 李华
网站建设 2026/9/9 21:18:59

量子科技工程化加速:年会聚焦后量子加密与计算资源池落地

作为一名长期关注量子科技赛道的从业者,今年是我第二次参加量子年会。相比去年,第二届2026量子年会暨Q10颁奖典礼的现场气氛明显不一样了——如果说上一届大家还在聊概念、讲愿景,这届年会上几乎每个分论坛都在讨论“怎么落地”“怎么集成”“…

作者头像 李华
网站建设 2026/9/9 21:17:45

软考高项成本管理避坑指南:挣值分析、储备与论文策略

准备了三个多月的高项,综合知识、案例分析、论文三科一路考下来,最后发现最让我心情复杂的不是范围管理,也不是进度网络图,而是成本管理。选择题里关于“应急储备到底算不算成本基准”这种题,我错了不止一次&#xff1…

作者头像 李华
网站建设 2026/9/9 21:17:02

用GTP5.4从零打造飞书编辑器:AI辅助开发实战

1. 从零到一:为什么我会想到用“GTP5.4”写一个飞书编辑器先交代一下背景。我主要负责团队内部的知识库和文档流程管理,飞书是日常协作的主力工具。飞书的文档能力确实强,但真正用久了你会发现,默认编辑器在批量处理、复杂排版、表…

作者头像 李华
网站建设 2026/9/9 21:16:57

用Python和Pygame实现六边形地图生成器:从坐标系统到地形生成

简介:一套基于Python的六角形世界地图生成器源码,面向游戏开发者、地图程序爱好者与Python学习者,可用于快速生成随机行星地表、构建岛屿或大陆轮廓。它通过参数调节可生成任意类型的随机行星表面,并能将六边形网格划为自定义领土…

作者头像 李华