简介:FreeModbus V1.6 是一份成熟的 Modbus 协议栈开源实现,支持 RTU 与 ASCII 两种传输模式,面向嵌入式开发者和工业自动化工程师,解决了 MCU 与上位机或 PLC 之间快速集成 Modbus 通信的问题。完整覆盖读写线圈、离散输入、保持/输入寄存器以及从站 ID 上报等常见功能码,适合需要构建稳定工业通信链路的项目。
压缩包共 1104 个文件,约占 4.4MB,主要包含 492 个头文件与 418 个 C 源文件,配合 34 个 txt 说明文档、20 个汇编启动文件以及多种链接脚本(lds/sct/ld/lcf),并提供了 makefile、vcproj、ewp、uv2、zdsproj 等工程配置,方便在不同 IDE 中直接编译移植。已有 664 人学习,适合作为学习 Modbus 协议栈分层实现和进行嵌入式项目移植的参考。
包内还包含 demo_rtu.bat、simple.bat 等演示脚本,tasks.c、portable.h.bak 等源码备份,加上 README、changelog 和 dox 文档,可帮助读者快速定位移植接口、了解版本变更与 API 用法,缩短开发调试周期。整套代码分层清晰,便于二次开发和功能裁剪。 做嵌入式工控的朋友应该都摸过FreeModbus这个经典开源协议栈,V1.6发布至今这么多年,依然是很多设备联网改造的首选。它体积小、代码清晰、跨平台,RTU和ASCII协议都支持,资源紧张的老单片机也能跑得动。最近我正好在STM32F411上把FreeModbus V1.6完整移植了一遍,还顺手把串口接收换成了DMA方式,整条链路调通之后,稳定性和CPU占用都让我很满意。这里把整个流程、原理和踩过的坑整理出来,给准备在STM32系列上做Modbus从站的朋友一个参考。
这篇内容适合谁?如果你正在做PLC通信、组态软件对接、传感器数据上送这类项目,或者手头有设备需要从私有协议改造成标准Modbus RTU,那这篇文章可以帮你少走不少弯路。我会从源码结构讲起,再到STM32F411的串口和定时器移植,最后补上DMA接收的改造思路。文中涉及的代码都是我在实际工程里验证过的,可以直接照抄再按自己的引脚和时钟改一改。
1. 为什么选择FreeModbus V1.6而不是自己写协议栈
1.1 FreeModbus到底解决什么问题
Modbus在工控现场属于“约定俗成”的通信语言。PLC、触摸屏、上位机组态软件基本都原生支持Modbus RTU,只要你的设备把寄存器地址公开,对方就能通过标准报文直接读写。FreeModbus V1.6正是用来帮你实现Modbus从站协议的一整套C代码,它把帧解析、CRC校验、功能码处理、异常响应都封装好了,你只需要提供串口读写和定时器中断这两个最基础的硬件接口。
很多工程师觉得Modbus协议看着简单,报文格式就那么几行,干脆自己写。但实际做下去就会发现坑很多:CRC计算容易错位、3.5字符帧间隔难以精确控制、多寄存器读写时地址越界处理麻烦、异常码响应不规范等。FreeModbus V1.6在这些细节上已经过大量项目锤炼,直接用它是性价比最高的选择。
1.2 和libmodbus、自研方案对比
libmodbus也很流行,但它的定位偏主机(Master)方向,且更依赖操作系统和文件描述符,在裸机MCU上跑并不顺手。FreeModbus则专门面向从机(Slave)场景,支持裸机轮询和RTOS两种运行方式,代码里大量使用条件编译,裁剪非常方便。
自研方案最麻烦的不是帧解析,而是协议兼容性。比如上位机发送04功能码读输入寄存器,你不仅要正确返回数据,还要在寄存器地址非法时返回异常码02。FreeModbus V1.6把这些细节全部处理好了,你只需做的事情就是:初始化、使能、在主循环里轮询eMBPoll,然后在寄存器回调函数里把你的变量映射到协议栈。这是真正意义上的“开箱即用”。
2. FreeModbus V1.6源码结构与移植的核心原理
2.1 源码目录结构:mb、port、demo三层
拿到FreeModbus V1.6源码后,首先要分清目录职责。整个协议栈主要分三层:
mb目录:核心协议代码,包括帧处理、功能码、CRC校验。这部分基本是通用的。port目录:移植层,也就是你需要改写的部分。包含portserial.c、porttimer.c、portevent.c。demo目录:各类硬件平台的参考例程,里面有AVR、STM32、LPC等工程的雏形。
理解了这个分层就知道,移植工作本质上就是补全port层接口。协议栈在运行过程中会调用这些接口去操作串口和定时器,而串口和定时器在不同MCU上的实现方式完全不同,这就是需要配置的“对接层”。
2.2 必须理解的三个函数调用链
FreeModbus V1.6的运行可以浓缩成三个函数:
eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); eMBEnable(); while (1) { eMBPoll(); }eMBInit负责把协议栈初始化到指定模式。第一个参数是通信模式,MB_RTU是常用模式,如果你的设备有特殊需求也可以用MB_ASCII。第二个参数是从站地址,必须跟你的设备拨码开关或配置保持一致。第三个参数是串口号,协议栈在调用xMBPortSerialInit时会把ucPort原样传给你,方便你通过参数区分UART1还是UART2。
eMBEnable调用后,定时器和串口中断才真正开始工作。从这一刻起,协议栈就进入了被动等待状态,只要串口上有合法的Modbus帧进来,它就会自动完成接收、校验、响应的全部流程。
eMBPoll是事件轮询函数。它需要放在主循环里被反复调用,协议栈的接收完成事件、定时器超时事件都通过事件队列传上来,eMBPoll从队列里取出事件并处理。很多人刚接触时以为eMBPoll只是处理接收的,其实它还负责发送完成后的状态切换,漏掉它会导致只收不发。
2.3 port层到底要改哪些文件
从实践角度看,普通裸机移植只需要重点改三个文件:
| 文件 | 需要实现的接口 | 作用 |
|---|---|---|
| portserial.c | xMBPortSerialInit、xMBPortSerialPutByte、xMBPortSerialGetByte、vMBPortSerialEnable | 串口收发和收发方向切换 |
| porttimer.c | xMBPortTimersInit、vMBPortTimersEnable、vMBPortTimersDisable | 字节间隔定时器,RTU模式判断帧结束 |
| portevent.c | xMBPortEventPost、xMBPortEventGet | 事件队列,裸机工程可以用全局变量实现 |
此外还要在中断服务函数里调用两个回调:串口收到一个字节时调用pxMBFrameCBByteReceived,发送缓冲区空且允许发送时调用pxMBFrameCBTransmitterEmpty。定时器溢出中断里调用vMBPortTimerExpired。移植的核心就是把这几个回调跟硬件中断对接好,协议栈就能正常运转了。
3. STM32F411移植实操:从建工程到跑通RTU从站
3.1 硬件准备与CubeMX的基础配置
我这次用的是STM32F411CEU6核心板,主频96MHz。选择F411的原因很简单:带DMA、主频高、价格便宜,很多项目都在用它做协议转换或采集设备。准备一个USART转USB模块,调试时配合串口助手或者Modbus Poll上位机工具使用。
用STM32CubeMX生成工程时,我的配置思路是这样的:
- 选择USART2作为Modbus串口,波特率9600,8数据位、无校验、1停止位,开启接收中断和发送中断。这里需要提醒一下,Modbus RTU默认就是8位数据位,不要配置成9位,否则协议栈解析会错位。
- 分配一个基本定时器TIM6,定时周期1ms,开启更新中断,专门给FreeModbus提供帧间隔计时。
- 如果后面要做DMA改造,还需要开启USART2的接收DMA通道。
F411的USART都比较灵活,你可以根据自己板子调整引脚。把USART2的TX配到PA2,RX配到PA3,这是最常用的映射。
3.2 移植第一步:portserial.c的串口对接
FreeModbus的串口操作接口并不是直接让人调HAL库的,它给了你一个类似“注册表”的层,你只需要把HAL库和DMA的操作填进去。先看最关键的初始化函数:
BOOL xMBPortSerialInit(UCHAR ucPort, ULONG ulBaudRate, UCHAR ucDataBits, eMBParity eParity) { // 这些参数在eMBInit时传入,这里根据参数完成串口外设的配置 // 通过CubeMX生成的MX_USART2_UART_Init()已经配置好了默认参数, // 如果波特率或校验方式跟默认不同,需要在这里修改USART2的句柄 return TRUE; }这里有一个常见误区:很多人在CubeMX里把波特率固定成9600,然后不管eMBInit传多少波特率,实际串口都是9600在工作。如果你的设备要支持1200、2400、4800等多种波特率,就必须在xMBPortSerialInit里根据传入的ulBaudRate重新初始化USART,而不是只用默认配置。
接下来是逐字节收发:
BOOL xMBPortSerialPutByte(CHAR ucByte) { // 发送一个字节,轮询方式或者中断方式都可以 // 推荐先把字节写入发送数据寄存器,再发一个"发送中断使能" // 等发送完成中断触发后,在中断里调用pxMBFrameCBTransmitterEmpty() while (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TXE) == RESET); huart2.Instance->DR = ucByte; return TRUE; }接收部分更讲究时机。协议栈要求每收到一个字节就要通知一次,所以我的做法是直接在主程序里打开接收中断,在串口中断里取出数据收寄存器,然后立刻调用协议回调:
void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_RXNE) != RESET) { pxMBFrameCBByteReceived(); } // 发送完成中断根据实际情况处理 HAL_UART_IRQHandler(&huart2); }有朋友问过:为什么不直接调用HAL_UART_Receive_IT?其实协议栈自己维护接收状态机,它需要“每收到一个字节就回调一次”这种原始数据流,而不是HAL库里的缓存式接收。所以最直接的方式反而是操作寄存器。
vMBPortSerialEnable是收发方向的切换开关。它的作用很关键:接收帧期间要关闭发送中断,发送期间要关闭接收中断,否则可能会在响应的同时误收自己的数据。
void vMBPortSerialEnable(BOOL xRxEnable, BOOL xTxEnable) { if (xRxEnable) { __HAL_UART_ENABLE_IT(&huart2, UART_IT_RXNE); } else { __HAL_UART_DISABLE_IT(&huart2, UART_IT_RXNE); } if (xTxEnable) { __HAL_UART_ENABLE_IT(&huart2, UART_IT_TC); } else { __HAL_UART_DISABLE_IT(&huart2, UART_IT_TC); } }3.3 移植第二步:porttimer.c的定时器对接
RTU模式下,协议栈需要判断一帧数据什么时候结束。Modbus标准规定两个字节之间如果超过3.5个字符时间,就认为这一帧已经结束。以9600波特率计算,一个字符约1.1ms,3.5个字符大约4ms。FreeModbus用了一个更通用的换算方式:以50微秒为单位计算超时时间,在初始化时把值传给xMBPortTimersInit。
在STM32上,我用TIM6作为基础定时器,配置成1ms中断一次。初始化函数里重新计算并装载自动重装值:
BOOL xMBPortTimersInit(USHORT usTim1Timerout50us) { // usTim1Timerout50us 是以50us为单位,实际中断间隔=usTim1Timerout50us * 50us // 如果TIM6配置成1ms中断,这里需要根据参数换算重装值 return TRUE; }移植时最容易出问题的点在于:vMBPortTimersEnable和vMBPortTimersDisable在协议栈里会被反复调用,每收到一个字节就要重新启动定时器,用来监视帧间隔。所以定时器的启停必须干净利落,不能被其他中断阻塞。把TIM6中断优先级设得比串口高一级没问题,但要注意两个中断之间不要互相抢占太久。
定时器溢出中断里调用:
void TIM6_DAC_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim6, TIM_FLAG_UPDATE) != RESET) { __HAL_TIM_CLEAR_FLAG(&htim6, TIM_FLAG_UPDATE); vMBPortTimerExpired(); } HAL_TIM_IRQHandler(&htim6); }3.4 主程序流程与寄存器回调映射
完成port层三个文件后,主程序的搭建就很简单了。我的工程里是这样组织的:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART2_UART_Init(); MX_TIM6_Init(); // 注册回调:从机地址、串口、波特率 eMBInit(MB_RTU, 0x01, 0, 9600, MB_PAR_NONE); eMBEnable(); while (1) { // 处理Modbus请求 eMBPoll(); // 用户其他业务逻辑 } }寄存器回调是真正跟业务打交道的地方。协议栈收到读保持寄存器的请求后,会调用你的回调函数,你需要根据寄存器地址把数据填入usRegHoldingBuf:
eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { // usAddress 是寄存器地址(1-65536) // 数据处理逻辑: // 读操作:把变量值写入pucRegBuffer // 写操作:从pucRegBuffer读取数据并更新变量 return MB_ENOERR; }建议把所有业务变量都统一管理起来,用一个结构体或者数组维护,然后在回调里按地址做相对偏移。比如我的设备里有温度、湿度、开关状态等变量,把地址规划成:0x0000温度、0x0001湿度、0x0002开关,回调里直接按usAddress判断范围再处理。这样后期增加点位时,只需要改回调函数,不需要动协议栈本身。
4. DMA串口接收改造:解决高波特率下的中断风暴
4.1 为什么要做DMA接收改造
FreeModbus默认的逐字节中断接收方式在小数据量场景下没有任何问题,但如果你把波特率调到115200甚至更高,一帧数据里每个字节都会触发一次中断,MCU被频繁打断,CPU占用率明显上升。而且高波特率下字节间隔很短,如果主循环里有复杂的浮点运算或者其他阻塞操作,很可能会漏字节或者收到帧后来不及处理。
用DMA接收的思路很简单:串口收到数据后由DMA自动搬运到内存缓冲区,整个过程不需要CPU干预。等一帧完整数据收完了,再统一交给FreeModbus去解析,这样既能减少中断次数,又能避免高频数据丢失。
我最终采用的方案是:DMA接收 + 串口空闲中断( IDLE )。串口收到一帧数据后,如果检测到总线空闲,就会触发IDLE中断,这时DMA已经把这帧数据全部搬到了缓冲区,我只需要在中断里处理这一帧即可。
4.2 DMA接收配置与缓冲区的闭环管理
CubeMX里开启USART2的接收DMA,数据宽度都选Byte,方向PeripheralToMemory,模式Normal。然后定义一个环形接收思路,我这里为了贴近协议栈,采用更直接的方法:
- 申请一个
uint8_t xRxBuffer[256],作为DMA接收目标。 - 每次启动DMA接收前,把DMA目标地址设为
xRxBuffer,接收长度设为256。 - 串口进IDLE中断时,用
__HAL_DMA_GET_COUNTER计算本次实际接收到的字节数。 - 把这几个字节逐个“喂”给FreeModbus。正常情况下一个Modbus RTU帧不会超过256字节,所以缓冲区容量足够。
代码里核心逻辑是这样的:
void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); uint16_t rxLen = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart2.hdmarx); // 把DMA缓冲区数据按字节交给协议栈 for (uint16_t i = 0; i < rxLen; i++) { pxMBFrameCBByteReceived(); } // 重启DMA接收,准备接收下一帧 HAL_UART_Receive_DMA(&huart2, xRxBuffer, RX_BUF_SIZE); } HAL_UART_IRQHandler(&huart2); }这个方案能跑通的关键在于:pxMBFrameCBByteReceived内部会调用xMBPortSerialGetByte,而后者需要从串口数据寄存器读取数据。在使用DMA接收时,数据已经不在数据寄存器里了。所以必须同步修改xMBPortSerialGetByte,让它从xRxBuffer的对应位置取数据,而不是读huart2.Instance->DR。
我采用一个索引变量gRxReadIndex来跟踪当前已读取的字节位置,每次xMBPortSerialGetByte从缓冲区取一个字节并递增索引:
BOOL xMBPortSerialGetByte(CHAR *pucByte) { if (gRxReadIndex < gRxTotalLen) { *pucByte = xRxBuffer[gRxReadIndex++]; return TRUE; } return FALSE; }整套流程就变成了:串口空闲中断到来,DMA已经填充好缓冲区,然后协议栈在后续处理中通过xMBPortSerialGetByte逐步消费缓冲区数据。这个模式下,中断里只是提交数据,真正耗时的帧解析放到主循环的eMBPoll里完成,实时性也能得到保证。
4.3 DMA发送改造的取舍
发送方向折腾DMA的实际收益没有接收那么明显。Modbus响应帧通常只有几十个字节,一次性写入发送数据寄存器也就几十个时钟周期,用DMA发送反而要处理DMA完成中断和总线占用问题。我的做法是:如果没有大批量连续发送场景,发送就保持原来的逐字节方式,接收用DMA就足够了。
如果你确实要用DMA发送,要注意发送完成中断的时机。Modbus RTU要求发送完一帧后要留出足够的时间间隔,用DMA发送时必须在HAL_UART_TxCpltCallback里调用发送完成的协议回调,并重新使能接收。否则上位机会因为响应帧尾和下一帧之间的间隔不对而报错。
5. 常见问题与排查技巧实录
移植FreeModbus V1.6的过程中,我遇到过不少问题,这里挑几个高频率的整理成表格,方便大家直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 上位机收不到响应 | 定时器未启用或优先级太低 | 确认TIM6更新中断已开启,中断服务函数里有调用vMBPortTimerExpired |
| 响应帧偶尔丢 | DMA缓冲区长度不够,或IDLE中断被阻塞 | 增大缓冲区,IDLE中断里只做缓冲提交,不要做耗时处理 |
| 数据全部错位,CRC报错 | 串口数据位配置成了9位,或波特率不一致 | 检查USART配置,确保8位数据位,波特率与协议栈参数一致 |
| 节点地址无响应,但直连上位机能通 | 设备地址配置错误,或回调里寄存器地址偏移错 | 确认eMBInit传的地址和上位机的轮询地址一致,默认寄存器起始地址从0x0001 |
| 写寄存器成功但读出来是旧值 | eMBRegHoldingCB里写操作没有更新变量 | 检查eMode开关,写操作分支里要从pucRegBuffer memcpy到变量 |
最让我印象深刻的一次坑是IDLE中断配合DMA时,第一次收到的数据总是对的,第二次开始就乱掉。排查了半天,发现是DMA接收结束后没有重新启动DMA,导致第二次数据直接丢弃。这个问题在逻辑上很容易忽略,因为第一次启动都在主程序里,后面就依赖IDLE中断里面重启DMA。
另外还要提一下中断嵌套。我的串口中断优先级设为2,TIM6定时器设为1(数值越小优先级越高)。这个设计保证即使串口正在处理,定时器溢出也能立刻打断,避免帧超时判断被延迟。反过来,如果定时器优先级更低,遇到高频串口中断时帧超时计数会被挤掉,经常出现收不到完整帧的情况。
6. 进阶经验:把FreeModbus用得更顺手的几个技巧
移植跑通只是第一步,真正在产品里稳定运行还需要注意一些细节。
第一个技巧是合理规划寄存器地址映射。我习惯用一个寄存器地址分配表格来管理所有数据,比如0x0000到0x001F放只读状态,0x0020到0x003F放可读写的设置参数。回调函数里只需要按地址段一次判断,不做大量switch-case嵌套,代码既清晰又不容易出错。
第二个技巧是在调试阶段开启FreeModbus的调试宏。在mbconfig.h里有一个调试开关,打开后协议栈会把接收错误、超时等信息通过调试串口输出,能快速定位是哪一步出了问题。产品发布前再关掉,几乎不增加额外代码量。
第三个技巧是用逻辑分析仪验证波形。调Modbus问题比调普通串口更需要看时序,我用逻辑分析仪抓UART波形,能直观看到请求帧和响应帧的字节间隔是否满足3.5字符要求。很多上位机“偶发超时”的问题,最后都是通过波形看出来响应间隔偏大导致的。
最后说个我个人习惯的小技巧:在eMBPoll前后加一个GPIO翻转,用来测量协议栈处理一帧请求到底花了多少时间。用示波器一看,就清楚是否需要在主循环里给Modbus处理让出更多时间。实测下来,在96MHz主频的F411上,FreeModbus V1.6处理一条03功能码请求并生成响应,时间不到1ms,整个系统留出了充足的余量去跑业务逻辑。这个版本虽然老,但在性能和代码可读性之间找到了很好的平衡,值得继续用。
本文还有配套的精品资源,点击获取