ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

STM32 USART3 DMA收发详解:通道配置、发送判断与不定长接收 简介面向嵌入式开发者的STM32F103标准库工程专注于利用DMA方式实现USART3的接收与发送解决传统串口中断频繁占用CPU的问题适用于工业通信、数据采集等实时性较高的场景。压缩包共171个文件、约3.41MB包含C源码、H头文件、S启动文件等核心代码以及uvproj/uvopt工程文件、o/axf/hex/map等编译链接产物另有htm/doc/txt格式的说明文档与批处理工具便于直接打开工程对照学习。目前已有3295人学习下载作为入门DMA串口通信的参考资料具有一定热度。资源提供完整的USART3DMA初始化、传输配置、中断处理与软件流程示例可帮助开发者快速理解DMA通道选择、传输方向设置、循环传输模式等关键细节并迁移到实际项目中的多串口或高波特率数据收发场景。 前阵子接手一个环境监测小项目主控是 STM32F103C8T6。传感器采集的数据要经过 USART3 持续往外发上位机还要通过同一路串口下发参数。最初图省事收发都用传统中断方式结果 115200 波特率下数据一密CPU 被频繁打断ADC 采样和 LED 状态刷新明显卡顿。后来把收发任务全部切到 DMA这个问题才算彻底解决。这篇内容主要讲 STM32F103 在标准库 v3.5 环境下的 USART3 DMA 收发实现重点是 DMA 通道怎么选、发送怎么判断完成、接收怎么做不定长数据处理。如果你正在做串口通信、Modbus RTU 从站、数据采集上报或者只是被 DMA 的通道映射搞晕了这篇应该能帮你少走点弯路。1. 为什么USART3收发要交给DMA1.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 在通道 6USART3 则是 TX 在通道 2、RX 在通道 3它们的通道关系刚好是反的。平时看别人例程是 USART1 的 DMA 还有 USART2、USART3 的复制过来一定要把通道号同步改掉。最稳的办法是打开 STM32F103 中文参考手册里的 DMA 请求映射表对着自己的外设逐个确认。2.2 引脚接线与最小系统实验环境USART3 的默认复用引脚是 PB10TX和 PB11RX。如果这两个引脚被其他外设占用可以重映射到 PD8/PD9但要留意重映射后会不会和 FSMC 等外设冲突。顺便说一句经常有人问 PA8 能不能当 USART3 的发送脚用答案是不能PA8 一般是 TIM1_CH1 或 MCO 时钟输出跟 USART3 没关系。我在调试时用的是 STM32F103 最小系统板加一个 USB 转 TTL 模块接线非常标准模块 TXD - MCU PB11USART3_RX模块 RXD - MCU PB10USART3_TXGND 与 MCU GND 共地注意USB 转 TTL 模块的 TXD 接 MCU 的 RXRXD 接 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 的时钟在 APB2DMA1 的时钟在 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。如果误配成 CircularDMA 搬完指定长度后不会停而是又从缓冲区开头继续搬同一段数据会被反复发送往下位机灌数据灌到怀疑人生。外设地址必须是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. 接收不定长数据的核心空闲中断 DMA5.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 已经把数据写进了 RxBufferDMA_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虽然能算出总字节数但数据并不在一段连续内存里直接交给协议栈解析很可能出错。解决思路有两个缓冲区大小设成最大帧长的两倍以上让一帧数据跨边界的概率大大降低。在 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 上跑起来是很省心的。本文还有配套的精品资源点击获取
返回列表