☰
CJ/T188协议详解:水表燃气表远程抄表帧结构、BCD编码与校验实战
2026/9/29 5:17:35 网站建设 项目流程

干智能抄表这行,尤其是做水表、燃气表集抄项目,CJ/T188协议是绕不过去的一道坎。电表那边有DL/T645,户用水表和燃气表这边则主要看CJ/T188。我最早啃这个协议是在一个水务物联网项目里,要同时接入三个不同厂家的水表,手上只有几份写得不太完整的通信手册,对着串口抓包硬试,前一周几乎天天在和地址域、校验和较劲。等真正把帧结构、控制码、数据标识、BCD编码这套逻辑理清楚之后,后面再换表、再对接新厂家,基本就是“换点表”的事情而已。这篇文章会把协议里最核心的东西拆开讲,结合两个可以直接照着做的实例,给正在写解析代码或者准备去现场调试的同学一份参考。

1. 做水表燃气表接入,为什么绕不开CJ/T188

1.1 这个协议管的是什么

CJ/T188全称叫《户用计量仪表数据传输技术条件》,行业里一般直接叫“188协议”。它解决的是集中器、采集终端、DTU或者NB-IoT模组和户用水表、燃气表、热量表之间的数据交换问题,具体定义了帧格式、控制命令、数据编码和校验方式。也就是说,表计怎么表示“我读数是多少”、主站怎么让表计“把阀门关掉”、表计上报的数据用什么顺序排列,这些都由这个协议约束。

实际项目里,无论表计走的是RS-485总线、M-Bus总线,还是NB-IoT无线网络,188协议的帧通常都会保留在业务层。很多NB-IoT水表看上去是“云端对云端”,其实模组把188帧原封不动地封装在AT指令或者平台JSON里透传上来。所以你只熟悉TCP/IP、HTTP那一套还不够,最后落到底层数据解析时,手里拿到的仍然是68 ... 16这样一串十六进制字节。

1.2 水表和燃气表为什么共用一套协议

水表、燃气表、热量表虽然计量介质不同,但抄表业务模型高度相似:都要读累计用量、瞬时流量、工作状态、表号,都要支持阀门控制、校时、参数设置。如果每家表厂、每种表型各搞一套协议,对集中器厂商和系统集成商来说就是灾难。CJ/T188把这些户用计量仪表的公共部分统一起来,同时留出厂家自定义数据区。这样一套采集器可以同时挂水表和燃气表,用地址域区分设备,用数据标识区分业务字段。

这也是我特别想强调的一点:协议是框架,厂家点表才是业务。188协议定义了报文的“集装箱”结构,但箱子里具体装什么货物(比如第几个字节代表累计水量、第几个字节代表阀门状态),往往要查对应厂家的《通信协议手册》。项目启动第一件事,不是急着写代码,而是先跟表厂要一份完整的点表。

2. 先把帧结构吃透:68起始符、BCD地址、CS校验

2.1 一条完整报文的字节布局

CJ/T188的帧结构不算复杂,主流的格式是下面这样(部分厂家版本会有细微差异,稍后单独说):

名称起始符地址域控制码数据长度数据域校验码结束符
长度1字节6字节1字节1字节L字节1字节1字节
示例值0x68A0~A5CLDATACS0x16

起始符固定是0x68,结束符固定是0x16。数据长度L表示数据域DATA的字节数,注意它不包含控制码和自身。校验码CS的计算范围是从控制码开始,到数据域最后一个字节为止,把这些字节做无符号累加,超过一字节的部分直接丢弃,只保留低8位。

举个例子,假设控制码是0x01、数据长度是0x03、数据域是00 01 00,那么CS就等于(0x01 + 0x03 + 0x00 + 0x01 + 0x00) & 0xFF = 0x05。所以一个最简单的帧尾就是05 16。这个计算规则是手写解析代码必须掌握的,我见过有人误把起始符0x68也算进去,结果CS永远对不上。

2.2 地址域:表号不是你以为的那种十六进制

地址域6个字节,在绝大多数188表计里采用BCD码表示,而且低字节在前。这是新手最容易翻车的地方。比如某块表铭牌上的表号是12345678,BCD编码后是12 34 56 78,按照低字节在前的发送顺序,字节序列就应该是78 56 34 12。如果你直接把ASCII表号强制转成十六进制,发出去12 34 56 78,表计根本不会理你。

