jDCS 开源项目:面向工业现场的 Modbus RTU 数据采集基础框架

SLOGAN:把"稳定采集"做扎实,把"外围"留给你。


先把定位说清楚,免得有人 clone 下来才发现不是自己想要的。

jDCS (Java Data Collector Service)是一套 Modbus RTU 数据采集框架,技术底座是 Spring Boot 3.5.16 + JDK 17,协议栈用的 digitalpetri 的 modbus-serial。它不是拿来就能跑的成品软件,而是一套骨架:采集内核那部分(连接管理、超时、重试、故障兜底)做扎实,外围那几件事(数据存哪、测点从哪来、日志怎么落)只给接口和默认实现。

开源地址先放这里:

工业现场的采集节点,大多是一台边缘网关,一根 RS485 线,后面挂一串从站。


一、这个项目从哪来

做过工业数采的人,大概率遇到过这么一幕。数据平台上某个车间的数据不刷新了,登录网关一看,进程活得好好的:

bash 复制代码
$ systemctl status jdcs
● jdcs.service - JDCS - Java Data Collector Service
     Active: active (running) since Wed 2026-08-12 08:31:04 CST; 3h 12min ago

CPU 0%,内存正常,没有异常堆栈,没有 OOM。再看日志,采集记录停在二十多分钟前,后面一片空白。

进程活着,采集停了,Restart=always 一次都没触发。因为在操作系统眼里这个服务运行得好好的。业内把这种故障叫"假死"。

写这个项目之前,我在一台网关前面蹲了两个小时,把这些坑一个个刨出来。后来发现根子不在某段业务代码,而在这类场景本身。三个坑叠在一起:

  • 串口不会告诉你对端断了。

TCP 里对端消失会给你 FIN 或 RST,是个明确事件。Modbus RTU 跑在串口上,从站断电或者对端的模拟器被关掉,你什么都收不到。没有异常,没有 EOF。在库看来链路一切正常,只是对端不再回应,于是读操作就静静等在那里。

  • 应用层超时穿不透 JNI。

很多人第一反应是加超时。典型写法是这样:

java 复制代码
Future<?> future = executor.submit(this::readFromSerial);
future.get(5, TimeUnit.SECONDS);

Future.get 超时之后,等待的调用方是返回了,可真正那次读还卡在 JNI 里,卡在 jSerialComm 的 readBytes() 里。future.cancel(true) 只能设置一个中断标志位,这个标志位穿不透 JNI 调用。结果是主线程靠硬超时正常返回,继续处理下一个请求,而那个卡住的线程永远留在那儿。跑得越久泄漏越多,线程池总有耗尽的一天。

  • 进程没退出,systemd 也不会管。

systemd 判断要不要重启,依据是进程是否退出。JVM 还活着,采集这件事已经死了,没有任何机制会来救它。

三个坑连起来就是一条很典型的无人值守故障链:

java 复制代码
对端静默 → 底层读永久阻塞 → JNI 线程泄漏 → 线程耗尽 → 采集停摆
                                          ↑
                                而进程还活着,没人来救

我当时用的 digitalpetri/modbus 的 modbus-serial 模块,库本身质量不错,模块化干净,不会像 modbus-tcp 那样拖进 Netty。翻源码发现它初始化串口时从不调用 setComPortTimeouts(...),而 jSerialComm 的读默认是无限阻塞的。库在应用层有个 requestTimeout,但这个超时只能把 Future 标记失败,打不断底层那次阻塞的 readBytes()。

这个问题我整理成 issue 反馈给了上游,编号 #170,维护者第二天就提了修复 PR #171。但从修复到正式发版有个时间差,在那之前用老版本的人还是得自己在应用层兜住。

也是从那时候起,我改了想法。不做"一个能跑的采集程序",做"一套把稳定采集做扎实的骨架"。

二、jDCS 能提供什么

下面这些是框架已经做好的部分。挑几个和现场关系最大的讲。

2.1 采集链路:批量合并 + 位型隔离

按 (slaveId, functionCode) 分组,同组内地址相邻的测点合并成一次请求,减少总线往返。

这里有个容易被忽略的例外:功能码 01(线圈)和 02(离散输入)不能参与合并。它们和寄存器的数据模型根本不一样:

