资讯动态

STM32F103 DMA驱动USART3收发实战:彻底解决串口丢包与CPU占用

发布时间:2026/10/20 8:02:32 来源:尧图企业网站定制
简介这份资源面向嵌入式开发初学者与中级工程师聚焦STM32F103标准库下USART3的DMA收发实现帮助解决串口通信中CPU占用高、实时性不足的问题。压缩包共171个文件约3.41MB以c源文件、h头文件、s汇编启动文件为主另含o目标文件、crf编译中间文件、uvproj与uvopt工程配置、map映射文件及hex固件等构成一套可直接编译的Keil工程模板。资源围绕DMA通道选择、传输方向与数据量配置、USART3时钟与波特率设置、DMA请求使能、传输完成与错误中断处理等关键环节展开并给出初始化、启动传输与中断回调的软件流程参考。已有3312人学习下载适合希望掌握DMAUSART高效收发、减少CPU干预并快速搭建实验工程的开发者对照实践。1. 串口收包总丢字节我用 DMA 把 CPU 彻底解放了前阵子调一块 STM32F103 的板子USART3 接了个外部模块波特率 115200数据帧不定长几十到两百字节来回跳。一开始用标准库的中断接收每来一个字节进一次中断主循环稍微忙一点就开始丢包尤其是同时刷 OLED 和跑定时器的时候丢得那叫一个玄学。后来改成 DMA 接收 空闲中断CPU 只在整帧收完后被叫醒一次丢包问题当场消失。这套方案就是标题里说的STM32F103 用 DMA 驱动 USART3 收发。它适合正在用标准库做串口通信、被丢包和 CPU 占用折磨的嵌入式开发者尤其是做多串口、大数据量透传或者 Modbus 这类不定长协议的朋友。下面我把这套代码从工程结构到寄存器配置完整拆一遍照着改就能跑。2. 方案选型与工程结构为什么是 DMA 空闲中断2.1 三种串口接收方式的取舍在 STM32F103 上收串口数据常见就三条路。第一条是轮询主循环里死等 RXNE 标志代码最简单但 CPU 被绑死稍微干点别的就漏字节基本只能用来调试。第二条是中断每收一个字节触发一次 USART3_IRQHandler115200 波特率下大约每 87 微秒进一次中断如果主循环里有耗时操作或者中断优先级没排好丢包是迟早的事。第三条就是 DMA让 DMA 控制器直接把 USART3-DR 里的数据搬到内存缓冲区CPU 完全不参与搬运只在整帧结束时处理一次。DMA 接收有个绕不开的问题怎么知道一帧收完了固定长度好办配好 DMA 传输数量就行不定长就麻烦了。常见做法是配合串口空闲中断IDLE总线空闲一个字节时间后触发 IDLE 标志这时候去读 DMA 还剩多少个没搬就能算出这一帧收了多少字节。这套组合是 STM32 上处理不定长串口帧最稳的方案之一我一般直接把它当默认模板用。发送这边同理可以用 DMA 把内存缓冲区搬到 USART3-DR避免在主循环里while(!TXE)死等。发送用 DMA 的收益没有接收那么夸张但在发大包或者高频发送时能明显减少主循环卡顿。2.2 工程目录与文件职责这套代码基于 STM32F103 标准外设库StdPeriph Lib不是 HAL。工程结构我按下面这样组织方便你往自己的工程里搬Project/ ├── CMSIS/ 内核与启动文件 ├── FWlib/ │ ├── inc/ 标准库头文件 │ └── src/ 标准库源文件 │ ├── stm32f10x_usart.c │ ├── stm32f10x_dma.c │ ├── stm32f10x_gpio.c │ └── stm32f10x_rcc.c ├── User/ │ ├── main.c 主循环与业务逻辑 │ ├── usart3_dma.c USART3 DMA 驱动实现 │ ├── usart3_dma.h 对外接口与缓冲区声明 │ └── stm32f10x_it.c 中断服务函数 └── Project.uvprojx Keil 工程文件usart3_dma.c是核心负责 GPIO、USART3、DMA 的初始化和收发接口stm32f10x_it.c里放USART3_IRQHandler处理空闲中断main.c只调用接口不碰寄存器。这样分层的好处是换串口或者换芯片型号时只动驱动文件业务代码不用改。2.3 关键资源映射USART3 和 DMA1 通道怎么对应STM32F103 的 DMA 请求映射是固定的选错了通道数据根本不动这是新手最容易翻车的地方。USART3 的收发对应关系如下方向外设请求DMA 控制器通道传输方向接收USART3_RXDMA1Channel 3外设到内存发送USART3_TXDMA1Channel 2内存到外设USART3 的引脚是 PB10TX和 PB11RX复用推挽输出和浮空输入。注意 PB10/PB11 默认是普通 IO必须开 AFIO 和 GPIOB 时钟配成复用功能否则串口没波形。DMA1 的时钟也要单独使能标准库里是RCC_AHBPeriph_DMA1挂在 AHB 总线上别和 APB 的时钟搞混。3. 核心驱动拆解从 GPIO 到 DMA 的完整配置链路3.1 时钟与 GPIO 初始化先把用到的时钟全部打开这一步漏一个后面就是黑匣子现象是串口完全没反应。代码如下/* usart3_dma.c —— 时钟与GPIO初始化 */ static void USART3_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; /* 使能 GPIOB、USART3、AFIO、DMA1 时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_USART3 | RCC_APB2Periph_AFIO, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* PB10 —— USART3_TX复用推挽输出 */ 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); /* PB11 —— USART3_RX浮空输入 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOB, GPIO_InitStructure); }逻辑说明TX 引脚必须配成GPIO_Mode_AF_PP因为串口发送是外设驱动引脚不是普通推挽RX 配成GPIO_Mode_IN_FLOATING让外部信号直接进输入寄存器。参数上GPIO_Speed_50MHz对 115200 甚至 921600 都够用不用纠结。这里最容易踩的坑是忘了开 AFIO 时钟标准库里复用功能重映射相关的寄存器在 AFIO 里虽然 PB10/PB11 不需要重映射但开 AFIO 是个保险习惯。3.2 USART3 参数配置与空闲中断使能串口本身的配置比较标准重点是最后要打开 IDLE 中断和 DMA 接收请求/* usart3_dma.c —— USART3参数配置 */ static void USART3_Mode_Config(void) { USART_InitTypeDef USART_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 的 DMA 接收和发送请求 */ USART_DMACmd(USART3, USART_DMAReq_Rx | USART_DMAReq_Tx, ENABLE); /* 使能空闲中断用于判定不定长帧结束 */ USART_ITConfig(USART3, USART_IT_IDLE, ENABLE); /* 配置 NVIC空闲中断优先级要低于 DMA避免打断搬运 */ NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel USART3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_Cmd(USART3, ENABLE); }逻辑说明USART_DMACmd这一句是打通串口和 DMA 的开关不开的话 DMA 请求根本发不出来现象是 DMA 计数器一直不动。USART_IT_IDLE是空闲中断注意它和普通的 RXNE 中断不一样IDLE 标志的清除方式很特殊——必须先读 SR 再读 DR标准库的USART_GetITStatus内部帮你做了这个序列但如果你自己写寄存器操作漏了这一步中断会反复进这是血泪经验。NVIC 优先级这里给的是 1实际项目里如果 DMA 中断也开了要让 DMA 优先级高于串口保证数据搬完再处理帧。3.3 DMA 接收通道配置循环模式与缓冲区设计接收 DMA 我一般配成循环模式缓冲区开大一点配合空闲中断算长度/* usart3_dma.c —— DMA接收通道配置 */ #define USART3_RX_BUF_SIZE 256 uint8_t USART3_RxBuffer[USART3_RX_BUF_SIZE]; /* DMA搬运目标 */ volatile uint16_t USART3_RxLen 0; /* 本帧实际长度 */ volatile uint8_t USART3_RxFlag 0; /* 帧接收完成标志 */ static void USART3_DMA_Rx_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel3); /* USART3_RX 对应通道3 */ DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART3-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)USART3_RxBuffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize USART3_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); }逻辑说明DMA_PeripheralBaseAddr填USART3-DR这是数据寄存器地址DMA 每次从这搬一个字节。DMA_DIR_PeripheralSRC表示外设是源、内存是目标接收方向别搞反。DMA_Mode_Circular是关键循环模式下 DMA 搬完 256 字节会自动回到开头继续搬不会停这样即使一帧超过缓冲区也不会卡死但会覆盖旧数据所以缓冲区要开得比最大帧长还大。DMA_MemoryInc_Enable让内存地址递增外设地址不递增因为 DR 就一个寄存器。3.4 空闲中断处理算出本帧长度并置标志中断服务函数是整个方案的大脑负责在帧结束时算长度、清标志/* stm32f10x_it.c —— USART3中断服务函数 */ void USART3_IRQHandler(void) { if (USART_GetITStatus(USART3, USART_IT_IDLE) ! RESET) { /* 清除IDLE标志先读SR再读DR */ volatile uint16_t temp; temp USART3-SR; temp USART3-DR; (void)temp; /* 停止DMA计算本帧收到的字节数 */ DMA_Cmd(DMA1_Channel3, DISABLE); USART3_RxLen USART3_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel3); /* 重新装载DMA准备下一帧 */ DMA_SetCurrDataCounter(DMA1_Channel3, USART3_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel3, ENABLE); USART3_RxFlag 1; /* 通知主循环处理 */ } }逻辑说明DMA_GetCurrDataCounter返回的是还没搬的字节数用总长度减去它就是本帧实际收到的字节数这是整个方案的核心公式。清 IDLE 标志必须按「读 SR 再读 DR」的顺序标准库的USART_GetITStatus虽然帮你判断了但清除动作还是得手动做漏了就会反复进中断。处理完记得重新装载 DMA 计数器并重新使能否则下一帧收不到。USART3_RxFlag用volatile修饰因为它在中断和主循环之间共享编译器优化时可能把它缓存到寄存器里导致主循环看不到变化。3.5 DMA 发送把缓冲区丢出去就不用管了发送 DMA 配置和接收类似只是方向反过来模式用普通模式/* usart3_dma.c —— DMA发送接口 */ void USART3_DMA_Send(uint8_t *buf, uint16_t len) { /* 等待上一次发送完成 */ while (DMA_GetCmdStatus(DMA1_Channel2) ! DISABLE); DMA_DeInit(DMA1_Channel2); /* USART3_TX 对应通道2 */ DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART3-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)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); }逻辑说明发送方向DMA_DIR_PeripheralDST表示外设是目标和接收正好相反。模式用DMA_Mode_Normal发完就停不需要循环。开头的while等待上一次发送完成防止连续调用时覆盖配置实际项目里更优雅的做法是用发送完成中断置标志但轮询等待在大多数场景够用。注意发送缓冲区如果是局部变量函数返回后内存就失效了DMA 还在搬会发出乱码所以发送缓冲区必须是全局或静态的这是新手常踩的坑。4. 主循环与收发联调把驱动跑起来4.1 主循环里的帧处理逻辑驱动配好后主循环只需要轮询标志位处理完清标志/* main.c —— 主循环处理接收帧 */ int main(void) { USART3_DMA_Init(); /* 内部依次调用 GPIO、USART、DMA 配置 */ while (1) { if (USART3_RxFlag) { /* 处理 USART3_RxBuffer 里的 USART3_RxLen 个字节 */ ProcessFrame(USART3_RxBuffer, USART3_RxLen); USART3_RxFlag 0; /* 清标志准备下一帧 */ } /* 其他任务刷屏、按键、定时器…… */ } }逻辑说明主循环不碰任何寄存器只判断标志位CPU 占用极低。ProcessFrame是你自己的协议解析函数比如 Modbus 的 CRC 校验、自定义帧头帧尾判断都放这里。注意处理期间如果新帧到来USART3_RxBuffer会被 DMA 覆盖所以要么处理够快要么用双缓冲。我一般开两个缓冲区交替中断里切换主循环处理另一个这样高频率收包也不会丢。4.2 用串口助手验证收发代码烧进去后先别急着接业务模块用 USB 转串口模块接 PB10/PB11打开串口助手验证。发送端测试主循环里定时调用USART3_DMA_Send发一串固定数据串口助手应该能稳定收到不丢字节。接收端测试串口助手手动发不定长数据比如先发 10 字节再发 50 字节观察USART3_RxLen是否准确。如果长度对不上先查 DMA 计数器有没有重新装载再查 IDLE 标志有没有清干净。验证时有个细节串口助手连续发送时如果两帧之间间隔太短空闲中断可能不触发因为总线一直不空闲。这时候要么在协议里加帧头帧尾要么用定时器做超时判定。IDLE 中断适合帧间有明显间隔的场景比如问答式通信。4.3 性能对比DMA 到底省了多少 CPU拿 115200 波特率、每帧 100 字节来算中断方式每帧要进 100 次中断每次中断进出栈加处理大约 2 微秒一帧就是 200 微秒的 CPU 开销。DMA 方式每帧只进一次空闲中断加上主循环处理总共不到 10 微秒。如果每秒收 50 帧中断方式吃掉 10 毫秒 CPUDMA 方式只吃 0.5 毫秒差距是 20 倍。波特率越高、帧越频繁差距越大。这也是为什么多串口项目基本都上 DMACPU 省下来干正事。5. 避坑与排查那些让我熬夜的串口玄学5.1 现象串口完全没数据DMA 计数器不动原因最常见的是时钟没开全尤其是 AFIO 和 DMA1 时钟。其次是 DMA 通道选错USART3_RX 必须是 DMA1_Channel3选成 Channel2 就收不到。还有一种情况是 GPIO 模式配错TX 配成普通推挽而不是复用推挽引脚没波形。解决按「时钟 → GPIO → USART → DMA → NVIC」的顺序逐项检查用万用表量 TX 引脚空闲时应该是高电平如果一直是低电平说明 GPIO 或 USART 没配好。DMA 计数器可以用调试器看DMA1_Channel3-CNDTR收数据时应该递减。5.2 现象能收到数据但长度总是缓冲区大小原因空闲中断没触发或者DMA_GetCurrDataCounter读的时机不对。如果 IDLE 标志没清干净中断会反复进但长度计算可能出错。另一个可能是 DMA 配成了普通模式而不是循环模式搬完 256 字节就停了计数器归零算出来长度就是满的。解决确认DMA_Mode_Circular配置正确确认中断里先清 IDLE 标志再读计数器。可以在中断里打个断点看USART3-SR的 IDLE 位是否真的置位了。5.3 现象发送数据出现乱码或只发了一半原因发送缓冲区是局部变量函数返回后内存被回收DMA 还在搬搬到的就是垃圾数据。或者发送 DMA 还没完成就重新配置覆盖了上一次的传输。解决发送缓冲区改成全局或静态数组调用USART3_DMA_Send前等待上一次完成。更稳的做法是开 DMA 发送完成中断在中断里置标志主循环根据标志决定能否发下一包。5.4 现象高频率收包时丢帧原因主循环处理一帧的时间太长DMA 已经把下一帧写进同一个缓冲区覆盖了未处理的数据。循环模式下 DMA 不会停覆盖是必然的。解决用双缓冲中断里切换缓冲区指针主循环处理另一个。或者把缓冲区开大让 DMA 有足够空间周转。根本办法是缩短ProcessFrame的耗时把耗时操作挪到其他地方。5.5 现象IDLE 中断反复触发程序卡死原因IDLE 标志清除方式不对。STM32 的 IDLE 标志必须「先读 SR 再读 DR」才能清只读 SR 或者只读 DR 都清不掉。标准库的USART_GetITStatus只判断不清除清除动作要自己写。解决在中断里手动执行temp USART3-SR; temp USART3-DR;两句都不能少。如果用了USART_ClearITPendingBit注意它对 IDLE 标志无效别用错。6. 二次开发改造从能跑到好用这套代码跑通之后真正花时间的是把它改造成适合自己项目的形态。我一般从三个方向动手。第一个方向是协议解析层解耦。驱动只负责收字节和置标志具体是 Modbus、自定义帧还是 AT 指令全部放到独立的protocol.c里。这样换协议不用动驱动驱动也能复用到其他串口。接口上我习惯暴露USART3_RxBuffer、USART3_RxLen、USART3_RxFlag三个东西主循环拿到数据后调Protocol_Parse解析结果再回调业务函数。第二个方向是双缓冲。单缓冲在高频收包时必然丢帧改成两个 256 字节的缓冲区中断里根据当前使用的缓冲区切换主循环处理另一个。实现上用一个volatile uint8_t buf_index记录当前 DMA 写入哪个缓冲区中断里切换并置标志主循环读另一个。注意切换时 DMA 要重新指向新缓冲区标准库没有直接改地址的接口得先DMA_Cmd(DISABLE)再改DMA_MemoryBaseAddr再使能或者用DMA_Init重新配一遍。第三个方向是发送队列。直接调USART3_DMA_Send在多个任务里并发调用会冲突我一般做一个环形发送队列业务层只管往队列里塞数据一个后台任务负责从队列取数据调 DMA 发送。队列深度根据实际发送频率定一般 4 到 8 个够用。发送完成中断里置标志后台任务看到标志就发下一包。改造完记得做压力测试用串口助手以最高频率连续发不定长数据跑半小时看有没有丢帧同时主循环里加个计数器统计每秒处理的帧数和发送端对比。我吃过一次亏双缓冲切换时忘了重新装载 DMA 计数器结果第二帧长度永远是 256查了一晚上才发现是DMA_SetCurrDataCounter漏了。从那以后我每次改 DMA 相关代码都强制走一遍「时钟 → 通道 → 方向 → 模式 → 计数器 → 中断」的检查清单一个都不跳。希望这套拆解帮到你少走点我踩过的弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