ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DLMS电能表抄表协议拆解:HDLC帧、三层建链与AXDR编码

DLMS电能表抄表协议拆解:HDLC帧、三层建链与AXDR编码

简介:《DMLS协议中文版》是一份面向电能量数据采集终端开发、调试与协议对接人员的IEC62056协议族中文说明手册,重点解决采集器与电能表通讯中的协议理解、报文分析与联调排错问题。文档按物理层、链路层、应用层三层模型展开,梳理Client-Server通讯流程、HDLC链路层的地址校验、帧长校验、CRC校验及长帧拆包组包机制,并深入讲解应用层的ASN.1语法、BER与AXDR编码、AARQ/AARE连接帧以及数据请求过程。资源包共1个doc文件,约620KB,篇幅集中,适合按协议层次查阅。已有870人学习,可作为协议入门和现场联调的参考资料;其中请求电量、瞬时量(电压、电流、功率)、负荷曲线和时间的实例报文,能帮助读者对照理解实际交互细节,提升报文解析与问题定位效率。适合采集终端研发、电表协议适配及现场运维人员作为案头参考。

1. 一帧 25 字节的报文里没有半个电量值,却决定了整次抄表

打开一份电能表串口抓包,第一条有实际内容的帧长这样:7e a0 21 00 22 00 23 03 93 0b 14 81 80 12 05 01 80 06 01 80 07 04 00 00 00 01 08 04 00 00 00 01 07 65 5e 7e。三十多个字节里没有电压、没有电流、没有电量,全是地址、控制字和一堆05 01 80这样的参数标识——可要是这一帧发不出去,后面的数据一个都读不回来。这就是 DLMS 协议族(资源标题写作 DMLS,正文通篇是 DLMS,指的是同一套 IEC 62056 协议族)最反直觉的地方:它把「线路通」和「拿到数」彻底切开,物理层通、链路层通、应用层连接建立,三层各自确认完,才轮到 class_id 和 OBIS 出场。这份中文说明手册的价值,正在于它用抓包把这三层怎么一层层扣起来讲清楚了,而不是丢一堆 ASN.1 定义让人自己猜。

2. HDLC 帧解剖:0x7e 之间的每个字节都在解决一个具体问题

链路层用的是 HDLC,一帧用两个0x7e夹住。很多人第一次看会疑惑:既然帧尾已经有 16 位 CRC 了,为什么帧头还要再加一个 HCS?答案藏在「分段」这件事上——一帧的帧头如果坏了,接收方不必等整帧收完、算完 FCS 才发现问题,当场就能丢弃,省掉一次无效等待。这种「提前判错」的思路在窗口小、超时长的电力通道里非常划算。

整帧的字段布局是这样的,从帧头到帧尾依次是帧类型与帧长、目的地址、源地址、控制字、HCS、信息域、FCS:

位置字段长度作用
1Flag1 字节固定0x7e
2帧类型与帧长2 字节帧类型判别、分段位 S、帧长
3目的地址域1/2/4 字节由扩展编址规则决定
4源地址域1/2/4 字节同上
5控制字1 字节帧类别、帧计数、P/F 位
6HCS2 字节帧头 CRC
7信息域0~N 字节可含 LLC 头 + 应用层数据
8FCS2 字节整帧 CRC
9Flag1 字节固定0x7e

2.1 帧类型与帧长字段:S 位如何表达「这只是一段」

这两个字节不是随便拆的,位域划分相当紧凑:高 4 位恒为1010,也就是 Frame Type 3,这是 DLMS 选定的帧格式;第 11 位单独留给 S;低 11 位才是长度,单位是字节,且不含首尾两个0x7e。

bit15 bit14 bit13 bit12 | bit11 | bit10 .................. bit0 1 0 1 0 | S | Frame Length Frame Type = A 分段位 帧长(不含 0x7e)

拿建链报文里的a0 21验算一下:a0展开是1010 0000,高四位1010对得上,S 位是 0 表示这是一段完整的、没有被分割的帧;0x021等于 33,正好是从目的地址到 FCS 的字节数。如果把这两个字节换成a8 21,那 S 位就是 1,接收方立刻知道后面还有人,得先挂起等下一段。

