
做FreeModbus从机移植最让人头疼的不是协议栈本身而是串口底层怎么“接得住”数据。CubeMX生成的标准HAL串口中断示例每来一个字节就进一次中断115200波特率下约87微秒一个字节短帧七八个字节就是七八次中断CPU全耗在进出中断上了。我之前把Modbus主站轮询周期压到10ms以内从机经常回不过来加日志一量中断处理占了将近一半的CPU时间。后来改成DMA自动搬运加空闲中断判定帧结束不到十行核心代码就把问题解决了。这一篇把DMA加空闲中断接收的完整思路、CubeMX配置、FreeModbus底层适配和踩坑记录都写出来。内容针对裸机环境不依赖RTOSCubeMX加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通常用NoneStop Bits1Modbus RTU标准配置如果用了RS485收发器方向控制引脚DE/RE在GPIO选项卡里设成推挽输出、低速初始电平看硬件设计通常默认拉低为接收方向。2.2 DMA通道Normal模式别用CircularDMA Settings页面点击Add选择USART1_RX会自动分配一个DMA通道。STM32F103上USART1_RX对应DMA1通道4其他芯片可能有差异。关键的参数在这里参数配置值ModeNormalData WidthByteMemory 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子优先级0DMA中断抢占优先级设为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.cenable、getbyte、putbyteFreeModbus的串口底层文件是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主频72MHzeMBPoll本身执行很快实测一次无事件调用不到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字节。但你拿Size20去定位指向的是偏移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在裸机上跑得很轻松。