拓冰建站拓冰建站
首页 / 资讯中心 / 正文

STM32F103 USART3 DMA收发实战:循环接收与空闲中断处理

简介面向STM32F103嵌入式开发者的实战资源包专注解决如何基于标准库利用DMA实现USART3高效收发的问题内容覆盖DMA通道选择、传输方向配置、USART3串口参数初始化及中断处理等核心环节能帮助开发者减少CPU干预、提升通信实时性适用于工业控制、数据采集等需要稳定串口通信的场景。压缩包大小3.41MB共171个文件以C/H源文件、汇编文件、工程配置与编译输出文件为主同时包含说明文档和调试辅助文件结构完整便于对照学习与二次移植已有3295人学习说明该资源具备一定参考价值。通过这份资料读者可获得可直接参考的示例程序、DMA与USART3初始化配置流程、中断服务程序设计思路以及标准库工程搭建经验无论是入门STM32的DMA应用还是优化现有串口收发效率都能从中找到实用参考缩短项目开发与调试周期。 很多朋友第一次在STM32F103上做USART3通信第一反应就是开RXNE中断进来一个字节处理一个字节。这个做法本身没问题网上大把例程也是这么写的。但真到了波特率拉高、数据量变大的场景你会发现主循环的时间被中断吃掉大半。之前我调试一块传感器采集板就遇到这个情况——115200波特率下传感器每10ms上报一帧中断频率已经到一万多次每秒主程序里一个简单的数据搬移都被拆得七零八落最后还开始丢帧。后来把USART3的接收和发送全部交给DMA搬运CPU只负责在空闲中断里取数据、在发送完成后再动缓冲区问题才算彻底解决。这篇文章不打算重复那种“打开CubeMX点两下生成代码”的流程而是把USART3加DMA这套方案的硬件通道关系、接收侧不定长数据的处理思路、发送侧完成时机的控制以及几个实际踩过的坑完整串一遍。适合已经跑通标准库、想进一步提升串口收发效率的人参考。1. 为什么USART3收发值得用DMA接管1.1 中断方式下串口是CPU的“隐形杀手”先算一笔账。115200波特率下一个字节帧1起始位加8数据位加1停止位大约是87微秒。如果每个字节都触发一次RXNE中断中断频率就是11.5kHz。每一次中断CPU要压栈、跳转、读DR、处理数据、清标志、出栈算下来少说5到10微秒。也就是说光是接收中断本身就占了CPU约10%的算力。如果还叠加发送中断、定时器中断、ADC中断、看门狗刷新主循环还能剩多少时间做正事低波特率比如9600中断频率降下来了但每字节的时长拉长到1ms左右。如果传感器一帧数据有几百字节接收中断依然密集。更麻烦的是中断服务函数里不适合做复杂解析而数据解析又是串口应用绕不开的环节最终还要回到主循环做数据就得在RAM里反复搬来搬去。这个开销在低负载时无所谓一旦系统实时性要求上来就非常难受。1.2 DMA搬运数据CPU只做协议处理DMA的本质就是一个硬件搬运工。按照你配置的源地址、目的地址、数据宽度、传输数量自动把数据从一个地方搬到另一个地方。对USART3来说接收方向DMA负责把USART3-DR里的数据搬到内存缓冲区发送方向DMA负责把内存缓冲区的数据搬到USART3-DR。整个搬运过程不占CPU指令周期。CPU需要做的只有两件事一是数据到达一定条件时去处理一次比如接收空闲中断触发说明一包数据可能收完了二是发送前把数据准备好、发送完成后再决定缓冲区能不能复用。这样就把“搬运数据”和“处理数据”这两件事解耦了主循环再也不会被高频中断撕碎。1.3 USART3和DMA1的通道绑定关系这一步先记牢USART3挂在APB1总线上在F103里只能使用DMA1而且通道是固定的USART3_TX对应DMA1的Channel2USART3_RX对应DMA1的Channel3。这个映射关系写死在硬件里不是你想用哪条通道就用哪条。很多人一开始就栽在这里把收发通道配反折腾半天收不到数据。还有两个容易混的点。一个是DMA1的时钟来自AHB使能DMA外设时钟要用RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE)不是APB1也不是APB2。另一个是引脚默认情况下USART3的TX/RX是PB10/PB11如果这两个脚被其他功能占用可以用AFIO重映射到PC10/PC11或者PD8/PD9但重映射后DMA通道关系不变变的只是引脚位置。2. 接收侧循环DMA配合空闲中断接收不定长数据2.1 定长接收和不定长接收模式选择完全不同先说结论如果协议帧长度固定比如每帧就是8字节那DMA用Normal模式缓冲区大小设成8开DMA传输完成中断就够了。如果帧长度不固定比如串口屏指令、Modbus RTU请求那需要的是循环模式加串口空闲中断。循环模式的意思是DMA把缓冲区收满后不会停而是自动回到缓冲区头继续写类似一个环形队列。这样无论对端什么时候发数据、发多少DMA都在后台接着不会因为一帧数据超过缓冲区长度就崩掉。空闲中断的作用是串口在收到数据后如果接收线上持续一个字符时间没有新数据就产生一次IDLE中断这时候大概率是“一帧发完了”可以取数据了。2.2 循环DMA接收的初始化配置下面这段是标准库V3.5环境下的初始化核心代码变量名尽量贴着工程习惯写。#define UART3_RX_BUF_SIZE 256 uint8_t uart3_rx_buf[UART3_RX_BUF_SIZE]; volatile uint16_t uart3_rx_last_pos 0; void USART3_DMA_RX_Init(uint32_t baud) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; DMA_InitTypeDef DMA_InitStructure; NVIC_InitTypeDef NVIC_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_IPU; GPIO_Init(GPIOB, GPIO_InitStructure); USART_InitStructure.USART_BaudRate baud; 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); DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(USART3-DR); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)uart3_rx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize UART3_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_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel3, DMA_InitStructure); DMA_Cmd(DMA1_Channel3, ENABLE); USART_DMACmd(USART3, USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART3, USART_IT_IDLE, ENABLE); 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); USART_Cmd(USART3, ENABLE); }这里有一个顺序问题值得强调接收方向要先DMA_Cmd使能DMA通道再USART_DMACmd打开USART3的接收DMA请求这样从第一字节开始就能稳定接收。如果两个语句顺序反了或者中间夹着USART_Cmd使能串口的操作偶尔会出现从某个字节开始错位的情况排查起来很费劲。RX引脚我用了上拉输入而不是浮空输入是因为对端设备如果未上电、线路悬空时浮空输入容易收到噪声把缓冲区填满上拉后空闲电平更稳定。2.3 空闲中断里怎么算数据长度和位置配置完DMA还不够IDLE中断的处理才是整个接收方案的核心。中断里要做的事情就三件清标志、算这次新增了多少数据、把数据交出去。void USART3_IRQHandler(void) { if (USART_GetITStatus(USART3, USART_IT_IDLE) ! RESET) { // 清IDLE标志先读SR再读DR这是硬件要求的清除序列 (void)USART3-SR; (void)USART3-DR; uint16_t remain DMA_GetCurrDataCounter(DMA1_Channel3); uint16_t head UART3_RX_BUF_SIZE - remain; uint16_t len (head UART3_RX_BUF_SIZE - uart3_rx_last_pos) % UART3_RX_BUF_SIZE; if (len 0) { if (uart3_rx_last_pos len UART3_RX_BUF_SIZE) { // 数据连续一次就能取完 uart3_rx_frame_cb(uart3_rx_buf[uart3_rx_last_pos], len); } else { // 数据跨过缓冲区末尾分成两段取 uint16_t first_len UART3_RX_BUF_SIZE - uart3_rx_last_pos; uart3_rx_frame_cb(uart3_rx_buf[uart3_rx_last_pos], first_len); uart3_rx_frame_cb(uart3_rx_buf[0], len - first_len); } } uart3_rx_last_pos head; } }为什么要算得这么细因为循环模式下缓冲区就是环形队列DMA写指针不断向前走走到末尾就回绕。假设缓冲区256字节上次数据取到200的位置这次DMA写到了50的位置说明新数据在200到255和0到50两段里。如果只简单地从0开始取len个字节跨过边界的数据顺序就是错的。这个两段式取法在第一版代码里没考虑结果帧头在尾部、数据在头部的情况全乱套后来花了不少时间才定位到是环形回绕的问题。还有一个细节空闲中断只说明串口线上出现了一个字符时间的空闲不代表这些数据一定是完整的一帧。如果对端连续发两个帧两帧之间间隔小于一个字符时间IDLE就不会拆分它们粘包现象就是这么来的。处理粘包是协议层的事比如Modbus RTU天然符合“3.5个字符时间为一帧间隔”的定义IDLE中断用来切帧非常合适如果是自定义协议建议在回调里按帧头、帧长再做一次解析。3. 发送侧DMA发送的工程化写法和完成时机控制3.1 发送缓冲区先规划好别用栈上临时数组发送方向一个很常见的翻车点是用了一个局部数组当发送缓冲区void test_send(void) { uint8_t buf[64]; // 填充buf... USART3_DMA_Send(buf, 64); // 函数返回后buf就被回收了 }此时DMA可能还没搬完甚至刚开始搬函数一返回栈上的buf随时会被后面的函数调用覆盖。结果就是串口发出去一堆乱码或者残缺数据。这个问题的标准解法是准备一个全局静态发送缓冲区调用者先把数据放进去确认DMA搬完了再复用这块区域。#define UART3_TX_BUF_SIZE 128 uint8_t uart3_tx_buf[UART3_TX_BUF_SIZE];如果调用方传入的数据本身就是一个全局数组或者static数组也可以让DMA直接指向那个地址省一次拷贝。但代价是调用方必须自己保证“发送完成前不修改这片数据”。二选一工程里我更喜欢拷贝一次安全省心尤其面对中断里也可能触发发送的场景固定缓冲区配合忙标志不容易出并发问题。3.2 DMA发送函数从配置到启动的完整代码发送通道用DMA1_Channel2模式是Normal意思是搬完指定数量的字节就停下来。对应的USART3_TX请求也要通过USART_DMACmd打开。volatile uint8_t uart3_tx_busy 0; uint8_t USART3_DMA_Send(const uint8_t *data, uint16_t len) { DMA_InitTypeDef DMA_InitStructure; if (len 0 || len UART3_TX_BUF_SIZE) { return 0; } if (uart3_tx_busy) { return 0; // 上一次发送还没完成直接返回失败 } memcpy(uart3_tx_buf, data, len); DMA_Cmd(DMA1_Channel2, DISABLE); DMA_DeInit(DMA1_Channel2); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)(USART3-DR); DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)uart3_tx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize len; 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_Medium; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel2, DMA_InitStructure); DMA_Cmd(DMA1_Channel2, ENABLE); USART_DMACmd(USART3, USART_DMAReq_Tx, ENABLE); uart3_tx_busy 1; return 1; }和接收侧一样发送方向也推荐“先DMA_Cmd使能通道再打开USART的发送DMA请求”。如果顺序反过来请求信号已经来了但DMA通道还没使能这个请求可能被丢弃第一字节就发不出去。这个发送函数是个非阻塞版本发送不成功会直接返回0。如果调用方需要确保数据一定发出可以在外部根据返回值决定是否需要重试。注意uart3_tx_busy的清除时机是整个发送方案的关键放在下一节细说。3.3 DMA完成和真正发完是两回事这是发送侧最容易被忽略的细节。DMA把最后一个字节写进USART3-DR后它的任务就结束了DMA1_FLAG_TC2会置位。但此时USART的移位寄存器里可能还停着这个字节没发完波特率越低这个残留窗口越长。如果依赖DMA的完成标志去清uart3_tx_busy紧接着又调用一次USART3_DMA_Send新的数据会覆盖uart3_tx_buf而上一轮最后一个字节还没真正离开引脚结果是偶发性的丢最后一个字节。更稳的做法是让USART3的TC中断来通知“物理发送完成”。TC标志由USART自己置位它表示数据已经从移位寄存器全部送出。初始化时先清一次TC标志再使能TC中断否则TC的初始值是1一开中断就会立刻进一次伪中断。// 初始化时先清TC标志再使能TC中断 USART_ClearITPendingBit(USART3, USART_IT_TC); USART_ITConfig(USART3, USART_IT_TC, ENABLE);中断处理里加上if (USART_GetITStatus(USART3, USART_IT_TC) ! RESET) { USART_ClearITPendingBit(USART3, USART_IT_TC); uart3_tx_busy 0; }这样uart3_tx_busy清0就意味着数据真的发出去了缓冲区可以放心复用。代价是TC中断会比DMA完成稍晚一两个字节的时间低速下也就是几毫秒对绝大多数场景完全可以接受。如果你的协议对发送时序极其敏感那就要评估这个延迟是否可容忍。4. 踩坑实录串口DMA收发的完整排查链路4.1 一字节都收不到从链路底层逐级往上查这类问题排错有个固定套路先确认引脚和时钟再确认DMA通道再确认USART的DMA请求和中断。见过不少代码初始化顺序写得花里胡哨最后发现寄存器里DMA的EN位根本没置上。现象可能原因排查方向完全收不到DMA1时钟未开RCC_AHBPeriphClockCmd有没有调用完全收不到通道号用错USART3_RX必须是DMA1_Channel3完全收不到USART侧DMA请求未打开确认USART_DMACmd(Rx, ENABLE)第一字节丢失先开了USART请求再开DMA通道调整顺序为先DMA_Cmd再USART_DMACmd收到乱码引脚复用配置错误TX必须为复用推挽RX为上拉或浮空输入GPIOB的时钟和引脚模式也容易漏。RCC_APB2PeriphClockCmd里忘记开RCC_APB2Periph_GPIOB后面所有GPIO配置都是白搭。如果用重映射到PD8/PD9或PC10/PC11还需要开AFIO时钟并调用GPIO_PinRemapConfig。4.2 收着收着开始乱码或丢字节多半是优先级或覆盖数据能收但乱码、丢字节问题往往出在DMA总线仲裁或缓冲区覆盖上。DMA1有多个通道每个通道的优先级通过DMA_Priority配置。如果把USART3_RX的通道优先级设成Low而同一个DMA1里还有一个一直很忙的通道在跑总线上可能出现USART3的DMA请求得不到及时响应的情况。F103的串口DR寄存器只有1字节深数据到了DR如果不能立刻被DMA搬走DRE请求得不到响应数据就丢了。解决办法是给串口接收通道一个相对高的优先级。缓冲区覆盖是另一个常见成因。循环模式下DMA写指针不断回绕如果应用层处理数据的速度慢于接收速度新数据就会把旧数据覆盖。IDLE中断里取到的len不会告诉你上一帧被覆盖了一部分它只告诉你现在的缓冲区里是什么。想发现这个问题可以在IDLE中断里对len的值做个统计如果连续出现len接近缓冲区大小的场景说明数据处理速度跟不上要么加大缓冲区要么改成半传输中断双缓冲方案要么降低对端发送频率。4.3 发送吞最后一个字节问题出在完成标志用错了这个现象看起来很奇怪数据基本都能发出去但最后一位经常消失或者最后一个字节被替换成0x00或0xFF。大概率就是前面说的DMA TC和USART TC的时机差异。排查方法也很简单在发送完成位置观察busy标志和TC标志的电平或者用示波器对比DR写入和TXD引脚实际输出的时间点一眼就能看出来。早期为了方便在一个项目里用DMA的TC中断去清busy标志结果9600波特率跑协议的时候每隔一段时间就会出现最后一位丢失排查了很久才想到是这个问题。改成USART的TC中断后再没出现过。5. 几种典型串口DMA方案怎么选边界在哪里5.1 定长帧Normal模式加传输完成中断最简单如果协议帧长固定比如每帧8字节、16字节、32字节接收方向不需要循环模式也不需要IDLE中断。DMA配成Normal模式缓冲区大小等于帧长开DMA1_Channel3的传输完成中断在中断里把数据拷贝走然后重新装载DMA计数器再使能就能稳定收帧。这个方案代码最少、逻辑最直观适合帧长固定、且DMA缓冲区一帧以内就能存放的场景。缺点也很明显帧长一变代码就得跟着改。5.2 不定长帧循环DMA加IDLE覆盖大多数场景这是本文主推的方案。循环模式加DMA1_Channel3配合USART3的IDLE中断帧长可以任意变化只要总吞吐量在系统能力范围内。它最大的优点是CPU开销极低中断只在帧间隔出现时触发一次而不是每字节触发。Modbus RTU这类协议尤其适合因为3.5字符时间的帧间隔定义和串口的IDLE检测天然吻合。缺点也有粘包只能靠协议层拆如果上层协议没有明显的帧头或长度字段这个方案会很难受。另外IDLE中断只在静默一段时间后触发如果对端一直以高速连续吐数据IDLE不会频繁触发数据会持续堆积在缓冲区需要额外机制处理。5.3 高速率大数据半传输中断加IDLE极限吞吐如果对端发送速度很快、单帧数据量又大光靠IDLE中断一次取出所有数据可能出现取数据时缓冲区已经被覆盖的情况。这时候可以再加一个DMA半传输中断缓冲区前半部分填满时触发一次中断应用层处理前半部分DMA继续填后半部分填满后回到头部之前又会触发半传输中断。配合IDLE中断相当于把缓冲区拆成两个槽应用层处理和DMA搬运可以流水线并行。代价是代码复杂度上了一个台阶需要处理半满中断、IDLE中断两处对缓冲区的并发访问建议用一个环形队列管理索引。5.4 缓冲区大小估算波特率与处理时间的换算缓冲区大小没有绝对标准但可以算。115200波特率下一个字节约87微秒如果应用层最坏情况需要10ms才能取走数据缓冲区至少要有115字节否则DMA写指针就会追上还没处理的数据。一般工程里接收缓冲区256字节起步如果帧很长再往上加发送缓冲区只要能装下最长一帧即可通常64到256字节够用。注意F103的DMA计数器是16位的单次传输长度不能超过65535字节。写到这里这套USART3加DMA收发的核心链路就全串起来了。我个人实际调试中的体会是DMA配置本身不难真正难的是把“数据什么时候完整、缓冲区什么时候可以复用”这两件事想清楚。接收侧用IDLE中断确定一帧结束发送侧用USART TC中断确定物理发送完毕这两处想明白了DMA串口基本就稳了一半。如果遇到现象怎么都排查不通建议先把缓冲区和DMA的当前计数变量加进调试器的Watch窗口数据一帧一帧地对往往比反复加打印信息高效得多。这套思路在HAL库环境里同样适用只是函数名换成了HAL_UART_Receive_DMA、HAL_UART_Transmit_DMA原理和流程完全一致。最后再分享一个小技巧调试阶段在串口空闲中断里临时加一个计数器统计IDLE触发次数和len的分布能帮你快速判断到底是总线优先级问题、缓冲区覆盖问题还是协议解析的问题比盲猜靠谱得多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门