☰
FreeModbus多从机改造:地址路由与寄存器池设计详解
2026/10/21 1:47:30 网站建设 项目流程

简介:FreeModbus1.5多从机工程包面向嵌入式、工业控制与物联网开发者,提供基于STM32平台的完整Modbus多从机通信实现。资源覆盖Modbus RTU(RS232/RS485)与Modbus TCP两种典型模式,重点展示主设备向多个从设备发起读写请求、从机地址分配、超时与校验错误处理等机制,同时说明Freemodbus库中从机管理、请求分发、响应组装以及异常码返回的工程实现。压缩包共263个文件,以C源文件、H头文件为主,另含汇编启动文件、目标文件、可烧录HEX以及Keil工程配置和调试辅助文件,整体约6.04MB,便于在嵌入式环境中直接参考、移植或二次开发。已有1152人学习下载,适合需要将多个从站设备接入控制网络的工程师用于方案预研、课程设计与毕业设计参考。除标准Modbus功能码处理外,工程还涉及API设计、多平台移植要点及调试工具配置,可帮助读者快速搭建多从机原型、验证通信时序并定位异常,提升协议栈集成与排错效率。

1. 为什么需要一个“多从机”的 FreeModbus:一台网关模拟 4 台仪表

freemodbus 多从机,听起来像是把多个 Modbus 从站塞进同一个 MCU,但真正做起来的第一步,是回答“地址来了往哪走”。我最早遇到这个需求是做一个串口网关:一条 RS-485 总线上挂 4 个采集节点,上位机按 1 到 4 号站轮询,实际物理设备只有一个单片机、一对收发器,却要同时承担 4 个从机地址的响应。原版 FreeModbus 1.5 的 usNodeID 是全局唯一的,一个实例只认一个地址,想改成多从机不是配个宏就能解决,必须在地址校验到功能码处理之间加一层地址路由。这个 zip 整理的就是这条改造路线的完整工程,适合做多表集抄、数据采集网关、现场仪表模拟器的开发者。拿到工程后,你不光能跑通多从机,还能看到协议栈该怎么改才不至于把原版状态机带崩。

2. 工程全景:多从机不是堆实例,而是加一层地址路由

2.1 FreeModbus 1.5 的代码分层与地址判断位置

FreeModbus 之所以适合嵌入式,是因为它的驱动依赖做得很薄。整个协议栈只需要四个外部函数:串口字节收发、定时器中断、以及一个eMBPoll驱动入口。它在运行时依赖两个中断源:串口接收中断和定时器超时中断。收到一字节数据后,串口中断把数据塞进 FIFO,定时器负责在 3.5 个字符时间后关闭接收窗口,通知上层当前帧收完。所有状态都收在 mb.c 里的eMBRTUState状态机中。

在单从机模式下,eMBInit(eMBRTU, ucNodeID, ...)调用之后,ucNodeID 就成了全局变量ucNodeID。RTU 帧处理流程是:接收完成事件被 eMBPoll 调度到,接着程序先校验 CRC,然后取出帧目的地址,和ucNodeID比较。相同就进入功能码解析,不同则丢弃。这个比较发生在 mb.c 的prvvProcessRTUFrame函数内,不到十行代码。

真正做多从机改造时,最保险的切入点是找到这不到十行的地址判断,把它替换成一个“查表 + 设置当前节点”的调用。协议栈其他部分不需要动,因为后续所有回调都通过usRegBuffer访问寄存器池,只要在进入功能码解析前设置好当前节点的寄存器池指针,读写自然就落到正确位置。这个思路听起来简单,但实际动手时,很多人会误以为要把整套状态机复制多份,导致工程越改越乱。

2.2 实例化多协议栈 vs 地址分发:为什么单串口下选后者

先说明一个概念:这里说的多从机,是“一个 UART 接口上同时支持多个从机地址”,而不是在一块板子上插多个串口从站。如果是后者,那 FreeModbus 多实例反而合理,每个串口各跑一套实例。但绝大多数网关设备只会用一组 RS-485 收发器,所以能不能共享协议栈,是工程能否简化的关键。

