简介:这份资源面向嵌入式开发初学者与中级工程师,聚焦STM32F103标准库下USART3的DMA收发实现,帮助解决串口通信中CPU占用高、实时性不足的问题。包内共171个文件,以c源码、h头文件、s汇编启动文件为主,辅以o目标文件、crf编译中间文件、uvproj与uvopt工程配置、hex烧录文件及map映射文件等,压缩包约3.41MB,构成一套可直接编译的Keil工程模板。内容围绕DMA通道选择、传输方向与源目标地址配置、USART3时钟与波特率设置、DMA请求使能、传输完成与错误中断处理等关键环节展开,并给出初始化、启动传输与中断回调的软件流程。已有3312人学习下载,适合希望掌握标准库DMA串口收发、对照工程模板快速搭建实验环境并排查传输异常的开发者参考。
1. 串口收包还在死等中断?这套 DMA 方案让 CPU 彻底解放
如果你用 STM32F103 做过串口通信,大概率经历过这样的场景:主循环里塞了一个while(USART_GetFlagStatus(USART3, USART_FLAG_RXNE) == RESET),波特率一高、数据一多,CPU 就被拖死在轮询里,稍微加点别的任务就开始丢字节。更别提用中断逐字节收,每来一个字节进一次中断,115200 波特率下每秒上万次中断,主循环基本别想干正事了。
这个项目就是冲着这个痛点来的:在 STM32F103 上用标准库(Standard Peripheral Library)配置 USART3,收发全部走 DMA 通道,CPU 只在数据搬完之后处理一次,中间完全不参与字节搬运。它适合正在做多串口数据采集、Modbus 通信、上位机指令解析的嵌入式开发者,尤其是那些主控资源紧张、又不想上 RTOS 的场景。整套代码基于标准库而非 HAL,对习惯寄存器级控制的开发者更友好,移植到同系列芯片也直接可用。
2. 架构拆解:USART3 与 DMA1 的通道映射关系
2.1 为什么是 USART3 配 DMA1 的这两个通道
STM32F103 的 DMA 控制器分 DMA1 和 DMA2,其中 DMA1 有 7 个通道,每个通道固定映射到特定的外设请求。USART3 的收发请求在 DMA1 上的映射是硬连线的,不能随便换通道:
| 外设请求 | DMA 控制器 | 通道 | 传输方向 |
|---|---|---|---|
| USART3_TX | DMA1 | Channel 2 | 存储器 → 外设 |
| USART3_RX | DMA1 | Channel 3 | 外设 → 存储器 |
这个映射关系在参考手册的 DMA 请求映射表里写死了,配错通道 DMA 请求根本不会触发,现象就是「代码看着没问题但一个字节都收不到」。我见过有人把 RX 配到 Channel 2,调了一下午以为是波特率问题,血泪经验。
发送方向是「存储器到外设」,DMA 从你给的缓冲区里读数据,逐个写到USART3->DR;接收方向是「外设到存储器」,每收到一个字节,USART3 向 DMA 发一次请求,DMA 把USART3->DR的值搬到你的缓冲区。两个方向互不干扰,可以同时开启。
2.2 整体数据流与中断分工
整个方案的数据流是这样的:
- 发送链路:应用层把待发数据放进
USART3_TX_BUF,调用发送函数 → 配置 DMA 传输长度 → 使能 DMA 通道 → DMA 自动把数据搬到 USART3 数据寄存器 → 搬完后触发 DMA 传输完成中断 → 在中断里关闭通道、置发送完成标志。 - 接收链路:USART3 每收到一个字节触发 DMA 请求 → DMA 把字节搬到
USART3_RX_BUF→ 收满预设长度后触发 DMA 传输完成中断 → 在中断里置接收完成标志,应用层轮询这个标志来处理数据。
关键点在于:CPU 只在「一整包数据搬完」时进一次中断,而不是每个字节进一次。这就是 DMA 方案相比中断收发的核心优势。
提示:接收用 DMA 传输完成中断,前提是你知道每包数据的固定长度。如果是变长协议(比如 Modbus RTU 靠 3.5 字符间隔断帧),需要额外配合 USART 的空闲中断(IDLE)来判断一帧结束,这个后面在改造章节会讲。
2.3 缓冲区设计:双缓冲与长度约束
缓冲区大小直接决定了单次能收发的最大数据量。DMA 的传输长度寄存器DMA_CNDTRx是 16 位的,所以单次传输最大 65535 字节。实际项目里一般不会开这么大,按协议最大帧长来定就行。
#define USART3_TX_BUF_SIZE 256 #define USART3_RX_BUF_SIZE 256 u8 USART3_TX_BUF[USART3_TX_BUF_SIZE]; u8 USART3_RX_BUF[USART3_RX_BUF_SIZE]; volatile u8 usart3_tx_done = 1; // 发送完成标志,1 表示空闲 volatile u8 usart3_rx_done = 0; // 接收完成标志这里usart3_tx_done初值设为 1,表示上电后发送通道空闲,可以直接发第一包。usart3_rx_done初值 0,等 DMA 收满一包后在中断里置 1。两个标志都用volatile修饰,防止编译器优化掉中断里的修改。
缓冲区建议用全局数组而不是局部变量,因为 DMA 传输是异步的,函数返回后局部数组就失效了,DMA 还在往里搬数据,这就是典型的踩坑点。
3. 初始化实战:从 GPIO 到 DMA 的完整配置链路
3.1 GPIO 与 USART3 复用配置
USART3 的默认引脚是 PB10(TX)和 PB11(RX)。F103 上 USART3 还可以重映射到 PC10/PC11 或 PD8/PD9,这里用默认引脚。
void USART3_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; // 使能 GPIOB 和 USART3 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); // PB10 - 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 - 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(复用推挽),配成普通推挽输出的话 USART 外设根本控制不了这个引脚。RX 配浮空输入即可,如果线路干扰大可以改成上拉输入。时钟这里有个容易翻车的点:USART3 挂在 APB1 上,要用RCC_APB1PeriphClockCmd,而 GPIOB 挂在 APB2 上,用RCC_APB2PeriphClockCmd。两个总线搞混了,外设就是不动。
3.2 USART3 参数初始化
void USART3_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_Tx | USART_DMAReq_Rx, ENABLE); // 使能 USART3 USART_Cmd(USART3, ENABLE); }USART_DMACmd这一句是很多人漏掉的关键步骤。它负责打开 USART3 内部的 DMA 请求开关,不调用的话,USART3 收到数据也不会向 DMA 发请求,DMA 自然一动不动。参数USART_DMAReq_Tx | USART_DMAReq_Rx表示收发两个方向的 DMA 请求都使能。
波特率 115200 是常见默认值,改波特率直接改USART_BaudRate字段即可,标准库会根据 APB1 时钟自动算分频。注意 APB1 时钟默认是 36MHz,如果你改了系统时钟树,要确认RCC_GetClocksFreq算出来的 APB1 频率是对的,否则波特率会有偏差。
3.3 DMA1 通道配置(收发分开)
DMA 的配置分发送通道(Channel 2)和接收通道(Channel 3),两者结构类似但方向和外设地址不同。
void USART3_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; // 使能 DMA1 时钟 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // ===== 发送通道 DMA1_Channel2 ===== DMA_DeInit(DMA1_Channel2); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&USART3->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)USART3_TX_BUF; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 存储器 -> 外设 DMA_InitStructure.DMA_BufferSize = USART3_TX_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_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_Medium; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel2, &DMA_InitStructure); // ===== 接收通道 DMA1_Channel3 ===== DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr = (u32)&USART3->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (u32)USART3_RX_BUF; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; // 外设 -> 存储器 DMA_InitStructure.DMA_BufferSize = USART3_RX_BUF_SIZE; DMA_Init(DMA1_Channel3, &DMA_InitStructure); // 配置 DMA 传输完成中断(接收通道) DMA_ITConfig(DMA1_Channel3, DMA_IT_TC, ENABLE); NVIC_Configuration(); }逐项说明关键参数:
DMA_PeripheralBaseAddr固定为&USART3->DR,因为收发都是通过这一个数据寄存器。DMA_DIR是方向控制,发送用DMA_DIR_PeripheralDST(外设做目的地),接收用DMA_DIR_PeripheralSRC(外设做源)。DMA_PeripheralInc必须Disable,外设地址固定不变;DMA_MemoryInc必须Enable,存储器地址要递增,否则所有字节都堆在缓冲区第一个位置。DMA_Mode_Normal表示搬完设定长度就停,适合定长收发。如果要循环接收(比如环形缓冲),改成DMA_Mode_Circular。DMA_M2M_Disable关闭存储器到存储器模式,串口通信必须关掉。
接收通道的中断配置里,DMA_ITConfig(DMA1_Channel3, DMA_IT_TC, ENABLE)打开传输完成中断,这样收满一包后能通知应用层。
3.4 NVIC 中断优先级配置
void NVIC_Configuration(void) { NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitStructure.NVIC_IRQChannel = DMA1_Channel3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }DMA1_Channel3 的中断向量是DMA1_Channel3_IRQn,别写成 Channel2 的。优先级分组用NVIC_PriorityGroup_2,抢占优先级 1、子优先级 0,这个优先级在大多数项目里够用。如果你的系统里还有更紧急的中断(比如电机控制 PWM),把 DMA 接收中断的抢占优先级调低一些。
4. 收发核心逻辑:中断服务函数与状态机
4.1 发送函数:启动 DMA 并等待完成
u8 USART3_SendData(u8 *buf, u16 len) { if (len > USART3_TX_BUF_SIZE) return 1; // 超长直接拒绝 if (usart3_tx_done == 0) return 2; // 上一包还没发完 usart3_tx_done = 0; // 拷贝数据到发送缓冲区 memcpy(USART3_TX_BUF, buf, len); // 关闭通道后才能改传输长度 DMA_Cmd(DMA1_Channel2, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel2, len); DMA_Cmd(DMA1_Channel2, ENABLE); return 0; }这个函数做了三件事:长度校验、状态检查、启动 DMA。返回值 0 表示成功,1 表示数据超长,2 表示发送通道忙。调用方根据返回值决定是重试还是丢弃。
关键细节:修改DMA_CNDTRx之前必须先关闭通道。DMA 通道在使能状态下,传输长度寄存器是只读的,直接写不进去。所以顺序一定是「关通道 → 设长度 → 开通道」。这个顺序搞反了,现象是发送长度永远是上次的值,或者干脆发不出去。
4.2 发送完成中断处理
void DMA1_Channel2_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC2) != RESET) { DMA_ClearITPendingBit(DMA1_IT_TC2); DMA_Cmd(DMA1_Channel2, DISABLE); // 搬完关通道 usart3_tx_done = 1; // 置空闲标志 } }发送完成中断里做两件事:关通道、置标志。关通道是为了下次发送时能重新设置长度。标志置 1 后,应用层就知道可以发下一包了。
注意这里用的是DMA1_IT_TC2,对应 Channel 2 的传输完成标志。中断服务函数名DMA1_Channel2_IRQHandler必须和启动文件里的向量表名字完全一致,拼错了就进不了中断。
4.3 接收完成中断与数据处理
void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC3) != RESET) { DMA_ClearITPendingBit(DMA1_IT_TC3); DMA_Cmd(DMA1_Channel3, DISABLE); usart3_rx_done = 1; // 通知应用层有完整一包数据 } }接收中断和发送中断结构一样,区别在于接收完成后置的是usart3_rx_done。应用层在主循环里轮询这个标志:
int main(void) { USART3_GPIO_Config(); USART3_DMA_Config(); USART3_Config(); // 启动接收 DMA,等待第一包数据 DMA_Cmd(DMA1_Channel3, ENABLE); while (1) { if (usart3_rx_done) { usart3_rx_done = 0; // 处理 USART3_RX_BUF 里的数据 ProcessPacket(USART3_RX_BUF, USART3_RX_BUF_SIZE); // 重新启动接收 DMA_SetCurrDataCounter(DMA1_Channel3, USART3_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel3, ENABLE); } // 其他任务照常跑,CPU 不被串口占用 } }主循环里处理完一包数据后,要重新设置接收长度并重新使能通道,准备接下一包。这个「处理完再重启」的模式适合定长协议。如果是变长协议,处理逻辑要配合空闲中断,下一章会讲。
注意:
ProcessPacket里如果处理时间较长,期间来的数据会丢失,因为 DMA 通道还没重新使能。对实时性要求高的场景,建议用双缓冲或者环形缓冲 + 空闲中断。
5. 避坑与排查:那些让 DMA 串口「装死」的细节
5.1 一个字节都收不到
现象:代码编译通过,串口助手发数据,接收缓冲区始终是初始值,usart3_rx_done永远为 0。
原因:最常见的是 DMA 通道映射搞错。USART3_RX 必须用 DMA1_Channel3,配成 Channel2 或别的通道,DMA 请求根本不会触发。其次是漏了USART_DMACmd(USART3, USART_DMAReq_Rx, ENABLE),USART 内部的 DMA 请求开关没打开。
解决:对照参考手册的 DMA 请求映射表确认通道号,检查USART_DMACmd是否调用,检查DMA_Cmd(DMA1_Channel3, ENABLE)是否执行。
5.2 只能收到第一个字节
现象:发一串数据,只有第一个字节正确,后面的全是 0 或者乱码。
原因:DMA_MemoryInc配成了Disable,DMA 每次搬运都写到缓冲区同一个地址,后面的字节把第一个覆盖了。或者DMA_PeripheralDataSize和DMA_MemoryDataSize配成了 HalfWord/Word,而串口是 8 位数据。
解决:确认DMA_MemoryInc = DMA_MemoryInc_Enable,两个 DataSize 都配成DMA_PeripheralDataSize_Byte和DMA_MemoryDataSize_Byte。
5.3 发送一次后再也发不出去
现象:第一包数据发送正常,第二包调用发送函数返回 2(忙),或者干脆没反应。
原因:发送完成中断没进,usart3_tx_done一直是 0。可能是中断向量名写错、NVIC 没使能、或者忘了在中断里清标志位导致反复进中断但逻辑不对。
解决:检查DMA1_Channel2_IRQHandler拼写,确认NVIC_Init里DMA1_Channel2_IRQn使能,确认中断里调用了DMA_ClearITPendingBit。
5.4 数据错位或偶尔丢包
现象:大部分数据正确,偶尔出现字节错位或整包丢失。
原因:接收处理期间 DMA 通道关闭,新来的数据无人接收。或者发送时没等上一包完成就修改了缓冲区。
解决:接收侧改用双缓冲或环形缓冲,配合 USART 空闲中断;发送侧严格检查usart3_tx_done标志,不要在忙的时候 memcpy 覆盖发送缓冲区。
5.5 波特率偏差导致乱码
现象:数据能收到但全是乱码,或者高波特率下误码率明显。
原因:系统时钟配置和标准库计算的 APB1 频率不一致,导致 USART 分频值算错。
解决:用示波器量一下 TX 引脚的实际波特率,或者打印RCC_GetClocksFreq的结果确认 APB1 频率。F103 在 72MHz 系统时钟下 APB1 是 36MHz,如果外部晶振不是 8MHz,要在system_stm32f10x.c里改HSE_VALUE。
6. 二次开发改造:变长协议与环形缓冲的落地思路
定长收发在实际项目里其实不多见,大部分协议是变长的。比如 Modbus RTU 靠帧间隔断帧,自定义协议靠帧头帧尾。这时候纯 DMA 传输完成中断就不够用了,需要引入USART 空闲中断(IDLE)。
思路是这样的:DMA 配成DMA_Mode_Circular或者保持 Normal 但缓冲区开大,同时打开 USART3 的 IDLE 中断。每收到一帧数据,总线空闲时触发 IDLE 中断,在中断里读取 DMA 剩余传输数量DMA_GetCurrDataCounter(DMA1_Channel3),用「缓冲区总长 - 剩余数量」算出这一帧的实际长度。
void USART3_IRQHandler(void) { if (USART_GetITStatus(USART3, USART_IT_IDLE) != RESET) { // 清除 IDLE 标志:先读 SR 再读 DR volatile u16 tmp; tmp = USART3->SR; tmp = USART3->DR; (void)tmp; // 计算本帧长度 u16 remain = DMA_GetCurrDataCounter(DMA1_Channel3); u16 recv_len = USART3_RX_BUF_SIZE - remain; if (recv_len > 0) { // 标记本帧有效,交给应用层处理 usart3_rx_len = recv_len; usart3_rx_done = 1; } // 重启 DMA 接收 DMA_Cmd(DMA1_Channel3, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel3, USART3_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel3, ENABLE); } }这里有个细节:清 IDLE 标志必须「先读 SR 再读 DR」,这是 F103 的硬件要求,只读 SR 清不掉。我一般会用一个volatile临时变量接一下,防止编译器把读操作优化掉。
再进一步,如果数据量大、处理耗时,可以把接收缓冲区做成环形缓冲,DMA 配成循环模式,应用层按读指针取数据。这样 DMA 永远在收,不会因为处理慢而丢包。环形缓冲的读写指针要注意原子性,单生产者(DMA 中断)单消费者(主循环)的场景下,读写指针各自只被一方修改,不需要加锁,但指针类型要用volatile并且保证读写顺序。
改造完之后,验证方法很简单:用串口助手发不同长度的数据帧,看usart3_rx_len是否和实际发送长度一致;再连续快速发多帧,看有没有丢帧。我习惯在ProcessPacket里加一个帧计数器,跑一晚上看计数和发送端是否对得上。
从那以后我每次配 DMA 串口,都强制走一遍「时钟 → GPIO → USART → DMA 通道 → NVIC → 中断向量名」的检查清单,尤其是通道映射和USART_DMACmd这两步,翻车太多次了。希望帮到你。
本文还有配套的精品资源,点击获取