更麻烦的是,这6个字节里经常不全是表号。不少表厂会把A0定义为厂家代码或者表类型标识,A1~A4放表号,A5做扩展或者填充。我用过的某品牌水表地址域就是10 78 56 34 12 00,其中0x10是厂区/产品分类,A1~A4是表号反序,最后一位补0。所以看到地址域后,一定要先看手册里对A0~A5的定义,不要想当然地认为6个字节全是表号。

2.3 控制码和数据长度:怎么判断是上行还是下行

控制码的最高位通常用来表示方向:最高位为0,一般是主站下发的请求;最高位为1,是从站的应答。比如主站发读数据命令,控制码常用0x01,表计正常应答则是0x81;主站写数据用0x03,表计回应则是0x83。这也是很多解析代码里用来判断“这条帧是主动上报还是应答帧”的关键位。

数据长度L是一字节,最大只能表示255。如果数据域超过255字节,协议层面要分帧传输,控制码里会带后续帧标志。实际水气表抄表业务中,常见的数据域不超过几十字节,分帧更多出现在历史数据整包读取的场景里。工程上建议先按单帧解析,遇到控制码里的后续帧标志再单独处理。

2.4 CS校验为什么用累加和而不是CRC

很多从电表645协议转过来的同学会问:为什么188协议不用CRC16,而用一个简单的8位累加和?说白了是历史原因。早期表计MCU资源有限,ROM和RAM都很紧张,CRC16对当时的嵌入式平台来说计算开销偏大。8位累加和实现极简单,代码几行就能写完,又能覆盖常见的单字节丢包、错位、翻转问题,对民用表计抄表场景来说性价比已经足够。

但在工程里不能只依赖CS。我处理过一种情况:串口线接触不良,仪表回帧中间丢了一个字节,恰好CS没对上,但帧头帧尾还在,靠CS就能判定丢帧。更隐蔽的情况是收发缓冲区被污染,某个字节被改写后CS碰巧一样,这时候就要靠结束符0x16和长度L双重校验来兜底。所以解析时建议同时检查三件事:帧尾是0x16、数据长度L能框住整个数据域、CS累加一致。

3. 命令和数据标识:读数据、写参数、校时、密钥

3.1 控制码背后的命令类型

CJ/T188和控制码相关的命令并不算多,工程中最常打交道的其实就是读数据、写数据、校时这几种。读数据就是主站向表计请求某个业务数据项,比如当前累计水量;写数据则是主站下发参数或控制指令,典型场景就是远程开关阀。表计对每一条请求都会回应答帧,应答帧的控制码会在请求基础上把方向位置1,比如读请求0x01对应应答0x81,写请求0x03对应应答0x83。

命令类型本身不难,难在各厂家对“同一件事”的定义不一样。比如校时命令,有的厂家用0x0D,有的厂家用0x05,数据域格式也完全不同。所以我不建议你在代码里写死一长串控制码分支,而是把控制码、数据标识、数据域格式做成配置项,换厂家时只改配置表。凡是手册里没有明确给出的控制码,一律以厂家实测帧为准。

3.2 数据标识DI:数据域的“业务地址”

数据标识一般用两个字节表示,行业里习惯叫DI,低字节在前发送。它的作用很像“寄存器地址”,主站告诉表计“我要读哪个数据项”。比如某厂点表里定义00 01表示当前累积水量,00 02表示当前瞬时流量,00 10表示阀门控制,那么主站发读数据命令时,数据域里就带00 01 00,其中最后一个0x00可能是补充信息或者固定填充。

要注意的是,DI的划分在不同厂家之间并不完全统一。公共区可能大同小异,但厂家私有区往往千差万别。对接过几个品牌后你会发现,点表这东西才是真正的核心生产资料,甚至比协议标准本身还重要。标准可能几年不变,但厂家的DI版本隔几个月就更新一次。所以整个解析层最好设计成“协议解析框架 + 外部点表配置”的结构,核心代码不跟着点表跑。

3.3 一次完整命令的交互时序

一次典型的188命令交互是这样的:主站根据目标表地址、控制码、DI和参数组包,发出请求帧;表计收到后先做地址匹配,地址匹配通过再做CS校验,最后执行业务并回传应答帧。如果主站发的地址和当前表地址不匹配,表计通常直接丢弃帧,什么都不回。这就是现场“发命令没反应”最常见的原因之一。

应答帧里的数据域一般会回显请求中的DI,方便主站把响应和请求对应起来。有些表计还会在数据域里附带执行结果状态,比如写数据应答时返回0x01代表成功,0x03代表失败。所以在写代码时,不要只盯着“有没有回包”,还要看应答数据域里的状态字,否则很容易把执行失败的帧当成成功处理。

4. 实例一:逐字节解析某水表的上行应答帧

4.1 场景和前置条件

假设现场装有一块某品牌水表,地址域为10 78 56 34 12 00,也就是A0=0x10表示厂区/类型,A1~A4对应表号12345678的BCD反序,A5补0。厂家点表告诉我们:DI00 01表示“当前累积水量”,数据域返回的是BCD编码的原始计量值。现在集中器下发了一条读数据命令,我们抓到表计回上来的这帧:

68 10 78 56 34 12 00 81 07 00 01 00 12 34 56 78 9D 16

4.2 逐字节拆解

先看帧头:第一个字节68是起始符,最后一个字节16是结束符,基本结构没问题。接着读地址域:10 78 56 34 12 00一共6字节,和预先配置的表地址完全一致,说明表计是在回应我们。然后是控制码81,最高位是1,代表从站应答,低字节里的功能含义对应读数据应答。

第8个字节是数据长度07,说明后面数据域有7个字节。数据域整体是00 01 00 12 34 56 78,其中前3个字节00 01 00是回显的请求标识和填充,和主站下发时保持一致;后4个字节12 34 56 78才是真正的业务数据。

最后验证CS。CS计算范围从控制码开始,一直到数据域最后一个字节:0x81 + 0x07 + 0x00 + 0x01 + 0x00 + 0x12 + 0x34 + 0x56 + 0x78 = 0x19D,取低8位就是0x9D,正好和帧里的校验字节一致。

4.3 把BCD码还原成真实水量

数据域里的12 34 56 78不是普通十六进制数,而是BCD码。解析时要把每个字节拆成两个半字节,分别还原成一个十进制数字:0x12 -> “12”,0x34 -> “34”,0x56 -> “56”,0x78 -> “78”,拼起来就是数字字符串12345678。

但“12345678”到底代表多少立方米,还取决于表计的小数位设置。有的表单位是0.001 m³,那累积水量就是12345.678 m³;有的表单位是0.0001 m³,那就是1234.5678 m³。这个小数位信息通常在表计铭牌上或者点表里会标明,不能凭感觉除10或除100,否则月底结算会对不上账。实际项目中,遇到无法确定小数位的情况,可以和表厂要一块同型号表做“放水试验”,灌入已知水量后对一下返回的BCD原始值,反向推算出倍率。

这里还有一个经验:当表计读数超过8位或者带扩展地址时,部分厂家会把数据域做得更长,或者在DI里带小数位标识。但大部分户用水表场景,4字节BCD已经够用,也就是最大能表示99999999的原始值。解析框架里建议把“BCD转字符串”和“字符串转数值”分开,避免在浮点转换时丢失精度。

5. 实例二:组包一条燃气表阀门控制命令

5.1 控制命令的数据域要求

再来看写操作的例子。某燃气表厂家点表里定义:DI00 10表示阀门控制,数据字节00代表关闭阀门,01代表开启阀门,后面再补一个字节00作为保留/填充。也就是说,写数据的数据域一共有4个字节:DI 2字节 + 控制参数1字节 + 填充1字节。

写操作的控制码用0x03,数据长度L就等于4。组包时首先确定地址域,仍沿用上一节里的6字节地址结构。这里特别提醒:控制阀门这类操作涉及安全,正式项目中不要把开关阀命令做成广播地址,一定要精确到具体表地址,避免误操作同一条总线上其他表计。

5.2 手把手组出完整帧

目标帧格式是68 + 地址域 + 控制码 + 数据长度 + 数据域 + CS + 16。地址域是10 78 56 34 12 00,控制码是0x03,数据长度是0x04,数据域是00 10 00 00。

