☰
STM32 DMA+空闲中断完美适配FreeModbus从机串口接收
2026/9/28 14:49:59 网站建设 项目流程

做FreeModbus从机移植,最让人头疼的不是协议栈本身,而是串口底层怎么“接得住”数据。CubeMX生成的标准HAL串口中断示例,每来一个字节就进一次中断,115200波特率下约87微秒一个字节,短帧七八个字节就是七八次中断,CPU全耗在进出中断上了。我之前把Modbus主站轮询周期压到10ms以内,从机经常回不过来,加日志一量,中断处理占了将近一半的CPU时间。后来改成DMA自动搬运加空闲中断判定帧结束,不到十行核心代码就把问题解决了。

这一篇把DMA加空闲中断接收的完整思路、CubeMX配置、FreeModbus底层适配和踩坑记录都写出来。内容针对裸机环境,不依赖RTOS,CubeMX加HAL库,芯片以STM32F103为例,换F4、G4也基本通用。已经能跑通标准FreeModbus从机示例、但觉得串口接收太浪费CPU的读者,直接看这篇就行。

1. 为什么DMA加空闲中断几乎是FreeModbus从机的标配

1.1 逐字节中断的CPU开销账本

FreeModbus官方示例里,串口底层用的是逐字节中断加标志位轮询。每收到一个字节,硬件触发USART中断,中断服务程序里把数据存入缓冲区,然后退出中断,主循环再通过eMBPoll慢慢消化。

这个做法功能上没问题,但代价不小。拿115200波特率来算,1个起始位加8个数据位加1个停止位,10个位时间,一个字节约87微秒。Modbus RTU最常用的读保持寄存器请求,从站地址、功能码、起始地址、寄存器数量、CRC加起来正好8个字节,意味着约700微秒内要触发8次USART中断。如果主站轮询周期是10ms,从机在这10ms里光接收就进8次中断,再加上发送应答的8次中断,总共16次。

你说一次中断能有多少开销?HAL库的中断处理函数进去之后要做一堆标志位判断,usart中断里还要判断是不是我们关心的那个串口,加上进入和退出中断的压栈出栈,逻辑分析仪实测一次中断处理至少要2到3微秒。16次中断就是40到50微秒,看起来不多,但这是纯开销,且主循环里eMBPoll还会被频繁打断。更关键的是,如果波特率上到460800甚至921600,中断频率翻四倍、八倍,CPU就真的被拖死了。

1.2 空闲中断帮你识别帧边界

DMA加空闲中断的组合,本质上是把“每个字节都去打扰CPU”变成了“一整帧收完再打扰一次”。

DMA做的事情很简单:串口收到一个字节,硬件自动把它搬到内存缓冲区,CPU完全不用管,搬完也不通知你。那什么时候需要通知CPU呢?要么缓冲区满了,要么串口线路上出现了一段空闲。

串口的空闲中断就是干这个的:发送或接收过程中,总线在一段时间内没有电平变化,硬件置上IDLE标志。Modbus RTU协议规定,帧和帧之间的静默间隔必须大于等于3.5个字符时间,而帧内部字节之间的间隔要小于1.5个字符时间。串口硬件检测到的空闲长度大约是1个字符时间(严格说是15个位时间),这个阈值刚好卡在1.5和3.5之间——帧内字节间隔不会触发空闲中断,帧间间隔一定触发。

所以DMA加空闲中断的方案天然契合Modbus RTU的帧格式:DMA把一帧完整收进缓冲区,空闲中断告诉你“可以处理了”。你只需要在中断回调里拿数据、通知协议栈、重新启动DMA接收,收工。

1.3 这套方案适合谁、什么场景

如果你的项目满足下面任意一条,就很值得换成DMA加空闲:

  • 从机需要被多个主站轮询,或者主站轮询周期短,从机要快速响应
  • 串口波特率在115200以上,比如458800、921600
  • 从机除了跑Modbus,还要做采样、显示、控制算法,CPU不能全耗在串口上
  • 用的是裸机环境,没有RTOS帮你做任务调度,中断多就是真卡

反过来,如果你只是做个小玩具,波特率9600,主站一分钟轮询一次,用官方默认的逐字节中断完全没问题,没必要增加复杂度。方案选型前先把实际场景想清楚。

2. CubeMX配置:串口、DMA、中断优先级一次搞定

2.1 串口基本参数填写

在CubeMX里打开一个工程(这里以STM32F103C8T6为例),把USART1设置为异步模式,也叫Asynchronous。

Parameters选项卡里这几项是Modbus从机必须要设对的:

参数推荐值说明
Baud Rate115200(或实际总线波特率)必须和主站一致
Word Length8 BitsModbus RTU标准是8位数据
ParityNone或Even通常用None
Stop Bits1Modbus RTU标准配置

如果用了RS485收发器,方向控制引脚(DE/RE)在GPIO选项卡里设成推挽输出、低速,初始电平看硬件设计,通常默认拉低为接收方向。

2.2 DMA通道:Normal模式,别用Circular

DMA Settings页面,点击Add,选择USART1_RX,会自动分配一个DMA通道。STM32F103上USART1_RX对应DMA1通道4,其他芯片可能有差异。

关键的参数在这里:

参数配置值
ModeNormal
Data WidthByte
Memory IncrementEnable

很多网上教程推荐Circular循环模式,配合DMA半传输中断做双缓冲,在数据流场景下确实好。但Modbus RTU是帧通信,不是连续数据流,用Circular模式反而会引入一个麻烦:缓冲区满了之后DMA自动回到起点继续写,如果上一帧数据还没处理完,新数据就把旧数据覆盖了。你还要费劲去对比当前DMA计数器的位置来计算帧长度。

用Normal模式简单直接:启动一次DMA接收,缓冲区收到数据后如果遇到空闲中断,接收停止,回调函数里把数据拿走,然后手动重新启动接收。一帧一启停,逻辑清晰,不容易出鬼。

Data Width选Byte是因为串口数据就是单字节。Memory Increment必须开,否则DMA每次都把数据写到同一个地址。Peripheral Increment不用开,串口数据寄存器只有一个。

2.3 NVIC优先级与485方向引脚

NVIC页面里,USART1全局中断和DMA1通道4中断要同时使能。优先级分组建议设置为优先级组2,也就是2位抢占优先级加2位子优先级。

我常用的分配方案:USART1中断抢占优先级设为0,子优先级0;DMA中断抢占优先级设为1,子优先级0。为什么串口中断要比DMA高?因为空闲中断是串口事件触发的中断,它决定了一帧的边界,处理不及时就可能丢掉帧结束的标志。DMA中断只是辅助搬运完成的通知,晚一会儿处理问题不大。

如果系统里还有systick做定时,注意systick优先级在HAL库默认是15(最低优先级组下),而USART中断不能再低了,否则回调里长时间处理会影响系统时钟节拍。实际经验是把UART中断放在优先级组的最高档。

485方向引脚如果串口是RS485应用,单独用一个GPIO,比如PA8,做DE和RE的控制。接收时拉低,发送时拉高。这个引脚在代码里要能快速操作,所以直接用HAL_GPIO_WritePin就行。

2.4 生成代码后手动打开空闲中断

生成代码之后,在main函数里CubeMX已经帮我们初始化好串口和DMA,但“接收一帧并触发空闲回调”这个动作要自己写。

STM32Cube HAL库版本在1.11以上,可以直接用这一行启动DMA加空闲接收:

HAL_UARTEx_ReceiveToIdle_DMA(&huart1, dma_rx_buf, DMA_RX_BUF_SIZE);

这个函数做两件事:启动DMA传输,同时使能串口的空闲中断。当串口线路上检测到空闲,或者DMA接收缓冲区写满时,会进入回调函数HAL_UARTEx_RxEventCallback。

如果你用的HAL库版本比较老,没有这个API,那就用老办法:先调用HAL_UART_Receive_DMA启动DMA,再手动加一句:

__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

然后自己写USART1_IRQHandler,在里面对UART_IT_IDLE进行处理。这个老方法需要处理HAL库的内部标志位,麻烦一点,但同样可行。建议直接升级HAL库,用新API。

3. 裸机下的核心代码实现

3.1 接收缓冲区与帧缓冲设计

DMA直接把串口数据搬到哪里?需要一个字节数组,大小按Modbus RTU最大帧来定。Modbus RTU最大帧长是256字节(从站地址1字节加功能码1字节加数据最多252字节加CRC 2字节),所以缓冲区设成256字节以上比较稳妥。

我习惯设成260,留点余量:

#define DMA_RX_BUF_SIZE 260 #define MAX_FRAME_LEN 256 uint8_t dma_rx_buf[DMA_RX_BUF_SIZE]; uint8_t modbus_rx_frame[MAX_FRAME_LEN]; volatile uint16_t modbus_rx_len = 0; volatile uint16_t modbus_rx_idx = 0;

两个缓冲区的分工要搞清楚。dma_rx_buf是DMA直接写入的缓冲区,物理层专用,可能会被新数据覆盖;modbus_rx_frame是协议栈要读的帧缓冲,从dma_rx_buf拷贝过来,保证解析期间数据不被破坏。