提示:帧长只统计两个0x7e之间的内容。自己写解析器时最容易在这一步翻车——把 Flag 也算进去,长度永远差 2,校验自然对不上。

2.2 地址域:扩展编址把 client 锁死在 128 个地址

HDLC 用的是扩展编址:一个地址字节的最低位是「还有没有后续字节」的标志,0 表示没完、后面还有,1 表示到此结束。这样地址长度就能自由伸缩,但同时带来一个硬约束——每个地址字节只剩 7 位有效数据。

client 端地址永远只占一个字节,最低位恒为 1,所以整个系统里 client 最多只能有 128 个。server 端为了做到「一个物理地址对应多个逻辑地址」,把地址拆成 upper HDLC address(逻辑)和 lower HDLC address(物理)两段,组合方式有三种:

结构字节数组成
只有 upper1upper 一字节
upper + lower2upper 一字节、lower 一字节
upper + lower 加宽4upper 两字节、lower 两字节

理论上还能更长,但实践中有上限。现场某款三相表经实测,只有四字节的 server 地址结构可用,其余两种发过去要么不回 UA,要么回的地址域对不上。建链报文里00 22 00 23就是这样一个四字节目的地址,而03是 client 的一字节地址。

这里有个反直觉的细节:地址域的顺序是目的在前、源在后,所以 client 发出去的帧,第一个地址是 server 的;server 回的 UA 帧,第一个地址反而变成了 client 的03,00 22 00 23挪到了后面。抓包对照的时候如果没意识到这一点,很容易以为电表回错地址了。

2.3 控制域:RRR/SSS 帧计数与 P/F 位

控制字这一字节承担三件事:区分帧类别、维护收发帧计数、携带 P/F 位。HDLC 的帧分三类,DLMS 用到的都在下面这张表里:

帧类型控制字示例用途
Ibit0 = 0信息传输帧,真正携带用户数据
RR0x01/0x11准备接收,用来要下一段或确认
RNR—接收未就绪,相当于忙帧
SNRM0x93设置正常响应模式,建链请求
UA0x73对 SNRM 和 DISC 的响应
DISC0x53结束链路
DM0x1F已处于断开状态
UI0x03保活,不影响链路状态
FRMR0x87拒绝接收,原因不明

I 帧的控制字里,bit7~bit5 是发送帧计数(原文记作 SSS),bit3~bit1 是接收帧计数(原文记作 RRR),bit4 是 P/F。三个比特意味着计数范围只有 0~7,超过就回绕。

计数的推进规则是这套协议里最容易出错的地方,值得单独说清楚:链路层连接建立之后,client 第一次请求数据(包括发 AARQ 那一帧),RRR 置 0、SSS 置 0;server 收到后返回响应,RRR 变成 1、SSS 还是 0;client 再次请求时两个计数各加 1,变成 RRR=1、SSS=1;server 再回,RRR=2、SSS=1。如此往复,直到数据取完。整个过程完全靠 I 帧完成,RR 帧不推进这个序列,它只做确认。

P/F 位分两个方向理解:Poll 位由 client 置,置 1 表示「请你回话」,置 0 表示「不用回」;Final 位由 server 置,置 1 表示这一轮发送结束,置 0 表示后面还有。要注意 Final 位只有在窗口大于 1 时才有意义,窗口等于 1 的时候,server 返回的每一帧这一位都恒为 1,因为一帧就是一整轮。

2.4 帧头校验与 16 位 CRC:为什么头部要单独兜一层

HCS 覆盖的范围是帧类型与帧长、目的地址、源地址、控制字这四段;FCS 覆盖的是两个0x7e之间的全部内容。两者用的都是 16 位 CRC,多项式是x^16 + x^12 + x^5 + 1,也就是常说的 CRC-16-CCITT,具体实现可以参考 IEC 62056-46 的附录 A。