计算CS:0x03 + 0x04 + 0x00 + 0x10 + 0x00 + 0x00 = 0x17。所以完整帧就是:

68 10 78 56 34 12 00 03 04 00 10 00 00 17 16

下发到485总线后,如果表计正常执行,会回一帧写应答,例如:

68 10 78 56 34 12 00 83 03 00 10 01 97 16

对照解析:控制码83是写应答;数据长度03说明数据域3字节;数据域00 10 01里,00 10是回显的DI,01表示执行成功。CS同样可以从控制码开始累乘验证。

5.3 命令下发之后的预期

远程开关阀和读数据不一样,它是会改变表计状态的命令,所以我建议在代码层加一个“命令确认”机制:下发后先等应答帧,收到应答后再补一条回读状态命令,确认阀门已经到位,而不是看到83应答就认为万事大吉。有些阀门执行机构卡滞或电池电压不足,表计应答成功但阀门根本没动,只有回读状态字才能发现问题。

另外,轮询系统里开关阀命令不要频繁重试。阀门动作需要时间,频繁下发可能烧毁电机驱动或者导致表计通信阻塞。我一般把开关阀命令的超时时间设置在3秒以上,重试次数不超过2次,两次重试之间至少间隔60秒。这个策略在多个项目里验证下来比较稳妥。

6. 和DL/T645协议的区别:电表645的解析套路别直接搬过来

6.1 两者帧结构的基本差异

做水气表接入的人,大概率也接触过电表DL/T645协议。188和645确实有不少相似处,都是起始符+地址域+控制码+数据长度+数据域+校验+结束符的结构,但细节差异很大,不能直接套用。

对比项DL/T645(电表)CJ/T188(水气热表)
适用计量设备电能表水表、燃气表、热量表等户用计量仪表
地址域6字节BCD,低字节在前6字节BCD,低字节在前,但A0常含厂家/类型信息
帧起始符固定0x68,部分版本地址域后还有第二个0x68主流版本只带一个0x68,但部分厂家会兼容带第二个0x68
数据域变换传输时数据域逐字节加0x33,解析时需减0x33一般没有强制加0x33处理
数据标识四级数据标识DI0~DI3,共4字节常见2字节DI,业务字段更多依赖厂家点表
关注业务字段电压、电流、功率因数、电能量累积流量、瞬时流量、阀门状态、温度、压力

最坑的是第二个0x68的兼容问题。有些集中器网关内部写死了645的解析逻辑:找到0x68后读6字节地址,再期望下一个字节是0x68,然后才读控制码。如果把这个逻辑原封不动用来解析188帧,恰好地址域后面跟的是控制码,比如68 10 78 56 34 12 00 81 ...,它就会把0x81误当成第二个起始符,整个帧解析直接错位。

6.2 业务场景差异带来的解析思路差异

电表645协议的数据标识体系非常规范,4字节DI基本能覆盖电力行业绝大多数数据项,所以解析器可以把DI表内置到程序里。但188协议覆盖水、气、热多种表计,各厂家产品差异又大,把全部DI内置到代码里并不现实。

我的做法是:底层帧解析只负责把地址、控制码、数据长度、数据域、CS完整拆出来,然后把“数据域里第几字节代表什么含义”完全交给点表配置层。换表厂的时候,只需要导入一份新的点表,解析框架一行代码都不用改。这个思路让我在后来的项目中节省了大量重复开发时间,也建议你在设计系统时尽早按这个模式来做。

7. 现场调试与代码实现里的真实坑

7.1 串口参数配置:先查波特率和校验位

第一次去现场接表,用USB转485连上表计后,串口助手收不到任何数据,或者全是乱码,十有八九是串口参数不对。CJ/T188常见波特率有1200、2400、4800、9600这几档,数据位多为8位,校验位多为偶校验,停止位1位。但不同厂家出厂默认值完全不同,我遇到过某品牌燃气表默认1200偶校验,另一品牌水表默认9600无校验。接表之前,第一件事是翻说明书确认串口参数,不要想当然。

把串口助手设置成HEX显示和HEX发送,这是基础中的基础。如果你用文本模式看十六进制帧,大概率会看到一堆乱码,然后在错误的方向上排查半天。用HEX模式才能直观看到68 ... 16的结构。

