跳到主要内容

入门:读一次电表时钟

理解 DLMS 最快的办法,是亲手读一个值。本文只做一件事:连上电表,读出它的时钟。

全过程只有两问两答:

客户端 电表
| ---------- AARQ(我想建立连接)------> |
| <--------- AARE(同意)--------------- |
| ---------- GET(读时钟)-------------> |
| <--------- 响应(当前时间)----------- |

为了最短路径,本文做了三个简化:

选择省掉了什么
走 TCP(Wrapper)HDLC 的帧、地址和 CRC
公共客户端,无认证密钥和加密
读时钟负荷曲线的分块传输和筛选

如果还不了解整体分层,可以先看架构、分层与历史。

看报文前的准备​

本文的报文都用十六进制书写,两个字符是一个字节。下面四个概念足够看懂全文。

1. 十六进制换算。 0x10 是十进制 16,0x1F 是 31,0x0C 是 12。两个字节合起来时高位在前,比如 07 EA 是 7 × 256 + 234 = 2026。

2. APDU。 应用层的一条完整消息,AARQ、AARE、GET 请求和响应都是 APDU。

3. Wrapper 头。 TCP 上每条 APDU 前面固定加的 8 字节,用来说明"谁发给谁、后面有多长"。

4. 两种编码。 本文会遇到两种写法,分清它们,报文就不再是天书:

编码用在哪里特点例子
BERAARQ、AARE 的外层每项都是"标签 + 长度 + 值"(TLV),可以逐层拆开02 01 00:02 表示整数,01 表示长度 1 字节,00 是值
A-XDRxDLMS,包括 GET字段按固定顺序排列,长度固定的字段不写标签和长度,更省字节类 ID 就是两个字节 00 08,前面什么都没有

第一步:要读什么​

DLMS 里读任何数据,都需要三个信息:

信息值含义
类 ID8Clock 接口类
OBIS0-0:1.0.0.255这块表上的"时钟"对象
属性2Clock 的 time 属性(属性 1 是对象名)

这三个数就是 DLMS 的核心。 读电量、读电压,也只是把这三个数换掉。

可以类比成面向对象编程:

概念是什么为什么需要编程类比
接口类(IC)规范预先定义好的一种"数据模板",用数字编号,比如 Clock 是 8所有厂商的时钟结构一致,客户端写一次解析逻辑就够了class Clock
对象电表里按某个接口类创建出来的一份实例一块表可能有多个同类对象,比如很多个 Registernew Clock()
OBIS对象的名字,由 6 个数字组成不管哪个厂商,同一个名字都代表同一种数据变量名
属性对象里的一项数据,从 1 开始编号一个对象不止一个值:Clock 既有当前时间,也有时区成员字段

第二步:建立连接​

电表不接受陌生请求,要先建立应用连接。TCP 连到电表(默认端口 4059)后,发送一条 AARQ。报文里的词比较多,先逐个弄清楚。

谁和谁在通信​

应用连接(Application Association)

  • 是什么:客户端和电表之间的一次"会话约定",约定双方用什么规则对话、客户端有什么权限。
  • 为什么需要:TCP 连接只说明网络通了,不说明你是谁、能做什么。电表要靠应用连接来区分权限,比如公共客户端只能读少量数据,管理客户端才能改参数。

客户端地址和公共客户端

  • 是什么:地址(规范里叫 SAP,服务访问点)用一个数字表示通信的一方。客户端地址 16 是规范保留给公共客户端的。
  • 为什么需要:不同地址对应不同权限。公共客户端不需要密码,让任何人都能先读一点基本信息,比如时钟和帧计数器。

逻辑设备

  • 是什么:一块物理电表内部可以分成多个"虚拟电表",每个就是一个逻辑设备,有自己的地址。
  • 为什么需要:比如计量部分和通信模块可以分开管理。地址 1 是管理逻辑设备,规范要求每块表都必须有,所以入门时发给它一定没错。

AARQ 和 AARE​

  • AARQ(Application Association Request):客户端发出的"我想建立连接"。
  • AARE(Application Association Response):电表的回答,里面写明同意还是拒绝。
  • ACSE(关联控制服务元素):负责建立和释放连接的那部分协议,AARQ 和 AARE 就属于它。它是 ISO 的通用标准,并不是 DLMS 专用的。
  • xDLMS:DLMS 专有的那部分协议,负责 GET、SET 这些实际操作。