3.2 空闲中断回调处理完整帧

进入回调函数后,说明一帧已经收完或者缓冲区满了。回调函数的第二个参数Size,是从启动接收以来总共接收到的字节数。因为我们在Normal模式下每帧重新启动一次接收,所以这个Size就是当前帧的实际长度。

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { if (Size > 0 && Size <= MAX_FRAME_LEN) { /* 优先关掉DMA,防止重新启动后新数据覆盖缓冲区 */ HAL_UART_DMAStop(&huart1); /* 拷贝到协议栈帧缓冲 */ memcpy((uint8_t *)modbus_rx_frame, dma_rx_buf, Size); modbus_rx_len = Size; modbus_rx_idx = 0; /* 通知FreeModbus收到完整一帧 */ xMBPortEventPost(EV_FRAME_RECEIVED); /* 重新启动接收 */ HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)dma_rx_buf, DMA_RX_BUF_SIZE); } } }

这里有几个细节值得展开。

回调函数进入时DMA其实已经停了,但为了保险,我习惯先主动调一次HAL_UART_DMAStop。防止某些库版本下DMA还在继续跑,前几字节被覆盖。

modbus_rx_idx归零很重要。FreeModbus协议栈读取帧时是逐字节读的,xMBPortSerialGetByte内部靠这个索引一路读下去。如果上一帧读了一半,这一帧把索引重置,才能保证从帧头开始。

xMBPortEventPost属于FreeModbus的事件管理,它在裸机环境下是把事件写入一个FIFO队列,主循环eMBPoll会消费这个事件。在中断里调用它是安全的,前提是portevent.c里的事件队列实现不涉及临界区嵌套。后面主循环会来取事件。

3.3 改造portserial.c:enable、getbyte、putbyte

FreeModbus的串口底层文件是portserial.c,这个文件把协议栈和实际硬件隔开。改DMA方案,主要动三个函数。

第一个是xMBPortSerialInit,初始化时不需要重新配置串口,因为CubeMX已经做了。这里只做变量初始化和485方向置为接收:

BOOL xMBPortSerialInit(UCHAR ucPort, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity) { modbus_rx_len = 0; modbus_rx_idx = 0; RS485_DIR_RX(); return TRUE; }

第二个是xMBPortSerialEnable,控制收发使能。这个函数会被协议栈反复调用来切换状态,初始化和每个通信周期都会用到:

BOOL xMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { if (xRxEnable) { RS485_DIR_RX(); if (HAL_UARTEx_ReceiveToIdle_DMA(&huart1, (uint8_t *)dma_rx_buf, DMA_RX_BUF_SIZE) != HAL_OK) { return FALSE; } } else { HAL_UART_DMAStop(&huart1); } if (xTxEnable) { RS485_DIR_TX(); } return TRUE; }

这个函数的调用时序是理解FreeModbus的关键。正常情况下的顺序是:使能接收,收到帧后进入回调,事件队列里放了EV_FRAME_RECEIVED,主循环eMBPoll取到事件,然后调用xMBPortSerialEnable(FALSE, FALSE)停止接收,再调用xMBPortSerialEnable(FALSE, TRUE)准备发送,发送完成后xMBPortSerialEnable(TRUE, FALSE)重新开始接收。

因为有这个机制,即使我在空闲中断回调里已经重新启动了DMA,协议栈处理到停止接收时也会调用DMAStop,把DMA关掉。两次启动之间重复调用ReceiveToIdle_DMA会不会出错?实测下来不会,HAL库在DMA已经在接收时再调用一次会返回HAL_BUSY,但不影响当前接收。为了保险,也可以加一个标志位判断,不过在Modbus从机场景下,帧间空闲时间足够,重复调用不会出乱子。

第三个是xMBPortSerialGetByte,协议栈解析帧时逐字节取用。直接从modbus_rx_frame里按索引取:

UCHAR xMBPortSerialGetByte(CHAR *pucByte) { if (modbus_rx_idx < modbus_rx_len) { *pucByte = modbus_rx_frame[modbus_rx_idx++]; return TRUE; } return FALSE; }

协议栈如果发现取不到字节,说明帧已经读完了,它会自己判断帧是否完整。这个函数不能越界取值,否则会把modbus_rx_frame后面的内存读出来。

第四个是xMBPortSerialPutByte,发送单个字节。这里不需要做太复杂的DMA发送,因为我们发送的数据量不大,而且FreeModbus是一个字节一个字节地调,在底层做DMA整帧发送反而要缓冲,复杂度高。直接轮询发送,简洁可靠:

UCHAR xMBPortSerialPutByte(CHAR ucByte) { while (!(huart1.Instance->SR & USART_SR_TXE)); huart1.Instance->DR = (uint8_t)ucByte; return TRUE; }

这里用寄存器操作而不是HAL_UART_Transmit,是因为HAL_UART_Transmit有超时机制,会引入不必要的等待,寄存器操作更直接。发送完成后,协议栈会在合适的时机调用xMBPortSerialEnable(TRUE, FALSE)把方向切换回接收,要注意硬件上485收发器的方向切换需要一点延时,一般在切换方向后加几个空指令或微秒级延时。

3.4 主循环:eMBPoll照常跑

主循环的代码不用做太大的改动,还是标准的FreeModbus裸机写法:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_USART1_UART_Init(); if (eMBInit(MB_RTU, 0x01, 1, 115200, MB_PAR_NONE) != MB_ENOERR) { while (1); } if (eMBEnable() != MB_ENOERR) { while (1); } while (1) { eMBPoll(); } }

注意eMBInit的第四个参数是波特率,这里要跟CubeMX里配置的一致。如果CubeMX里用的是115200,这里也填115200。协议栈底层不会重新配置串口寄存器,它只是把这个值传给xMBPortSerialInit,我们有需要可以拿来用。

eMBPoll在裸机环境下是一个需要频繁调用的函数,它内部会检查事件队列里有没有新的帧事件,有就解析,没有就立即返回。所以主循环里不要放耗时的阻塞操作,否则eMBPoll的调用频率会被拖低,影响帧处理和超时判断。我的经验是,如果有别的任务,可以把eMBPoll放到一个定时中断里调用,或者放在主循环里每隔几十微秒调用一次。F103主频72MHz,eMBPoll本身执行很快,实测一次无事件调用不到1微秒。

4. 调试实录与避坑清单

4.1 帧错位的根因:DMA回绕与Size计算

第一次用DMA加空闲中断时最容易碰到的问题就是:能收到数据,但前面的数据是错位的,比如把CRC当成了地址码,导致CRC校验不过。

排查下来的根因,绝大多数是用了Circular模式又没正确处理回绕。Circular模式下DMA写满缓冲区后自动回到缓冲区头,如果HAL的RxEventCallback里直接拿Size参数当偏移量,得到的地址就是错的。比如缓冲区大小是260,实际这一帧数据从偏移250开始写,写了20个字节,DMA回绕后分布是偏移250到259共10字节,加上偏移0到9共10字节,总共20字节。但你拿Size=20去定位,指向的是偏移20,根本对不上。

解决办法最简单:DMA模式改成Normal,每帧重新启动接收,每次回调的Size就是当前帧从缓冲区头开始的长度,不会漂移。如果非要用Circular提升所谓的数据吞吐,需要自己读DMA的NDTR寄存器算当前位置,逻辑复杂且容易出错,Modbus这种小数据量场景完全没有必要。

4.2 缓冲区容量与最长帧长的关系

Modbus RTU最大帧长是256字节,但有个约定是PDU部分最大253字节(从站地址1字节加CRC2字节,加起来256字节)。如果你在回调里判断Size大于缓冲区长度会出现什么后果?DMA已经写越界了,内存被破坏。

一定要保证DMA_BUF_SIZE至少比最大帧长大。我设计的是260,而MAX_FRAME_LEN设256。这里有个细节:如果一帧数据恰好收满256字节,紧接着总线空闲,空闲中断触发,DMA正好写满缓冲区,Size等于256,处理没问题。如果一帧收满256字节后总线没有空闲,DMA继续写第257个字节,就会越界。这种情况在合法Modbus帧里不会出现,因为一帧就是256字节封顶,下一个字节属于下一帧,中间必然有3.5字符时间的静默,空闲中断会先触发。

但如果是异常总线,有主机一直发垃圾数据,缓冲区就可能溢出。保险起见可以把DMA_BUF_SIZE设成300甚至更大,并把缓冲区作为一个环形缓冲区,在回调里判断Size如果超过允许帧长直接丢弃并重新启动。我的代码里Size > MAX_FRAME_LEN时直接什么都不做,但要注意此时还是要重新启动DMA,否则后面就再也收不到数据了。所以我在if判断外重新启动了接收,确保设备永远在接收状态。

4.3 空闲中断误触发与波形观察

有段时间我怀疑空闲中断不可靠,因为时不时出现帧解析错误。用逻辑分析仪抓了串口波形,发现主站发出的帧是正常的,但我在回调里测到的Size总是比实际帧长多一个字节。

