ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Java解析HJ212协议:帧结构、CRC校验与Netty粘包处理实战

Java解析HJ212协议:帧结构、CRC校验与Netty粘包处理实战 简介面向环保监测系统开发者与Java工程师这份资源提供了一套可直接运行的hj212协议解析工程解决污染源在线监控数据传输标准落地时的编码、解码与数据映射难题。压缩包共308个文件以197个class和100个java源文件为主体覆盖协议解析核心逻辑其余XML配置、prefs项目文件、license及properties等辅助文件用于支持Eclipse导入与运行整体仅327KB轻量清晰。目前已有5073人学习下载。工程内置T212Mapper、SegmentParser、DataConverter等关键类支持CpData字段级映射、分帧解析与多类型数据转换可结合Socket或HTTP通信直接调用也可按标准自行扩展。对需要快速掌握hj212协议并复用解析能力的项目而言这套源码是可直接落地的实用工具。1. Java 解析国标 HJ212 协议先看懂帧再谈对接接环保数采仪项目的 Java 工程师第一周基本都被 HJ212 协议按在地上摩擦。数采仪通过 TCP 一包一包往服务端灌数据帧全是以##开头的文本看起来像能直接 split真拆起来却不是那么回事。有的设备 CRC 怎么算都对不上有的中文变乱码有的长报文被拆成好几包发过来。这些坑不是算法难而是协议边界没吃透。HJ212 是环保污染物在线监测数采仪和平台之间的主流国标协议对 Java 工程师来说最紧要的是把帧结构、命令字、CRC 校验和数据段解析这四件事弄清楚。这篇按我实际拆项目的顺序写新手能照着敲代码熟手可以直接跳到第五章看踩坑记录。2. 帧结构与命令字从 ST 开头到 CRC 结尾逐字节拆给你看2.1 帧头、长度与设备地址一帧完整报文怎么切HJ212 不是二进制协议它是一条 ASCII 文本协议。一帧完整的报文长这样##0167ST22,QN20240607103000123,CN2011,CPDataTime20240607103000;w01001-Rtd7.02,w01001-FlagN;a21001-Rtd23.5,a21001-FlagN,MNABCDEF1234567890,SB1,CRC1234##开头的##是帧起始标志固定两个#。紧随其后的 4 个 ASCII 数字是数据段长度这里的0167表示从ST开始到CRC最后一位校验值结束一共 167 个字符。注意这个长度不包含开头的##也不包含长度字段自身那 4 位。写切帧代码的时候正确做法是读到##再读 4 字节长度等缓冲区攒够 length 个字节后再处理这一帧而不是拿##当分隔符去切。数据段内部是一个一个用逗号分隔的键值项。前三项基本固定ST是系统编码固定为 22QN是这条请求或响应的唯一编号格式是YYYYMMDDHHMMSS加 3 位毫秒序号CN是命令字。中间CP...是核心数据段用两个包裹里面才是污染物浓度、状态、数据时间这些业务字段。MN是设备唯一标识14 位字母数字落库时拿它当设备主键不会错。SB是系统编号2017 版协议里算可选项遇到没有 SB 的帧属于正常别直接按固定位置取。结尾CRC后跟 4 位十六进制校验值大写。有的资料里示例报文写CRC1234那只是演示用的占位真实值必须由程序算出来再填回去。我测试时经常看到有人把示例帧里的长度和 CRC 当固定值写进代码换个设备立刻解析失败这种属于对帧格式的基本误解。2.2 命令字 CN2011 是上报1011 是拉取1021 是下发CN 决定这条帧是干什么的。我用得最多的是这一张表CN方向含义2011数采仪 → 平台实时/定时数据上报2012数采仪 → 平台历史数据回报1011平台 → 数采仪请求实时数据1012平台 → 数采仪请求历史数据1061数采仪 → 平台注册 / 心跳1021平台 → 数采仪设置参数 / 下发控制2021数采仪 → 平台参数设置确认注意不同厂家的数采仪会对命令字做扩展校时、子站升级这类命令未必完全按表走。所以解析校验阶段不要把 CN 当死枚举用框架里留一个命令映射表报文来了先查 CN 再决定走哪条处理链路。我一般在校验阶段只保证 CN 是 4 位数字取值是否合法放到业务层再判断这样接新设备时不用改解码器。实时上报 2011 帧里CP 内部主要带DataTime和一组污染物数据。污染物 key 的命名规则是一个字母表示介质a代表烟气/废气w代表废水加 5 位污染物代码再减号加字段后缀。w01001-Rtd是废水 pH 值的实时读数a21001-Rtd是烟气二氧化硫的实时读数-Rtd是实时值-Flag是数据标记位。Flag 的值常见 N正常、F异常或超标、D停运、C校准校准中解析时遇到FlagX这种未定义值应当把这条数据组标记为脏数据而不是直接忽略。2.3 CP 内部的两层嵌套先按分号切组再按逗号拆键值CP 内部的拆分顺序是反直觉的先按分号;切成若干数据组不能先按逗号切。因为逗号是组内键值对的分隔符先按逗号切会把DataTime和第一个污染物的值拆成碎片。真实报文里一个组往往是w01001-Rtd7.02,w01001-FlagN这种两个字段对齐出现一个数值一个状态标。完整解析顺序是四层外层数据段按逗号切出 ST、QN、CN、CP、MN、CRCCP 内部按分号切组每组按逗号切字段每个字段再按等号切键和值。四层都是纯字符处理不需要上正则匹配包。Java 侧用String.split就能完成但等一下——第四层外层数据段里CP 的取值本身包含逗号和分号如果先无脑按逗号切CP...会被切成多段。所以正确做法是先定位CP和收尾的把 CP 内容单独抽出来再把剩余部分交给外层键值解析。拆QN20240607103000123时我一般不做强校验只取前 14 位当业务时间后 3 位毫秒序号不落库因为页面展示和过期判断只需要到秒。有些设备会在MN后面带上V版本字段这种可选字段不要在解析时做位置假设全部走键名匹配最稳解析完统一放进一个 Map 再取。3. Java 解码落地把 HJ212 帧从文本变成业务对象3.1 建模帧对象、数据组、字段区分开放先声明两个实体Hj212Frame对应完整外层帧PollutantGroup对应 CP 内的一组污染物数据。Java 侧我不建议用一个巨大的 Map 存全部字段后续做类型转换、写数据库和打印日志时全 String Map 会散一地排查问题很痛苦。public class Hj212Frame { private String st; // 系统编码一般固定 22 private String qn; // 请求编号 private String cn; // 命令字 private String cpRaw; // CP 内部的原始内容 private String mn; // 设备唯一标识 private String sb; // 系统编号可空 private String crc; // 帧内携带的 CRC 值 } public class PollutantGroup { private String code; // 污染物编码例如 w01001 private BigDecimal rtd; // 实时值 private String flag; // N/F/D/C }字段数量按业务需要取舍真实项目里还有-Avg平均值、-Min最小值、-Zs折算值这一类后缀实体按设备手册加字段别贪多。实时值用BigDecimal而不是Double因为浓度值在数据库里对应DECIMAL类型用Double转过去容易出现精度误差后面做报表 SUM、AVG 时更麻烦。选型理由很简单一条 2011 帧里可能带几组到几十组污染物数据每组有值有状态不用ListPollutantGroup存组、组内再拆键值对后面按时间轴绘制曲线、按站点聚合统计都难做。3.2 完整解析路径先取 CP 内容再拆外层字段下面是一段不依赖 Spring 的纯 Java 解析方法可以直接放进 JUnit 里跑。输入参数bodyWithoutHead是去掉##和 4 位长度之后的数据段也就是从ST开始到 CRC 值结束的内容。public Hj212Frame parseFrame(String bodyWithoutHead) { int cpStart bodyWithoutHead.indexOf(CP); int cpEnd bodyWithoutHead.indexOf(, cpStart 5); if (cpStart 0 || cpEnd 0) { throw new IllegalArgumentException(缺少 CP... 数据段); } // 先抽出 CP 内部内容避免内部逗号干扰外层拆分 String cpRaw bodyWithoutHead.substring(cpStart 5, cpEnd); // 头尾分开解析CP 之前只有 ST/QN/CNCP 之后只有 MN/SB/CRC String headPart bodyWithoutHead.substring(0, cpStart); String tailPart bodyWithoutHead.substring(cpEnd 2); MapString, String head parseKeyValuePairs(headPart); MapString, String tail parseKeyValuePairs(tailPart); Hj212Frame frame new Hj212Frame(); frame.setSt(head.get(ST)); frame.setQn(head.get(QN)); frame.setCn(head.get(CN)); frame.setMn(tail.get(MN)); frame.setSb(tail.get(SB)); frame.setCrc(tail.get(CRC)); frame.setCpRaw(cpRaw); return frame; }这段代码的关键点有两处。第一indexOf(CP)定位 CP 起点然后indexOf(, cpStart 5)找 CP 收尾这样可以避免 CP 内部如果出现异常字符导致截取错位。第二头部和尾部单独解析是因为 CP 抽出去之后剩下的字段值里不再包含逗号parseKeyValuePairs按逗号切就安全了。parseKeyValuePairs的实现private MapString, String parseKeyValuePairs(String segment) { MapString, String map new LinkedHashMap(); if (segment null || segment.isBlank()) { return map; } for (String pair : segment.split(,)) { if (pair.isBlank()) { continue; } int eq pair.indexOf(); if (eq 0 || eq pair.length() - 1) { continue; } String key pair.substring(0, eq).trim(); String value pair.substring(eq 1).trim(); if (!key.isEmpty()) { map.put(key, value); } } return map; }这里参数说明eq 0过滤掉没有键的片段eq pair.length() - 1过滤掉有键没值的脏字段。.trim()很重要某些数采仪在逗号后边会带空格不 trim 会导致MN取出来的值带前导空格落库后关联不上设备。CP 内部的数据组解析public ListMapString, String parseCpGroups(String cpRaw) { ListMapString, String groups new ArrayList(); for (String groupRaw : cpRaw.split(;)) { if (groupRaw.isBlank()) { continue; } MapString, String group new LinkedHashMap(); for (String pair : groupRaw.split(,)) { int eq pair.indexOf(); if (eq 0) { group.put(pair.substring(0, eq).trim(), pair.substring(eq 1).trim()); } } groups.add(group); } return groups; }这个方法的输入就是上一段解析出的cpRaw。它返回的是ListMapString, String每个 Map 里至少有一个Rtd和一个Flag第一个 Map 通常还包含DataTime。实际项目中拿到ListMap之后再转成PollutantGroup转换逻辑里做new BigDecimal(value)和非法值判断比直接在解析层到处写 try-catch 干净。3.3 CRC 校验与 GBK 字符集解析前先过这两关CRC 校验是所有 HJ212 解析器里最玄学的部分。我用的实现是反射式 CRC16多项式0xA001初始值0xFFFFpublic static String crc16Hex(String content) { int crc 0xFFFF; byte[] bytes content.getBytes(StandardCharsets.ISO_8859_1); for (byte b : bytes) { crc ^ (b 0xFF); for (int i 0; i 8; i) { if ((crc 0x0001) ! 0) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return String.format(%04X, crc); }参数说明校验范围是从ST开始到CRC之前的所有字符。也就是说调用方式大概是crc16Hex(body.substring(0, body.indexOf(CRC)))然后和帧内CRC后边的 4 位十六进制比较。字节编码这里必须用ISO_8859_1不要用 UTF-8。因为帧文本在网络层已经按 GBK 解码成 Java String再用 UTF-8 转字节会改变中文字符的字节序列算出来的 CRC 必然对不上。这里有一个更隐蔽的坑标准里 CRC 覆盖范围到CRC前为止但部分设备把CRC这两个等于号本身也算进去了。遇到校验对不上的时候先试两种范围再试 CRC 高低字节是否互换多数情况是字节序问题少数情况是设备固件没有按标准实现。我一般会把这一段逻辑抽成独立方法让测试用例同时覆盖标准帧和厂家样例帧。byte[] raw new byte[byteBuf.readableBytes()]; byteBuf.readBytes(raw); String frameText new String(raw, Charset.forName(GBK));这段代码要放在网络层而不是解析层。数采仪上报的帧文本默认是 GBK 编码Netty 拿到的是ByteBuf直接按 GBK 转字符串后续 CRC 计算和中文污染物的展示才不会乱。如果设备支持配置 UTF-8就做成可配置项但默认值必须是 GBK。4. 从 TCP 到业务链路Netty 解码器、指令下发与落库4.1 手写 ByteToMessageDecoder 解决粘包和半包Netty 自带的LengthFieldBasedFrameDecoder要求数据开头就是长度而 HJ212 开头是##加 4 位长度长度字段不在数据最前面。虽然能通过调 offset 参数实现但可读性差。我一般自己写一个ByteToMessageDecoder粘包逻辑直观也好单测。public class Hj212Decoder extends ByteToMessageDecoder { Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { while (in.isReadable()) { in.markReaderIndex(); if (in.readByte() ! #) { continue; } if (!in.isReadable()) { in.resetReaderIndex(); return; } if (in.readByte() ! #) { // 只有一个 #不是有效帧头跳到下一个字节继续找 in.resetReaderIndex(); in.skipBytes(1); continue; } byte[] lenBytes new byte[4]; if (in.readableBytes() 4) { in.resetReaderIndex(); return; } in.readBytes(lenBytes); int length; try { length Integer.parseInt(new String(lenBytes, StandardCharsets.US_ASCII)); } catch (NumberFormatException e) { in.skipBytes(1); continue; } if (in.readableBytes() length) { // 半包等下一次数据到达再处理 in.resetReaderIndex(); return; } byte[] body new byte[length]; in.readBytes(body); out.add(new String(body, Charset.forName(GBK))); // 部分设备帧尾带结束 ##消费掉但不强求 if (in.readableBytes() 2 in.getByte(in.readerIndex()) # in.getByte(in.readerIndex() 1) #) { in.skipBytes(2); } } } }这段解码器的逻辑说明循环里先把 readerIndex 用markReaderIndex()存起来读到非#字符直接 continuereaderIndex 自然前进等于逐字节丢弃前缀脏数据。读到连续两个#后尝试解析 4 位长度如果可读字节不够半包就resetReaderIndex()回到帧头位置等下一包。length是数据段长度不包含##本身所以判断readableBytes() length后返回让 Netty 继续积累缓冲区。注意最后那段处理帧尾##的逻辑有的数采仪每帧结尾会再补一对##有的不补。这里用getByte先探测、不消费确认是##才跳过。不要用强制读取否则遇到不补尾帧的设备会把下一帧的第一个#吞掉。4.2 下行指令构造先把 CRC 算出来再回填长度上位机除了收数采仪的上行报文还要向下发命令比如定时请求实时数据、校时、设置采样周期。构造下行帧的逻辑跟解析相反先拼数据段算 CRC再算长度最后拼## 长度 数据段 CRC。下面是构造一个 1011 请求实时数据帧的示例public static String buildRequestRealtime(String mn, String qn, int sb) { String cpContent DataTime LocalDateTime.now() .format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)); String bodyWithoutCrc ST22,CN1011,CP cpContent ,MN mn ,SB sb ,CRC; String crc crc16Hex(bodyWithoutCrc); String body bodyWithoutCrc crc; return String.format(##%04d%s, body.length(), body); }拼接顺序说明bodyWithoutCrc必须以CRC结尾因为这个字符串是要交给crc16Hex做校验的原始输入。CRC 算出来之后补在末尾再用body.length()作为长度字段回填。%04d保证长度不足 4 位时左侧补零超过 4 位就用实际位数大多数数采仪的帧长度不会超过 9999。实际项目中上行帧里SB可能没有下行帧最好照抄设备最近一次上行帧里的 SB 值。有的设备对 SB 校验很严格填 0 会直接拒绝命令。4.3 数据落库与告警判断解析完Hj212Frame和ListPollutantGroup之后落库前要做一个比较关键的处理设备时间对齐。数采仪上报的DataTime是设备本地时间字段格式是yyyyMMddHHmmss。部署环境在东八区时可以直接转换如果服务部署在海外或容器时区是 UTC必须先转成统一时区再入库。LocalDateTime dataTime LocalDateTime.parse( groupMap.get(DataTime), DateTimeFormatter.ofPattern(yyyyMMddHHmmss));LocalDateTime.parse要求字符串严格匹配模板。如果设备把毫秒也带上了比如20240607103000123就要截取前 14 位再解析否则会抛DateTimeParseException。异常处理逻辑要落到解析失败时记一条告警日志但不要把整条帧丢弃因为污染物的 Rtd 值可能是有效的。告警判断一般放在落库之后。Flag 为 F 表示数据异常或超标可以直接触发业务告警Flag 为 D 表示设备停运不应计入有效率统计。这里最容易出问题的是把FlagD的停运数据当成 0 值入库后面做均值统计时会严重拉低数据。5. 避坑手册HJ212 解析最容易翻车的地方与排查顺序5.1 编码与校验CRC 对不上、中文变乱码翻车点一CRC 校验失败率极高。现象是日志里打印出computedCrc9C3F报文里写的是3F9C或者计算值永远跟帧内值差一截。原因是设备厂家对标准理解不统一有的把 CRC 高低字节互换输出有的把CRC这段文本也算进校验范围。解决方法是先确认你们当前设备的手册用厂家提供的样例帧做单测拿到帧先跑两种范围、两种字节序的组合一旦对上就锁定配置。翻车点二中文乱码。现象是MN和DataTime都正常但 CP 里的中文字段名或者污染物名称显示成??或者。原因是网络层用了Charset.forName(UTF-8)解码而数采仪默认输出 GBK。解决方法是网络层统一按 GBK 解码数据库连接串里也加characterEncodinggbk如果数据库表已经是 UTF-8就在转换LocalDateTime之前先把字符串按 GBK 转字节再重新解码但这只在数据源是 GBK 乱码时兜底。翻车点三CP 内容解析出key正常但value后边多一串脏字符。现象是w01001-Rtd7.02解析出来的值打印为7.02,a21001-Rtd23.5。原因是没有先按CP和边界截取直接对整个外层按逗号 split 了。解决方法是严格按 3.2 节写的顺序先抽cpRaw再解析剩余外层CP 内部的逗号只属于内部数据组。5.2 拆包与分包粘包错位、长报文丢帧翻车点四粘包时帧切错。现象是同一台设备单独发一帧能解析连续发几帧就有帧 CRC 错而且错的是上一帧的内容。原因是解码器按##找帧头但上一帧帧尾可能也有##直接按帧头定界会把上一帧尾巴当成新帧头。解决方法是必须走长度字段切帧先找到##再读 4 位长度消费完数据段之后如果能探测到帧尾##就顺手消费否则留给下一轮。同时确认长度字段的基准length指的是从ST到 CRC 结束的字符数不是整包长度。翻车点五长报文被分包后解析不出完整数据。现象是设备上报一个大浓度列表时收到的帧 CRC 都合法但 CP 里只有一半数据或者第二条分片在解析时indexOf(CP)找得到、但DataTime重复。原因是数采仪按最大包长把数据拆成多包分包标志位于外层数据段例如PNUM3,PNO1这样的字段。解决方法是按MN QN PNUM做分片缓存等PNO收齐后再合并 CP 内容。不同厂家分包字段名不完全相同有的是Sno和Snum但思路一致先攒齐再解析不要一帧一解析。翻车点六QN和DataTime时间错位。现象是上报数据入库后数据时间比当前时间慢 8 个小时或者比当前时间快 8 小时。原因是设备上报的DataTime是设备本地时间服务器容器时区是 UTC两者没有对齐。解决办法是把DataTime解析为LocalDateTime后按服务器时区转Instant再转回LocalDateTime落库保证DataTime只代表一个时间点。这里不建议直接8小时硬算因为系统切换夏令时或换设备时硬编码偏移会引入新的错位。6. 上线前验证用模拟终端和抓包回放把解析器测到能躺平6.1 模拟数采仪造帧回放解析器写完不是结束验证才是大头。我会先用一个简单的 ServerSocket 模拟数采仪往本地 9999 端口发造好的 HJ212 帧验证解码器和解析链路能完整跑通。构造模拟帧的代码在项目里长期保留每次接新设备都靠它补测试用例。ServerSocket server new ServerSocket(9999); while (true) { Socket socket server.accept(); String frame ##0167ST22,QN20240607103000123,CN2011, CPDataTime20240607103000;w01001-Rtd7.02,w01001-FlagN; a21001-Rtd23.5,a21001-FlagN,MNABCDEF1234567890,SB1,CRC crc16Hex(ST22,QN20240607103000123,CN2011, CPDataTime20240607103000;w01001-Rtd7.02,w01001-FlagN; a21001-Rtd23.5,a21001-FlagN,MNABCDEF1234567890,SB1,CRC) ##; socket.getOutputStream().write(frame.getBytes(Charset.forName(GBK))); socket.getOutputStream().flush(); socket.close(); }把这段代码跑起来之后再把实际项目里的入口端口指到 9999应该能在日志里看到完整解析出来的一条Hj212Frame落库后数据库里出现一套w01001和a21001的记录。如果解析器连模拟帧都过不了先查 CRC再查长度字段最后查是不是 GBK 解码没生效。验证的时候我一般会额外做三件事。第一把同一帧连续发 100 次确认粘包场景下每帧都能独立解析不出现 CRC 串帧第二把长度字段篡改成比实际小的值再发确认半包逻辑会等待而不是抛异常第三从真实数采仪抓一段报文回放用 Wireshark 导出 TCP 流里的原始字节替换掉模拟帧里的内容跑一遍。从那以后我每次接新的数采仪样机都强制要求厂家先给一份当天实测报文我把报文替换进单测跑一遍再上生产这套流程帮我避掉了大部分夜间告警。这套办法不一定最省事但绝对能让你在验收时少通宵几次希望帮到你。本文还有配套的精品资源点击获取
返回列表