分工可以这样记:ACSE 负责"接通电话",xDLMS 负责"通话内容"。

应用上下文和 OID​

应用上下文(Application Context)

  • 是什么:这次对话采用的规则。DLMS 定义了 4 种,由 OID 的最后一个数字区分:
最后一位访问方式是否加密
1LN(逻辑名)否
2SN(短名)否
3LN(逻辑名)是
4SN(短名)是
  • 为什么需要:连接时先说好规则,电表不支持就当场拒绝,不用等到后面解析报文时才出错。

LN 和 SN

  • LN(Logical Name,逻辑名):用 6 字节的 OBIS 编码来指定对象,含义清晰,是现在的主流。
  • SN(Short Name,短名):用 2 字节的短地址指定对象,更省字节,但每块表的地址可能不同,是早期低速链路留下的方式。

OID(Object Identifier,对象标识符)

  • 是什么:国际上通用的一种"全球唯一编号",像一棵树,每个数字往下走一层。它不是 DLMS 发明的,在 HTTPS 证书等很多领域也都在用。
  • 为什么需要:ACSE 是通用标准,需要用一种全球不会重复的方式来表示"我用的是哪套规则"。

本文的 2.16.756.5.8.1.1 逐层展开:

数字含义
2ISO 和 ITU 联合管理的分支
16按国家划分
756瑞士(DLMS UA 注册在瑞士)
5瑞士的组织分支
8DLMS UA
1应用上下文
1LN,不加密

报文里写成 60 85 74 05 08 01 01,是 BER 的 OID 压缩写法:前两层 2.16 合成一个字节 60(2 × 40 + 16 = 96),756 因为大于 127,要占两个字节 85 74。

OID 和 OBIS 的区别

两者都是一串数字,但完全不是一回事:

OIDOBIS
命名的是什么规则和机制,比如"LN 不加密"电表里的数据,比如"时钟"
来源ISO/ITU 的通用体系DLMS/COSEM 专用,IEC 62056-6-1
长度不固定固定 6 组
出现在哪里建立连接时(AARQ、AARE)操作数据时(GET、SET、ACTION)
类比选择"说哪种语言"选择"问哪件事"

用户信息和 InitiateRequest​

用户信息(user-information)

  • 是什么:ACSE 在 AARQ 里预留的一个"附加信息"字段,ACSE 自己不解析,原样交给上层。
  • 为什么需要:ACSE 是通用协议,不懂 DLMS。DLMS 需要协商功能和报文大小,就把这些内容放进这个字段,搭连接请求的顺风车,省掉一次来回。这就是报文里 BE → 04 → 01 一层套一层的原因。

InitiateRequest 和 InitiateResponse

  • 是什么:装在用户信息里的 xDLMS 消息,客户端用 InitiateRequest 提出"我要什么",电表用 InitiateResponse 回答"实际给你什么"。
  • 为什么需要:不同电表支持的功能和内存大小不同,通信前要先对齐。

InitiateRequest 里的每一项:

字段是什么有什么用本例
专用密钥(dedicated key)只在本次连接使用的临时加密密钥用临时密钥加密业务数据,减少长期密钥的暴露不加密,所以不带
response-allowed客户端是否希望电表回复这条请求广播场景可以要求电表不回复,避免信道拥塞默认值,需要回复
服务质量(quality of service)早期规范留下的字段,用来协商通信质量等级现在基本不用,但格式上保留了这个位置,所以要填"不带"不带
DLMS 版本协议的版本号双方确认能互相理解,目前主流是 66
一致性块(conformance block)一组开关,声明要用哪些功能见下文只开 GET
最大 PDU能接收的单条消息的最大字节数见下文65535

一致性块和功能位

  • 是什么:一个 24 位的开关表,每一位代表一项功能,1 表示使用,0 表示不用。下面是几个常见的位:
