
1. 为什么我最终放弃了纯中断收发方案刚接触STM32F103的串口时几乎所有人都是从接收中断发送轮询这套组合拳起步的。我也不例外。当时做一个Modbus RTU的从机设备波特率115200主机每50ms轮询一次每次请求8字节响应大概20~30字节。用接收中断逐字节存进缓冲区主循环里判断帧间隔来切帧——这套逻辑在实验室跑了一整天都没出问题结果到了现场只要旁边那台变频器一启动串口就开始丢包偶尔还会把两帧粘在一起解析出乱七八糟的数据。后来我把逻辑分析仪挂上去看波形才明白115200波特率下一个字节的传输时间大约是86.8微秒。接收中断从触发到进ISR、保存现场、执行代码、恢复现场在72MHz的F103上大概要花1~2微秒单看一个字节完全来得及。但问题在于当系统里还有定时器中断、ADC中断在抢优先级的时候串口中断被延迟个十几微秒是常有的事。一旦延迟超过下一个字节的到来时间ORE溢出错误标志就置位了数据直接丢。更麻烦的是如果我没及时清ORE后续接收会一直卡死。那段时间我试过提高串口中断优先级、试过把主频超到128MHz、试过在ISR里只做最少的搬运工作效果都不理想。直到我把DMA搬进来才真正把CPU从一个字节一个字节地伺候串口这件事里解放出来。再配合IDLE空闲中断来判断一帧的结束不定长接收这个困扰我很久的问题才算彻底解决。这篇文章就把我这套方案的完整实现过程拆开讲清楚。STM32F103的USARTDMAIDLE组合核心价值在于CPU几乎不参与数据搬运只在一帧收完这个事件上被唤醒一次发送侧用DMA把一整块数据丢出去CPU发完就可以去干别的。适合所有做串口通信的嵌入式开发者尤其是搞工业设备、传感器采集、上位机通信这类对实时性和稳定性有要求的场景。哪怕你之前只用过中断收发跟着走一遍也能上手。2. 三个外设各自的角色与协作逻辑2.1 USART数据进出的门户USART本身没什么好说的配置波特率、数据位、停止位、校验位使能收发。但有几个细节决定了后面DMA和IDLE能不能正常工作。首先是波特率误差。F103的USART波特率由PCLK经过分频得到公式是Tx/Rx Baud fPCLKx / (16 * USARTDIV)以USART1为例它挂在APB2上默认72MHz。要得到115200USARTDIV 72000000 / (16 * 115200) 39.0625。整数部分是39小数部分0.0625对应的是DIV_Fraction寄存器16 * 0.0625 1所以BRR寄存器应该写0x271。实际误差算下来是0.06%左右远小于3%的容限没问题。但如果你用的是9600这种低波特率或者PCLK不是标准值一定要用参考手册RM0008里的公式重新算一遍别直接抄别人的BRR值。其次是状态标志的清除顺序。USART的ORE、NE、FE、PE这几个错误标志清除方式很讲究。RM0008里明确写了先读SR再读DR才能清除这些标志。如果你只读DR不读SRORE可能清不掉。这个坑我在纯中断方案里踩过DMA方案里虽然CPU不直接读DR但IDLE中断处理时如果不清干净同样会出问题。2.2 DMA让数据自己走DMA在F103上有两个控制器DMA1有7个通道DMA2有5个通道。USART1的TX和RX分别对应DMA1的Channel4和Channel5这个映射关系是硬件固定的不能随便改。USART2和USART3的映射又不一样具体要查参考手册的DMA请求映射表。DMA搬运串口数据本质上是把外设数据寄存器和内存缓冲区之间建立一条自动通道。以接收为例配置好源地址USART1-DR、目标地址自定义数组、传输方向外设到内存、数据宽度8位、传输数量缓冲区大小之后每来一个字节DMA控制器就自动把它从DR搬到数组里同时把剩余传输数量减一。整个过程不需要CPU插手。这里有个关键点DMA的传输完成中断和串口的IDLE中断是两回事。DMA传输完成中断只在搬够了指定数量的字节时触发如果你设的缓冲区是256字节但对方只发了20字节DMA传输完成中断永远不会来。这就是为什么必须配合IDLE中断——IDLE中断在串口总线空闲时触发不管收了多少字节只要总线静下来了它就告诉你这一帧结束了。2.3 IDLE空闲中断判断帧结束的利器IDLE中断的触发条件是串口在接收完一个字节后总线保持空闲状态超过一个字节的传输时间。换句话说如果两个字节之间的间隔超过了一个字节的时长IDLE就会置位。这个特性正好用来做不定长帧的边界判断。比如Modbus RTU规定帧间隔至少3.5个字符时间而IDLE检测的是1个字符时间所以IDLE触发时这一帧肯定已经结束了。对于自定义协议只要发送方在帧与帧之间留出足够的间隔IDLE就能准确切帧。IDLE中断的清除也有讲究。它和ORE等错误标志不一样清除方式是先读SR再读DR。在DMA接收模式下DR里的数据已经被DMA搬走了但读DR这个动作仍然必须做否则IDLE标志清不掉中断会反复触发。2.4 三者协作的完整数据流把这三个外设串起来看一次完整的接收过程是这样的串口收到第一个字节RXNE置位同时向DMA发出请求。DMA控制器响应请求把DR里的字节搬到内存数组传输数量减一。串口继续收字节DMA继续搬CPU全程不参与。发送方发完最后一个字节总线进入空闲状态。空闲时间超过一个字节时长IDLE标志置位触发IDLE中断。CPU进入IDLE中断服务函数计算这一帧收了多少字节缓冲区大小减去DMA剩余传输数量处理数据然后重新配置DMA准备下一帧。发送侧的逻辑更简单CPU把要发的数据准备好配置DMA的源地址为数据数组、目标地址为USART-DR、传输数量为数据长度使能DMA和串口的TX请求然后就可以走了。DMA会一个字节一个字节地把数据喂给串口发完之后触发DMA传输完成中断在中断里关闭DMA、清标志即可。3. 从零搭建CubeMX配置与代码实现3.1 时钟与串口基础配置我用的是STM32F103C8T6最小系统板外部晶振8MHzPLL倍频到72MHz。CubeMX里时钟树配置如下HSE选Crystal/Ceramic ResonatorPLL Source选HSEPLL MUL选9倍频AHB不分频APB1分频系数236MHzAPB2不分频72MHz。这样USART1挂在APB2上时钟就是72MHz。USART1的参数Baud Rate 115200Word Length 8 BitsParity NoneStop Bits 1Data Direction Receive and Transmit。Over Sampling选16 Samples。这些是标准配置没什么特别的。3.2 DMA通道的添加与参数含义在CubeMX的DMA Settings标签页里点Add选USART1_RX然后配置参数接收通道设置发送通道设置DirectionPeripheral To MemoryMemory To PeripheralPriorityHighMediumModeNormalNormalIncrement AddressPeripheral不变Memory递增Peripheral不变Memory递增Data WidthPeripheral: Byte, Memory: BytePeripheral: Byte, Memory: Byte这里重点说两个参数。Mode选Normal还是Circular接收通道我建议用Normal因为每次IDLE中断后我都要重新配置DMA用Normal模式逻辑更清晰。如果你用Circular模式DMA会循环覆盖缓冲区虽然不用重新配置但计算帧长度和防止覆盖会很麻烦。发送通道也用Normal发完就停。Priority接收通道给High因为串口数据来得快优先级低了可能被其他DMA请求挤掉。发送通道给Medium就够了发送对实时性要求没那么高。3.3 NVIC中断优先级的分组策略F103的NVIC支持4位优先级CubeMX里可以设置分组。我一般用NVIC_PRIORITYGROUP_4也就是4位全给抢占优先级0位给子优先级。这样有16级抢占优先级足够用了。中断优先级分配如下USART1全局中断抢占优先级1DMA1 Channel5接收中断抢占优先级2DMA1 Channel4发送中断抢占优先级3SysTick抢占优先级15最低为什么USART1的优先级要比DMA高因为IDLE中断是用来切帧的如果它被DMA中断打断可能导致帧边界判断延迟。而DMA接收中断其实我基本不用因为Normal模式下DMA搬够数量才触发但我的缓冲区设得比实际帧大所以这个中断很少来。发送DMA中断优先级最低发完通知一下就行。3.4 接收缓冲区的设计双缓冲与长度计算接收缓冲区我用了两个数组rx_buffer[256]作为DMA的目标缓冲区frame_buffer[256]作为解析用的帧缓冲区。IDLE中断触发后先把rx_buffer里的数据拷贝到frame_buffer记录长度然后重新配置DMA的传输数量为256准备接收下一帧。为什么要拷贝而不是直接解析rx_buffer因为重新配置DMA后rx_buffer马上又会被新数据填充如果解析过程比较慢数据就被覆盖了。拷贝一次虽然多花点时间但256字节的memcpy在72MHz的F103上也就几微秒完全可以接受。长度计算用这个公式frame_len RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx);__HAL_DMA_GET_COUNTER宏读取的是DMA的CNDTR寄存器也就是剩余传输数量。缓冲区大小减去剩余数量就是已经收到的字节数。3.5 发送侧的DMA配置与忙状态管理发送比接收简单但有一个坑DMA发送忙的时候不能再次启动。如果你在上一帧还没发完的时候就调用发送函数DMA会被重新配置导致上一帧数据发到一半就断了。我的做法是加一个tx_busy标志volatile uint8_t tx_busy 0; void uart_send_dma(uint8_t *data, uint16_t len) { if (tx_busy) return; // 上一帧还没发完直接丢弃 tx_busy 1; HAL_UART_Transmit_DMA(huart1, data, len); } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { tx_busy 0; } }实际项目中如果发送频率很高丢弃可能不合适那就得加一个发送队列。但对于大多数应用加个忙标志就够了。4. 代码落地从初始化到中断处理的完整链路4.1 初始化顺序先DMA后串口初始化的顺序很重要。CubeMX生成的代码里MX_DMA_Init()会在MX_USART1_UART_Init()之前调用这是正确的。因为DMA控制器的时钟必须先使能通道配置必须先完成串口初始化时才能正确关联DMA句柄。但CubeMX生成的代码只做了基础配置IDLE中断需要手动使能。在MX_USART1_UART_Init()之后加上__HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);然后在启动DMA接收HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE);这两行代码的顺序不能反。如果先启动DMA接收再使能IDLE中断中间可能会漏掉一个IDLE事件。4.2 IDLE中断服务函数的写法HAL库把USART1的中断统一放在USART1_IRQHandler里最终会调用HAL_UART_IRQHandler。但HAL库默认不处理IDLE中断所以我们需要在USART1_IRQHandler里手动判断void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); uart_idle_callback(); } HAL_UART_IRQHandler(huart1); }__HAL_UART_CLEAR_IDLEFLAG这个宏在HAL库里的实现就是先读SR再读DR符合RM0008的要求。但要注意这个宏在某些HAL版本里可能不存在如果编译报错就手动写volatile uint32_t tmp; tmp huart1.Instance-SR; tmp huart1.Instance-DR; (void)tmp;4.3 帧长度计算与DMA重启的时序uart_idle_callback是核心处理函数void uart_idle_callback(void) { uint16_t remaining __HAL_DMA_GET_COUNTER(hdma_usart1_rx); uint16_t received RX_BUFFER_SIZE - remaining; if (received 0) { memcpy(frame_buffer, rx_buffer, received); frame_len received; frame_ready 1; } // 停止DMA重新配置再启动 HAL_UART_DMAStop(huart1); HAL_UART_Receive_DMA(huart1, rx_buffer, RX_BUFFER_SIZE); }这里有个时序问题HAL_UART_DMAStop会关闭DMA通道和串口的DMA请求然后HAL_UART_Receive_DMA重新配置并启动。这两步之间有一个短暂的空窗期如果此时正好有新数据到来可能会丢。但在实际应用中帧与帧之间至少有3.5个字符的间隔这个空窗期只有几微秒完全来得及。如果你对这点空窗期都不放心可以用双缓冲方案准备两个缓冲区DMA在收A的时候CPU处理BIDLE中断里切换DMA的目标地址到另一个缓冲区。但这样代码复杂度会上升不少大多数场景没必要。4.4 主循环里的帧处理与协议解析主循环里只需要判断frame_ready标志while (1) { if (frame_ready) { frame_ready 0; process_frame(frame_buffer, frame_len); } }process_frame里做协议解析。以Modbus RTU为例先判断地址、功能码、CRC然后执行相应操作最后组织响应数据通过DMA发出去。这里要注意frame_buffer和frame_len在process_frame执行期间不能被修改。如果处理时间比较长而新的一帧又来了uart_idle_callback会覆盖frame_buffer。解决办法是在process_frame开头就把数据拷到局部变量或者加一个processing标志在uart_idle_callback里判断如果正在处理就暂存到另一个缓冲区。5. 实测中暴露的问题与排查过程5.1 第一帧数据总是丢失现象上电后发送的第一帧数据IDLE中断触发了但received计算出来是0。排查过程我在uart_idle_callback里加了打印发现__HAL_DMA_GET_COUNTER返回的值等于RX_BUFFER_SIZE说明DMA根本没搬数据。但IDLE中断确实触发了说明串口收到了数据。根因HAL_UART_Receive_DMA调用之后DMA通道使能需要几个时钟周期。如果在这之前数据就来了DMA还没准备好数据就丢了。而IDLE中断是在数据收完之后才触发的所以中断来了但DMA没搬到数据。解决在HAL_UART_Receive_DMA之后加一个短暂的延时或者更稳妥的做法是在启动DMA接收之前先清一次IDLE标志和ORE标志。我最终的做法是在初始化完成后先读一次SR和DR清标志再启动DMA接收。5.2 高速连续发送时的数据覆盖现象上位机以10ms间隔连续发送100字节的数据包偶尔解析出来的数据长度不对或者内容错位。排查过程用逻辑分析仪抓串口波形发现数据本身没问题。然后在uart_idle_callback里记录每次的received值发现有时候两次IDLE中断之间的间隔非常短第一次的数据还没被主循环处理第二次就来了frame_buffer被覆盖。根因主循环处理一帧的时间超过了帧间隔。100字节在115200波特率下传输时间约8.7ms加上3.5字符间隔约0.3ms总共约9ms。如果主循环里还有其他耗时操作处理时间超过9ms就会覆盖。解决把frame_buffer改成双缓冲用两个数组交替。uart_idle_callback里根据一个标志位决定往哪个数组写主循环处理另一个。这样即使处理慢一点也不会丢帧。5.3 DMA发送完成中断不触发现象调用HAL_UART_Transmit_DMA之后数据确实发出去了但HAL_UART_TxCpltCallback不执行tx_busy一直是1后续发送全部被丢弃。排查过程检查DMA配置发现发送通道的传输完成中断没有使能。CubeMX里默认只使能了DMA通道中断但HAL库的HAL_UART_Transmit_DMA内部会调用HAL_DMA_Start_IT这个函数会自己使能中断。问题出在我手动改过DMA配置把中断关掉了。根因CubeMX生成的MX_DMA_Init里DMA通道的中断使能是在HAL_DMA_Init里做的但如果你在用户代码里重新配置了DMA可能会覆盖这个设置。解决不要在用户代码里手动改DMA的中断使能位让HAL库自己管理。如果确实需要手动配置确保CCR寄存器的TCIE位是1。5.4 空闲中断频繁误触发现象没有数据发送的时候IDLE中断偶尔也会触发received计算出来是0。排查过程查RM0008IDLE标志的置位条件是检测到空闲线路。如果串口引脚悬空或者受到干扰可能会被误判为空闲状态。根因硬件上没有加下拉电阻RX引脚在空闲时电平不稳定。解决在RX引脚上加一个10kΩ的上拉电阻到3.3V确保空闲时电平稳定在高电平。同时在软件里加判断如果received为0直接返回不做任何处理。6. 几个容易被忽略的细节与优化方向6.1 缓冲区大小的权衡缓冲区设多大合适设小了一帧数据超过缓冲区就会溢出设大了浪费RAM。F103C8T6只有20KB RAM不能太奢侈。我的经验是先统计实际协议里最长的帧长度然后乘以2作为缓冲区大小。比如Modbus RTU最长帧是256字节那就设512。但512字节对20KB RAM来说有点多所以实际项目中我一般设256然后在协议层限制单帧不超过200字节。如果确实需要处理长帧可以用Circular模式加半传输中断把缓冲区当成环形队列来用。但这样代码复杂度会明显上升除非必要不建议。6.2 波特率与DMA搬运速度的匹配有人担心DMA搬运速度跟不上串口接收速度。这个担心是多余的。DMA的搬运速度取决于AHB总线时钟F103的AHB是72MHz搬运一个字节只需要一个总线周期约14ns。而115200波特率下一个字节是86.8微秒DMA有足够的时间响应。真正需要注意的是DMA仲裁。如果多个DMA通道同时请求仲裁器会根据优先级决定谁先响应。所以接收通道的优先级要设高一点避免被其他通道阻塞。6.3 低功耗场景下的DMA唤醒如果项目有低功耗需求可以在主循环里进入Sleep模式靠串口IDLE中断唤醒。配置步骤如下在uart_idle_callback里处理完数据后不要立即重新启动DMA接收而是设置一个标志。主循环里判断标志处理完数据后调用__WFI()进入Sleep。在USART1_IRQHandler里IDLE中断触发后先重新启动DMA接收再退出中断。这样CPU大部分时间都在Sleep只有数据到来时才被唤醒功耗可以降到毫安级。6.4 用DMA测速验证搬运效率想验证DMA到底有没有提升效率可以做一个简单的测速实验用定时器产生一个1kHz的方波在方波上升沿翻转一个GPIO。然后分别用中断方式和DMA方式接收同样长度的数据用示波器观察GPIO翻转的抖动。我实测的结果是中断方式下每收一个字节GPIO翻转会延迟2~5微秒抖动明显DMA方式下GPIO翻转几乎不受影响抖动小于1微秒。这直观地说明了DMA把CPU从数据搬运中解放出来了。6.5 从F103迁移到其他系列时的注意事项这套方案在F103上跑通之后如果想迁移到F4或H7系列大部分逻辑是通用的但有几个地方要改DMA控制器不同F4有DMA1和DMA2但通道映射和F103不一样要重新查参考手册。时钟频率不同F4的主频可以到168MHz波特率计算的分频系数要重新算。HAL库版本不同不同系列的HAL库API可能有细微差异比如__HAL_UART_CLEAR_IDLEFLAG宏在某些系列里不存在需要手动实现。IDLE中断的清除方式F4和F103一样都是先读SR再读DR但H7系列可能有所不同要查对应手册。迁移的时候先把串口和DMA的基础配置跑通再逐步加上IDLE中断和帧处理逻辑不要一次性全改完再调试。6.6 调试工具的选择与使用技巧调试串口DMA光靠打印是不够的因为打印本身就会占用串口。我的工具组合是逻辑分析仪抓TX和RX波形看数据内容、帧间隔、波特率是否准确。我用的是8通道24MHz的廉价逻辑分析仪配合开源软件足够用了。Keil的Event Recorder可以在不占用串口的情况下输出调试信息通过SWD接口传输。配置稍微麻烦一点但用起来很爽。DMA的CNDTR寄存器在调试器里实时观察这个寄存器的值可以直观地看到DMA搬了多少字节。还有一个技巧在uart_idle_callback里翻转一个空闲GPIO用示波器测量翻转频率可以大致判断帧的到达速率。如果翻转频率异常高说明IDLE中断误触发了需要检查硬件。7. 写在最后的一些个人体会这套USARTDMAIDLE的方案我从F103一直用到F407中间踩过的坑基本都在这篇文章里了。回过头看最核心的其实不是代码怎么写而是理解每个外设的边界在哪里DMA解决的是搬运问题IDLE解决的是切帧问题USART解决的是电气层问题。三者各司其职配合起来才能发挥最大效果。如果你刚开始接触这套方案我的建议是先用CubeMX生成基础代码把DMA收发跑通再加IDLE中断。不要一上来就手写寄存器配置那样调试成本太高。等跑通之后再回头去看HAL库的源码理解它到底做了什么这样进步最快。另外串口通信的稳定性很大程度上取决于硬件。RX引脚的上拉电阻、TVS管、共模电感这些该加就加别省。软件做得再好硬件有干扰照样丢数据。我在现场调试的时候遇到过因为串口线太长导致通信不稳定的情况后来换成屏蔽双绞线问题立刻消失。这些经验只有真正做过项目才能体会到。