资讯动态

HJ212解析器实战:Java实现TCP粘包拆包与CRC校验

发布时间:2026/9/12 10:31:32 来源:尧图企业网站定制
简介面向环保数据通信开发者的HJ212协议Java解析器示例聚焦环境监测数据采集传输协议HJ212的报文解析与数据处理适合需要对接环保数采仪、搭建数据接入平台或学习自定义协议解析的程序员。压缩包共112个文件其中以100个Java源码为主辅以8个XML配置、项目说明与许可证文件整体仅92KB代码结构紧凑且模块划分清晰。解析器覆盖T212报文帧拆解、数据段映射、监测因子CpData转换、污染指标解析等核心环节涉及SegmentParser、T212Mapper、T212Factory等关键类并提供一个可直接运行的解析Demo便于对照报文观察每一步解析结果。目前已有1309人学习通过研读源码可快速掌握HJ212帧格式、CRC校验及Java实现思路既可用于教学参考也能直接复用或扩展至实际环保数据采集项目。1. HJ212 解析器环保数采接入的第一个技术门槛HJ212 解析器是所有环保数采平台绕不开的起点。数采仪按 HJ/T 212 标准把污染物浓度、流量、设备状态打包成 ASCII 文本帧通过 TCP 或 4G DTU 推送到服务端帧本身就是一串由##、4 位十进制长度、分号分隔字段和嵌套数据区组成的文本。第一次接触的人很容易按;直接 split结果在 CRC 校验和 CP 内层字段上反复踩坑。用 Java 写一个可复用的 HJ212 解析器核心不是正则而是先把帧边界、CRC 范围和嵌套结构讲清楚再写拆包与解析代码。我会直接给出一套能用于生产项目的解析器骨架以及一个能自建帧、验证粘包的 demo适合正在做数据接入的后端工程师和维护者参考。2. HJ212 报文帧结构先看懂 ## 与 的边界HJ212 协议本身是纯文本协议每一帧有明确的物理布局固定包头开头4 位长度给出数据段长度之后是数据段、CRC 和 CRLF 结束符。理解这个布局是写解析器的前提因为粘包、半包和 CRC 校验全部依赖这个边界。2.1 帧结构的四个组成部分实际线上最常见的完整帧长这样长度和 CRC 用占位符表示##xxxxQN20240101120000000;ST22;CN2011;PW123456;MN010000A9300001;CPa34001-Rtd12.5,a34002-Rtd0.34xxxx\r\n拆开看就是四个部分组成占用字符数说明包头2固定为##用于寻找帧起始数据段长度4ASCII 编码的十进制数字例如0147数据段N从QN或ST开始到最后一个结束CRC 校验4对数据段整个字符串计算 CRC-16 后转大写十六进制结束符2\r\n部分设备可能只发\n解析时要兼容包头##很容易找但不要看到##就认为是一个新帧因为字符串里a34001-Rtd等字段中也可能出现#。正确做法是先锁定##再读后面的 4 位长度然后用长度去截帧。如果长度不够说明帧还没到齐继续等下一次 TCP 读取。2.2 数据段字段与 CP 嵌套规则数据段由若干固定字段组成字段之间用分号;分隔。常见字段包括QN请求时间戳常见为 17 位格式类似yyyyMMddHHmmssSSS用于标识一条唯一的命令或数据包。ST系统编码例如21代表水环境监测22代表大气环境监测。CN命令编码2011一般表示实时数据上报2061是应答包9011是设置类的请求或应答。PW设备访问密码普通场景下是 6 位数字。MN设备唯一标识例如010000A9300001。CP数据区是整个协议最需要小心的部分。CP的值固定以开始、以结束。内部用分号分隔一组组数据每组用等号连接字段名和值。比如CPa34001-Rtd12.5,a34002-Rtd0.34其中a34001-Rtd是污染物浓度字段12.5是数值。难点在于数据段的多个字段是分号分隔的而CP内部也有分号。如果直接对整个数据段split(;)外部字段和内部字段会被一起拆开CP本身也很难还原。因此解析器必须先找到CP把CP值整体抠出来再对剩余部分做外部字段拆分。2.3 CRC 计算边界不算包头算整个数据段协议文档对 CRC 的定义是对数据段所有字符按 ASCII 码做 CRC-16/CCITT 校验多项式为0x1021初值0xFFFF计算结果用 4 位大写十六进制表示。也就是说计算范围从QN或ST开始一直到最后一个结束不包含前面的长度字段也不包含后面的 CRC 和\r\n。写解析器前可以先在命令行验证一次长度逻辑dataQN20240101120000000;ST22;CN2011;PW123456;MN010000A9300001;CPa34001-Rtd12.5 echo -n $data | wc -c printf %04d\n ${#data}echo -n不输出换行wc -c统计字节数因为这一段全部是 ASCII 字符字节数等于字符数。printf %04d把字符数补成 4 位十进制正好对应包头里的长度字段。这里要特别提醒实际设备数据如果包含中文长度必须按协议字符集计算通常协议要求 ASCII遇到非 ASCII 字符先转义或按设备手册处理否则长度对不上CRC 也会一起失败。3. 用 Java 实现 HJ212 解析器核心类CRC 与字段拆解这一章给出一个不依赖 Spring、可以直接拷进工具类的 Java 实现。类名用Hj212Parser包含三个能力算 CRC、校验并解析一帧、从 CP 中提取内部字段。代码在hj212-master这类骨架工程里通常会拆成Crc16和Hj212Parser两个类这里为方便演示合并成一个。3.1 CRC-16/CCITT 的 Java 实现与参数说明CRC 计算用位循环方式实现不引入查表法代码短且容易读懂import java.nio.charset.StandardCharsets; public class Hj212Parser { private static final int CRC_POLY 0x1021; private static final int CRC_INIT 0xFFFF; public static String crc16(String data) { int crc CRC_INIT; byte[] bytes data.getBytes(StandardCharsets.ISO_8859_1); for (byte b : bytes) { crc ^ (b 0xFF) 8; for (int i 0; i 8; i) { if ((crc 0x8000) ! 0) { crc (crc 1) ^ CRC_POLY; } else { crc 1; } crc 0xFFFF; } } return String.format(%04X, crc); } }这段代码里有两个关键参数CRC_POLY是多项式0x1021对应协议里的 CCITT 多项式CRC_INIT是初值0xFFFF。每一字节先异或到 crc 高 8 位再做 8 次移位位 15 为 1 时与多项式异或最终保留低 16 位。ISO_8859_1保证每个字符按一个字节处理不会引入 UTF-8 的多字节干扰。如果设备帧里出现了协议外的字符这里得到的 byte 流仍能按单字节计算不会抛异常但 CRC 很可能因字符集不一致而对不上。3.2 解析主流程先用长度截帧再拆字段解析一帧的标准步骤是校验##开头读第 3 到 6 个字符得到数据段长度截取数据段取出后面 4 位 CRC用crc16校验校验通过后进入字段解析。import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.Map; public class Hj212Parser { public static class Hj212Frame { public final MapString, String header new LinkedHashMap(); public final MapString, String cp new LinkedHashMap(); } public static Hj212Frame parseFrame(String frame) { if (frame null || !frame.startsWith(##)) { throw new IllegalArgumentException(帧必须以 ## 开头); } int dataLen Integer.parseInt(frame.substring(2, 6)); int frameLen 6 dataLen 4 2; if (frame.length() frameLen) { throw new IllegalArgumentException(帧长度不足期望 frameLen); } String dataSection frame.substring(6, 6 dataLen); String crcCode frame.substring(6 dataLen, 6 dataLen 4); if (!crcCode.equalsIgnoreCase(crc16(dataSection))) { throw new IllegalArgumentException(CRC 校验失败: crcCode); } Hj212Frame result new Hj212Frame(); int cpIdx dataSection.indexOf(CP); if (cpIdx 0) { throw new IllegalArgumentException(缺少 CP 字段); } String outerFields dataSection.substring(0, cpIdx); String cpRaw dataSection.substring(cpIdx 3, dataSection.length()); String cpBody cpRaw.substring(1, cpRaw.lastIndexOf()); for (String field : outerFields.split(;)) { putField(result.header, field); } for (String field : cpBody.split(;)) { putField(result.cp, field); } return result; } private static void putField(MapString, String map, String field) { int eq field.indexOf(); if (eq 0) { map.put(field.substring(0, eq).trim(), field.substring(eq 1).trim()); } } }流程中的几个关键参数分别是frameLen中6是包头和长度字段的占用长度4是 CRC 位数2是 CRLF。这里先按长度截出数据段再做 CRC 校验而不是先按\r\n找结束符这样对设备只发\n或丢失\r的情况更宽容。字段拆分之前先找CP把外层的QN、ST、CN、PW、MN等字段和 CP 内部字段分开。outerFields.split(;)安全的前提是这些外部字段的值不会包含分号如果未来出现字段值带分号的扩展命令需要改用可配置的分隔符扫描逻辑。cpRaw.substring(1, cpRaw.lastIndexOf())用于剥离 CP 最外层的一对内部字段再用分号拆分。3.3 返回结果与 CP 内部字段的存放方式解析结果放在Hj212Frame里header保存外部公共字段cp保存 CP 内部字段。这样写的好处是后续业务代码可以直接取frame.cp.get(a34001-Rtd)拿到污染物浓度而不用关心CP的嵌套干扰。public static String buildFrame(String dataSection) { String crc crc16(dataSection); return String.format(##%04d%s%s\r\n, dataSection.length(), dataSection, crc); }buildFrame是给 demo 和测试用的它先用dataSection.length()得到字符数再用%04d补成 4 位随后拼上 CRC 和\r\n。这里没有做字符集适配如果将来需要发送包含中文的字段得先用协议要求的编码转换并确认长度。这一章的实现重点是边界长度字段告诉解析器从哪里截断CRC 告诉解析器这段数据是否完整可信CP的定位告诉解析器内外层字段如何分区。三件事按顺序做解析器就站稳了一半。4. HJ212 解析器对接 TCP粘包、半包与重发帧的处理真正生产环境里数采仪和服务端的连接通常是 TCP。TCP 是流协议一次read返回的字节可能包含完整一帧、半帧也可能是多帧粘在一起。如果直接把read结果丢给parseFrame大概率会出现substring越界或 CRC 乱报。这一章给一个适合 HJ212 的拆包器把字节流变成完整帧列表。4.1 为什么必须自己做粘包拆包HJ212 不依赖\r\n作为帧结束的唯一依据因为数据段内可能出现\r或转义后的换行。可靠的边界是包头##加 4 位长度。拆包器应该维护一个缓冲区每次收到新字节先追加再循环尝试从缓冲区头部解析一帧若缓冲区不足 6 字节等待下一批数据。若找不到##丢弃开头无效字节继续找。若长度字段合法且缓冲区已够一帧截出来做完整帧校验。若 CRC 校验不通过说明这一帧损坏跳过当前##继续找下一个包头。4.2 基于缓冲区的粘包解码器实现用ByteArrayOutputStream做追加缓冲drain 逻辑放在每次写入后统一执行import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.List; public class Hj212Decoder { private final ByteArrayOutputStream buffer new ByteArrayOutputStream(); public ListString accept(byte[] chunk) throws IOException { buffer.write(chunk); ListString frames new ArrayList(); byte[] all buffer.toByteArray(); int offset 0; while (offset 6 all.length) { if (all[offset] ! # || all[offset 1] ! #) { offset; continue; } String lenText new String(all, offset 2, 4, StandardCharsets.US_ASCII); int dataLen; try { dataLen Integer.parseInt(lenText); } catch (NumberFormatException e) { offset 2; continue; } int frameLen 6 dataLen 4 2; if (offset frameLen all.length) { break; } String frame new String(all, offset, frameLen, StandardCharsets.ISO_8859_1); try { Hj212Parser.parseFrame(frame); frames.add(frame); offset frameLen; } catch (IllegalArgumentException e) { offset 2; } } buffer.reset(); if (offset all.length) { buffer.write(Arrays.copyOfRange(all, offset, all.length)); } return frames; } }这段代码的核心是while循环里的三次判断第一次用all[offset] ! #跳过无效字符第二次在长度字段非数字时跳过 2 个字节避免死循环第三次在缓冲区长度不足一帧时break保留剩余数据。buffer.reset()后写入未消费的部分保证下一批 TCP 数据到达时继续处理。值得注意的细节是长度字段解析用US_ASCII帧内容用ISO_8859_1解码。原因很简单协议字段都是 ASCII长度必须按 ASCII 读成数字内容在 Java 内存里只做字节搬运用 ISO-8859-1 可以无损地保留每个字节后续再用协议字符集解读。4.3 帧校验失败与重发收到坏帧怎么办CRC 校验失败的帧不一定需要中断连接。一种常见处理是跳过当前##继续找下一个##。上面代码已经这样做了。但如果错误是长度字段本身损坏4 位数字可能解析出一个很大的dataLen导致offset frameLen超过缓冲区长度结果永远是break缓冲区越积越大。我一般会给缓冲区设一个上限比如 8KB超过后直接从最后一个##位置重置异常场景行为防止的问题找不到##前进一个字节无效垃圾数据长期占用缓冲区长度非数字前进两个字节避免重解析同一区域长度合理但数据不足等待下一包保留半包CRC 失败丢弃当前开头坏帧不污染正常帧对于重发核心是QN。数采仪可能因为网络抖动重发同一包数据服务端可以在接入层用MN QN做去重。简单做法是维护一个ConcurrentHashMapString, Longkey 是MN_QNvalue 是接收时间超过 5 分钟淘汰收到重复 key 时直接丢弃不进入业务队列。这个逻辑放在解码器之后、消息队列之前成本很低。5. 给 HJ212 demo 加自检生成合法帧并断言解析结果解析器写完不能只靠设备来验证。我会在工程里放一个main方法用buildFrame生成合法帧再模拟半包和粘包送入Hj212Decoder最后打印和断言结果。这样每次改完协议逻辑都能在本地或 CI 跑一遍自检。public static void main(String[] args) throws Exception { String section QN20240101120000000;ST22;CN2011;PW123456;MN010000A9300001;CPa34001-Rtd12.5,a34002-Rtd0.34; String frame Hj212Parser.buildFrame(section); Hj212Decoder decoder new Hj212Decoder(); ListString first decoder.accept(frame.substring(0, 10).getBytes(StandardCharsets.ISO_8859_1)); ListString second decoder.accept((frame.substring(10) frame).getBytes(StandardCharsets.ISO_8859_1)); if (!first.isEmpty()) { throw new AssertionError(半包阶段不应输出完整帧); } if (second.size() ! 2) { throw new AssertionError(粘包后应拆出 2 帧实际 second.size()); } Hj212Parser.Hj212Frame parsed Hj212Parser.parseFrame(second.get(0)); if (!12.5.equals(parsed.cp.get(a34001-Rtd))) { throw new AssertionError(CP 字段解析结果不正确); } System.out.printf(pass: %s%n, parsed.header.get(MN)); }这里的first阶段只发了帧前 10 字节缓冲区里肯定不是一个完整帧正常输出空列表second阶段把剩余半包和整帧一起送入拆包器应当恢复两帧。最后一个断言检查 CP 内部字段确保CP边界没有被split(;)破坏。自检通过后还有三个验证技巧可以沉淀到测试环境验证内容方法目的CRC 算法用在线 CRC-16/CCITT 工具比对一帧确认多项式、初值、输出大小写半包边界每 1、2、3 字节切分帧做循环测试找到拆包器对边界长度的误判坏帧跳过在合法帧前插入随机字符确认丢坏帧后仍能恢复后续合法帧最后提醒一个容易忽略的地方接口文档里的CN、ST、PW在不同行业版本里含义可能不同解析器只负责把字段拆出来不要在里面写死业务码判断。把协议解析和业务编码解耦后续升级成 HJ212-2017 或带加密扩展的版本时才不会牵一发动全身。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价