实例化方案的具体做法是把eMBRTUState结构体、寄存器回调数组同时复制多份,每个实例绑定一个地址。它的优点是隔离性强,但从机数增多后,每个实例都要独立申请事件队列、缓冲区、定时器,内存消耗成倍增长。更重要的是,串口中断收到一个字节后必须知道“这是给哪个实例的”,才能把数据喂给对应队列,这本身就是个路由问题,绕了一圈又回到地址分发。

地址分发方案只维护一个协议栈实例,通过一张表把地址映射到寄存器池。它的内存开销主要来自节点表和寄存器池本身,协议栈状态机只有一份。实际工程中,如果从机数在 4 到 8 个,地址分发的静态内存占用大约比单从机多出几百字节,完全可以接受。另一个隐性好处的时序响应稳定:协议栈只有一个事件队列,不会出现多个实例同时抢同一串口导致的互斥问题。

2.3 目录结构:改动点集中在哪几个文件

拿到 zip 后,先看整体文件布局。工程里最核心的目录包括 modbus 协议栈源码、port 移植层、以及多从机应用层。

目录/文件性质在多从机方案中的角色
lib/modbus/include/mb.h原版公开接口,未改
lib/modbus/include/mbconfig.h修改增加多从机节点上限、寄存器区长度宏
lib/modbus/mb.c修改地址判断处插入节点查找
lib/modbus/functions/mbfuncholding.c修改读取保持寄存器时使用当前节点寄存器池
lib/modbus/port/portserial.c修改串口收发方向切换,绑定节点表初始化
lib/modbus/port/porttimer.c原版仅需确认定时周期与波特率匹配
lib/app/multi_slave.c/h新增节点表结构、注册函数、地址查找
lib/app/slave_regs.c/h新增默认节点寄存器池、通用回调模板
demo/main.c示例串口时钟初始化、节点注册、主循环 eMBPoll

读代码的顺序,我会建议先看 multi_slave.h,再看 slave_regs.c,最后回到 mb.c 看那处插入的调用。因为节点表结构定义了多从机的数据模型,后面所有逻辑都是在为这个表服务。修改 mbconfig.h 时要注意,MB_MULTI_SLAVE_MAX定义了最大节点数,改大一点不会影响编译,但会多占内存,需要配合链接脚本调整堆栈。

编译时,确保把新增的 multi_slave.c 加入工程,并注意头文件路径。MDK 工程里如果漏加 multi_slave.c,链接会报 undefined symbol;GCC 工程则要把它加入源文件列表。我自己第一次在 GNU 工具链下编译,忘记了 Makefile 的依赖生成,导致改了头文件后没有重新编译,调了半个多小时查不到问题,这也是移植过程中很常见的坑。

3. 核心模块拆解:一帧 RTU 请求进来后,数据是怎么被路由到对应的从机

3.1 从机节点结构体与节点表注册机制

多从机的数据模型是整棵树的根。multi_slave.h 中定义的xMultiSlaveNode结构体,把 FreeModbus 原本分散在全局的寄存器池和回调函数全部收拢到节点里:

/* modbus 寄存器回调原型来自 mb.h */ typedef eMBErrorCode (*eMBRegCallBack)(uint8_t *pucRegBuffer, uint16_t usAddress, uint16_t usNRegs, eMBRegisterMode eMode); typedef struct { uint8_t ucNodeID; /* 从机地址,1~247 */ uint16_t *usHoldRegs; /* 保持寄存器池指针 */ uint16_t usNumHoldRegs; /* 保持寄存器数量 */ uint16_t *usInputRegs; /* 输入寄存器池指针 */ uint16_t usNumInputRegs; /* 输入寄存器数量 */ eMBRegCallBack cbHold; /* 保持寄存器读写回调 */ eMBRegCallBack cbInput; /* 输入寄存器读回调 */ } xMultiSlaveNode;

