简介:这份资源面向嵌入式开发初学者与中级工程师,聚焦STM32F103标准库下USART3的DMA收发实现,帮助解决串口通信中CPU占用高、实时性不足的问题。压缩包共171个文件,约3.41MB,以c源文件、h头文件、s汇编启动文件为主,另含o目标文件、crf编译中间文件、uvproj与uvopt工程配置、map映射文件及hex固件等,构成一套可直接编译的Keil工程模板。内容围绕DMA通道选择、传输方向与数据量配置、USART3时钟与波特率设置、DMA请求使能、传输完成与错误中断处理等关键环节展开,并给出初始化、启动传输与中断回调的软件流程。已有3312人学习下载,适合希望掌握标准库DMA串口收发、减少CPU干预并提升通信效率的开发者参考与移植。1. 串口收数据还在死等中断?聊聊 STM32F103 上用 DMA 把 USART3 跑满的实战方案如果你用 STM32F103 标准库做过串口通信,大概率经历过这样的场景:主循环里放一个while(USART_GetFlagStatus(USART3, USART_FLAG_RXNE) == RESET)死等,或者开个接收中断,每来一个字节就跳一次 ISR。波特率低的时候勉强能用,一旦拉到 115200 甚至更高,再加上系统里还有定时器、ADC、SPI 在抢 CPU,丢包就成了玄学问题——有时候跑一整天没事,有时候一上电就丢前几个字节。这套资源要解决的,就是 USART3 收发全走 DMA、把 CPU 从字节搬运里彻底解放出来的问题。它基于 STM32F103 标准库(StdPeriph_Lib),不依赖 HAL,适合还在维护老工程、或者想搞清楚 DMA 请求映射底层逻辑的嵌入式从业者。下面从通道选型、寄存器配置到收发分离的实现,一步步拆开讲。2. DMA 请求映射与 USART3 通道选型:为什么你的 DMA 配置不生效2.1 先搞清楚 USART3 的收发各挂在哪条 DMA 通道STM32F103 的 DMA1 有 7 个通道,每个通道的请求来源是硬件固定映射的,不能随便改。USART3 的映射关系是:方向DMA 控制器通道请求来源USART3_TXDMA1Channel 2USART3_TXUSART3_RXDMA1Channel 3USART3_RX很多人翻车就翻在这里:看到 USART1 用 Channel 4(TX)和 Channel 5(RX),想当然以为 USART3 也差不多,结果配了半天通道,DMA 死活不触发。记住一个口诀——USART3 发二收三,这是芯片参考手册里写死的,标准库的DMA_Init不会帮你检查通道对不对,配错了它照样返回成功,但数据就是不动。选型上还有一个容易忽略的点:DMA1 和 DMA2 的分工。STM32F103 只有 DMA1 能挂 USART3,DMA2 一般留给 ADC、SPI1 这类外设。如果你的工程里 SPI1 也在用 DMA,注意别和 USART3 抢通道——SPI1 的 TX/RX 挂在 DMA1 的 Channel 3 和 Channel 2 上,和 USART3 正好冲突。常见做法是 SPI1 改用中断模式,或者把 SPI1 的 DMA 请求让给 USART3,具体看哪个外设的实时性要求更高。2.2 标准库下 DMA 初始化的关键参数逐个拆先看发送方向的 DMA 配置代码,这是整个方案的基础:// USART3_TX 使用 DMA1 Channel2 void USART3_TX_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // DMA1 时钟必须开 DMA_DeInit(DMA1_Channel2); // 先复位,避免残留配置 DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR; // 外设地址固定为数据寄存器 DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)USART3_TX_Buf; // 内存缓冲区首地址 DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 内存 -> 外设 DMA_InitStructure.DMA_BufferSize = 0; // 初始为 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_Medium; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; // 外设请求,非内存到内存 DMA_Init(DMA1_Channel2, &DMA_InitStructure); DMA_Cmd(DMA1_Channel2, DISABLE); // 先不使能,等有数据要发时再开 }逐条说下为什么这么设。DMA_PeripheralBaseAddr必须指向USART3->DR的地址,因为 DMA 搬运的本质就是往数据寄存器里写字节,硬件自动触发发送移位。DMA_DIR_PeripheralDST表示目的地是外设,也就是内存往外设搬。DMA_PeripheralInc_Disable很关键——外设地址不能自增,否则第二个字节就写到 DR 后面的寄存器去了,直接跑飞。DMA_Mode_Normal表示搬完指定数量就停,适合发送这种一次性任务;如果设成DMA_Mode_Circular,发完一轮会自动从头再来,串口会不停重复发同一段数据,除非你就是要做循环发送。接收方向的配置和发送有三处不同,这是最容易搞混的地方:// USART3_RX 使用 DMA1 Channel3 void USART3_RX_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)USART3_RX_Buf; 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_Mode_Circular是常见做法,因为串口数据什么时候来不确定,循环模式让 DMA 搬完一圈自动回到缓冲区头部继续搬,不会因为缓冲区满了就停止接收。DMA_Priority_High给接收通道更高优先级,避免发送大量数据时把接收请求挤掉导致丢包。DMA_BufferSize设成缓冲区大小,比如 256 或 512,具体看你的协议一帧最长多少字节。2.3 USART3 本身的初始化不能省DMA 配好了,USART3 这边也得把对应的使能位打开,否则 DMA 请求根本发不出来:void USART3_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); // USART3 挂在 APB1 // 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); 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); USART_DMACmd(USART3, USART_DMAReq_Tx | USART_DMAReq_Rx, ENABLE); // 关键:使能 DMA 请求 USART_Cmd(USART3, ENABLE); }USART_DMACmd这一行是很多人漏掉的,没有它,USART3 不会向 DMA 控制器发出请求,DMA 配置再对也没用。另外注意 USART3 的时钟在 APB1 上,不是 APB2,开错总线时钟会导致寄存器写不进去。3. 发送与接收的代码实现:从阻塞式到 DMA 搬运的完整改造3.1 发送端:把数据丢给 DMA 就返回发送的核心思路是——应用层把数据准备好,调用一个函数启动 DMA,然后该干嘛干嘛,DMA 搬完了通过中断通知你。先看发送函数:#define USART3_TX_BUF_SIZE 256 uint8_t USART3_TX_Buf[USART3_TX_BUF_SIZE]; // 返回 0 表示启动成功,1 表示上一次发送还没完成 uint8_t USART3_SendBytes(uint8_t *data, uint16_t len) { if (len == 0 || len > USART3_TX_BUF_SIZE) return 1; if (DMA_GetCmdStatus(DMA1_Channel2) != DISABLE) return 1; // 通道还在忙 memcpy(USART3_TX_Buf, data, len); // 拷贝到 DMA 源缓冲区 DMA_Cmd(DMA1_Channel2, DISABLE); // 改配置前先关通道 DMA_SetCurrDataCounter(DMA1_Channel2, len); // 设置本次搬运长度 DMA_ClearFlag(DMA1_FLAG_TC2 | DMA1_FLAG_GL2); // 清发送完成标志 DMA_ITConfig(DMA1_Channel2, DMA_IT_TC, ENABLE); // 开传输完成中断 DMA_Cmd(DMA1_Channel2, ENABLE); // 启动 return 0; }这里有几个细节值得说。DMA_GetCmdStatus判断通道是否还在使能状态,如果上一次发送没完成就返回忙,避免覆盖还没搬完的数据。DMA_SetCurrDataCounter必须在通道关闭时调用,运行中改这个寄存器行为不确定。DMA_ClearFlag清标志要在使能之前做,否则可能一开通道就误触发中断。发送完成中断服务函数里做两件事——清标志、关通道,然后可以置一个软件标志告诉应用层“发完了”。void DMA1_Channel2_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC2) != RESET) { DMA_ClearITPendingBit(DMA1_IT_TC2); DMA_Cmd(DMA1_Channel2, DISABLE); // 发送完成,关通道 g_USART3_TX_Done = 1; // 通知应用层 } }别忘了在NVIC里使能DMA1_Channel2_IRQn,否则中断进不来。3.2 接收端:循环 DMA + 空闲中断的组合拳接收比发送麻烦一点,因为数据什么时候来、来多少都不确定。纯 DMA 循环模式虽然能自动搬,但应用层不知道一帧数据什么时候结束。常见做法是 DMA 循环接收 + USART 空闲中断(IDLE)配合——DMA 负责把字节搬进缓冲区,空闲中断负责在总线安静下来时告诉应用层“这一帧收完了”。先看空闲中断的配置:void USART3_IDLE_Init(void) { NVIC_InitTypeDef NVIC_InitStructure; // 使能 USART3 空闲中断 USART_ITConfig(USART3, USART_IT_IDLE, ENABLE); 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); }中断服务函数里要算清楚这一帧收了多少字节:#define USART3_RX_BUF_SIZE 256 uint8_t USART3_RX_Buf[USART3_RX_BUF_SIZE]; volatile uint16_t g_RxLen = 0; volatile uint8_t g_RxFlag = 0; void USART3_IRQHandler(void) { if (USART_GetITStatus(USART3, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART3); // 读 DR 清空闲标志(必须做) // 计算已接收字节数 = 缓冲区大小 - DMA 剩余计数器 g_RxLen = USART3_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel3); g_RxFlag = 1; // 通知应用层处理 // 重新校准 DMA 计数器,准备接收下一帧 DMA_Cmd(DMA1_Channel3, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel3, USART3_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel3, ENABLE); } }USART_ReceiveData(USART3)这一句不能省,空闲中断的标志需要读一次 DR 才能清除,不读的话中断会反复触发。DMA_GetCurrDataCounter返回的是还没搬的字节数,用缓冲区总大小减去它就是已经收到的字节数。重新校准 DMA 计数器是为了让下一帧从缓冲区头部开始写,避免上一帧的残留数据干扰。应用层主循环里这样用:while (1) { if (g_RxFlag) { g_RxFlag = 0; // 此时 USART3_RX_Buf 里有 g_RxLen 个字节,可以解析协议了 ProcessFrame(USART3_RX_Buf, g_RxLen); } // 其他任务 }3.3 收发同时跑时的优先级与缓冲区规划发送和接收同时工作时,DMA1 的 Channel2 和 Channel3 会竞争总线。STM32F103 的 DMA 控制器在同一时刻只能服务一个通道,硬件按优先级仲裁。接收通道设DMA_Priority_High、发送设DMA_Priority_Medium,是为了保证接收不丢字节——发送慢一点没关系,接收丢了就找不回来了。缓冲区规划上,发送缓冲区建议和接收缓冲区一样大或者略小,因为发送是主动行为,应用层可以控制节奏。接收缓冲区如果协议帧比较长,比如 Modbus RTU 一帧最多 256 字节,那就设 256 或 512。注意 STM32F103 的 RAM 只有 20KB 或 64KB(看具体型号),别把缓冲区开太大挤占其他变量空间。提示:如果接收数据量很大且帧率很高,空闲中断可能来不及处理就被下一帧覆盖。这种情况可以考虑用双缓冲(DMA 的 Memory Target 切换),但标准库对双缓冲支持有限,需要手动操作寄存器,复杂度较高。一般工业场景 115200 波特率下单缓冲够用。4. 避坑与排查:DMA 串口调试中最容易翻车的五个点4.1 现象:DMA 配置全对但一个字节都收不到原因通常是USART_DMACmd没调用,或者调用时参数写错了。标准库里这个函数的第二个参数是USART_DMAReq_Tx和USART_DMAReq_Rx,不是USART_DMAReq_TX(大小写不同)。另外检查RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE)有没有执行——DMA1 时钟在 AHB 上,不在 APB1/APB2,开错了时钟 DMA 寄存器写不进去。解决:在初始化完成后,用调试器读一下DMA1_Channel3->CCR寄存器的 EN 位是否为 1,再读USART3->CR3的 DMAT 和 DMAR 位是否为 1。两个都对了,再查 GPIO 和波特率。4.2 现象:发送能发但数据错位,第一个字节变成 0x00原因一般是DMA_SetCurrDataCounter在通道使能状态下被调用,或者DMA_ClearFlag没做导致传输完成标志残留。还有一种可能是发送缓冲区是局部变量,函数返回后栈被回收,DMA 搬的是已经被覆盖的内存。解决:发送缓冲区用全局数组或静态数组,确保生命周期覆盖整个 DMA 传输过程。DMA_SetCurrDataCounter前先DMA_Cmd(DISABLE),设完再DMA_Cmd(ENABLE)。4.3 现象:接收偶尔丢最后一两个字节原因是空闲中断触发时,DMA 可能还有一个字节正在搬运中,DMA_GetCurrDataCounter读到的值比实际少 1。这是 DMA 和中断之间的竞争窗口,属于硬件层面的时序问题。解决:在空闲中断里读计数器之前加一个极短的延时(几个 NOP),或者连续读两次计数器取较小值。更稳妥的做法是在协议层做超时重传,不依赖单次接收的完整性。4.4 现象:系统跑一段时间后 DMA 通道卡死,不再触发中断原因通常是中断标志没有正确清除,导致中断反复触发或者挂起。DMA_ClearITPendingBit和DMA_ClearFlag要配对使用,只清一个可能不够。另外如果中断服务函数里做了耗时操作(比如解析协议、打印日志),可能阻塞后续 DMA 中断。解决:中断服务函数里只做标志清除和数据长度记录,协议解析放到主循环。检查DMA1->ISR寄存器的 TCIF、HTIF、TEIF 等标志位,确认没有残留。4.5 现象:和 SPI1 同时用时,SPI 数据出错原因前面提过——SPI1 的 TX 用 DMA1 Channel3,和 USART3_RX 冲突。两个外设同时请求同一个通道,DMA 控制器只响应优先级高的那个,另一个的数据就丢了。解决:把 SPI1 改成中断模式,或者把 USART3 的接收改用查询方式(不推荐,失去 DMA 意义)。如果必须都用 DMA,考虑用 DMA2 挂 SPI1(STM32F103 的 DMA2 只有部分型号有),或者换用有更多 DMA 通道的型号。5. 进阶技巧:用 DMA 半传输中断做流式数据的分包处理前面讲的空闲中断方案适合“一帧数据之间有明确间隔”的场景,比如 Modbus RTU 的 3.5 字符间隔。但如果数据是连续流式的,帧与帧之间没有空闲,空闲中断就不会触发。这时候可以用 DMA 的半传输中断(HT)和传输完成中断(TC)配合,把缓冲区切成两半,一半在收的时候另一半在处理。配置上只需要在接收 DMA 初始化后加一行:DMA_ITConfig(DMA1_Channel3, DMA_IT_HT | DMA_IT_TC, ENABLE);然后在DMA1_Channel3_IRQHandler里判断是半传输还是全传输:void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT3) != RESET) { DMA_ClearITPendingBit(DMA1_IT_HT3); // 前半缓冲 [0, SIZE/2) 已满,可以处理 ProcessHalfBuffer(USART3_RX_Buf, USART3_RX_BUF_SIZE / 2); } if (DMA_GetITStatus(DMA1_IT_TC3) != RESET) { DMA_ClearITPendingBit(DMA1_IT_TC3); // 后半缓冲 [SIZE/2, SIZE) 已满,可以处理 ProcessHalfBuffer(USART3_RX_Buf + USART3_RX_BUF_SIZE / 2, USART3_RX_BUF_SIZE / 2); } }这个方案的好处是处理延迟固定——每收满半个缓冲区就处理一次,不会因为等空闲而积压。代价是应用层要自己处理帧边界,如果一帧数据跨了半缓冲区,需要做拼接。我一般会在协议层加一个帧头帧尾校验,跨缓冲区的帧先缓存起来,等下一半数据到了再拼。验证 DMA 是否真的在搬运,最直接的方法是用调试器看DMA1_Channel3->CNDTR寄存器的值。每收一个字节这个值减 1,收到 0 后循环模式会自动重载为DMA_BufferSize。如果这个值一直不变,说明 DMA 请求没触发,回去查USART_DMACmd和通道使能位。另一个方法是拿示波器看 TX 引脚,DMA 发送时波形应该是连续的,如果中间有断点,说明 DMA 搬运速度跟不上波特率,需要检查 DMA 优先级或者总线是否被其他高优先级外设占用。从那以后我每次配 DMA 串口,都强制走一遍“时钟 → 通道映射 → 外设 DMA 使能 → 中断标志清除”这四步检查,少一步都不下载。希望帮到你。本文还有配套的精品资源,点击获取
简介:这份资源面向嵌入式开发初学者与中级工程师,聚焦STM32F103标准库下USART3的DMA收发实现,帮助解决串口通信中CPU占用高、实时性不足的问题。压缩包共171个文件,约3.41MB,以c源文件、h头文件、s汇编启动文件为主,另含o目标文件、crf编译中间文件、uvproj与uvopt工程配置、map映射文件及hex固件等,构成一套可直接编译的Keil工程模板。内容围绕DMA通道选择、传输方向与数据量配置、USART3时钟与波特率设置、DMA请求使能、传输完成与错误中断处理等关键环节展开,并给出初始化、启动传输与中断回调的软件流程。已有3312人学习下载,适合希望掌握标准库DMA串口收发、减少CPU干预并提升通信效率的开发者参考与移植。1. 串口收数据还在死等中断?聊聊 STM32F103 上用 DMA 把 USART3 跑满的实战方案如果你用 STM32F103 标准库做过串口通信,大概率经历过这样的场景:主循环里放一个while(USART_GetFlagStatus(USART3, USART_FLAG_RXNE) == RESET)死等,或者开个接收中断,每来一个字节就跳一次 ISR。波特率低的时候勉强能用,一旦拉到 115200 甚至更高,再加上系统里还有定时器、ADC、SPI 在抢 CPU,丢包就成了玄学问题——有时候跑一整天没事,有时候一上电就丢前几个字节。这套资源要解决的,就是 USART3 收发全走 DMA、把 CPU 从字节搬运里彻底解放出来的问题。它基于 STM32F103 标准库(StdPeriph_Lib),不依赖 HAL,适合还在维护老工程、或者想搞清楚 DMA 请求映射底层逻辑的嵌入式从业者。下面从通道选型、寄存器配置到收发分离的实现,一步步拆开讲。2. DMA 请求映射与 USART3 通道选型:为什么你的 DMA 配置不生效2.1 先搞清楚 USART3 的收发各挂在哪条 DMA 通道STM32F103 的 DMA1 有 7 个通道,每个通道的请求来源是硬件固定映射的,不能随便改。USART3 的映射关系是:方向DMA 控制器通道请求来源USART3_TXDMA1Channel 2USART3_TXUSART3_RXDMA1Channel 3USART3_RX很多人翻车就翻在这里:看到 USART1 用 Channel 4(TX)和 Channel 5(RX),想当然以为 USART3 也差不多,结果配了半天通道,DMA 死活不触发。记住一个口诀——USART3 发二收三,这是芯片参考手册里写死的,标准库的DMA_Init不会帮你检查通道对不对,配错了它照样返回成功,但数据就是不动。选型上还有一个容易忽略的点:DMA1 和 DMA2 的分工。STM32F103 只有 DMA1 能挂 USART3,DMA2 一般留给 ADC、SPI1 这类外设。如果你的工程里 SPI1 也在用 DMA,注意别和 USART3 抢通道——SPI1 的 TX/RX 挂在 DMA1 的 Channel 3 和 Channel 2 上,和 USART3 正好冲突。常见做法是 SPI1 改用中断模式,或者把 SPI1 的 DMA 请求让给 USART3,具体看哪个外设的实时性要求更高。2.2 标准库下 DMA 初始化的关键参数逐个拆先看发送方向的 DMA 配置代码,这是整个方案的基础:// USART3_TX 使用 DMA1 Channel2 void USART3_TX_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); // DMA1 时钟必须开 DMA_DeInit(DMA1_Channel2); // 先复位,避免残留配置 DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR; // 外设地址固定为数据寄存器 DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)USART3_TX_Buf; // 内存缓冲区首地址 DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 内存 -> 外设 DMA_InitStructure.DMA_BufferSize = 0; // 初始为 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_Medium; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; // 外设请求,非内存到内存 DMA_Init(DMA1_Channel2, &DMA_InitStructure); DMA_Cmd(DMA1_Channel2, DISABLE); // 先不使能,等有数据要发时再开 }逐条说下为什么这么设。DMA_PeripheralBaseAddr必须指向USART3->DR的地址,因为 DMA 搬运的本质就是往数据寄存器里写字节,硬件自动触发发送移位。DMA_DIR_PeripheralDST表示目的地是外设,也就是内存往外设搬。DMA_PeripheralInc_Disable很关键——外设地址不能自增,否则第二个字节就写到 DR 后面的寄存器去了,直接跑飞。DMA_Mode_Normal表示搬完指定数量就停,适合发送这种一次性任务;如果设成DMA_Mode_Circular,发完一轮会自动从头再来,串口会不停重复发同一段数据,除非你就是要做循环发送。接收方向的配置和发送有三处不同,这是最容易搞混的地方:// USART3_RX 使用 DMA1 Channel3 void USART3_RX_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART3->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)USART3_RX_Buf; 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_Mode_Circular是常见做法,因为串口数据什么时候来不确定,循环模式让 DMA 搬完一圈自动回到缓冲区头部继续搬,不会因为缓冲区满了就停止接收。DMA_Priority_High给接收通道更高优先级,避免发送大量数据时把接收请求挤掉导致丢包。DMA_BufferSize设成缓冲区大小,比如 256 或 512,具体看你的协议一帧最长多少字节。2.3 USART3 本身的初始化不能省DMA 配好了,USART3 这边也得把对应的使能位打开,否则 DMA 请求根本发不出来:void USART3_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); // USART3 挂在 APB1 // 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); 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); USART_DMACmd(USART3, USART_DMAReq_Tx | USART_DMAReq_Rx, ENABLE); // 关键:使能 DMA 请求 USART_Cmd(USART3, ENABLE); }USART_DMACmd这一行是很多人漏掉的,没有它,USART3 不会向 DMA 控制器发出请求,DMA 配置再对也没用。另外注意 USART3 的时钟在 APB1 上,不是 APB2,开错总线时钟会导致寄存器写不进去。3. 发送与接收的代码实现:从阻塞式到 DMA 搬运的完整改造3.1 发送端:把数据丢给 DMA 就返回发送的核心思路是——应用层把数据准备好,调用一个函数启动 DMA,然后该干嘛干嘛,DMA 搬完了通过中断通知你。先看发送函数:#define USART3_TX_BUF_SIZE 256 uint8_t USART3_TX_Buf[USART3_TX_BUF_SIZE]; // 返回 0 表示启动成功,1 表示上一次发送还没完成 uint8_t USART3_SendBytes(uint8_t *data, uint16_t len) { if (len == 0 || len > USART3_TX_BUF_SIZE) return 1; if (DMA_GetCmdStatus(DMA1_Channel2) != DISABLE) return 1; // 通道还在忙 memcpy(USART3_TX_Buf, data, len); // 拷贝到 DMA 源缓冲区 DMA_Cmd(DMA1_Channel2, DISABLE); // 改配置前先关通道 DMA_SetCurrDataCounter(DMA1_Channel2, len); // 设置本次搬运长度 DMA_ClearFlag(DMA1_FLAG_TC2 | DMA1_FLAG_GL2); // 清发送完成标志 DMA_ITConfig(DMA1_Channel2, DMA_IT_TC, ENABLE); // 开传输完成中断 DMA_Cmd(DMA1_Channel2, ENABLE); // 启动 return 0; }这里有几个细节值得说。DMA_GetCmdStatus判断通道是否还在使能状态,如果上一次发送没完成就返回忙,避免覆盖还没搬完的数据。DMA_SetCurrDataCounter必须在通道关闭时调用,运行中改这个寄存器行为不确定。DMA_ClearFlag清标志要在使能之前做,否则可能一开通道就误触发中断。发送完成中断服务函数里做两件事——清标志、关通道,然后可以置一个软件标志告诉应用层“发完了”。void DMA1_Channel2_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC2) != RESET) { DMA_ClearITPendingBit(DMA1_IT_TC2); DMA_Cmd(DMA1_Channel2, DISABLE); // 发送完成,关通道 g_USART3_TX_Done = 1; // 通知应用层 } }别忘了在NVIC里使能DMA1_Channel2_IRQn,否则中断进不来。3.2 接收端:循环 DMA + 空闲中断的组合拳接收比发送麻烦一点,因为数据什么时候来、来多少都不确定。纯 DMA 循环模式虽然能自动搬,但应用层不知道一帧数据什么时候结束。常见做法是 DMA 循环接收 + USART 空闲中断(IDLE)配合——DMA 负责把字节搬进缓冲区,空闲中断负责在总线安静下来时告诉应用层“这一帧收完了”。先看空闲中断的配置:void USART3_IDLE_Init(void) { NVIC_InitTypeDef NVIC_InitStructure; // 使能 USART3 空闲中断 USART_ITConfig(USART3, USART_IT_IDLE, ENABLE); 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); }中断服务函数里要算清楚这一帧收了多少字节:#define USART3_RX_BUF_SIZE 256 uint8_t USART3_RX_Buf[USART3_RX_BUF_SIZE]; volatile uint16_t g_RxLen = 0; volatile uint8_t g_RxFlag = 0; void USART3_IRQHandler(void) { if (USART_GetITStatus(USART3, USART_IT_IDLE) != RESET) { USART_ReceiveData(USART3); // 读 DR 清空闲标志(必须做) // 计算已接收字节数 = 缓冲区大小 - DMA 剩余计数器 g_RxLen = USART3_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel3); g_RxFlag = 1; // 通知应用层处理 // 重新校准 DMA 计数器,准备接收下一帧 DMA_Cmd(DMA1_Channel3, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel3, USART3_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel3, ENABLE); } }USART_ReceiveData(USART3)这一句不能省,空闲中断的标志需要读一次 DR 才能清除,不读的话中断会反复触发。DMA_GetCurrDataCounter返回的是还没搬的字节数,用缓冲区总大小减去它就是已经收到的字节数。重新校准 DMA 计数器是为了让下一帧从缓冲区头部开始写,避免上一帧的残留数据干扰。应用层主循环里这样用:while (1) { if (g_RxFlag) { g_RxFlag = 0; // 此时 USART3_RX_Buf 里有 g_RxLen 个字节,可以解析协议了 ProcessFrame(USART3_RX_Buf, g_RxLen); } // 其他任务 }3.3 收发同时跑时的优先级与缓冲区规划发送和接收同时工作时,DMA1 的 Channel2 和 Channel3 会竞争总线。STM32F103 的 DMA 控制器在同一时刻只能服务一个通道,硬件按优先级仲裁。接收通道设DMA_Priority_High、发送设DMA_Priority_Medium,是为了保证接收不丢字节——发送慢一点没关系,接收丢了就找不回来了。缓冲区规划上,发送缓冲区建议和接收缓冲区一样大或者略小,因为发送是主动行为,应用层可以控制节奏。接收缓冲区如果协议帧比较长,比如 Modbus RTU 一帧最多 256 字节,那就设 256 或 512。注意 STM32F103 的 RAM 只有 20KB 或 64KB(看具体型号),别把缓冲区开太大挤占其他变量空间。提示:如果接收数据量很大且帧率很高,空闲中断可能来不及处理就被下一帧覆盖。这种情况可以考虑用双缓冲(DMA 的 Memory Target 切换),但标准库对双缓冲支持有限,需要手动操作寄存器,复杂度较高。一般工业场景 115200 波特率下单缓冲够用。4. 避坑与排查:DMA 串口调试中最容易翻车的五个点4.1 现象:DMA 配置全对但一个字节都收不到原因通常是USART_DMACmd没调用,或者调用时参数写错了。标准库里这个函数的第二个参数是USART_DMAReq_Tx和USART_DMAReq_Rx,不是USART_DMAReq_TX(大小写不同)。另外检查RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE)有没有执行——DMA1 时钟在 AHB 上,不在 APB1/APB2,开错了时钟 DMA 寄存器写不进去。解决:在初始化完成后,用调试器读一下DMA1_Channel3->CCR寄存器的 EN 位是否为 1,再读USART3->CR3的 DMAT 和 DMAR 位是否为 1。两个都对了,再查 GPIO 和波特率。4.2 现象:发送能发但数据错位,第一个字节变成 0x00原因一般是DMA_SetCurrDataCounter在通道使能状态下被调用,或者DMA_ClearFlag没做导致传输完成标志残留。还有一种可能是发送缓冲区是局部变量,函数返回后栈被回收,DMA 搬的是已经被覆盖的内存。解决:发送缓冲区用全局数组或静态数组,确保生命周期覆盖整个 DMA 传输过程。DMA_SetCurrDataCounter前先DMA_Cmd(DISABLE),设完再DMA_Cmd(ENABLE)。4.3 现象:接收偶尔丢最后一两个字节原因是空闲中断触发时,DMA 可能还有一个字节正在搬运中,DMA_GetCurrDataCounter读到的值比实际少 1。这是 DMA 和中断之间的竞争窗口,属于硬件层面的时序问题。解决:在空闲中断里读计数器之前加一个极短的延时(几个 NOP),或者连续读两次计数器取较小值。更稳妥的做法是在协议层做超时重传,不依赖单次接收的完整性。4.4 现象:系统跑一段时间后 DMA 通道卡死,不再触发中断原因通常是中断标志没有正确清除,导致中断反复触发或者挂起。DMA_ClearITPendingBit和DMA_ClearFlag要配对使用,只清一个可能不够。另外如果中断服务函数里做了耗时操作(比如解析协议、打印日志),可能阻塞后续 DMA 中断。解决:中断服务函数里只做标志清除和数据长度记录,协议解析放到主循环。检查DMA1->ISR寄存器的 TCIF、HTIF、TEIF 等标志位,确认没有残留。4.5 现象:和 SPI1 同时用时,SPI 数据出错原因前面提过——SPI1 的 TX 用 DMA1 Channel3,和 USART3_RX 冲突。两个外设同时请求同一个通道,DMA 控制器只响应优先级高的那个,另一个的数据就丢了。解决:把 SPI1 改成中断模式,或者把 USART3 的接收改用查询方式(不推荐,失去 DMA 意义)。如果必须都用 DMA,考虑用 DMA2 挂 SPI1(STM32F103 的 DMA2 只有部分型号有),或者换用有更多 DMA 通道的型号。5. 进阶技巧:用 DMA 半传输中断做流式数据的分包处理前面讲的空闲中断方案适合“一帧数据之间有明确间隔”的场景,比如 Modbus RTU 的 3.5 字符间隔。但如果数据是连续流式的,帧与帧之间没有空闲,空闲中断就不会触发。这时候可以用 DMA 的半传输中断(HT)和传输完成中断(TC)配合,把缓冲区切成两半,一半在收的时候另一半在处理。配置上只需要在接收 DMA 初始化后加一行:DMA_ITConfig(DMA1_Channel3, DMA_IT_HT | DMA_IT_TC, ENABLE);然后在DMA1_Channel3_IRQHandler里判断是半传输还是全传输:void DMA1_Channel3_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_HT3) != RESET) { DMA_ClearITPendingBit(DMA1_IT_HT3); // 前半缓冲 [0, SIZE/2) 已满,可以处理 ProcessHalfBuffer(USART3_RX_Buf, USART3_RX_BUF_SIZE / 2); } if (DMA_GetITStatus(DMA1_IT_TC3) != RESET) { DMA_ClearITPendingBit(DMA1_IT_TC3); // 后半缓冲 [SIZE/2, SIZE) 已满,可以处理 ProcessHalfBuffer(USART3_RX_Buf + USART3_RX_BUF_SIZE / 2, USART3_RX_BUF_SIZE / 2); } }这个方案的好处是处理延迟固定——每收满半个缓冲区就处理一次,不会因为等空闲而积压。代价是应用层要自己处理帧边界,如果一帧数据跨了半缓冲区,需要做拼接。我一般会在协议层加一个帧头帧尾校验,跨缓冲区的帧先缓存起来,等下一半数据到了再拼。验证 DMA 是否真的在搬运,最直接的方法是用调试器看DMA1_Channel3->CNDTR寄存器的值。每收一个字节这个值减 1,收到 0 后循环模式会自动重载为DMA_BufferSize。如果这个值一直不变,说明 DMA 请求没触发,回去查USART_DMACmd和通道使能位。另一个方法是拿示波器看 TX 引脚,DMA 发送时波形应该是连续的,如果中间有断点,说明 DMA 搬运速度跟不上波特率,需要检查 DMA 优先级或者总线是否被其他高优先级外设占用。从那以后我每次配 DMA 串口,都强制走一遍“时钟 → 通道映射 → 外设 DMA 使能 → 中断标志清除”这四步检查,少一步都不下载。希望帮到你。本文还有配套的精品资源,点击获取