1
驱动概览
这是什么

Modbus TCP 驱动是 EasyPro 智慧楼宇管控平台用于接入 Modbus 协议设备的协议驱动。Modbus 是工业自动化领域应用最广泛的通信协议之一,几乎所有主流 PLC、变频器、仪表、网关都支持它。

平台作为 Modbus 主站(Master),主动轮询一个或多个 从站(Slave)设备,读取传感器数据、下发控制指令。一个驱动实例对应一台从站设备(由 IP + 端口 + 从站号三元组唯一确定)。

关键事实
属性值说明
驱动名称modbusSPI 注册名,getDriverName() 返回
版本(空)getDriverVersion() 当前未设置版本号
接入模式EMBEDDED 内嵌编译进平台主应用,随启动加载
协议库modbus4j(Serotonin)成熟的 Java Modbus 实现
传输方式TCP / RTU-over-TCP / RTU-over-UDP / UDP由实例属性 connectType 决定
通信模型主从 · 请求-响应平台主动轮询,设备被动应答
Web 接口无getHttpApiCallBackList() 返回 List.of()
定时任务1 个(心跳+读触发)每 5 秒巡检全部实例
2
Modbus 协议核心概念
主从通信拓扑

Modbus 是单主多从架构。主站逐一向每个从站发起请求,从站被动应答。同一时刻主站只与一个从站通信。

