简介:面向STM32F103嵌入式开发者的一份标准库工程资源,核心演示如何利用DMA控制器实现USART3串口的高效接收与发送,解决实时通信场景中CPU频繁干预数据搬运的问题。适合具备基础STM32开发经验、希望掌握DMA与USART联动配置的工程师或学习者参考。
压缩包共171个文件,约3.41MB,以C语言源文件(30个.c)、头文件(31个.h)及汇编启动文件(33个.s)为主体,并附带Keil工程配置(uvproj/uvopt)、编译生成的目标文件(.o/.axf/.hex)以及链接映射文件(.map)等,可直接打开工程查看或烧录验证。
资源已有3311人浏览学习。工程代码完整,覆盖DMA通道选择、传输方向配置、USART3参数初始化、中断处理及主流程框架,开发者可在此基础上快速移植到实际项目中,减少底层驱动编写时间,提升串口数据吞吐效率。
1. 为什么 USART3 要用 DMA:CPU 中断搬字节是最不划算的买卖
在数据采集板上,USART3 承担了传感器数据上报和指令接收的工作。最初用标准库的串口中断逐字节收发,115200 波特率下每收一个字节进一次中断、每发一个字节也进一次中断,主循环里的任务被频繁打断,系统响应一塌糊涂。把 USART3 的收发改到 DMA 上之后,中断频率从“每字节一次”降为“一帧一次”,CPU 只负责解析数据。这篇笔记梳理了标准库下 STM32F103 实现 USART3 加 DMA 收发的完整链路:外设时钟、引脚复用、DMA 通道映射、IDLE 中断配合,以及我实际调试中遇到的几个坑。适合已经会串口基本收发、想把 CPU 从搬运中解放出来的开发者参考。
2. USART3 的外设基础:时钟域、引脚复用与串口初始化顺序
2.1 APB1 上的串口:USART3 和 USART1 的差异
很多人在标准库项目里习惯从 USART1 的配置改起。USART1 挂在 APB2 总线上,时钟由 RCC_APB2PeriphClockCmd 开启;USART3 却挂在 APB1 总线上,时钟使能函数是 RCC_APB1PeriphClockCmd。两个外设时钟域不同,APB2 最高 72MHz,APB1 最高 36MHz。对通信波特率来说 36MHz 足够,但如果你把 USART3 当成 APB2 设备来开时钟,串口寄存器根本不会响应。
引脚差异同样明显。USART1 默认引脚是 PA9/PA10,USART3 默认引脚是 PB10/PB11。USART3 支持重映射到 PD8/PD9,板级布线受限时很有用。重映射时除了开启目标 GPIO 端口时钟,还必须开启 AFIO 时钟并调用 GPIO_PinRemapConfig(GPIO_Remap_USART3, ENABLE),否则重映射不生效,信号依然从默认引脚走。
| 项目 | USART1 | USART3 |
|---|---|---|
| 所在总线 | APB2 | APB1 |
| 时钟使能函数 | RCC_APB2PeriphClockCmd | RCC_APB1PeriphClockCmd |
| 默认 TX/RX | PA9 / PA10 | PB10 / PB11 |
| 重映射 TX/RX | PB6 / PB7 | PD8 / PD9 |
| DMA 发送通道 | DMA1_Channel4 | DMA1_Channel2 |
| DMA 接收通道 | DMA1_Channel5 | DMA1_Channel3 |
这张表是写代码前的第一份对照清单。外设、引脚、DMA 通道三者关系先列清楚,能避免后面把方向搞混。
2.2 引脚模式:复用推挽和浮空输入不能省
GPIO 初始化看起来简单,最容易出错的是 TX 引脚必须配置成 GPIO_Mode_AF_PP,即复用推挽输出。复用含义是引脚控制权从 GPIO 交给 USART3,让串口移位寄存器驱动引脚。如果误写成普通推挽输出 GPIO_Mode_Out_PP,程序运行正常,但引脚电平由 ODR 决定,USART3 发送数据根本到不了引脚,逻辑分析仪上自然看不到波形。
RX 引脚一般配成 GPIO_Mode_IN_FLOATING 浮空输入。板级如果已有外部上拉电阻,浮空输入是常见做法,线路空闲电平由外部电阻决定,内部上下拉不会引入额外偏置。如果板上没有上拉,用 GPIO_Mode_IPU 上拉输入更稳妥,避免空闲时 RX 电平不确定产生杂波。
2.3 引脚和重映射的完整代码
// usart3_board.c #include "stm32f10x.h" void USART3_GPIO_Config(void) { GPIO_InitTypeDef gpio; // USART3 默认引脚在 PB10/PB11,先开 GPIOB 时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); // 如果板级需要重映射到 PD8/PD9,打开下面两行 // RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); // GPIO_PinRemapConfig(GPIO_Remap_USART3, ENABLE); // 发送引脚 PB10:复用推挽输出,翻转速率 50MHz gpio.GPIO_Pin = GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpio); // 接收引脚 PB11:浮空输入,不需要配置 Speed gpio.GPIO_Pin = GPIO_Pin_11; gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOB, &gpio); }逻辑说明:先开启 GPIOB 时钟,再分别配置 TX 和 RX。RX 没有设置 GPIO_Speed,因为 Speed 只对输出模式有意义,浮空输入下库函数会忽略该字段。
参数说明:GPIO_Pin_10 对应 PB10,GPIO_Pin_11 对应 PB11。重映射时改成 GPIO_Pin_8、GPIO_Pin_9,并把 GPIO_Init 的第一个参数换成 GPIOD。GPIO_Speed_50MHz 是通信引脚的常见选择,波特率较高时别用 2MHz 低速模式,容易把边沿拉缓。
2.4 串口初始化:USART_DMACmd 是容易漏的一步
USART3 外设配置和常规串口一样,波特率、字长、停止位、校验位、硬件流控逐个填入结构体,然后调用 USART_Init。大部分应用不需要流控,填 USART_HardwareFlowControl_None。
初始化完成后不要漏掉 USART_DMACmd。这行代码让 USART3 在收发数据时向 DMA 控制器发出请求。很多人的 DMA 配置完全正确,但串口这边没使能 DMA 请求,数据永远不动。
void USART3_Init(uint32_t baud) { USART_InitTypeDef usart; // 使能 USART3 时钟,注意是 APB1 外设 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); usart.USART_BaudRate = baud; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART3, &usart); // 关键:让串口接收和发送都允许 DMA 请求 USART_DMACmd(USART3, USART_DMAReq_Rx | USART_DMAReq_Tx, ENABLE); USART_Cmd(USART3, ENABLE); }逻辑说明:先开外设时钟,再调用 USART_Init 配置参数,随后使能 DMA 请求,最后使能串口。USART_Cmd 放在最后,确保参数和 DMA 请求都配置完成后再让串口运行,避免漏配期间产生异常中断。
参数说明:baud 是调用方传入的波特率,常用 9600、115200、460800。USART_Mode_Rx | USART_Mode_Tx 表示收发都启用。USART_DMAReq_Rx 和 USART_DMAReq_Tx 分别对应接收和发送方向的 DMA 请求位。
2.5 初始化顺序的边界
RCC 时钟使能的先后其实无所谓,但必须在任何外设寄存器操作之前完成。如果先写 USART3 寄存器再开时钟,总线时钟还没起来,外设操作会被忽略,现象就是寄存器写不进去。调试时可以把初始化顺序固定为:GPIO 时钟、串口时钟、DMA 时钟、GPIO 模式、USART_Init、USART_DMACmd、DMA_Init、DMA_Cmd。这样一层一层加,出问题也容易定位。
3. DMA 通道配置:USART3_TX 走通道2、RX 走通道3,方向不能反
3.1 DMA1 的通道映射表
STM32F103 的 DMA1 有 7 个通道,每个通道可以接收多个外设的请求,但同一时刻只能服务一个。通道与外设请求的对应关系由硬件固定,不是软件随意指定。USART3_TX 对应 DMA1_Channel2,USART3_RX 对应 DMA1_Channel3。把发送方向的初始化填进 Channel3,USART3 的发送请求根本不会触发该通道,数据链路直接断开。
| DMA1 通道 | 外设请求 | 方向 | 外设地址 |
|---|---|---|---|
| DMA1_Channel2 | USART3_TX | 内存 → 外设 | &USART3->DR |
| DMA1_Channel3 | USART3_RX | 外设 → 内存 | &USART3->DR |
方向容易搞混,根源在标准库的宏命名。DMA_DIR_PeripheralDST 直译是“外设是目的地”,对应内存到外设,即发送;DMA_DIR_PeripheralSRC 是“外设是源”,对应外设到内存,即接收。发送通道如果误填 PeripheralSRC,DMA 会试图从 USART3->DR 往外搬数据,而发送场景下 DR 没有可读内容,发送缓冲区纹丝不动。
3.2 DMA_InitTypeDef 字段逐项拆解
标准库的 DMA_InitTypeDef 里,和 USART3 收发相关的字段逐个说明。
DMA_PeripheralBaseAddr 填 (uint32_t)&USART3->DR。DMA 只搬这一个数据寄存器,不关心引脚是 PB 还是 PD。DMA_MemoryBaseAddr 填发送或接收缓冲区数组首地址,强转成 uint32_t。DMA_DIR 按 3.1 的方向表填写。DMA_BufferSize 是单次传输的数据量,数据宽度配成 Byte 时单位就是字节。DMA_PeripheralInc 固定 Disable,因为 DR 只有一个;DMA_MemoryInc 填 Enable,数据要依次放进缓冲数组。
DMA_PeripheralDataSize 和 DMA_MemoryDataSize 都填 Byte。如果误填 HalfWord,DMA 每次搬运 16 位,而 USART3 只产生 8 位请求,高 8 位会变成未知值,出现字节错位。DMA_Mode 发送用 Normal,接收不定长帧也常用 Normal。DMA_Priority 接收优先级通常高于发送,接收丢帧不可恢复,发送慢一点还能重发。DMA_M2M 必须填 Disable,填 Enable 会让 DMA 使用内部触发绕过外设,完全脱离 USART3,表现非常诡异。
3.3 通道初始化代码:两个函数一次配齐
// usart3_dma.c #define USART3_RX_BUF_SIZE 256 uint8_t usart3_tx_buf[256]; uint8_t usart3_rx_buf[USART3_RX_BUF_SIZE]; // 发送通道配置保存为全局结构体,发送前可只改地址和长度 DMA_InitTypeDef usart3_dma_init; void USART3_TX_DMA_Config(uint16_t len) { RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel2); usart3_dma_init.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR; usart3_dma_init.DMA_MemoryBaseAddr = (uint32_t)usart3_tx_buf; usart3_dma_init.DMA_DIR = DMA_DIR_PeripheralDST; // 内存 -> 外设 usart3_dma_init.DMA_BufferSize = len; usart3_dma_init.DMA_PeripheralInc = DMA_PeripheralInc_Disable; usart3_dma_init.DMA_MemoryInc = DMA_MemoryInc_Enable; usart3_dma_init.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; usart3_dma_init.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; usart3_dma_init.DMA_Mode = DMA_Mode_Normal; usart3_dma_init.DMA_Priority = DMA_Priority_High; usart3_dma_init.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel2, &usart3_dma_init); DMA_Cmd(DMA1_Channel2, ENABLE); } void USART3_RX_DMA_Config(void) { DMA_InitTypeDef dma; DMA_DeInit(DMA1_Channel3); dma.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR; dma.DMA_MemoryBaseAddr = (uint32_t)usart3_rx_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; // 外设 -> 内存 dma.DMA_BufferSize = USART3_RX_BUF_SIZE; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma.DMA_Mode = DMA_Mode_Normal; dma.DMA_Priority = DMA_Priority_VeryHigh; dma.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel3, &dma); DMA_Cmd(DMA1_Channel3, ENABLE); }逻辑说明:发送通道结构体特意存成全局,因为发送时数据地址和长度经常变,后续只需修改 MemoryBaseAddr 和 BufferSize,其他字段保持原值。接收通道用局部结构体就可以,初始化一次后基本不动。DMA_DeInit 先把通道恢复默认值,防止上一次配置残留影响本次参数。
参数说明:接收缓冲区 256 字节,后续 IDLE 中断里重载计数器也用同一个宏。DMA_Priority 接收比发送高一级,避免数据溢出。usart3_rx_buf 是全局数组,生命周期覆盖整个 DMA 传输过程,不能放在会被释放的局部作用域里。
3.4 Normal 模式和 Circular 模式怎么选
发送方向用 Normal 是唯一合理选择。发送完一帧自动停止,下次发送前重新配置计数器,逻辑清晰。发送开 Circular 会循环搬运同一段缓冲区,把上次数据反复发出去,通常不是想要的。
接收方向要看业务。不定长协议帧优先用 Normal 加 IDLE 中断,帧结束由串口空闲中断通知 CPU,DMA 计数器记录剩余值,CPU 取出数据后重载。这个方案支持任意帧长,代码简单。Circulad 接收需要自己判断帧边界,还要处理缓冲区回绕,复杂度更高。定长帧可以考虑 Circular 加半传输中断,但那是另一套设计风格,不建议混用。
3.5 缓冲区大小与数据同步
接收缓冲区大小取决于最大帧长。协议帧最大 200 字节,缓冲区开 256 比较合适。IDLE 中断里用总长度减 DMA_GetCurrDataCounter 当前计数得到实际长度。缓冲区开太小,长帧会覆盖缓冲区尾部,数据直接错乱。
缓冲区数组建议全局或静态。栈空间本就有限,几百字节数组放栈上有溢出风险。如果主循环与 DMA 同时访问接收缓冲区,变量要加 volatile 修饰,或用互斥标志保证读写顺序。主循环检测到新帧后尽快把数据拷走,清掉长度标记,避免新帧覆盖旧帧。
4. 常见问题排查:DMA 接收不触发、发送错乱、IDLE 卡死的五个原因
4.1 先搭好正确框架:IDLE 中断怎么配合 DMA 计数
不定长帧接收的常见做法是让 DMA 一直处于接收状态。当串口线上出现一个字节时间的空闲电平时,USART3 产生 IDLE 中断。此时 DMA_GetCurrDataCounter(DMA1_Channel3) 返回的是剩余未搬运的数量,缓冲区总长度减剩余值,就是这一帧实际收到的字节数。
// stm32f10x_it.c volatile uint16_t usart3_rx_len = 0; void USART3_IRQHandler(void) { if (USART_GetITStatus(USART3, USART_IT_IDLE) != RESET) { // 读 SR 再读 DR,硬件清除 IDLE 标志 USART_ReceiveData(USART3); uint16_t remain = DMA_GetCurrDataCounter(DMA1_Channel3); uint16_t received = USART3_RX_BUF_SIZE - remain; // 标记本次接收长度,具体解析放到主循环 usart3_rx_len = received; // 重载计数器前必须失能通道,否则写入无效 DMA_Cmd(DMA1_Channel3, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel3, USART3_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel3, ENABLE); } }逻辑说明:IDLE 中断里的工作拆成三步:清标志、算长度、重载计数。USART_ReceiveData(USART3) 看起来只是读寄存器,实际是在完成 SR 到 DR 的访问顺序。时序不对,IDLE 标志不会被清除,中断会反复进入,主循环直接卡死。
参数说明:USART3_RX_BUF_SIZE 必须和接收 DMA 初始化时的 BufferSize 一致。DMA_GetCurrDataCounter 返回 16 位剩余计数,received 是已经搬入缓冲区的字节数。重载时先 DISABLE、再写计数器、最后 ENABLE,这个顺序不能反。
4.2 问题一:数据发出去但接收 DMA 不触发
现象:串口助手能收到 MCU 发来的数据,但接收缓冲区始终是空的,程序没有任何反应。
原因:这种“发送正常、接收不触发”优先检查 USART3->CR3 的接收 DMA 请求使能位。如果 USART_DMACmd 里只使能了 TX,或者按位或写错,接收方向不会向 DMA 发出请求。
解决:检查初始化代码。正确写法是 USART_DMACmd(USART3, USART_DMAReq_Rx | USART_DMAReq_Tx, ENABLE)。同时确认接收 DMA 通道的 DMA_DIR 是 DMA_DIR_PeripheralSRC。
4.3 问题二:DMA 发送完全没波形,但程序不报错
现象:调用发送函数后,逻辑分析仪在 TX 引脚上看不到任何电平变化,程序不崩溃不卡死,发送函数像被吞了。
原因:最常见是 GPIO 模式配成了 GPIO_Mode_Out_PP。普通推挽输出下引脚由 ODR 控制,USART3 移位寄存器无法驱动引脚。第二个可能是 USART3 时钟没开对,寄存器读写落在未使能的外设上。第三个可能是重映射场景没开 AFIO 时钟,信号走了默认引脚而观察点接在重映射引脚上。
解决:TX 引脚改成 GPIO_Mode_AF_PP。用 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE) 开时钟。重映射时补上 RCC_APB2Periph_AFIO 和 GPIO_PinRemapConfig。
4.4 问题三:第一次发送正常,第二次开始卡死或数据错乱
现象:程序启动后第一次发送正常,第二次调用发送函数要么卡在 while 循环,要么发出上一次残留的数据。
原因:发送通道是 Normal 模式,第一次传输完成后 DMA1_FLAG_TC2 置位。第二次发送前没清除标志,等待完成的 while 条件立即满足,此时 DMA 通道还没重新装载,直接设置 NDTR 不生效,发送的还是旧数据或干脆不发送。
解决:重新装载前调用 DMA_ClearFlag(DMA1_FLAG_TC2),按“失能通道 → 清标志 → 设置计数器 → 使能通道”执行。先使能再写计数,外设请求一旦触发会按旧配置搬运。
4.5 问题四:IDLE 中断只触发一次,第二帧再也没有数据
现象:接收 DMA 配合 IDLE 中断,第一帧数据正常解析,第二帧到来后 usart3_rx_len 不再更新。
原因:中断服务函数里只取走数据,没有重载 DMA 计数器。DMA1_Channel3 的 NDTR 降为 0 后通道自动停止,后续字节无法写入缓冲区。
解决:在 IDLE 中断里完成“失能通道 → 设置计数器 → 使能通道”三件套。同时确认 IDLE 标志已被读 SR/DR 清除,否则中断会连续进入,看起来像是卡死。
4.6 问题五:后半段数据乱码,像是字节错位
现象:收到的帧前半段正确,后半段变成随机值或格式错误,接收长度始终对不上。
原因:缓冲区生命周期问题。发送函数传入局部数组指针,函数返回后栈空间被回收,DMA 还在从失效地址搬运数据,前半段碰巧没被覆盖,后半段被其他栈变量污染。另一个原因是 DMA 数据宽度填错,内存侧配成 HalfWord,USART3 是 8 位寄存器,每次搬运 16 位导致字节错位。
解决:发送缓冲区用全局或静态数组,等 DMA 传输完成后再复用。检查 DMA_PeripheralDataSize 和 DMA_MemoryDataSize 都填 Byte。
4.7 一个排查顺序建议
如果 DMA 收发同时出问题,不要一头扎进 DMA 配置。先用普通串口中断模式验证 USART3 收发通路,把 DMA 相关代码注释掉,在中断里做回环,确认引脚、时钟、波特率都没问题,再恢复 DMA 配置逐个功能验证。这个顺序能区分“串口本身问题”和“DMA 配置问题”,减少一半调试时间。调试工具方面,逻辑分析仪比串口助手更适合看波形,DMA 配置错误往往表现为引脚完全没有电平变化。
5. 验证技巧:回环测试与动态重载 NDTR 的收尾细节
5.1 回环测试怎么搭
把 PB10 和 PB11 用杜邦线短接,调用发送函数发出 0x55、0xAA、0x01、0x02、0x03 一组长数据。正常情况下接收 DMA 会把同样数据搬进缓冲区,IDLE 中断里算出的长度应为 5。回环数据不对,问题大概率在两个 DMA 通道的配置关系上,和外部设备无关。
void USART3_DMA_Send(uint8_t *data, uint16_t len) { // 等待上一次发送完成 while (DMA_GetFlagStatus(DMA1_FLAG_TC2) == RESET) ; // 失能、清标志、改地址和长度、使能 DMA_Cmd(DMA1_Channel2, DISABLE); DMA_ClearFlag(DMA1_FLAG_TC2); usart3_dma_init.DMA_MemoryBaseAddr = (uint32_t)data; usart3_dma_init.DMA_BufferSize = len; DMA_Init(DMA1_Channel2, &usart3_dma_init); DMA_Cmd(DMA1_Channel2, ENABLE); }逻辑说明:这个发送函数基于 3.3 节保存的全局结构体,发送前只改地址和长度,其余通道参数不变。while 等待的是上一次传输完成标志,第一次调用时 TC2 处于复位状态,不会卡住。
5.2 发送完成后不要立即复用缓冲区
数据交给 DMA 后 CPU 可以继续做别的事,但缓冲区是共享资源。DMA 还没搬完时修改缓冲区,发送内容会混杂新旧数据。我一般维护一个 busy 标志,在 DMA 传输完成中断里清除,主循环发送前检查标志,这个习惯避免大量偶发乱码。
讲一个教训:某个模拟项目里,我把 DMA1_Channel2 和 Channel3 的方向配置写反,串口助手收到的数据和发送内容完全对不上,查了一下午,最后对照数据手册的 DMA 请求映射表一栏一栏核对才定位。从那以后,我每次写 USART 的 DMA 代码,都先画一张“通道号—方向—地址—缓冲区长度”对照表再动手,这个小前置步骤帮我省下了很多次无意义的排查。希望帮到你。
本文还有配套的精品资源,点击获取