首页 / 资讯中心 / 文章详情

DLMS/COSEM协议三层机制:OBIS寻址与HDLC帧校验

DLMS/COSEM协议三层机制:OBIS寻址与HDLC帧校验 ★ FEATURED ARTICLE
简介面向智能电表、水表嵌入式开发者的 DLMS/COSEM 协议实现代码包适合需要在 C 语言环境下完成抄表通信、协议对接与认证测试的工程师参考学习。压缩包共 2 个文件由 1 个 .c 源文件与 1 个 .h 头文件构成整体约 41KB体量轻便可直接并入现有嵌入式工程编译验证。代码遵循 DLMS 协会蓝皮书、绿皮书与黄皮书的技术约定贴合最新 CTT 测试规范已通过 CCT 软件测试并取得 DLMS 证书同时具备 MID 与 KEMA 认证背景在欧盟及东南亚多国的电表、水表项目中批量应用。已有 1107 人学习下载读者可从中了解协议栈的分层组织方式、对象标识与报文封装思路以及应对一致性测试与认证要求的实现细节适合用作协议移植、问题排查与合规自测的参考底本。1. 一条读表指令要穿过三层协议才摸到一个寄存器把一条“读当前有功电能”的指令丢进串口只看线上字节会看到一帧以 0x7E 开头、以 0x7E 结尾、中间夹着两段十六位校验码的数据。它看起来像一个自造的私有协议但实际上是 DLMS/COSEM 协议栈的最外层封装。这份协议库真正值得珍藏的地方不是它实现了多少个功能码而是它把一次读操作拆成了三层彼此独立的机制对象怎么寻址、帧怎么在线路上传递、数据怎么按类型编码。这三层各自有各自的失败模式寻址错了服务端返回的是“拒绝”而不是错误码帧边界错了整帧会被静默丢弃编码错了你会拿到一个语法正确但语义错位的值。分开理解比背下来一整张报文格式表管用得多。2. OBIS 与接口类六个数字是怎么定位到一个对象的在 DLMS 里寻址不靠内存地址也不靠寄存器编号而是靠一段形如1.0.1.8.0.255的六段式字符串。第一次看到它的人往往以为这是个字符串键实际上它是六个字节每个字节都有独立语义。更关键的是它并不唯一——同一个 OBIS 可以挂在不同接口类的对象上能做哪些操作由接口类决定。2.1 OBIS 六段各自描述什么OBIS 的六段记作 A-B-C-D-E-F每一段描述的不是层级而是不同维度的属性。把它们理解成“六张表里的外键”会更准确A 说这是什么介质C 说测的是哪个物理量D 说这个量经过了哪种统计处理F 说这是当前值还是历史存档。段描述维度典型取值A能量/介质类型0 抽象、1 电、2 燃气、3 水B通道号0 主通道多回路时递增C物理量1 有功电能、2 有功功率、4 电流、5 电压、96 设备内部量D处理方式8 总和、1~4 费率、6 最大值E费率号0 不分时1~n 对应各费率F存储/采集序号0 当前值1~n 历史存档255 逻辑名尾所以1.0.1.8.0.255读出来是电A1、主通道B0、有功电能C1、总和D8、不分时E0、当前逻辑设备的主值F255。把它换成1.0.1.8.1.255读到的是费率 1 的分项电能换成1.0.1.8.0.1读到的就是上一次结算周期存下来的历史值。这套编码的好处是同一个六元组在任何厂商、任何国家的设备上含义都一致客户端不需要预先知道服务端内部是怎么排布数据的。2.2 class_id 与版本号OBIS 之外的第二个坐标OBIS 只回答了“是什么量”没有回答“以什么方式提供”。同样的1.0.1.8.0.255如果挂在类的 3 上它就是一个寄存器只有 value 和 scaler_unit 两个属性能读不能触发动作如果挂在类 4 上它变成扩展寄存器多了 capture_time 和 status如果挂在类 5 上它就是需量寄存器能读到当前需量和上一次结算需量。类决定了这个对象身上有哪些属性、哪些方法。因此一个 COSEM 对象由四元组唯一确定逻辑设备地址、OBIS、class_id、版本号。前面三个好理解版本号常常被忽略。版本号的存在是为了让同一个类能平滑演进类 3 的 version 0 只定义了两个属性version 1 可能追加一个 status服务端把 version 声明在关联对象里客户端据此决定要不要读新增的属性。如果客户端不看版本号硬读就会拿到一个object-undefined或者read-write-denied的结果。常见的实现思路是给每个类写一张属性表用(class_id, version, attribute_index)三元组查表而不是在代码里写一堆if class_id 3# 每个接口类的属性元数据按 version 分组 CLASS_META { 3: { # Register 0: {1: (logical_name, octet-string), 2: (value, long-unsigned), 3: (scaler_unit, structure)}, }, 8: { # Clock 0: {1: (logical_name, octet-string), 2: (time, date-time), 3: (time_zone, long), 4: (status, unsigned)}, }, } def attribute_type(class_id: int, version: int, index: int) - str: # 查不到就说明客户端和服务端的版本协商没对齐 try: return CLASS_META[class_id][version][index][1] except KeyError: raise LookupError(fclass {class_id} v{version} 没有属性 {index})这段查表的实际作用是把“协议知识”从控制流里挪出来。表里没写到的组合要么是规范里还没定义要么是服务端声明的版本比客户端支持的版本更新两种情况下都应该在协商阶段就暴露而不是等读到一半才发现字段对不上。版本号看起来是小事但它直接决定了客户端的属性表该按哪一套来查。2.3 LN 与 SN同一个对象的两种引用方式DLMS 支持两套引用方式。LNLogical Name引用直接用 OBIS 六字节作为对象的身份寻址一个对象需要 8 个字节SNShort Name引用则把类编号和 OBIS 折叠成一个或两个字节的短名寻址只需要 2 个字节1 字节类 1 字节短名或者更长。折叠规则由配套规范按接口类分别给出不同的类折叠方式不一样。SN 的意义是省带宽。在低速串口、NB-IoT 这类链路上每帧少 6 个字节是有实际价值的。代价是短名和 OBIS 之间需要一份映射表而这份表由服务端自己维护客户端必须通过类 17SAP Assignment把整张表读下来才能用。这就带来一个常见问题如果客户端的映射表和服务端不同步比如服务端固件升级后新增了对象读到的东西就会错位。LN 的优势是自描述客户端不需要任何预置映射就能发起请求。所以现在大多数实现默认走 LN 引用SN 只在老设备或极窄带宽场景下用。判断当前用的哪种引用方式看关联请求里的应用上下文标识0x60 0x85 0x74 0x05 0x08 0x01 0x01表示 LN 无加密0x60 0x85 0x74 0x05 0x08 0x01 0x03表示 LN 带加密SN 引用对应最后一位换掉。一个对象引用在代码里通常长这样from dataclasses import dataclass dataclass(frozenTrue) class CosemObjectRef: class_id: int version: int obis: bytes # 必须是 6 字节A-B-C-D-E-F def __post_init__(self): if len(self.obis) ! 6: raise ValueError(OBIS 必须是 6 个字节) def attribute_descriptor(self, index: int) - bytes: # 属性描述符 class_id(2, 大端) OBIS(6) 属性序号(1) return self.class_id.to_bytes(2, big) self.obis bytes([index]) def method_descriptor(self, index: int) - bytes: # 方法描述符结构一样只是第三段的语义变成方法序号 return self.class_id.to_bytes(2, big) self.obis bytes([index]) # 读 1.0.1.8.0.255 的 value类 3 的第 2 个属性 ref CosemObjectRef(class_id3, version0, obisbytes([1, 0, 1, 8, 0, 255])) print(ref.attribute_descriptor(2).hex()) # 000301080 0ff02attribute_descriptor的返回值就是后面 APDU 里那段对象描述符的原文长度固定 9 字节class 2 OBIS 6 index 1。把它做成方法而不是拼装函数好处是同一个对象引用既能生成读请求的描述符也能生成写请求、动作请求的描述符语义一致不会出现“读的时候写 2、写的时候写成 1”这种字节位置错位。3. HDLC 帧与 Wrapper 封装链路层的边界、校验与转义应用层字节再多再复杂落到串口上也只是一串连续的 0 和 1。链路层要解决的是接收方怎么知道一帧从哪开始、到哪结束怎么判断中间没有被打断或篡改。DLMS 的答案是用 0x7E 做帧界、用两段 CRC 做校验、用转义机制保证帧界字节不会在数据区里意外出现。3.1 帧结构逐字段拆解从 0x7E 到 FCS一帧完整的 HDLC 帧按下表排布总长度 7固定头部 地址长度 × 2 控制 1 HCS 2 LLC 3 APDU 长度 FCS 2 首尾 2 个帧界。偏移字段长度说明0Flag1固定 0x7E帧起始1Frame Format2低 3 位是分段信息其余位编码帧长度域3Destination Address1~4最低位是地址存在位最高位是方向位—Source Address1~4结构同上方向位取反—Control1I 帧 / UI 帧 / RR / RNR / SNRM / UA / DISC—HCS2从 Frame Format 到 Control 的校验—LLC3固定0xE6 0xE6 0x00—APDU可变应用层原样字节—FCS2从 Frame Format 到 APDU 末尾的校验—Flag1固定 0x7E帧结束控制域的取值决定了这一帧的语义。SNRM0x93是建立连接UA0x73是确认DISC0x53是断开RR0x01和 RNR0x11是接收就绪与接收未就绪。I 帧的最低位固定为 0接收序号藏在 bit5~bit7 里。很多实现把控制域当常量处理只要不是这几种就丢帧——这在大多数场景下没问题但遇到对端发送 RNR 要求暂停时不做流控就会一直重发。LLC 三个字节是固定值作用是给上层一个“这是 DLMS 应用数据”的标记。它存在的原因是 HDLC 本身是通用链路层同一根线上可能还跑别的 LLC 协议靠这三个字节区分。3.2 转义规则与 CRC-16/X.25 的实现细节帧界字节 0x7E 只能出现在帧首尾所以数据区里凡是出现 0x7E 或 0x7D都要先发一个 0x7D再把原字节异或 0x20 发出去。接收端看到 0x7D 就把下一个字节再异或 0x20 还原。这条规则简单但有两个容易翻车的细节转义后的长度变了帧头里的长度域却不会跟着变CRC 是对转义前的原始字节算的不是对线上字节算的。CRC 用的是 CRC-16/X.25多项式 0x1021 的反射形式 0x8408初始值 0xFFFF算完之后整体按位取反输出时低字节在前。这个“低字节在前”和整数的大端习惯相反很多实现就是在这里对不上的。def crc16_x25(data: bytes) - bytes: 计算 DLMS 的 HCS / FCS返回低字节在前的两个字节。 crc 0xFFFF for byte p a hrefhttps://download.csdn.net/download/taxiangren80/12060914 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
阅读完成 · 觉得有帮助?
咨询建站