
干智能抄表这行尤其是做水表、燃气表集抄项目CJ/T188协议是绕不过去的一道坎。电表那边有DL/T645户用水表和燃气表这边则主要看CJ/T188。我最早啃这个协议是在一个水务物联网项目里要同时接入三个不同厂家的水表手上只有几份写得不太完整的通信手册对着串口抓包硬试前一周几乎天天在和地址域、校验和较劲。等真正把帧结构、控制码、数据标识、BCD编码这套逻辑理清楚之后后面再换表、再对接新厂家基本就是“换点表”的事情而已。这篇文章会把协议里最核心的东西拆开讲结合两个可以直接照着做的实例给正在写解析代码或者准备去现场调试的同学一份参考。1. 做水表燃气表接入为什么绕不开CJ/T1881.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码表示而且低字节在前。这是新手最容易翻车的地方。比如某块表铭牌上的表号是12345678BCD编码后是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也就是A00x10表示厂区/类型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 164.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。计算CS0x03 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是回显的DI01表示执行成功。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 00DI 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 00DI 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 01B厂可能是10 00地址域编码规则也可能不同。所以遇到任何一台新表第一件事就是抓真实报文把协议解析代码里所有“固定偏移假设”全部去掉改成由长度和校验驱动的解析再配合点表配置来还原业务数据。把这一套框架搭好以后换厂家、换表型都只是加一份配置的事。这是我做过几个水气表项目之后最想告诉你的一句话。