1. 项目缘起与整体方案设计STM32F103 这颗芯片在嵌入式圈子里算是老面孔了C8T6 最小系统板几块钱就能买到资料铺天盖地但真正把 USART 收发做到“稳、快、不丢包”的人其实没想象中多。我见过太多项目里串口就是HAL_UART_Receive轮询或者开个接收中断一个字节一个字节往缓冲区塞波特率一高、数据一长CPU 基本就被串口吃掉了主循环卡得跟幻灯片一样。这个项目的出发点很直接用DMA 搬运数据用USART 空闲中断判定帧结束实现不定长数据的整帧接收同时发送也走 DMA把 CPU 从串口事务里彻底解放出来。先说清楚这套方案解决什么问题。传统接收方式有两个痛点一是定长接收你得提前知道对方发多少字节实际通信里帧长经常变化二是中断频繁115200 波特率下一个字节一次中断一秒上万次中断稍微复杂点的系统根本扛不住。DMA 加空闲中断的组合本质上是让硬件帮你数数、帮你搬数据你只在“一帧结束了”这个时刻被打断一次。这个思路在工业采集、上位机通信、模块透传场景里非常通用适合有一定 STM32 基础、想从“能跑”进阶到“跑得稳”的开发者。方案选型上我用的是SPL 标准外设库不是 HAL。原因很实际F103 这类老芯片用 SPL 代码量小、执行路径短、寄存器操作直观调试时看寄存器一目了然而且网上 F103 的 SPL 例程存量巨大遇到问题好查。HAL 的抽象层在 F1 上反而显得笨重中断回调层层嵌套出问题时排查链路长。当然这不是说 HAL 不好只是这个项目追求的是“轻、快、可控”SPL 更合适。整体数据流是这样的发送时CPU 把数据丢进发送缓冲区启动 DMA 通道DMA 自动把数据从内存搬到 USART 数据寄存器搬完触发发送完成中断接收时DMA 循环模式持续把 USART 收到的字节搬到接收缓冲区同时开启 USART 空闲中断当总线空闲一个字节时间后触发中断此时读取 DMA 剩余计数算出这一帧实际长度交给上层处理。整个过程中 CPU 只在帧头和帧尾介入中间搬运完全不参与。注意DMA 和空闲中断配合的核心在于“DMA 计数器的实时读取”这个值决定了帧长计算是否准确后面会详细讲。2. 核心细节解析与实操要点2.1 为什么是 DMA 加空闲中断而不是纯中断或纯 DMA纯中断接收的问题前面说了中断频率太高。有人会说那用 DMA 定长接收不就行了问题是定长接收要求每帧长度固定实际通信协议里帧长往往由数据内容决定比如 Modbus RTU 靠 3.5 字符间隔判帧自定义协议靠帧头帧尾长度根本不固定。空闲中断恰好补上了这个缺口它检测的是总线空闲状态一帧数据发完总线静默超过一个字节传输时间硬件就置位 IDLE 标志。这个机制天然适配不定长帧。从 CPU 占用角度看假设波特率 115200一帧 100 字节纯中断方式要进 100 次中断每次中断进出栈加处理至少几十个周期总共几千个周期DMA 加空闲中断只进 1 次中断CPU 开销降低两个数量级。数据量越大、波特率越高优势越明显。我实测过 460800 波特率下连续收包纯中断方式丢包率肉眼可见DMA 方式稳如老狗。2.2 DMA 通道选择与循环模式的取舍STM32F103 的 DMA1 有 7 个通道USART1 的 RX 和 TX 分别固定映射到通道 5 和通道 4这个在参考手册 RM0008 的 DMA 请求映射表里写得很清楚不能随便改。USART2 的 RX/TX 是通道 6 和通道 7USART3 是通道 3 和通道 2。选通道时先查表别凭感觉。接收 DMA 我配置成循环模式发送 DMA 配置成普通模式。为什么这么分接收是持续不断的你不知道下一帧什么时候来循环模式下 DMA 搬完一圈自动回到起点继续搬永远不会停配合空闲中断随时可以取数据。发送是事件驱动的发完一帧就结束普通模式搬完指定数量后自动停止并触发完成中断下次发送前重新设置计数即可。如果发送也用循环模式发完会从头再发一遍那就乱套了。这里有个细节接收 DMA 循环模式下缓冲区大小要设得足够大至少能装下两帧数据。因为空闲中断触发到你去读数据之间有时间差如果缓冲区太小新帧可能覆盖旧帧还没读走的数据。我一般设 256 字节对于大多数应用足够了。2.3 空闲中断的触发时机与帧长计算空闲中断的触发条件是USART 接收线上出现一个字节时间的空闲电平。注意是“一个字节时间”不是随便一个空闲就触发。比如 115200 波特率下一个字节 10 位1 起始 8 数据 1 停止时间约 86.8 微秒。总线静默超过这个时间IDLE 标志置位。这意味着如果两帧之间间隔太短小于一个字节时间硬件会认为它们是一帧这是协议设计时要注意的。帧长计算是这套方案的关键。DMA 有个CNDTR 寄存器记录剩余待传输数量。初始化时设为缓冲区大小每搬一个字节减一。假设缓冲区 256 字节当前 CNDTR 读到 200说明已经搬了 56 字节这一帧长度就是 56。但循环模式下有个坑如果一帧数据超过缓冲区大小DMA 会绕回覆盖CNDTR 的值就不能简单用“缓冲区大小减剩余”来算了。所以缓冲区一定要大于最大帧长这是硬约束。提示读取 CNDTR 后要立即处理因为 DMA 可能还在继续搬数据读到的值只是那一瞬间的快照。2.4 SPL 库下的中断服务函数编写要点SPL 的中断服务函数不像 HAL 那样有统一回调需要自己写。USART1 的中断函数是USART1_IRQHandler里面要判断是空闲中断还是其他中断。空闲中断的标志位是USART_FLAG_IDLE清除方式是“先读 SR 再读 DR”这个顺序不能反反了清不掉。很多人在这里踩坑中断反复进程序卡死。DMA 发送完成中断在DMA1_Channel4_IRQHandler里处理清除标志用DMA_ClearITPendingBit。注意 DMA 的传输完成标志和半传输标志是分开的如果开了半传输中断也要处理不然会一直进中断。我一般只开传输完成中断半传输用不上。3. 实操过程与核心环节实现3.1 硬件连接与最小系统检查先确认硬件没问题。STM32F103C8T6 最小系统板USART1 的 TX 是 PA9RX 是 PA10。用 USB 转 TTL 模块连接时模块的 TX 接 PA10RX 接 PA9共地。注意电平匹配F103 是 3.3V 电平如果 USB 转 TTL 模块输出 5V长期用可能损伤 IO最好选支持 3.3V 的模块或者加电平转换。最小系统板的 5V 转 3.3V 电路一般是 AMS1117输入 5V 输出 3.3V如果直接用 ST-Link 供电注意 ST-Link 的 3.3V 输出电流有限带不动太多外设。Keil MDK 工程搭建时记得装 F103 的 Pack 包不然器件选不了。调试用 ST-LinkSWD 模式接线是 SWDIO、SWCLK、GND、3.3V 四根。如果连不上先检查复位电路和晶振F103 外部晶振是 8MHz倍频到 72MHz这个在 system_stm32f10x.c 里配置。3.2 DMA 初始化代码详解先看接收 DMA 的配置。用 SPL 的话结构体是DMA_InitTypeDef关键参数如下DMA_InitTypeDef DMA_InitStructure; DMA_DeInit(DMA1_Channel5); 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_Circular; 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 的数据寄存器内存地址是接收缓冲区方向是外设到内存。外设地址不递增因为 USART 就一个数据寄存器内存地址递增因为要依次存到缓冲区不同位置。数据宽度都是字节。循环模式。优先级设高避免被其他 DMA 请求打断导致数据错位。M2M 是内存到内存这里关掉。发送 DMA 配置类似区别在于方向是内存到外设模式是普通模式通道是 DMA1_Channel4。发送前要重新设置DMA_SetCurrDataCounter因为普通模式发完就停了下次发送得重新装填计数。3.3 USART 初始化与空闲中断开启USART 初始化用 SPL 的USART_Init波特率 1152008 数据位1 停止位无校验无硬件流控。关键是开启 DMA 请求和空闲中断USART_DMACmd(USART1, USART_DMAReq_Tx | USART_DMAReq_Rx, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE);注意USART_ITConfig开的是空闲中断不是接收中断。接收中断这里不需要开因为数据由 DMA 搬CPU 不参与。NVIC 里要配置 USART1 中断和 DMA1_Channel4 中断的优先级USART1 优先级可以设高一点保证空闲中断及时响应。3.4 空闲中断服务函数与帧长计算实现中断服务函数是核心代码如下void USART1_IRQHandler(void) { if(USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { USART_ReceiveData(USART1); // 先读 SR 再读 DR 清标志 uint16_t remaining DMA_GetCurrDataCounter(DMA1_Channel5); uint16_t received_len RX_BUFFER_SIZE - remaining; if(received_len 0) { // 将数据拷贝到处理缓冲区或者直接置标志让主循环处理 memcpy(frame_buffer, rx_buffer, received_len); frame_length received_len; frame_ready 1; } // 重新校准 DMA 计数防止累积误差 DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUFFER_SIZE); } }这里有个关键操作USART_ReceiveData读 DR 是为了清 IDLE 标志读到的值丢弃。然后读 CNDTR 算帧长。算完后我习惯重新设置 DMA 计数虽然循环模式下不设也能跑但设了之后缓冲区起点固定帧长计算更直观。如果不重设下一帧的 CNDTR 基准就变了算出来会错。注意memcpy在中断里执行时间要控制如果帧很长建议只置标志让主循环去拷贝中断里做最少的事。3.5 发送 DMA 的启动与完成处理发送函数这样写void uart_send_dma(uint8_t *data, uint16_t len) { while(DMA_GetCmdStatus(DMA1_Channel4) ! DISABLE); // 等上一次发完 DMA_SetCurrDataCounter(DMA1_Channel4, len); DMA1_Channel4-CMAR (uint32_t)data; DMA_Cmd(DMA1_Channel4, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); }先等上一次 DMA 发完然后重设计数和内存地址使能通道。发送完成中断里清标志置发送完成标志位。注意DMA_GetCmdStatus判断的是通道使能状态普通模式发完后通道自动关闭所以这个等待是有效的。3.6 printf 重定向到 USART 的 DMA 方式很多人习惯用 printf 调试标准库的fputc重定向是阻塞发送一个字节一个字节等效率低。改成 DMA 方式int fputc(int ch, FILE *f) { uart_send_dma((uint8_t *)ch, 1); return ch; }但这样每调一次 printf 就启动一次 DMA开销反而大。更好的做法是攒够一行再发或者用sprintf格式化到缓冲区再整块 DMA 发送。我一般用后者调试信息攒到 128 字节再发效率高很多。4. 常见问题与排查技巧实录4.1 空闲中断不进或反复进这是最常见的问题。不进中断先检查USART_ITConfig有没有开 IDLE 中断NVIC 有没有使能。反复进中断基本是标志没清干净。清除顺序必须是先读 SR 再读 DRSPL 的USART_ReceiveData内部就是读 DR但前面必须有一次读 SR 的操作。我通常直接调USART_GetITStatus读 SR再调USART_ReceiveData读 DR这样最稳。还有一种情况是波特率不匹配收到的数据全是乱码空闲中断可能正常触发但数据不对。用示波器看波形或者先用轮询方式确认波特率正确。4.2 DMA 数据错位或丢包数据错位通常是 DMA 配置问题。检查外设地址和内存地址有没有搞反方向对不对。丢包可能是缓冲区太小一帧没读完就被新数据覆盖。把缓冲区加大或者在中断里尽快拷贝走数据。另外 DMA 优先级如果被其他高优先级通道抢占也可能导致 USART 的 DMA 请求响应不及时把 USART 的 DMA 通道优先级设高。还有一个隐蔽的坑DMA 传输完成中断和 USART 空闲中断同时触发时如果处理不当可能竞争。我的做法是接收只用空闲中断DMA 接收不开发送完成中断避免两个中断源同时操作缓冲区。4.3 帧长计算偏差帧长算出来比实际多 1 或少 1一般是 CNDTR 读取时机问题。空闲中断触发时DMA 可能刚好搬完最后一个字节CNDTR 已经减了也可能还没减。这个临界状态导致偏差。解决办法是在中断里先关 DMA 通道读 CNDTR再开 DMA 通道。但关开之间有窗口期可能丢数据。更稳妥的做法是接受这个偏差在协议层用帧头帧尾校验或者用固定帧长加填充。我实测下来115200 波特率下偏差概率很低偶尔出现一次协议层加个 CRC 校验就能过滤掉。4.4 常见问题速查表现象可能原因排查方法解决措施空闲中断不进IDLE 中断未开或 NVIC 未使能查 USART_CR1 的 IDLEIE 位和 NVIC 寄存器开中断配 NVIC中断反复进IDLE 标志未清查中断函数是否读 SR 再读 DR按顺序读 SR 和 DR数据错位DMA 地址或方向配错查 DMA_CCR 和 CPAR/CMAR外设到内存地址不递增丢包缓冲区太小或优先级低查 CNDTR 和 DMA 优先级加大缓冲区提高优先级帧长偏差CNDTR 读取时机多次测试统计偏差协议层加校验发送卡死上次 DMA 未完成查 DMA_ISR 的 TC 标志等待完成再启动波特率乱码时钟配置错误查 RCC 和 USART_BRR确认 72MHz 和 BRR 值4.5 实操心得与避坑建议第一个心得接收缓冲区用双缓冲。一个缓冲区给 DMA 写一个给主循环读中断里切换指针。这样彻底避免读写冲突代价是多占一倍内存F103C8T6 有 20K RAM完全够用。第二个心得DMA 测速时关掉其他中断。测速时如果其他中断频繁打断测出来的速度不准。我测过 F103 的 DMA 搬运速度内存到内存大概 1 字节每周期外设到内存受限于外设速度USART 115200 下 DMA 占用率极低基本可以忽略。第三个心得调试时先用轮询确认硬件通路。DMA 配置复杂出问题时不好定位。先用最简单的轮询收发确认 USART 硬件和波特率没问题再上 DMA这样排查范围小很多。第四个心得注意 DMA 和 CPU 的总线竞争。F103 的 DMA 和 CPU 共享总线DMA 大量搬运时会抢占 CPU 总线周期导致 CPU 执行变慢。如果系统对实时性要求高DMA 传输量要控制或者用更高主频的芯片。第五个心得空闲中断的超时时间不可调。F103 的空闲中断固定是一个字节时间不能改。如果协议要求更长的帧间隔得用定时器辅助判断。比如 Modbus RTU 的 3.5 字符间隔空闲中断只能判 1 字符剩下的靠定时器补。这套方案我在好几个项目里用过从简单的上位机通信到工业采集模块稳定性没得说。关键是把中断里的操作压到最少数据搬运交给 DMACPU 只做决策不做苦力。代码量不大但每个细节都有讲究配错一个参数就可能跑不起来。建议先把接收调通再加发送最后加协议层一步步来别想着一次全对。