寄存器(FC03/04) 位型(FC01/02)
每个点占 1 个寄存器 = 2 字节 1 bit
一个字节装几个点 半个 8 个
响应长度 点数 × 2 字节 ⌈点数 / 8⌉ 字节

按"寄存器 × 2 字节"的模型去合并位型测点,第二个及之后的测点会按寄存器偏移切片,必然越界。更麻烦的是第一个测点会把整个寄存器当成 BOOL 读,非零即真,静默给出一个错误的状态值。所以 jDCS 里位型功能码每个测点独立成批。

另外,框架对异常做了三层隔离。批次读取失败、切片解析失败、数据出口抛异常,各管各的,任何一层出问题都不会中断整轮采集。

2.2 故障自愈:超时、废弃、重建

这是 jDCS 和多数 Modbus 封装差别最大的地方。

请求超时之后,对应会话(客户端加专用执行器)可能已经处于半死状态。继续用它,只会污染接下来的每一个请求。所以 jDCS 的做法是超时后直接丢弃整个会话,下次读取时重建。

重建还有一个冷却窗口。这点容易漏:如果一轮采集里有 N 个测点都在等重建,没有冷却窗口,一轮之内就能把全部重连机会烧光。加上冷却窗口后,重试频率被约束到 reconnect-interval-ms,调度周期才是真正的重试驱动者。

JNI 卡死这一层,框架直接观察 modbus-io-* 线程数,超过阈值就触发进程主动退出,把烂摊子交给操作系统。

2.3 数据模型:对齐主流工业网关

数据类型对标映翰通等主流工业网关的定义,并做了别名归一化,把各家的方言统一到一套标准名称:

映翰通类型 标准名 位宽
BIT BOOL 1 寄存器
WORD UINT16 16
INT INT16 16
DWORD UINT32 32
DINT INT32 32
FLOAT FLOAT32 32
DOUBLE DOUBLE64 64
ULONG UINT64 64
LONG INT64 64
STRING STRING 自定义

INT 这一格值得单独说一句。IEC 61131-3 标准里 INT 就是 16 位,C 和 Java 里 int 是 32 位,同一个名字两套语义。工控领域特别容易在这里翻车,所以框架按 PLC 的约定来。UINT 也一样,对齐成 16 位,跟 INT 保持对称,另外补上了 IEC 的 UDINT、ULINT。

有几个类型框架是明确拒绝的。SINT、USINT、BYTE 都是 8 位,而 Modbus 的最小数据单位是 16 位寄存器,没有 8 位容器装它们。硬塞进 16 位会静默产出错误值,所以宁可直接报错。BCD16 / BCD32 同理。BCD 不是数值类型而是编码格式,每 4 位表示一位十进制数,0x0012 当 INT16 读出来是 18 而不是 12,必须专门解码。配置期遇到这两个类型会直接抛 Unsupported data type,不会悄悄给你一个错值。

字节序覆盖 ABCD / CDAB / BADC / DCBA 四种,也认 Swapped、Big_Endian 这类工业别名,映翰通界面上的 Swapped 可以直接填进去。解析规则是固定的:先做寄存器级成对交换,再做寄存器内部字节交换,奇数个寄存器时最后一个落单保留。七个寄存器的话,前三对两两互换,第七个不动。

还有一个容易配错的点:线圈和离散输入测点必须配 BOOL。位打包的数据如果当成 UINT16 解析,在 BADC / DCBA 字节序下会做寄存器内字节交换,一个线圈状态从 1 变成 256。BOOL 判的是"非零即真",跟字节序无关,四种字节序下都正确。

2.4 工程性:依赖少、好扩展、有得看

依赖很克制。协议栈只引入 modbus-serial 一个第三方库,其余就是 spring-boot-starter-web、Lombok 和配置元数据处理器。对一个跑在边缘设备上的服务,少一个传递依赖就少一份体积和风险。关键是它不拖 Netty,modbus-serial 的传递依赖只有 jSerialComm。

框架自带一个状态页和健康接口。启动后打开 http://localhost:8080/,串口连接、测点数量、I/O 队列积压、最近采集时刻都在上面,每 3 秒刷新一次。单文件静态页,零依赖零构建。

GET /health 返回 JSON,可以直接对接 Prometheus、K8s 的 liveness probe,或者自己写个管理台。

