HJ212协议解析实战:Java+Netty自定义协议拆包与CRC16校验
简介:面向环保监测系统开发者的 HJ212 协议 Java 解析示例包,聚焦《污染源在线自动监控(监测)系统数据传输标准》的数据编解码与通信处理,适合需要快速理解协议结构、在项目中接入污染物数据采集与转换的初中级 Java 工程师。压缩包共 308 个文件,约 327KB,以 100 个 java 源码和 197 个 class 文件为主体,辅以 xml 配置、properties、classpath、project 等工程文件,源码与编译产物并存,便于在 Eclipse 中直接导入并调用解析入口。已有 5083 人学习下载,说明其在同类协议解析资料中具有一定参考度。包内围绕字段映射、数据层级反序列化、报文分段解析与数据转换等环节组织实现,读者可据此梳理 HJ212 报文从接收、拆分到对象化的处理链路,并参考异常处理与单元测试思路完成本地验证,减少从协议文本到可运行代码之间的摸索成本,对搭建小型环保数据接入模块有直接借鉴意义。
1. 从一条 240 字节的报文说起:HJ212 在 Java 里到底要解析什么
现场数采仪连上来,第一包通常是##0240QN=20240101120000001;ST=32;CN=2011;...;CP=&&DataTime=20240101120000;a34013-Rtd=1.23,a34013-Flag=N&&ABCD\r\n这种纯 ASCII 串,中间夹着&&,末尾四位十六进制。多数人第一反应是new String(bytes).split(";"),然后被CP=&&...&&里嵌套的分号、逗号和-Rtd后缀教育一遍。
HJ212 这套面向环保污染物在线监测的国标传输协议,帧结构只有「帧头 + 长度 + 数据段 + CRC16 + 回车换行」四件套,看着比自定义二进制协议简单得多。真正让人翻车的地方全在边界:长度字段算不算结尾的\r\n、CRC 的计算范围含不含##、因子值给-时怎么入库、设备等不到应答时如何十分钟内重发三次。下面按一份能直接跑起来的 Java 工程,从拆包、校验、解析讲到应答组包,顺带把现场联调的血泪经验记下来。适合正在做环保数据上报对接的后端,也适合拿 Netty 练手自定义协议的人。
2. 协议骨架拆解:从##到\r\n,一条报文的四层结构
2.1 帧结构:2 字节帧头、4 位长度、4 位 CRC16、2 字节 CRLF
不管上层业务是实时数据、分钟数据还是心跳,HJ212 的帧永远是同一副骨架:
| 位置 | 长度 | 内容 | 说明 |
|---|---|---|---|
| 帧头 | 2 字节 | ## | ASCII 0x23 0x23,用来对齐流 |
| 长度域 | 4 字节 | 0240 | 十进制 ASCII,不足四位左补 0,表示数据段字节数 |
| 数据段 | N 字节 | QN=...;CP=&&...&& | 真正承载业务的字符串 |
| 校验域 | 4 字节 | ABCD | CRC16 结果,十六进制 ASCII |
| 结束符 | 2 字节 | \r\n | 0x0D 0x0A |
关键在「长度域」这三个字。它统计的是数据段的字节数,不包括##、长度域本身、CRC 和\r\n。但现场设备厂商实现水平参差,有的会把结尾的\r\n也算进去,于是同一批里两种长度混着来——这一点在解码器里必须做成双分支容错,否则日志里会出现「前一条正常、后一条全部错位」的灵异现象。
数据段本身是纯文本,字段之间用;分隔,值里允许出现=、,、-。CRC16 只对数据段原始字节计算,不碰帧头。把这三条记牢,后面所有解析代码都是它的推论。
2.2 数据段七字段:QN、ST、CN、PW、MN、Flag、CP 各自管什么
数据段是「键=值;键=值」的扁平结构,前面六个字段固定,最后一个CP是可变的数据体:
| 字段 | 含义 | 典型值 | 落地时的用途 |
|---|---|---|---|
QN | 请求编号 | 20240101120000001 | 通常是时间戳加序号,做幂等去重的主键 |
ST | 系统编码 | 32 | 表示子系统类型(如污染源在线监测) |
CN | 命令编码 | 2011 | 决定这条报文走哪个 Handler,是最重要的路由键 |
PW | 访问密码 | 123456 | 平台下发给设备时用于鉴权,设备上传时也给 |
MN | 设备唯一标识 | DEV0001 | 设备身份,等价于设备号,入库必带 |
Flag | 标志位 | 5、4 | 8 位,按位定义(是否拆分包、是否需应答等) |
CP | 数据段 | &&DataTime=...;a34013-Rtd=1.23&& | 用&&包裹,内部再按;和,分层 |
CN是整个协议的分发核心。代码里只会实现对接方文档要求的那几个,常见的比如2011实时数据、2051分钟数据、2061小时数据、2031日数据、9011心跳。具体编码以设备手册和平台接口文档为准,别硬背,我一般把命令码做成枚举,缺哪个补哪个,配合default分支打日志告警,比在switch里堆if-else好维护得多。
Flag这个字段值得单独说一句。它在标准里有按位的语义,但不同版本、不同厂商对某几位究竟代表什么理解并不一致。稳妥做法是:解析阶段只保留原始字符串,另外提供一个getFlagBit(int index)方法按位取值,语义映射交给适配层配置,别在协议层硬编码bit3 == 1 表示 xxx。这样以后换个对接方,改的是一行配置而不是全量回归。
2.3 工程分层:传输层、编解码层、协议层、业务层怎么切
我把这个项目拆成四层,原因是现场对接会反复变——今天是 TCP 直连,明天可能要求走 MQTT 转发;今天只要实时数据,明天要补分钟数据。分层做对了,变化就只落在最外面一层。
- 传输层:Netty 的
ServerBootstrap/Bootstrap,负责端口监听、连接保活、断线重连。这一层完全不知道 HJ212 是什么。 - 编解码层:
Hj212FrameDecoder负责切帧,Hj212Codec负责把字节变成Hj212Frame对象,Hj212Frames负责反向组包。这层关心字节,不关心业务。 - 协议层:
Hj212Parser把数据段拆成字段,Crc16做校验,异常统一抛Hj212Exception。这层是纯函数,可以脱离网络单测。 - 业务层:按
CN路由的Hj212Handler,做入库、幂等、告警、应答。
分层的收益很实在:协议层是纯函数,用EmbeddedChannel就能跑回归,不需要真的起个服务;传输层换 MQTT 时,编解码层和业务层一行不动。这也是我后面讲二次开发时的基础。
3. 工程落地:目录结构、依赖和一条能跑通的启动链路
3.1 目录结构:codec、protocol、handler、service 四个包
hj212-gateway/ ├── pom.xml └── src/main/java/com/example/hj212/ ├── Hj212Application.java # Spring Boot 启动类 ├── codec/ │ ├── Crc16.java # CRC16 校验 │ ├── Hj212FrameDecoder.java # 拆包,处理粘包半包 │ ├── Hj212Codec.java # 字节 -> Hj212Frame │ └── Hj212Frames.java # 组包 + 应答构造 ├── protocol/ │ ├── Hj212Frame.java # 报文对象 │ ├── Hj212Parser.java # 数据段 -> 对象 │ ├── Hj212Cn.java # 命令码枚举 │ └── Hj212Exception.java ├── handler/ │ ├── Hj212Handler.java # 业务接口 │ ├── Hj212DispatchHandler.java # 按 CN 分发 │ ├── RealtimeDataHandler.java # CN=2011 │ └── HeartbeatHandler.java # CN=9011 ├── service/ │ ├── MonitorDataService.java # 落库 + 幂等 │ └── DeviceRegistry.java # 设备在线状态 └── config/NettyServerConfig.java # 端口、线程数四个包对应上面四层,边界很清晰。protocol包里没有任何 Netty 和 Spring 的 import,这一点建议拿 CI 的 ArchUnit 卡住,不然半年后一定有人往里塞@Autowired。
3.2 pom 依赖与 JDK 选择
依赖不用多,我一般就这几样:Spring Boot 3.x(JDK 17 起步)、netty-all4.1.x、spring-boot-starter-jdbc加一个 H2 或 MySQL 驱动、spring-boot-starter-test做回归。不引 Lombok 也能写,Hj212Frame就七个字段,手写 getter/setter 反而看得清楚。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <!-- 协议解析全部自己写,Netty 只提供 IO 和 ByteToMessageDecoder 骨架 --> <dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.100.Final</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> </dependencies>JDK 别贪新。整套代码用到的是String、byte[]、Charset这类基础 API,JDK 17 足够,现场部署的服务器往往是内网老机器,JDK 21 反而可能装不上。这一点看着不起眼,但工控网里的环境真不是想升就能升。
3.3 启动 Netty 服务并跑通第一个报文
传输层就是一个标准的 Netty 服务端,pipeline里挂三段:
@Component public class NettyServerConfig { @Value("${hj212.port:8021}") private int port; private Channel serverChannel; public void start() throws InterruptedException { EventLoopGroup boss = new NioEventLoopGroup(1); EventLoopGroup worker = new NioEventLoopGroup(); // 默认 CPU*2 ServerBootstrap b = new ServerBootstrap(); b.group(boss, worker) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 256) // 现场设备可能同时重连 .childOption(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.TCP_NODELAY, true) // 报文小,别攒 .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline() .addLast(new Hj212FrameDecoder()) // 1. 切帧 .addLast(new Hj212Codec()) // 2. 校验 + 转对象 .addLast(new Hj212DispatchHandler()); // 3. 按 CN 分发 } }); serverChannel = b.bind(port).sync().channel(); } }这里有两个参数值得动手改一遍再理解:SO_BACKLOG调大是因为现场断电恢复时,几十台设备会在同一秒重连;TCP_NODELAY打开是因为 HJ212 单条报文只有几百字节,Nagle 算法攒包的几十毫秒延迟会让心跳超时判断失准。
跑通的第一步不用真接设备。写一个 JUnit 测试,用EmbeddedChannel把一条手搓的报文喂进去,能读出Hj212Frame就说明拆包、CRC、解析三段都通了:
@Test void shouldParseRealtimeFrame() { String seg = "QN=20240101120000001;ST=32;CN=2011;PW=123456;" + "MN=DEV0001;Flag=5;CP=&&DataTime=20240101120000;" + "a34013-Rtd=1.23,a34013-Flag=N&&"; byte[] body = seg.getBytes(Charset.forName("GBK")); String raw = "##" + String.format("%04d", body.length) + seg + Crc16.calc(body) + "\r\n"; EmbeddedChannel ch = new EmbeddedChannel(new Hj212FrameDecoder(), new Hj212Codec()); ch.writeInbound(Unpooled.copiedBuffer(raw, Charset.forName("GBK"))); Hj212Frame f = ch.readInbound(); assertEquals("2011", f.getCn()); assertEquals("DEV0001", f.getMn()); assertEquals("1.23", f.getCpMap().get("a34013-Rtd")); }这条用例的价值不只是「测试通过」。它把帧格式固化成可执行的断言,以后谁改了解码器,跑一下就知道有没有改坏——比在联调现场抓包对字节高效得多。
4. 核心代码拆解:CRC16、粘包解码、CP 解析与应答组包
4.1 CRC16 校验:0xA001 多项式与 4 位十六进制输出
校验算法本身不复杂,难的是范围界定。计算对象是数据段原始字节,不含##、不含长度域、不含末尾\r\n,结果转成 4 位十六进制大写字符串。
public final class Crc16 { private static final int POLY = 0xA001; // 反向多项式,等价于 Modbus CRC16 /** 对数据段原始字节计算,返回 4 位十六进制大写 */ public static String calc(byte[] data) { int crc = 0xFFFF; for (byte b : data) { crc ^= (b & 0xFF); for (int i = 0; i < 8; i++) { if ((crc & 0x0001) != 0) { crc = (crc >>> 1) ^ POLY; } else { crc >>>= 1; } } } return String.format("%04X", crc & 0xFFFF); } }参数说明:POLY是反向多项式 0xA001,初值 0xFFFF,不异或输出。这几项任意一个改动都会导致校验值不同,而 CRC 出错时你只能看到「校验失败」四个字,看不出是哪一项错的,所以这三行一定要写死在常量里并加注释。
b & 0xFF这步别省。Java 的byte是有符号的,中文 GBK 字节经常是负数,直接参与异或会得到负数结果,最后%04X输出成 8 位。我见过有人在这上面卡了一下午,最后发现是byte符号扩展。
4.2 拆包解码器:自己写 ByteToMessageDecoder 而不是用 LengthField
Netty 自带的LengthFieldBasedFrameDecoder在这里用不上——它的长度域是二进制定长整数,而 HJ212 的长度是4 个 ASCII 字符表示的十进制数。硬套的话得先写个ByteToMessageDecoder把 ASCII 转成 int,等于多绕一层,不如自己写。
public class Hj212FrameDecoder extends ByteToMessageDecoder { private static final int MAX_DATA_LEN = 8 * 1024; @Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) { // 1. 对齐帧头,前面不属于 HJ212 的脏字节直接丢 int head = indexOfDoubleSharp(in); if (head < 0) { in.skipBytes(in.readableBytes()); return; } if (head > 0) { in.skipBytes(head); } // 2. ## + 4 位长度,至少 6 字节才够判断 if (in.readableBytes() < 6) { return; // 半包,不移动读指针 } int fieldLen = readAsciiInt(in, in.readerIndex() + 2, 4); if (fieldLen <= 0 || fieldLen > MAX_DATA_LEN) { in.skipBytes(2); // 长度非法,跳过这个 ## 重新找 return; } // 3. 长度到底含不含结尾的 \r\n,两种写法都兼容 int dataLen = fieldLen; int crcEnd = 6 + fieldLen + 4; if (in.readableBytes() >= crcEnd + 2 && !isCrlf(in, crcEnd)) { if (isCrlf(in, 6 + (fieldLen - 2) + 4)) { dataLen = fieldLen - 2; // 该厂家把 \r\n 算进了长度 } } // 4. 整帧 = ##(2) + 长度域(4) + 数据段 + CRC(4) + CRLF(2) int total = 2 + 4 + dataLen + 4 + 2; if (in.readableBytes() < total) { return; // 还是半包,等下一批字节 } byte[] frame = new byte[total]; in.getBytes(in.readerIndex(), frame); // 读副本,不动指针 in.skipBytes(total); // 整帧消费掉 out.add(frame); } // indexOfDoubleSharp / readAsciiInt / isCrlf 是三个按字节判定的短函数,略 }这段代码有三处必须理解透:
第一,「不够就 return」且不移动读指针。这是ByteToMessageDecoder的契约,框架发现你没有消费字节,会把数据留着等下一次read拼上来。反过来,如果半包时手贱skipBytes了,报文就永久丢了,日志里表现为「设备发了但服务端没收到」。
第二,第 3 步的双分支容错。先按「不含\r\n」算 CRC 的结束位置,看那里是不是\r\n;不是的话再按「含\r\n」试一次。这种写法比在配置里给每台设备打标记省事,代价是每次多判断两个字节。
第三,长度非法时skipBytes(2)而不是整段丢弃。因为数据段里有可能出现##字符串(比如某个因子值是 base64),整段丢会把后面的正确帧一起吃掉。只丢两个字节,最多浪费一轮扫描。
4.3 数据段解析:为什么不能直接 split(";")
拿到数据段之后,最直觉的写法是seg.split(";")然后遍历。这条路上有两个坑:一是CP字段的值里也可能出现;(多组因子之间就用分号分隔),二是某些厂家会在值后面附带多余的分号导致空串。所以我用「先定位CP=,前半段才允许 split」的策略:
public static Hj212Frame parse(String dataSegment) { Hj212Frame f = new Hj212Frame(); // CP 之后的内容不再按 ';' 拆,避免把因子数据切碎 int cpIdx = dataSegment.indexOf("CP="); if (cpIdx < 0) { throw new Hj212Exception("缺少 CP 字段: " + dataSegment); } for (String kv : dataSegment.substring(0, cpIdx).split(";")) { int i = kv.indexOf('='); if (i <= 0) { continue; // 跳过空串和脏字段 } String k = kv.substring(0, i).trim(); String v = kv.substring(i + 1).trim(); switch (k.toUpperCase()) { case "QN" -> f.setQn(v); case "ST" -> f.setSt(v); case "CN" -> f.setCn(v); case "PW" -> f.setPw(v); case "MN" -> f.setMn(v); case "FLAG" -> f.setFlag(v); default -> { /* 未知字段忽略,不要抛异常 */ } } } // 去掉首尾的 &&,注意不能用 replace("&&",""),值里可能有 && String cp = dataSegment.substring(cpIdx + 3); if (cp.startsWith("&&")) { cp = cp.substring(2); } int end = cp.lastIndexOf("&&"); if (end >= 0) { cp = cp.substring(0, end); } f.setCp(cp.trim()); f.setCpMap(Hj212Parser.parseCp(f.getCp())); return f; }几个细节说明。k.toUpperCase()是因为现场确实有设备发Qn=、Flag=这种混合大小写;default分支静默忽略而不是抛异常,是因为标准在演进,设备可能带一些你版本里没有的扩展字段,抛异常等于把一个无害字段变成致命错误。lastIndexOf("&&")而不是indexOf,是为了兼容 CP 内部再嵌套&&分段的老设备。
PW和MN拿到之后建议做一次长度和字符集检查:MN是设备身份,出现空串或者带不可见字符,后面入库时会变成一堆找不到设备的孤儿数据。
4.4 CP 数据段:因子、Rtd、Flag 和组织结构
CP内部是两层结构:外层用;分隔不同的数据组,内层用,分隔同一个因子的多个属性。因子名一般形如a34013(烟尘)、a21026(二氧化硫)这样的编码,每个编码后面挂-Rtd(实时值)、-Flag(数据标记)、-Avg(平均值)、-Cou(累计值)等后缀。
public static Map<String, String> parseCp(String cp) { Map<String, String> map = new LinkedHashMap<>(); for (String group : cp.split(";")) { if (group.isBlank()) { continue; } for (String kv : group.split(",")) { int i = kv.indexOf('='); if (i <= 0) { continue; } map.put(kv.substring(0, i).trim(), kv.substring(i + 1).trim()); } } return map; } /** 取实时值,设备未接或故障时给 '-',这时代码里返回 null 而不是 0 */ public static Double getRtd(Map<String, String> cp, String polCode) { String v = cp.get(polCode + "-Rtd"); if (v == null || v.isEmpty() || "-".equals(v)) { return null; } return Double.valueOf(v); }用LinkedHashMap是为了保留原始顺序,出问题时打印出来和原始报文顺序一致,肉眼对起来快。getRtd里把-转成null这一步非常关键:HJ212 里-表示「该因子本次无有效数据」,如果直接Double.parseDouble会抛异常导致整条报文丢弃,如果转成 0 又会污染统计口径,让环保数据里凭空多
本文还有配套的精品资源,点击获取