HJ212协议解析实战:Java+Netty自定义协议拆包与CRC16校验
发布时间:2026/11/2 23:44:25 锦皓数字建站

简介面向环保监测系统开发者的 HJ212 协议 Java 解析示例包聚焦《污染源在线自动监控监测系统数据传输标准》的数据编解码与通信处理适合需要快速理解协议结构、在项目中接入污染物数据采集与转换的初中级 Java 工程师。压缩包共 308 个文件约 327KB以 100 个 java 源码和 197 个 class 文件为主体辅以 xml 配置、properties、classpath、project 等工程文件源码与编译产物并存便于在 Eclipse 中直接导入并调用解析入口。已有 5083 人学习下载说明其在同类协议解析资料中具有一定参考度。包内围绕字段映射、数据层级反序列化、报文分段解析与数据转换等环节组织实现读者可据此梳理 HJ212 报文从接收、拆分到对象化的处理链路并参考异常处理与单元测试思路完成本地验证减少从协议文本到可运行代码之间的摸索成本对搭建小型环保数据接入模块有直接借鉴意义。1. 从一条 240 字节的报文说起HJ212 在 Java 里到底要解析什么现场数采仪连上来第一包通常是##0240QN20240101120000001;ST32;CN2011;...;CPDataTime20240101120000;a34013-Rtd1.23,a34013-FlagNABCD\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 字节ABCDCRC16 结果十六进制 ASCII结束符2 字节\r\n0x0D 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、48 位按位定义是否拆分包、是否需应答等CP数据段DataTime...;a34013-Rtd1.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 # CN2011 │ └── HeartbeatHandler.java # CN9011 ├── service/ │ ├── MonitorDataService.java # 落库 幂等 │ └── DeviceRegistry.java # 设备在线状态 └── config/NettyServerConfig.java # 端口、线程数四个包对应上面四层边界很清晰。protocol包里没有任何 Netty 和 Spring 的 import这一点建议拿 CI 的 ArchUnit 卡住不然半年后一定有人往里塞Autowired。3.2 pom 依赖与 JDK 选择依赖不用多我一般就这几样Spring Boot 3.xJDK 17 起步、netty-all4.1.x、spring-boot-starter-jdbc加一个 H2 或 MySQL 驱动、spring-boot-starter-test做回归。不引 Lombok 也能写Hj212Frame就七个字段手写 getter/setter 反而看得清楚。dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency !-- 协议解析全部自己写Netty 只提供 IO 和 ByteToMessageDecoder 骨架 -- dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.100.Final/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency /dependenciesJDK 别贪新。整套代码用到的是String、byte[]、Charset这类基础 APIJDK 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 ChannelInitializerSocketChannel() { 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 QN20240101120000001;ST32;CN2011;PW123456; MNDEV0001;Flag5;CPDataTime20240101120000; a34013-Rtd1.23,a34013-FlagN; 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 而不是用 LengthFieldNetty 自带的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, ListObject 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 MapString, String parseCp(String cp) { MapString, 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(MapString, 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 又会污染统计口径让环保数据里凭空多本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。