
简介Java解析国标hj212协议资源包围绕环保行业污染源在线自动监控系统数据传输标准面向环保监测系统后端开发者及协议对接人员适合需要接入数采仪、数据上报平台或希望理解hj212报文结构的Java工程师参考复用。压缩包共308个文件其中100个Java源码对应协议解析核心逻辑197个class为编译后可直接调用的类文件另附XML、properties、project、classpath等工程配置便于导入Eclipse或单独抽取依赖使用整体仅327KB。已有5073人学习使用。资源内含T212Mapper、SegmentParser、DataConverter等关键解析类覆盖数据帧分段、CP数据映射、字段数据类型转换等环节并结合XML/JSON解析、Socket网络通信、异常处理和JUnit测试等实践展示从报文接收、解析到业务对象转换的完整过程。对从事环保监测系统或物联网数据接入的开发人员而言是一份可直接运行、便于二次开发的实战参考。1. Java 解析 HJ212 协议先搞清楚这是一个文本协议不是消息队列接到过环保监测平台对接任务的 Java 工程师大概都经历过这样的场景数采仪厂商丢过来一份 PDF 说“我们支持国标 HJ212”打开一看满屏十六进制和ST32;CN2011;...的字符串。第一反应往往是“这跟 JSON 比也太原始了”但真上手处理就会发现HJ212 协议能在环保行业用十几年靠的就是它的确定性帧头帧尾固定、字段用分号切分、CRC16 校验防篡改任何一个字段都能按规则还原成结构化数据。HJ212 全称是《污染物在线监控监测系统数据传输标准》2017 版是目前的主流规定了现场机数采仪与上位机监控平台之间的 TCP 通讯格式、命令编码和数据结构。Java 解析 HJ212 协议的核心工作就是把一串像##0136ST32;CN2011;PW123456;MN010000A8900016F000169DC0;CP...;CRC16;这样的报文拆成可以落库、可以推送告警的 Java 对象。这篇文章从帧格式讲到解析代码、从第三库选型讲到现场踩坑按我实际做项目的方式一步步拆给你看。2. HJ212 帧格式拆解从包头 4 个字符到 CRC16 校验的完整还原2.1 帧结构总览一条完整报文里到底塞了哪些东西HJ212 的报文从物理上看是 TCP 字节流但逻辑上是一个打包好的文本行。以 2017 版标准为例一条典型的请求帧长这样##0136ST32;CN2011;PW123456;MN010000A8900016F000169DC0;CPQN20240101120000000;ST32;CN2011;PW123456;MN010000A8900016F000169DC0;Flag4;CPRtd12.5;;CRC16;先直观拆出来看帧由四段拼成帧段示例内容长度或结束符作用包头##2 字节ASCII 字符报文起始标记缺了就直接丢数据段长度01364 字节十进制 ASCII指ST到CRC16;之间的字符数数据段ST32;CN2011;PW123456;MN110...;CP...;以;结束实际要解析的业务内容校验和CRC16;4 位十六进制 分号CRC16-CCITT 校验值这里最容易忽略的是数据段长度是字符串长度不是字节长度。纯英文数字环境下两者相等一旦出现中文一个汉字在多字节编码下按字节算就长了按字符算才和帧里标称的一致。我第一版实现就是按 UTF-8 字节数去截断结果现场发来中文备注字段时全部截错位这是第一个坑。2.2 数据段字段的语义分层ST/CN/PW/MN/CP 各自管什么数据段整体可以看成两层外层是国家码字段内层是CP...包着的业务数据段。外层字段相对固定STSystem Type系统类型32代表废气、21代表水、22代表空气等决定后续字段含义的解释方式。CNCommand Code命令编码2011是实时数据上报、2012是应答、3011是取数采仪时间、3013是设置采样速率。这是整个协议里决定逻辑走向的字段。PWPassword数采仪接入密码默认123456。MNDevice ID设备唯一标识14 位字符相当于设备的“身份证号”入库时这是主键候选。Flag数据段拆分标志0表示无拆分4表示拆分包后续还有数据。CPData Section真正的业务数据用包起来内部又是一组键值对。CP 内部格式是长这样的QN20240101120000000;ST32;CN2011;PW123456;MN110...;Flag4;CPRtd12.5再看细一点CP 里还可能出现Rtd实时值、Zg折算值、Cou累计值、DataTime采样时间以及SB2RS232之类的设备参数。解析的目标就是把这层嵌套的字符串彻底压平。2.3 CRC16 校验别跳过它是防报文错乱的最后防线HJ212 的 CRC16 用的是 CCITT 多项式0x1021初始值为0xFFFF计算范围从ST开始到CP...结束不含末尾的CRC16;。很多网上的示例代码直接照搬 Modbus 的 CRC 算法算出来对不上。一个标准实现public static String crc16(String data) { int crc 0xFFFF; byte[] bytes data.getBytes(StandardCharsets.US_ASCII); for (byte b : bytes) { crc ^ (b 0xFF) 8; for (int i 0; i 8; i) { if ((crc 0x8000) ! 0) { crc (crc 1) ^ 0x1021; } else { crc 1; } crc 0xFFFF; } } String hex Integer.toHexString(crc).toUpperCase(); return String.format(%4s, hex).replace( , 0); }逻辑说明从0xFFFF初值出发每个字节先异或到高 8 位再逐位移位、按需异或多项式。结果转成 4 位大写十六进制字符串。参数说明data必须是ST开头、CP...结尾的完整数据段不含##、长度段和CRC16;本身否则校验必定失败。3. 现成库还是自己写找一个能落地的 Java HJ212 解析方案3.1 开源社区的实现Netty 系解析器和单线程解析器的差异在做任何自研之前建议先看一眼社区里已有的 Java 实现。常见做法是GitHub 上有围绕 Netty 服务端封装的 HJ212 解析依赖它们把 TCP 拆包粘包、心跳应答、解析入库都做成了可配置组件适合做平台端长期运行的服务。另一类是不依赖网络框架的纯解析工具类输入一段字符串输出一个HJ212Message对象适合在采集程序里嵌入式调用。两类怎么选核心看你的接入形态自建 TCP 服务端、需要支撑几百台设备长连接直接用 Netty 系解析器它自带拆包器避免自己处理半包。已有数据总线数采仪通过前置机转发文本帧进来只需要解析器不要引入 Netty 的重量级依赖。3.2 选型对比三个判断维度和一个决策表选型别只看 GitHub Star从维护状态、协议覆盖范围、扩展成本三个维度看才靠谱。维度自研维护成本低社区库维护活跃旧实现但稳定协议版本覆盖自己补齐所有 CN 命令通常覆盖 2017 版核心命令可能缺 2017 版新增字段拆包粘包处理要自己写已内置只做解析需自己包一层中文编码适配自己控制多数已处理要改源码二次开发成本改自己的代码无负担按开源协议修改继承后改 bug 成本高我的判断标准很简单如果项目对环保行业是长期投入协议版本会随新规升级比如 HJ212-2017 相比 2005 版新增了Bct等字段选一个解析和网络层分离的库或者直接自己写解析核心、把网络层留给 Netty两条路都稳。最怕的是选一个看似全能的库结果它把网络层和解析逻辑焊死在一起想扩展一个自定义 CN 命令还得动它的源码。3.3 为什么我最后选择自研解析核心三个理由最终我在生产项目里选择了自研解析核心、自持 TCP 接入层。理由有三第一HJ212 的帧结构太规则了解析核心就是“取长度 → 截断 → 分号切分 → 等于号拆分 → 递归展开 CP”篇幅约两百行没有黑匣子。第二环保项目的监测因子因地域而异同一个CN2011实时数据帧A 省要求解析SO2、NOxB 省还要求解析H2S社区库的固定 POJO 反而束缚手脚。第三设备厂商的“方言”太多有的在CP里塞了非标准的字段自研解析器可以在不改变整体结构的前提下把这些字段放进MapString, String扩展格里。自研不等于重复造轮子网络层用 Netty 的ByteToMessageDecoder处理粘包解析层用自研工具类处理帧内容。两层的边界要清晰。提示如果只是验证协议、测试设备连通性直接用社区库没问题但如果是做省级平台这类需要长期运营的项目解析核心自研的优势会随着时间放大。4. 自己写解析器把 HJ212 数据段拆成键值对的完整实现4.1 从完整帧到结构化数据一个可直接落地的解析类既然决定自研先定义输出结构。最灵活的做法是传入完整报文返回一个HJ212Message对象public class HJ212Message { private String st; // 系统类型 private String cn; // 命令编码 private String pw; // 密码 private String mn; // 设备唯一标识 private String flag; // 拆分标志 private MapString, String cp; // 展开后的CP键值对 // 省略 getter/setter实际项目用 Lombok Data 即可 }然后是解析主入口。建议第一步先做帧完整性校验再进入字段拆分public static HJ212Message parse(String frame) { if (!frame.startsWith(##)) { throw new IllegalArgumentException(missing frame header); } // 跳过包头##后取4位长度 int declaredLen Integer.parseInt(frame.substring(2, 6)); // 数据段从 ST 开始到 CRC16 前结束 String dataSection frame.substring(6, 6 declaredLen); // 校验CRC String expectedCrc frame.substring(6 declaredLen 1, 6 declaredLen 5); String actualCrc crc16(dataSection); if (!expectedCrc.equals(actualCrc)) { throw new IllegalArgumentException(CRC mismatch); } HJ212Message msg new HJ212Message(); String[] outerFields dataSection.split(;); for (String field : outerFields) { if (field.startsWith(CP)) { // CP字段特殊处理先提取外层 包裹的内容 String cpInner field.substring(5, field.length() - 4); // 注意CP尾部是 内层本身还可能以 结尾 if (cpInner.endsWith()) { cpInner cpInner.substring(0, cpInner.length() - 2); } MapString, String cpMap parseCpFields(cpInner); msg.setCp(cpMap); } else { String[] kv field.split(, 2); switch (kv[0]) { case ST - msg.setSt(kv[1]); case CN - msg.setCn(kv[1]); case PW - msg.setPw(kv[1]); case MN - msg.setMn(kv[1]); case Flag - msg.setFlag(kv[1]); default - { /* 未知字段忽略或扩展 */ } } } } return msg; } private static MapString, String parseCpFields(String cpInner) { MapString, String map new LinkedHashMap(); String[] fields cpInner.split(;); for (String field : fields) { String[] kv field.split(, 2); if (kv.length 2) { map.put(kv[0], kv[1]); } // 特殊情况某些字段值本身可能包含号如参数配置所以用limit2 } return map; }逻辑说明先验证帧头##再按长度字段截出数据段用CRC16做完整性校验。外层字段除CP外都是键值;的平铺结构直接拆CP内层剥掉后再做一次同规则拆分。split(, 2)的第二个参数是防止值本身含号时误切。参数说明declaredLen是字符数不是字节数多字节字符场景下必须配合正确编码使用field.substring(5, length - 4)针对CP...的CP占 3 字符前后各 2 个。4.2 中文编码问题一个字符串长度引发的血泪案例先看一个真实场景某水污染监测站上报的备注字段里含中文原始帧如下##0138ST21;CN2011;PW123456;MN010000A8900016F000169DC0;CPDataTime2024-06-01 12:00:00;Rtd3.5;北排口;CRC16;如果按 UTF-8 字节计算declaredLen北排口三个汉字共 9 个字节而帧里标注的长度是按字符数计的于是Integer.parseInt(substring(2,6))会少算或多算取决于设备端是字符计数还是字节计数。我们遇到的设备是字符计数平台端却按字节截断直接导致CP内容解析后乱码、CRC 校验失败。解决方式解析前统一用StandardCharsets.UTF_8解码字节流解析时一律按String.length()口径截取。如果在 GBK 编码设备上对接最好在 TCP 接入层配置ByteBuf的解码字符集为 GBK否则中文字段全乱。建议在接入层就把编码统一成 UTF-8解析层不碰字节流只碰 String。4.3 最小可运行示例从读取 TCP 字节流到输出解析结果写一个能直接跑的 TCP 服务端骨架依赖 Netty 的ByteToMessageDecoder解决半包问题public class HJ212Decoder extends ByteToMessageDecoder { Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { // 找包头 int startIdx indexOf(in, ##); if (startIdx 0) { return; } in.skipBytes(startIdx); if (in.readableBytes() 6) { return; } // 读长度字段注意是字符 byte[] lenBytes new byte[4]; in.getBytes(0, lenBytes); int declaredLen Integer.parseInt(new String(lenBytes, StandardCharsets.UTF_8)); if (in.readableBytes() 4 declaredLen 6) { return; // 等待更多数据 } in.skipBytes(4); byte[] data new byte[declaredLen]; in.readBytes(data); byte[] crcBytes new byte[6]; in.readBytes(crcBytes); out.add(HJ212Parser.parse(new String(data, StandardCharsets.UTF_8))); } }逻辑说明这个解码器做三件事——找到##包头读取 4 位长度字段凑齐完整一帧再交给解析器。参数说明6这个数字对应CRC16;的长度declaredLen可能很大有些设备一帧塞几百个监测因子in.readableBytes()的边界判断不能省否则半包时直接解析会抛异常。5. 接入现场常见的 5 个坑从 CRC 校验失败到中文乱码5.1 设备不回包先查 TCP 长连接的心跳与超时机制现象数采仪 TCP 连接建立后平台发送了取时间命令CN3011设备无响应过几分钟连 TCP 连接也被对端关闭。原因很多数采仪的长连接设计是“平台先发命令、设备才应答”但如果设备侧要求平台定期下发心跳命令通常是CN3011取时间或CN3012校时平台没发设备在空闲超时后主动断开。解决平台侧维护一个定时任务按设备厂商要求的频率一般是 30~60 秒下发心跳帧。注意心跳帧的MN字段必须与连接的MN一致否则设备拒绝应答。CN3011的响应帧会带设备时间可以顺带校准平台侧的时间漂移。5.2 CRC 校验老失败问题多半出在计算范围不对现象用官方示例帧测试解析器CRC 总是校验失败。把官方算出来的CRC16;和自研算出来的对比只有少数帧能对上。原因CRC16 的计算范围包含ST开头到CP...结束的完整字符串。但有的设备厂商把###的前导符或者末尾;也算进去了还有的厂商用的是 Modbus CRC16 算法多项式不同。解决先用官方标准帧做单元测试逐字节对照计算范围。如果设备端的算法和国标不一致最好的办法是在接入层加一个“厂商兼容模式”把CP尾部后面的;排除在计算范围外并允许 CRC 大小写混用。拿现场的失败帧反查将合法计算范围打日志打到调试窗口再与设备厂商确认后写入兼容模块。5.3 解析出来字段全部错位大概率是拆分粘包处理没到位现象设备连续上报数据时偶发一帧解出来的MN是乱码CN字段值变成了“20112011”。原因TCP 是字节流不是按帧边界传数据的。设备一次写入可能只到了半个帧解析器读到##后以为长度够了直接截断实际数据还没到齐于是后面的帧内容被串到了前一帧。解决服务端必须用ByteToMessageDecoder或者LengthFieldBasedFrameDecoder这类拆包器。HJ212Decoder里“读长度 → 判断剩余字节数 → 不足则 wait”的逻辑不能省。如果同时接多台设备连接和解析器实例要一一绑定不能共享同一个解析状态。5.4 监测因子 Rtd 值缺失别只盯着解析器看看设备配置现象能连上设备、能收到心跳但CN2011实时数据帧里CP只有DataTime和Flag没有Rtd字段。原因这基本不是协议解析问题是数采仪本身的采样或上报配置不对。很多数采仪要主动设置上报因子集合通过CN3014设置上报参数如果平台没下发过配置命令设备按默认配置只上报少量数据。解决平台启动后先查询设备当前的采样配置CN3013取采样速率CN3014相关命令按厂商手册处理确认是否需要下发因子配置。同时看Zg、Cou、Rtd三个字段是否齐全——有的设备用Rtd表示瞬时值有的用Cou表示累计值语义映射得跟厂商确认。5.5 中文乱码问题从 GBK 到 UTF-8 的转换策略现象设备上报的排放口名称是中文平台入库后变成“????”日志里打印出来是乱码。原因设备端用 GBK 编码平台端 TCP 接入层用 UTF-8 解码。HJ212Decoder里new String(bytes, StandardCharsets.UTF_8)在遇到 GBK 中文时会产生替换字符?导致长度校验和 CRC 双双失败如果?改变了字符串长度。解决在接入层统一处理编码。第一步设备接入时动态适配先尝试 UTF-8 解码出现非法字符序列时回退到 GBK以替换字符出现率为判断条件第二步所有下发的控制指令统一转成 GBK 字节发送。注意Netty 里ByteBuf.toString(Charset)的 charset 必须与设备端一致HF212 标准本身没规定传输必须用 UTF-8现实中 GBK 设备很多。提示上述出现的 5 条踩坑记录前三条属于协议层、后两条属于设备集成层排查时建议按这个顺序来——先保证帧完整再保证编码正确最后才怀疑设备配置。6. 进阶玩法透传转发、多包缓存与超时窗口的落地配置6.1 把解析器做成独立管道Netty Pipeline 里加一层业务处理器解析器只负责把文本帧变成HJ212Message业务处理要独立出去。常见做法是在 Netty 的ChannelPipeline里串联三个 handlerHJ212Decoder字节流入产出HJ212Message对象。MessageFilterHandler按MN做设备白名单过滤、按CN做命令路由。BusinessDispatchHandler把数据推向业务线程池落库、告警、转发。这样做的价值在于设备厂商如果想在平台端实时看到原始报文用于排查问题可以单独加一个RawFrameLoggerHandler放在最前面把每一帧原文打印到日志文件不会污染业务链路。生产环境的排障大多靠这个日志恢复现场。6.2 Flag4 的多包缓存如何拼装被拆分的超长报文HJ212 支持把一条长数据拆分成多帧传输Flag字段指示拆分状态Flag0表示单包Flag1首包Flag2中包Flag3末包Flag4表示后续还有包。多包场景常见于一次性上报几百个监测因子。拼包策略private final MapString, StringBuilder packetCache new ConcurrentHashMap(); public void handleMessage(HJ212Message msg) { if (4.equals(msg.getFlag())) { packetCache.computeIfAbsent(msg.getMn(), k - new StringBuilder()) .append(msg.getCp().toString()); // 不落库等末包 } else { // 把缓存里的内容和当前帧合并后交给业务层 StringBuilder cached packetCache.remove(msg.getMn()); if (cached ! null) { String fullData cached.toString() msg.getCp().toString(); dispatchToBusiness(msg.getMn(), fullData); } } }逻辑说明以MN为 key 缓存中间包等末包到达后再合并。参数说明缓存必须有超时清理机制比如用Caffeine设置 5 分钟过期否则设备中途断连缓存永远残留导致后续正常帧被脏数据污染。合并后的数据要重新做一次完整解析——因为 CP 字段被拆开后拆分边界可能落在半截键值对中间直接拼接文本后重新跑parseCpFields是最安全的。6.3 超时窗口与设备会话管理让故障自动可感知设备与平台建立 TCP 长连接后如果设备死机或网线断开TCP 层可能要等到 TCP 超时才能察觉这期间平台会以为设备在线。所以在业务层必须加一个“最后心跳时间”的监控。实现要点每次收到任何帧包括心跳命令响应都更新lastActiveTime。定时任务每 30 秒扫描一次超过 2 分钟没有活动的连接标记设备离线并触发告警。离线重连由设备发起平台不需要主动重连统计口径要按离线时长算。这个窗口的时长不能设太短。有些数采仪是上报型设备平台不发指令它就不主动上传只有收到心跳命令才回帧如果设备心跳周期是 60 秒超时窗口设成 120 秒比较稳。6.4 我踩过的一个生产教训盲改编码方式导致全平台乱码有段时间平台接入了一批老设备解析后所有中文都乱码。我第一反应是设备端编码问题直接全局把解码字符集从 UTF-8 改成 GBK结果新设备的中文全炸了。后来才意识到这是一个混合编码环境不同厂商的设备可能用不同编码必须按连接维度配置字符集。最后在HJ212Decoder里加了一个Charset参数通过设备 IP 或MN前缀动态选择解码字符集才算真正解决。这也是为什么我反复强调HJ212 解析的核心是确定性的帧结构但设备厂商的“方言”要求你必须把编码、CRC 计算范围、Cmd 语义都做成可配置项。验证方法在正式接入前用官方标准帧做单测接入后用日志里的RawFrameLoggerHandler对比原始帧和解析结果再做一次断包、粘包、多包、中文字段、异常帧的空洞测试。这套流程走完平台对接才会稳。希望帮到你。本文还有配套的精品资源点击获取