ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot整合Modbus TCP:工业数据采集全流程实战与避坑指南

SpringBoot整合Modbus TCP:工业数据采集全流程实战与避坑指南 前阵子做农业物联网项目遇到一个典型的场景客户的大棚里已经布好了环境采集设备空气温湿度、土壤墒情、光照度、CO2浓度这些传感器全部通过工业网关汇聚到局域网对外统一提供 Modbus TCP 服务。我的任务是让 SpringBoot 后端定时把这些数据读回来、解析成业务数据、写入数据库再供给大屏和手机端展示。刚接到这个需求时觉得不算难真正动手才发现Modbus TCP 报文本身很简洁但寄存器地址、字节序、单位换算、断线重连这些细节处处都能卡住你。这篇文章不打算写成 API 文档式的罗列而是把从数据链路梳理、技术选型、核心代码实现到稳定性加固的完整过程连同我踩过的几个坑一起分享出来。如果你也要做 SpringBoot 整合 Modbus TCP 的设备数据采集这篇文章应该能帮你少走不少弯路。1. 接入之前先想清楚从传感器到 SpringBoot 的数据链路与点位表1.1 农业设备如何接进 Modbus TCP很多做纯后端业务开发的同事第一次接触工业设备容易默认设备应该像数据库一样给你一个 SDK 或者 REST API。实际上农业场景里的传感器远没有那么“现代化”你面对的往往是一个 485 接口、支持 Modbus RTU 协议的传感器模块需要借助串口服务器或者工业网关把它转成 TCP 接入局域网才可能被后端程序访问。这里有两类典型组网方式在现场都经常遇到第一种是设备本身就带网口、原生支持 Modbus TCP。比如一些高端的气象站、水肥一体机、PLC 控制的温室环境柜直接把网线插到交换机上分配一个局域网 IP监听 502 端口就行。第二种是传感器走 RS485 总线通过串口服务器或者 DTU 转成 Modbus TCP。这种方式下物理层是 485到了 TCP 层报文的协议结构仍然是 Modbus TCP 的标准格式只是串口服务器的 IP 和端口变成了你的目标地址。无论哪种方式对后端来说看到的东西是一样的一个 IP、一个端口 502、一个从站地址Unit ID。你在程序里要做的本质就是发起 TCP 连接按照 Modbus TCP 协议格式发送读取请求然后解析返回的寄存器数据。这个过程和“数据库连接 查询 结果集解析”在逻辑上是完全对应的。理解了这层对应关系后面写代码就不会心虚。数据库的“表”对应设备的“寄存器区”数据库的“字段”对应设备手册里的“点位”而一条 SELECT 语句对应 Modbus 里的功能码请求。1.2 点位表阅读设备手册的第一步任何一台支持 Modbus 的设备厂商都会提供一份点位表这是整个采集程序的核心依据。点位表通常长这样寄存器地址数据类型单位数值范围说明0有符号 16 位0.1℃-400~800空气温度1无符号 16 位0.1%0~1000空气湿度2有符号 16 位0.1℃-400~800土壤温度3无符号 16 位0.1%0~1000土壤湿度4~5无符号 32 位1 Lux0~200000光照强度6~7无符号 32 位1 ppm0~5000CO2 浓度拿到点位表之后第一件事不是写代码而是确认两件事。第一是功能码。农业设备常用的可读寄存器是“保持寄存器Holding Register”和“输入寄存器Input Register”对应功能码分别是 0x03 和 0x04。绝大多数传感器点位放在保持寄存器里但总有例外所以必须从手册里确认。如果手册写的是“读取数据功能码 03”那就用 readHoldingRegisters如果写的是 04就用 readInputRegisters。用错功能码设备会直接返回异常。第二是地址偏移。很多设备手册上标注的地址是 PLC 习惯的“40001 起”方式40001 对应协议地址 040002 对应协议地址 1依次类推。而你在代码里填写的是协议地址不是设备手册第一列的那个 40001 编号。如果手册写“空气温度寄存器地址 40001”程序里读的起始地址就是 0。这个偏移问题我见过太多人在现场折腾半天才发现务必在第一遍读手册时就标记清楚。另外还有一点非常关键优先按连续地址批量读取。Modbus TCP 的读取请求可以一次从起始地址连续读多个寄存器。把所有点位规划到连续区间后一个请求就能把空气温湿度、土壤温湿度、光照、CO2 全部拿回来比逐点读取少很多网络往返稳定性和效率都高出一截。我下面代码里的点位就是按照连续 8 个寄存器设计的。2. 技术选型与工程初始化为什么选 modbus4j以及最大的依赖坑2.1 库选型对比Java 生态里做 Modbus TCP 客户端可选的无非是三条路自己用 Netty 或原生 Socket 写协议栈、用老牌的 jamod、用维护活跃的 modbus4j。先说自研。Modbus TCP 的报文结构确实不算复杂7 字节 MBAP 头加 PDU功能码也有限真要自己写一个满足“批量读寄存器”的最小实现两三百行代码也能跑通。但问题在于协议只是冰山一角设备返回异常码时的语义判断、半包粘包处理、断线重连、字节序组合、超时控制这些工程细节在真实项目里非常耗时。不到万不得已不建议重复造轮子。jamod 是很多老项目中使用的库零几年就有了但已经非常久不更新API 设计也偏老。如果你手里有一台旧设备、旧代码可能还会看到它新项目我基本不推荐。modbus4j 是 Infinite Automation 开源的纯 Java 实现内部基于 Netty功能覆盖了读线圈、读保持寄存器、读输入寄存器、写单个/多个寄存器等完整操作而且有 TcpMaster 这种开箱即用的连接封装API 清晰社区活跃度也足够。这是我在这个项目里的选择。2.2 工程依赖与基础配置项目基础用的是 Spring Boot 2.7.18 JDK 11。如果你用 Spring Boot 3.x也是可以的但要注意依赖兼容问题建议先在一个 demo 里验证一遍 modbus4j 的 Netty 3.x 依赖有没有冲突再做升级。pom.xml 里引入 modbus4j 时有一个非常容易被忽略的坑3.0.6 版本默认会带进来 slf4j-log4j12 这个依赖。如果你的项目用的是 LogbackSpring Boot 默认就是也会有输出冲突甚至 ClassNotFound 风险。建议显式排除dependency groupIdcom.infiniteautomation/groupId artifactIdmodbus4j/artifactId version3.0.6/version exclusions exclusion groupIdorg.slf4j/groupId artifactIdslf4j-log4j12/artifactId /exclusion /exclusions /dependency顺便提一句modbus4j 依赖的是 Netty 3.x和你项目里可能用到的 Spring Boot 内嵌 Netty 4.x/5.x 不是同一个版本线只要 modbus4j 内部自己用一般不会冲突。如果项目里同时引入 Netty 做其他网络服务需要留意类冲突。2.3 超时与重试参数怎么定application.yml 里我建议单独拆一个 modbus 配置块把设备的 IP、端口、从站号、超时时间、采集周期都集中管理便于现场调整modbus: device: host: 192.168.1.210 port: 502 unit-id: 1 timeout: 3000 retries: 0 collect: interval: 30000 start-address: 0 point-count: 8关于超时和重试我一开始吃过亏。现场调试时网线松动或者设备开机晚请求会一直等待超时整个采集线程被卡住后面的数据就一直不更新了。后来我把读超时统一设置为 3000 毫秒写操作也设置同样值重试次数设为 0。重试这个参数不是越大越好Modbus 协议本身没有复杂的重传语义网络环境干净的话宁可让它快速失败、下一个周期再试也不要在一个坏请求上反复阻塞采集线程。采集周期到底设多少取决于业务需要。大棚环境变化不是毫秒级的30 秒固定延迟完全够用。设备数量多或者有 PLC 之类快速状态采集需求的时候可以缩短到 5 秒。但注意不要小于设备的内部扫描周期否则设备还没处理完上一个请求你又发新请求了会造成响应很乱。3. 核心代码实现连接管理、批量读取与点位解析3.1 配置类与连接管理器断线自动重连配置类直接绑定 yml 前缀用构造器交给连接管理器即可。核心在连接管理器它负责创建 ModbusMaster、保持连接、并在检测到断开时自动重建。Data Configuration ConfigurationProperties(prefix modbus.device) public class ModbusDeviceConfig { private String host; private int port 502; private int unitId 1; private int timeout 3000; private int retries 0; }Component public class ModbusConnectionManager { private static final Logger log LoggerFactory.getLogger(ModbusConnectionManager.class); private final ModbusDeviceConfig config; private volatile ModbusMaster master; public ModbusConnectionManager(ModbusDeviceConfig config) { this.config config; } PostConstruct public void init() { connect(); } public synchronized ModbusMaster getMaster() { if (master null || !master.isConnected()) { log.warn(Modbus master unavailable, reconnect...); reconnect(); } return master; } private void connect() { try { InetAddress inetAddress InetAddress.getByName(config.getHost()); TcpParameters params new TcpParameters(); params.setHost(inetAddress); params.setPort(config.getPort()); params.setReadTimeout(config.getTimeout()); params.setWriteTimeout(config.getTimeout()); ModbusFactory factory ModbusFactory.getInstance(); ModbusMaster newMaster factory.createTcpMaster(params, false); newMaster.setTimeout(config.getTimeout()); newMaster.setRetries(config.getRetries()); newMaster.connect(); this.master newMaster; log.info(Modbus TCP connect success: {}:{}, unitId{}, config.getHost(), config.getPort(), config.getUnitId()); } catch (Exception e) { this.master null; log.error(Modbus TCP connect failed: {}, e.getMessage(), e); } } private void reconnect() { if (master ! null) { try { master.destroy(); } catch (Exception ignored) { } master null; } connect(); } public int getUnitId() { return config.getUnitId(); } }这里有个细节createTcpMaster 的第二个参数传入 false。这个参数表示是否启用 RFC 1006ISO-on-TCP封装一般标准 Modbus TCP 设备都用 false。如果你对接的是某些特殊工控网关或者 PLC 走 ISO 协议才需要设为 true。我建议这个参数也做成配置项现场遇到连接成功后却收不到响应的情况时首先尝试切换这个开关。业务线程每次采集时调用 getMaster() 拿到当前可用连接。如果设备重启过或者网络闪断过isConnected() 会准确反映出断开状态自动触发换新连接。这样轮询任务保持简单不用每写一个采集方法就考虑一次重连。3.2 采集器主体一次请求读回全部点位采集器是核心入口负责发起批量读取。按照 1.2 里的点位表我用起始地址 0、长度 8 的批量读保持寄存器请求一次拿回全部 16 位寄存器值。Component public class ModbusDataFetcher { Value(${modbus.collect.start-address}) private int startAddress; Value(${modbus.collect.point-count}) private int pointCount; Resource private ModbusConnectionManager connectionManager; public MapString, Object fetch() throws Exception { ModbusMaster master connectionManager.getMaster(); int unitId connectionManager.getUnitId(); int[] registers master.readHoldingRegisters(unitId, startAddress, pointCount); if (registers null || registers.length pointCount) { throw new IllegalStateException(register response incomplete: Arrays.toString(registers)); } PointParser parser new PointParser(registers); MapString, Object data new HashMap(); data.put(airTemperature, parser.readSignedInt16(0, 0.1)); data.put(airHumidity, parser.readUnsignedInt16(1, 0.1)); data.put(soilTemperature, parser.readSignedInt16(2, 0.1)); data.put(soilHumidity, parser.readUnsignedInt16(3, 0.1)); data.put(illuminance, parser.readUnsignedInt32(4, 1)); data.put(co2, parser.readUnsignedInt32(6, 1)); return data; } }收到的寄存器值为 int 数组每一项的取值区间是 0~65535。为什么 modbus4j 返回的是这种格式因为它已经帮我把两个字节拼成了一个 16 位无符号整数省去了处理字节序的底层步骤。但这也带来一个陷阱如果点位是“有符号 16 位”比如空气温度可能是 -10.0℃modbus4j 返回的会是 65436即 0xFF9C而不是 -100。你必须在业务层做符号位转换否则负数全部变成 65000 多采集记录直接废掉。3.3 点位解析器从裸寄存器到业务数值我单独封装了一个 PointParser专门把裸寄存器值转换成业务上要用的 double 或 float。这样点位解析逻辑集中以后点位表变更只改这一个类。public class PointParser { private final int[] registers; public PointParser(int[] registers) { this.registers registers; } public double readSignedInt16(int index, double scale) { if (index registers.length) { throw new IllegalArgumentException(register index out of range: index); } return (short) registers[index] * scale; } public double readUnsignedInt16(int index, double scale) { return (registers[index] 0xFFFF) * scale; } public double readUnsignedInt32(int index, double scale) { int high registers[index] 0xFFFF; int low registers[index 1] 0xFFFF; return ((long) high 16 | low) * scale; } public float readFloat32(int index, boolean bigEndian) { int high registers[index] 0xFFFF; int low registers[index 1] 0xFFFF; int bits bigEndian ? (high 16) | low : (low 16) | high; return Float.intBitsToFloat(bits); } }readSignedInt16 里的 (short) 强转是它最关键的一步。寄存器的 0xFFFF 在 Java 里是 65535但强转到 short 后就还原成 -1。农业传感器里温度、速率的正负号全靠这一步。readUnsignedInt32 是农业设备里更容易出问题的位置。Modbus 规范里多字节数据默认大端序高字节在前也就是寄存器 4 是高位、寄存器 5 是低位。我上面给出的代码就是按大端序组合。但不少国产农业传感器厂商在固件里自行实现了小端序这种情况下同样两个寄存器真实数值可能是 strings 反过来。所以采购设备时跟厂商确认“32 位数据是大端还是小端”比拿到设备后肉眼猜高效得多。如果现场已经出现数据量级完全不对的情况可以把这个 bigEndian 入参颠倒测试一下通常立刻就能对比出正确的字节序。4. 数据入库与查询接口让采集结果真正可用4.1 表结构设计与批量写入策略采集到的数据最终要落在数据库里供前端做实时看板和历史曲线。农业场景下的采样数据是典型的时间序列数据但考虑到团队技术栈和维护习惯我用 MySQL 存储也完全够用。表结构按一次采样一条记录设计CREATE TABLE sensor_sample ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, device_code VARCHAR(32) NOT NULL COMMENT 设备编码, collect_time DATETIME NOT NULL COMMENT 采集时间, air_temperature DECIMAL(6,2) DEFAULT NULL COMMENT 空气温度℃, air_humidity DECIMAL(6,2) DEFAULT NULL COMMENT 空气湿度%, soil_temperature DECIMAL(6,2) DEFAULT NULL COMMENT 土壤温度℃, soil_humidity DECIMAL(6,2) DEFAULT NULL COMMENT 土壤湿度%, illuminance INT DEFAULT NULL COMMENT 光照强度Lux, co2 INT DEFAULT NULL COMMENT CO2浓度ppm, PRIMARY KEY (id), KEY idx_device_time (device_code, collect_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT传感器采集数据表;这里我特意建了(device_code, collect_time)联合索引。农业大棚往往不止一个前端最常见的就是按设备查某段时间的曲线这个索引能覆盖绝大多数查询。温湿度用 DECIMAL(6,2) 已经足够光照和 CO2 用整数即可没必要为了“看上去精确”存浮点反而增加存储和前端渲染负担。采集服务里组装实体并落库Service public class DataCollectService { private static final Logger log LoggerFactory.getLogger(DataCollectService.class); Resource private ModbusDataFetcher dataFetcher; Resource private SensorSampleMapper sampleMapper; Value(${modbus.device.code:DEV-001}) private String deviceCode; public int collectAndSave() { try { MapString, Object data dataFetcher.fetch(); SensorSample sample new SensorSample(); sample.setDeviceCode(deviceCode); sample.setCollectTime(LocalDateTime.now()); sample.setAirTemperature(toBigDecimal(data.get(airTemperature))); sample.setAirHumidity(toBigDecimal(data.get(airHumidity))); sample.setSoilTemperature(toBigDecimal(data.get(soilTemperature))); sample.setSoilHumidity(toBigDecimal(data.get(soilHumidity))); sample.setIlluminance(toInteger(data.get(illuminance))); sample.setCo2(toInteger(data.get(co2))); sampleMapper.insert(sample); return 1; } catch (Exception e) { log.error(采集设备数据失败, e); return 0; } } private BigDecimal toBigDecimal(Object value) { return value null ? null : BigDecimal.valueOf((Double) value).setScale(2, RoundingMode.HALF_UP); } private Integer toInteger(Object value) { return value null ? null : (int) Math.round((Double) value); } }单个大棚一采一条记录一天也就两千八百多条MySQL 完全扛得住。如果后面扩展到几十个棚、几百个设备可以引入批量插入减少 IO或者按天分表。我的建议是先保证业务跑顺表结构预留时间字段和索引真到需要水平扩展的时候再做迁移。4.2 提供给前端的最新值和曲线接口数据入库后给前端提供接口就很简单了。我做了两个接口一个是查设备最新一条采样数据用于看板实时卡片另一个是查某个时间范围内的时间序列用于图表曲线。RestController RequestMapping(/api/samples) public class SampleController { Resource private SensorSampleMapper sampleMapper; GetMapping(/latest) public SensorSample latest(RequestParam String deviceCode) { return sampleMapper.selectOne(new LambdaQueryWrapperSensorSample() .eq(SensorSample::getDeviceCode, deviceCode) .orderByDesc(SensorSample::getCollectTime) .last(LIMIT 1)); } GetMapping(/range) public ListSensorSample range(RequestParam String deviceCode, RequestParam LocalDateTime start, RequestParam LocalDateTime end) { return sampleMapper.selectList(new LambdaQueryWrapperSensorSample() .eq(SensorSample::getDeviceCode, deviceCode) .between(SensorSample::getCollectTime, start, end) .orderByAsc(SensorSample::getCollectTime)); } }“最新一条”这个接口如果设备中途断线过返回的其实是最早前的旧数据。我在实际项目里额外加了一个字段表示数据的新鲜程度前端拿到最新记录后比较 collect_time 和当前时间超过一定阈值就在大屏上显示“数据过期”状态。这个虽然不起眼但真正做农业大屏的时候非常关键客户最怕看到一张看似正常实际早已失联的监控面板。5. 稳定性优化与现场排错实录5.1 串行轮询与线程模型设计Modbus TCP 一主多从的通信模型下主站发出请求后必须等待从站应答才能继续同一个会话的下一个请求。所以采集任务必须串行不能在多个线程里同时向同一个设备发送请求。我在定时任务里用的 Scheduled(fixedDelay 30000) 恰好天然满足这个要求Component public class CollectTask { private static final Logger log LoggerFactory.getLogger(CollectTask.class); Resource private DataCollectService collectService; Scheduled(fixedDelayString ${modbus.collect.interval}) public void collect() { long start System.currentTimeMillis(); try { int count collectService.collectAndSave(); log.info(采集完成, 写入{}条记录, 耗时{}ms, count, System.currentTimeMillis() - start); } catch (Exception e) { log.error(采集任务失败: {}, e.getMessage(), e); } } }这里我刻意用了 fixedDelay 而不是 fixedRate。fixedDelay 是上一次执行结束后再等待固定间隔执行下一次而 fixedRate 是按固定频率发起如果上一次请求因为设备超时卡了 3 秒两个周期会重叠出现并发请求打到设备上的危险局面。fixedDelay 虽然会让实际周期略微长于配置值但换来了采集链路的完全串行一秒钟的偏差对于农业数据采集来说无伤大雅。如果一个后端要同时管理多个大棚、多台设备可以按设备维度创建独立线程池每个设备一个调度任务避免一台设备超时拖慢所有设备的采集。5.2 断线、超时、异常码的处理设备侧环境决定了断线不是“如果发生”的问题而是“什么时候发生”的问题。现场常见的情况包括设备临时断电、网线被老鼠咬断、串口服务器死机重启、交换机电口松动。所以连接管理器里的自动重连只是基础保障采集任务本身也要做异常兜底。采集方法内必须 try-catch 住所有异常不能让异常抛出到调度线程里。虽然 Spring 的 Scheduled 在方法抛出异常后通常不会取消后续调度但如果这种方式和监控组件配合不好异常日志很容易刷爆存储。更稳妥的做法是采集失败后写一条失败日志同时记录连续失败次数当连续失败超过 3 次时发送告警通知一旦某次采集成功连续失败计数清零。这样既能让运维第一时间发现问题又不会因为单次网络抖动就疯狂告警。Modbus 从站返回异常时modbus4j 会抛出 ModbusTransportException 或 ModbusResponseException异常里带功能码和异常码。常见的异常码含义很值得背下来异常码含义排查方向0x01非法功能码设备不支持当前功能码检查用 03 还是 040x02非法数据地址读取地址超出设备寄存器映射范围核对点位表偏移0x03非法数据值批量读取的数量或参数不合法检查 point-count0x04从站设备故障设备内部错误看设备侧日志或重启设备我在现场遇到最多的就是 0x02。十有八九是点位表里 40001 这种表达方式没有换算成协议地址 0程序从 40001 开始读设备直接拒绝响应。5.3 常见故障排查表再整理一份通用的排查表遇到问题按表逐项对照能省很多时间现象可能原因处理方式连接一直失败IP/端口配置错误、设备未开机、防火墙拦截 502 端口先 ping 通目标 IP再 telnet IP 502 验证端口连接成功但请求无响应从站单元 ID 错误、设备内部程序卡死核对 unit-id重启串口服务器或设备返回 0x02 异常寄存器地址越界重新换算点位表协议地址返回数值巨大或为负有符号/无符号处理错误、32 位字节序颠倒重点检查符号位转换和大小端组合偶发超时网络闪断、设备响应慢、串口服务器半满状态适当增大读超时检查交换机运行状态并发请求冲突定时任务用 fixedRate 导致请求重叠改为 fixedDelay 或者使用独立串行线程池其中最隐蔽的是最后一条。我见过一个项目刚开始就一两个设备线上看起来正常后来设备数量增加到 50 台以上定时任务和手动刷新同时触发设备开始间歇性返异常。最后定位到是并发请求到了 485 总线从站根本来不及处理。排查这个问题的时候不要在错误日志里无限深挖先检查自己的调度模型是否真的串行。5.4 抓包验证从字节层面解决数据异常问题如果我修改了程序参数还是觉得数据不对我会做最后一步验证用 Wireshark 抓包看一眼实时报文直接从字节层面判断是设备侧问题还是代码侧问题。Modbus TCP 报文结构分了 MBAP 头和 PDU 两部分。MBAP 头 7 字节事务处理标识符 2 字节、协议标识符 2 字节、长度 2 字节、单元标识符 1 字节。后面 PDU 是功能码 1 字节加数据区。一次读保持寄存器请求报文长这样00 00 00 00 00 06 01 03 00 00 00 08逐段解释00 00 是事务标识符00 00 是协议标识符Modbus TCP 固定为 000 06 表示后面还有 6 个字节01 是单元标识符从站号03 是功能码读保持寄存器00 00 是起始地址00 08 是读取数量。正常响应报文格式00 00 00 00 00 13 01 03 10 XX XX XX XX ...其中 00 13 是长度01 是单元标识符03 是功能码10 是后续数据字节数十进制 16对应 8 个寄存器。收到的十六进制数据就是 8 个寄存器的原始值。如果响应里的功能码是 0x83说明从站返回了异常紧接着的一个字节就是异常码。看到 0x83 0x02基本可以确定是地址越界代码里不用再去纠结解析逻辑直接回去核对点位表。我在实践中用抓包验证过一件很诡异的事串口服务器固件默认开启了“字节交换”导致单个寄存器的高低字节被调换温度 25.6℃ 读出来变成 0.1℃ 数量级的数据。程序里怎么改都发现不了问题直到抓包看到原始字节顺序才意识到是硬件配置的问题。所以在遇到“数据量级不对”的时候不要急着改代码先抓一次包看原始字节对比点位表确认是设备给的字节就长这样还是代码组合错了。这样定位问题至少快一倍。最后分享一个我的工作习惯任何 Modbus 设备接入前先要求厂商提供一份测试点位表在没有真实设备的情况下用 Modbus Slave 模拟器在本地把采集逻辑跑通再带到现场联调。这个习惯替我节省了大量现场时间。哪怕你的点位解析代码再简单先在模拟环境里验证一遍寄存器地址和字节序也会比直接到现场对着真机调试从容得多。农业物联网项目的设备分散在各处一次去大棚现场的成本可能比写代码的成本高出好几倍前期的模拟验证怎么投入都不亏。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进