简介:这是一份面向智能电网与自动抄表领域开发者的DLMS/COSEM与HDLC通信协议资料包,聚焦IEC 62056标准体系,既有中英文规范文档,也有可直接参考的协议栈实现源码,以及HDLC链路层相关技术资料。对于需要设计智能电能表、开发数据采集模块或维护计量自动化系统的工程师,能提供从标准研读到代码落地的完整支撑。压缩包共195个文件,以C/C++头文件与源码(h/c/cpp)为主体,配套PDF/DOC格式的技术文档、Markdown说明、Makefile构建脚本及zbak备份文件,便于按需查阅与二次开发。包内源码涉及CSM关联管理、定时器、事件组、AES/CMAC/GCM加密等关键模块,可帮助读者具体理解DLMS/COSEM的安全机制与通信流程,同时HDLC相关资料可辅助分析底层链路控制。资源整体约31.28MB,已有68人学习,适合电力计量、自动抄表与配网自动化方向的技术人员深度研读。
1. 从一只电表抄不到数说起:DLMS/COSEM 到底是什么样的通信协议
排查集中器抄表故障时,我最常遇到的场景是:数据链路层看起来正常,示波器上能看到一串串字节,可表端就是不给回应。折腾半天才发现,自己面对的根本不是简单的寄存器读写,而是一套带对象模型的应用层协议——DLMS/COSEM,以及承载它的 HDLC 数据链路层。这套协议在智能电表、能源计量、配电终端里几乎是事实标准,尤其是出口表计项目,几乎绕不开。DLMS/COSEM 解决的问题很直接:不同厂家、不同型号的电表,如何用一套统一的对象模型和通信规则完成抄表、参数配置、事件上报;而 HDLC 则负责把 DLMS 的应用层报文可靠地封装成帧,在串口或网络上传输。适合谁读?做电表嵌入式开发的工程师、写集中器采集程序的同行、以及刚接触计量通信标准、想把文档和源码用起来的新人。
2. 协议栈拆解:DLMS/COSEM 为什么选 HDLC,以及和普通串口协议的本质差异
2.1 三层模型:应用层对象模型、数据链路层 HDLC、物理层串口
DLMS/COSEM 很少单独出现,工程上通常是一整套分层结构。最顶层是 DLMS/COSEM 应用层,负责定义“读什么”“写什么”的语义;中间是 HDLC 数据链路层,负责把应用层报文切成长度可控、带校验的帧;最底层是物理层,常见的是 UART/RS-485,也可能走 TCP/IP 网络。理解这个分层,最大的价值在于排查问题时能立刻定位:表端不回数据,到底是物理层没收到,还是链路层帧校验失败,又或者是应用层对象不存在。
很多从 Modbus 转过来的工程师,第一次看 DLMS/COSEM 都会不太适应。Modbus 里一个保持寄存器地址就对应一个数据点,功能码清清楚楚;DLMS/COSEM 则更像一个微型的面向对象数据库——电表被建模成一组对象,每个对象有类 ID、OBIS 码、属性 ID,读数据要组装一个读请求 APDU,再解析表端返回的读响应。这中间任何一层对不上,结果就是黑匣子,抓了包也不知道错在哪。
和面向字节的 UART/CAN 通信协议相比,DLMS/COSEM 最大的特点是自带完备的对象寻址能力。用 UART 传数据时,收发双方必须提前约定好寄存器映射表;而 DLMS/COSEM 里,想看正向有功总电量,直接请求 OBIS 码 1.0.1.8.0.255 的特定属性即可,不需要关心这个表是哪家主控方案。这种解耦,让集中器项目里换电表厂家变得轻松许多。
2.2 COSEM 对象模型:把电表数据变成可寻址对象
COSEM 的核心是对象模型。电表内部的数据点,比如电压、电流、电量、事件记录、费率时段,都被映射成一个个逻辑对象。每个对象有三个关键要素:类 ID(class_id)表明这是“电能读数”还是“寄存器”还是“事件日志”;OBIS 码标识具体对象,例如 1.0.1.8.0.255 就代表正向有功总电量;属性 ID 表示读的是当前值、单位还是缩放因子。
以最常见的读表流程为例,客户端的读请求 APDU 里会携带一个 ReadRequest 结构,其中包含变量访问说明,也就是“目标对象引用”。表端收到后,根据 class_id 和 OBIS 码查找本地对象表,再把对应属性值用 A-XDR 规则编码返回。我一般会把 OBIS 码表和 class_id 打印成一张速查卡贴在工位上,因为调试时最容易翻车的不是协议栈本身,而是把 1.0.1.8.0.255 和 1.0.1.9.0.255 搞混——前者是正向有功总电量,后者是反向有功总电量,一字之差,现场数据就完全是另一个意义。
还需要注意,DLMS 协议存在两种接入模式:SNRM/UA 之后的 LN 模式(Logical Name)和 SN 模式(Short Name)。项目里首次对接,我一般直接选 LN 模式,因为对象引用直接用 OBIS 码,可读性好;SN 模式省流量,但变量名被压缩成短编号,通信链路出问题时排查成本高得多。如果在 Gazelle 一致性测试中看到“Application Context Name”报错,多半就是 LN/SN 模式或版本号不匹配。
2.3 HDLC 帧格式与链路建立:从 SNRM/UA 到 AARQ/AARE
HDLC 层在 DLMS 里的作用,是把 DLMS APDU 包装成带标志、地址、控制、长度、校验的帧。一个典型帧结构包括:起始标志 0x7E、帧格式/地址字段、控制字段、长度字段、HCS 头校验、数据部分、FCS 帧校验、结束标志 0x7E。注意 HDLC 帧在 DLMS 里地址字段可能占 1 到 2 个字节,具体看帧格式字节里地址长度位;这个细节最常见的坑,后面避坑章会单独写。
链路建立分两个阶段。第一阶段是数据链路层握手:客户端发 SNRM,表端回 UA;第二阶段才是应用层初始化:客户端发 AARQ(Application Association Request),表端回 AARE。很多新手在串口助手看到 UA 帧就觉得链路通了,然后直接发读请求,结果表端毫无响应。原因就是缺少 AARQ/AARE 这个过程,应用层关联还没建立,表端不接受读请求。
我用一个表格把这幾個阶段和对应帧整理一下,方便对照排查:
| 阶段 | 发起方 | 帧类型 | 用途 | 失败时常见表现 |
|---|---|---|---|---|
| 链路建立 | 客户端 | SNRM | 协商 HDLC 参数 | 无响应或返回 DM |
| 链路确认 | 服务端 | UA | 接受链路参数 | 客户端一直重发 SNRM |
| 应用关联 | 客户端 | AARQ | 协商应用上下文 | 表端返回 AARE 错误码 |
| 应用确认 | 服务端 | AARE | 接受应用关联 | 读请求无响应 |
| 数据交换 | 客户端 | READ/WRITE | 读写对象属性 | 返回 DATA_ACCESS_ERROR |
3. 从文档到源码:读懂蓝色书源码包的结构,并把它变成可编译的协议栈
3.1 文档与源码的关系:标准文档才是主心骨
很多同行拿到 DLMS/COSEM 标准文档和所谓“软件实现源码”后,第一反应是打开源码就开始找 main 函数。这是个容易走偏的做法。蓝色书这类文档,核心价值在规范,源码只是将规范固化为可执行代码的参考实现;如果只看代码不看规范,遇到代码里看起来“不合理”的逻辑,你根本判断不了是代码的问题还是自己理解不到位。
我一般会先查三部分:第一部分是 DLMS/COSEM 应用层规范,重点看对象模型、OBIS 码分配和 APDU 结构;第二部分是 HDLC 数据链路层规范,重点看帧格式、地址字段长度、HCS/FCS 算法;第三部分是配套的源码包,看标准代码的组织方式。把文档中的帧格式图和源码里的结构体定义对照着看,比单啃任何一边都快得多。
从标准文档和源码包的目录看,常见做法会按层级组织源码,类似这样:
# 解压后先看整体目录结构(常见做法) find . -maxdepth 3 -type f \( -name "*.c" -o -name "*.h" -o -name "*.pdf" \) | sort | head -60这个命令能把源码文件和 PDF 文档都列出来,方便你快速确认“这份文档对应的规范版本”。我在看一个协议实现时,第一步永远先确认规范版本。DLMS/COSEM 的版本变更多轮,不同版本在 A-XDR 编码细节、对象属性定义上有差异;如果文档和源码不是同一版本,移植时遇到字符编码不对、属性编号变化,会让人怀疑人生。
3.2 源码结构剖析:帧处理、对象管理、应用层三个模块怎么分工
一个可用的 DLMS/COSEM 协议栈,源码层面通常能拆出三块:HDLC 帧收发模块、COSEM 对象管理模块、DLMS 应用层编解码模块。理解这种分工,对裁剪和移植极其重要。
/* * 示意:HDLC 帧收发的关键入口 * 路径通常是 src/hdlc/hdlc_framer.c */ int hdlc_frame_send(uint8_t *buf, uint16_t len) { /* 组装 HDLC 帧头 */ buf[0] = 0x7E; /* 起始标志 */ buf[1] = (frame_format_byte); /* 帧格式,含地址长度位 */ buf[2] = dest_addr; /* 目的地址,可能是双字节 */ buf[3] = control_field; /* 0x08 表示 SNRM,0x10 表示 UA */ buf[4] = (uint8_t)(len & 0xFF); /* 长度字段低字节 */ buf[5] = (uint8_t)((len >> 8) & 0xFF); /* 长度字段高字节 */ /* 按规范计算 HCS,填入 buf[6..7] */ uint16_t hcs = hcs_calc(buf, 6); buf[6] = (uint8_t)(hcs >> 8); buf[7] = (uint8_t)(hcs & 0xFF); /* 拷贝应用层数据 */ memcpy(&buf[8], payload, len); /* 计算 FCS 并追加,最后加结束标志 */ return frame_len; }这段代码的逻辑说明:帧头组装完成后,HCS 只覆盖帧头部分,FCS 覆盖数据段;长度字段是可变长度的,不同规范版本对长度字段的字节数和位宽处理不完全相同,移植时要以你手上的版本为准。参数说明里最需要关注的是dest_addr和帧格式字节——HDLC 地址字段长度由帧格式字节决定,默认可能是 1 字节也可能是 2 字节,需要根据从站设备地址位数来配置。
对象管理模块这边,主要是构建一个对象表:
/* 示意:COSEM 对象表注册 */ typedef struct { uint16_t class_id; /* 如 1 表示数据,3 表示寄存器 */ uint8_t obis[6]; /* OBIS 码,如 {1,0,1,8,0,255} */ uint8_t attr_id; /* 属性 ID,2 表示当前值 */ void *data_ptr; /* 指向实际存储的数据 */ } cosem_obj_t; const cosem_obj_t object_table[] = { { 1, {1,0,1,8,0,255}, 2, &total_energy }, { 3, {1,0,1,8,0,255}, 2, &total_energy_register }, /* 按项目实际数据点继续追加 */ };这份代码的要点是 class_id 和 OBIS 码必须和文档定义一致,否则读请求来了,表端匹配不到对象会直接报“对象不存在”。另一个注意点是 attr_id 配合属性含义使用,读取当前值和读取上次冻结值,属性 ID 不同。
3.3 把源码装进 STM32 这类 MCU:裁剪与编译参数调整
源码如果是在 PC 上跑的,移植到 STM32 这类嵌入式平台时,会碰到几个绕不开的改造点。第一是内存分配。协议栈内部可能用到 malloc,但 MCU 工程里我一般会把 malloc 替换成静态缓冲池,或者直接改成一个固定大小的接收缓冲区,按最大帧长一次分配到位。
第二是字节序。DLMS 的 A-XDR 编码和 HDLC 长度字段都是大端序;在 little-endian 的平台上,数值类型在网络字节序和本机字节序之间的转换必须显式处理。很多移植后通信时好时坏的问题,最后都追溯到“多字节目长度字段被按小端解析”。
第三是串口驱动接口。源码包里的 serial_driver 可能调用了操作系统抽象层,嵌入式里直接把发送和接收函数替换成串口中断/轮询版本即可。我常用的做法是保留协议栈的接口签名,在底层写两个适配函数:
/* 示意:串口驱动适配层 */ int serial_send(uint8_t *data, uint16_t len) { HAL_UART_Transmit(&huart1, data, len, 0xFFFF); /* 如果是 RS-485,这里需要先拉高 DE 方向脚,发送完毕后延时再拉低 */ return len; } int serial_recv(uint8_t *data, uint16_t len) { /* 在中断或状态机里调用协议栈的 hdlc_input_byte() */ return rx_len; }参数说明中要特别注意 RS-485 的方向切换时机。发送完最后一字节后,不能立刻把 DE 拉低,因为移位寄存器可能还在输出;一般需要按波特率补一个字节时间再切方向,否则最后一个字节会被截断,表端收到的帧 FCS 校验不过,表现就是客户端迟迟等不到 UA。
4. 移植后第一件事:跑通“读电表总电量”的最小链路
4.1 硬件准备与串口参数配置
协议栈移植完,先不要急着接真表。我会先用一块 RS-485 转 USB 工具连接 PC 与开发板,再把开发板的 A/B 线接到电表的 RS-485 端口,构成一个最简单的三条设备链路。电表 RS-485 通常不支持即插即用,参数必须严格按表端规格配置:典型波特率是 2400、9600、38400,数据位 8 位,无校验,1 位停止位。注意电表这边地址默认值通常不是 1,而是厂家出厂时设置的从站地址,比如 0x93 或 0x01,这个值和 HDLC 帧里的地址字段直接相关。
如果从站地址拿不准,可以先发一个广播式的链路请求,或从电表铭牌标签上找“通信地址”字段。我踩过最浅的坑就是打开串口助手能收到表端定时主动上报的报文,但自己的请求却一直没响应——后来发现是 DSAP/SSAP 填错了,源服务访问点和服务端地址字段没有按规范设置。
配置代码示例:
/* 示意:串口和协议栈参数初始化 */ typedef struct { uint32_t baudrate; /* 9600 常用 */ uint8_t data_bits; /* 8 */ uint8_t stop_bits; /* 1 */ uint8_t parity; /* 0: 无 */ } serial_cfg_t; /* 协议栈参数 */ typedef struct { uint8_t client_addr; /* 客户端地址,通常 0x01 */ uint8_t server_addr; /* 表端地址,需与电表一致 */ uint8_t d_sap; /* 目的服务访问点 0x01 */ uint8_t s_sap; /* 源服务访问点 0x01 */ } dlms_cfg_t;逻辑说明:客户端地址是这台采集器自己的地址,服务端地址是目标电表的地址;服务访问点用于区分同一链路内的服务类型,抄表场景固定配置即可。大部分“链路不通”都是这里某个参数填得和表端出厂值不一致。
4.2 最小握手序列:SNRM、UA、AARQ、AARE
链路测通与否,我习惯分成两个阶段验证。先用 PC 串口工具发几个字节的 HDLC SNRM 帧,看是否返回 UA;再进到应用层,发 AARQ 拿 AARE 响应。每一层成功后再往下走,能大幅缩小排障范围。
SNRM 帧的组装要点:帧格式字节置位地址长度位之后,地址字段填表端地址,控制字段填 0x08。如果表端返回 DM(Disconnect Mode),说明链路参数不被接受,通常是帧格式字节里的协商参数和表端不匹配;如果什么都收不到,先查物理层 TTL 电平方向有没有接反。
完成链路层握手后,应用层发起 AARQ。AARQ 的关键字段是 application_context_name,例如 LN 模式关联时填 0x01 0x00 0x00 0x01。表端返回 AARE 里如果带错误码,常见的是“应用上下文不支持”,此时去查协议版本号是否对齐。这个阶段我在 Gazelle 测试和真实表计上都遇过连续翻车,最后都是因为协商上下文名不匹配。
4.3 组装读请求并解析读响应
链路完整建立后,读数据就相对机械了。读取正向有功总电量的 APDU 里,请求类型是读请求,变量访问说明指向 class_id=1、OBIS=1.0.1.8.0.255、attribute_id=2。
/* 示意:组装读请求并调用协议栈发送 */ uint8_t apdu[64]; uint16_t apdu_len = build_read_request( apdu, 1, /* class_id: 数据类 */ (uint8_t[]){1,0,1,8,0,255}, /* OBIS */ 2 /* attribute_id: 当前值 */ ); hdlc_send(apdu, apdu_len);这里的 build_read_request 内部会把对象引用按 A-XDR 规则编码。解析响应时,注意读响应里的数据类型标记,比如 0x06 表示 Octet String、0x10 表示 Unsigned、0x09 表示 Float;电量数据通常携带一个缩放的整数,后面还可能跟一个 scale 字段。我一般先把响应帧打印成 hex,再逐字节对照规范解析,而不是直接依赖解析库,因为不同表厂对电量小数位的处理并不一致。
验证成功的标志很明确:AARE 已确认,读响应帧里能找到 1.0.1.8.0.255 对应的数据值,而且和电表表盘显示一致。到这一步,这个项目的协议栈打通才算数,接下来才是处理各种花式异常。
5. 避坑与排查:DLMS/HDLC 联调的 5 个高频问题
5.1 现象:串口工具能看到表端主动上报,但自己的请求总是石沉大海
原因分两类,一类是目的地址/源地址字段填错,HDLC 帧地址字段 1 字节还是 2 字节没和表端对齐;另一类是 RS-485 半双工方向切换时机不对,发送完后立刻切接收,最后一个字节被收发器吞掉。
解决:先抓一帧表端主动上报的报文,从帧头里反推地址字段长度和顺序;再在示波器上观察 DE 方向脚,确认发送结束后再拉低。如果是半双工切换问题,在发送函数的末尾加上一个字节宽度的延时。这个坑在 38400 波特率下尤其明显,9600 波特率下不容易暴露,换高速率就翻车。
5.2 现象:SNRM 有响应,AARQ 发出去后等不到 AARE
原因:应用上下文名或协议版本号不匹配;也可能 AARQ 里的 conformance block 声明了表端不支持的属性,比如你声明支持 UDP-Wrapper,但表端固件只支持 HDLC 本地端口。这是典型的“链路层没问题,应用层握手失败”。
解决:把 AARQ 的上下文名改成 1.0.0.1(LN 模式)再试;如果还不行,把 conformance block 里的功能位逐个减少,每次只保留最基本的 read/write。用 Gazelle 测试时,这类问题最常出现在“服务端不支持某个服务”这一类用例里。
5.3 现象:读响应能收到,但解析出的电量数值大得离谱或为负数
原因:A-XDR 编码里整型是大端序,而 MCU 端用的可能是小端解析;也可能是数据对象返回的是一个带缩放因子的整数,直接当原始值用了。比如表端返回 12345 和 scale=-1,实际值应该是 1234.5。
解决:统一用网络字节序转换函数读取多字节整型;解析响应时先看数据类型标记,再根据数据类型决定是否查找后续的 scale 字段。这个坑在换表厂或换协议栈版本时会周期性复发,因为部分表计固件对数据类型标记的处理不符合规范,必须做兼容。
5.4 现象:运行一段时间后,集中器和电表之间出现“谁也发不了数据”的假死状态
原因:HDLC 的接收状态机收到了一个残缺帧,标志字段 0x7E 之后的数据不完整,FCS 校验失败;协议栈实现里没有对错误帧做复位处理,导致后续所有字节都被当作“帧中间数据”而丢弃。这也是嵌入式协议栈常见的状态机健壮性问题。
解决:在接收状态机里增加超时和超长保护,只要超过最大帧长或 FCS 校验失败,立即重置接收状态机,等待下一个 0x7E。这类“玄学”问题现场排查成本高,最好在移植时就把帧状态机做成可注入错误帧的测试用例。
5.5 现象:主动上报事件数据和集中器定时抄表相互冲突,事件数据丢失
原因:DLMS 应用层关联的并发控制没做好。集中器定时发起读请求的同时,表端恰好在推事件上报,两条 APDU 在 HDLC 层交错,帧序列号错乱,表端会把其中一帧当作非法帧丢弃。
解决:在应用层建立互斥锁,同一时刻只允许一个逻辑链路发起请求;主动上报优先级低一些,上报请求先在本地缓存,等待当前请求完成后再发送。协议栈不是只管编解码就行,调度层这一把关不做好,现场就会间歇性丢数据。
6. 进阶用法:用抓帧脚本和一致性测试把协议栈调到“可交付”
协议栈跑通基础读写后,不能急着竣工,还要做正确性验证和异常边界验证。我的习惯是先写一个小工具,从串口抓原始字节,然后按 HDLC 帧格式把每一帧拆开,在 PC 上做二次校验,专门用来和协议栈内部的分析结果对照。
# 示意:从串口抓帧并按 HDLC 帧格式拆分 import serial def parse_hdlc_frame(buf): assert buf[0] == 0x7E # 高位字节是帧格式,bit4 表示地址长度 frame_fmt = buf[1] addr_len = 1 if (frame_fmt & 0x10) == 0 else 2 addr = buf[2:2 + addr_len] ctrl = buf[2 + addr_len] # 长度字段在控制字段之后,可能是 1 字节或 2 字节 info_len = buf[3 + addr_len] info_start = 4 + addr_len # 校验 HCS 和 FCS 之后,返回应用层数据 return addr, ctrl, buf[info_start:info_start + info_len] ser = serial.Serial('COM23', 9600, timeout=2) raw = ser.read(256) try: addr, ctrl, data = parse_hdlc_frame(raw) print(f"addr={addr.hex()} ctrl=0x{ctrl:02X} data_len={len(data)}") except Exception as e: print(f"bad frame: {e}")这段脚本的思路是:把“链路层帧格式对不对”和“应用层数据对不对”彻底分开。脚本跑完,如果帧校验都能通过,那就要去协议栈上层检查 APDU 编码;反过来,如果帧格式都拆不齐,说明问题在 HDLC 层。这样做排障效率比在嵌入式里打断点高得多。
另一个值得投入的方向是用 Gazelle 这类一致性测试工具做验证。有些项目甲方会明确要求协议栈通过一致性测试;就算不要求,我也建议把测试里的基本关联、读取、写入、事件上报用例跑一遍。这类测试很容易暴露一些平时联调发现不了的问题,比如服务和对象访问权限的边界、非预期长度的帧。
最后说一个我个人保留的习惯:每次改完协议栈代码,一定会重新抓一遍 SNRM 到读响应的完整流程,把所有帧的地址字段位宽、长度字段字节序、AARQ 上下文名都过一遍,再进下一步。这样做确实费时间,但能省掉以后在现场通宵排查的精力。协议栈这东西,程序跑通不代表正确,只有抓帧审视过,才敢说有了底。希望帮到你。
本文还有配套的精品资源,点击获取