STM32 FreeModbus V1.6移植实战:从零实现Modbus RTU从站(含DMA串口)
2026/9/2 1:20:09 网站建设 项目流程

简介:FreeModbus V1.6 是一套可在嵌入式设备上运行的 Modbus 协议栈源码,同时支持 RTU 与 ASCII 两种传输模式,覆盖 0x01 读线圈、0x02 读离散输入、0x03 读保持寄存器、0x04 读输入寄存器、0x05 写单线圈、0x06 写单寄存器、0x0F 写多线圈、0x10 写多寄存器、0x17 读写多寄存器以及 0x11 报告从站 ID 等常用功能码,非常适合工业控制、PLC 通信和单片机主从机项目的底层通信开发。整个资源包共 1104 个文件,压缩后约 4.4MB,核心代码由 .c 与 .h 文件构成,另附链接脚本、构建脚本、移植模板、演示批处理以及示例任务代码,能够帮助开发者在不同 MCU 平台上快速完成协议栈移植。借助移植层和演示工程,读者可直观理解状态机调度与串口收发逻辑,再结合自己的硬件平台修改底层接口,即可获得稳定的 Modbus 通信能力。已有 664 人学习下载,适合具备一定嵌入式基础、希望直接集成 Modbus 功能的工程师参考。 做嵌入式的朋友,尤其是接触过工业现场设备的人,大概率都绕不开Modbus RTU。而提起在STM32上实现Modbus RTU从站,FreeModbus V1.6几乎是一个绕不开的选项。这个版本发布至今已经十几年没有更新,但直到今天,很多量产设备、开源项目、教学案例里跑的仍然是这一版。最近我在STM32F411上把FreeModbus V1.6完整移植了一遍,顺手接了DMA串口,把Modbus轮询、寄存器读写都调通了,过程中踩了几个比较有代表性的坑。这篇文章就把完整过程和排查思路整理出来,给准备在STM32平台上移植FreeModbus的朋友做个参考。

如果你只是想让板子尽快跑起来完成项目,这篇文章可以直接照着抄;如果你是做产品,想知道这套老协议栈到底能不能撑住现场设备的长期运行需求,它也值得你花几分钟看完。后面所有代码都在STM32F411上实测通过,但思路对绝大多数带UART和基本定时器的MCU都适用。下面不绕弯子,直接进入正题。

1. FreeModbus V1.6:一版十多年前发布的协议栈,为什么还在被大量移植

1.1 先搞清楚它到底是个什么东西

FreeModbus是一个开源的Modbus从站协议栈实现,V1.6是稳定发布版本中最常被用到的一个。它做的事情很纯粹:帮你解析Modbus RTU报文、校验CRC、维护状态机、分发功能码,然后把最终的数据读写请求用回调函数的形式提交给应用层。也就是说,协议栈管“交通”,应用层管“货物”,两边通过固定接口对接,不需要你关心报文格式和超时状态机这些繁琐细节。

很多刚接触的人一看到源码目录里有demo、port、mb、functions四个文件夹就发怵,觉得代码量很大。其实真正需要你动手改的只有port目录下的几个平台相关文件,剩下的大部分代码都跟你无关。mb目录是协议核心,functions目录是功能码处理,demo目录只是一个参考入口。移植FreeModbus,本质上是完成MCU外设事件和协议栈回调函数之间的对接。

1.2 为什么选它而不是其他Modbus方案

市面上Modbus方案不少,有商业协议栈,也有各种修改版。我把它们放在一起对比过,结论很明确:如果只需要做一个从站设备,FreeModbus V1.6仍然是最稳的选择之一。

它的优势体现在几个方面。第一,代码量小,裁剪后核心只有两三千行,RAM占用极小,在资源紧张的单片机上跑毫无压力。第二,状态机按字节驱动,天然适合中断处理,不依赖RTOS也能工作得很好。第三,V1.6作为发布多年的版本,被无数项目验证过,边界情况处理得比较成熟。相比之下,很多增加主站功能或加入OS抽象层的修改版,功能更全但代码复杂度和潜在问题也多不少,对于只需要从站功能的项目来说,属于过度设计。