查了半天,问题出在485总线上。主从机之间用RS485走线比较长,末端设备没接120欧匹配电阻,信号反射导致总线电平在帧结束后抖动,串口把抖动的毛刺当成了一个额外字节。空闲中断本来应该在帧结束后触发的,但实际上多等了一个毛刺字节的空闲时间。

这个问题从软件层面看,帧长比预期多字节,CRC一定会校验失败。解决办法有两个层面:硬件上在总线两端加匹配电阻,软件上在回调里除了判断Size范围,还要做基本的帧头合法性检查,比如第一个字节是从站地址,如果地址不是本机地址,直接丢弃,不用去管完整性。这个检查放到物理层回调里,可以大大减少无效事件对协议栈的打扰。

另外,如果主站发送的帧字节间隔比较大,比如用USB转485适配器导致字节间有超过1.5字符时间的间隔,串口空闲中断会在帧中间触发,导致一帧被截断成两段。这个问题在快速轮询和低速从机配置并存时会出现。解决办法是把空闲中断的触发条件调宽,STM32有些系列支持空闲检测阈值的配置,或者干脆把帧间判定交给T35定时器,空闲中断只负责从DMA搬运数据。这个方案和FreeModbus的契合度更高,也更灵活,不过代码复杂度高一些,需要改porttimer.c,后面有空单独写一篇。

4.4 485方向切换的时序细节

RS485是半双工,收发方向切换的时机很敏感。FreeModbus应答帧发送完毕后,要把方向从发送切回接收。我是把切换动作放在了xMBPortSerialEnable(TRUE, FALSE)里,也就是协议栈每次准备接收时都会执行RS485_DIR_RX。

在发送时,xMBPortSerialEnable(FALSE, TRUE)里会执行RS485_DIR_TX,这时马上用xMBPortSerialPutByte发送第一个字节。问题在于,切换方向到第一个字节真正出现在总线上,需要一点时间。MAX3485这类收发器的方向切换时间是纳秒到微秒级,但考虑到走线电容和总线偏置,最好在切换后加几个微秒的延时。我实际加的是操作寄存器,然后循环空转几个周期,约2微秒,稳定不掉帧。

帧发送完毕也需要类似处理。xMBPortSerialPutByte是逐字节轮询发送,协议栈发完最后一个字节后,会调用xMBPortSerialEnable(TRUE, FALSE)切回接收。如果立即拉低方向引脚,最后一个字节还留在发送移位寄存器里没发完,总线上的帧被截断,对端会收到CRC错误。解决这个问题的标准思路是在xMBPortSerialEnable里检查串口发送移位寄存器是否为空。具体做法是等待USART_SR的TC位:

#define RS485_DIR_RX() do { \ while (!(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC))); \ HAL_GPIO_WritePin(RS485_DE_PORT, RS485_DE_PIN, GPIO_PIN_RESET); \ } while (0)

这是我最想强调的细节之一,很多人移植FreeModbus加485方向控制,数据有时发不出去或者帧残缺,大概率就是没等发送移位寄存器清空就切了方向。要注意TC位的清零时序,读SR再读DR或者直接调用__HAL_UART_CLEAR_FLAG手动清掉,不同HAL版本有细微差别,但只要等待TC位在切换方向前置位,实测都是可靠的。

4.5 常见问题速查表

现象可能原因排查和处理方向
从机能发不能收,或收到的都是0xFF485方向脚初始电平不对,RX引脚被拉成发送初始化时把方向引脚拉到接收电平
偶发收不到请求,主站报超时DMA回调里没有及时重新启动接收,帧间空闲不够回调里先拷贝再重启,或把重启动作放到帧事件处理完成后
帧能收到但CRC总错误DMA模式是Circular导致数据错位,或485总线波形反射换Normal模式;检查终端匹配电阻
第一个请求正常,后续全部乱套回调里没把modbus_rx_idx清零,或缓冲区被覆盖检查帧缓冲索引初始化和长度重置
回复帧缺失最后一个字节485方向切换太早,发送寄存器没发完在接收使能时等待TC标志再拉低方向脚
高波特率下偶尔丢帧空闲中断优先级偏低,或回调里处理时间过长提高USART中断优先级,回调里只做搬运,不做协议解析

写在最后的调试建议

这方案我在F103和F407上都跑通了,整体稳定性在115200下连续跑24小时没有出现一帧错乱。最关键的调试工具不是调试器,是逻辑分析仪。把串口的RX、TX和485方向控制引脚同时挂上去,观察空闲中断触发时波形卡在哪个位置,坐标一对照,所有时序问题一目了然。实测下来,用DMA加空闲中断后,同样20ms轮询周期,从机的中断次数从每周期16次降到2次,CPU空余时间明显多了,FreeModbus在裸机上跑得很轻松。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询