ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Node.js 实战 HJ212-2017 协议解析:拆包、CRC 与设备对接

Node.js 实战 HJ212-2017 协议解析:拆包、CRC 与设备对接 做环保行业在线监测对接的时候HJ212-2017 协议是绕不开的一道坎。很多刚接触这个协议的人一开始的诉求都是“给我一份 Node.js 解析代码”但实际做下来你会发现真正难的不是把一帧报文拆开而是把 CRC 范围搞对、把粘包拆包处理好、把命令应答流程跑通。这篇文章就是我从零写 Node.js 版 HJ212-2017 协议解析服务的过程记录包含完整可运行的代码思路、踩过的坑和现场联调的实战经验适合需要对接烟气、废水、VOC 等在线监测设备的开发者参考。1. HJ212-2017 不是“解析一个包”而是一整套设备对话协议1.1 我们到底在跟谁通信HJ212-2017 全称是《污染物在线监控监测系统数据传输标准》在污染源在线监控项目里数采仪或者监测设备会主动连接平台然后定时上报监测数据。平台端要做的不仅是“收到一帧数据然后解析”还要维护设备会话状态、回应心跳、校准时间、下发反控指令甚至处理设备掉线后的重新登录。Node.js 做这类 TCP 长连接服务其实非常合适。协议本身是文本流一帧一帧以##开头Node.js 的net模块处理 TCP 流式数据很顺手写一个拆包器就能稳定对接几百路设备。以我实际做过的一个烟气在线监测项目为例设备侧用的是 GPRS 拨号上网每隔几十秒到几分钟不等主动通过 TCP 连接我这边部署的 Node.js 服务。设备上报的数据包括二氧化硫、氮氧化物、氧含量、烟气流速、烟气温度、湿度等参数平台根据这些数据做浓度折算、排放量计算然后入库展示。这些数据的载体就是 HJ212-2017 协议帧。1.2 帧格式一句话拆解HJ212-2017 帧结构按顺序由这几部分组成先建立一个整体印象组成长度说明包头2 字节固定为##数据段长度4 字节十六进制表示数据段字节数不足四位前补0数据段可变核心字段从QN开始到CP...结束CRC 校验4 字节对数据段做 CRC-16 校验输出 4 位十六进制回车换行2 字节\r\n即0x0D 0x0A数据段内部是分号分隔的键值对核心字段包括字段含义示例QN请求编号17 位时间戳加随机序号QN20240101120000001ST系统编码不同监控系统类型用不同值ST21CN命令编码决定这一帧干什么CN2011实时数据上报PW设备密码默认123456PW123456MN设备唯一标识MN1234567890ABCDEFCP数据区特殊的大字段用包裹CPDataTime...一帧真实报文长这样我用这个做全篇的解析样例##0136QN20240101120000001;ST21;CN2011;PW123456;MN1234567890ABCDEF;CPDataTime20240101120000;Cou0.03;SO20.07;NOx0.12;O219.8;T128.5;F12.3;V562.51234 \r\n看起来确实不复杂但细节全藏在长度计算和 CRC 校验里。2. Node.js 解析器核心缓冲、拆包、找帧2.1 为什么不能直接按行分割新手很容易想到一个方案既然协议帧以\r\n结尾那我用readline或者按换行符切分不就行了实际不行。TCP 是流式协议数据到达是分块且无序的。一次data事件里可能只有半帧数据也可能包含了两帧甚至更多帧。如果直接按换行分割半帧时你拿不到完整内容粘包时又会把两帧当成一帧。而且协议的数据段里也有可能出现类似换行的字节虽然标准字段里一般不出现但你不能赌这个。正确的姿势是维护一个全局的接收缓冲区每次收到新 chunk 就追加进去然后不停寻找##包头读到长度字段后判断缓冲区是否攒够了完整一帧如果够就切出来剩余数据继续循环处理。2.2 BufferParser 实现我写的拆包器长这样核心逻辑都在_extract里class FrameParser { constructor() { this.buffer Buffer.alloc(0); } push(chunk) { this.buffer Buffer.concat([this.buffer, chunk]); return this._extract(); } _extract() { const frames []; for (;;) { // 找包头 const headIndex this.buffer.indexOf(##); if (headIndex -1) { // 连包头都没找到只保留末尾几个字节防止无限增长 this.buffer this.buffer.subarray(-4); break; } // 包头前面有多余字节直接裁掉 if (headIndex 0) { this.buffer this.buffer.subarray(headIndex); } // 至少要有 ## 4字节长度字段 if (this.buffer.length 6) break; const dataLenHex this.buffer.toString(latin1, 2, 6); const dataLen parseInt(dataLenHex, 16); // 防御脏数据长度字段解析异常时跳过包头 if (Number.isNaN(dataLen) || dataLen 10 || dataLen 2048) { this.buffer this.buffer.subarray(2); continue; } // 完整帧长度 包头2 长度字段4 数据段 CRC4 CRLF2 const frameLen 2 4 dataLen 4 2; // 缓冲区还不够一帧等下一次 data 事件 if (this.buffer.length frameLen) break; const frame this.buffer.subarray(0, frameLen); frames.push(frame); this.buffer this.buffer.subarray(frameLen); } return frames; } }这里有几个细节我想单独说一下。第一dataLen我加了上下限判断。有些设备断电重启后会发送乱码长度字段解析出几千甚至几万的值如果不限制缓冲区会一直等一个永远等不到的“完整帧”最后内存爆掉。HJ212 一帧数据段长度不会超过几百字节我一般限制在 2048 以内你可以根据业务场景调整。第二找不到包头时我只保留最后 4 个字节。为什么是 4因为正常帧是连续到达的如果缓冲区最后几个字节恰好是下一帧包头的前几个字符比如#保留下来才能完整拼出包头。但保留太多又会积累脏数据所以 4 到 5 个字节比较合适。第三我用了subarray而不是slice来裁切 Buffer。subarray是视图操作不会复制底层内存性能更好。当然这也意味着旧 Buffer 可能无法被 GC但对于我们这种一帧几十到几百字节的小数据量场景随手用slice也完全没问题不必过度优化。2.3 拿到帧之后先别急着解析frames里返回的是完整帧 Buffer包含##包头和尾部 CRC、CRLF。下一步要做的不是立刻正则拆字段而是先把数据段取出来做 CRC 校验。校验通过这帧数据才值得往下解析校验失败大概率是粘包或者设备端数据损坏直接丢弃并记录日志。const server net.createServer((socket) { const parser new FrameParser(); socket.on(data, (data) { const frames parser.push(data); for (const frame of frames) { handleFrame(frame, socket); } }); });解析器在连接级维护每来一个 socket 连接就 new 一个 FrameParser不同设备的半包数据互相不干扰。3. 数据段解析与 CRC 校验的“坑”3.1 用正则还是用 split数据段长这样QN20240101120000001;ST21;CN2011;PW123456;MN1234567890ABCDEF;CPDataTime20240101120000;Cou0.03;SO20.07很多人第一时间会想按;split 不就行了但注意CP...内部也有分号比如 CP 内部有DataTime20240101120000;Cou0.03;SO20.07这些字段也是分号分隔的。如果直接对整个数据段按分号 splitCP 内容就会被拆得稀碎。所以我处理数据段时先按;切出最外层字段但对 CP 单独做提取function parseDataSegment(dataSeg) { const obj {}; // 先提取 CP 完整内容 const cpMatch /CP([\s\S]*?)/.exec(dataSeg); obj.CP cpMatch ? cpMatch[1] : ; // 移除 CP 部分后再按分号切外层字段 const withoutCP dataSeg.replace(/CP[\s\S]*?/, ); for (const part of withoutCP.split(;)) { if (!part) continue; const eqIndex part.indexOf(); if (eqIndex -1) continue; const key part.slice(0, eqIndex).trim(); const value part.slice(eqIndex 1).trim(); obj[key] value; } return obj; }注意正则里的[\s\S]*?用了非贪婪模式这样万一 CP 内部有文本也能正确闭合到最后一个。实际协议里 CP 内部的字符串理论上不会出现但设备厂商实现五花八门防御性写法总没错。3.2 CRC-16/MODBUS 实现CRC 校验是整个解析器里最容易出错、也最坑的一个点。标准里说的算法是 CRC-16生成多项式x16 x15 x2 1即0x8005初始值0xFFFF校验范围是从数据段第一个字符到最后一个字符输出时低字节在前。说白了这就是 CRC-16/MODBUS 算法。不过 MODBUS 习惯上把输入输出做位反转标准文档里不太讲这个细节所以网上实现版本很多有的用0x1021CRC-16/X25、有的用0x8005但高低字节顺序写反结果就是永远校验失败。我在 Node.js 里的实现// CRC-16/MODBUS 位运算实现 function crc16Modbus(buffer) { let crc 0xFFFF; for (let i 0; i buffer.length; i) { crc ^ buffer[i]; for (let j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; } // 转成协议要求的 4 位十六进制字符串低字节在前 function crcHex(buffer) { const crc crc16Modbus(buffer); const low (crc 0xFF).toString(16).padStart(2, 0); const high ((crc 8) 0xFF).toString(16).padStart(2, 0); return ${low}${high}.toUpperCase(); }为什么这样写0x8005位反转后就是0xA001所以按位运算时异或的是0xA001。这是 CRC-16/MODBUS 的标准实现也完全符合 HJ212-2017 标准里“生成多项式 0x8005初值 0xFFFF”的定义。校验过程也很简单function verifyCRC(frame) { // frame 是完整帧 Buffer // 数据段范围跳过 ## 和 4字节长度字段到 CRC 之前 const dataSeg frame.subarray(6, frame.length - 6); const crcInFrame frame.toString(latin1, frame.length - 6, frame.length - 2); const crcCalculated crcHex(dataSeg); return crcInFrame crcCalculated; }这里frame.length - 6是 CRC 起始位置因为尾部固定是 4 位 CRC 2 位 CRLF共 6 字节。3.3 校验失败时怎么排查我踩过最深的坑不是算法本身而是“算对了但拼不进去”的错乱。下面我列几条实战排查经验数据段是否包含尾部 CRLF标准里 CRC 校验范围结束于数据段最后一个字符不包含数据段和 CRC 之间的\r\n。但有些文档示例把数据段结束写错导致实现时多算两个字节校验必挂。你验证的时候拿设备厂商给的标准报文手工数一遍数据段长度确认有没有算入\r\n。CRC 十六进制字符串大小写。有设备发a3c5有设备发A3C5解析端最好统一转大写再比较别在这个细节上翻车。交叉验证。单靠一套代码算出来很难确认自己写对了。你可以用 Python 的crcmod库或者在线 CRC 计算器选 CRC-16/MODBUS 模式拿同一份数据段比对结果。Node.js 侧也可以用 npm 包crc做交叉验证不过协议栈总共就十几行代码自持完全够用。极少数设备 CRC 实现不标准。有些老国企设备端程序可能用了 CRC-16/X25 算法平台解析会报校验失败。遇到这种情况先别急着改服务器代码用抓包工具抓原始字节流确认是不是设备端的问题再跟厂商沟通处理。你在解析端强行兼容反而不利于暴露设备问题。4. 命令分派与回包组帧不能只做单向解析4.1 CN 命令编码速查HJ212 协议的命令体系核心在CN字段下面是我项目里最常用的几张CN含义方向1061设备登录设备 - 平台1062设备登出设备 - 平台1091设备时钟同步请求设备 - 平台2011实时数据上报设备 - 平台2012历史数据上报设备 - 平台2031心跳包设备 - 平台2041应答平台 - 设备2051设置参数平台 - 设备2061反控指令平台 - 设备2091时钟同步应答平台 - 设备注意2041 应答非常重要。设备发 2031 心跳平台必须回一个 2041 应答帧否则设备会认为平台不在线然后不断重连甚至重启。4.2 分派逻辑CRC 校验通过后进入命令分派。我习惯用switch按 CN 处理function handleFrame(frame, socket) { // 先校验 CRC if (!verifyCRC(frame)) { logger.warn(CRC check failed, raw frame hex:, frame.toString(hex)); return; } const dataSeg frame.subarray(6, frame.length - 6).toString(latin1); const parsed parseDataSegment(dataSeg); const qn parsed.QN; const st parsed.ST; const cn parsed.CN; const pw parsed.PW; const mn parsed.MN; const cpRaw parsed.CP; switch (cn) { case 1061: handleLogin(mn, socket); sendReply(socket, qn, 1061, mn, ExeRtn1); break; case 2031: handleHeartbeat(mn); sendReply(socket, qn, 2041, mn, ExeRtn1); break; case 2011: handleRealtimeData(mn, cpRaw); sendReply(socket, qn, 2041, mn, ExeRtn1); break; case 2012: handleHistoryData(mn, cpRaw); sendReply(socket, qn, 2041, mn, ExeRtn1); break; case 1091: handleClockSyncRequest(socket, qn, mn); break; default: logger.info(Unhandled CN${cn} from MN${mn}); } }这里有两个容易忽略的点handleLogin不只是记录一下设备上线你还要维护一个MN - socket的映射。因为 TCP 重连后 socket 对象会变旧 socket 要清理掉否则会出现设备已经断线但你还在往旧 socket 写数据的错误。handleRealtimeData里建议把MN、DataTime、解析后的污染物数据打一条结构化日志方便后期对账。4.3 组帧回包回包要严格按照 HJ212-2017 的格式组帧。我封装了一个sendFrame函数function buildFrame({ st, cn, pw, mn, cp }) { // 生成请求编号格式YYYYMMDDHHmmss 3位随机数 const now new Date(); const pad (n, len 2) String(n).padStart(len, 0); const qn ${now.getFullYear()}${pad(now.getMonth() 1)}${pad(now.getDate())} ${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())} ${pad(Math.floor(Math.random() * 1000), 3)}; const data QN${qn};ST${st};CN${cn};PW${pw};MN${mn};CP${cp}; const len Buffer.byteLength(data, latin1).toString(16).toUpperCase().padStart(4, 0); const crc crcHex(Buffer.from(data, latin1)); return Buffer.from(##${len}${data}${crc}\r\n, latin1); } function sendFrame(socket, frame) { socket.write(frame); }调用示例// 心跳应答 sendFrame(socket, { st: 21, cn: 2041, pw: 123456, mn: 1234567890ABCDEF, cp: ExeRtn1 });ExeRtn1表示执行成功。有的平台还要求带上SN字段表示命令序列号但是设备上报的心跳包里通常不带SN所以回包时也可以不追加上去。具体看对接设备厂商怎么要求联调阶段要问清楚。4.4 CP 数据区字段解析CP 内部最常见的字段是DataTime它表示监测数据的时间格式为yyyyMMddHHmmss。实时数据则会携带污染物监测因子比如DataTime20240101120000;Cou0.03;SO20.07;NOx0.12;O219.8;T128.5;F12.3;V562.5我把 CP 内容按;切分再按拆键值function parseCP(cpRaw) { const result {}; for (const item of cpRaw.split(;)) { if (!item) continue; const eqIndex item.indexOf(); if (eqIndex -1) continue; const key item.slice(0, eqIndex).trim(); const value item.slice(eqIndex 1).trim(); result[key] value; } return result; }然后根据业务字段映射入库。这里要特别提醒不同设备厂商的因子编码不统一。比如有的用SO2有的用S03还有的用中文备注字段有的字段名带前缀如01-Rtd36.8、011-Cou0.03这类前缀在协议标准里表示监测状态或通道信息但厂商之间实现并不完全一致。所以我在项目里维护了一张点位映射表收到 CP 后先做一次键名归一化再写入数据库。这张表在建项目初期一定要跟设备厂商确认清楚别等到联调现场再一个个试。5. 踩坑记录真实项目里最容易翻车的几个点5.1 长度字段和 CRC 计算范围不一致这个坑我遇到好几次。标准里数据段长度是从QN开始到 CP 最后一个结束但某些厂商设备在组帧时把长度字段也算上了QN...之外的东西或者漏掉了尾部的两个。结果就是你按frame.length - 6取数据段算 CRC 时长度字段和实际对不上而如果我按“长度字段读到的 dataLen”去切帧又会把 CRC 算错。我现在的处理方式是长度字段只用来切帧确定这一帧的边界CRC 校验范围永远按“从第 6 个字节开始到帧尾 CRC 之前”计算。这样即使设备端长度字段有小偏差只要它的 CRC 按数据段正确计算我这边一样能校验通过。如果长度字段偏差较大可能导致切帧出错那就要在日志里记录原始 hex逐帧对比才能定位。5.2 CRC 十六进制大小写听起来很蠢但真的遇到过。设备上报crc009b服务端解析后比较时没统一转大写直接判失败。生产环境里这种问题排查起来特别烦因为报文数据看着都对就是校验不过。建议在verifyCRC里比较前把两边的字符串都.toUpperCase()另外设备端返回的 CRC 值两边固定 4 位不足四位前补位补全。5.3 数据段里出现 GBK 编码HJ212 协议本身是纯 ASCII 文本格式但有些厂商会把设备名称、站点名称、甚至是某个自定义备注字段塞进 CP 里而且是 GBK 编码。Node.js 的Buffer.toString(utf8)遇到 GBK 字节序列可能产生乱码甚至导致后续解析错位。我的兜底策略是解析字段名时用latin1它保证一个字节一个字符不会篡改数据遇到需要展示的中文字段再用iconv-lite单独做 GBK 转码。实际操作中我一般优先保证核心字段QN、ST、CN、PW、MN、CP解析不错次要字段能读出来就行不要因为一个备注字段把整帧数据废掉。5.4 设备长时间不上报心跳超时检测属于运维层面的基本功但很多人上线时没做。我在内存里维护一张lastHeartbeatByMN的 Map每次收到 2031 心跳或者 2011 实时数据时更新对应MN的时间戳然后每隔一分钟扫描一次const HEARTBEAT_TIMEOUT 5 * 60 * 1000; setInterval(() { const now Date.now(); for (const [mn, lastTime] of lastHeartbeatByMN) { if (now - lastTime HEARTBEAT_TIMEOUT) { logger.warn(Device ${mn} heartbeat timeout); // 触发告警比如推送到企业微信或者短信 notifyAlarm(mn, heartbeat_timeout); // 可选的强制断开让设备自动重连 // const socket mnSocketMap.get(mn); // if (socket) socket.destroy(); } } }, 60 * 1000).unref();这个环节要设计的合理些。超时时间建议跟设备厂商确认有的设备心跳间隔 5 分钟那超时阈值设 10 分钟比较稳妥别把正常设备踢下线。5.5 多设备天然共享端口很多初做协议对接的人会误以为一台设备一个端口实际生产环境通常所有设备连接同一个 TCP 服务端口。区分设备靠MN字段以及发起连接的源 IP 和端口。不要用 socket 对象直接当设备主键设备重连后 socket 就变了正确的做法是用MN作为设备唯一标识再关联到当前活跃的 socket。6. 部署与运维从本地跑通到持续稳定运行6.1 进程守护与日志协议服务必须 7×24 小时在线直接用node server.js跑肯定不行。我用pm2守护启动配置里设置好内存限制和重启策略pm2 start server.js --name hj212-server --max-memory-restart 512M --restart-delay 3000日志方面协议帧原文一定要落盘这是排查问题的重要依据。我建议把每一帧原始 hex 按设备、按小时写进文件或者打到结构化日志系统。等真出问题时你光看解析结果完全不够必须能回溯原始报文。6.2 入库字段标准化解析后的数据要标准化入库否则每个厂商的字段都不同后面做报表和超标告警会很痛苦。我一般建一张monitor_data表核心字段如下字段类型说明mnvarchar设备编号data_timedatetime监测时间coudecimal一氧化碳浓度so2decimal二氧化硫浓度noxdecimal氮氧化物浓度o2decimal氧含量temperaturedecimal烟气温度flow_speeddecimal烟气流速flow_ratedecimal烟气排放量create_atdatetime入库时间解析前先查点位映射表把厂商因子名统一转换成标准字段。这个映射表我会做成配置文件或者数据库表避免改代码。6.3 联调才是重头戏写解析器本身几天就能完成真正耗时的是跟设备厂商联调。我的经验是上线前一定要准备一个协议模拟器自己先构造心跳、实时数据、历史数据等各种命令把服务端逻辑跑通。等厂商设备到场后先抓一帧真实报文和协议标准、服务端解析结果三方比对逐一确认长度字段、CRC 算法、时间格式、因子命名。这步如果跳过后面上线遇到千奇百怪的兼容性问题你连问题出在哪一层都分不清楚。最后再说一个我在实际项目里的体会HJ212-2017 这套协议并不难难点在于你不接触真实设备就看不到那些“协议没写但厂商都做了”的细节。把基础拆包、CRC 校验、命令应答跑通之后剩下的就是拿真实报文慢慢治。希望这份解析实践能帮你省掉几个加班的夜晚。
返回列表