位功能
11GET 分块传输
14一次请求多个对象
19GET(读)
20SET(写)
21选择性访问
22事件通知
23ACTION(执行方法)
  • 为什么需要:电表能力差别很大。客户端列出想用的功能,电表返回双方都支持的部分,之后双方就只用这些功能,不会发出对方不懂的请求。
  • 位的编号从左边开始,00 00 10 的二进制是 00000000 00000000 00010000,从第 0 位数到第 19 位正好是那个 1。

PDU 和最大 PDU

  • 是什么:PDU(协议数据单元)就是一条完整的消息。最大 PDU 是一方能接收的单条消息上限。
  • 为什么需要:电表内存很小,发送方不能发超过对方上限的消息。数据太长时,要用分块传输拆成多条。

AARE 里多出来的内容​

  • 连接结果:00 接受,01 永久拒绝,02 暂时拒绝。这是判断连接成败的唯一依据。
  • 诊断信息:拒绝时用来说明原因,比如认证失败或应用上下文不支持,方便排错。
  • VAA 名称:VAA 是"虚拟应用关联",早期规范的概念,这个字段告诉客户端用哪个名字访问连接对象。使用 LN 时固定为 0007,收到后不用处理。

报文拆解​

客户端发出:Wrapper 头 + AARQ​

完整报文,共 39 字节:

00 01 00 10 00 01 00 1F
60 1D A1 09 06 07 60 85 74 05 08 01 01
BE 10 04 0E 01 00 00 00 06 5F 1F 04 00 00 00 10 FF FF

第 1 行是 Wrapper 头:

字节字段含义
00 01版本固定为 1
00 10源地址16,表示公共客户端,不需要密码
00 01目的地址1,表示电表的管理逻辑设备
00 1F长度后面的 APDU 有 31 字节

第 2 行是 AARQ 的开头,说明要用哪种"对话规则":

字节字段含义
60标签这是一条 AARQ(连接请求)
1D长度AARQ 的内容有 29 字节
A1标签接下来是应用上下文名称
09长度内容有 9 字节
06标签类型是对象标识符(OID)
07长度OID 有 7 字节
60 85 74 05 08 01 01OID 值即 2.16.756.5.8.1.1。开头是 DLMS UA 的编号,最后的 1 表示按逻辑名(LN)访问、不加密

第 3 行是用户信息,告诉电表客户端的能力:

字节字段含义
BE标签接下来是用户信息
10长度内容有 16 字节
04标签类型是字节串
0E长度字节串有 14 字节,里面就是 InitiateRequest
01InitiateRequest 标签从这里开始是 xDLMS 的初始化请求
00专用密钥不带
00response-allowed使用默认值:需要响应
00服务质量不带
06DLMS 版本版本 6
5F 1F标签接下来是一致性块,即客户端想用的功能
04长度内容有 4 字节
00未用位数后面 3 字节的 24 位全部有效
00 00 10功能位只有第 19 位为 1,表示 GET。本例只需要这一个功能
FF FF最大 PDU客户端最多能接收 65535 字节

电表返回:Wrapper 头 + AARE​

00 01 00 01 00 10 00 2B
61 29 A1 09 06 07 60 85 74 05 08 01 01
A2 03 02 01 00
A3 05 A1 03 02 01 00
BE 10 04 0E 08 00 06 5F 1F 04 00 00 00 10 04 00 00 07

第 1 行是 Wrapper 头,源和目的地址与请求相反:

字节字段含义
00 01版本固定为 1
00 01源地址1,电表
00 10目的地址16,公共客户端
00 2B长度后面的 APDU 有 43 字节

后面 4 行是 AARE。最重要的只有第 3 行:

字节字段含义
61标签这是一条 AARE(连接响应)
29长度内容有 41 字节
A1 09 06 07 60 85 74 05 08 01 01应用上下文电表确认使用同样的规则,结构与请求相同
A2 03 02 01 00连接结果A2 是结果标签,03 是长度,02 01 表示 1 字节整数,00 表示接受
A3 05 A1 03 02 01 00诊断信息A3 是诊断标签,05 是长度,A1 03 表示来自 ACSE 用户,02 01 00 表示没有问题
BE 10 04 0E用户信息外层与请求含义相同
08InitiateResponse 标签从这里开始是 xDLMS 的初始化响应
00服务质量不带
06DLMS 版本版本 6
5F 1F 04 00一致性块头含义与请求相同
00 00 10协商后的功能双方都支持的功能:GET
04 00电表最大 PDU电表最多能接收 1024 字节
00 07VAA 名称使用 LN 访问时固定为 0007

