Modbus TCP 驱动功能设计
工业标准 Modbus 协议接入 · 多寄存器批量读写 · 写功能码自动回退
Modbus TCP 驱动是 EasyPro 智慧楼宇管控平台用于接入 Modbus 协议设备的协议驱动。Modbus 是工业自动化领域应用最广泛的通信协议之一,几乎所有主流 PLC、变频器、仪表、网关都支持它。
平台作为 Modbus 主站(Master),主动轮询一个或多个 从站(Slave)设备,读取传感器数据、下发控制指令。一个驱动实例对应一台从站设备(由 IP + 端口 + 从站号三元组唯一确定)。
| 属性 | 值 | 说明 |
|---|---|---|
| 驱动名称 | modbus | SPI 注册名,getDriverName() 返回 |
| 版本 | (空) | getDriverVersion() 当前未设置版本号 |
| 接入模式 | EMBEDDED 内嵌 | 编译进平台主应用,随启动加载 |
| 协议库 | modbus4j(Serotonin) | 成熟的 Java Modbus 实现 |
| 传输方式 | TCP / RTU-over-TCP / RTU-over-UDP / UDP | 由实例属性 connectType 决定 |
| 通信模型 | 主从 · 请求-响应 | 平台主动轮询,设备被动应答 |
| Web 接口 | 无 | getHttpApiCallBackList() 返回 List.of() |
| 定时任务 | 1 个(心跳+读触发) | 每 5 秒巡检全部实例 |
Modbus 是单主多从架构。主站逐一向每个从站发起请求,从站被动应答。同一时刻主站只与一个从站通信。
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 | 读写 | 0x01 | 0x05 / 0x0F | 开关量输出(继电器、指示灯) |
| 离散输入(DISCRETE) | 1 bit | 只读 | 0x02 | — | 开关量输入(按钮、状态) |
| 保持寄存器(HOLDING) | 16 bit | 读写 | 0x03 | 0x06 / 0x10 | 设定值、参数(最常用) |
| 输入寄存器(INPUT) | 16 bit | 只读 | 0x04 | — | 测量值(温度、电压) |
「线圈/离散」是 1 位的开关量;「保持/输入」是 16 位的数值量。保持寄存器既能读又能写,是下发控制的主要通道,也是本驱动写功能码回退逻辑的核心。
每个寄存器在设备内有唯一 地址(address)。平台从 0 起按寄存器偏移寻址(PLC-zero 寻址)。例如保持寄存器地址 10 对应设备手册上常见的 40011(40001 + 10)。
对于 32 位整数、浮点数等跨多寄存器的数据类型(占 2 个 16 位寄存器),还需指定 字节序(dataOffSet) 说明高低字节的排列顺序,否则不同厂商设备会解析出错误数值。
Modbus 是纯请求-响应协议,设备不会主动上报。因此驱动必须 周期性轮询 每个点位。这与 MQTT/KNX 等「事件驱动」驱动形成鲜明对比:
| 维度 | Modbus(轮询) | MQTT/YCBox(推送) |
|---|---|---|
| 数据来源 | 主站主动读 | 设备主动上报 |
| 实时性 | 取决于轮询周期 | 近实时(事件触发) |
| 网络负载 | 固定(不论数据是否变化) | 按需(有变化才发) |
| 适用场景 | 工业 PLC、仪表、网关 | 物联网网关、传感器 |
读循环放在每实例独立的虚拟线程里运行,互不阻塞;单台设备慢响应不会拖累其他设备。
相邻地址的点位打包成一次请求,减少报文数量;遇到地址断裂或读写边界自动拆包。
部分老旧设备不支持 0x10 批量写,驱动可自动降级为 0x06 单寄存器写,兼容性最大化。
定时任务每 5 秒巡检全部实例,掉线自动重连、配置变更自动重建。
实现 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 ↔ 实体对象的转换、点位唯一码生成 |
驱动为每个实例维护独立的 ModbusMaster 连接。建连由专门的 initThread 完成,连接失败按 retryInterval 间隔持续重试,直到成功。建连前会做一次端口可达性预检(isPortOpen),避免长时间阻塞。
ModbusAliveTask 每 5 秒触发一次 checkDriverInstanceAlive(),这是驱动的「主 reconciler」循环。它会:
- 遍历所有「已打开」的驱动实例
- 为每个实例创建或复用
ModbusTcpServer - 离线的实例触发重连;配置变更的实例重建连接
- 调用
server.startRead()拉起(每实例一个)虚拟线程读循环
老的 ModbusReadValueTask(cron 0/4)已被标记为 @Deprecated,其读逻辑被注释。读值现由 aliveTask 拉起的虚拟线程驱动,不再走单独的定时任务。
读循环会先把一个实例下的所有点位按寄存器类型分组,组内按地址排序,再把地址连续或接近的点位打包成一次读请求(一个 packet),从而最小化网络往返。拆包规则由两个参数控制:
| 参数 | 含义 | 拆包行为 |
|---|---|---|
packetInterval | 地址间隔阈值 | 相邻点位地址差超过此值 → 拆成新包 |
packetLength | 单包最大寄存器数 | 超过此长度 → 拆包(防报文过大) |
| 读写边界 | 只读点 vs 读写点 | 不同访问模式的点位不会混在同一包 |
读回的原始字节按点位的 dataType 解析为平台类型。跨寄存器类型(int32/float 占 2 个寄存器)的字节序由 dataOffSet 决定:
| Modbus 数据类型 | 平台类型 | 占寄存器数 |
|---|---|---|
| boolean / bit | Boolean | — |
| int8 / int8u | Integer | 1(取高/低 8 位) |
| int16 / int16u | Integer | 1 |
| int32 / int32u | Integer / Long | 2 |
| float | Float | 2 |
写保持寄存器有两种功能码可选:
| 功能码 | 名称 | 特点 |
|---|---|---|
0x10 | 写多个寄存器 | 一次请求写一批,效率高(默认) |
0x06 | 写单个寄存器 | 一次写一个,兼容性好但 int32/float 需发 2 次 |
少数老旧或定制设备只支持 0x06 而拒绝 0x10。若平台用默认 0x10 写,会收到超时或异常响应,导致控制指令失败。本驱动的功能码回退机制正是为解决此问题而生。
由 writeRegisterWithFallback 实现,区分两种失败场景,仅在 writeCodeSwitchMode=auto 时触发降级:
返回 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」。
线圈点位(1 位开关量)的写不经过回退逻辑,直接用 0x0F(写多个线圈)批量下发。
每个 Modbus 驱动实例由以下属性配置,存储于 iot_driver_attribute。来源:ModbusTcpDriverInstance 实体 + BuildBean.buildDriverInstance 字段映射。
| 属性名 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
host | string | 必填 | — | 从站设备 IP 地址 |
port | int | 必填 | — | 从站端口(标准 502) |
slaveId | int | 必填 | — | 从站编号(1-247) |
connectType | string | 可选 | tcp | 连接方式:tcp / rtu2tcp / rtu2udp / udp |
databits | string | 必填 | — | 多寄存器字节序:0123/3210/1032/2301 |
packetInterval | int | 必填 | — | 分包地址间隔阈值(超出则拆包) |
packetLength | int | 必填 | — | 单包最大寄存器数 |
timeout | int | 必填 | — | 连接/读取超时(毫秒) |
equipCycleInterval | int | 必填 | — | 完整轮询周期间隔(毫秒) |
commandCycleInterval | int | 必填 | — | 同一从站连续命令间隔(毫秒,控速) |
retryInterval | int | 必填 | — | 重连/重试间隔(毫秒) |
writeFunctionCode | string | 可选 | 0x10 | 写保持寄存器功能码:0x10 / 0x06 |
writeCodeSwitchMode | string | 可选 | auto | 切换模式:auto(自动降级)/ manual(不降级) |
host + port + slaveId 三元组必须在同一项目内唯一。重复会报「IP、端口和从站编号已被占用」。批量导入时同样按此 key 集合去重。
每个 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 |
寄存器类型"] --> 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):
# 基础四元组 registerType && address && dataType && dataOffSet # 若 bitLength 非空,追加 registerType && address && dataType && dataOffSet && bitLength
例如:保持寄存器&&10&&float&&3210。批量校验时按此五元组去重。
Modbus 驱动不对外暴露任何 HTTP 接口:getHttpApiCallBackList() 返回 List.of()。所有数据采集与控制都通过定时任务和平台的点位下发机制完成。
这是 Modbus 与 BACnet/MQTT/KNX 等驱动的一个显著区别——后者通常提供扫描/导入/检测类 API。Modbus 的点位需在平台侧手工或批量配置。
| 任务名 | Cron | 频率 | 职责 |
|---|---|---|---|
modbusAliveTask | 0/5 * * * * ? | 每 5 秒 | 心跳巡检全部实例:建连/重连/重建/拉起读循环 |
readValueTask 已废弃 | 0/4 * * * * ? | 每 4 秒 | @Deprecated,读逻辑已注释;读值改由 aliveTask 的虚拟线程承担 |
多字节值(int32/float)的字节排列顺序由 dataOffSet/databits 决定,覆盖四种常见端序:
| 取值 | 端序名称 | 字节顺序 |
|---|---|---|
0123 | Big-Endian | 大端序(Modbus 标准) |
3210 | Little-Endian | 小端序 |
1032 | Big-Endian Byte Swap | 大端字节交换 |
2301 | Little-Endian Byte Swap | 小端字节交换 |
读请求抛出异常时调用 reConnected() 销毁旧连接重建,避免连接腐化。
0x10 写超时(ModbusTransportException)最多重试 3 次,每次 reConnected + retryInterval。
设备返回异常响应(isException())不重试,直接进入功能码降级判断。
设备在线/离线状态变化经 updateOnOff 推送至平台,前端实时可见。
databits(或点位 dataOffSet)为 0123 / 3210 / 1032 / 2301 四种取值之一,观察哪个能得到正确数值。writeCodeSwitchMode=auto,驱动会自动重试 3 次后降级为 0x06 单寄存器写,并在成功后持久化切换。若设为 manual 则不会降级,需手动将 writeFunctionCode 改为 0x06。详见写功能码切换专题。host + port + slaveId 三元组必须唯一。但同一台设备的同一个从站号,不同从站号可分别建实例;或同一台设备在不同项目下可重复配置(项目间不冲突)。equipCycleInterval(完整轮询周期)控制。常见取值 1000-5000ms。设太短会增加设备负载和网络压力;设太长则数据更新慢。commandCycleInterval 控制同一从站连续命令间隔,用于防止「轰炸」老旧设备,通常 50-200ms。connectType 设为 rtu2tcp(RTU over TCP)或 rtu2udp。驱动会以 RTU 帧格式封装报文经 TCP/UDP 传输。readValueTask(每 4 秒)读值。当前版本已将其标记 @Deprecated,读值改由 aliveTask(每 5 秒)拉起的每实例独立虚拟线程承担。这种改进让读循环更连续、不依赖固定 cron 节拍,且实例间互不阻塞。