简介:DLMS协议库以C语言源码形式提供一套可直接研读的DLMS/COSEM通信协议实现,面向电表、水表、能源计量终端及采集设备的嵌入式开发、协议移植、一致性测试与维护人员,帮助读者理解蓝皮书、绿皮书、黄皮书所定义的通信模型、对象接口和访问流程。压缩包共2个文件,由1个C源文件和1个H头文件组成,整体约41KB,头文件用于承载接口、结构体与常量声明,源文件给出核心协议逻辑,便于在项目中按模块裁剪、编译、调试和二次开发。该库已在欧盟及东南亚多个国家的电表、水表中批量应用,并通过CCT软件测试取得DLMS证书,满足MID、KEMA认证及最新CTT测试规范,对排查一致性测试问题、核对对象访问细节和梳理认证要点具有参考价值。目前已有1107人学习,适合需要对照规范与认证要求查漏补缺的工程师查阅。
1. 一套抄读程序跑了三天,最后卡在 HDLC 地址上
某实验室的采集程序连着跑了三天,串口上 7E 帧发得有来有回,读回来的却是空数据,最后定位到 HDLC 目的地址少配了一位。这类事情在 DLMS/COSEM 上太常见:规范厚得像砖头,现场真正卡住人的,往往就是地址、OBIS 码、A-XDR 长度这几个点。DLMS 协议库.rar 把这些层一次性摊开了——COSEM 对象模型、HDLC 与 Wrapper 链路、ACSE 关联、GET/SET/ACTION 服务、安全认证都在里面,算得上值得珍藏的通信协议库。它适合做电表固件、采集终端、能源网关的同学,也适合那些手上有一台支持 DLMS 的表、却只能靠抓包猜字段的人。下面按我实际拆包的顺序讲,从对象表一路走到能跑通的请求帧。
2. 先立对象模型:OBIS 码与 COSEM 接口类的对应拆解
拿到一个协议库,新手最容易犯的错是先去看收发函数,结果被一堆 class_id、attribute_id、OBIS 搞得晕头转向。DLMS 的通信是「对象驱动」的:你不是在发一条命令,而是在指名道姓地访问某个对象的某个属性。所以第一步是把对象模型这张表立起来,后面所有编码解码都是它的下游。
2.1 OBIS 六元组到底在标定什么
OBIS 码用 A-B-C-D-E-F 六段表示,每段 0 到 255。A 段是介质/能量类型,1 表示电,0 表示抽象对象;B 段是通道号;C 段是物理量,比如 1 是有功功率、32 是电压、96 是内部数据;D 段是测量类型或费率;E 段是费率号;F 段是总/分相,255 一般表示「总计」或「不区分」。别小看这套编号,采集侧和表侧对不上的时候,八成就是某一段理解偏了。
| OBIS | class_id | 常用属性 | 含义 |
|---|---|---|---|
| 0.0.96.1.0.255 | 1(Data) | attr 2 | 电表序列号,octet-string |
| 0.0.42.0.0.255 | 1(Data) | attr 2 | 逻辑设备名,用于 SNRM 之外的身份确认 |
| 0.0.1.0.0.255 | 8(Clock) | attr 2 | 电表时钟,date-time 结构体 |
| 1.0.1.8.0.255 | 3(Register) | attr 2 | 正向有功总电能 |
| 1.0.2.8.0.255 | 3(Register) | attr 2 | 反向有功总电能 |
| 1.0.32.7.0.255 | 3(Register) | attr 2 | A 相电压 |
这张表最好落成配置文件,而不是散在代码里。原因很实际:同一个型号的表,不同批次可能少几个对象;换个厂商,OBIS 顺序又不一样。硬编码的结果就是每接一款表改一次编译。
2.2 用对象表驱动,别把 class_id 写进业务逻辑
我一般的做法是拿一份 JSON 或 YAML 当「点表」,字段就四个:名字、class_id、OBIS、属性号。上层业务只说「我要 meter_serial_number」,由底层的映射函数翻译成 class-id + instance-id + attribute-id 三元组。这样换表只改点表,不动代码。
# point_table.json 中的一条: # {"name": "meter_serial_number", "class_id": 1, "obis": "0.0.96.1.0.255", "attr": 2} import json class CosemRef: __slots__ = ("class_id", "obis", "attr") def __init__(self, class_id: int, obis: bytes, attr: int): self.class_id = class_id self.obis = obis # 固定 6 字节 self.attr = attr # 属性号,1 开始 def obis_to_bytes(text: str) -> bytes: parts = [int(p) for p in text.split(".")] if len(parts) != 6: raise ValueError(f"OBIS 必须是 6 段: {text}") for p in parts: if not 0 <= p <= 255: raise ValueError(f"OBIS 段越界 {p} in {text}") return bytes(parts) def load_point_table(path: str) -> dict: with open(path, encoding="utf-8") as f: rows = json.load(f) table = {} for r in rows: table[r["name"]] = CosemRef(r["class_id"], obis_to_bytes(r["obis"]), r["attr"]) return table这段代码的关键在obis_to_bytes:OBIS 是文本给人看的,协议里传输的是 6 个原始字节,中间的转换只该做一次。参数说明上,class_id必须和属性号成对出现——class 3 的 Register 属性 2 是值,class 4 的 Extended Register 属性 2 也是值,但属性 3 的语义就完全不同了。把CosemRef做成不可变的,能挡住不少「运行中被别的模块改了属性号」的诡异问题。
2.3 接口类、属性、访问权限,这三个概念别混着用
接口类(Interface class)定义的是「这类对象有哪些属性、哪些方法」,属性号和方法号是类级别的约定;OBIS 是「这个具体实例在电网里的地址」;访问权限是「当前这个客户端能不能读它」。三者正交,混用就会出问题。我见过最典型的翻车:把一个 class 3 的 Register 当成 class 1 的 Data 去读,class_id 写错了,表端直接返回 access-result = 3(object-undefined),而现场同学以为是对端不支持。
另一个高频误区是把 scaler 忘了。Register 的 attr 2 通常是一个整数,attr 3 是 scaler_unit 结构体,里面有个 scale_factor 是有符号整数,真实的工程量 = 原始值 × 10^scale_factor。电表侧出于精度考虑很少直接给浮点,读回来 12345 你觉得不对,其实乘上 0.01 就是 123.45 kWh。这个换算必须放在点表层,不能散在业务里,否则每一处读数都要有人去记「这里要除以 100」。
还有一个边界:不是所有对象都允许 GET。有的表把某些属性设成只写或者只允许通过 ACTION 访问,你在客户端侧再怎么重试也没用。成熟的库会在本地维护一份支持列表,读之前先判断,避免把总线拖死。把这份列表和点表放一起管理,比事后一个个试要省事得多。
3. 链路层选型别拍脑袋:HDLC 帧、Wrapper 头与 AARQ 关联
对象表立起来之后,下一步要决定数据怎么送到表里。DLMS 有两条主路:串口/光学口上的 HDLC,和 TCP/UDP 上的 Wrapper。选错了,后面一半的调试成本都白花。
3.1 HDLC 帧结构与 HCS/FCS 的分工
HDLC 帧的基本形态是:起始标志 0x7E、帧格式域 2 字节、目的地址、源地址、控制域、帧校验序列 HCS 2 字节、LLC 头 3 字节、APDU、帧校验序列 FCS 2 字节、结束标志 0x7E。很多人第一次看会疑惑为什么有两个校验:HCS 只覆盖帧格式域到控制域这一段头部,FCS 覆盖帧格式域到 APDU 结束。接收端先校验 HCS,头部没问题再去收载荷,属于提前失败的设计。
地址域是按字节编码的,每字节低 7 位是地址值,最高位表示「后面还有没有地址字节」,最后一字节的最高位必须是 0。常见约定是客户机地址从 0x10 起,服务端从 0x01 起,广播用 0x7F。单字节地址够用的场景占九成,超过 0x7F 就要拆成多字节,拆分顺序搞反是最隐蔽的错——帧能发出去,对端就是不回。
帧格式域里的帧类型、帧计数、分段标志是拼出来的,别手写魔法数字,直接用库里FRAME_TYPE_SNRM这类常量。控制域在 I 帧里携带发送/接收序号,序号回绕处理不对,表现是「连读几十帧后突然超时」,这个坑后面第 5 章还会提。
3.2 Wrapper 模式:TCP 上多出来的 8 字节
走 TCP 的时候没有 HDLC 那套帧头和转义,取而代之的是 8 字节 Wrapper 头:版本号 2 字节(一般是 0x0001)、源 wPort 2 字节、目的 wPort 2 字节、长度 2 字节,后面直接跟 APDU。wPort 是「端口号」的概念,常见取值里 0x0001 是管理逻辑设备,0x0010 以上给用户自定义。客户端侧源端口随便给,目的端口必须和表端配置一致,这个不一致的表现和地址配错很像:TCP 连得上,请求发出去石沉大海。
Wrapper 的好处是没有转义、没有 FCS、没有地址压缩,实现简单;坏处是它不提供链路层的确认与重传,可靠性要靠 TCP 或者应用层超时兜底。所以做无线采集的时候我一般会额外加一层请求-响应配对逻辑,别指望链路层。
3.3 关联握手:AARQ 里哪个字段错了会被直接拒
无论走哪条链路,真正开始读数据之前都要建立应用层关联:客户端发 AARQ,服务端回 AARE。这一步最容易出问题的是应用上下文名称(application-context-name),它以 0.0.40.0.0.x 打头,无加密和带加密各有一套,短名引用和逻辑名引用又各有一套。抄串了的表现很干脆:AARE 里 result 直接是 rejected-permanent,连诊断信息都不给。
认证字段也得对齐。低安全等级用 Lowest 或 HLS 的挑战应答,高等级要带加密套件。如果表端配的是 HLS,客户端发的是无认证,同样被拒。我一般会把 AARQ 的关键字段做成一个可配置的结构体:应用上下文、认证机制、调用方标识、协商的 DLMS 版本与最大 PDU 尺寸,改配置不改代码。最大 PDU 尺寸尤其要注意,写太大表端不接受,写太小后面读长数据会一直触发分块。
def build_snrm(dst: int, src: int, fmt_lo: int) -> bytes: """组一个 SNRM 帧,用于 HDLC 链路上建立数据链路""" body = bytes([0x7E, fmt_lo, 0x00, dst, src, 0x10]) # 0x10 为控制域占位 hcs = fcs16(body[1:]) # HCS 覆盖头部 frame = body + bytes([hcs & 0xFF, hcs >> 8]) frame += bytes([0xE6, 0xE6, 0x00]) # 常见 LLC 头 fcs = fcs16(frame[1:]) # FCS 覆盖到 LLC frame += bytes([fcs & 0xFF, fcs >> 8, 0x7E]) return frame def fcs16(data: bytes) -> int: """CRC-16/X-25,DLMS HDLC 用的就是这个""" crc = 0xFFFF for b in data: crc ^= b for _ in range(8): crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1 return crc ^ 0xFFFFfcs16用的是反射多项式 0x8408、初值 0xFFFF、结果取反,最后按低字节在前发送。这段代码几乎每个 DLMS 实现里都有一份,但对不对得靠抓包验:拿一个已知正确的 SNRM 帧,把你算出来的 FCS 和帧里的两个字节对一遍,对不上就是多项式或者字节序错了。build_snrm里控制域和帧格式域的具体取值,以库里常量定义为准,不同实现的分段标志位摆放略有差异,别跨库照抄。
3.4 两类链路的取舍
串口直连、光学口、RS-485 总线,选 HDLC;透过采集终端、走 4G 或者以太网到表,选 Wrapper。混合场景也常见:终端下挂总线用 HDLC,终端上行用 Wrapper,协议库一般两层都实现了,你只需要在配置里选。真正要留神的是 MTU:HDLC 的单帧长度受限于地址和分段机制,长 APDU 会被拆;Wrapper 只看 2 字节长度字段,理论上能到 64K,但表端实际支持多少要试。
4. 服务层落地:GET/SET/ACTION 与 A-XDR 编码的可执行写法
链路通了、关联建了,才开始真正干活。DLMS 的服务层就三类:GET 读属性、SET 写属性、ACTION 调方法。看起来简单,落到字节上有一堆细节。
4.1 GET 的三种形态与选择性访问
GET-Request 分 normal、next、with-selective-access 三种。normal 读完整属性;next 用于分块传输,服务端回了>def wrap_llc(apdu: bytes) -> bytes: """HDLC 链路上的 LLC 头,3 字节""" return bytes([0xE6, 0xE6, 0x00]) + apdu def get_request(invoke_id: int, class_id: int, obis: bytes, attr: int, high_priority: bool = False) -> bytes: iid = (0x80 if high_priority else 0x00) | (invoke_id & 0x0F) apdu = bytes([0xC0, 0x01, iid]) apdu += bytes([class_id >> 8, class_id & 0xFF]) apdu += obis apdu += bytes([attr, 0x00]) # 属性号 + 无选择性访问 return wrap_llc(apdu) def read_len(buf: bytes, pos: int): """解析 A-XDR 长度前缀,返回 (长度, 新位置)""" b = buf[pos] if b < 0x80: return b, pos + 1 n = b & 0x7F return int.from_bytes(buf[pos + 1:pos + 1 + n], "big"), pos + 1 + n def parse_get_response(apdu: bytes): if apdu[0] != 0xC4: raise ValueError(f"不是 GET-Response: {apdu[0]:#x}") if apdu[3] != 0x00: raise RuntimeError(f"GET 失败, result={apdu[3]}") return decode_axdr(apdu, 4) def decode_axdr(buf: bytes, pos: int): tag = buf[pos]; pos += 1 if tag == 0x09: # octet-string n, pos = read_len(buf, pos) return buf[pos:pos + n], pos + n if tag == 0x12: # long-unsigned n, pos = read_len(buf, pos) return int.from_bytes(buf[pos:pos + n], "big"), pos + n if tag == 0x02: # structure,按成员递归 n, pos = read_len(buf, pos) out = [] for _ in range(n): v, pos = decode_axdr(buf, pos) out.append(v) return out, pos raise NotImplementedError(f"未处理的 A-XDR 标签 {tag:#x}")
get_request里 invoke-id 只占低 4 位,高位是优先级标志,这个位运算写错的表现是「请求发出去,响应的 invoke-id 对不上,被本地逻辑当成超时丢掉」。decode_axdr用递归处理 structure 是最省事的写法,但要注意递归深度——时钟对象、电参量结构体嵌套不深,问题不大;如果遇到 array 套 structure 套 array 的负荷曲线,老老实实加深保护。
响应解析里最值得抄的是「先判 result 再解数据」这个顺序。不少实现直接跳到数据段去解码,result 非零的时候解出来的是垃圾,然后在业务层报一个毫不相关的异常,排查时完全抓不到重点。
4.4 SET 与 ACTION 的边界
SET-Request 和 GET 结构类似,tag 是 0xC1,多一个「写优先级」的概念。写操作在电表上是敏感动作,通常需要更高的安全等级或者专门的写权限,直接拿读的关联去写,回的是 read-write-denied。SET 里可以用 null-data(0x00)表示「这个成员不改」,改结构体里的某个字段时特别有用,省得先把整个结构读回来再原样写回去。
ACTION-Request(tag 0xC3)用来调方法,比如清需量、校时、复位。方法号和属性号是两套编号,别混。ACTION 的响应里可能带返回值,也可能只回成功,取决于方法本身的定义。真要做校时这类动作,我习惯先 GET 一次 Clock 对象确认当前偏差,再 ACTION,再 GET 验证,三步都留日志,出事能对得上。
5. 避坑与排查:现场联调最容易翻车的五件事
协议库能跑通 demo 是一回事,能在现场稳定抄读是另一回事。下面五条都是血泪经验,每条按现象、原因、解决写,能省掉不少来回。
5.1 帧能发出去,但一直超时
现象:串口上能看到完整的请求帧,对端一个字节都不回,换一台同型号的表情况一样。
原因:HDLC 地址编码不对。地址域每字节最高位表示是否续接,最后一字节最高位必须为 0;多字节地址的拼接顺序也容易反。另一个可能是波特率、校验位、数据位不匹配,但这种通常连 0x7E 都收不干净,和「完全不回」有区别。
解决:先在客户端侧回环验证自己的帧,用fcs16重算一遍 HCS 和 FCS,与抓包工具解出来的值比对。地址从单字节 0x10/0x01 这种最简单的组合开始试,通了再改。多字节地址按规范逐字节拆,写完打印十六进制对照。
5.2 关联成功,GET 却返回 access-result = 3
现象:AARQ/AARE 握手正常,第一个 GET 请求回 access-result 3,换个对象读可能又好了。
原因:object-undefined,class_id 或 OBIS 对不上。最常见的是把不同接口类的相同 OBIS 混着用,或者厂商侧的对象实例号并非标准的六段编码——有些表在 OBIS 前面还加了逻辑设备号。
解决:对照表端提供的对象清单逐条核对,别用「同型号应该一样」去推。没有清单的情况下,先读 0.0.40.0.0.255 之类的通用对象确认基础通路,再按业务对象逐个试探。点表里给每条记录加一个「已验证」标记,现场只跑验证过的。
5.3 读回来的数值差 10 倍、100 倍
现象:电压读回来 22000,电流读回来 500,电能读回来 1234567,全都不像真实值。
原因:scaler 没处理。Register 的值是整数,真实值需要乘 10^scale。另一个可能是单位选错,比如把 kW 当成 W 读。
解决:把 attr 3 的 scaler_unit 一起读回来,在点表层做换算,业务层只拿工程量。换算函数要有边界判断:scale 一般在 -6 到 6 之间,超出就说明解析错了,直接抛异常比默默算出一个错值好。
5.4 HLS 认证总是失败
现象:同一个密码,用厂商工具连得上,用自己写的客户端一直回认证失败。
原因:HLS 的挑战应答里,客户端要先把服务端下发的那串随机数(system-title)和自己的密码做处理,有的实现是直接拼接后做哈希,有的是带盐。另外密码在协议里走的是 octet-string,长度必须和配置一致,少一位都过不去。
解决:抓厂商工具的握手报文,逐字节对比自己的 AARQ 和后续的应答 APDU,重点看 system-title 是否原样回传、认证值长度是否正确。密码别用字符串长度当字节长度,中文或者特殊字符会翻车。
5.5 大数据块读到一半断了
现象:读负荷曲线或者事件日志,前几块正常,中间突然超时或者数据错位。
原因:分块传输时帧序号回绕没处理好,或者 next 请求的 block-number 没有严格 +1。HDLC 的 I 帧发送/接收序号是 3 位或者 7 位循环的,实现里用整数一直递增,到边界就崩。
解决:把序号逻辑单独抽成函数,放单元测试里跑一千次循环,验证回绕。分块读取加一个整体超时和最大块数保护,避免死循环把总线拖死。
6. 进阶验证:把一次性联调变成能回归的协议栈测试
现场调通一次不难,难的是三个月后改了个字段,怎么确认没把别的读点弄坏。我后来养成一个习惯:把每次联调抓到的完整报文存成样本,做一套离线的编解码回归。
具体做法是三层。第一层是纯函数测试,fcs16、read_len、OBIS 转换这些不依赖硬件的函数,用已知输入输出对拍,样本直接取自抓包。第二层是帧级回放:把一段会话的 APDU 序列存成 JSON,包括请求和期望响应,测试时喂给解析函数,比对结果。第三层才是端到端,连真表或者模拟器,跑一遍点表里所有标记为「已验证」的对象。
| 验证层 | 输入 | 通过标准 |
|---|---|---|
| 纯函数 | 抓包得到的字节片段 | 输出与报文里的字段完全一致 |
| 帧级回放 | 请求/响应 APDU 序列 | 解析结果、异常分支都符合预期 |
| 端到端 | 点表全部已验证对象 | 全部读到且数值在合理区间 |
回放样本的采集有个小技巧:抓包时把时间戳和 invoke-id 一起记下来,回放时按 invoke-id 匹配而不是按顺序,这样中间插入新请求也不会错位。另外,异常响应比正常响应更值得存——access-result 非零、AARE rejected、data-block 中断这几种,线上真出问题时全靠它们定位。
我做这套东西是被坑出来的。有次改了一个 A-XDR 长度前缀的判断,把一个 128 字节边界上的字符串解码改错了,本地测的都是短报文,一点事没有,上线后某个型号的表序列号全变空。从那以后我每次动编解码代码,都强制先跑一遍回放样本,跑不过不许提交。这份 DLMS 协议库里的实现和例子,正好可以当作样本来源,对着改比从零写省太多时间,需要的话拿去用,希望帮到你。
本文还有配套的精品资源,点击获取