节点表的定义是一个全局静态数组,长度由MB_MULTI_SLAVE_MAX决定。数组元素本身不持有数据,只持有指针,所以寄存器池要在别处定义并赋给节点。比如在三号从机里,你可能有一个温度数组和一个状态数组,它俩就分别作为usHoldRegs和usInputRegs。当上位机请求三号从机的保持寄存器时,协议栈找到的是&xMBNodeTable[2],读写时通过pxNode->usHoldRegs访问三号自己的数组,天然隔离。

注册节点就是一个简单拷贝。vMultiSlaveRegisterNode把传入的节点拷贝到表中下一个空闲槽位,并检测重复地址。看代码时注意,拷贝后节点表里的usHoldRegs指针仍然指向调用者定义的静态数组,所以这些数组的生命周期必须是全局的,不能定义在函数内部。否则初始化函数的栈一释放,指针就悬空了,运行一段时间后寄存器数据完全不可控。

3.2 地址分发:查表、锁定当前节点、进入功能码处理

多从机改造最关键的一处就在 mb.c 的prvvProcessRTUFrame。原版的地址判断是“相同则处理”,改成“查表成功则处理”。新加的查找函数如下:

/* 查找地址对应的从机节点 */ const xMultiSlaveNode *pxMBFindNode(uint8_t ucNodeAddress) { uint8_t ucIndex; for (ucIndex = 0; ucIndex < g_ucSlaveCount; ucIndex++) { const xMultiSlaveNode *pxNode = &xMBNodeTable[ucIndex]; if (pxNode->ucNodeID == ucNodeAddress) { return pxNode; } } return NULL; }

这个函数在 mb.c 中被调用,返回的节点指针会保存在一个内部静态变量里,称为当前节点。当功能码处理函数需要访问寄存器时,都从当前节点取寄存器池。注意查找函数本身不接受广播地址,因为广播帧不需要回复,FreeModbus 原版也不会为广播帧执行功能码处理。如果确实需要广播功能,通常在应用层单独处理,不走节点路由。

从性能上看,线性查找在最坏情况下需要循环整个节点表。一个 Modbus RTU 帧的传输时间在 115200 波特率下大约是几百微秒,而几十次循环只占几个指令周期,完全不会成为瓶颈。实际工程里如果节点数超过 30,我一般会先把节点表按地址排序,再用二分查找,但这属于可选的优化,不是必要改造。

3.3 保持寄存器读写:回调里如何通过当前节点访问寄存器池

功能码 03 和 06、16 都落在保持寄存器回调上。多从机版示例回调如下:

static eMBErrorCode prvHoldRegsCallback( uint8_t *pucRegBuffer, uint16_t usAddress, uint16_t usNRegs, eMBRegisterMode eMode) { const xMultiSlaveNode *pxNode = pxMBGetCurrentNode(); uint16_t i; if (usAddress == 0 || usAddress + usNRegs > pxNode->usNumHoldRegs + 1) { return MB_ENOREG; } if (eMode == MB_REG_WRITE) { for (i = 0; i < usNRegs; i++) { pxNode->usHoldRegs[usAddress - 1 + i] = (uint16_t)(pucRegBuffer[i * 2] << 8) | pucRegBuffer[i * 2 + 1]; } } else { for (i = 0; i < usNRegs; i++) { uint16_t usValue = pxNode->usHoldRegs[usAddress - 1 + i]; pucRegBuffer[i * 2] = usValue >> 8; pucRegBuffer[i * 2 + 1] = usValue & 0xFF; } } return MB_ENOERR; }

这里pxMBGetCurrentNode()返回的是地址分发阶段设置的当前节点。地址范围判断是关键,usAddress是 Modbus 1 起始的地址,所以和数组长度比较时要把偏移量算清楚。如果不小心写错,很容易出现“能读前几个寄存器,读后面的就异常”的怪问题。

