简介面向STM32F103嵌入式开发者的USART3 DMA通信实例包聚焦标准库环境下串口高效收发与CPU占用优化适合已掌握基础外设、希望进阶DMA传输的开发者参考。压缩包共171个文件约3.41MB以C源文件、H头文件为主辅以汇编启动文件、编译中间文件及少量工程配置与说明文档覆盖工程搭建、外设初始化到运行输出的完整链路。已有3315人学习下载具备一定参考热度。内容围绕DMA1通道与USART3的协同配置展开包含发送与接收方向、传输量、循环模式及完成与错误中断处理并给出缓冲区组织与软件流程示例读者可据此搭建可验证的串口DMA收发工程对照中断回调与状态更新排查常见问题也可作为其他USART口DMA改造的模板。1. 从一次半帧错位说起STM32F103 的 USART3 DMA 到底救的是什么场去年帮人看一块 STM32F103 的板子USART3 跑 115200上位机每 20 ms 下发一帧 64 字节从站用接收中断逐字节存数组跑十几分钟就冒出半帧、错位帧。后来换成 STM32F103 的 USART3 走 DMA 搬运配标准库StdPeriph 3.5.0直接操作外设寄存器问题消失。这套写法的核心价值只有一句CPU 不要在搬字节上浪费时间——发送不用死等 TC接收不用每字节进一次中断主循环该干什么干什么。它适合谁已经跑通串口、手上是标准库工程、正在被高波特率或不定长帧折腾的从业者。下面按我实际的落地顺序拆先把硬件链路钉死再分别做发送端和接收端然后是踩坑清单最后是压测验收。2. 先把硬件链路钉死USART3 的引脚、DMA 请求通道与工程骨架动手写代码之前有两件事必须先确认否则后面所有调试都是在黑匣子里瞎猜USART3 的 DMA 请求到底挂在哪个通道上以及这几个外设分别由哪条总线供时钟。这两条搞错代码写得再漂亮也是一个字节都动不了。2.1 USART3 为什么只能挂 DMA1 的通道 2 和通道 3STM32F103 的 DMA 请求映射是硬件固化在芯片里的不是软件可选。你在 DMA_InitStructure 里填的是通道号通道和请求源之间是硬连线。USART3_TX 固定接 DMA1 的通道 2USART3_RX 固定接 DMA1 的通道 3这条关系写在参考手册的 DMA1 请求映射表里。DMA1 通道可服务的请求节选本方案用途Channel1ADC1、TIM2_CH3、TIM4_CH1不用Channel2SPI1_RX、USART3_TX、TIM1_CH1、TIM2_UP、TIM3_CH3USART3 发送Channel3SPI1_TX、USART3_RX、TIM1_CH2、TIM3_UP、TIM3_CH4USART3 接收Channel4SPI2_RX、USART1_TX、I2C2_TX不用Channel5SPI2_TX、USART1_RX、I2C2_RX不用Channel6USART2_RX、I2C1_TX不用Channel7USART2_TX、I2C1_RX不用这张表还有个隐藏信息通道 2 和通道 3 同时被 SPI1 和 USART3 共享。也就是说如果你的板子上 SPI1 也在跑而且用了 DMA那它和 USART3 就是抢同一对通道两者不能同时用 DMA。我见过一个项目就是这么翻车的SPI1 刷屏刷得好好的一开 USART3 的 DMA 接收屏就不刷新了。所以选型时先看整块板子的 DMA 通道分配别只看眼前这一个外设。另一个常被忽略的点是 USART3 挂在哪条总线。USART1 在 APB2USART2 和 USART3 在 APB1所以开时钟是 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE)而 DMA1 挂在 AHB 上走的是 RCC_AHBPeriphClockCmd。三条不同的时钟线漏一条就全盘不动。2.2 时钟、GPIO 与 USART3 模式配置的最小闭环引脚这块USART3 默认映射在 PB10TX和 PB11RX上重映射后才到 PD8/PD9。多数小板子用默认映射就够了PB10 配复用推挽输出PB11 配浮空输入或上拉输入。这里有个细节——PB11 配浮空输入时如果外部没有上拉电阻线上悬空期间会收到一堆 0x00 的乱码尤其是在对端还没上电的时候。我一般直接配 GPIO_Mode_IPU上拉输入能省掉一批“上电瞬间收到脏数据”的投诉。/* 时钟、GPIO、USART3 一次性配好注意三条时钟线 */ static void USART3_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE); /* USART3 在 APB1 */ RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); /* DMA1 在 AHB */ /* 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_IPU; GPIO_Init(GPIOB, GPIO_InitStructure); } static void USART3_Mode_Config(uint32_t baud) { USART_InitTypeDef USART_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); USART_ITConfig(USART3, USART_IT_IDLE, ENABLE); /* 接收侧只开 IDLE */ USART_DMACmd(USART3, USART_DMAReq_Tx | USART_DMAReq_Rx, ENABLE); /* 关键一步 */ USART_Cmd(USART3, ENABLE); }这段里最容易漏的是 USART_DMACmd 那一行。它对应 CR3 寄存器里的 DMAT 和 DMAR 两个位作用是告诉 USART“你的 DR 有数据了别去找 CPU去找 DMA”。这一位不开DMA 通道配得再完美也是干瞪眼——因为 DMA 请求根本没发出来。我统计过自己踩过的 USARTDMA 类问题这一条占了三成以上。还有一点接收侧我只开了 IDLE 中断没开 RXNE 中断。因为 RXNE 一旦开了DMA 每搬一个字节硬件都会把 RXNE 置起来如果此时 CPU 进中断读 DR就会和 DMA 抢同一个字节丢数据丢得莫名其妙。要用 DMA 收就别开 RXNE 中断。2.3 工程骨架把 DMA 收发切成三层文件顺手说一下文件组织这决定了后期改起来是五分钟还是五小时。我的做法是三层usart3_dma.c 只放硬件相关GPIO、USART、DMA 通道、NVICbsp_uart.c 放缓冲区和收发接口proto_parse.c 放协议状态机。三层之间只通过三个函数交互接口方向说明USART3_DMA_Send(buf, len)上层调下层非阻塞缓冲区忙就返回失败on_byte_stream(buf, len)下层调上层IDLE 中断里把本帧原始字节交出去proto_feed(p, n)解析层逐字节喂状态机产出完整帧这么切的好处是换波特率、换 DMA 通道、换协议改动都局限在一层里。我见过太多工程把 DMA 配置、协议解析、业务逻辑全塞进 main.c半年后自己都不敢动。顺便把 NVIC 也配好优先级数字后面第 5 章还会用到static void NVIC_Config(void) { NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); /* 2 位抢占 2 位子优先级 */ 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); NVIC_InitStructure.NVIC_IRQChannel DMA1_Channel2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_Init(NVIC_InitStructure); }USART3_IRQn 和 DMA1_Channel2_IRQn 在 NVIC 里是两条独立的线抢占优先级给成一样都是 1子优先级错开保证 DMA 发送完成中断不会打断 IDLE 中断里正在跑的取帧逻辑。3. 发送端DMA 搬运的完整写法与三个必踩的参数发送端比接收端简单但“简单”不等于“不会错”。我调试过的发送侧问题基本都集中在两个地方缓冲区被提前复用和 TC 判据用错。这两个都属于那种现象很玄学、原因很朴素的类型。3.1 发送缓冲区为什么必须是静态的、有上限的DMA 是异步的。你调用完发送函数返回了DMA 还在后台一个字节一个字节往 USART3-DR 里灌。如果这时你把传进来的指针指向的栈变量释放了或者往同一个 buffer 里写了新内容DMA 搬出去的就会是半截旧数据混半截新数据——这种错误在逻辑分析仪上看起来像是“数据被随机篡改”非常难查。所以我的规矩是DMA 发送缓冲区一律用文件作用域的静态数组调用方传进来的数据先 memcpy 进去再启动 DMA。代价是一次拷贝115200 波特率下拷 64 字节大概 1 微秒都不到完全可以接受。缓冲区大小怎么定按最坏情况算。115200 波特率、8N1理论字节率约 11520 B/s如果主循环最长可能 20 ms 才喂一次数据那这段时间最多要发出去 230 字节缓冲区至少给 256。我一般直接给 256 或 512几十字节的 RAM 换一份安心。波特率理论字节率20 ms 内最大字节数建议发送缓冲9600~960 B/s~1964115200~11520 B/s~230256460800~46080 B/s~92210243.2 DMA1_Channel2 逐项配置与 CNDTR 的改写时机先看配置。这里用的是正常模式Normal不是循环模式因为发送是“来一笔发一笔”不存在自动重来。#define USART3_TX_BUF_SIZE 256 static uint8_t s_tx_buf[USART3_TX_BUF_SIZE]; static volatile uint8_t s_tx_busy 0; /* 1 DMA 正在搬别动缓冲区 */ static void USART3_TX_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel2); /* 先复位清掉可能的残留配置 */ DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART3-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)s_tx_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; /* 内存 - 外设 */ DMA_InitStructure.DMA_BufferSize 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_ITConfig(DMA1_Channel2, DMA_IT_TC, ENABLE); /* 只开传输完成中断 */ /* 通道先不使能等第一笔数据来了再开 */ }逐项说明几个关键参数。DMA_PeripheralBaseAddr 填的是 USART3-DR注意是 DR 的地址而不是 SR 的写错到 SR 上会变成往外设状态寄存器写数据结果就是什么都不发。DMA_DIR 填 DMA_DIR_PeripheralDST 表示方向是内存到外设接收端正好相反。DMA_PeripheralInc 必须是 Disable因为 USART3 只有一个 DR 寄存器地址不能递增DMA_MemoryInc 必须是 Enable否则永远只搬第一个字节。两个 DataSize 都填 Byte因为 USART 配置的是 8 位字长填 HalfWord 会按 16 位搬发出去的数据全是错位的。然后是最容易被写错的地方——CNDTR 的改写。标准库没有为 CNDTR 提供写接口只能直接操作寄存器而写 CNDTR 的前提是通道已经关闭uint8_t USART3_DMA_Send(const uint8_t *data, uint16_t len) { if (len 0 || len USART3_TX_BUF_SIZE) return 1; if (s_tx_busy) return 2; /* 上一笔没发完直接拒绝 */ memcpy(s_tx_buf, data, len); s_tx_busy 1; DMA_Cmd(DMA1_Channel2, DISABLE); /* 改 CNDTR 前必须关通道 */ DMA1_Channel2-CNDTR len; /* 传输个数 本帧字节数 */ DMA1_Channel2-CMAR (uint32_t)s_tx_buf; /* 内存地址也重设一遍防止被改过 */ DMA_ClearFlag(DMA1_FLAG_TC2 | DMA1_FLAG_GL2); DMA_Cmd(DMA1_Channel2, ENABLE); return 0; }在通道使能状态下改 CNDTRDMA 的行为是未定义的——我实测过改动有时生效有时不生效取决于当前 DMA 是否正好在搬运周期里。这种“偶发”最耗时间所以干脆养成习惯先 DISABLE改完再 ENABLE。清标志那一行也不能省TC2 是上一笔留下的不清掉的话使能后 DMA 会立刻进一次完成中断你会看到发送函数刚返回中断就来了非常困惑。3.3 TC 中断里收尾DMA 的 TC 不等于数据已出线这是发送侧最大的一个坑也是我用血泪经验换来的DMA 的传输完成TC只代表最后一字节已经从内存写进了 USART3-DR不代表这个字节已经从 TX 引脚移出去了。USART 内部还有一个移位寄存器最后一字节可能要在里面待上整整一个字符时间115200 下约 87 微秒。这个差别在纯数据发送时看不出来但在两种场景下会立刻翻车一是发完就切 RS485 的 DE 方向脚最后半位被截断二是发完立刻进低功耗或者复位最后一个字节直接丢。/* DMA 传输完成中断只表示内存搬完了 */ void DMA1_Channel2_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC2) ! RESET) { DMA_ClearITPendingBit(DMA1_IT_TC2); DMA_Cmd(DMA1_Channel2, DISABLE); s_tx_busy 0; /* 缓冲区可以复用了但线上可能还有 1 字节在移 */ } } /* 真正要确认出线时补这一句 */ void USART3_Wait_Last_Byte(void) { while (USART_GetFlagStatus(USART3, USART_FLAG_TC) RESET); }更彻底的做法是如果是 RS485 半双工把方向切换放到 USART 的 TC 中断里做而不是 DMA 的 TC 中断里。具体就是 USART_ITConfig(USART3, USART_IT_TC, ENABLE)在 USART3_IRQHandler 里判断 USART_IT_TC 再拉低 DE然后关掉这个中断。多的几行代码换掉一整类“最后一字节丢失”的问题。3.4 printf 重定向到 DMA按行攒、按块发调试期最方便的事就是把 printf 接到 DMA 上这样打印日志几乎不占 CPU。但写法有讲究。最朴素的重定向是每来一个字符就启动一次 DMA/* 反面教材每个字节一次 DMA开销比直接轮询还大 */ int fputc_bad(int ch, FILE *f) { uint8_t c (uint8_t)ch; USART3_DMA_Send(c, 1); while (s_tx_busy); return ch; }这个写法有两个问题一是每字节一次 DMA 配置寄存器操作开销远超收益二是如果 printf 里有 %s 打印长字符串中间任何一次 s_tx_busy 拒绝都会导致日志缺字符。正确姿势是按行攒int fputc(int ch, FILE *f) { static uint8_t line[128]; static uint16_t n 0; if (ch \n || n sizeof(line)) { USART3_DMA_Send(line, n); /* 一次 DMA 推一整行 */ while (s_tx_busy) { } /* 调试版阻塞等待保证日志不乱序 */ n 0; } if (ch ! \n) line[n] (uint8_t)ch; return ch; }注意这里面的 while (s_tx_busy) 是调试期专用的阻塞等待。生产代码不能这么写因为一旦串口被对端流控卡住整个主循环就挂死在这里。产品里我一般换成一个 256 字节的发送环形队列加一个后台搬运任务printf 只负责入队队列满就丢弃并计数——丢日志总比卡死强。4. 接收端IDLE 中断配 DMA 剩余计数切出不定长帧接收端是这套方案真正出彩的地方。传统做法是每收一个字节进一次中断115200 下大约每 87 微秒进一次主循环稍微干点重活就跟不上。用 DMA 之后中断次数从“每字节一次”降到“每帧一次”这个量级差异在带 RTOS 或者跑 LCD 刷新的板子上是能立刻感觉到的。4.1 循环模式下的写指针反推与回绕处理接收端我用的是循环模式Circular这是关键选型。循环模式下 DMA 搬满缓冲区后会自动从头覆盖不需要在中断里手动重启 DMA——省掉了“重启瞬间来的字节丢失”这类经典问题。写指针的推算方式DMA 的 CNDTR 寄存器里存的是“还剩多少个字节没搬”所以已经搬了多少 缓冲区长度 - 剩余数。#define USART3_RX_BUF_SIZE 512 static uint8_t s_rx_buf[USART3_RX_BUF_SIZE]; static uint16_t s_rx_last 0; /* 上一次已经交给上层的写指针位置 */ static 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)s_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); }缓冲区长度怎么定原则是“最坏情况下两帧之间上层没来得及处理也不能被覆盖”。115200 下 512 字节相当于 44 毫秒的缓冲只要上层取帧逻辑不是特别重就够用。如果协议里单帧最长能到 300 字节那就得给到 1024 以上留出至少两帧的余量。4.2 IDLE 中断里清标志的正确顺序IDLE 中断的触发条件是RX 线上在收完一个字节后又保持空闲超过一个字符时间。这正好是“一帧结束”的天然标记用来切不定长帧再合适不过。但清标志的顺序有讲究。IDLE 标志的清除方式是“先读 SR再读 DR”这两步缺一不可而且必须先 SR 后 DR。用标准库的 USART_GetFlagStatus USART_ReceiveData 也是这个序列。我下面直接操作寄存器看得更清楚volatile uint32_t g_ore_cnt 0; void USART3_IRQHandler(void) { /* 一次读走 SRIDLE / ORE / RXNE 的状态都在这一下里定格 */ uint16_t sr USART3-SR; uint16_t dr USART3-DR; /* 读 DR 完成清标志序列同时吃掉可能的残留字节 */ (void)dr; if (sr USART_FLAG_ORE) { /* 溢出必须清否则后续接收全废 */ g_ore_cnt; } if (sr USART_FLAG_IDLE) { uint16_t remain DMA_GetCurrDataCounter(DMA1_Channel3); /* 剩余可写数 */ uint16_t pos USART3_RX_BUF_SIZE - remain; /* 当前写指针 */ if (pos ! s_rx_last) { uint16_t len (pos s_rx_last) ? (uint16_t)(pos - s_rx_last) : (uint16_t)(USART3_RX_BUF_SIZE - s_rx_last pos); on_byte_stream(s_rx_last, len); s_rx_last pos; } } }这里 pos 和 s_rx_last 的大小关系必须分两种情况pos 大于 s_rx_last 说明这一帧没有越过缓冲区尾部长度直接相减pos 小于 s_rx_last 说明写指针跨过了 511 回到 0长度要分成“尾段 头段”两截算。这个回绕判断只要漏写症状就是“每隔一段时间丢一帧”而且丢的位置跟缓冲区大小有关非常有规律但如果不盯着 CNDTR 看很难想到是这个原因。4.3 上层取帧环形缓冲、双缓冲还是直接拷贝on_byte_stream 拿到的是“环形缓冲区里从 start 开始、长 len 的一段连续区域”。这里有个选择是直接在中断里解析还是先拷出来再解析我的建议是拷出来。中断里只做 memcpy把数据搬到一个独立的解析缓冲区然后置一个标志位解析在主循环里做。原因是解析状态机里可能有 CRC 计算、可能有 memcpy 到业务结构体、甚至可能触发一次回包这些都太重放在中断里会拉长中断响应时间影响其他中断。static void on_byte_stream(uint16_t start, uint16_t len) { if (start len USART3_RX_BUF_SIZE) { proto_feed(s_rx_buf[start], len); /* 没跨尾一次喂完 */ } else { uint16_t first USART3_RX_BUF_SIZE - start; proto_feed(s_rx_buf[start], first); /* 尾段 */ proto_feed(s_rx_buf[0], len - first); /* 头段 */ } }如果解析确实很轻比如只是把字节塞进另一个队列直接喂也完全可以省一次拷贝。判断标准是中断里执行时间能不能稳定控制在 10 微秒以内。超了就该挪到主循环。4.4 一个能直接跑的帧解析状态机拿到字节流之后需要一个状态机来切帧。我用的这套结构简单、抗干扰遇到任何异常都能自己回到找帧头的状态不会卡死typedef enum { ST_HEAD1 0, ST_HEAD2, ST_LEN, ST_BODY, ST_CRC } parse_state_t; static parse_state_t s_state ST_HEAD1; static uint8_t s_len 0; static uint8_t s_idx 0; static uint8_t s_body[64]; void proto_feed(const uint8_t *p, uint16_t n) { while (n--) { uint8_t b *p; switch (s_state) { case ST_HEAD1: if (b 0x5A) s_state ST_HEAD2; break; case ST_HEAD2: if (b 0xA5) s_state ST_LEN; else if (b ! 0x5A) s_state ST_HEAD1; /* 0x5A 就留在原地重试 */ break; case ST_LEN: if (b 0 || b sizeof(s_body)) { s_state ST_HEAD1; break; } s_len b; s_idx 0; s_state ST_BODY; break; case ST_BODY: s_body[s_idx] b; if (s_idx s_len) s_state ST_CRC; break; case ST_CRC: if (crc8(s_body, s_len) b) proto_dispatch(s_body, s_len); s_state ST_HEAD1; /* 无论对错都回到找头 */ break; } } }三个细节值得说一下。ST_HEAD2 那一步如果收到的是 0x5A不能直接退回 ST_HEAD1因为它可能就是新帧的第一个头字节所以我让它留在原地如果收到既不是 0xA5 也不是 0x5A 的字节才彻底退回。ST_LEN 里对长度做了上下限检查防止一个干扰字节把 s_idx 推到数组外面去——这是嵌入式里最常见的本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?