def hdlc_fcs(data: bytes) -> int: """HDLC FCS:CRC-16-CCITT,poly=0x1021,init=0xFFFF,结果取反。 采用反射算法,0x8408 是 0x1021 的位反转形式。""" crc = 0xFFFF for byte in data: crc ^= byte for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0x8408 else: crc >>= 1 return crc ^ 0xFFFF frame_body = bytes.fromhex( "002200230393" "0b14" # 地址 + 控制字 + HCS "81801205018006018007040000000108040000000107" ) print(hex(hdlc_fcs(frame_body))) # 与帧尾两字节比对

这段代码有三个地方值得盯住:初值必须是0xFFFF而不是 0;多项式要用反射形式0x8408,直接拿0x1021右移会算错;最后结果要取反。发送时 CRC 的低字节在前,所以抓包里看到65 5e,实际数值是0x5e65。如果算出来和帧尾对不上,先检查是不是把首尾两个0x7e也喂进去了——这是血泪经验里排名第一的低级错误。

3. 建链、参数协商与长帧重组:一次抄表为什么要握手三次

物理层通了不等于能通信,链路层通了也不等于能取数。DLMS 的三层结构决定了它必须建立两次连接:一次在链路层,靠 SNRM 和 UA 完成;一次在应用层,靠 AARQ 和 AARE 完成,这个过程叫 Negotiation。两次握手各管一摊事,缺一次都拿不到数据。

3.1 SNRM 与 UA:逐字节拆一帧建链报文

把开头那帧建链报文摊开看,每个字段的职责一目了然:

字节值含义
Flag7e帧头
类型与长度a0 21Frame Type A,S=0,长度 33
目的地址00 22 00 23server 地址,四字节结构
源地址03client 地址,一字节
控制字93SNRM
HCS0b 14帧头校验
运行控制域81 80SNRM 标识
参数组长度1218 字节
参数 105 01 80最大发送信息域长度 = 128
参数 206 01 80最大接收信息域长度 = 128
参数 307 04 00 00 00 01发送窗口 = 1
参数 408 04 00 00 00 01接收窗口 = 1
FCS65 5e整帧校验
Flag7e帧尾

信息域的编码方式很规整:一个字节的参数标识、一个字节的参数长度、然后是参数值。05表示「最大发送信息域长度」,长度域是01,值只有一字节80,也就是 128 字节。窗口参数不一样,长度域是04,值占四个字节,00 00 00 01按大端读就是 1。

SNRM 里的用户信息其实可以整段不写,含义是「我接受你(server)已经配置好的参数」。这在现场调试时很有用:不确定电表支持多大的帧,就先发一个不带参数的 SNRM 探路。

server 回的 UA 帧长得很像,但地址顺序反了过来:7e a0 21 03 00 22 00 23 73 28 f0 ... 53 3b 7e。源地址成了03,目的地址成了00 22 00 23,控制字73是 UA,HCS 从0b 14变成了28 f0。UA 后面跟着的参数组就是协商结果,client 必须按这个结果调整后续发送,不能还按自己原来的想法打包。

3.2 四个链路参数:为什么默认窗口只有 1

UA 帧里能协商的链路参数一共四个,分成发送和接收两个方向:

参数标识名称默认值说明
05transmit maximum information field length128 字节本方一帧能装多少用户数据
06receive maximum information field length128 字节本方一帧能收多少用户数据
07transmit window size1本方一次能连发几帧
08receive window size1本方一次能收几帧

窗口默认给 1 不是偷懒,是权衡。窗口大于 1 意味着发送方要缓存在途帧、接收方要能重排乱序、双方还要维护更复杂的确认逻辑,而电表主控的 RAM 通常以 KB 计,多缓几帧就是实打实的成本。窗口为 1 时协议退化成停等模式,一发一收,逻辑最简单,出错也最好定位。真正决定吞吐的是信息域长度,从 128 往上谈,比调窗口划算得多。

3.3 长数据帧:S 位与 RR 帧的接力

用户数据超过 128 字节,一次装不下,就得启分段流程。规则不复杂:发送方把大数据包切成若干段,除最后一段外,每段的 S 位置 1;接收方看到 S=1,就知道还没完,回一个 RR 帧要下一段,直到收到 S=0 的那一段为止。

def reassemble(frames): """把带 S 位的 HDLC 分段帧拼回完整应用层数据""" buf = bytearray() for f in frames: seg_bit = (f[1] >> 4) & 0x01 # 帧长字段高字节的第 4 位即 S 位 buf.extend(f[payload_start:f[1] & 0x0F]) # 这里只示意取信息域 if not seg_bit: # S=0 表示最后一段 return bytes(buf) # S=1:此处应向对端回 RR 帧,请求下一段 return None # 没等到最后一段,调用方须按超时处理

真正的工程实现里,这个循环要配三重保护:段序号连续性检查、单段超时重发、总段数上限。读负荷曲线一定会走分段,因为曲线数据动辄几百上千字节,而信息域默认只有 128 字节。曲线读不全的故障,十有八九是这里没处理干净——要么把第一段当成了全部,要么 RR 发早了导致段序错乱。

3.4 帧计数与结束链路:为什么 DISC 之前要先发 RR

数据取完之后不能直接甩一个 DISC 过去。手册里明确写了:请求数据结束后还要再发一个 RR 帧,收到确认之后才可以发 DISC 结束链路。

这一帧 RR 的作用是「清账」。前面每个 I 帧都推进了收发计数,最后发一个 RR,等于告诉 server:我这边所有帧都收到了,你可以把状态清掉。少了这一步,server 端的计数停在半路,下次建链时如果 client 从头计数,两边就可能对不上,表现就是「第一次抄成功、第二次一直超时」这种玄学故障。

DISC 发出去之后,server 回 UA 表示接到指令后正常断开,回 DM 表示它本来就已经断开——这两种响应都算正常,抓包时别把 DM 当成错误。断开之后如果还要再抄一次,必须重新走完整的 SNRM/UA 流程,不能复用上一次的链路状态。

4. 应用层:ASN.1 语法、BER/AXDR 双编码与 OBIS 寻址

链路通了只是修好了路,跑什么车由应用层决定。DLMS 应用层最特别的一点是:它不用固定表格定义帧格式,而是用 ASN.1 这门抽象语法语言描述结构,再靠 BER 和 AXDR 两套编码把结构落成字节。抽象带来通用性,也带来了理解成本——但一旦看懂,后面所有帧都是同一套语法的实例。

4.1 ASN.1 语法骨架:tag、IMPLICIT/EXPLICIT 与 SEQUENCE/CHOICE

一个 ASN.1 描述大致长这样:名字、标签、数据类型,大括号里是各个数据项。标签里的 class type 分四种,决定了这个名字的适用范围:

class type取值含义
Universal00在所有 DLMS 应用中含义唯一
Application01含义与具体应用相关
Context-specific10含义依赖上下文,同一结构里可能代表不同东西
Private11厂家自定义范围

IMPLICIT 和 EXPLICIT 描述的是子结构对父结构标签的继承关系:IMPLICIT 会替换掉父结构的 tag,EXPLICIT 则保留。不写默认为 EXPLICIT。这个区别直接决定编码时要不要多一个包装层,写错一个关键字,解析就整体偏移。

数据类型分简单型和复合型,简单型的 tag 是固定的,比如 INTEGER 是0x02、OCTET STRING 是0x04、OBJECT IDENTIFIER 是0x06、SEQUENCE 是0x10、SET 是0x11。复合型里最常用的是两个:SEQUENCE 表示内容按顺序排列,CHOICE 表示从若干候选里挑一个。请求数据时最常用的 GET-Request 就是一个典型的 CHOICE:

GET-Request ::= CHOICE { get-request-normal [1] IMPLICIT Get-Request-Normal, get-request-next [2] IMPLICIT Get-Request-Next, get-request-with-list [3] IMPLICIT Get-Request-With-List }

三个选项只能填一个。取单个属性用 normal,连续读用 next,批量读用 with-list。看懂这个 CHOICE,就等于看懂了应用层请求的分类方式。

4.2 BER 编码:TLV 三元组与标识字节的位域

BER 的编码结构是「标识 + 长度 + 内容」三层,内容里可以再嵌一层同样的结构。标识字节的前几位有明确分工:高两位是 class,第三位区分简单类型和复合类型,剩下的是长度。

拿 AARE 响应帧的开头几个字节实拆一遍:

61 AARE 的 tag,[APPLICATION 1] 42 整体长度 0x42 A1 application-context-name 的 tag 09 长度 9 06 OBJECT IDENTIFIER 07 长度 7 60 85 74 05 08 01 01 OID 内容 A2 result 的 tag 03 02 01 00 Association-result = 0(接受) A3 result-source-diagnostic 的 tag 05 A1 03 02 01 00 Associate-source-diagnostic

61这个字节拆开看:01是 Application class,1是复合类型标志,00001是 tag 编号 1,合起来就是[APPLICATION 1]。后面的A1、A2、A3都是 Context-specific 的标签,编号分别是 1、2、3,正好对上 ASN.1 定义里的[1]、[2]、[3]。

长度域有个坑:小于 128 时就是一个字节;大于等于 128 时要用长形式,首字节最高位置 1,低位表示后面跟几个字节的长度。AARE 这种小帧碰不到,但一旦协商出更大的 PDU,请求帧里就会出现81 xx这样的编码,解析器如果只认单字节长度,当场就崩。

4.3 AXDR 编码:为什么 XDLMS 放弃「标识 + 长度」

DLMS 里其实有两套协议:用 ASN.1 描述的 DLMS 用 BER 编码,用 ASN.1 描述的 XDLMS 用 AXDR 编码。只有 AARQ 和 AARE 的部分内容走 DLMS,绝大多数应用层数据通信走 XDLMS。

AXDR 是对 XDR 的扩展,核心思路是「语法已经静态确定了,编码里就别再重复类型和长度」。同样两个整数0x1234和0x5678,两套编码的差别非常直观:

BER 编码: 30 08 SEQUENCE,长度 8 02 02 12 34 INTEGER,长度 2,值 0x1234 02 02 56 78 INTEGER,长度 2,值 0x5678 共 12 字节 AXDR 编码: 11 SEQUENCE 标识 12 34 A 的值,大端直接写 56 78 B 的值,大端直接写 共 5 字节

省掉的正是「每项的类型标识」和「每项的长度」。因为解析方手里有同一份 ASN.1 定义,知道第一个字段一定是 Integer16、第二个一定是 Unsigned16,照着顺序读就行。代价是编码和解码必须严格对齐同一份语法,任何一方版本不一致,读出来的就是一堆垃圾数。

注意:AXDR 里的整数是大端,21 34就是 0x2134,不做任何包装也不做字节序转换。把 OCTET STRING 当 INTEGER 解、或者把大端按小端读,是数值异常的常见来源。

4.4 一帧 AARE 的完整拆解:从 61 42 到 21 34

最有意思的是 AARE 这帧——它的前半段是 BER,后半段的 user-information 里装的是 AXDR,一帧里两套编码并存。

61 42 AARE tag + 长度 A1 09 06 07 60 85 74 05 08 01 01 application-context-name A2 03 02 01 00 result = 0 A3 05 A1 03 02 01 00 result-source-diagnostic 88 02 07 80 ACSE-requirements 89 07 60 85 74 05 08 02 01 mechanism-name AA 0A 80 08 41 42 43 44 45 46 47 48 authentication-value BE 04 user-information 的 CHOICE,OCTET STRING 0E 内容长度 08 InitiateResponse 的 explicit tag 00 proposed-quality-of-service 未使用 06 negotiated-dlms-version-number 5F 1F [APPLICATION 31] tag 04 长度 00 00 08 1D conformance 位串 21 34 server 端 PDU 尺寸上限,大端读作 0x2134 00 07 00 07 LN2 相关的两个 Unsigned16

BE 04这两个字节是切换点:BE是 user-information 的 CHOICE 标签,04说明里面装的是 Universal 的 OCTET STRING。从这里往后的08 00 06 5F 1F ...全部按 AXDR 解析,08是 InitiateResponse 的 explicit tag,5F 1F是[APPLICATION 31]。

这就是为什么很多解析库要把一帧拆成两段处理:外层按 BER 走,进了 user-information 换 AXDR 的解码器。用一套解码器硬啃整帧,前面能过、后面必错。

4.5 OBIS 与 class_id:应用层怎么唯一定位一个数据项

链路通了、应用层连接也建好了,接下来的问题变成「我要的那个数在哪儿」。DLMS 的答案是二维坐标:class_id 说明这是哪一类对象,OBIS 说明是这一类里的哪一个。

请求电量、请求瞬时量(电压、电流、功率)、请求负荷曲线、请求

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进