2026/10/20 11:50:40

Spring Boot与Modbus整合:从设备接入到稳定采集的完整实践

Spring Boot与Modbus整合:从设备接入到稳定采集的完整实践 最近在一套设备接入项目里我把 Spring Boot 和 Modbus 从选型到落地完整做了一遍。先说结论Spring Boot 从来不是“直接解析 Modbus 报文”的框架它真正擅长的是把 Modbus 通讯打包成一个稳定、可观测、能长期运行的设备接入服务。这篇文章把整个整合过程拆开讲包括为什么选 Spring Boot 来干这件事、库怎么选、连接怎么管、读写链路怎么写、轮询和断线重连怎么设计最后再分享一段现场排错的完整经过。如果你是刚接触 Modbus 或者已经接了几台设备但总觉得代码零散这篇应该能给你一条比较清晰的实践路径。1. 为什么要在Spring Boot里接管Modbus通讯这一层1.1 Modbus协议本身到底在解决什么问题Modbus 是一个诞生很多年但至今仍在工业现场大量使用的协议。它能留下来不是因为技术多先进而是因为它“足够简单又足够通用”。打开任何一个 PLC、电表、变频器、温控器的手册基本都能看到一张寄存器地址表。你可以用几个固定功能码去操作它读线圈、读离散输入、读保持寄存器、读输入寄存器、写单线圈、写单寄存器、写多寄存器。底层跑在串口上就是 Modbus RTU跑在以太网上就是 Modbus TCP。所以它本质上是一套“设备问、设备答”的主从协议。你的中心系统作为主站轮询地发出请求从站设备收到后把寄存器里的数据回给你。这种模式的好处是协议非常轻量坏处也很明显设备本身没有主动上报能力你想拿到数据就必须自己去“读”而且同一时刻的总线/网络通常只允许一个主站在发指令。理解了这一点后面在做 Spring Boot 整合时很多设计决策就有依据了。1.2 Spring Boot在这个架构里的真实角色Spring Boot 在整条链路上处在什么位置我理解下来它是“连接层和业务层之间的胶水”。往下它通过 Modbus 库跟现场设备打交道往上它把读到的寄存器值、线圈状态转换成业务系统认识的 JSON 接口。如果业务方要做能耗曲线、设备诊断、告警推送他们不会关心 Modbus 的功能码和数据格式只关心你现在给了我什么数值。Spring Boot 在这类场景里最值钱的是工程生态配置中心管设备清单、定时任务管轮询、日志和指标管运行状态、自动装配帮我把连接对象管理起来。也就是说你不需要自己造一套生命周期管理、并发控制、异常恢复的框架这些都有现成组件可以组合。但也别过度设计如果只是临时测试几个点位写个脚本反而更快。真正适合用 Spring Boot 的情况是设备数量多、需要长期采集、数据要持久化、并且要开放给多个上层系统使用。2. 开工前的硬准备库选型、模拟器与地址标定2.1 三个主流库的选择逻辑Java 生态里做 Modbus 的库不算多我主要比较过这三个j2mod经典同步 API结构简单适合快速上手和搞明白协议帧的格式。缺点是代码风格比较老遇到复杂异步场景会别扭一些。modbus4j功能覆盖面不错RTU 和 TCP 都支持但仓库更新速度一般文档也比较零散。digitalpetri 的 modbus-master基于 Netty 实现API 是异步的支持 TCP、RTU、ASCII设计上更贴近现代 Java 的习惯事务标识、超时处理都封装得比较干净。我最终选择了 digitalpetri 这套。Maven 坐标像这样dependency groupIdcom.digitalpetri.modbus/groupId artifactIdmodbus-master-tcp/artifactId version1.2.0/version /dependency异步 API 的好处是Spring Boot 的请求线程不会被底层 IO 长时间占用你可以让请求带上明确超时然后用CompletableFuture去等结果。当然轮询任务里如果直接future.get()该阻塞的还是会阻塞但至少你有机会把“发请求”和“等响应”拆开处理。2.2 用虚拟从站搭一个可重复的联调环境项目一开始可能还没有真实的 PLC 或者电表这时候不要等硬件。装一个 Modbus 从站模拟工具本地起一个从站服务设置好从站号、端口、寄存器起始地址和初始值。实际联调阶段有两点需要注意。第一Modbus TCP 的默认端口是 502这个端口在 Linux 上属于特权端口低于 1024普通 Java 进程监听不了。开发环境我建议直接把从站的监听端口改成 1502避免权限问题等部署到生产再让运维帮考虑端口的映射关系。第二从站软件里显示的地址往往和协议帧里的地址不是一回事。比如软件里写“HR 40001”实际 Modbus 报文里的地址偏移是 0。你必须明确自己这套系统采用哪种地址基准是从设备手册拿到的“一基地址”直接减一还是统一按“零基地址”处理。这个约定没定好后面会读出一堆错位数据。2.3 设备参数集中到配置文件的正确姿势我在项目里会把每个设备的连接参数收敛到 YAML 配置里而不是散落在代码常量中modbus: default-timeout: 3000 poll-period: 5000 devices: pump-01: host: 127.0.0.1 port: 1502 unit-id: 1 address-style: one-based meter-01: host: 192.168.1.20 port: 502 unit-id: 2 address-style: zero-based为什么要单独放一个address-style因为我发现很多设备手册把第一个保持寄存器标成 40001而库内部是按 0 开始的地址去发报文。把地址基准放在配置里声明出来后续读寄存器时统一处理比每个读取方法里各写一遍转换逻辑要安全得多。配置类可以用ConfigurationProperties直接绑定非常顺手。3. 核心链路从连接对象到读写保持寄存器3.1 连接生命周期一个设备一个Master别为每次读取重建连接Modbus TCP 的 Master 对象内部是一个 Netty 客户端持有 TCP 连接和事件循环线程。这个对象并不轻每次读取都重新创建会在短时间内占用大量端口和线程对现场设备也不友好。我的做法是做一个注册表按设备标识缓存 Master 实例Component public class ModbusMasterRegistry { private final MapString, ModbusTcpMaster masters new ConcurrentHashMap(); public ModbusTcpMaster getMaster(String deviceKey) { return masters.computeIfAbsent(deviceKey, this::createMaster); } private ModbusTcpMaster createMaster(String deviceKey) { DeviceConfig cfg config.getDevices().get(deviceKey); ModbusTcpMasterConfig masterConfig new ModbusTcpMasterConfig .Builder(cfg.getHost(), cfg.getPort()) .setTimeout(Duration.ofMillis(cfg.getTimeout())) .build(); return new ModbusTcpMaster(masterConfig); } }computeIfAbsent保证了并发情况下同一个设备只会创建一份 Master。如果设备连接参数变化了需要显式移除旧实例并关闭连接不要原地去改一个正在工作的连接对象。3.2 读保持寄存器与读线圈的代码骨架读取保持寄存器是最常用的功能码它拿到的是一组 16 位寄存器。核心方法封装长这样public short[] readHoldingRegisters(String deviceKey, int startAddress, int quantity) { DeviceConfig cfg config.getDevices().get(deviceKey); int addr normalizeAddress(cfg, startAddress); ModbusTcpMaster master registry.getMaster(deviceKey); CompletableFutureReadHoldingRegistersResponse future master.readHoldingRegisters(cfg.getUnitId(), addr, quantity); ReadHoldingRegistersResponse response future.get(cfg.getTimeout(), TimeUnit.MILLISECONDS); return response.getRegisters(); }normalizeAddress做的事情很简单如果配置是one-based就把传入地址减一如果是zero-based则直接用。这里future.get(timeout)是第二道保险因为即使底层 Netty 配置了超时你还是希望当前线程能明确地在可控时间内返回值而不是无限期挂起。读线圈稍微不同返回的byte[]是按位存储的。第 0 个字节的第 0 位代表起始地址对应的线圈第 0 个字节的第 1 位代表下一个线圈。很多人第一次在这踩坑以为是按字节对齐其实 Modbus 用的是位映射LSB 在前。3.3 写单寄存器、写多寄存器容易忽略的细节写操作和读操作在 API 上是对称的但有一个细节必须强调Java 的short是有符号的而 Modbus 寄存器天然是 16 位无符号。很多电表里的寄存器值可能大于 32767直接用short强转会把高位符号位弄反。我习惯写一个工具方法public static short toUnsignedShort(int value) { return (short) (value 0xFFFF); }写入时把 int 先截断到低 16 位再传给库。读取时则用reg 0xFFFF把无符号范围恢复出来。写多个寄存器时尤其要注意一次性写 10 个寄存器之前先确认设备允许的最大连续写数量不同厂家差异挺大的超过限制设备会直接回异常码。3.4 字节序、无符号数、32位组合所有转换代码的核心Modbus 寄存器只有 16 位所以 32 位浮点数必须拆到两个相邻寄存器里。这个转换有一个“字顺序”的问题。常见的大端字序是第一个寄存器是高字第二个寄存器是低字小端字序则反过来。我见过不少设备手册写得不明确导致的后果是同一个地址你用 Modbus 调试工具读出来数值正常程序里读出来却是个天文数字。最稳妥的转换方式是用ByteBuffer手动拼字节public static float regsToFloat(short[] regs, int offset, boolean bigEndianWord) { byte[] bytes new byte[4]; if (bigEndianWord) { bytes[0] (byte) (regs[offset] 8); bytes[1] (byte) regs[offset]; bytes[2] (byte) (regs[offset 1] 8); bytes[3] (byte) regs[offset 1]; } else { bytes[0] (byte) (regs[offset 1] 8); bytes[1] (byte) regs[offset 1]; bytes[2] (byte) (regs[offset] 8); bytes[3] (byte) regs[offset]; } return ByteBuffer.wrap(bytes).getFloat(); }长整型和字符串也是同理只是寄存器数量不同。字符串要特别注意设备手册里字符的排列是“先高字节后低字节”还是相反否则读出来的英文句子会变成乱码。这些转换逻辑放到一个独立的ModbusDataCodec类里单元测试会非常方便。4. 生产级工程体操轮询频率、线程池与断线恢复4.1 同步等待与异步响应之间的平衡点代码里虽然写了future.get(timeout)但你要清楚它阻塞的是当前线程。如果在Scheduled方法里串行读取 20 台设备每台设备最坏耗 3 秒超时那这一轮轮询可能高达 60 秒调度器会被卡死。要么给每台设备的轮询单独开一个小线程池要么把一次轮询拆成多线程并发执行。我偏好的方案是给整个 Modbus 模块配一个独立的任务调度器Bean(modbusTaskScheduler) public TaskScheduler modbusTaskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(8); scheduler.setThreadNamePrefix(modbus-poll-); scheduler.setErrorHandler(t - log.error(Modbus poll error, t)); return scheduler; }然后在调度注解里指定它Scheduled(fixedDelayString ${modbus.poll-period:5000}, scheduler modbusTaskScheduler) public void pollAllDevices() { deviceKeys.forEach(this::pollOneDevice); }线程池大小不是越大越好取决于现场设备数量和单台设备响应速度。如果只是十来台设备4 到 8 个线程完全够用。4.2 定时轮询为什么选fixedDelay而不是fixedRate这是我从实际踩坑里得出的教训。第一次上线时我用了fixedRate结果某天一台设备频繁超时轮询任务开始叠加线程池慢慢被占满连正常接口的响应也变慢了。后来改成fixedDelay之后问题立刻缓解。原因很简单fixedRate是按照“上次任务开始时间”来计算下一次触发时间的不管上次是否执行完fixedDelay则是等上一次任务结束之后再开始计时间隔。Modbus 轮询本质是“读一遍数据”的串行采集用fixedDelay更符合直觉。单台设备内部如果点位很多也应该合并成批量请求比如把 40001 到 40020 一次读完而不是 20 个点位发 20 次请求。这样从站端压力小很多整体吞吐量和延迟完全是两回事。4.3 断线重连不能只靠“超时后重试”TCP 连接断开后客户端不一定能立刻感知。很多时候要等下一次写请求发出才发现对方已经没有响应了。所以我在轮询里会捕获TimeoutException同时维护一个失败计数。连续失败达到阈值时才主动销毁旧 Master 并触发重建。实现上可以在注册表里加一个移除方法public void invalidate(String deviceKey) { ModbusTcpMaster master masters.remove(deviceKey); if (master ! null) { master.disconnect(); } }这样下一次轮询时computeIfAbsent会自动创建新连接。重连频率不能太频繁否则设备网络抖动时你会不断重建连接反而加剧问题。连续 3 次失败再重建是比较合适的折中。另外要顺手记录每次读取的耗时和成功状态。生产环境里“这台设备这周超时率多少”比“某一次读成功了没”重要得多。5. 上层要什么我就给什么REST接口与DTO设计5.1 从点位出发设计接口而不是从寄存器出发直接暴露一个GET /modbus/read?devicepump-01start40001quantity2的接口不是不行但它把寄存器地址的细节泄露给了上层。我更推荐把点位定义也放进配置modbus: devices: pump-01: host: 127.0.0.1 port: 1502 unit-id: 1 address-style: one-based points: - device: pump-01 name: inletPressure register: 41001 type: float wordOrder: BIG_ENDIAN - device: pump-01 name: motorStatus register: 41003 type: uint16上层系统只要问“pump-01 当前有哪些值”接口就把该设备所有配置好的点位值返回出去。这样做的好处是现场设备换了一个型号、寄存器地址整体偏移了你只需要改配置和点位映射接口对上层完全透明。5.2 统一DTO避免返回裸Map我见过不少接口直接返回MapString, Object一开始写起来很爽但后面加字段、做版本兼容都很难受。一个稳定的响应结构应该是这样的public record ModbusReadResponse( String device, ListModbusPointValue points, long timestamp) { public record ModbusPointValue( String name, Object value, String type, int quality) { } }quality字段可以简单表示读取质量0 表示正常1 表示超时2 表示数据转换异常。上层系统拿不到值时可以明确知道是设备问题而不是接口问题。这个结构后面加报警阈值、单位符号都不需要破坏已有字段。5.3 并发请求串口网关时必须做的信号量控制如果现场不是直接走 Modbus TCP 接 PLC而是通过一个串口服务器把 RS485 转成了 TCP那么“同一时刻只允许一个请求访问总线”这个限制就变得很现实。多个线程并发写同一个串口网关轻则报文碎片化重则设备直接不响应。我在服务里给每个设备加了一个Semaphore(1)所有读写操作都要先拿到信号量再执行private final MapString, Semaphore semaphores new ConcurrentHashMap(); public T T withDeviceLock(String deviceKey, SupplierT action) { Semaphore semaphore semaphores.computeIfAbsent(deviceKey, k - new Semaphore(1)); if (!semaphore.tryAcquire(config.getTimeout(), TimeUnit.MILLISECONDS)) { throw new ModbusBusyException(deviceKey); } try { return action.get(); } finally { semaphore.release(); } }这样无论是定时轮询任务还是外部 REST 请求触发的读取同一个设备上永远只有一个请求在走彻底避免并发冲突。直接连 TCP 的 PLC 一般内部能处理多请求但加上这个信号量也不会损失太多性能却能让行为更可预测。6. 一段现场排查复盘超时、地址偏移与浮点错乱6.1 现象Modbus Poll能通Spring Boot却总是超时那次是在一套泵站数据采集项目里设备是某厂家的压力变送器网关。现象很典型现场工程师用 Modbus 调试工具读取完全正常指针刷刷跳我的 Spring Boot 服务一启动日志里超时异常一条接一条。第一步先排除了 IP、端口、从站号这些基础配置怎么看都没问题。这时候我意识到一个容易被忽略的点调试工具和应用程序用的是同一个 TCP 端口 502但很多网关设备的 TCP Server 只允许一个连接会话。调试工具开着连接的时候应用再去连它就不给握手了或者连上了也不回数据。让现场把调试工具关掉再单独运行应用请求立刻通了。这个坑在第一次做现场联调时非常容易中招。6.2 抓包后的第一个发现连接会话被占用为了确认这个判断我在网关侧跑了抓包。现象很明显应用发出 TCP SYN网关回了 ACK但后续 Modbus 请求发过去后对端一直没有响应直到应用超时断开。对比调试工具连接时的抓包发现调试工具长连期间第二个会话根本没有进入 ESTABLISHED 状态。问题定位后处理方案有两个一是约定现场调试完成后必须断开工具连接二是在应用侧把 TCP keep-alive 打开并记录每个设备的连接状态出现超时后主动释放连接避免自己这边残留半开连接。更重要的是我把那段时间的抓包文件保留了下来后面所有“设备没反应”的排查都以抓包为准不再凭经验猜。6.3 数据错一位设备手册的一基地址和库的零基地址连接问题解决后数据终于能读回来了但数值整体错位。设备手册说压力值在 40002我读 40002 得到的却是在调试工具里看到 40003 的数据。一开始怀疑点位映射错了翻了几遍配置都对。后来仔细核对库的 API 文档才发现库内部的startAddress是按零基地址算的。手册上那个 40002 是“一基地址”也就是用户视图里的第 2 个保持寄存器代码里应该传的其实是 1。我在配置里统一声明address-style: one-based并在读取入口统一执行startAddress - 1这个问题就彻底解决了。这件事给团队的教训是所有点位表都必须明确标注地址基准否则换了设备、换了库这种错位会反复出现。6.4 浮点数读数异常字顺序与字节顺序的双重陷阱地址对了以后压力值读出来还是不对是个完全离谱的大数。当时第一反应是设备数据格式不是 float而是两个 16 位整数拼接的。我用调试工具切换了一下浮点显示格式发现工具里选“AB CD”顺序显示正常选“CD AB”就变成乱数。这就是字顺序的问题。我把regsToFloat里的大小端参数调成小端字序后读数立刻正常。其实设备手册里写过这些寄存器遵循小端字序只是一般人看手册时根本不会注意到这一小节。后来我把所有点位的数据类型、字顺序全部补进了配置表并且给每种类型加了单元测试用例再也没出过这种“读数对不上”的事。整个项目做下来我的体会是Modbus 集成本身不难难的是把地址基准、字节序、连接并发、断线恢复这些边界条件都收拾干净。每次遇到“奇怪问题”优先去看报文抓包和手册正文而不是反复改业务代码。按这个思路做Spring Boot 整合 Modbus 就会变成一个稳定而且好维护的长期服务。