CJ/T 188-2018密文帧解析:1.8万只混装水表版本嗅探

0 阅读11分钟

关键信息摘要: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-2004CJ/T 188-2018混装异常占比
数据加密未规定,明文传输第 7 章重写,按 GM/T 0002-2012 用 SM4,密钥 16 字节44%
控制码无密文定义增密文位 D3,置 1 表示密文传输22%
读请求帧L=02H,只带 DIL=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 为 201894.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 项目文档
  • 三级统一能源计量管理模型公开技术说明,合众致达