1. 为什么我最终选了 DMA 空闲中断这套组合串口收发这件事看起来是嵌入式里最基础的功能但真正在项目里跑起来坑一点都不少。我最早做 STM32F103 项目的时候串口接收用的是最原始的方式开接收中断每来一个字节进一次中断在中断里把数据塞进缓冲区再靠协议里的帧头帧尾去判断一帧什么时候结束。数据量小的时候没问题一旦对上位机频繁通信、或者接的是那种一次吐几十上百字节的模块CPU 基本就被串口中断吃满了主循环里其他任务的实时性直接崩掉。后来换成 DMA 收发加空闲中断不定长接收这套方案才算真正把 CPU 解放出来。这套组合的核心价值在于DMA 负责搬运数据CPU 不参与每个字节的搬运空闲中断负责判断一帧结束不用再依赖协议里的固定长度或者超时定时器。对于 STM32F103 这种主频 72MHz、SRAM 只有 20KB 的芯片来说这套方案既省 CPU 又省内存属于性价比极高的做法。这篇文章我打算把整套方案从原理到代码完整拆一遍包括 DMA 的配置逻辑、空闲中断为什么能判断帧结束、缓冲区怎么设计才不会溢出、以及我在实际调试中踩过的那些坑。适合已经会点灯、会配串口但还没系统用过 DMA 的兄弟也适合用过 DMA 但被不定长接收折磨过的老手。代码基于标准外设库 SPL 来写因为 F103 这类老项目里 SPL 的存量代码太多了直接给寄存器版本反而不方便移植。先说清楚这套方案解决的核心问题不定长数据的接收。什么叫不定长就是上位机发过来的每一帧长度不固定可能这帧 8 个字节下一帧 30 个字节。传统做法要么用固定长度接收浪费且不灵活要么用字节中断加超时判断占 CPU 且超时时间难调。DMA 空闲中断的思路是让 DMA 一直往缓冲区里搬数据总线空闲时触发一次中断这时候 DMA 已经搬了多少个字节就说明这一帧有多长。逻辑非常干净。2. 先把原理吃透DMA 和空闲中断到底在干什么2.1 DMA 在串口收发里的角色定位DMA 全称是直接存储器访问说白了就是一个独立于 CPU 的搬运工。串口接收时数据从 USART 的接收数据寄存器 DR 进来正常情况下需要 CPU 读走开了 DMA 之后DMA 控制器检测到 USART 的接收事件自动把 DR 里的数据搬到我们指定的内存缓冲区全程不需要 CPU 插手。发送方向同理CPU 把要发的数据放进缓冲区DMA 自动一个字节一个字节地喂给 USART 发送。这里有个关键点很多人一开始会搞混DMA 的传输是硬件触发的不是软件轮询的。USART 每收到一个字节会产生一个 DMA 请求DMA 控制器响应这个请求完成一次搬运。整个过程 CPU 完全不知情除非你开了传输完成中断或者半传输中断。对于 STM32F103USART1 的接收对应 DMA1 通道 5发送对应 DMA1 通道 4USART2 接收是 DMA1 通道 6发送是通道 7USART3 接收是通道 3发送是通道 2。这个对应关系在参考手册 RM0008 的 DMA 请求映射表里写得很清楚配错通道是新手最常见的错误之一现象就是 DMA 死活不工作但代码看起来又没错。2.2 空闲中断为什么能判断一帧结束空闲中断的触发条件是串口总线在一个字节传输时间之内没有收到新数据。换句话说当一帧数据发完总线安静下来超过一个字节的传输时间后硬件就会置位 IDLE 标志如果开了空闲中断就会进中断。这个特性正好用来判断不定长帧的边界。因为一帧数据内部字节是连续到达的字节与字节之间的间隔远小于一个字节的传输时间而帧与帧之间上位机总会有停顿这个停顿通常远大于一个字节时间。所以空闲中断触发的时候基本可以认为一帧结束了。这里要算一下时间。以 115200 波特率为例一个字节是 10 位1 起始位 8 数据位 1 停止位传输时间是 10 / 115200 ≈ 86.8 微秒。也就是说总线空闲超过约 87 微秒空闲中断就会触发。这个时间对于绝大多数上位机来说帧间间隔都远大于它所以判断是可靠的。但如果你的上位机是那种连续高速发送、帧间几乎无间隔的场景空闲中断就可能把两帧粘在一起这种情况需要靠协议层再拆包。2.3 两者配合的完整数据流把 DMA 和空闲中断串起来看整个接收流程是这样的初始化时把 DMA 接收缓冲区的地址告诉 DMA长度设为一个足够大的值比如 128 或 256 字节启动 DMA 接收。数据来了DMA 自动往缓冲区里搬每搬一个字节DMA 的剩余传输计数寄存器 CNDTR 减一。一帧发完总线空闲触发空闲中断。在空闲中断里读取 CNDTR用缓冲区总长度减去 CNDTR就得到这一帧实际收到的字节数。把数据取走处理然后重新设置 CNDTR或者干脆重新启动 DMA准备接收下一帧。这个流程的精髓在于CNDTR 寄存器天然记录了已接收的字节数不需要我们自己维护计数器。这也是为什么这套方案代码量很小逻辑却很稳。3. 手把手配置从 GPIO 到 DMA 的完整代码3.1 硬件连接和 GPIO 配置先确认硬件。STM32F103C8T6 最小系统板上USART1 的引脚是 PA9TX和 PA10RX。如果你用的是 USB 转串口模块注意模块的 TX 接板子的 RX模块的 RX 接板子的 TX别接反了。另外 F103 是 3.3V 电平如果你的串口模块是 5V 的要么选支持 3.3V 的模块要么加电平转换电路直接怼 5V 到 PA10 上时间长了可能损伤引脚。GPIO 配置这块TX 配成复用推挽输出RX 配成浮空输入或者上拉输入。我一般用上拉输入防止悬空时收到乱码。代码用 SPL 写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); }这里有个细节USART1 挂在 APB2 总线上USART2/3 挂在 APB1 上开时钟的时候别开错。DMA1 挂在 AHB 总线上用 RCC_AHBPeriphClockCmd 开时钟。这几个时钟使能漏掉任何一个现象都是外设完全不工作而且不会报错只能靠经验排查。3.2 USART 参数配置与空闲中断使能USART 的配置比较标准波特率、数据位、停止位、校验位、收发模式。关键是最后要手动使能空闲中断因为 SPL 的库函数里没有直接对应的宏需要操作 CR1 寄存器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); // 使能 USART1 接收和发送 USART_Cmd(USART1, ENABLE); }注意 USART_IT_IDLE 这个中断源SPL 里是支持的直接调用 USART_ITConfig 就行。但清除空闲中断标志的方式比较特殊不能直接用 USART_ClearITPendingBit因为 IDLE 标志的清除需要先读 SR 寄存器再读 DR 寄存器。这个坑后面会详细说。3.3 DMA 接收通道配置DMA 接收配置是整套方案的核心。外设地址是 USART1 的 DR 寄存器内存地址是我们的缓冲区方向是外设到内存模式用循环模式还是普通模式需要斟酌。#define RX_BUFFER_SIZE 128 uint8_t rx_buffer[RX_BUFFER_SIZE]; void USART1_DMA_Rx_Config(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); // USART1_RX 对应 DMA1 通道5 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)rx_buffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; // 外设到内存 DMA_InitStructure.DMA_BufferSize RX_BUFFER_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_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel5, DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); // 使能 USART1 的 DMA 接收请求 USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); }这里我选的是普通模式而不是循环模式。原因在于普通模式下DMA 搬完指定长度就停了配合空闲中断每次处理完一帧后重新设置 CNDTR 再启动逻辑清晰缓冲区不会因为循环覆盖导致数据错乱。循环模式虽然省去了重启的步骤但缓冲区满了会自动从头覆盖如果处理不及时旧数据会被新数据冲掉排查起来很头疼。3.4 DMA 发送通道配置发送方向相对简单但有个关键点DMA 发送完成之前不能修改发送缓冲区否则发出去的数据会错乱。我一般用一个独立的发送缓冲区发送前把数据拷进去启动 DMA然后靠发送完成中断或者查询 TC 标志来判断是否可以发下一包。#define TX_BUFFER_SIZE 128 uint8_t tx_buffer[TX_BUFFER_SIZE]; void USART1_DMA_Tx_Config(void) { DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel4); // USART1_TX 对应 DMA1 通道4 DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)USART1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)tx_buffer; 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_Channel4, DMA_InitStructure); // 使能 USART1 的 DMA 发送请求 USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); }发送函数这样写void USART1_DMA_Send(uint8_t *data, uint16_t len) { if (len TX_BUFFER_SIZE) return; // 等待上一次发送完成 while (DMA_GetCmdStatus(DMA1_Channel4) ! DISABLE); memcpy(tx_buffer, data, len); DMA_Cmd(DMA1_Channel4, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA_Cmd(DMA1_Channel4, ENABLE); }4. 空闲中断处理整套方案最关键的十几行代码4.1 中断服务函数的正确写法空闲中断的处理逻辑不复杂但细节特别多写错一个地方就是收不到数据或者数据错位。先看代码void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 清除空闲中断标志先读 SR再读 DR volatile uint32_t temp; temp USART1-SR; temp USART1-DR; (void)temp; // 计算接收到的数据长度 uint16_t rx_len RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5); if (rx_len 0) { // 处理数据比如拷到另一个缓冲区或者直接解析 USART1_Rx_Callback(rx_buffer, rx_len); } // 重新启动 DMA 接收 DMA_Cmd(DMA1_Channel5, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); DMA_Cmd(DMA1_Channel5, ENABLE); } }4.2 清除空闲中断标志的坑这段代码里最容易出错的就是清标志那两行。空闲中断的标志位 IDLE 不能用常规的清除方式必须按照参考手册的要求先读一次 SR 寄存器再读一次 DR 寄存器才能把 IDLE 位清掉。如果你用 USART_ClearITPendingBit(USART1, USART_IT_IDLE)会发现标志根本清不掉中断会一直反复进程序直接卡死。我当初就被这个坑折磨了半天现象是串口一收到数据就死循环在中断里。后来翻 RM0008 才发现这个特殊要求。所以代码里用两个 volatile 变量去读寄存器确保编译器不会把这两次读优化掉。这个细节在 ST 的参考手册里有明确说明但很多教程里一笔带过导致新手反复踩坑。4.3 数据长度计算的边界情况RX_BUFFER_SIZE - DMA_GetCurrDataCounter(DMA1_Channel5)这个计算看起来简单但有个边界情况要注意如果一帧数据的长度正好等于缓冲区大小CNDTR 会减到 0此时 DMA 传输完成中断会触发而空闲中断可能还没来。这种情况下如果只靠空闲中断处理这一帧数据就丢了。解决办法有两个一是把缓冲区设得比最大帧长还大一些留出余量二是同时开启 DMA 传输完成中断在传输完成中断里也做一次数据处理。我一般用第一种简单可靠。比如协议最大帧长是 100 字节缓冲区就设 128 或 256。另外如果 rx_len 算出来是 0说明是干扰导致的空闲中断直接忽略即可不要做任何处理。4.4 中断优先级的安排USART1 的中断优先级要合理设置。如果系统里还有其他实时性要求高的中断比如定时器用于电机控制那串口中断优先级不能太高否则会打断关键任务。我一般把 USART1 中断设成中等优先级抢占优先级 1子优先级 1 左右。DMA 中断如果开了优先级和串口中断设成一样或者略低。这里有个经验空闲中断的处理时间要尽量短。中断里只做数据长度的计算和拷贝复杂的协议解析放到主循环里做。因为空闲中断触发时下一帧数据可能很快就来了如果中断里耗时太长DMA 缓冲区可能被新数据覆盖。5. 实操中踩过的坑和排查经验5.1 常见问题速查表现象可能原因排查方法完全收不到数据DMA 通道配错查 RM0008 确认 USART1_RX 是 DMA1 通道5收到数据但长度不对CNDTR 读取时机不对确保在空闲中断里读且读之前 DMA 没被重启中断反复进入死循环IDLE 标志没清掉用读 SR 读 DR 的方式清除数据错位或丢字节缓冲区被覆盖加大缓冲区缩短中断处理时间发送数据乱码发送缓冲区被提前修改等 DMA 发送完成再改缓冲区波特率高了就出错中断处理太慢优化中断代码或降低波特率5.2 缓冲区设计的经验缓冲区大小怎么定我的经验是取协议最大帧长的 1.5 到 2 倍。比如你的协议规定单帧最大 64 字节那缓冲区设 128 字节比较稳妥。这样即使一帧数据刚好接近最大长度也不会触发 DMA 传输完成而丢失空闲中断。另外接收缓冲区和处理缓冲区最好分开。DMA 往接收缓冲区搬数据空闲中断里把数据拷到处理缓冲区然后重启 DMA。这样即使主循环处理慢DMA 也能继续接收新数据不会因为处理阻塞导致丢包。代价是多占一点内存但对于 F103 的 20KB SRAM 来说多几十字节完全不是问题。5.3 波特率和中断响应的关系波特率越高空闲中断的判断窗口越短。115200 时一个字节约 87 微秒如果波特率提到 921600一个字节只有约 10.8 微秒。这时候如果系统里还有其他高优先级中断空闲中断可能被延迟响应导致两帧数据被合并。实测下来F103 在 72MHz 主频下115200 波特率跑这套方案非常稳921600 也能跑但需要确保没有其他中断长时间占用 CPU。如果非要上更高波特率建议用循环 DMA 加双缓冲的方案不过那就复杂多了一般项目用不上。5.4 printf 重定向到串口的正确姿势很多人喜欢用 printf 调试把 printf 重定向到 USART1。用 DMA 发送的话重定向函数要改一下int fputc(int ch, FILE *f) { USART1_DMA_Send((uint8_t *)ch, 1); return ch; }但这样每调一次 printf 就启动一次 DMA效率很低。更好的做法是用一个格式化缓冲区把整条日志拼好再一次性 DMA 发出去。或者干脆用 SPL 的轮询方式发调试信息DMA 留给正式的数据通信两者分开互不干扰。6. 性能实测与方案对比6.1 三种接收方案的实测对比我在 F103C8T6 上实测了三种接收方案条件都是 115200 波特率上位机连续发送 1000 帧、每帧 32 字节的数据方案CPU 占用丢包率代码复杂度适用场景字节中断 超时判断约 35%0.2%中低速、小数据量DMA 空闲中断约 3%0%中中高速、不定长循环 DMA 双缓冲约 2%0%高高速、连续流DMA 空闲中断这套方案CPU 占用从 35% 降到 3%提升非常明显。这 3% 主要是空闲中断的处理开销和主循环的数据拷贝。如果进一步优化把数据拷贝也省掉直接在 DMA 缓冲区上解析还能再降一点。6.2 DMA 测速的小技巧想验证 DMA 到底有没有在工作、速度怎么样可以做个简单的测速实验让上位机以固定速率发送数据在空闲中断里翻转一个 GPIO用示波器看翻转频率。或者更简单在中断里对一个全局变量累加主循环里每秒打印一次看每秒处理了多少帧。我实测下来115200 波特率下这套方案每秒能稳定处理超过 300 帧、每帧 32 字节的数据CPU 还有大量余量跑其他任务。如果换成 921600 波特率每秒能处理 2000 帧以上但这时候要注意主循环的处理速度要跟上否则处理缓冲区会积压。6.3 什么时候不该用这套方案这套方案虽好但也不是万能的。如果你的应用场景是单字节命令交互比如上位机发一个字节、板子回一个字节那用字节中断反而更简单直接上 DMA 属于杀鸡用牛刀。另外如果协议本身有严格的帧头帧尾和长度字段那用 DMA 空闲中断收到数据后还是要在协议层做校验空闲中断只是帮你判断了物理层的帧边界逻辑层的拆包该做还得做。还有一种情况多串口同时高速通信。F103 只有 DMA1 的 7 个通道如果三个串口同时收发通道会不够用这时候要么用轮询要么换带 DMA2 的型号比如 F4 系列。7. 代码组织与工程移植建议7.1 文件结构怎么分我一般把串口 DMA 相关的代码分成三个文件usart_dma.c放初始化和收发函数usart_dma.h放声明和缓冲区大小宏定义中断服务函数放在stm32f10x_it.c里或者单独一个usart_dma_it.c。这样移植到新工程时把这三个文件拷过去改一下通道号和引脚定义就能用。回调函数用弱定义的方式方便上层重写__weak void USART1_Rx_Callback(uint8_t *data, uint16_t len) { // 默认空实现用户在自己的文件里重写 }这样底层驱动和上层业务解耦换个项目直接复用驱动业务逻辑单独写。7.2 移植到其他 STM32 型号的注意事项从 F103 移植到 F4 或者 G0 系列主要改这几个地方DMA 通道号变了F4 的 USART1_RX 是 DMA2 通道 5时钟使能函数变了F4 用 AHB1空闲中断的清除方式在有些型号上也有差异。但整体思路完全一样DMA 搬数据、空闲中断判帧尾、CNDTR 算长度这套逻辑是通用的。移植的时候建议先点灯确认工程能跑再单独测串口发送最后测 DMA 接收一步一步来别一次性全改完再调出了问题很难定位。7.3 几个提升稳定性的小技巧第一在空闲中断里加一个长度上限判断。如果算出来的 rx_len 超过协议最大帧长说明数据异常直接丢弃并重启 DMA不要往上层传。第二给接收缓冲区加一个帧计数器。每次空闲中断处理完一帧计数器加一。主循环里定期检查计数器如果长时间不变化说明通信断了可以做超时告警。第三DMA 重启之前先关中断。虽然概率很低但如果在重启 DMA 的过程中来了新数据可能导致 CNDTR 设置错乱。稳妥的做法是在重启 DMA 的那几行代码前后关一下全局中断设置完再开。这套方案我在好几个量产项目里用过从工业采集板到消费类设备跑一两年都没出过通信问题。核心就是把这十几行中断代码写对剩下的都是常规配置。真正难的不是代码本身而是理解 DMA 和空闲中断各自在干什么、为什么要这么配合。把原理吃透了换个芯片、换个波特率都是改几个参数的事。