第三步:发 GET​

连接建立后,把第一步的三个数填进请求。这条报文里有几个新词:

术语是什么为什么需要
GET读取对象属性的服务。对应的还有 SET(写属性)和 ACTION(执行方法,比如拉合闸)DLMS 对数据的操作只有这几种,非常规整
NormalGET 的一种形式:一次读一个属性另外还有 With-List(一次读多个)和 Next(继续取下一块数据)
调用 ID(invoke ID)客户端给请求打的编号,0 到 15电表原样带回,客户端靠它把响应和请求对上号
优先级分高和普通两档支持优先级的电表会先处理高优先级请求
需要确认(confirmed)要求电表必须回复不需要确认的请求适合广播,比如同时下发校时
选择性访问只读属性的一部分负荷曲线可能有几万条,可以只读某个时间段。时钟只有一个值,用不上

编号、优先级和确认方式都压缩在一个字节 C1 里,以节省流量。

00 01 00 10 00 01 00 0D
C0 01 C1 00 08 00 00 01 00 00 FF 02 00

第 1 行的 Wrapper 头与 AARQ 的相同,只有长度变成 00 0D(13 字节)。

第 2 行是 GET 请求,用的是 A-XDR,字段按顺序排列:

字节字段含义
C0服务GET 请求
01类型Normal,读单个属性
C1调用 ID 和优先级二进制 1100 0001:最高位 1 表示高优先级,次高位 1 表示需要确认,低 4 位表示调用 ID 为 1
00 08类 ID8,Clock
00 00 01 00 00 FFOBIS0-0:1.0.0.255,每字节对应 OBIS 的一组
02属性2,time
00选择性访问不使用

加粗的三项,就是第一步说的三个数。

第四步:读懂响应​

先看几个术语:

术语是什么为什么需要
数据类型标签值前面的一个字节,说明值的类型,比如 09 是字节串属性的类型可能不同,接收方要靠它知道如何解析。注意这里和 GET 请求不同,返回的数据是带类型的
字节串(octet-string)一段原始字节,前面带长度时间没有单独的基本类型,Clock 把 12 字节的时间装在字节串里
未指定字段值为 FF(两字节字段为 FF FF,偏差是 80 00)电表不提供该值时用它占位,这样 12 字节的结构不会变
偏差(deviation)本地时间与 UTC 相差的分钟数只有知道时区,才能把电表时间换算成统一的时间
时钟状态一个按位表示的字节告诉你这个时间是否可信:比如第 0 位表示时间无效,第 1 位表示时间存疑,第 7 位表示正处于夏令时

报文如下:

00 01 00 01 00 10 00 12
C4 01 C1 00 09 0C
07 EA 0B 08 07 10 1E 00 FF 80 00 00

第 1 行的 Wrapper 头:00 01 版本,00 01 来自电表,00 10 发给公共客户端,00 12 表示后面有 18 字节。

第 2 行是响应头:

字节字段含义
C4服务GET 响应
01类型Normal
C1调用 ID 和优先级与请求相同,用来配对请求和响应
00结果成功,后面是数据。如果是 01,后面跟错误码
09数据类型字节串(octet-string)
0C长度12 字节

第 3 行的 12 字节就是时间:

字节字段含义
07 EA年2026
0B月11
08日8
07星期星期日(1 是星期一,7 是星期日)
10时16
1E分30
00秒0
FF百分之一秒未指定
80 00与 UTC 的偏差未指定
00时钟状态正常

结果:2026-11-08 16:30:00。

本节和上一节的 AARE、响应是按规范构造的示例,真实电表协商出的参数和时间会不同。

完整代码​

单文件,只用 JDK,Java 8 即可编译。报文常量已按上面的字段分行:

ReadClock.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;