7.2 读不到地址时的排查路径

发读数据命令没有响应,按这个顺序排查:先确认串口参数,再看RS-485的A/B线有没有接反,然后看地址域编码是否正确,最后看数据标识和长度是否匹配。A/B反接时没有任何数据回包,这比地址写错还隐蔽,因为万用表量电压可能看起来正常,但通信就是建立不起来。

地址域编码错误最常见的表现是:主站发命令后,总线上一片安静。这时候可以找一个支持“读通信地址”功能的表,用广播地址发一条读地址命令,让表计主动回复自己的表号。不同厂家的读地址命令格式不同,但一旦能读到表号,就可以根据返回帧反推出地址域的准确写法,再回头修正请求帧。

7.3 解析代码里的兼容写法

我提供一个简单的Python解析函数,兼容不带第二个68和带第二个68的两种帧变体。它的作用是输入一整帧字节流,返回地址、控制码、数据域、校验结果,非常适合在联调阶段快速验帧。

def parse_cj188(buf: bytes) -> dict: if buf[0] != 0x68 or buf[-1] != 0x16: raise ValueError("帧头或帧尾错误") addr = buf[1:7] idx = 7 # 部分厂家版本在地址域后保留了第二个0x68起始符 if buf[idx] == 0x68: idx += 1 ctrl = buf[idx] length = buf[idx + 1] data = buf[idx + 2: idx + 2 + length] cs = buf[idx + 2 + length] calc = (sum(buf[idx: idx + 2 + length]) & 0xFF) return { "addr": addr.hex(), "ctrl": hex(ctrl), "length": length, "data": data.hex(), "cs": hex(cs), "calc": hex(calc), "valid": cs == calc, }

调用示例,直接用上一节水表的应答帧:

frame = bytes.fromhex("68 10 78 56 34 12 00 81 07 00 01 00 12 34 56 78 9D 16") print(parse_cj188(frame))

跑完valid会显示True,说明整帧解析正确。组包时可以用下面这个函数,控制码和数据域传进去,自动补CS和帧头帧尾:

def build_frame(addr: bytes, ctrl: int, data: bytes) -> bytes: body = bytes([ctrl, len(data)]) + data cs = sum(body) & 0xFF return b"\x68" + addr + body + bytes([cs, 0x16])
# 读当前累积水量:地址 10 78 56 34 12 00,DI 00 01 print(build_frame(bytes.fromhex("10 78 56 34 12 00"), 0x01, bytes.fromhex("00 01 00")).hex()) # 关闭阀门:地址 10 78 56 34 12 00,DI 00 10,参数 00 print(build_frame(bytes.fromhex("10 78 56 34 12 00"), 0x03, bytes.fromhex("00 10 00 00")).hex())

输出结果和前面手工组的帧一致。

7.4 半包、粘包和超时处理

用DTU或4G透传模块抄表时,还会遇到一种常见情况:一帧完整报文被拆成了几段TCP包到达服务器。如果服务器代码每收到一段就立刻按“一整帧”去解析,大概率会报CS错误或者长度越界。正确做法是维护一个接收缓冲区,每次收到数据先追加进去,然后持续尝试查找68起始符,按长度字段取够数据域,再检查帧尾0x16和CS,之后才能把这一帧从缓冲区里切出去。

超时这块也值得调一下。有的表计内部处理时间较长,尤其是写阀门命令后要等待阀门动作,应答可能超过500ms。我一般把抄表帧超时设置在1秒左右,开关阀命令超时给到3~5秒。如果一上来就把超时设成200ms,满屏都是超时日志,容易误判为通信故障。

最后分享一点个人体会。CJ/T188协议本身不复杂,真正复杂的是现场表计实现的不一致。同样是“读当前累积量”,A厂可能是00 01,B厂可能是10 00,地址域编码规则也可能不同。所以遇到任何一台新表,第一件事就是抓真实报文,把协议解析代码里所有“固定偏移假设”全部去掉,改成由长度和校验驱动的解析,再配合点表配置来还原业务数据。把这一套框架搭好,以后换厂家、换表型都只是加一份配置的事。这是我做过几个水气表项目之后最想告诉你的一句话。

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

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

立即咨询