flowchart LR M[" 平台
Modbus 主站
(Master)"] S1["设备 A
从站 1
IP:502"] S2["设备 B
从站 2
IP:502"] S3["设备 C
从站 3
IP:502"] M -->|"请求/响应"| S1 M -.->|"请求/响应"| S2 M -.->|"请求/响应"| S3 style M fill:#e8f0fe,stroke:#007aff,stroke-width:2px,color:#1c1c1e style S1 fill:#e8f8f0,stroke:#34c759,color:#1c1c1e style S2 fill:#e8f8f0,stroke:#34c759,color:#1c1c1e style S3 fill:#e8f8f0,stroke:#34c759,color:#1c1c1e
四类寄存器

Modbus 设备的数据按 寄存器类型 组织,每种类型有不同的读写权限和对应的功能码。这是理解点位配置的基础:

寄存器类型数据宽度读写读功能码写功能码典型用途
线圈(COIL)1 bit读写0x010x05 / 0x0F开关量输出(继电器、指示灯)
离散输入(DISCRETE)1 bit只读0x02—开关量输入(按钮、状态)
保持寄存器(HOLDING)16 bit读写0x030x06 / 0x10设定值、参数(最常用)
输入寄存器(INPUT)16 bit只读0x04—测量值(温度、电压)
记忆要点

「线圈/离散」是 1 位的开关量;「保持/输入」是 16 位的数值量。保持寄存器既能读又能写,是下发控制的主要通道,也是本驱动写功能码回退逻辑的核心。

地址与寻址

每个寄存器在设备内有唯一 地址(address)。平台从 0 起按寄存器偏移寻址(PLC-zero 寻址)。例如保持寄存器地址 10 对应设备手册上常见的 40011(40001 + 10)。

对于 32 位整数、浮点数等跨多寄存器的数据类型(占 2 个 16 位寄存器),还需指定 字节序(dataOffSet) 说明高低字节的排列顺序,否则不同厂商设备会解析出错误数值。

3
为什么这样设计
轮询 vs 推送

Modbus 是纯请求-响应协议,设备不会主动上报。因此驱动必须 周期性轮询 每个点位。这与 MQTT/KNX 等「事件驱动」驱动形成鲜明对比:

维度Modbus(轮询)MQTT/YCBox(推送)
数据来源主站主动读设备主动上报
实时性取决于轮询周期近实时(事件触发)
网络负载固定(不论数据是否变化)按需(有变化才发)
适用场景工业 PLC、仪表、网关物联网网关、传感器
设计取舍
每实例独立虚拟线程

读循环放在每实例独立的虚拟线程里运行,互不阻塞;单台设备慢响应不会拖累其他设备。

智能分包读取

相邻地址的点位打包成一次请求,减少报文数量;遇到地址断裂或读写边界自动拆包。

写功能码自动回退

部分老旧设备不支持 0x10 批量写,驱动可自动降级为 0x06 单寄存器写,兼容性最大化。

心跳巡检 + 自动重连

定时任务每 5 秒巡检全部实例,掉线自动重连、配置变更自动重建。

4
整体架构
组件协作
flowchart TB subgraph 接入层 A["ModbusAccess
实现 DriverAccess"] end subgraph 管理层 M["ModbusTcpServerManager
实例/点位校验 · 心跳巡检 · 写分发"] end subgraph 连接层["连接层(每实例一个)"] S1["ModbusTcpServer #1
建连 · 虚拟线程读循环"] S2["ModbusTcpServer #2
建连 · 虚拟线程读循环"] end subgraph 任务 T["ModbusAliveTask
cron 0/5 * * * * ?"] end T -->|"每5秒触发"| M A -->|"委托"| M M -->|"巡检/建连"| S1 M -->|"巡检/建连"| S2 M -->|"issuePointValueBatch"| S1 style A fill:#e8f0fe,stroke:#007aff,stroke-width:2px,color:#1c1c1e style M fill:#f0f0f5,stroke:#5856d6,stroke-width:2px,color:#1c1c1e style S1 fill:#e8f8f0,stroke:#34c759,color:#1c1c1e style S2 fill:#e8f8f0,stroke:#34c759,color:#1c1c1e style T fill:#fff4e8,stroke:#ff9500,color:#1c1c1e
核心类职责
类职责
ModbusAccess驱动接入层,实现 DriverAccess 接口的 6 个核心方法,是平台加载驱动的入口
ModbusTcpServerManager实例与点位属性校验、心跳巡检、写值分发、实例开关
ModbusTcpServer单实例连接管理(建连/重连)、分包读取、写值与功能码回退
ModbusAliveTask定时任务,每 5 秒巡检全部实例存活状态
BuildBean属性 Map ↔ 实体对象的转换、点位唯一码生成
5
连接生命周期
建连与重连

驱动为每个实例维护独立的 ModbusMaster 连接。建连由专门的 initThread 完成,连接失败按 retryInterval 间隔持续重试,直到成功。建连前会做一次端口可达性预检(isPortOpen),避免长时间阻塞。

sequenceDiagram participant T as AliveTask (每5秒) participant M as ServerManager participant S as ModbusTcpServer participant D as 从站设备 T->>M: checkDriverInstanceAlive() M->>S: startRead() 若未运行 S->>S: 端口预检 isPortOpen alt 端口可达 S->>D: 建立 TCP 连接 D-->>S: 连接成功 S->>S: 启动读循环虚拟线程 else 端口不可达/超时 S->>S: sleep(retryInterval) 后重试 end Note over S,D: 读循环中若连续异常 → reConnected() 销毁后重建
心跳巡检

ModbusAliveTask 每 5 秒触发一次 checkDriverInstanceAlive(),这是驱动的「主 reconciler」循环。它会:

  • 遍历所有「已打开」的驱动实例
  • 为每个实例创建或复用 ModbusTcpServer
  • 离线的实例触发重连;配置变更的实例重建连接
  • 调用 server.startRead() 拉起(每实例一个)虚拟线程读循环
注意

老的 ModbusReadValueTask(cron 0/4)已被标记为 @Deprecated,其读逻辑被注释。读值现由 aliveTask 拉起的虚拟线程驱动,不再走单独的定时任务。

6
数据采集流程
智能分包读取

读循环会先把一个实例下的所有点位按寄存器类型分组,组内按地址排序,再把地址连续或接近的点位打包成一次读请求(一个 packet),从而最小化网络往返。拆包规则由两个参数控制:

参数含义拆包行为
packetInterval地址间隔阈值相邻点位地址差超过此值 → 拆成新包
packetLength单包最大寄存器数超过此长度 → 拆包(防报文过大)
读写边界只读点 vs 读写点不同访问模式的点位不会混在同一包
读循环时序
sequenceDiagram participant L as 虚拟线程读循环 participant P as PlatformClient participant D as 从站设备 loop 每个采集周期 L->>P: 获取该实例全部点位 L->>L: buildPackage(分组+排序+分包) loop 每个 packet L->>D: 读请求(按功能码 0x01/0x02/0x03/0x04) D-->>L: 寄存器原始字节 L->>L: dataAnylysis(按 dataType 解析) L->>L: sleep(commandCycleInterval) 控速 end L->>P: pushValue(推送变化值) L->>L: sleep(equipCycleInterval) 进入下一周期 end
数据类型解析

读回的原始字节按点位的 dataType 解析为平台类型。跨寄存器类型(int32/float 占 2 个寄存器)的字节序由 dataOffSet 决定:

Modbus 数据类型平台类型占寄存器数
boolean / bitBoolean—
int8 / int8uInteger1(取高/低 8 位)
int16 / int16uInteger1
int32 / int32uInteger / Long2
floatFloat2
7
下行写与功能码自动回退
两种写保持寄存器的功能码

写保持寄存器有两种功能码可选:

功能码名称特点
0x10写多个寄存器一次请求写一批,效率高(默认)
0x06写单个寄存器一次写一个,兼容性好但 int32/float 需发 2 次
痛点

少数老旧或定制设备只支持 0x06 而拒绝 0x10。若平台用默认 0x10 写,会收到超时或异常响应,导致控制指令失败。本驱动的功能码回退机制正是为解决此问题而生。

自动回退决策流程

由 writeRegisterWithFallback 实现,区分两种失败场景,仅在 writeCodeSwitchMode=auto 时触发降级:

flowchart TD Start(["平台下发写值"]) --> W10["用 0x10 写"] W10 --> R1{结果?} R1 -->|成功| Done(["完成 ✓"]) R1 -->|异常响应
返回 false| SceneB R1 -->|超时
ModbusTransportException| Retry{"重试 < 3 次?"} Retry -->|是| Reconnect1["reConnected + sleep
(retryInterval)"] --> W10 Retry -->|否
超时耗尽| SceneA subgraph SceneA["场景 A:超时 3 次耗尽"] RA1{"switchMode
= auto?"} -->|是| RA2["reConnected → 降级 0x06"] RA2 --> RA3{0x06 成功?} RA3 -->|是| Persist(["✓ 持久化切换为 0x06"]) RA3 -->|否| FailA(["失败 ✗ 抛出超时"]) RA1 -->|否, manual| FailA end subgraph SceneB["场景 B:异常响应"] RB1{"switchMode
= auto?"} -->|是| RB2["直接降级 0x06
(不重连不重试)"] RB2 --> RB3{0x06 成功?} RB3 -->|是| Persist RB3 -->|否| FailB(["失败 ✗"]) RB1 -->|否, manual| FailB end style Start fill:#e8f0fe,stroke:#007aff,color:#1c1c1e style Done fill:#e8f8f0,stroke:#34c759,color:#1c1c1e style Persist fill:#e8f8f0,stroke:#34c759,stroke-width:2px,color:#1c1c1e style FailA fill:#fdecee,stroke:#ff3b30,color:#1c1c1e style FailB fill:#fdecee,stroke:#ff3b30,color:#1c1c1e
关键设计:仅成功才落库
防误判

功能码切换只有在 0x06 写入真正成功后才会调用 switchWriteFunctionCode 持久化到平台数据库(更新内存 volatile 对象 + 异步写库)。这避免了因偶发网络抖动导致的误切换——只有设备真正响应 0x06 成功,才认为它「确实只支持 0x06」。

写线圈(COIL)

线圈点位(1 位开关量)的写不经过回退逻辑,直接用 0x0F(写多个线圈)批量下发。

8
实例属性表
连接与采集参数(共 13 项)

每个 Modbus 驱动实例由以下属性配置,存储于 iot_driver_attribute。来源:ModbusTcpDriverInstance 实体 + BuildBean.buildDriverInstance 字段映射。

属性名类型必填默认说明
hoststring必填—从站设备 IP 地址
portint必填—从站端口(标准 502)
slaveIdint必填—从站编号(1-247)
connectTypestring可选tcp连接方式:tcp / rtu2tcp / rtu2udp / udp
databitsstring必填—多寄存器字节序:0123/3210/1032/2301
packetIntervalint必填—分包地址间隔阈值(超出则拆包)
packetLengthint必填—单包最大寄存器数
timeoutint必填—连接/读取超时(毫秒)
equipCycleIntervalint必填—完整轮询周期间隔(毫秒)
commandCycleIntervalint必填—同一从站连续命令间隔(毫秒,控速)
retryIntervalint必填—重连/重试间隔(毫秒)
writeFunctionCodestring可选0x10写保持寄存器功能码:0x10 / 0x06
writeCodeSwitchModestring可选auto切换模式:auto(自动降级)/ manual(不降级)
唯一性校验

host + port + slaveId 三元组必须在同一项目内唯一。重复会报「IP、端口和从站编号已被占用」。批量导入时同样按此 key 集合去重。

9
点位属性表
5 项点位属性 + 级联规则

每个 Modbus 点位由以下属性定义,存储于 iot_driver_point_attribute。其中数据类型、偏移、位长度的可选项随上一级联动:

属性名含义可选项 / 规则
registerType寄存器类型线圈 / 离散输入 / 保持寄存器 / 输入寄存器
address寄存器地址整数,范围受 option_min/max 约束
dataType数据类型级联 registerType,如线圈→boolean;保持→int16/int32/float/bit 等
dataOffSet字节/位偏移级联 registerType→dataType:高8位/低8位/高字节/低字节 或位序 0123/3210/1032/2301
bitLength位提取长度默认 "0";级联 dataOffSet
级联关系一览
flowchart LR RT["registerType
寄存器类型"] --> DT["dataType
数据类型"] DT --> DO["dataOffSet
字节/位偏移"] DO --> BL["bitLength
位长度"] style RT fill:#e8f0fe,stroke:#007aff,color:#1c1c1e style DT fill:#f0f0f5,stroke:#5856d6,color:#1c1c1e style DO fill:#fff4e8,stroke:#ff9500,color:#1c1c1e style BL fill:#e8f8f0,stroke:#34c759,color:#1c1c1e

选择上一级后,下一级的可选值会动态收窄。这保证了点位配置在协议层面的合法性。

点位唯一码

点位在实例内的唯一标识由以下公式生成(BuildBean.getPointUniqueCode):

formula
# 基础四元组
registerType && address && dataType && dataOffSet

# 若 bitLength 非空,追加
registerType && address && dataType && dataOffSet && bitLength

例如:保持寄存器&&10&&float&&3210。批量校验时按此五元组去重。

10
Web 接口与定时任务
Web 接口(HTTP API)

Modbus 驱动不对外暴露任何 HTTP 接口:getHttpApiCallBackList() 返回 List.of()。所有数据采集与控制都通过定时任务和平台的点位下发机制完成。

对比

这是 Modbus 与 BACnet/MQTT/KNX 等驱动的一个显著区别——后者通常提供扫描/导入/检测类 API。Modbus 的点位需在平台侧手工或批量配置。

定时任务
任务名Cron频率职责
modbusAliveTask0/5 * * * * ?每 5 秒心跳巡检全部实例:建连/重连/重建/拉起读循环
readValueTask 已废弃0/4 * * * * ?每 4 秒@Deprecated,读逻辑已注释;读值改由 aliveTask 的虚拟线程承担
11
数据类型与容错
字节序取值

多字节值(int32/float)的字节排列顺序由 dataOffSet/databits 决定,覆盖四种常见端序:

取值端序名称字节顺序
0123Big-Endian大端序(Modbus 标准)
3210Little-Endian小端序
1032Big-Endian Byte Swap大端字节交换
2301Little-Endian Byte Swap小端字节交换
容错与重试策略
读异常 → 重连

读请求抛出异常时调用 reConnected() 销毁旧连接重建,避免连接腐化。

写超时 → 重试 3 次

0x10 写超时(ModbusTransportException)最多重试 3 次,每次 reConnected + retryInterval。

异常响应不重试

设备返回异常响应(isException())不重试,直接进入功能码降级判断。

状态推送

设备在线/离线状态变化经 updateOnOff 推送至平台,前端实时可见。

12
常见问题
Q1
点位读到的 int32/float 数值乱码,怎么排查?
这是典型的字节序配置错误。不同厂商设备的 32 位数据高低字节排列可能不同。请尝试切换实例属性 databits(或点位 dataOffSet)为 0123 / 3210 / 1032 / 2301 四种取值之一,观察哪个能得到正确数值。
Q2
下发控制指令后设备没反应,日志显示写超时?
大概率是设备不支持 0x10 批量写。若实例 writeCodeSwitchMode=auto,驱动会自动重试 3 次后降级为 0x06 单寄存器写,并在成功后持久化切换。若设为 manual 则不会降级,需手动将 writeFunctionCode 改为 0x06。详见写功能码切换专题。
Q3
一台从站设备可以建多个驱动实例吗?
同一项目内,host + port + slaveId 三元组必须唯一。但同一台设备的同一个从站号,不同从站号可分别建实例;或同一台设备在不同项目下可重复配置(项目间不冲突)。
Q4
轮询周期设多少合适?
由 equipCycleInterval(完整轮询周期)控制。常见取值 1000-5000ms。设太短会增加设备负载和网络压力;设太长则数据更新慢。commandCycleInterval 控制同一从站连续命令间隔,用于防止「轰炸」老旧设备,通常 50-200ms。
Q5
RTU 设备怎么接入?
若 RTU 串口设备通过串口服务器接入网络,将实例 connectType 设为 rtu2tcp(RTU over TCP)或 rtu2udp。驱动会以 RTU 帧格式封装报文经 TCP/UDP 传输。
Q6
readValueTask 和 aliveTask 有什么区别?
早期版本用独立的 readValueTask(每 4 秒)读值。当前版本已将其标记 @Deprecated,读值改由 aliveTask(每 5 秒)拉起的每实例独立虚拟线程承担。这种改进让读循环更连续、不依赖固定 cron 节拍,且实例间互不阻塞。