1. 为什么我最终选了 DMA 加空闲中断这套组合串口收发这件事看起来是嵌入式里最基础的功能但真到项目里坑一点都不少。我最早做 STM32F103 的串口通信时用的是最原始的方式每来一个字节进一次中断在中断里把数据塞进缓冲区再靠一个超时定时器判断一帧结束。这套逻辑跑通不难但一旦波特率上去、数据量变大CPU 基本就被串口中断吃满了主循环里其他任务卡得厉害。后来换成 DMA 收发加空闲中断才算真正把 CPU 解放出来。这套方案的核心价值在于DMA 负责搬运数据CPU 不参与每个字节的读写空闲中断负责判断一帧的边界不用再靠定时器猜帧尾。两者配合既解决了高速率下的 CPU 占用问题又解决了不定长数据的帧识别问题。它适合谁适合所有用 STM32F103 做串口通信、又需要处理不定长数据帧的人比如对接上位机协议、接 Modbus、接各种传感器模组、做数据透传网关。哪怕你之前只用过轮询方式收发跟着这套思路走一遍也能把串口这块彻底吃透。我下面讲的内容全部基于 STM32F103 标准外设库SPL来写不依赖 CubeMX 生成的 HAL 代码。原因很简单F103 这颗芯片在工业现场存量巨大很多老项目、老工程都是 SPL 打底HAL 虽然方便但在一些对时序和资源敏感的场景里SPL 的可控性更好代码量也更小。当然思路是通用的你用 HAL 也能照着改。2. 方案整体设计与关键取舍2.1 三种串口接收方式的对比在动手写代码之前先把可选方案摆出来对比一下这样你才能明白为什么最终落到 DMA 加空闲中断上。方案CPU 占用帧边界识别适合场景主要缺点轮询接收极高靠程序逻辑极低速、调试阻塞主循环几乎不可用单字节中断接收高靠超时定时器中低速、数据量小高波特率下中断频繁易丢数据DMA 加空闲中断极低硬件空闲检测高速、不定长、大数据量需要理解 DMA 与 IDLE 配合轮询方式基本只在写测试代码时用正式项目里没人会拿它跑业务。单字节中断是很多教程的入门写法波特率 9600 时还能接受一旦到 115200 甚至更高每字节约 87 微秒就进一次中断如果主循环里还有别的活丢字节是迟早的事。DMA 加空闲中断则把这两件事都解决了数据搬运交给 DMA帧结束交给串口的 IDLE 检测。2.2 为什么用空闲中断而不是定时器判帧不定长接收的关键是判断“这一帧什么时候结束”。常见做法有两种一种是开一个定时器每收到一个字节就重置计数超过若干毫秒没新数据就认为一帧结束另一种就是用串口自带的 IDLE 空闲中断。定时器方案的问题在于超时阈值很难定。波特率不同、上位机发送间隔不同阈值就得跟着调调不好要么把一帧拆成两帧要么把两帧粘成一帧。而 IDLE 中断是硬件层面的当串口接收线上出现一个字节时间的空闲电平硬件就触发 IDLE 标志。这个“一个字节时间”是跟着波特率自动变化的115200 下大约 87 微秒9600 下大约 1 毫秒完全不用你操心。所以只要一帧数据内部字节是连续的帧与帧之间有哪怕一个字节时间的间隔IDLE 就能准确切出帧边界。注意IDLE 判断的是“总线空闲”如果上位机把两帧数据背靠背连续发出来、中间没有任何间隔那 IDLE 只会触发一次两帧会被当成一帧。这是这套方案唯一的天然限制协议设计时要保证帧间有间隔或者在应用层靠帧头帧尾再切分。2.3 DMA 通道与串口的对应关系STM32F103 的 DMA 有 DMA1 和 DMA2USART1 的收发请求挂在 DMA1 上。具体通道分配是固定的不能随便选USART1_TX 对应 DMA1_Channel4USART1_RX 对应 DMA1_Channel5USART2_TX 对应 DMA1_Channel7USART2_RX 对应 DMA1_Channel6USART3_TX 对应 DMA1_Channel2USART3_RX 对应 DMA1_Channel3这个对应关系在参考手册 RM0008 的 DMA 请求映射表里写得很清楚选错通道DMA根本不会响应。我见过有人把 USART1_RX 配到 Channel4 上调了半天以为是中断没进其实是通道选错了。所以第一步先把这张表记牢或者写代码时直接查手册确认。3. 核心细节拆解与实操要点3.1 DMA 接收的循环模式与缓冲区设计DMA 接收串口数据最常用的配置是循环模式Circular。为什么用循环而不是普通模式因为串口数据是源源不断来的如果用普通模式DMA 搬完设定长度就停了你得在中断里重新启动它中间这段时间数据就丢了。循环模式下DMA 搬完一圈自动回到缓冲区开头继续搬永远不会停省去了反复重启的麻烦。缓冲区大小要结合你的最大帧长来定。比如你的协议单帧最长 256 字节那缓冲区至少给 256最好给 512 留余量。我一般习惯给 512 字节因为 F103C8T6 有 20KB RAM这点开销完全承受得起。缓冲区定义成全局数组注意加volatile或者至少保证编译器不会把它优化掉因为 DMA 是在背后改这块内存的编译器不知情。#define USART1_RX_BUF_SIZE 512 uint8_t USART1_RxBuffer[USART1_RX_BUF_SIZE];这里有个细节DMA 循环模式下你随时可以通过读取DMA_GetCurrDataCounter()知道 DMA 还剩多少字节没搬用缓冲区总长减去剩余计数就是当前已经收到的字节数。这个技巧在 IDLE 中断里判断本帧长度时非常关键。3.2 空闲中断的触发时机与标志清除IDLE 中断的触发条件是串口接收完一个字节后接收线保持空闲高电平达到一个字节时间。注意它是在“接收线空闲”时触发而不是在“收到数据”时触发。所以它的语义是“这一帧发完了线空了”。进 IDLE 中断后第一件事是清除 IDLE 标志。清除方法是先读 SR 寄存器再读 DR 寄存器。标准库提供了USART_GetITStatus()和USART_ReceiveData()调用它们就完成了读 SR 和读 DR 的动作标志自然被清掉。如果你只清标志不读 DR标志可能清不掉中断会反复进。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 读 SR 再读 DR清除 IDLE 标志 USART_ReceiveData(USART1); // 计算本帧长度 uint16_t recv_len USART1_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); // 处理数据... // 处理完后重置 DMA 计数准备下一帧 DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, USART1_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }3.3 为什么处理完要重置 DMA 计数这是很多人第一次写会忽略的点。DMA 循环模式下计数器搬完一圈会自动重载但如果你在 IDLE 中断里读到了本帧长度处理完数据后下一帧的数据会接着上一帧的位置继续往缓冲区后面写而不是从缓冲区开头写。这样缓冲区里的数据位置就乱了你没法用“总长减剩余”这个公式算下一帧长度。所以标准做法是每处理完一帧就把 DMA 关掉、把计数器重置为缓冲区总长、再打开。这样下一帧数据一定从缓冲区开头开始写长度计算永远准确。关掉再打开 DMA 通道这个动作很快微秒级不会丢数据因为此时总线是空闲的本来就没有新数据进来。提示重置 DMA 时一定要先DMA_Cmd(DISABLE)再DMA_SetCurrDataCounter()最后DMA_Cmd(ENABLE)。顺序反了计数器可能设不进去因为 DMA 使能状态下计数器是只读的。3.4 发送方向的 DMA 配置接收讲完了发送这边用 DMA 同样能省 CPU。发送 DMA 一般用普通模式Normal因为发送是主动行为你明确知道要发多少字节发完就停不需要循环。配置时把内存地址指向要发送的缓冲区外设地址指向 USART 的 DR 寄存器传输方向设为内存到外设。发送 DMA 的关键是判断发送完成。有两种方式一是查询 DMA 的传输完成标志 TC二是开 DMA 传输完成中断。如果发送频率不高查询就够了如果发送很频繁建议开中断在中断里置个标志位通知主循环“上一包发完了可以发下一包了”。这里要注意DMA 传输完成只代表数据全部写进了 USART 的发送寄存器不代表最后一个字节已经从 TX 引脚移出去了。如果要确保物理层发完还得等 USART 的 TC传输完成标志。void USART1_SendDMA(uint8_t *buf, uint16_t len) { while (DMA_GetFlagStatus(DMA1_FLAG_TC4) RESET); // 等上一次发完 DMA_Cmd(DMA1_Channel4, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA1_Channel4-CMAR (uint32_t)buf; DMA_Cmd(DMA1_Channel4, ENABLE); }4. 完整实操流程与关键代码实现4.1 工程搭建与基础配置先说工程环境。我用的是 Keil MDK芯片选 STM32F103C8T6标准外设库版本 3.5.0。新建工程后把 SPL 的启动文件、stm32f10x.h、system_stm32f10x.c、以及用到的外设驱动文件stm32f10x_gpio.c、stm32f10x_usart.c、stm32f10x_dma.c、stm32f10x_rcc.c加进来。时钟配置成 72MHz这是 F103 的常用主频。GPIO 这边USART1 默认引脚是 PA9TX和 PA10RX。PA9 配成复用推挽输出PA10 配成浮空输入或上拉输入。我一般用上拉输入防止悬空时收到乱码。void USART1_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE); // TX PA9 GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // RX PA10 GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); }4.2 USART 参数配置与 IDLE 中断使能USART 配置里波特率设 1152008 位数据位1 位停止位无校验无硬件流控。这些是常规配置按你的实际协议改。关键是使能 IDLE 中断用USART_ITConfig(USART1, USART_IT_IDLE, ENABLE)。同时别忘了在 NVIC 里使能 USART1 的中断通道。void USART1_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(USART1, USART_InitStructure); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); USART_Cmd(USART1, ENABLE); }NVIC 配置里USART1 中断优先级别设太低否则高波特率下可能来不及处理。我一般给抢占优先级 1、子优先级 1比普通定时器高一点。4.3 DMA 接收通道初始化DMA 接收通道初始化有几个参数要仔细设。外设地址是USART1-DR内存地址是接收缓冲区方向外设到内存缓冲区大小按字节内存地址递增外设地址不递增模式选循环优先级中等。void USART1_Rx_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)USART1_RxBuffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize USART1_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_Medium; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); }注意最后一行USART_DMACmd这是把 USART 的 DMA 接收请求打开。很多人 DMA 通道配好了但忘了开 USART 的 DMA 请求结果 DMA 一直不动查半天查不出问题。这个使能位在 USART_CR3 寄存器里标准库封装成了这个函数。4.4 发送 DMA 通道初始化发送通道用普通模式方向内存到外设其他参数类似。初始化时先不使能等真正要发数据时再启动。void USART1_Tx_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel4); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)0; // 发送时再设 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_Channel4, DMA_InitStructure); USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); }4.5 中断服务函数与数据帧处理中断服务函数是整个方案的中枢。它只做三件事清标志、算长度、把数据拷出来交给应用层处理然后重置 DMA。数据处理本身不要在中断里做太重拷到一个队列或者置个标志让主循环处理都行。void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { USART_ReceiveData(USART1); // 清 IDLE 标志 uint16_t recv_len USART1_RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); if (recv_len 0) { // 把数据拷到应用层缓冲区或者直接置标志 memcpy(App_RxBuffer, USART1_RxBuffer, recv_len); App_RxLen recv_len; App_RxFlag 1; } // 重置 DMA准备下一帧 DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, USART1_RX_BUF_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }主循环里检测App_RxFlag为 1 就处理数据处理完清零。这样中断里只做搬运业务逻辑在主循环职责清晰。4.6 printf 重定向到串口调试时经常要用 printf 打印信息。把 printf 重定向到 USART1需要实现fputc函数。如果发送也走 DMA那fputc里就不能直接等 DMA否则每打印一个字符都启动一次 DMA效率极低。我的做法是printf 走阻塞发送直接查 TXE 标志业务数据走 DMA 发送。两者分开互不影响。int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }用这个函数前记得在 Keil 里勾选 Use MicroLIB否则 printf 可能不工作。5. 实测数据与性能对比5.1 CPU 占用对比测试我在 72MHz 主频、115200 波特率下做了个简单测试让上位机连续发送 1000 帧、每帧 128 字节的数据对比单字节中断和 DMA 加空闲中断两种方式的 CPU 占用。接收方式主循环空转计数相对 CPU 占用单字节中断约 12000高DMA 加空闲中断约 98000极低主循环空转计数是指在主循环里累加一个变量数值越大说明 CPU 越空闲。可以看到 DMA 方式下主循环空转次数是单字节中断的 8 倍左右CPU 基本没被串口占用。这个差距在波特率更高、数据量更大时会更明显。5.2 不同波特率下的稳定性我分别在 9600、57600、115200、256000 四档波特率下各跑了 10 万帧数据DMA 加空闲中断方式没有出现丢帧或粘帧。256000 时单字节中断方式已经开始丢数据DMA 方式依然稳定。这说明这套方案在高波特率下优势明显。提示波特率越高IDLE 中断的间隔越短中断服务函数要尽量精简。如果中断里做了耗时操作可能影响下一帧的接收。我的建议是中断里只做拷贝和置标志重活留给主循环。5.3 缓冲区大小的实测影响缓冲区给 128、256、512 分别测过。128 时如果单帧超过 128 字节就会绕回覆盖导致数据错乱。256 能覆盖大多数协议帧。512 最稳妥RAM 开销也不大。如果你的协议单帧可能超过 512那就得加大缓冲区或者改用双缓冲方案。6. 常见问题与排查技巧实录6.1 常见问题速查表现象可能原因排查方法完全收不到数据DMA 通道选错查 RM0008 请求映射表收不到数据忘了开 USART DMA 请求检查 USART_DMACmd 是否调用只收到第一帧IDLE 标志没清确认中断里读了 DR帧长度算错DMA 计数没重置检查是否重置 CurrDataCounter数据错位缓冲区太小绕回加大缓冲区或改双缓冲发送卡死等 TC 标志死循环检查 DMA 是否真的启动printf 无输出没勾 MicroLIBKeil 选项里勾选6.2 我踩过的几个坑第一个坑是 DMA 通道选错。前面说过USART1_RX 必须用 DMA1_Channel5我一开始想当然用了 Channel4结果 DMA 死活不动查了两小时才发现。所以通道对应关系一定要查手册确认别凭记忆。第二个坑是 IDLE 标志清除不彻底。标准库的USART_GetITStatus会读 SR但如果你没读 DR标志可能清不掉。我后来养成习惯进 IDLE 中断第一件事就是USART_ReceiveData(USART1)把 DR 读掉标志必清。第三个坑是 DMA 计数重置顺序。有次我先DMA_SetCurrDataCounter再DMA_Cmd(DISABLE)结果计数器设不进去下一帧长度全乱。正确顺序是先关通道、再设计数、再开通道这个顺序不能反。第四个坑是发送 DMA 和 printf 抢串口。我一开始 printf 也走 DMA结果 printf 和业务发送同时启动 DMA数据混在一起。后来把 printf 改成阻塞发送业务走 DMA问题解决。如果你的项目里 printf 用得少也可以直接关掉 printf用自定义的调试输出函数。6.3 双缓冲方案的扩展思路如果单帧数据可能超过缓冲区大小或者你想进一步降低中断里的拷贝开销可以考虑双缓冲。思路是准备两块缓冲区DMA 在写 A 的时候CPU 处理 BDMA 写满 A 触发中断切换到 BCPU 处理 A。这样 DMA 和 CPU 并行工作效率更高。不过双缓冲实现复杂一些F103 的 DMA 本身不支持自动双缓冲需要靠中断手动切换。对于大多数应用单缓冲加 IDLE 中断已经够用双缓冲属于进阶优化按需选择。6.4 关于 5V 转 3.3V 的硬件提醒F103 的 IO 是 3.3V 电平如果上位机或外设是 5V 电平直接接会损坏芯片。常见做法是用电平转换芯片或者用电阻分压仅限低速。RX 引脚接收 5V 信号时可以串一个 1K 电阻再并一个 3.3V 稳压管做钳位但这不是长久之计正规做法还是用电平转换模块。TX 引脚输出 3.3V一般 5V 器件能识别为高电平问题不大。硬件这块别省烧了芯片得不偿失。7. 几个容易被忽略的细节7.1 中断优先级与实时性USART1 的 IDLE 中断优先级要设得合理。如果系统里还有别的中断比如定时器中断、SPI 中断要保证 USART1 的优先级不会导致 IDLE 中断被长时间阻塞。我的经验是给 USART1 抢占优先级 1比普通定时器高比系统滴答定时器低。这样既保证串口及时响应又不影响系统时基。7.2 DMA 与 Cache 一致性问题F103 是 Cortex-M3 内核没有 D-Cache所以不存在 Cache 一致性问题。但如果你以后换到 M7 内核的芯片比如 F7、H7DMA 和 CPU 之间的 Cache 一致性就必须处理否则会读到旧数据。F103 上不用操心这个但知道这个差异对以后升级有好处。7.3 低功耗场景下的考虑如果项目有低功耗需求串口空闲时可以考虑关闭 DMA 和 USART 时钟收到唤醒信号再打开。不过 F103 的低功耗模式本身功耗就不算特别低如果对功耗要求苛刻可能得换 L 系列。这块按项目实际需求来不是所有场景都要做。7.4 代码可移植性设计我把串口 DMA 收发封装成了一个结构体加一组函数换串口时只改结构体里的通道号和寄存器地址业务代码不用动。这样从 USART1 换到 USART2 或者 USART3改几行配置就行。封装思路是把 DMA 通道、USART 外设、缓冲区指针、缓冲区大小都放进结构体初始化函数接收这个结构体中断服务函数里根据标志判断是哪个串口。这样代码复用性高维护也方便。typedef struct { USART_TypeDef *USARTx; DMA_Channel_TypeDef *DMA_RxChannel; DMA_Channel_TypeDef *DMA_TxChannel; uint8_t *RxBuffer; uint16_t RxBufferSize; volatile uint16_t RxLen; volatile uint8_t RxFlag; } UART_DMA_Handle;这套封装我在几个项目里都用过换串口时确实省事。如果你项目里串口多建议也这么封装一下。7.5 调试时的实用技巧调试串口 DMA 时我习惯先用示波器或者逻辑分析仪看 TX 引脚有没有波形确认硬件通路没问题。然后用调试器看 DMA 的 CNDTR 寄存器看计数有没有在变判断 DMA 有没有在工作。再看 USART 的 SR 寄存器看 IDLE 标志有没有置起来。这三步基本能定位大部分问题。软件层面可以在 IDLE 中断里翻转一个 GPIO用示波器看中断有没有进、进的频率对不对非常直观。8. 写在最后的一点个人体会这套 DMA 加空闲中断的方案我从最早的 F103 项目一直用到后面几款芯片思路基本没变过只是寄存器名字和库函数换了。它的价值不在于代码多复杂而在于把 CPU 从串口搬运这种重复劳动里解放出来让主循环能专心处理业务逻辑。我见过太多项目因为串口中断占用太高导致主循环卡顿、其他任务响应不及时最后不得不重构。如果一开始就用 DMA 加空闲中断这些麻烦都能避免。最后分享一个小技巧如果你的协议帧有固定帧头帧尾可以在 IDLE 中断拿到数据后先在应用层做一次帧头帧尾校验把粘帧的情况再切一次。这样即使上位机发送间隔不够也能靠协议层兜底。硬件加软件两层保障这套串口收发就真的稳了。