写保持寄存器时,Modbus 协议会把单个寄存器 06 和批量写 16 都送入同一个回调。pucRegBuffer里是按大端排列的原始字节,所以要拼成 uint16_t 再存入寄存器池。反过来读操作要把 uint16_t 拆成高、低字节写入缓冲区。这个字节序约定不能改,改了协议栈和其他主机就通信不上。如果你要给某个从机的寄存器做线性变换,直接在这个模板回调外加一层换算即可,不需要改动协议栈本身。

4. 避坑实录:多从机移植中最常见的 5 个现场问题

多从机改造代码量不大,但移植到具体 MCU 上的坑五花八门。下面这 5 条是我和一起调试的朋友几乎每周都会遇到的,按“现象-原因-解决”写清楚,建议收藏。

4.1 现象:只有第一个从机能通,其余从机全部无响应

现象描述:把四个从机节点都注册好后,主站发 01 号地址能正常读数据,换 02、03、04 地址就石沉大海。用调试器看节点表,每个节点地址都写得没错。

原因:最可能是初始化顺序里节点表没有清空。表是全局变量,在 C 语言里未初始化时可能全是 0,但某些平台下静态数组会有残留随机值。地址匹配时,01 号刚好命中第一个节点,后面的地址要么匹配到残留的随机节点,要么没有匹配。

解决:初始化函数开头做一次 memset 清空节点表,并且在vMultiSlaveRegisterNode做一条地址合法性检查,检查ucNodeID是否在 1 到 247 区间。还要注意,如果注册函数里不检查重复地址,两个从机相同地址时后一个会覆盖前一个,也会导致“只有一个从机通”。所以注册时必须包含重复检测。

4.2 现象:读保持寄存器返回异常码,上位机报表全是 0x86

现象描述:主站用 03 功能码读从机寄存器,返回的不是数据,而是异常帧。抓包看确实每个地址都回帧了,功能码是 0x83,最高位被置位,数据域里是异常码 2。

原因:协议栈找不到合法的寄存器地址,MB_ENOREG被返回。多从机之后,每个节点的usNumHoldRegs值经常和实际数组长度不一致。比如usHoldRegs数组定义成 32 个元素,但节点里usNumHoldRegs填了 64,上位机读 35 到 40 时,边界检查通过了,访问数组却越界,返回值是垃圾数据或者直接 HardFault。

解决:把每个节点的数组长度定义在节点声明的同一文件里,用sizeof(usHoldRegs) / sizeof(usHoldRegs[0])这种方式给usNumHoldRegs赋值,避免魔法数字。同时回调里的地址判断要统一用>= usNumHoldRegs而不是>,因为 Modbus 地址从 1 起,数组下标从 0 起,差一个小数点就能错一个寄存器。

4.3 现象:波特率 9600 收发正常,改成 115200 后连续 CRC 错误

现象描述:同一个工程,9600 波特率下 pymodbus 扫全部地址都通,改成 115200 后开始偶发 CRC 错误,且错误频率乱跳,不是固定地址。

原因:FreeModbus 的 RTU 状态机靠两个定时器:一个用于字符间间隔,一个用于帧结束。不同波特率下 3.5 字符时间差一个数量级。若porttimer.c的重载值没有和波特率联动,帧边界判断会出错,导致协议栈把一个完整帧拆开解析,CRC 自然对不上。

解决:移植层用定时器时钟频率和波特率动态计算重载值。假设定时器时钟是 1MHz,波特率 115200,3.5 字符时间计数值 = (3.5 * 11 * 1000000) / 115200,约等于 334 个计数。实际工程建议留 10% 余量,取 300 左右。修改波特率时,porttimer 的初始化接口要能接收新的波特率,或者从全局配置里读取。否则你只改 UART 波特率,定时器还拿着 9600 的旧值,就会出现这种半天摸不着头脑的问题。

