简介:DL/T698.45规约源代码面向电力行业通信与嵌入式开发人员,是电能信息采集与管理系统主站与采集终端、电能表之间数据交换协议的具体实现,覆盖通信架构、数据链路层、应用层、接口类及其对象标识等完整层次。压缩包共288个文件,以119个C源文件与85个头文件为主体,代码结构按链路帧处理、对象访问、事件上报等模块拆分,辅以工程配置文件、构建脚本和少量说明文件,整体仅1.93MB,便于快速定位与移植。已有686人学习下载。通过这份源码,读者可以深入理解面向对象互操作性协议在点对点、多点共线及一点对多点通信场景下的实际编码方法,尤其适合用于标准对照学习、协议解析调试以及电力采集系统的二次开发,为智能电网环境下的数据集中管理提供可复用的工程基础。
1. dlt698.45规约源代码:它是谁,能帮你解决哪三类联网难题
dlt698.45规约源代码在电能计量行业里不是稀罕物,但真正能拿过来就编译、就联调的却不多。它本质上是一套将DL/T 698.45面向对象数据交换协议翻译成可执行代码的工程化实现:一边把上层业务请求编码成标准帧,一边把终端回包拆成可读的对象属性。对要写采集主站、做终端调试工具、做用能信息采集网关的开发者来说,这份资源能解决三件事:APDU的组帧与拆帧、对象模型OAD的映射、主站与终端的服务交互。它适合两类人:刚接触用电信息采集、想找个能跑的参考实现的从业者,以及手里终端型号繁杂、需要快速定义协议差异的老手。下载后第一件事不是移植,而是先确认它的编程语言和协议版本,因为698.45的地址域长度和服务枚举在不同落地实现里有差异,拿错版本会浪费一整天。
2. 面向对象的数据交换:先从帧结构和对象模型认清698.45的骨架
2.1 帧的骨架:链路层怎么把应用数据装进一条条帧
第一次接触698.45的开发者,最容易把帧结构和645那种老规约的帧混在一起理解。645按功能码组织报文,读电压是一个功能码,读电量是另一个功能码;而698.45是按对象模型组织数据,一条GET请求的目标是一个对象属性,响应也是按对象属性返回。这个差异决定了后面所有的解析代码结构,所以先把帧骨架看清楚。
不管上层怎么抽象,数据最终要通过链路层发送。常见的698.45帧长这样:起始符在前,接着是长度字段,然后才是控制域和地址域,再往后是用户数据区APDU,末尾跟校验和结束符。长度字段最要命,它告诉接收方这一帧到底有多长,也是拆粘包时判断帧边界的唯一依据。
0x68 | LEN_LO LEN_HI | CTRL | ADDR ... | APDU ... | CS | 0x16上面这个小图不是代码,是用来说明帧内字段的排列顺序。实际实现里LEN长度字段常见是两个字节,高位在哪边要对照协议原文看。我一般会先把长度解析写成独立函数,因为后面所有buffer处理都靠它判断帧边界:
def extract_frame_length(raw: bytes) -> int: if len(raw) < 4 or raw[0] != 0x68: raise ValueError("不是有效帧头") # 长度字段从第2字节开始,是否跨2字节看具体实现 if raw[1] & 0x80: return ((raw[1] & 0x7F) << 8) | raw[2] return raw[1]这段代码的精髓在最高位标志位。我遇到过很多终端实现会把长度字段的最高位当扩展标志用,如果不管这个位,遇到长帧会把长度直接算错,导致后续所有字段错位。参数说明:raw是完整接收缓冲,返回长度代表从控制域开始的数据长度,具体起始位置以源码包里的帧结构体为准。
地址域包含主站地址、终端地址和选用的地址格式,长度不是固定死的,有的实现按长帧处理,有的按短帧处理。下载到的源码里如果地址域解析逻辑写得比较细致,说明作者踩过坑;如果只有一条简单的数组拷贝,那就要当心——终端地址换成多级路由地址时,这里最容易翻车。
2.2 对象模型:OAD与属性类,规约里的“面向对象”到底指什么
698.45的核心设计是面向对象。每个被采集的数据点被定义为一个类,类下面有各种属性,定位到一个具体属性用的是OAD:对象类标识加属性标识。常见的OAD是4个字节,前两个字节标识类,后两个字节标识属性。比如“当前正向有功电能”这类电量数据、“A相电压”这类测量数据、“掉电记录”这类事件数据,都会落成一张对象清单表。
| 对象类别 | 典型内容 | 数据格式 | 说明 |
|---|---|---|---|
| 计量类 | 有功电能、无功电能、需量 | BCD、二进制 | 大多按规约里的寄存器表映射 |
| 测量类 | 电压、电流、频率 | ASCII、二进制 | 单位换算在主站侧做 |
| 事件类 | 失压、掉电、编程记录 | 带时标复杂类型 | 一般带CP56Time2a时间戳 |
| 参数类 | 表地址、通信参数 | BCD、ASCII | 读和写都走SET服务 |
这张表不是为了背,是为了让你拿到源码以后能快速找到计量类对应代码的位置。很多源码包会提供一份对象清单文档,或者在代码里维护一个类名与OAD的映射字典。没有这份映射的源码包,基本就只能帮你组帧,帮不了你理解业务数据,使用价值会打对折。新增一种表计类型时,面向对象协议只需要在对象注册表里加一行,而不是像645那样新增一个功能码分支,这也是698.45扩展性好的根本原因。
一句话记住:OAD在报文里是定位符,在代码里是索引键。解析响应数据时,如果不知道当前OAD对应的单位、精度和数据类型,解析出来的就是一堆没意义的数字。
2.3 服务原语与消息流向:GET、SET、ACTION、REPORT的作用边界
除了对象模型,698.45还定义了几类公共服务原语。GET是读取对象属性,SET是写对象属性,ACTION是执行某个动作,比如冻结、清除事件,REPORT是终端主动上报。所有业务都跑在这四类服务上,再加上一些链路服务,比如注册和心跳。
写采集主站时对GET和REPORT要格外上心:主站定时采集用的是GET,终端异常上报用的是REPORT。这两种消息在APDU里的组织结构不同,GET一般是请求-响应模式,REPORT一般是终端单方向推送。调试REPORT翻车概率高,因为终端只在事件发生或定时到达时才会推数据,你在现场等一小时都可能等不到一条,所以最好先在验证环境里制造一条事件再观察。
主站和终端建立应用层连接后,还要先完成注册,有些终端还需要定时心跳保持链路。如果终端无响应,先检查是不是注册没成功,不要一头扎进业务帧排查。服务类型靠APDU头里的标识区分,不同版本实现里这些标识的枚举值不完全一致。拿到源码后第一件事就是打开服务枚举定义,和手上终端的版本核对。看到“报文中tag不对”的报错,多半不是网络问题,是枚举没对齐。
3. 源码包落地动作:把编码解码层与对象模型拆成可改写的模块
3.1 源码包里该有的目录骨架:先分清实现代码、样例工程和文档
下载完先别急着编译,先看文件夹结构。一份合格的698.45源码包,至少该有四个区域:核心实现、样例工程、文档、测试数据。我见过很多包把实现和示例混在一起,找入口要翻半天目录,这种结构在工程化时很费劲。
├── src/ # 核心实现,编解码和对象模型 ├── sample/ # 可运行的示例工程 ├── doc/ # 协议摘要和设计说明 └── test/ # 样例报文和回归用例核心实现目录是重点,编解码器、帧处理、对象模型都在这里。样例工程的作用是告诉你“怎么调用”,不是让你直接拿去生产。文档区通常有协议摘要或二次开发说明,测试目录里放的是抓包到的真实报文和期望结果,这两个目录是验证实现的钥匙。如果整个包只有孤零零几个源文件,没有测试报文,复现时就要多花不少时间自己造帧。
常见的源码包适配平台不太一样,有的面向嵌入式环境,用C或C++写,有的面向PC主站,用Java或C#,还有的会附带Python测试脚本。常见做法是先读工程文件里的目标平台说明,而不是根据代码风格猜。选错实现工程直接编译,会看到一堆头文件缺失和莫名其妙的类型错误,这不是代码坏了,是你拿了给另一平台准备的目录。
3.2 对象抽象层怎么搭:一个示例OAD类与属性注册表
对象模型在代码里通常落成一个OAD抽象类加一张注册表。OAD负责把类标识和属性标识编码成字节,注册表负责把OAD翻译成人能读懂的字段名。下面是工程里常见的一种写法:
class OAD: def __init__(self, class_id: int, attr_id: int): self.class_id = class_id self.attr_id = attr_id def to_bytes(self) -> bytes: # 高字节在前,OAD固定4字节 return bytes([ (self.class_id >> 8) & 0xFF, self.class_id & 0xFF, (self.attr_id >> 8) & 0xFF, self.attr_id & 0xFF ])这里把OAD做成类而不是字典,是因为同属一个类下的多个属性有类似的处理逻辑,比如同属测量类的电压、电流虽然属性号不同,但解析套路相近。用类方法便于后续补属性名注册。参数说明:class_id是类标识,通常由规约给出;attr_id是属性序号;to_bytes返回的是组帧时要放进报文里的4个字节。
注册表的作用是反向解析。收到响应帧后,解析器拿到4字节OAD,需要知道它对应的是电压还是电量:
REGISTRY = { 0x0101: "当前正向有功电能", 0x0102: "当前反向有功电能", 0x0201: "A相电压", 0x0301: "掉电记录", # 具体编号以规约原文和你手里终端的对象清单为准 } def lookup(oad: OAD) -> str: key = (oad.class_id << 16) | oad.attr_id return REGISTRY.get(key, "未知对象")注意:注册表里的编号千万别照着别的项目的代码抄,不同厂商对扩展对象类的分配经常不一致。我一般先拿终端走一遍GET服务扫描,读到实际返回的对象清单后再动态写入注册表,而不是写死一版静态字典。
3.3 编解码器主干:帧解析与APDU分发怎么写
编解码器是整个源码包的大动脉。一端从网络缓冲里取出原始字节,解析成帧;另一端把业务对象编码成字节,塞进帧里发出去。解析侧的关键是先对长度字段做校验,避免把截断的半个包当完整帧处理:
def parse_frame(raw: bytes): if len(raw) < 10: return None # 半包或空包,等下一个接收事件 if raw[0] != 0x68: raise ValueError("invalid frame head") length = extract_frame_length(raw) if len(raw) < 3 + length + 2: return None # 数据还没收齐,继续等待 ctrl = raw[3] addr = raw[4:12] # 地址域宽度需按源码实际协议对齐 apdu = raw[3 + length:] return ctrl, addr, apdu这段代码体现的是缓冲安全的思路:先检查长度够不够,不够就返回None,由上层循环继续收包,而不是直接索引越界。参数说明:ctrl是控制域,addr是地址域切片,apdu是用户数据区,后续继续交给APDU分发器处理。
APDU分发器再按服务类型把报文分流到不同处理函数:
def dispatch(apdu: bytes): tag = apdu[0] & 0x1F # 掩码宽度以源码枚举定义为准 if tag == SERVICE_GET: handle_get(apdu) elif tag == SERVICE_SET: handle_set(apdu) elif tag == SERVICE_ACTION: handle_action(apdu) elif tag == SERVICE_REPORT: handle_report(apdu) else: log_unknown_apdu(tag)这里的tag取值千万不要自己脑补,拿到源码后先看枚举定义,再用测试报文反推验证。常见做法是用源码包里的样例报文跑一遍,把样例里出现的tag值记下来,对不上就查是不是版本差异。
3.4 把源码接进工程:常见调用方式与数据缓存策略
编解码器单独写得再好,接进主站工程时还是要处理半包和粘包。TCP是字节流,没有帧边界,一次recv可能收到半个帧,也可能收到三四帧连在一起。我一般会做一个接收缓存,先把数据追加进去,再循环提取完整帧处理:
buffer = b"" while True: chunk = sock.recv(1024) if not chunk: break buffer += chunk while True: frame = try_extract_frame(buffer) if frame is None: break buffer = buffer[len(frame):] process_frame(frame)这段循环的要点是先借助extract_frame_length判断帧边界,提取一帧后立刻从buffer里切掉。如果提取失败,说明数据还没凑齐,直接跳出内层循环,继续等下一段chunk。参数说明:recv缓冲区大小在低流量场景下用1024足够,高并发主站建议调到4096并配合超时机制,避免长时间占着线程。
接完以后要做一件事:把解析出的APDU按OAD维度做缓存。同一个对象属性可能被多个业务模块同时查询,不缓存的话每次都要重新解析和组帧,主站并发一高CPU占用就上去了。缓存键用OAD的四字节整数,值存最近一次解析结果和采集时间。
4. 报文调试与字节序陷阱:一条条核对字段怎么对齐
4.1 报文调试工具链:Wireshark + hex转储 + 自写checker
抓包是排查698.45问题的第一步。但Wireshark自带的解析器未必能覆盖这个协议,很多时候抓到的只是一坨hex数据。我常用的方法是用tshark把TCP负载导成hex,再交给自写的小工具逐字节核对:
tshark -r capture.pcap -T fields -e frame.number -e ip.src -e tcp.port -e data.data这条命令的输出是帧号、源IP、端口和数据负载,正好用来定位主站发出的APDU片段。说明:data.data字段只在TCP负载为非空时出现,看到空值说明那一帧是纯ACK,不用管。
拿到hex以后,我还习惯做一份8字节一行的转储,方便对照协议表格。写一个带地址偏移的打印函数放在调试工具里,比用文本编辑器硬看一行长hex高效得多:
def hex_dump(data: bytes, addr: int = 0): for offset in range(0, len(data), 8): chunk = data[offset:offset+8] hex_part = " ".join(f"{b:02X}" for b in chunk) print(f"{addr+offset:06X} {hex_part}")参数说明:addr是打印时的起始地址偏移,配合Wireshark里看到的帧偏移号使用,一秒能定位到差错的字节位置。这个函数在现场排查时几乎每天都在用。
4.2 字段对齐:从帧头到数据体的逐字节核对
报文调试里最常见的问题是“看起来对,实际全线错位”。要避免这个,就得按固定的顺序从帧头一路核对到数据体。下面这张表是我调试时固定在草稿纸上画的核对顺序:
| 核对位置 | 字段 | 常见错误 |
|---|---|---|
| 帧起始 | 起始符 | 和645的0x68混淆 |
| 长度区 | 长度字段 | 高低位反了,或漏加校验域长度 |
| 控制区 | 控制域 | 没区分短帧和长帧 |
| 地址区 | 地址域 | 把路由地址当普通终端地址 |
| 数据区 | APDU | 字节序错,BCD解析成整型 |
| 帧尾 | 校验+结束符 | 校验范围算错 |
判断数据错位有个技巧:把抓包后整理好的hex逐字节标上对应的协议字段名,先标到地址域,再标到APDU,如果APDU里的OAD正好落在你期望的位置上,说明链路层没拆错;如果OAD出现乱码,问题多半出在长度字段或地址域宽度上。
如果源码包里带了测试报文,直接把这条链路跑一遍,确认解析器输出的控制域和地址域是否和抓包数据一致。我的习惯是每改一次解析代码就在测试报文上跑一遍对立检查,防止改了一个字节影响后面十个字节。
4.3 字节序与编码:大端、小端、ASCII与BCD混用
698.45的报文里同时存在多种编码方式,这是最容易踩坑的地方。同一个时间字段,有的厂家按大端发,有的按小端发,按错的顺序解析出来的时间能差到让人看不懂。字节序调试的核心原则是先确认字段类型,再谈解析。
BCD编码是另一个重灾区。电流、电压这类数值经常按BCD存放,一个字节存两位十进制数。如果按普通整型解析,0x12会被当成十进制的18,而它实际代表的是数字12。写一个通用的BCD转换函数比在业务代码里到处手写移位可靠:
def bcd_to_int(data: bytes) -> int: result = 0 for byte in data: result = result * 100 + ((byte >> 4) * 10 + (byte & 0x0F)) return result这个函数按一个字节两位BCD数累加,适合处理两位数和四位数的电量值。参数说明:data是BCD编码的字节序列,返回的是十进制数值。注意遇到负数、小数或带缩放因子的对象时,这个函数不直接适用,需要先按对象注册表里的精度信息做换算。
字节序和编码问题在联调阶段会反复出现。我的经验是:先把所有字段列一张类型清单,标清楚大端还是小端、BCD还是整型、ASCII还是十六进制,然后再写解析代码。清单比解析器本身更有价值。
4.4 时间戳与时区:抄表数据里最容易错的字段
事件记录、冻结数据、上报报文里都带时间戳。698.45里常见的时间类型是CP56Time2a,很多厂家和主站实现都沿用了这种编码方式。一个典型的CP56Time2a含毫秒、分钟、小时、日、月、年等信息,各字段按不同位宽打包在7个字节里:
def parse_cp56time2a(data: bytes): ms = data[0] | (data[1] << 8) minute = data[2] & 0x3F hour = data[3] & 0x1F day = data[4] & 0x1F month = data[5] & 0x0F year = 2000 + (data[6] & 0x7F) return year, month, day, hour, minute, ms // 1000这段函数里每个字段都带掩码,原因是这些字节的高位可能保留给星期、夏令时等标志位,直接读整字节会把高位杂讯当成有效数据。milliseconds低字节在前,所以要按小端拼起来再除以1000得到秒。参数说明:data指向从报文里截取的7字节时间戳字段;返回值是常规的UTC时间各分量,具体时区转换由调用方决定。
现场最容易翻车的是时区。终端时间通常是设备本地时间,主站数据库如果统一存UTC,入库前必须做一次转换,否则事件记录会比实际时间差八小时。我习惯在解析器里只提取“原始时间分量”,不做任何时区偏移,把所有时区逻辑集中到数据入库层,这样即使终端时钟配置五花八门,修正时也只有一个入口。
5. 避坑记录:698.45工程化里最常见的六个翻车点(现象→原因→解决)
5.1 帧被拒收,终端没有任何响应
现象:主站工具连上终端端口后,TCP握手正常,但发送业务帧后终端完全不理,Wireshark里看不到任何应用层回包。
原因:忽略了会话管理。698.45在实际工程里经常要求在业务报文前先完成注册或连接管理,某些终端还会校验地址域和主站地址,配置不一致直接不处理。还有一种情况是没发心跳,终端认为会话已过期。
解决:先检查地址域里的终端地址是否和现场配置一致;然后在业务帧前补一条注册请求,注册成功后再发GET或SET。如果终端侧重启后连接就断,要加一个自动重注册逻辑,在主站侧维护会话状态,别把请求硬塞给已失效的会话。
5.2 解析结果差一个字节,数据全部错位
现象:终端返回的响应能解出OAD,但后面的数据字段整体向左或向右偏移一字节,读出的电压值变成几百伏甚至为负。
原因:长度字段的统计范围搞错了。有的实现长度从地址域开始算,有的从用户数据区开始算,解析器按错误口径做裁剪,会把校验域当作数据带进来,也会把数据域末尾截掉。
解决:重新翻帧结构表,确认长度字段的计数起点。修复方式是在解析器里单独记录帧头部宽度,按“头部固定字段长度 + 长度字段值”切割,别直接用固定偏移。做完改动后用样例报文重新跑一遍全链路核对。
5.3 对象ID对不上,OAD映射表失效
现象:用源码包里自带的注册表解析现场数据,返回的OAD查不到对应对象名,或者电压和电流的值互相置换。
原因:不同终端厂商对扩展类对象的分配不一致,主站侧对象表用的还是旧版本标准,终端已经升级到新版本对象清单。
解决:不要在代码里写死一份静态OAD字典。接入新设备时先走GET服务把终端支持的对象清单读出来,动态更新注册表。维护成“终端型号→对象映射”的多级结构,同一型号缓存一份,避免每次连接都重新扫描。
5.4 源码里混着多套语言,编译不通过
现象:下载的包里有多个工程目录,有的是C++写的服务端库,有的是Python写的调试脚本,导入到编译环境后头文件缺失、类型定义找不到,编译直接中断。
原因:源码包面向多平台提供了多套实现,而自己拿错了目录。很多包根目录下follow的readme文件读一半就开始编译,自然容易踩坑。
解决:先看目录结构和工程文件,区分核心实现、样例工程和测试脚本。确认目标平台是嵌入式Linux还是Windows上位机,再选择对应编译入口。如果核心库和测试脚本是不同语言,别试图把两套代码编进同一个工程,用进程间通信或命令行调用让它们协作。
5.5 抓包里混进旧规约帧,误判协议版本
现象:用普通网络工具抓主站与终端之间的流量,同一端口上既有698.45报文也有645报文,解析器把所有帧都当698.45处理,校验全部失败。
原因:现场系统经常同时兼容多种规约,协议版本由端口或应用层特征区分。只按端口过滤会把所有数据都塞进同一个解析器。
解决:在解析入口加协议版本识别,先看帧起始符后面的控制域和特征字段,识别不了就回落到其他解析器,不要直接报错终止。主站侧最好按端口或会话把协议类型固定下来,让每个通道只处理一种帧格式。
5.6 响应时间戳错乱,现场数据无法入库
现象:事件记录时间和主站时间差八小时,或者分钟字段出现60,小时字段出现30这样明显非法的值。
原因:时间戳字段的位宽掩码没处理好,把保留位当成了有效数据;同时缺少统一时区转换,本地时间被当成UTC直接入库。
解决:在时间戳解析函数里按掩码提取各分量,校验分钟不能超过59、小时不能超过23,发现非法值先记录原始hex再告警。时区转换统一放在入库层,不要在解析器里做多次偏移。
6. 验收到位才算复现:三个微习惯帮你确认手里的源码是能用的
6.1 准备一套基线报文做回归测试
源码包里的样例报文不是白给的。我的习惯是进入工程的第一步,就把样例报文整理成独立测试用例,每条用例包含原始hex帧和期望解析结果。后续任何改动都先跑一遍回归,确保没把原本正常的功能改坏。
| 用例 | 报文类型 | 期望输出 |
|---|---|---|
| case01 | GET请求 | OAD正确、服务类型正确 |
| case02 | GET响应 | 解析出对象值且精度正确 |
| case03 | REPORT事件 | 时间戳解析合法 |
| case04 | SET确认 | 状态码非0 |
把这四类用例放进pytest或自写的脚本里,每次代码变更后执行一遍。现场联调时,这套用例就是你的后悔药,改坏了哪一步能立刻定位。
6.2 用抓包器对照校验你自己的实现
当源码包解析结果和现场报文对不上时,别急着改代码。先抓包,用hex_dump把帧按8字节一行打印出来,对照协议表格逐字段核对。重点看长度字段、地址域宽度和OAD三个位置。解析器输出与现场数据不一致,九成是这三处之一出了问题。
一份好的hex_dump输出能直接贴在缺陷记录里,比干巴巴的文字描述高效得多。把字段错位的位置用偏移号标出来,修改时直接锁定对应代码段,不必整份文件翻来翻去。
6.3 调试开关与现场介入手段
最后留一个调试开关。源码里的打印逻辑不要全删,也不要全留。用一个环境变量控制各级日志输出,平时关闭,现场出问题时打开就能看到每一帧的解析过程:
import os DEBUG = os.getenv("DEBUG_FRAME") == "1" def log(*args): if DEBUG: print("[frame]", *args)现场排查时,在解析器入口打一行地址域和APDU头,就能判断是链路层问题还是应用层问题。打开开关看到的是完整链路,关闭时生产环境零干扰。接入新终端时强制打开一次,确认首帧解析正常再关掉。
我在一个模拟项目X里就吃过亏:拿到的代码能解析样例,但一接现场终端数据全乱,查了一整天发现是地址域宽度写死成6字节,可那台终端用的是10字节地址。从那以后,我每次接入新终端都强制先跑一遍基线回归,再谈协议扩展。希望帮到你。
本文还有配套的精品资源,点击获取