框架和裸用底层库的区别,大致是这样:

直接用 modbus-serial jDCS
连接生命周期 自己管 自动重连 + 冷却窗口
超时 只有应用层 Future 超时 硬超时 + 会话废弃
JNI 卡死 无感知 线程数监测 + 主动退出
调度停摆 无感知 独立看门狗
位型功能码 容易踩错位 已隔离
数据类型 自己解析 10 种类型 + 4 种字节序
状态观测 无 状态页 + /health

三、技术栈与选型

组件 版本 用途
Spring Boot 3.5.16 应用框架、调度、配置绑定
digitalpetri/modbus-serial 2.1.6 Modbus RTU 协议栈(核心)
jSerialComm 2.11.0 底层串口 I/O(传递依赖)
Lombok 最新 样板代码简化

Java 生态里的 Modbus 库屈指可数。选 digitalpetri/modbus-serial 的原因有三个:

  • 一是模块化 。modbus-serial 只依赖 jSerialComm,不会传递引入 Netty 这种重型依赖(那是 modbus-tcp 才有的)。跑在边缘设备上,这一点很关键。
  • 二是维护活跃,版本迭代频繁,issue 响应快。我自己提交的那个 issue 就是第二天收到修复 PR 的。
  • 三是关键缺陷已经修掉 。历史上那个超时内存泄漏(Issue #124)在 v2.1.2 修复了,当前 2.1.6 稳定可用。

需要提前说明的边界:jSerialComm 的读写默认无限阻塞,写超时只在 Windows 生效。这两条是这个技术栈的固有前提,jDCS 的分层兜底就是围绕它们设计的。

四、几个不显眼但很要命的设计细节

这部分是我在实现和调试过程中真正花过时间的地方。功能列表谁都能列,坑踩没踩过是另一回事。

4.1 全角空格:从 trim 换到 strip

数据类型、字节序这些字段是字符串,从 yml 读进来难免带空白,常规做法是用 String.trim() 裁掉。问题在于 trim() 只裁码点小于等于 U+0020 的字符,全角空格 U+3000 不在这个范围里。

中文输入法下面,FLOAT32 后面跟一个全角空格,肉眼完全看不出来。用 trim() 处理,空格留着,配置校验报出来的错误信息是这样:

java 复制代码
Unsupported data type: FLOAT32 

末尾多了一个看不见的字符。你盯着 FLOAT32 这几个字母反复看,怎么看都是对的。我第一次遇到这个问题,还以为是编码问题,查了半天编码配置。

后来统一改成 String.strip()。它按 Character.isWhitespace 判断,全角空格、EN QUAD、行分隔符这些 Unicode 空白都能认出来。为了确认这个改动真的有用,我做了个反证:把 strip() 换回 trim(),同一套探针跑,四个用了全角空格的用例全部失败;换回来全部通过。

这个坑和字节序、超时都不一样,它不产生错值,它只是让你找不到为什么配置读不进来。

4.2 补齐奇数字节:源头单点,而非过程多点

前面提过,Modbus 有个硬约束,1 寄存器等于 2 字节。解析器依赖这个前提,字节数组长度必须是偶数,否则直接抛 Byte array length must be even。

位型功能码是例外。读 1 个线圈,设备按位打包,只返回 1 个字节,长度是奇数。

怎么处理这个矛盾,有两种思路。

一种是解析器内部加判断:识别到 BOOL 就允许单字节,识别到其他类型就要求偶数。这是过程多点补丁,改起来解析器里到处是 if,漏一处就有线上风险。

jDCS 走的是另一条路。在采集层源头 ,ModbusRtuMaster.doRead() 的返回值统一过一遍 padToEven(),奇数长度高位补 0:

java 复制代码
设备响应 → doRead() → padToEven() → 上层
                        ↑ 唯一调用点

补零之后位型数据也变成 2 字节,解析器拿到的输入永远是合法的。

这背后有个原则 :解析器是契约的执行者,它的入参就该是偶数长度;采集层是数据的入口,把数据对齐到契约是它的责任。源头一处修复,优于过程处处打补丁。顺带说,padToEven 对寄存器数据是完全透明的,长度本来就是偶数,函数直接返回原引用,不分配新数组。

我实测过它对字节序有没有影响,结论是没有。寄存器数据根本不会被改动;位型数据补零后,BOOL 判的是"非零即真",字节怎么换都不改变"有没有非零字节"这件事。四种字节序乘 256 个取值,一共 1024 例,全部正确。补零方向也有讲究,必须补在高位,[0x01] 要变成 [0x00, 0x01],数值才是 1;补在末尾就成 256 了。

4.3 大整数不丢精度:未缩放时保留原始类型

value 字段的类型契约是这样:没配缩放偏移的时候,保留原始类型,不做统一转换。INT16 给 Short,UINT16 给 Integer,UINT32 给 Long,INT64 给 Long,UINT64 给 BigInteger,FLOAT32 给 Float。

为什么不统一成 double 省事?因为会丢精度。举个例子,9007199254740993,也就是 2 的 53 次方加一。这个数转成 double 会变成 9007199254740992,末位没了。累积电量、高精度计数器这类测点,差一个数就是一笔账对不上。

需要统一按 double 取值的时候,用 ((Number) point.getValue()).doubleValue(),或者直接调 parseScaled。

配套的还有一条:解析失败时,rawValue 和 value 都置为 null。这样消费方没有判 success 就落库,落进去的也是明确的空值,不会把一段无法解析的字节流当成有效原始数据存下来。

4.4 该拒绝的就拒绝

框架在几个地方选择了"直接报错"而不是"尽力兼容"。

8 位类型(SINT / USINT / BYTE)拒绝,原因上面说了。BCD 拒绝,理由也一样。配置里出现非有限值的缩放(scale 是 NaN 或 Infinity)也拒绝,启动期就报。

这些拒绝是有意识的设计。工业现场最怕的不是报错,是静默给错值。一个错值流到数据平台,可能几周后才被发现,那时候已经没法追了。配置期严一点,运行期才能松。

至于功能码和数据类型搭不搭配,框架反而不校验。比如功能码 01 配了 FLOAT32,validate() 不会拦。原因是 ModbusPoint 这个实体同时给读测点和写测点用,写测点不需要数据类型,硬把两者绑一起会误拒合法的写配置。这个取舍写在文档的已知边界里了。

五、五层防御:鲁棒性从哪来

这是理解 jDCS 最重要的一章。它的鲁棒性不是靠"堆保险",而是分层,每层应对不同种类的故障,缺一层就留一个Bug。

L1 请求级。 每次请求两道保护:协议类异常自动重试,最多 read-retry-count 次;应用层硬超时,operation-timeout-ms 到点即返回。

这里有个容易忽略的区分:协议异常(从站返回错误码)不重试。它反映的是从站自身的问题,重试只是白白占用总线。

L2 连接级。 前面讲的会话废弃加冷却重建。

L3 监控级。 旁路看门狗。

L1 和 L2 覆盖的是"请求失败"。还有种情况连失败都没有,比如 @Scheduled 因为线程耗尽不再触发,或者 I/O 线程消费不动请求、队列持续积压。这时候采集链路看起来很正常,只是什么都不干了。看门狗跑在独立线程上,采集线程卡死的时候它照样能发现问题。

这里补一条实践中的真实经验。队列停滞检测的效力其实有限。因为采集是同步单任务提交,提交后最多等一个 operation-timeout-ms 就换新会话,队列很难长时间持续非空。真正兜住 JNI 卡死的是泄漏线程计数(直接看 modbus-io-* 线程数)和累计硬超时计数。别因为有个队列检测,就低估了泄漏线程检测。

L4 进程级。 主动退出。

错误已经没法在 JVM 内恢复时(JNI 卡死、连续重连失败、线程泄漏超阈值),框架主动退出,把恢复交给操作系统。

这里有个反直觉的选择,用 Runtime.halt() 而不是 System.exit()。因为 System.exit() 会触发 shutdown hook,走到 @PreDestroy,调用 client.disconnect()。而致命错误场景下,disconnect() 很可能正是卡住的源头,你调用它,JVM 就永远退不出去。Runtime.halt() 直接终止 JVM,行为等价于 kill -9。

L5 系统级。 systemd。

前四层都在 JVM 内部。如果 L4 也没机会执行,比如 JNI 把整个 JVM 挂死,或者 OOM 的最后阶段,唯一能恢复的只剩进程管理器。

jdcs.service 里相关的配置是:

bash 复制代码
[Unit]
Description=JDCS - Java Data Collector Service
After=network.target

# 无限重启:禁用 systemd 启动速率限制。
# systemd 默认限制为「10 秒内最多启动 5 次」,超限后放弃重启并把服务标记为 failed;
# StartLimitIntervalSec=0 表示完全不限速,服务崩溃后由 systemd 无限次自动拉起,适配长期无人值守。
# 主动停止:systemctl stop jdcs ------ 手动停止后不会被自动拉起,需 systemctl start 方可恢复。
StartLimitIntervalSec=0

[Service]
Type=simple
User=jdcs
Group=dialout
WorkingDirectory=/opt/jdcs

ExecStart=/usr/bin/java \
  -Xms256m -Xmx512m \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/opt/jdcs/dumps \
  -XX:+ExitOnOutOfMemoryError \
  -Duser.timezone=Asia/Shanghai \
  -jar /opt/jdcs/jdcs.jar

# Restart=always:进程以任何原因退出(崩溃 / Runtime.halt / OOM)均重新拉起。
# RestartSec=10:两次重启之间的等待时间,用于避免崩溃时高频空转;它不是重启次数上限。
Restart=always
RestartSec=10

StandardOutput=append:/var/log/jdcs/stdout.log
StandardError=append:/var/log/jdcs/stderr.log

[Install]
WantedBy=multi-user.target

任何原因的退出都会在 10 秒后被拉起,而且不设次数上限。systemd 默认有个启动速率限制(10 秒内最多 5 次),超了就放弃重启并把服务标成 failed。对无人值守的现场,这个默认值不合适,所以显式关掉。

RestartSec=10 不限制次数,它是两次重启之间的等待,避免配置写错时每秒空转几百次、把 CPU 和日志打爆。

需要主动停下就 systemctl stop。手动停止不会触发自动拉起,这是 systemd 的既定行为。

六、十分钟快速上手

环境要求:

  • Zulu JDK 17 或更高(硬性)
  • Maven 3.8+,
  • 串口用真实设备或 Mthings之类的模拟器都行。

JDK 17 不是建议是前提。底层 modbus-serial v2.x 要求 JDK 17+,项目自己也用了 Record、Switch 表达式、String.strip() 这些语言特性,JDK 8 / 11 编译都过不去。

三步就能跑起来。

第一步,配串口 ,编辑 application-modbus.yml:

javascript 复制代码
modbus:
  serial-port: /dev/ttyUSB0      # Windows: COM8;Linux: /dev/ttyUSB0
  baud-rate: 9600
  parity: NONE
  data-bits: 8
  stop-bits: 1

  # 关键:operation-timeout-ms 必须 > response-timeout-ms
  response-timeout-ms: 2000
  operation-timeout-ms: 5000

  # 采集轮询周期,默认 2000ms
  collection-interval-ms: 2000

第二步,配测点 ,编辑 application-modbus-points.yml:

javascript 复制代码
modbus:
  points:
    - point-id: temperature_hall
      point-name: 大厅温度
      slave-id: 1
      function-code: 3
      address: 1
      data-type: UINT16
      byte-order: ABCD
      scale: 0.1
      unit: "℃"

测点是配置文件驱动的。这么设计是为了简化,适合中小型采集系统。大型项目测点动辄上千,配置文件就不好维护了,可以换成数据库驱动。框架对"测点从哪来"不做任何假设 ,只要能拿到一个 ModbusPoint 列表,采集链路原样可用。这也是留给大家扩展的地方。

register-count 这一项,数值类型不用手填。寄存器数量由 dataType 唯一确定,UINT32 就是两个寄存器,DOUBLE64 就是四个,框架启动时会自动对齐。只有 STRING 必须显式指定长度,因为类型本身不含长度信息。

第三步,打包运行:

bash 复制代码
mvn clean package
java -jar target/jdcs-1.0.0.jar

打开 http://localhost:8080/ 就能看到状态页。

两个参数依赖,别设错

框架有两处参数依赖,代码里不做运行期强制校验。设错不会启动报错,而是运行期以误判或频繁重启的形式暴露出来。

  • operation-timeout-ms 必须大于 response-timeout-ms。设反了,正常的慢响应会被当成卡死,触发会话废弃和硬超时计数,累计到阈值进程就退出了。
  • watchdog-idle-threshold-ms 必须大于一轮采集的最坏耗时。一轮最坏耗时大概是批次数 × 重试次数 ×(响应超时 + 重试间隔)。设小了,设备大面积离线时慢轮次会被误判成调度停摆,进程重启;重启后设备还是离线,就变成周期性重启。

这两条依赖和常见误配的速查,写在架构文档 Architecture_API.md的配置手册里。

七、三个扩展点

jDCS 的定位是骨架,所以有几样东西明确留给开发者。

数据出口是存数据库、发 MQTT、推 Kafka,还是上报 HTTP,你自己定。测点来源默认走配置文件,要换成数据库驱动也是个明确的扩展点。日志内核只给控制台输出,落盘和轮转交给你的日志规范。

默认实现是逐点记录工程值:

接自己的数据出口只要实现一个接口:

java 复制代码
public interface ModbusDataHandler {
    /**
     * 框架保证:本方法抛出的任何异常都会被捕获并记录,
     * 不会影响采集链路。
     */
    void handle(ModbusPoint point);
}

框架在这里给了个很实用的保证:handle 抛出的任何异常都会被捕获记录,不影响 Modbus 采集链路。

也就是说,你的 MQTT 抖动、数据库超时、HTTP 502,都不会让 Modbus 设备被误判为故障。采集和消费是解耦的。

接 MQTT 大概长这样:

java 复制代码
@Component
public class MqttDataHandler implements ModbusDataHandler {

    private final MqttClient mqttClient;

    public MqttDataHandler(MqttClient mqttClient) {
        this.mqttClient = mqttClient;
    }

    @Override
    public void handle(ModbusPoint point) {
        if (!Boolean.TRUE.equals(point.getSuccess())) {
            return;    // 失败测点不发布
        }
        String topic = "factory/" + point.getPointId();
        String payload = String.format("{\"value\":%s,\"unit\":\"%s\",\"ts\":%d}",
                point.getValue(), point.getUnit(), point.getTimestamp());
        mqttClient.publish(topic, payload.getBytes());
    }
}

八、部署到生产

项目自带一键部署脚本和 systemd 服务单元。

bash 复制代码
# 构建
mvn clean package

# 部署(需要 root,脚本在仓库根目录)
sudo bash install.sh

# 验证
systemctl status jdcs
curl http://localhost:8080/health

脚本会创建专用用户 jdcs 并加入 dialout 组,建目录授权,复制 jar,注册服务,启用自启动。

服务单元里内置了几个 JVM 参数:

bash 复制代码
-Xms256m -Xmx512m                   # 采集框架内存占用很低,512MB 够
-XX:+HeapDumpOnOutOfMemoryError     # OOM 时保留现场
-XX:HeapDumpPath=/opt/jdcs/dumps
-XX:+ExitOnOutOfMemoryError         # OOM 时主动退出,交给 systemd 重启

/health 返回的运行状态可以直接对接你的监控体系:

日志默认由 systemd 的 StandardOutput=append 写到 /var/log/jdcs/。这个文件不会自动滚动,长时间无人值守的话,建议在 logback-spring.xml 里加一个 RollingFileAppender,由 logback 自己管滚动和保留,不需要引入任何外部工具。注意加完之后要把服务单元的 StandardOutput / StandardError 改成 journal,不然同一份日志会写两遍。

九、上线前的检查清单

这份清单是从架构文档的"生产环境使用纪律"里摘的,都是踩过或者想清楚了的地方,上生产前对着过一遍。

  1. 采集循环用 tryFill() 或 tryParse() ,别用严格版的 parse()。单点异常降级成失败结果,不中断整轮轮询。

  2. 读 rawValue / value 之前先判 success。失败时这两个字段都是 null,直接取会踩空。

  3. 取数值统一用 ((Number) v).doubleValue() 。未缩放时 value 是原始类型,INT16 是 Short、UINT32 是 Long,直接强转 Double 会抛 ClassCastException。

  4. 同一个 ModbusPoint 实例不要跨线程并发写 。validate() 和 parse() 会就地归一化配置,dataType、byteOrder、registerCount、charset、scale、offset 这些字段会被写回实体。多个线程同时读同一个测点没问题,同时写就不行了。

  5. 核对那两个参数依赖 (operation-timeout-ms > response-timeout-ms、看门狗阈值大于最坏轮次耗时)。前面单独讲过。

  6. 线圈和离散输入测点统一配 BOOL。这条重复三遍了,因为它是配错率最高的一个。

  7. 先跑一次 validate() 。框架已经在 ModbusRtuMaster.init() 里对全部测点自动执行了,但如果你有自己的加载入口,记得手动调一次,让拼写错误在启动期暴露。

数据类型的别名是大小写不敏感的,也允许前后有空白(用 strip() 裁),bit、DiNt、float、float_32 这些写法都能正确归一化。但别指望框架能猜到你想表达什么,UINT 就是 UINT16,两字节,这个没有商量余地。

十、测试与质量

测试套件分成这几种:

测试类 形态 内容 需要硬件
TestModbusPointParser main() 自测 159 项断言 否
TestModbusBatchReadPlanner JUnit 5 14 项 否
TestModbusPointParserSimple JUnit 5 1 项 否
TestModbusRead @SpringBootTest 真实串口集成测试 是

解析器那 159 项覆盖类型映射、四种字节序、奇数落单、缩放、宽容 API、异常容错这些。离线就能跑,失败时退出码为 1,可以直接挂 CI。

bash 复制代码
java -cp "out:$(deps)" com.ty.jdcs.test.TestModbusPointParser
# 总计: 159, 通过: 159, 失败: 0

十一、它适合谁

说清楚边界,比一味说好更有用。

适合这些场景:中小型工业采集系统,需要长期无人值守的现场,边缘网关上跑的数据采集,以及想把重心放在业务(数据怎么用)而不是通信细节(数据怎么来)的团队。也能直接当二次开发的脚手架用。

不太适合的:需要开箱即用的完整 SCADA 或组态软件的;需要 Modbus TCP / ASCII 的(目前只做 RTU);需要多主站并发采集的。这些都能在现有骨架上扩展,但框架本身没做。

十二、写在最后

这个项目的出发点很小,就是把一次假死刨到底。

刨完之后发现,真正的问题不是某个库有 bug,而是这类场景本身就没有哪一层会主动来救你。串口不会告诉你对端断了,Java 的超时穿不透 JNI,进程没死 systemd 也不触发。能靠的只有自己搭的这套分层防御。

jDCS 把它们固化下来,做成了一个可以直接拿来用的骨架。

完整的架构说明和配置手册在仓库的 Architecture_API.md。如果时间有限,推荐先看第十章"故障兜底链路"和第十一章"生产环境使用纪律",前者是理解鲁棒性设计的入口,后者是上面那份检查清单的完整版。

如果有用,欢迎 Star ⭐,也欢迎把你的数据出口扩展(Kafka、InfluxDB、OPC UA 之类)分享回社区。

相关推荐
海马1 小时前
Spring Boot 开发知识整理
java·spring boot·后端
xiaolinudao1231 小时前
将多个 Excel 表格中的数据合并到单个表中|6 种实现方案全解析
java·前端·excel
fthux1 小时前
开源小工具 Who Arrives:看看你的公网 IP、地区、时区和网络
http·开源·github
谢亮_vipxieliang1 小时前
Spring Boot 3.x 从零开始——环境搭建与第一个 REST 项目
java·spring boot·后端
JJJennie7771 小时前
大模型网关怎么选?MAI Gateway vs 开源方案全面对比
网关·开源·gateway·ai网关
来自于狂人1 小时前
GitHub 开源趋势日报 | 2026年10月10日,告别 Mermaid 的图表设计系统
开源·github
Wang's Blog1 小时前
Java 项目部署之 Docker工具快速入门: Docker 架构拆解:镜像、容器、守护进程与 Registry
java·docker·架构
栗子~~1 小时前
SpringCloud Gateway 基于 Nacos 实现动态路由
java·spring cloud·gateway
学心理学的程序员1 小时前
腾讯开源 BrowserSkill:让 AI 直接接管你登录好的真实浏览器,自动化再也不用重新登录一遍
人工智能·开源·自动化