做环保行业在线监测对接的时候,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 位,时间戳加随机序号 | QN=20240101120000001 |
| ST | 系统编码,不同监控系统类型用不同值 | ST=21 |
| CN | 命令编码,决定这一帧干什么 | CN=2011实时数据上报 |
| PW | 设备密码,默认123456 | PW=123456 |
| MN | 设备唯一标识 | MN=1234567890ABCDEF |
| CP | 数据区,特殊的大字段,用&&包裹 | CP=&&DataTime=...&& |
一帧真实报文长这样,我用这个做全篇的解析样例:
##0136QN=20240101120000001;ST=21;CN=2011;PW=123456;MN=1234567890ABCDEF;CP=&&DataTime=20240101120000;Cou=0.03;SO2=0.07;NOx=0.12;O2=19.8;T=128.5;F=12.3;V=562.5&&1234 \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
数据段长这样:
QN=20240101120000001;ST=21;CN=2011;PW=123456;MN=1234567890ABCDEF;CP=&&DataTime=20240101120000;Cou=0.03;SO2=0.07&&很多人第一时间会想:按;split 不就行了?但注意CP=&&...&&内部也有分号,比如 CP 内部有DataTime=20240101120000;Cou=0.03;SO2=0.07,这些字段也是分号分隔的。如果直接对整个数据段按分号 split,CP 内容就会被拆得稀碎。
所以我处理数据段时,先按;切出最外层字段,但对 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 习惯上把输入输出做位反转,标准文档里不太讲这个细节,所以网上实现版本很多,有的用0x1021(CRC-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, 'ExeRtn=1'); break; case '2031': handleHeartbeat(mn); sendReply(socket, qn, '2041', mn, 'ExeRtn=1'); break; case '2011': handleRealtimeData(mn, cpRaw); sendReply(socket, qn, '2041', mn, 'ExeRtn=1'); break; case '2012': handleHistoryData(mn, cpRaw); sendReply(socket, qn, '2041', mn, 'ExeRtn=1'); 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: 'ExeRtn=1' });ExeRtn=1表示执行成功。有的平台还要求带上SN字段,表示命令序列号,但是设备上报的心跳包里通常不带SN,所以回包时也可以不追加上去。具体看对接设备厂商怎么要求,联调阶段要问清楚。
4.4 CP 数据区字段解析
CP 内部最常见的字段是DataTime,它表示监测数据的时间,格式为yyyyMMddHHmmss。实时数据则会携带污染物监测因子,比如:
DataTime=20240101120000;Cou=0.03;SO2=0.07;NOx=0.12;O2=19.8;T=128.5;F=12.3;V=562.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-Rtd=36.8、011-Cou=0.03,这类前缀在协议标准里表示监测状态或通道信息,但厂商之间实现并不完全一致。所以我在项目里维护了一张点位映射表,收到 CP 后先做一次键名归一化,再写入数据库。这张表在建项目初期一定要跟设备厂商确认清楚,别等到联调现场再一个个试。
5. 踩坑记录:真实项目里最容易翻车的几个点
5.1 长度字段和 CRC 计算范围不一致
这个坑我遇到好几次。标准里数据段长度是从QN=开始,到 CP 最后一个&结束;但某些厂商设备在组帧时把长度字段也算上了QN=...之外的东西,或者漏掉了&&尾部的两个&。
结果就是,你按frame.length - 6取数据段算 CRC 时,长度字段和实际对不上;而如果我按“长度字段读到的 dataLen”去切帧,又会把 CRC 算错。
我现在的处理方式是:长度字段只用来切帧,确定这一帧的边界;CRC 校验范围永远按“从第 6 个字节开始到帧尾 CRC 之前”计算。这样即使设备端长度字段有小偏差,只要它的 CRC 按数据段正确计算,我这边一样能校验通过。如果长度字段偏差较大可能导致切帧出错,那就要在日志里记录原始 hex,逐帧对比才能定位。
5.2 CRC 十六进制大小写
听起来很蠢,但真的遇到过。设备上报crc=009b,服务端解析后比较时没统一转大写,直接判失败。生产环境里这种问题排查起来特别烦,因为报文数据看着都对,就是校验不过。建议在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表,核心字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| mn | varchar | 设备编号 |
| data_time | datetime | 监测时间 |
| cou | decimal | 一氧化碳浓度 |
| so2 | decimal | 二氧化硫浓度 |
| nox | decimal | 氮氧化物浓度 |
| o2 | decimal | 氧含量 |
| temperature | decimal | 烟气温度 |
| flow_speed | decimal | 烟气流速 |
| flow_rate | decimal | 烟气排放量 |
| create_at | datetime | 入库时间 |
解析前先查点位映射表,把厂商因子名统一转换成标准字段。这个映射表我会做成配置文件或者数据库表,避免改代码。
6.3 联调才是重头戏
写解析器本身几天就能完成,真正耗时的是跟设备厂商联调。我的经验是:上线前一定要准备一个协议模拟器,自己先构造心跳、实时数据、历史数据等各种命令,把服务端逻辑跑通。等厂商设备到场后,先抓一帧真实报文,和协议标准、服务端解析结果三方比对,逐一确认长度字段、CRC 算法、时间格式、因子命名。这步如果跳过,后面上线遇到千奇百怪的兼容性问题,你连问题出在哪一层都分不清楚。
最后再说一个我在实际项目里的体会:HJ212-2017 这套协议并不难,难点在于你不接触真实设备就看不到那些“协议没写但厂商都做了”的细节。把基础拆包、CRC 校验、命令应答跑通之后,剩下的就是拿真实报文慢慢治。希望这份解析实践能帮你省掉几个加班的夜晚。