4.4 现象:发送完响应帧后,紧接着的接收帧全部丢失

现象描述:上位机单发一条请求,从机能回正确响应。但连续快速轮询时,从机偶尔丢帧,表现为该地址的响应隔一次消失一次。抓包发现从机发送结束后立刻开始接收回波,而回波被当成接收数据。

原因:RS-485 半双工,发送和接收共用一对差分线。发送完后 DE 引脚如果立刻拉低,收发器还会把发送引脚上的最后一位反射回接收端,这个回波进入 MCU 的 RX 引脚后,会被当作下一帧的第一个字节。多从机节点数多,主站轮询周期短,这个问题更容易暴露。

解决:在发送完成中断里等待串口的 TXC/TXE 标志位置位,然后延迟一个字符时间再关闭发送模式。这个延迟可以用一个短延时定时器,或者直接把发送状态机的状态迁移延后到定时器中断。如果 MCU 的串口支持硬件自动方向切换,可以打开 RTS 流控自动控制 DE,但软件实现时一定要避免在主循环里做阻塞式延时,否则主循环繁忙时方向切换会被推迟,下一帧就切不回来了。

4.5 现象:运行几小时后某个从机突然不响应,重启后恢复

现象描述:设备运行一两个小时,某一从机地址突然无响应,其他地址正常。复位后又好,过几个小时又复现,看起来像随机故障。

原因:十有八九是寄存器池越界写坏。多从机的寄存器池和节点表存在连续内存区域,某个从机的写回调边界检查不严,一次写长度过大的 16 功能码就会把数据写到相邻节点结构体上。结构体内的ucNodeID被覆盖成别的值,查找时自然匹配不上。

解决:给节点表末尾放一个保护魔术字,每次进入pxMBFindNode时检查魔术字是否被改变。如果发现变了,说明有内存越界,可以主动触发断言或保存现场。这是查这类问题的有效手段。另外,建议每个从机注册自己的回调时,都在回调第一行做完整的长度检查,不要依赖协议栈内部已经做了检查,因为协议栈只检查了缓冲区长度,没有检查节点寄存器池长度。

5. 二次开发:把自己协议接进这个多从机工程

5.1 新增一个从机的三步

假设你的系统要增加一个 3 号从机,只需要在 slave_regs.c 里开一组静态寄存器池,然后在 multi_slave.c 的初始化函数里注册节点:

static uint16_t regsSlot03[16]; static uint16_t inputsSlot03[8]; static const xMultiSlaveNode node03 = { .ucNodeID = 3, .usHoldRegs = regsSlot03, .usNumHoldRegs = 16, .usInputRegs = inputsSlot03, .usNumInputRegs = 8, .cbHold = prvGenericRegsCallback, .cbInput = prvGenericInputsCallback, }; void vMultiSlaveInit(void) { vMultiSlaveRegisterNode(&node01); vMultiSlaveRegisterNode(&node02); vMultiSlaveRegisterNode(&node03); }

之后功能码读写就会自动路由到这个节点的寄存器池。如果 3 号从机的寄存器需要做数据换算,单独写一个cbHold替换prvGenericRegsCallback即可。

5.2 验证:用一段 Python 脚本扫一遍多从机

验证整个工程最有效的方式是写一段串口扫描脚本。要点是循环请求各地址的保持寄存器,打印每个地址的响应状态。这里用 pymodbus 的串口客户端:

from pymodbus.client import ModbusSerialClient client = ModbusSerialClient(port='/dev/ttyUSB0', baudrate=115200, timeout=0.5) for addr in range(1, 6): rr = client.read_holding_registers(0, 8, slave=addr) if rr.isError(): print(addr, "error") else: print(addr, "ok", rr.registers)

跑通后,每次改代码都可以先用这个脚本确认没有破坏已有从机。从那以后我每次调多从机,都会先扫一遍地址再开始改逻辑,省掉太多现场排查的力气。希望帮到你。

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

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

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

立即咨询