ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103串口DMA+IDLE中断实现不定长数据高效收发

STM32F103串口DMA+IDLE中断实现不定长数据高效收发

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的USART+DMA+IDLE组合,核心价值在于:CPU几乎不参与数据搬运,只在"一帧收完"这个事件上被唤醒一次;发送侧用DMA把一整块数据丢出去,CPU发完就可以去干别的。适合所有做串口通信的嵌入式开发者,尤其是搞工业设备、传感器采集、上位机通信这类对实时性和稳定性有要求的场景。哪怕你之前只用过中断收发,跟着走一遍也能上手。

2. 三个外设各自的角色与协作逻辑

2.1 USART:数据进出的门户

USART本身没什么好说的,配置波特率、数据位、停止位、校验位,使能收发。但有几个细节决定了后面DMA和IDLE能不能正常工作。

首先是波特率误差。F103的USART波特率由PCLK经过分频得到,公式是:

Tx/Rx Baud = fPCLKx / (16 * USARTDIV)

以USART1为例,它挂在APB2上,默认72MHz。要得到115200,USARTDIV = 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不读SR,ORE可能清不掉。这个坑我在纯中断方案里踩过,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 三者协作的完整数据流

把这三个外设串起来看,一次完整的接收过程是这样的:

  1. 串口收到第一个字节,RXNE置位,同时向DMA发出请求。
  2. DMA控制器响应请求,把DR里的字节搬到内存数组,传输数量减一。
  3. 串口继续收字节,DMA继续搬,CPU全程不参与。
  4. 发送方发完最后一个字节,总线进入空闲状态。
  5. 空闲时间超过一个字节时长,IDLE标志置位,触发IDLE中断。
  6. CPU进入IDLE中断服务函数,计算这一帧收了多少字节(缓冲区大小减去DMA剩余传输数量),处理数据,然后重新配置DMA准备下一帧。

发送侧的逻辑更简单:CPU把要发的数据准备好,配置DMA的源地址为数据数组、目标地址为USART->DR、传输数量为数据长度,使能DMA和串口的TX请求,然后就可以走了。DMA会一个字节一个字节地把数据喂给串口,发完之后触发DMA传输完成中断,在中断里关闭DMA、清标志即可。

3. 从零搭建:CubeMX配置与代码实现

3.1 时钟与串口基础配置

我用的是STM32F103C8T6最小系统板,外部晶振8MHz,PLL倍频到72MHz。CubeMX里时钟树配置如下:HSE选Crystal/Ceramic Resonator,PLL Source选HSE,PLL MUL选9倍频,AHB不分频,APB1分频系数2(36MHz),APB2不分频(72MHz)。这样USART1挂在APB2上,时钟就是72MHz。

USART1的参数:Baud Rate 115200,Word Length 8 Bits,Parity None,Stop Bits 1,Data Direction Receive and Transmit。Over Sampling选16 Samples。这些是标准配置,没什么特别的。

3.2 DMA通道的添加与参数含义

在CubeMX的DMA Settings标签页里,点Add,选USART1_RX,然后配置:

参数接收通道设置发送通道设置
DirectionPeripheral To MemoryMemory To Peripheral
PriorityHighMedium
ModeNormalNormal
Increment 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全局中断:抢占优先级1
  • DMA1 Channel5(接收)中断:抢占优先级2
  • DMA1 Channel4(发送)中断:抢占优先级3
  • SysTick:抢占优先级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处理B,IDLE中断里切换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。

排查过程:查RM0008,IDLE标志的置位条件是"检测到空闲线路"。如果串口引脚悬空或者受到干扰,可能会被误判为空闲状态。

根因:硬件上没有加下拉电阻,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中断唤醒。配置步骤如下:

  1. 在uart_idle_callback里处理完数据后,不要立即重新启动DMA接收,而是设置一个标志。
  2. 主循环里判断标志,处理完数据后调用__WFI()进入Sleep。
  3. 在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. 写在最后的一些个人体会

这套USART+DMA+IDLE的方案,我从F103一直用到F407,中间踩过的坑基本都在这篇文章里了。回过头看,最核心的其实不是代码怎么写,而是理解每个外设的边界在哪里:DMA解决的是"搬运"问题,IDLE解决的是"切帧"问题,USART解决的是"电气层"问题。三者各司其职,配合起来才能发挥最大效果。

如果你刚开始接触这套方案,我的建议是先用CubeMX生成基础代码,把DMA收发跑通,再加IDLE中断。不要一上来就手写寄存器配置,那样调试成本太高。等跑通之后,再回头去看HAL库的源码,理解它到底做了什么,这样进步最快。

另外,串口通信的稳定性很大程度上取决于硬件。RX引脚的上拉电阻、TVS管、共模电感这些,该加就加,别省。软件做得再好,硬件有干扰照样丢数据。我在现场调试的时候,遇到过因为串口线太长导致通信不稳定的情况,后来换成屏蔽双绞线,问题立刻消失。这些经验,只有真正做过项目才能体会到。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进