1.3 移植任务的总览:六个关键函数

FreeModbus对平台的抽象很克制,真正需要你实现的平台相关函数就这几个:

文件函数职责
portserial.cxMBPortSerialInit初始化串口、引脚、DMA
portserial.cxMBPortSerialPutByte发送一个字节
portserial.cxMBPortSerialGetByte读取一个接收到的字节
portserial.cvMBPortSerialEnable使能/失能收发中断
porttimer.cxMBPortTimersInit初始化T3.5超时定时器
porttimer.cvMBPortTimersEnable / Disable启停超时定时器

再加上串口接收中断里调用prvvUARTRxISR()、发送完成中断里调用prvvUARTTxReadyISR()、定时器超时中断里调用prvvTIMERExpiredISR(),整个移植工作就完成了。这比大多数人想象的要简单,但恰恰因为简单,才容易在细节上栽跟头。

2. 移植的第一步:理清端口层三块责任,别急着写代码

2.1 串口部分的收发回调不能搞反

端口层最容易犯的第一个错误,是把两个中断回调的作用搞混。prvvUARTRxISR()是“收到一个字节”的通知,由接收中断调用;prvvUARTTxReadyISR()是“发送寄存器为空”的通知,由发送完成中断调用。协议栈内部靠这两个信号推进状态机,如果顺序反了,或者漏调了其中一个,最典型的症状就是:主站发请求从站没响应,但从站串口上能看到完整的数据波形。

我在F411上写的串口中断处理逻辑大致是这样的:

