1. 为什么我劝你别急着拿 DL/T 645 的老经验套 698.45先讲个场景。前阵子公司接了个项目要对接某省电力公司的用电信息采集系统。对方丢过来一份报文要求我一看是 DL/T 698.45心想这不就是 645 的升级版嘛无非帧头帧尾变一变。结果抓包回来对着十六进制串蹲了半天越看越不对劲——帧头确实是 68可中间那堆字节怎么都跟 645 对不上号。后来老老实实翻协议文档才发现 698.45 和 645 之间的差别根本不是升级而是换了一套完全不同的表达方式。1.1 698.45 不是 645 的升级版是换了一套语法DL/T 645 的报文结构属于典型的位置即含义——主要看的是偏移量。哪几个字节是地址、哪个字节是控制码、数据域里每一位代表什么全靠固定偏移去卡。这种方式写起来快解起来也快但缺点很明显扩展性差加一个新功能就得重新约定数据域布局。698.45 走的是另一条路核心思路是标签即含义学名叫 ASN.1 的 BER/TLV 编码再加上国网自己定义的裁剪编码规则。报文的表达自由度大得多同一个字段可能出现在不同的位置但它一定带一个说明自己是什么的标签。这种设计让协议能承载更复杂的数据模型比如对象、属性、方法的组合调用。所以你调试 698.45 的时候最该先改掉的习惯就是在第几个字节取什么。正确姿势是顺着标签走一遍 TLV 树一层一层剥开看内容。1.2 先认清两个关键差异裁剪编码和对象模型搞懂 698.45有两个坎必须迈过去否则后面看什么都费劲。第一个是裁剪编码如果去看国网规范里面写作裁剪编码。这个名词听着玄乎其实说白了就是为了压缩字节数把一些固定前缀或多余位去掉后再传输。比如时间标签、日期时间、长度字段都有一套裁剪规则。不理解这套规则你就没法解释为什么这个长度字节读出来不是实际长度。第二个是对象模型。698.45 不再像 645 那样直接用数据标识指来指去而是把电表里的数据封装成对象。你要读一块数据得通过读取请求指定对象的具体属性比如读取某对象某属性编号的值。这个思路跟 SNMP 很像报文先建立一个请求上下文再在上下文里做 Get/Set。很多同事第一次看 698.45 报文卡就卡在找到了数据域但不知道这段是啥意思。因为数据域里不是直接给你读出来而是一层请求-响应的封装结构。所以这篇文章我会优先带你把帧结构→APDU→对象属性这条路径走通。2. 抓包别乱抓先把三层结构盘明白其实调协议最怕的不是看不懂报文而是抓了一堆报文却分不清自己看的是哪一层的封装。698.45 报文从物理口出来到应用层中间隔着好几层每一层有自己的职责调试时得先定位当前这组字节到底是哪一层的东西。2.1 从电表串口还是集中器上行口抓决定你会看到什么先说一个最重要的判断你在哪个口抓的包直接决定报文长什么样。电表侧本地口比如红外口、RS-485口抓到的通常是 698.45 在串口链路上的帧帧结构里会带链路层控制字段面向的是读取单个表计的应用。集中器上行口比如 4G/以太网口抓到的往往是集中器与主站之间的远程通信报文虽然应用层大概率还是 698.45但外层多了一层 TCP/UDP、通信协议甚至安全加密得先剥掉外层才能看到 TLV。很多第一次做集中器联调的朋友在主站侧抓了一堆 TCP 包拿 Wireshark 直接搜 68 开头的字节结果搜半天搜不到。这不是消息丢了而是你还隔着一层加密或私有封装。先确认你盯的是哪一段链路再开始找帧头。2.2 APDU 层真正承载业务逻辑的地方链路层剥掉以后核心就是 APDU应用协议数据单元。698.45 的业务全部发生在 APDU 这一层读取数据、设置参数、下发命令、上报事件都是 APDU 的范畴。APDU 的内部结构是典型的 TLV 嵌套由应用层协议控制信息A-PCI和数据域A-UserData组成。我习惯把 APDU 想象成一个快递包裹APDU 最开头是面单信息PIID、调用序号中间是包装说明书决定这个包裹是请求还是响应是携带数据还是报错最里面才是货具体的数据项一个或多个 TLV。你解析报文的顺序本质上就是撕面单→看说明书→掏货的过程。2.3 长度字段的编码规则别被数字吓到裁剪编码在长度信息上很容易误导人。常规思维是一个字节表示长度但在 698.45 里长度字段可能占一个字节也可能占两个字节甚至更多。它有一套机制高字节的最高位如果置 1说明后面还有字节继续表示长度没置 1那么这个字节就是最终长度。我踩过一次坑读某个业务数据时显示长度是0x83我直接拿0x83 131去卡后续字节怎么都卡不对。后来才反应过来0x83的最高位是 1表示真正的长度在接下来的两个字节里后面跟着0x01 0x32实际长度是0x0132 306字节。这个问题在长报文场景下特别常见解析脚本里必须要做递归式读取不能想当然认为长度只占一个字节。3. 开源工具选型Wireshark 漏掉的活儿用 Python 补齐要说解析报文大家第一反应都是 Wireshark。但 Wireshark 对 698.45 的支持目前还很有限默认没有官方解析器社区插件也极少。我在网上找过一圈要么是只支持 645 的老插件要么是某个大厂内部共享的半成品拿来用不了多久就遇到解析不了的帧。3.1 Wireshark 对 698.45 的支持现状Wireshark 内置了相当多协议解析器但你在 Decode As 里面翻大概率找不到 DL/T 698.45。如果网上搜到有人说装了某某插件就能解析也要小心——很多插件是针对某个设备厂商的私有扩展并不适用于标准 698.45。我的做法是Wireshark 负责链路层和传输层过滤真正解析 TLV 交给 Python 脚本。抓包时先把报文按 TCP/UDP 会话导出来再用脚本去逐帧解析。如果你实在想在 Wireshark 里看到字段级展示也可以自己写 Lua 解析器但成本不低性能也一般。除非你天天调这个协议否则不建议投产级使用 Lua 方案。3.2 用 Python 写一个最小可用的帧解析脚本既然 Wireshark 不给力那就自己来。我整理了一个最小的 698.45 APDU 解析脚本框架保留了最核心的 TLV 遍历逻辑你可以根据实际业务再扩展。解析的核心逻辑分三步第一步从链路层数据里找到 APDU 起点第二步遍历 TLV按标签识别字段第三步对识别出的字段做裁剪编码还原。直接上代码这是我在多个项目里反复打磨后的骨架import struct def parse_length(buf, pos): 解析 698.45 的裁剪长度编码 返回 (最终长度值, 读取长度字段消耗的字节数) first buf[pos] if first 0x80 0: return first, 1 nbytes first 0x7F if nbytes 0: # 长长度形式裁剪后续字节数由再下一字节决定这里按常见实现处理 nbytes buf[pos 1] return int.from_bytes(buf[pos2:pos2nbytes], big), nbytes 2 length_val int.from_bytes(buf[pos1:pos1nbytes], big) return length_val, nbytes 1 def parse_tlv(buf, pos, depth0): 简单 TLV 遍历器 返回 (tag, value_bytes, 下一个字段起始位置) tag buf[pos] length, len_bytes parse_length(buf, pos 1) value_start pos 1 len_bytes value_end value_start length return tag, buf[value_start:value_end], value_end def parse_apdu(data): 入参加载 APDU 原始字节 流程读取 PCI - 读取用户数据 - 遍历数据项 TLV print(fAPDU 总长: {len(data)}) if len(data) 2: return # 应用层协议控制信息里第一个字节通常是 PIID piid data[0] print(fPIID: 0x{piid:02X}) # 这里简化处理直接认为用户数据从偏移 1 开始 # 生产环境应根据具体帧类型解析 A-PCI pos 1 while pos len(data): tag, value, next_pos parse_tlv(data, pos) print(f[TLV] pos{pos:#04x} tag0x{tag:02X} len{len(value)} value_hex{value.hex()}) pos next_pos # 示例从一个读取响应帧里取出 APDU 片段来解析 if __name__ __main__: # 这串数据是示例实际请替换成你抓到的 APDU sample_bytes bytes.fromhex(020100050f0001000000000001020304) parse_apdu(sample_bytes)这个脚本不追求面面俱到但它把 698.45 最核心的 TLV 遍历闭环跑起来了。实际项目里你会在while pos len(data)这个循环里做 tag 的分支判断比如0x0f代表某个数据项类型、0x1f代表某类对象属性等具体编号得对照你手上的主站规约文档。3.3 快速验证脚本用几个典型特征字节判断帧类型拿到一段十六进制串先别急着写大而全的解析器。用特征字节先做粗判断事半功倍。以下是我经常用的快速分类逻辑如果开头是68后面第四个字节是91或90之类大概率是链路层规约帧需要继续剥。如果是 APDU则看第一个字节是不是PIID第二个字节是不是操作类型比如0x02请求、0x03响应等。数据域里如果连续出现多个0x0f标签很可能表示一组时间标签 数据项的结构。这些特征判断看起来土但在现场排查时非常管用。你可以先写一个classify_frame(hexstr)函数打个日志把帧类型、APDU 偏移、长度猜测都打出来心里有底之后再深入解析。4. 一次完整的数据帧解析演练从原始字节到业务数据光讲理论容易飘我拿一个实际抓到的帧带你从头到尾走一遍。4.1 从集中器抓到的读取请求帧场景是这样的主站下发一个读取请求集中器转发给电表。报文从 485 口抓回来后我把链路层剥掉得到一个 APDU。先看整体布局02 01 00 05 0F 00 01 00 00 00 00 00 00 01 02 03 04这个二进制串看着没规律但如果按 698.45 的逻辑拆偏移字节含义002调用序号或 PIID代表某次会话101表示后续数据为请求类型200保留或优先级标志305长度裁剪编码表示后续还有 5 个字节4-80F 00 01 00 00第一层 TLVtag0Flength00不对这里需要仔细看注意TLV 的 length 处理不能想当然。如果0F后面直接跟00表示 value 长度为 0但那显然不是我们想要的。实际上这里的0F可能只是某个数据项类型标识后面00 01 00 00又是更内层的对象属性编码。所以我解析时会打印每一级 TLV 的位置和内容而不是一眼扫过去就下结论。4.2 电表返回数据的帧结构逐字段拆解电表返回的响应帧结构会更复杂因为要带着实际数值。常见响应 APDU 会包含以下几部分PIID调用序号回显请求里的值响应类型标记成功或失败对象属性结果列表一个或多个 TLV每个 TLV 的 value 就是具体的计数值、状态字、费控信息等时间标签部分帧会带上电表时间我在项目里最常解析的是当前总电能示值这种数据。经过 TLV 一层层剥开后最后你会看到一段 4 字节或 8 字节的二进制数根据规约要求去掉小数点再乘上倍率就得到实际的 kWh 值。举一个我实际解析过的精简片段01 03 00 0A 0F 02 00 00 05 00 00 01 02 03 04 05 0601PIID03响应类型00保留0A后续数据长度裁剪编码0F 02数据项类型标签 子类型00 00 05对象属性 ID后面00 00 01 02 03 04 05 06实际数据内容对应不同数据类型可能是时间、数字、字符串等当你把这些字段打通再回头看原始报文就不会觉得是一堆乱码了。4.3 分段长帧的重组逻辑实际电表下行数据里有个很头疼的问题长帧分段。集中器一次读多个数据项返回内容超过链路层单帧能承载的长度就会拆成多帧发送。而且 698.45 的分段机制跟 TCP 分段不同它是在应用层做的每帧都带块编号需要你按编号把内容拼接起来。我当时调一个批量读取负载曲线的项目返回值拆成了三帧第一帧带总块数后面两帧是剩余数据。拼接时除了去掉中间的分段标记还要注意每帧里可能重复出现的时间标签。如果直接把时间标签也拼进去解析出来的数据会有冗余导致后续数值错位。所以解析脚本里我加了一个判断如果读取到连续数据类型的分段则缓存到块队列等到所有分块到齐后再统一做 TLV 遍历。这个逻辑听起来简单但现场经常出现帧乱序、帧丢失必须根据块序号做容错处理不能只做简单的 append。5. 调试 698.45 报文最容易踩的五个坑调 698.45 这个协议一段时间后我发现它比较坑的地方相对集中。你提前知道这些坑至少能少走一半弯路。5.1 接线序和时间标签别当成普通整数处理时间标签在 698.45 里极其常见尤其负载曲线、冻结数据、事件记录这些功能。问题在于时间标签的编码格式是裁剪的 BCD 码或压缩格式不是纯二进制整数。你如果按照int.from_bytes去读读出来的数字完全不能用。我遇到过这种情况解析出一个时间字节串0x20123010153233同事想当然地按整数转最后变成一串天文数字。实际上这串字节要按位拆开每两位代表年、月、日、时、分、秒读出来是2020-12-30 10:15:23:33部分规约还有毫秒字段。建议你在解析函数里单独封装一个parse_datetime()专门处理这种字段别混在通用 TLV 逻辑里。5.2 帧头特征字节误判68 开头的未必就是 698.45645 协议的帧头也是 68所以很多人在抓混合报文时会把 645 的帧拿 698 脚本去解解出来的东西自然驴唇不对马嘴。区分方法很简单看帧头的控制域和地址域长度。698.45 的链路层地址域长度不是固定的而且会带长度信息645 的地址域是固定的 6 字节。更稳妥的做法是在脚本里先做协议预判根据帧头之后的地址域长度特征、控制字范围自动选择 698 解析器或 645 解析器。我在实际项目里见过两台设备混接同一总线报文交替出现靠人眼去分根本分不过来。5.3 长帧分段的优先级和超时处理前面讲过分段重组但现场最大的坑是分段帧超时。协议规定接收方要在一个窗口时间内收齐所有分块超时就得丢弃。如果你的采集链路延迟大或者中间有路由转发很容易触发超时。这时候不能只靠协议栈的自动重传你得在应用层设计一个分块缓存超时回收机制否则缓存会越积越多最终把内存占满。5.4 数据项类型标签表找不对解析白搭698.45 的 TLV 标签类型有很多0x0F、0x10、0x11、0x1F等都有特定含义。但不同厂商的采集终端实现细节会有差异有的厂商会把私有字段挂在保留标签里。我在项目里的经验是解析器必须做成配置驱动比如 JSON 配置一个 tag 字典表不要写死 if-else。这样换个厂商设备改配置就行不用改代码。否则每次联调新设备都得手改代码能把自己改疯。5.5 别忽略带符号数和缩放因子电表数据里有功功率、电压、电流等测量量通常带符号和缩放因子。如果解析脚本默认按无符号整数去读负功率就会变成超大正数倍率没乘对明明 220V 电压读出来变 2200。建议对每个数据项都明确它的数据类型标签比如integer、unsigned、float、BCD等再做相应的字节转换。6. 我的团队落地这套解析链路时的几点经验最后聊点组织层面的内容。协议解析看着是技术活但其实非常依赖调试环境和工具链的完整程度。6.1 抓包硬件怎么摆如果你的目标是解析电表侧业务数据最好在电表的 485 口和无源转化器之间串一个离线录波设备或者用双股网线引出并接一个 OK 线的 USB 转 485 工具同时采集上行和下行报文。如果条件允许优先用带时间戳的工业串口服务器这样能精确对齐主站下发和电表回应的时序排查超时类问题会轻松很多。6.2 给新手的上手顺序建议如果你是第一次接触 698.45我建议按这个顺序走先找一份标准里的对象定义表和数据类型表搞清楚常见对象、属性编号在报文中大概长什么样。拿调试工具抓一段最简单的读取电压/电流/功率报文用上面的 Python 脚本跑一遍 TLV 遍历把层次结构梳理清楚。再看读取冻结数据或读取事件这种更复杂的报文观察时间标签在哪个层级出现。最后才去碰分段长帧和异常分支比如响应错误码。新手最容易犯的错是上来就搞大而全的解析器结果连基本帧头偏移都搞错。从简到繁把每一步输出打印清楚后续扩展自然水到渠成。6.3 后续可以扩展的方向这套解析链路稳定之后还可以往两个方向扩展一是把 TLV 解析结果结构化输出成 JSON 或 CSV方便直接灌入数据库或可视化面板二是做离线 PCAP 批量回放把历史抓包数据统一解析用于回归测试和问题追溯。我目前就是把解析核心封装成独立模块对外提供parse_frame(frame_bytes)接口上层不管是 CLI、Web API 还是 MQTT 上报都能复用同一套解析逻辑。调试 698.45 报文这件事本质上是个耐心活加细心活。工具和脚本只能帮你把字节翻译成人话真正判断业务逻辑对不对还得靠你对协议模型的掌握。希望这篇文章能帮你在面对一坨十六进制报文时不再觉得无从下手。如果你在解析过程中也遇到什么奇怪的现象欢迎分享出来咱们一起看看究竟是协议的理解问题还是设备的实现问题。