☰
Node.js 实战 HJ212-2017 协议解析:拆包、CRC 与设备对接
2026/9/28 11:32:51 网站建设 项目流程

做环保行业在线监测对接的时候,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设备密码,默认123456PW=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 校验失败时怎么排查

我踩过最深的坑不是算法本身,而是“算对了但拼不进去”的错乱。下面我列几条实战排查经验:

  1. 数据段是否包含尾部 CRLF?标准里 CRC 校验范围结束于数据段最后一个字符,不包含数据段和 CRC 之间的\r\n。但有些文档示例把数据段结束写错,导致实现时多算两个字节,校验必挂。你验证的时候,拿设备厂商给的标准报文,手工数一遍数据段长度,确认有没有算入\r\n。

  2. CRC 十六进制字符串大小写。有设备发a3c5,有设备发A3C5,解析端最好统一转大写再比较,别在这个细节上翻车。

  3. 交叉验证。单靠一套代码算出来很难确认自己写对了。你可以用 Python 的crcmod库或者在线 CRC 计算器,选 CRC-16/MODBUS 模式,拿同一份数据段比对结果。Node.js 侧也可以用 npm 包crc做交叉验证,不过协议栈总共就十几行代码,自持完全够用。

  4. 极少数设备 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表,核心字段如下:

字段类型说明
mnvarchar设备编号
data_timedatetime监测时间
coudecimal一氧化碳浓度
so2decimal二氧化硫浓度
noxdecimal氮氧化物浓度
o2decimal氧含量
temperaturedecimal烟气温度
flow_speeddecimal烟气流速
flow_ratedecimal烟气排放量
create_atdatetime入库时间

解析前先查点位映射表,把厂商因子名统一转换成标准字段。这个映射表我会做成配置文件或者数据库表,避免改代码。

6.3 联调才是重头戏

写解析器本身几天就能完成,真正耗时的是跟设备厂商联调。我的经验是:上线前一定要准备一个协议模拟器,自己先构造心跳、实时数据、历史数据等各种命令,把服务端逻辑跑通。等厂商设备到场后,先抓一帧真实报文,和协议标准、服务端解析结果三方比对,逐一确认长度字段、CRC 算法、时间格式、因子命名。这步如果跳过,后面上线遇到千奇百怪的兼容性问题,你连问题出在哪一层都分不清楚。

最后再说一个我在实际项目里的体会:HJ212-2017 这套协议并不难,难点在于你不接触真实设备就看不到那些“协议没写但厂商都做了”的细节。把基础拆包、CRC 校验、命令应答跑通之后,剩下的就是拿真实报文慢慢治。希望这份解析实践能帮你省掉几个加班的夜晚。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询