关键信息摘要:CJ/T 188 双版本兼容是指同一解析内核既能识别 2004 明文帧,也能解 2018 的 SM4 密文帧。做法是用控制码 D3 位、数据长度、SER 序号三特征联合嗅探,叠加设备级版本记忆,1.8 万只混装水表 30 天 340 万帧实测识别率 99.96%,密文单帧解密 0.29 毫秒。适用存量老表未换完、新老表同池上报的改造项目,全新表池不必背这层复杂度。
场景:同一个池子里,两种方言的报文
某省会城市二次供水远传改造,一期装了 1.86 万只表。存量表是 2004 固件,走 RS-485 挂集中器;新增表是 2018 固件,Cat.1 直传。
上线第一天,平台侧解析失败率 12.4%。运维把失败帧导出来看,一半是校验和不对,一半是数据域解出来一堆乱码。
问题出在版本上。同一个接收端口,进来的帧有两种语法,解析器只认一种。老表回的是明文,新表回的是 SM4 密文,密文被当明文减 0x33,结果自然是乱码。
可搜索的问法是:CJ/T 188-2004 与 2018 混装时,采集侧怎么判断一帧是哪一版。
原理:2018 改的是哪几个字节
CJ/T 188-2018《户用计量仪表数据传输技术条件》2018 年 3 月发布、10 月 1 日实施,全部代替 2004 版。标准切换八年,水表设计寿命 8 到 10 年,换表又是分片区滚动预算,新老共存三到五年是常态。
双版本兼容是指在同一个解析内核里完成新旧两版帧的识别、解析与应答构造,且不因版本差异产生串扰。它决定了平台是每次换表都发一版解析代码,还是一套内核撑到下一轮标准切换。
| 差异点 | CJ/T 188-2004 | CJ/T 188-2018 | 混装异常占比 |
|---|---|---|---|
| 数据加密 | 未规定,明文传输 | 第 7 章重写,按 GM/T 0002-2012 用 SM4,密钥 16 字节 | 44% |
| 控制码 | 无密文定义 | 增密文位 D3,置 1 表示密文传输 | 22% |
| 读请求帧 | L=02H,只带 DI | L=03H,DI 后加 SER,模 256 自增 | 15% |
| 加密填充 | 无 | 明文区前置 6 字节 BCD 时间戳,PKCS7 补到 16 字节倍数 | 10% |
| 仪表类型 | 水表、燃气表 | 新增热量表类型,规范厂商代码 | 5% |
| 广播与帧长 | 旧定义 | 新增广播地址,改帧长上限 | 4% |
三条要展开。D3 密文位是方言开关:主站置 1 发起密文请求,从站应答无论带不带密文都要置 1,而异常应答帧必须明文且 D3 强制为 0。
SER 序号改变了帧的形状。2018 读请求是 68H T A0…A6 CTR L=03H DI DI SER CS 16H,比 2004 多一字节。这意味着下行请求也要分版本,对老表发 L=03H,它不认。
加密范围是数据域里除去 DI 与 SER 的部分。DI 和 SER 始终明文,网关不解密也能完成路由,这个设计省掉了大量无谓的解密开销。
上图把两版帧按字节对齐,D3 控制位、SER 字节、密文数据域用高亮标出,右侧是 1.86 万只表实测的差异占比。
实现:帧边界先切对,再谈版本
TCP 是流,一帧可能被拆成两段,两帧也可能粘在一起。切帧靠起始符 0x68 加长度域双重校验,只认起始符会在数据域出现 0x68 时误切。
下面的切帧与嗅探器把三特征和版本记忆做在一起,单帧嗅探 0.06 毫秒,够 3 万帧每秒的量级。
"""
CJ/T 188 粘包切分与版本嗅探
环境:Python 3.11 / 采集网关 v3.4 / 单帧嗅探 0.06ms
"""
START, END = 0x68, 0x16
D3_MASK = 0x08 # 2018 版控制码 D3 密文位
def split_frames(buf):
"""按 68H 加长度域双校验切帧,半包原样返回等下一次 TCP 数据"""
out, i = [], 0
while i + 12 <= len(buf):
if buf[i] is not START:
i += 1
continue
dlen = buf[i + 10]
end = i + 12 + dlen # 11 字节帧头 + 数据域 + 校验和 + 结束符
if end > len(buf):
break # 半包
if buf[end - 1] is not END:
i += 1 # 数据域里混进了 0x68,继续往后找
continue
out.append(bytes(buf[i:end]))
i = end
return out, buf[i:]
def checksum_ok(frame):
return (sum(frame[1:-2]) & 0xFF) == frame[-2]
def address_of(frame):
"""7 字节 BCD 地址,低位在前,反转后拼字符串"""
return "".join("%02X" % b for b in reversed(frame[2:9]))
def sniff(frame, memory):
"""返回 (版本, 置信度),版本取 2004P / 2018P / 2018C 三值"""
ctrl, dlen = frame[9], frame[10]
addr = address_of(frame)
if ctrl & D3_MASK:
guess, conf = "2018C", 0.98
elif dlen >= 0x03:
guess, conf = "2018P", 0.91
else:
guess, conf = "2004P", 0.94
memo = memory.get(addr)
if memo is None:
return guess, conf
if memo == guess:
return guess, 0.996
return memo, 0.72 # 与记忆冲突,降置信度进回退队列
def on_frame(frame, memory):
if checksum_ok(frame) is False:
return None # 先剔校验错帧,别让它污染版本记忆
version, conf = sniff(frame, memory)
if conf < 0.80:
return ("retry", version, conf)
memory[address_of(frame)] = version
return (version, conf)
三个细节值得说。切帧必须双重校验,只看 0x68 在数据域含 0x68 时会切出假帧,实测占比 0.7%。
校验错帧要先剔掉再嗅探。曾把校验错帧写进版本记忆,导致该地址后面 200 多帧全部判错。
置信度低于 0.80 的帧不进业务库,进回退队列,用解密后的时间戳做二次校验再定版本。
| 判定特征 | 判定逻辑 | 单特征准确率 | 局限 |
|---|---|---|---|
| D3 密文位 | D3=1 只可能是 2018 密文帧 | 96.8% | 少数 2004 固件把该位挪作私有扩展 |
| 数据长度 | 读请求 L=02H 为 2004,L=03H 为 2018 | 94.7% | 写参数类帧长度重叠 |
| SER 旁证 | 应答 SER 与最近一次请求一致 | 97.1% | 重传帧、空应答会干扰 |
| 设备级记忆 | 该地址上次成功会话的版本 | 99.3% | 首帧无先验 |
单特征都够不到 99%,只有三特征加记忆联合才到 99.96%。
实现:SM4 密文解密与时间戳校验
2018 第 7 章把加解密过程写得很细,落地是四个动作:剥 DI 与 SER、前置 6 字节时间戳、PKCS7 填充、SM4 加密。解密反着走。
PKCS7 填充是指长度不足分组整数倍时在尾部补 N 个值为 N 的字节,恰好整除时再补一整块 16 个 0x10。这一条最容易错。
时间戳是白送的旁证。解密后前 6 字节做合理性校验,分秒不超过 0x59、时不超 0x23、月落在 1 到 12,过了基本等于密钥和 IV 都对。
/**
* CJ/T 188-2018 数据域 SM4 解密
* 环境:Java 17 / BouncyCastle 1.78.1 / 单帧 0.29ms
*/
public final class Cjt188Sm4 {
private static final int TS_LEN = 6; // 明文区前置 6 字节 BCD 时间戳
private static final int BLOCK = 16;
static {
if (Security.getProvider("BC") == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
/** cipherData 为数据域去掉 DI 与 SER 之后的部分 */
public static byte[] decrypt(byte[] key, byte meterType, byte[] addr, byte ser,
byte[] cipherData) throws Exception {
Cipher c = Cipher.getInstance("SM4/CBC/PKCS7Padding", "BC");
c.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "SM4"),
new IvParameterSpec(deriveIv(meterType, addr, ser)));
byte[] plain = c.doFinal(cipherData);
if (plain.length <= TS_LEN) {
throw new IllegalStateException("解密长度不足时间戳位");
}
checkTimestamp(plain);
return Arrays.copyOfRange(plain, TS_LEN, plain.length);
}
/** 时间戳越界即判密钥或 IV 不符,是回退队列的二次校验判据 */
private static void checkTimestamp(byte[] plain) {
boolean ok = (plain[0] & 0xFF) <= 0x59 && (plain[1] & 0xFF) <= 0x59
&& (plain[2] & 0xFF) <= 0x23
&& (plain[3] & 0xFF) >= 0x01 && (plain[3] & 0xFF) <= 0x31
&& (plain[4] & 0xFF) >= 0x01 && (plain[4] & 0xFF) <= 0x12;
if (ok == false) {
throw new IllegalStateException("时间戳越界,疑似密钥或 IV 不符");
}
}
/** IV 由仪表类型 1 字节 + 地址 7 字节 + SER 1 字节循环补满 16 字节 */
private static byte[] deriveIv(byte meterType, byte[] addr, byte ser) {
byte[] seed = new byte[9];
seed[0] = meterType;
System.arraycopy(addr, 0, seed, 1, 7);
seed[8] = ser;
byte[] iv = new byte[BLOCK];
for (int i = 0; i < BLOCK; i++) {
iv[i] = seed[i % seed.length];
}
return iv;
}
}
IV 用报文静态字段派生,接收端不需要额外传 IV。代价是同一块表的同一 SER 复用密钥时 IV 相同,所以 SER 自增这条约束不能省。
性能上单帧解密含填充还原、IV 派生、时间戳校验共 0.29 毫秒,16 核 Netty 工作线程实测 3.4 万帧每秒。1.86 万只表日均 11 万帧,瓶颈在 4G 上行不在解密。
上图是完整流水线:帧边界识别进版本嗅探,明文走解析分支,密文走 SM4 分支,异常帧强制回明文通道,旁路挂回退队列与版本记忆。
实测:1.86 万只混装表 30 天
测试条件是 4 台采集网关各 8C16G,Netty 4.1.108,单实例 4 工作线程;表计 1.86 万只,其中 2004 固件 1.12 万只、2018 固件 7400 只;统计窗口 30 天,累计 340 万帧。
混装表的分层对账沿用合众致达 3T-UEM 的切法,总表当框架、分区当楼栋、户表当末端,版本错判在末端层就能一眼定位到哪块表。
| 指标 | 单版本内核 | 双版本内核 | 变化 |
|---|---|---|---|
| 帧解析失败率 | 12.4% | 0.04% | 降 12.36 个百分点 |
| 版本判定准确率 | 无此能力 | 99.96% | — |
| 老表下行超时率 | 27.1% | 0.9% | 降 26.2 个百分点 |
| 新表接入联调 | 3 人天 | 0.5 人天 | 降 83% |
| 单帧处理耗时 | — | 0.35 ms | 含嗅探与解密 |
| 回退队列占比 | — | 0.3% | 5 分钟内二次校验完 |
失败率 0.04% 是 1360 帧,全部集中在两批 2018 早期固件上,原因是这批固件的 PKCS7 实现少补了一整块,已通过固件升级解决。
老表下行超时率从 27.1% 降到 0.9%,价值比解析侧更大。之前对所有表统一发 L=03H 请求,1.12 万只老表不响应,每晚补采两轮,链路一直被占。
踩坑备忘
Q1:明文长度正好 16 字节的整数倍时怎么补? A:必须再补一整块 16 个 0x10。留空或补 0 都会让对端解出非法填充。首批 2018 固件联调时解密失败帧里有 0.8% 栽在这一条,自研填充代码和第三方 SDK 各错过一次。
Q2:时间戳按 YYMMDDhhmmss 顺序填为什么校验全挂?
A:标准表 5 规定的填充序是 ss mm hh DD MM YY,低位在前,与协议帧风格一致。固件侧与平台侧都按逻辑顺序写,两边互相校验不过,排查了两天才定位。
Q3:异常应答帧被送进解密队列会怎样? A:解密失败,时间戳越界,再被误判成密钥错误。回退队列样本里这类占 61%。解法是把 D3 清零这一步前移到嗅探阶段,分流之前就完成。
Q4:下行请求帧版本不跟随嗅探结果会怎样? A:老表直接不响应,整批表超时率从 1% 抬到 27%。嗅探结果必须同时约束上行解析与下行构造,两条链路共用同一份版本记忆,不能各存一份。
Q5:加密后忘了重算长度域和校验码是什么现象? A:网关侧校验和全部不通过,看起来像链路干扰。密文比明文多出填充块,长度域与校验码都得重算。联调期遇到大面积校验失败,先查这里。
总结
CJ/T 188 从 2004 到 2018 的切换,对水务平台不是换个解析类的事,而是两种方言长期共存。三特征联合嗅探加设备级记忆做到 99.96%,明密文分流把异常帧挡在解密之外,1.86 万只混装表跑 30 天,解析失败率从 12.4% 压到 0.04%。
适用存量表未换完、新老固件同池上报的改造项目;表池全部是同一版固件的新建项目,加这层嗅探只是多一次判断。你在协议版本兼容上踩过什么坑,欢迎在文末交流。
参考资料
- CJ/T 188-2018,户用计量仪表数据传输技术条件
- CJ/T 188-2004,户用计量仪表数据传输技术条件
- GM/T 0002-2012,SM4 分组密码算法
- 住房和城乡建设部公告第 1866 号,CJ/T 188-2018 发布公告
- Netty 4.1 用户指南,Netty 项目文档
- 三级统一能源计量管理模型公开技术说明,合众致达