void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) != RESET) { // 收到一个字节,交给协议栈 prvvUARTRxISR(); __HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_RXNE); } if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_TC) != RESET) { // 最后一字节发送完成,通知协议栈发送阶段结束 prvvUARTTxReadyISR(); __HAL_UART_CLEAR_FLAG(&huart1, UART_FLAG_TC); } HAL_UART_IRQHandler(&huart1); }

这里有个细节值得多说一句:发送完成标志建议用TC而不是TXE。TXE表示发送数据寄存器为空,但移位寄存器可能还在往外送最后一个字节,如果此时做RS485方向切换,会把帧尾截断,主站那边就会报CRC错误。TC表示整个发送过程结束,用这个标志做方向切换才安全。

2.2 定时器部分:基础定时器就够用

T3.5超时判断是Modbus RTU帧结束的判定依据,也是FreeModbus移植中最关键的定时器。它不需要PWM输出,不需要输入捕获,只需要一个能产生更新中断的基本定时器即可,所以STM32上的TIM6或者TIM7是最合适的选择。把它们留给别的功能,用通用定时器反而浪费。

xMBPortTimersInit的核心工作是配置定时器的预分频和自动重载值,让定时器溢出周期等于T3.5。vMBPortTimersEnable的作用是清空计数器、使能更新中断并启动定时器;vMBPortTimersDisable则相反。协议栈在每次接收到新字节时都会重新启动这个定时器,如果两个字节之间的间隔超过T3.5,就认为一帧结束,开始解析数据。所以它的每次溢出,都意味着“帧结束了,该干活了”。

2.3 初始化调用顺序不要自作主张

FreeModbus的API调用顺序是固定的:先eMBInit,再注册回调,最后eMBEnable,之后循环调用eMBPolleMBInit会根据你传入的串口号、地址、波特率调用前面提到的xMBPortSerialInitxMBPortTimersInit。很多人喜欢在main函数开头自己先初始化串口和定时器,然后再调eMBInit,这样会导致外设被初始化两次,容易出现配置覆盖或中断未生效的诡异现象。

我建议的做法是:外设时钟和GPIO在eMBInit之前开启,但串口参数、定时器参数完全交给xMBPortSerialInitxMBPortTimersInit去配置。这样移植代码和业务逻辑的边界最清晰,出问题也容易排查。

3. T3.5定时器与RS485方向控制:最容易翻车的两个现场

3.1 T3.5到底怎么算,为什么是11位

Modbus RTU规定,帧与帧之间的间隔必须大于等于3.5个字符时间,接收端在此基础上判断一帧数据是否结束。一个字节在串行线路上占用的时间不是8位,而是11位:1个起始位、8个数据位、1个校验位(或者无校验时的停止位)、1个停止位。所以T3.5的计算公式是:

T3.5 = 3.5 × 11 / 波特率

常见的几个波特率对应数值我列成了表格,方便直接查:

波特率T3.5(毫秒)近似值
96004.0104.0 ms
192002.0052.0 ms
384001.0021.0 ms
1152000.3340.33 ms

FreeModbus V1.6在内部会把T3.5换算成定时器计数周期,然后通过xMBPortTimersInit参数传给你。不同移植版本对参数含义的解释略有不同,我移植时直接看的是源码里的注释,以“协议栈传入的数值即定时器自动重载值”为准。如果你拿到的版本参数语义不明确,可以用逻辑分析仪抓一下主站的帧间隔,再回头校准定时器周期。

3.2 STM32F411上的TIM6配置实例

F411的APB1定时器时钟经过倍频后通常是100MHz,我把它分频100倍得到1MHz的计数频率,也就是每个计数tick为1微秒。波特率9600时T3.5约4.01ms,对应的自动重载值就是4010。下面是裁剪过的核心配置:

void vMBPortTimersInit(USHORT usTimerT35Value) { __HAL_RCC_TIM6_CLK_ENABLE(); htim6.Instance = TIM6; htim6.Init.Prescaler = 100 - 1; // 100MHz / 100 = 1MHz,1us一个tick htim6.Init.CounterMode = TIM_COUNTERMODE_UP; htim6.Init.Period = usTimerT35Value - 1; // 自动重载值,由协议栈传入 htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Base_Init(&htim6); HAL_NVIC_SetPriority(TIM6_DAC_IRQn, 2, 0); HAL_NVIC_EnableIRQ(TIM6_DAC_IRQn); } void TIM6_DAC_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim6, TIM_FLAG_UPDATE) != RESET) { __HAL_TIM_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE); prvvTIMERExpiredISR(); } }

注意Period的实际含义是自动重载值减1,因为定时器是从0开始计数的。这个细节如果搞错,定时器会提前1个tick溢出,在高速率下可能造成帧结束判定偏差。还有一点,vMBPortTimersEnable里建议做两件事:清空计数器,然后使能更新中断并启动定时器。如果只启动定时器而忘记清计数器,第二次接收时定时器可能从上次残留值继续计,导致超时时间错误。

3.3 RS485的DE引脚:时机比电平更重要

工业现场Modbus RTU基本都是RS485总线,DE/RE引脚方向切换是个经典问题。FreeModbus没有提供方向控制的接口,需要你自己在串口发送和发送完成的地方控制一个GPIO。最直接的方案是:xMBPortSerialPutByte里发送第一个字节前拉高DE,发送完成中断里拉低DE。

但这里有一个非常容易踩的坑:如果在TXE中断里拉低DE,或者用HAL_UART_Transmit这样的阻塞发送函数并在函数返回后立刻拉低DE,最后一帧的停止位可能还没完全发出去。正确的做法是像我前面说的,使用TC标志。当TC置位时,说明最后一个停止位已经从引脚上送出去了,这时候拉低DE才不会截断帧尾。

另外,如果板上用了自动方向切换芯片或者收发器自带延时,方向控制的代码可以做得更简单,但绝大多数MAX485类芯片都需要软件控制方向。我在调试时用示波器同时抓A/B差分波形和DE引脚电平,发现DE拉低瞬间如果和停止位重叠,主站收到的帧末尾会多出一些杂散电平,CRC大概率报错。这类问题常常被误判为定时器不准,实际都是方向切换时机的问题。

4. DMA串口方案:什么场景值得上,接入思路和隐藏的坑

4.1 标准中断接收在什么情况下不够用

FreeModbus默认的接收路径是每个字节触发一次接收中断,然后调用prvvUARTRxISR()。在9600或19200波特率下,这个频率很低,CPU完全没压力。但在115200甚至更高波特率下,每字节间隔只有约87微秒,如果主站连续发送大量寄存器数据,中断频率会明显上升,加上协议栈在中断里做的事情不止收数据,还有定时器重启和状态机推进,对实时性要求较高的场景就有点吃力了。

DMA方案的核心价值就在这里:把逐字节接收从CPU中断里解放出来,由DMA把整帧数据搬到内存,CPU只在帧结束(空闲中断)时处理一次。需要注意的是,DMA不会让Modbus通信本身变快,因为波特率是主站和从站协商好的,它只是减少了CPU被频繁打断的次数,让系统有更多精力处理其他任务。

4.2 IDLE+DMA整帧接收后如何喂给协议栈

DMA方案和FreeModbus对接的关键在于:协议栈的接收状态机是按字节推进的,它并不知道DMA已经帮你收好了一整帧。所以最稳妥的对接方式是:在串口空闲中断触发时,读出DMA当前接收长度,然后把缓冲区里的字节逐个调用prvvUARTRxISR()喂给协议栈,让协议栈按原有逻辑完成帧结束判断和解析。

这样不需要改动协议栈内部任何代码,维护成本最低。伪代码如下:

void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); // 计算DMA已接收长度 uint16_t len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); // 把这一帧数据按字节投递给协议栈 for (uint16_t i = 0; i < len; i++) { prvvUARTRxISR(); } // 清空DMA计数器,准备下一帧 __HAL_DMA_SET_COUNTER(&hdma_usart1_rx, RX_BUF_SIZE); } HAL_UART_IRQHandler(&huart1); }

这里有一个容易翻车的地方:prvvUARTRxISR()内部会读取串口数据寄存器来获取字节内容。如果用DMA接收,数据已经被DMA搬到了缓冲区里,串口数据寄存器里已经什么都没有了,所以必须自己维护一个接收索引,在循环里逐个取出缓冲区字节。更简单的做法是每次都从缓冲区头部开始读取,然后重置DMA计数器,但这要求一帧数据必须在缓冲区里连续保存,下一帧数据到来前必须处理完。

4.3 STM32F411上DMA接收的配置要点

F411的UART支持DMA循环模式,配合空闲中断可以很方便地实现不定长接收。我这里用的是普通模式而非循环模式,每帧接收完成并处理完后重新装载DMA计数器,逻辑更直观,也不容易出现缓冲区覆盖问题。

DMA初始化时注意几点。第一,接收缓冲区大小必须大于最大Modbus报文长度,Modbus RTU最大报文通常是256字节,所以缓冲区我开了512字节,留了余量。第二,DMA的数据宽度是字节,方向和串口接收请求要匹配。第三,空闲中断标志需要在读取数据长度之后再清除,顺序不能反,否则可能丢失中断。

还要提醒一句:如果只是做点对点测试,波特率不高的情况下标准中断接收方案完全够用,没必要为了用DMA而用DMA。DMA方案的收益在高速率、频繁通信、系统任务繁杂时才能体现出来,引入DMA也意味着多一个排查链路,项目进度紧时反而拖后腿。

5. 跑通第一帧数据:寄存器回调设计与调试链路

5.1 最小启动流程:从xMBInit到eMBPoll

把端口层写完,终于到了让协议栈真正工作起来的环节。启动流程固定如下:

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); // 1. 初始化Modbus协议栈:RTU模式,从站地址1,串口1,波特率9600,无校验 eMBInit(MB_RTU, 0x01, 1, 9600, MB_PAR_NONE); // 2. 注册寄存器访问回调 eMBRegHoldingCB(RegHoldingCB); eMBRegInputCB(RegInputCB); eMBRegCoilsCB(RegCoilsCB); eMBRegDiscreteCB(RegDiscreteCB); // 3. 使能协议栈 eMBEnable(); while (1) { // 4. 主循环轮询,处理接收完成的帧 eMBPoll(); } }

eMBPoll需要在主循环里被高频调用,但千万不能阻塞。很多人在main里写了大段延时或者HAL_Delay,导致eMBPoll无法及时处理请求,从站响应就会慢半拍。实测下来,轮询间隔控制在1到2毫秒内,响应时延就很稳定。如果主循环有其他耗时任务,建议把eMBPoll放到定时器中断或者RTOS高优先级任务里。

5.2 保持寄存器和输入寄存器回调的写法

寄存器回调是协议栈和应用层数据之间的桥梁。以保持寄存器为例,回调函数会收到三个关键参数:读写缓冲区指针、起始地址、寄存器个数。典型写法如下:

eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { uint16_t regIndex = usAddress - REG_HOLDING_START; if (usAddress < REG_HOLDING_START || usAddress + usNRegs > REG_HOLDING_START + REG_HOLDING_NUM) { // 地址越界,返回错误,主站会收到异常码02 return MB_ENOERR == 0 ? MB_ENOREG : MB_ENOREG; } if (eMode == MB_REG_WRITE) { // 写操作:主站发来的数据在缓冲区里,按大端格式读出 for (USHORT i = 0; i < usNRegs; i++) { holdingRegs[regIndex + i] = (pucRegBuffer[2 * i] << 8) | pucRegBuffer[2 * i + 1]; } } else { // 读操作:把寄存器值按大端格式填入缓冲区 for (USHORT i = 0; i < usNRegs; i++) { pucRegBuffer[2 * i] = holdingRegs[regIndex + i] >> 8; pucRegBuffer[2 * i + 1] = holdingRegs[regIndex + i] & 0xFF; } } return MB_ENOERR; }

Modbus寄存器数据是大端字节序,也就是高位字节在前。这一点新手特别容易忽略,如果填反了,读出来得到的数据每个寄存器都不对,但看起来又“有值”,非常迷惑。我调试时遇到过一次类似问题,用Modbus Poll连续读了几十个寄存器,发现数值高低位完全颠倒了,排查过程一度以为是报文地址算错了,最后才意识到是大端小端的问题。

5.3 用Modbus Poll验证:异常码02和03的典型排查路径

调试工具我用的是Modbus Poll,它是最常用的Modbus主站模拟软件。新建连接时选好串口、波特率、从站地址、功能码,然后点连接开始轮询。如果从站无响应,优先检查三处:串口RX/TX引脚是否接反、T3.5定时器是否溢出、RS485方向控制是否正常。如果从站有响应但返回异常码,问题大概率出在回调函数里。

  • 异常码01(非法功能码):说明主站请求的功能码在协议栈里不支持,检查功能码枚举是否在functions目录里有对应实现。FreeModbus默认支持01、02、03、04、05、06、15、16等功能码,如果主站用了其他扩展功能码,就需要自己扩展。
  • 异常码02(非法数据地址):回调函数检测到地址越界并返回MB_ENOREG。重点检查寄存器的起始地址映射,尤其是Modbus协议里地址偏移的问题。从站回调里拿到的usAddress是报文中携带的地址,不是物理索引,必须先做偏移计算。
  • 异常码03(非法数据值):写寄存器时值超出范围,或者功能码要求的数据长度不匹配。比如写单个保持寄存器使用了功能码16(写多个寄存器),或者写入值刚好超过寄存器位宽能表达的范围。

从站端如果还是查不出来,可以在从站里加一个调试串口,把接收到的原始报文和eMBPoll返回的错误码打印出来。FreeModbus的eMBPoll返回值是eMBErrorCode,通过它可以判断当前状态机的处理结果,这一步是我做产品时排查通信问题最常用的手段。

最后再分享一个我在实际项目中养成的习惯:FreeModbus移植完成后,先用Modbus Poll连续跑一晚上在线轮询,如果一晚上没有出现任何CRC错误和超时,说明T3.5时序、方向切换和中断优先级基本是稳定了的。如果测试中发现偶发超时,先别急着改代码,用逻辑分析仪抓一下从站的响应波形,看看T3.5和帧间隔是否在标准范围内,很多时候问题不是出在代码逻辑,而是出在时钟配置或者中断响应延迟上。等到波形和协议栈状态都对上了,这个从站才能真正放心地丢到工业现场去跑。

本文还有配套的精品资源,点击获取

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

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

立即咨询