public class ReadClock {
static final int CLIENT_SAP = 16; // 公共客户端
static final int SERVER_SAP = 1; // 管理逻辑设备

static final byte[] AARQ = hex(""
+ "60 1D" // AARQ,内容 29 字节
+ "A1 09 06 07 60 85 74 05 08 01 01" // 应用上下文:LN,不加密
+ "BE 10 04 0E" // 用户信息外层
+ "01 00 00 00 06" // InitiateRequest,DLMS 版本 6
+ "5F 1F 04 00 00 00 10" // 一致性块:只要 GET
+ "FF FF"); // 客户端最大 PDU

static final byte[] GET_CLOCK = hex(""
+ "C0 01 C1" // GET 请求,Normal,调用 ID 1
+ "00 08" // 类 ID:8(Clock)
+ "00 00 01 00 00 FF" // OBIS:0-0:1.0.0.255
+ "02" // 属性:2(time)
+ "00"); // 不使用选择性访问

public static void main(String[] args) throws IOException {
String host = args[0];
int port = args.length > 1 ? Integer.parseInt(args[1]) : 4059;

try (Socket socket = new Socket(host, port)) {
socket.setSoTimeout(5000);
DataOutputStream out = new DataOutputStream(socket.getOutputStream());
DataInputStream in = new DataInputStream(socket.getInputStream());

byte[] aare = exchange(in, out, AARQ);
int result = associationResult(aare);
if (result != 0) {
throw new IOException("电表拒绝连接,association-result = " + result);
}

byte[] response = exchange(in, out, GET_CLOCK);
System.out.println("电表时间:" + parseClock(response));
}
}

static byte[] exchange(DataInputStream in, DataOutputStream out, byte[] apdu)
throws IOException {
out.writeShort(1); // Wrapper 版本
out.writeShort(CLIENT_SAP); // 源
out.writeShort(SERVER_SAP); // 目的
out.writeShort(apdu.length);
out.write(apdu);
out.flush();
System.out.println("-> " + toHex(apdu));

in.readUnsignedShort(); // 版本
in.readUnsignedShort(); // 源
in.readUnsignedShort(); // 目的
byte[] reply = new byte[in.readUnsignedShort()];
in.readFully(reply);
System.out.println("<- " + toHex(reply));
return reply;
}

/** 遍历 AARE 的顶层 TLV,取出 A2 里的结果。本例中的长度都小于 128。 */
static int associationResult(byte[] aare) throws IOException {
if ((aare[0] & 0xFF) != 0x61) {
throw new IOException("不是 AARE");
}
for (int i = 2; i < aare.length; i += 2 + (aare[i + 1] & 0xFF)) {
if ((aare[i] & 0xFF) == 0xA2) {
return aare[i + 4] & 0xFF;
}
}
throw new IOException("AARE 中没有 association-result");
}

static String parseClock(byte[] r) throws IOException {
if ((r[0] & 0xFF) != 0xC4 || r[1] != 0x01) {
throw new IOException("不是 Get-Response-Normal");
}
if (r[3] != 0x00) {
throw new IOException("读取失败,data-access-result = " + (r[4] & 0xFF));
}
if (r[4] != 0x09 || r[5] != 0x0C) {
throw new IOException("不是 12 字节的 octet-string");
}
int year = (r[6] & 0xFF) << 8 | (r[7] & 0xFF);
return String.format("%04d-%02d-%02d %02d:%02d:%02d",
year, r[8] & 0xFF, r[9] & 0xFF, r[11] & 0xFF, r[12] & 0xFF, r[13] & 0xFF);
}

static byte[] hex(String s) {
String h = s.replace(" ", "");
byte[] b = new byte[h.length() / 2];
for (int i = 0; i < b.length; i++) {
b[i] = (byte) Integer.parseInt(h.substring(i * 2, i * 2 + 2), 16);
}
return b;
}

static String toHex(byte[] b) {
StringBuilder sb = new StringBuilder();
for (byte x : b) {
sb.append(String.format("%02X ", x));
}
return sb.toString().trim();
}
}

运行:

javac -encoding UTF-8 ReadClock.java
java ReadClock 192.168.1.100 4059

把地址换成支持 TCP 的电表或 DLMS 模拟器。

实测结果​

下面是这段代码读 Gurux DLMS 模拟器(用自带的 crystal.xml 模板,-w 启用 Wrapper)的真实输出。模拟器只作为外部测试对象,上面的代码没有使用它的任何代码。-> 是发出的,<- 是收到的,程序只打印 APDU,不打印 8 字节的 Wrapper 头:

-> 60 1D A1 09 06 07 60 85 74 05 08 01 01 BE 10 04 0E 01 00 00 00 06 5F 1F 04 00 00 00 10 FF FF
<- 61 29 A1 09 06 07 60 85 74 05 08 01 01 A2 03 02 01 00 A3 05 A1 03 02 01 00 BE 10 04 0E 08 00 06 5F 1F 04 00 00 00 10 03 40 00 07
-> C0 01 C1 00 08 00 00 01 00 00 FF 02 00
<- C4 01 C1 00 09 0C 07 E4 06 18 FF 0C 03 33 FF FE B6 00
电表时间:2020-06-24 12:03:51

第 1 行(发出 AARQ): 与第二步的 AARQ 逐字节相同。

字节含义
60 1DAARQ,内容 29 字节
A1 09 06 07 60 85 74 05 08 01 01应用上下文:LN,不加密
BE 10 04 0E用户信息外层
01 00 00 00 06InitiateRequest,DLMS 版本 6
5F 1F 04 00 00 00 10一致性块:只要 GET
FF FF客户端最大 PDU:65535

第 2 行(收到 AARE): 模拟器接受了连接。

字节含义
61 29AARE,内容 41 字节
A1 09 06 07 60 85 74 05 08 01 01应用上下文:LN,不加密
A2 03 02 01 00连接结果:00 接受
A3 05 A1 03 02 01 00诊断:没有问题
BE 10 04 0E用户信息外层
08 00 06InitiateResponse,DLMS 版本 6
5F 1F 04 00 00 00 10协商后的功能:GET
03 40模拟器最大 PDU:832(示例里是 1024)
00 07VAA 名称:LN 固定值

第 3 行(发出 GET): 与第三步的 GET 逐字节相同。

字节含义
C0 01 C1GET 请求,Normal,调用 ID 1
00 08类 ID:8,Clock
00 00 01 00 00 FFOBIS:0-0:1.0.0.255
02属性:2,time
00不使用选择性访问

第 4 行(收到时间):

字节含义
C4 01 C1GET 响应,Normal,调用 ID 1
00成功
09 0C字节串,12 字节
07 E4年:2020
06月:6
18日:24
FF星期:未指定
0C时:12
03分:3
33秒:51
FF百分之一秒:未指定
FE B6偏差:-330 分钟(按有符号 16 位解释)
00时钟状态:正常

和前文的示例对比,有三处值得注意:

  • 最大 PDU 是协商结果,每块表都可能不同。
  • 星期是 FF(未指定)。解析时不能假定每个字段都有值,年、月、日、时、分、秒同样可能是"未指定",上面的简化代码没有处理这种情况。
  • 偏差是 -330 分钟,而模板里的时间是 UTC+5:30。也就是说,这个模拟器的符号是"UTC 减本地时间",正是下一节提到的坑。

模板里的时间是静态的,所以读出来是 2020 年。

读不出来时​

现象常见原因
TCP 连不上或超时地址、端口不对,或电表只开了串口 / HDLC
发出 AARQ 后没有回应Wrapper 地址不对,或电表要求 HDLC 成帧
AARE 中 A2 03 02 01 后面不是 00电表拒绝连接:不开放公共客户端,或要求认证
GET 响应是 C4 01 C1 01 03第 4 个字节 01 表示失败,最后的 03 表示无权读写
GET 响应是 C4 01 C1 01 04同样失败,最后的 04 表示对象不存在,检查类 ID 和 OBIS

读到的时间看起来差了几小时,先检查偏差字段:不同电表对正负号的处理并不一致,所以解析时不要想当然。

接下来​

现在你已经走通了 DLMS 的主干。这条链路上,每处复杂度都能找到自己的位置:

  • 换对象: 把类 ID、OBIS、属性换成 3、1-0:1.8.0.255、2,读的就是总正向有功电量。注意它的值要配合属性 3 的 scaler_unit 换算。
  • 加认证: 在 AARQ 里加 LLS 或 HLS 字段。
  • 加加密: 把 GET 包进加密 APDU。
  • 换链路: 把 8 字节 Wrapper 换成 HDLC 帧,APDU 不用改。
  • 读长数据: 负荷曲线需要选择性访问和分块传输。