Python实现HJ212-2017环保协议:组帧、拆帧与CRC校验完整指南
1. 项目背景与协议核心问题前阵子接了一个环保数采仪对接的活儿需求很直接“按 HJ212-2017 协议把设备实时数据送到省平台”。本来想着网上找个现成 Python 库改改就行结果翻了半天要么是某个硬件厂家封装的半吊子要么只解决了发帧没解决收帧要么压根没有 CRC 校验模块。最后决定自己用 Python 把整个协议从组帧到拆帧完整实现一遍顺便把这个过程整理成一份可以照着抄的代码解析笔记。1.1 为什么需要自己实现HJ212-2017HJ212-2017 的全称是《污染物在线监控监测系统数据传输标准》它和更早的 HJ/T 212-2005 一脉相承是国内环保在线监测设备与监控平台之间的事实通信协议。你可能在化工厂、污水处理厂、垃圾焚烧站甚至很多工业园区里看到的那些 CEMS 烟气在线监测仪、水质分析仪、VOCs 监测设备上联平台时走的基本都是这一套。这套协议的适用范围非常广废气、废水、噪声、VOCs 都能用同时它又是典型的“看起来简单、实际暗坑不少”的协议。我选择自己实现还有三个现实原因协议本质是一个小型状态机核心只有组帧、拆帧、CRC 校验、命令应答四件事自己写一遍完全可控出问题也好定位不用猜第三方库内部干了什么。我需要同时扮演设备和平台两个角色一方面要写模拟器给平台灌数据做联调另一方面要接收真实设备的数据做解析和转发。这种双向需求单一方向的现成库很难同时满足。生产环境里经常要做协议扩展比如把某个平台的 2011 数据转成另一个平台的 2012 应答或者把多台设备的帧合并上报这些逻辑只有在自己掌控协议底层时才能做得干净。所以我这篇文章的定位就很明确用 Python 3 标准库实现 HJ212-2017 的组帧、拆帧、CRC 校验和常用命令应答并给出可以直接改改参数就用起来的完整代码。适合正在做环保数采、设备上云、协议转换的开发者参考。1.2 协议数据包结构拆解先看一帧真实报文长什么样站点和密码信息已脱敏##0137QN20240101120000001;ST21;CN2011;PW123456;MN010000A8900016F000169DC0;Flag4;CPDataTime20240101120000;a21002-A11.6;a34001-A10.7\r\nD7F3\r\n这串东西看起来挺乱拆开其实很规整帧部分示例说明包头##固定两个 ASCII 字符表示一帧开始数据段长度01374 位十进制不足左边补 0统计的是数据段字节数数据段QN...;ST...;CP...以分号连接的字段串末尾带一个\r\n校验码D7F3数据段的 CRC16 计算结果4 位十六进制大写包尾\r\n固定两个 ASCII 字符这里有两个特别容易搞错的点我必须先说清楚长度字段统计的范围0137是“数据段”的字节数。数据段从QN开始到第一个\r\n结束也就是说最后的回车换行符也被算进长度里。计算 CRC 时同样是对这整段字节包含结尾\r\n做 CRC16。为什么明明看到的数据只有一百多字符数据段里的QN20240101120000001这种字段本身是一个个 ASCII 字符一个汉字都没有所以一个字节对应一个字符。你数一遍就能对上长度 0137 是从QN到\r\n的全部字符数。数据段内部的基本字段如下字段含义示例QN请求编号17 位14 位时间戳 3 位随机数20240101120000001ST系统编号21 表示废气22 表示废水21CN命令编号2011 为实时数据上传3011 为心跳2011PW接入密码平台分配123456MN设备唯一标识平台分配20 位左右010000A8900016F000169DC0Flag标志位0 为正常0CP命令参数区用包起来内部还是分号字段DataTime...常用命令编号再列一张表后面所有代码都是围绕这几个编号展开的CN方向含义1011平台→设备请求时间同步1012设备→平台时间同步应答2011设备→平台实时监测数据上传2012平台→设备数据上传应答3011设备→平台心跳3012平台→设备心跳应答理解了这些就可以开始写 Python 实现了。2. 环境准备与核心工具函数2.1 Python版本与基础依赖我用的是 Python 3.8 以上版本整个项目没有引入任何第三方依赖只用标准库里的socket、datetime、random、struct。如果你的环境连不上公网装不了 pip 包这套代码依然能跑这在很多内网部署的环保项目里非常实用。因为 HJ212-2017 从帧头到数据段全部是 ASCII 可见字符除了结尾的\r\n所以组帧时把 Python 字符串直接encode(ascii)成 bytes 即可。千万别用什么 GBK、UTF-8一旦数据段里混进非 ASCII 字符长度字段就会算错CRC 也永远对不上。这一点我在第六节踩坑清单里还会强调。2.2 CRC16算法协议正确性的基石HJ212-2017 的校验算法是 CRC-16/IBM也就是大家经常说的 CRC16-IBM 或 Modbus CRC16。多项式是0x8005初始值是0xFFFF输入输出都做反射结果异或值0x0000。在 Python 里最稳定的实现是逐位计算def crc16_ibm(data: bytes) - int: crc 0xFFFF for byte in data: crc ^ byte for _ in range(8): if crc 0x0001: crc (crc 1) ^ 0xA001 else: crc 1 return crc 0xFFFF核心逻辑解释一下外层循环把每个字节异或进 CRC 寄存器内层循环做 8 次右移如果最低位是 1就异或上0xA001。0xA001是0x8005多项式反转后的结果对应 CRC-16/IBM 的反射算法。这个循环直接和 Modbus CRC 算法一模一样所以如果之前写过 Modbus 设备驱动这里几乎是无缝切换。组帧时把 CRC 结果格式化成 4 位十六进制大写crc_str f{crc16_ibm(data_segment.encode(ascii)):04X}这里有一个字节序细节标准文档对于 CRC 校验码的字节序描述比较晦涩但实际设备对接时绝大多数平台和设备采用的都是直接把 16 位整数值按照十六进制字符串顺序发送也就是我这个04X的做法。如果你解析平台数据时发现 CRC 总不对再试试把高低字节交换一下个别老设备会按低字节在前发送。2.3 字节流和缓冲区的处理思路TCP 是流式协议没有消息边界。当你用 socket 收数据时可能一次收到半帧也可能一次收到三帧。所以我不建议写成“每次 recv 就是一帧”这种天真代码而是用一个字节缓冲区不断累积再从中提取完整帧。下文第四章的extract_frame就是干这个用的。它的核心思路是先找##包头再从包头后的 4 个字节读出长度判断缓冲区里有没有足够数据够就切完整一帧不够就继续等。这种“找头-读长-校验-切帧”的做法是所有 TCP 协议解析的通用套路。3. 报文构建从零组装一个上传帧3.1 字段生成规则QN、ST、CN、PW、MN、Flag现在写组帧代码。先做一个生成 QN 的小函数QN 是 17 位字符串前 14 位是YYYYMMDDHHMMSS格式的时间戳后 3 位是随机数。随机数的作用是区分同一秒内发出的多帧请求用于平台做去重和请求关联。from datetime import datetime import random def generate_qn(dt: datetime None) - str: dt dt or datetime.now() timestamp dt.strftime(%Y%m%d%H%M%S) random_part f{random.randint(0, 999):03d} return timestamp random_part然后是组装数据段主函数。我把公共字段固定放前面把 CP 里的业务参数单独用字典传入这样代码清晰也方便以后扩展def build_frame(st: str, cn: str, mn: str, pw: str, cp_params: dict, flag: int 0, qn: str None) - str: qn qn or generate_qn() cp_builder ;.join(f{k}{v} for k, v in cp_params.items()) cp_field fCP{cp_builder} fields [ fQN{qn}, fST{st}, fCN{cn}, fPW{pw}, fMN{mn}, fFlag{flag}, cp_field, ] data_body ;.join(fields) data_segment data_body \r\n crc crc16_ibm(data_segment.encode(ascii)) frame f##{len(data_segment):04d}{data_segment}{crc:04X}\r\n return frame注意len(data_segment)拿到的是字符串长度因为内容全 ASCII所以等于字节数。如果你在 CP 参数里不小心放了一个中文单位符号这里就会出问题后文会讲怎么规避。3.2 CP参数区的构造细节CP 是命令参数区外面用包裹内部还是字段值;字段值的结构。实时数据上传CN2011时CP 里必须包含DataTime和至少一个监测因子。监测因子代码有固定规则废气因子以a开头例如a21002表示氮氧化物a34001表示一氧化碳a01006表示烟气流量废水因子以w开头例如w01018表示 pH 值w21001表示 CODw21003表示氨氮。如果同一个排放口有多台分析仪因子代码后面要加采样点后缀比如a21002-A1表示 1 号分析仪的氮氧化物a21002-A2表示 2 号分析仪。这个后缀非常容易漏漏了平台端解析出来的数据点编号就对不上我在实际交付时吃过这个亏。假设一台废气站点要上报烟气温度、氮氧化物浓度和一氧化碳浓度cp_params { DataTime: 20240101120000, a01006-A1: 66.5, a21002-A1: 1.6, a34001-A1: 0.7, } frame build_frame( st21, cn2011, mn010000A8900016F000169DC0, pw123456, cp_paramscp_params, flag0, ) print(frame)这段代码生成的就是一帧 CN2011 的实时数据上报平台收到后会返回 CN2012 的应答帧。3.3 完整帧的发送一个最小可用的socket客户端组完帧就要发出去。HJ212-2017 规定设备主动连接平台的 TCP 端口连接建立后设备一直保持链路定时上报数据。最小发送代码如下import socket def send_frame(sock: socket.socket, frame: str): sock.sendall(frame.encode(ascii))这里一定要用sendall而不是send。TCP 下send可能只发送部分字节sendall会循环确保全部发出。然后配合 receive 循环下面这个函数可以一边发 2011 数据、一边收平台返回的 2012 应答def run_sender(host, port, st, cn, mn, pw, cp_params): with socket.create_connection((host, port), timeout10) as sock: frame build_frame(st, cn, mn, pw, cp_params) send_frame(sock, frame) data sock.recv(4096) print(收到原始字节:, data) print(解析结果:, parse_frame_bytes(data)[0] if data else None)真实生产环境里发送后不能立刻close()。平台可能还没来得及回 2012你一关连接平台端直接判定掉线。正确做法是等到收到应答或者连续超时才关闭。这也是第五节的实操案例里会提到的细节。4. 报文解析把收到的数据读懂4.1 从字节流中提取完整帧组帧做完了接下来是解析。我先把“从缓冲区提取一帧”的代码拿出来单独介绍因为这是最容易写崩的地方。def extract_frame(buffer: bytes): start buffer.find(b##) if start -1: return None, buffer if len(buffer) start 6: return None, buffer try: length int(buffer[start 2:start 6]) except ValueError: # 头部长度字段非法丢弃这个包头的两个字节继续找 return None, buffer[start 2:] total 2 4 length 4 2 if len(buffer) start total: return None, buffer[start:] frame buffer[start:start total] rest buffer[start total:] return frame, rest函数返回两个值第一个是提取出的一整帧 bytes第二个是剩余未处理数据。外部代码放在循环里不断调用它就能把粘在一起的十几帧一点点切出来。这里total的计算是##2 字节 长度字段4 字节 数据段length 字节 CRC4 字节 包尾\r\n2 字节。我见过很多人只算了前三段结果把 CRC 当成下一帧的包头来了解析全乱。4.2 解析数据段与CP区还原拿到完整帧后写parse_frame_bytes把数据段拆成 dict。实现思路定位长度字段、切片、校验 CRC、剥离末尾\r\n、按分号拆字段、每个字段按第一个分成键值。def parse_frame_bytes(frame: bytes): if not frame.startswith(b##): raise ValueError(帧头错误缺少 ##) body_len int(frame[2:6]) body frame[6:6 body_len] crc_recv frame[6 body_len:6 body_len 4] crc_calc f{crc16_ibm(body):04X}.encode(ascii) if crc_recv.upper() ! crc_calc: raise ValueError(fCRC 校验失败接收 {crc_recv.decode()}计算 {crc_calc.decode()}) body body.rstrip(b\r\n).decode(ascii) parsed {} for part in body.split(;): key, _, value part.partition() parsed[key] value # 将 CP 内部字段单独解析出来 if CP in parsed and parsed[CP].startswith() and parsed[CP].endswith(): cp_inner parsed[CP][2:-2] cp_dict {} for cp_part in cp_inner.split(;): cp_key, _, cp_value cp_part.partition() cp_dict[cp_key] cp_value parsed[CP] cp_dict return parsed这样得到的结果大概是这样{ QN: 20240101120000001, ST: 21, CN: 2011, PW: 123456, MN: 010000A8900016F000169DC0, Flag: 0, CP: { DataTime: 20240101120000, a21002-A1: 1.6, a34001-A1: 0.7, } }这个结构已经足够支撑业务逻辑把因子代码和值遍历出来拼成你数据库表的一行记录或者转成 JSON 给上层的 MQ、消息队列都行。4.3 命令分派与响应实现解析只是第一步关键是根据 CN 做出响应。一个完整的最小服务端循环应该是这样收到 CN1011 时间同步请求回 CN1012收到 CN2011 数据上传回 CN2012收到 CN3011 心跳回 CN3012。应答帧的写法有讲究应答帧的 QN 必须复用请求帧的 QN这样平台才能把应答和请求关联上。下面是 2012 应答的构造def build_response(ack_cn: str, request: dict, execute_result: int 1) - str: cp_params { QN: request.get(QN, ), ExeRtn: str(execute_result), } return build_frame( strequest.get(ST, ), cnack_cn, mnrequest.get(MN, ), pwrequest.get(PW, ), cp_paramscp_params, flag0, qnrequest.get(QN, ), )调用build_response(2012, parsed)就能生成正确的应答帧。注意ExeRtn1表示执行成功如果是 0 表示失败失败时一般还要在 CP 里带个错误描述。但标准里没有强制的错误描述字段所以通常就是 1 或 0 两个值。有了这些函数一个能同时当“设备模拟器”和“平台模拟器”的程序已经齐了下面用真实场景串一遍。5. 实操一台废气设备的接入案例5.1 实时数据上传与心跳联调现在模拟一个完整场景某废气排放口有一台 CEMS每 10 秒上报一次烟气数据同时每 30 秒发一次心跳平台地址是192.168.1.100:8000。设备信息如下参数值ST21MN010000A8900016F000169DC0PW123456因子1a21002-A1氮氧化物因子2a34001-A1一氧化碳因子3a01006-A1烟气流量我用一个线程跑上报一个线程跑心跳共用同一个 socket 连接import threading import time def report_loop(sock, st, mn, pw): while True: cp_params { DataTime: datetime.now().strftime(%Y%m%d%H%M%S), a21002-A1: f{10 random.random() * 5:.2f}, a34001-A1: f{0.5 random.random():.2f}, a01006-A1: f{200 random.random() * 100:.2f}, } frame build_frame(st, 2011, mn, pw, cp_params, flag0) send_frame(sock, frame) time.sleep(10) def heartbeat_loop(sock, st, mn, pw): while True: frame build_frame(st, 3011, mn, pw, {}, flag0) send_frame(sock, frame) time.sleep(30)实际对接中最容易忽略的一点是上报线程和心跳线程千万不要各自 create_connection。平台端对同一设备的连接是有状态管理的你每次新建连接平台会认为设备不断掉线重连日志里会刷一堆断线告警。正确做法是主进程维护一个连接两个线程共享并在断线时重连。5.2 边界情况拆包粘包与非法报文真实 TCP 环境里没有“一条消息发一次 recv”这种好事。我抓过现场的包一个 TCP 报文里经常挤着三四帧 2011或者一帧被切成了两半。所以数据接收端要用第四章的extract_frame放在 while 循环里持续处理def recv_loop(sock): buffer b while True: chunk sock.recv(4096) if not chunk: break buffer chunk while True: frame, buffer extract_frame(buffer) if frame is None: break try: parsed parse_frame_bytes(frame) handle_command(parsed) except ValueError as exc: print(非法帧被丢弃:, exc)handle_command根据CN字段分发回对应应答帧。这里我故意把extract_frame放在外层 while 里是因为一个 chunk 可能包含多个完整帧extract_frame返回None表示缓冲区不足或者暂时找不到包头就退出内层循环继续收网络数据。对于 CRC 校验失败或者长度字段不对的帧直接丢弃同时打日志记录原始 hex方便事后定位。我经常看到有人把坏帧继续拼接导致整个缓冲区污染所以记住一个原则解析出错时优先从下一个##重新找头而不是硬着头皮往下读。6. 踩坑经验与排查清单6.1 我踩过的五个坑这里把我在实际对接省平台时踩过、也帮同行排查过的坑集中写出来每一件都是血泪教训。1. CRC 计算范围漏了结尾的\r\n。这是最高频的错误没有之一。数据段以\r\n结尾CRC 计算必须包含这两个字节。否则你自己组帧自己拆没问题但平台端校验必然失败平台日志只显示“数据校验错误”你根本看不出哪里的问题。2. 长度字段把##也算进去了。##0137中的 0137 只是数据段的长度不是整帧长度。一个整帧长度应该是2 4 137 4 2 149。如果把整帧长度算进去平台解析时数据段长度比实际多 2读到 CRC 位置全错甚至会把 CRC 当成数据段内容。3. QN 随机数生成出问题。有人图省事每次给 QN 固定后三位结果同一秒内发了两帧平台把后一帧判为重复请求直接丢弃。QN 的规则是 14 位时间戳 3 位随机数你只要控制好同一秒内不超过 1000 帧随机数足够用。4. 监测因子代码漏了采样点后缀。很多新手写 CP 参数时习惯只写a210021.6但现场设备往往是双分析仪配置平台要求必须区分-A1、-A2。不带后缀平台会当成默认排放口单一数据来解析导致点位数据对不上。5. 发完帧立刻close()socket。设备端如果发送后马上关闭连接TCP 连接会进入半关闭状态平台先把 2012 应答发回来然后收到连接关闭信号直接判定设备离线。我见过一个项目因为这个原因每 10 秒一条数据平台却一直显示设备离线最后查出来是客户端写完就 close。我把这些坑整理成一张排查速查表现象可疑点排查方法平台收不到任何数据防火墙、端口、连接是否建立先用 telnet/nc 测试 TCP 连通收到数据但校验失败CRC 范围、字节序、编码独立算一遍 CRC和帧里的 4 位对比数据解析字段错位长度字段统计错误数一遍数据段的真实字符数平台提示重复请求QN 重复打印 QN检查同一秒是否重复设备频繁掉线发完就 close、心跳超时检查 socket 生命周期6.2 排查工具与自测方法最后分享一个我很推荐的自测方法写一个独立的“帧合法性检查”函数专门用来验证自己组好的帧。做协议开发时我习惯把组帧函数和解析函数分开然后用下面的逻辑做回归测试frame build_frame( st21, cn2011, mn010000A8900016F000169DC0, pw123456, cp_params{DataTime: 20240101120000, a21002-A1: 1.6}, flag0, ) parsed parse_frame_bytes(frame.encode(ascii)) assert parsed[CN] 2011 assert parsed[CP][a21002-A1] 1.6 print(自检通过)这个测试能保证你改了字段顺序、加了参数之后组帧和解析依然自洽。但要注意自洽不代表会和平台兼容所以有条件的话一定要用平台官方的测试工具或者抓包工具验证一次真实线上帧。还可以准备一个本地 mock 服务用 Python 起一个 socket server 监听端口把收到的原始字节用十六进制打印出来。很多平台对接人员手里都有这种东西它比任何调试器都好用因为你能亲眼看到线上数据长什么样而不是只看到平台界面上的“成功/失败”。我在实际工作中始终保留这个十六进制打印的 mock 服务。不管是接新设备还是换新平台先让两端对着这个 mock 服务自测一遍再切真实环境基本可以过滤掉八成低级错误。7. 写在最后的实际体会这套 HJ212-2017 的 Python 实现我前后用了三个项目从最早的单文件脚本演进到现在结构化的协议包。最大体会是别把协议代码和业务逻辑写在一起。协议层只负责把字节变成 dict、把 dict 变成字节业务层再去决定数据写库、报警、转发。这样遇到平台升级、新增命令时你只需要在协议层加一个 CN 分支业务层一行不用动。另外一个小技巧如果可能尽量给每个设备连接建立一个独立的任务用 QN 做请求去重。平台下发的应答帧和你的请求帧 QN 是关联的你在代码里维护一个最近 N 秒的 QN 集合能非常方便地判断这条应答是不是自己发出去的避免旧帧回放导致逻辑混乱。协议开发看起来低门槛但“能通”和“稳定”之间隔着大量边界场景。把 CRC 算对、长度字段算准、socket 生命周期管好这三件事做到位这套代码基本就能扛住真实环境。至少我线上跑了半年的几个站点从没因为协议解析本身出过数据丢失。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →