简介DLMS协议库.rar 面向智能电表、水表通信开发及协议移植人员提供一套精简的DLMS协议实现代码用于理解对象模型、报文交互与通信流程并服务于嵌入式项目中的协议对接与一致性验证。压缩包共2个文件、约41KB由1个C源文件和1个头文件组成分别承载接口声明与核心实现体积虽小但便于查阅结构、裁剪逻辑和对照调试。相关代码已在欧盟及东南亚多个国家的电表、水表中批量应用并通过CCT测试、取得DLMS证书同时满足MID、KEMA认证和最新CTT测试规范可作为认证准备与协议测试的参考样本。其内容覆盖DLMS协会蓝皮书、绿皮书、黄皮书所关注的核心机制适合开发者结合规范梳理服务接口、数据对象与通信状态。目前已有1107人学习适合需要快速熟悉DLMS框架、积累协议实现经验的中高级嵌入式开发者收藏研读。1. 一台电表的应答为什么“时有时无”DLMS 协议库能替你做什么现场联调最让人摸不着头脑的一幕就是同一块表、同一根 485 线上一条命令回了下一条就没影。抓包看着字节都对程序却解不出电量——十有八九是 DLMS 帧里的 FCS 算错、转义漏了或者 OBIS 寻址少了一个字节。这份 DLMS 协议库处理的就是这种事把 HDLC 成帧、A-XDR 编解码、COSEM 对象寻址、关联认证这些重复劳动封装成一组 C 接口你只需要调用一个“读属性”的函数。它不适合想研究协议规范的人适合已经拿着表、要在一两周内把采集程序跑起来的一线开发者从完整性看算得上值得珍藏的通信协议库。2. 协议栈定位与技术选型DLMS 库在抄表链路里的边界在哪里读源码前先得把边界划清楚否则会写出一堆“看起来能跑、换个表就失灵”的代码。DLMS/COSEM 是一套分层的计量通信协议从上到下依次是应用层COSEM 对象与 APDU、表示层A-XDR 编码、链路层HDLC 或 Wrapper再往下才是 RS-485、红外、TCP 这类物理通道。这份库覆盖的是前三层第四层要你自己按实际硬件去接线。2.1 分层一条命令要穿过四层以最典型的“读正向有功总电能”为例一次成功交互在协议上的实际路径是这样的应用层先确定要读哪个对象也就是 class_id3Register、instance_id1.0.1.8.0.255、attribute_id2value表示层把这三个字段按 A-XDR 打包成 Get-Request APDU链路层再给它套上 HDLC 帧头、地址、控制字、HCS/FCS中间还要插一段 LLC 头通常是E6 E6 00告诉对端“上层是 xDLMS”最后串口把这些字节一个个推出去。很多新手在这里出错是因为直接把 APDU 往串口塞忘了 LLC 和 HDLC 两段。对端收到后它按 HDLC 解析会因为控制字节对不上而直接丢帧现象就是“发出去了、没回应、也不报错”。库的价值就在这dlms_client_*这一层调完帧的拼装、转义、校验、重发、超时全都是内部做完的。分层还有个实际好处是换通道不用改业务代码。同一套 COSEM 对象访问逻辑底下从 HDLC 换成 WrapperTCP 上跑 DLMS多为集中器与主站之间的场景上层调用几乎不变只是 link 结构体里的 type 字段换一下端口号和地址填法跟着变。2.2 解包之后目录结构与模块职责压缩包解开目录一般呈现为“源码 例程 文档 移植层”四大块的结构我按职责列一遍方便你按图索骥去找要改的地方dlms/ ├── src/ │ ├── hdlc.c / hdlc.h HDLC 成帧、FCS、转义、地址与控制字 │ ├── wrapper.c TCP/UDP 上的 Wrapper 头封装 │ ├── axdr.c / axdr.h A-XDR 基本类型编解码与长度前缀 │ ├── apdu.c Get/Set/Action 请求应答的组装与解析 │ ├── cosem.c COSEM 类表Register/Profile/Clock 等 │ ├── obis.c OBIS 六字节与压缩形式的互转 │ ├── association.c SNRM/AARQ/AARE/Release 会话管理 │ ├── security.c LLS 口令与 HLS 挑战应答 │ └── client.c / server.c 客户端主流程 / 从站应答桩 ├── port/ │ ├── port_serial.c 串口收发与波特率切换 │ └── port_timer.c 超时与重发计时 ├── examples/ │ ├── read_register.c 最小读表例程 │ └── virtual_meter.c 虚拟从站离线自测用 └── docs/ 帧格式说明与对象清单模块主要数据结构职责改动频率hdlchdlc_ctx_t帧同步、FCS、字节转义、分段低axdrdlms_value_t类型标签分派、大端取值中扩类型时cosem/obiscosem_obj_t对象寻址与属性映射高换表就要改associationdlms_client_t建链、认证、释放低port函数指针收发与计时高换板子就要改这张表是我判断“该不该动它”的依据hdlc和axdr属于协议骨架除非你对端设备真的实现了私有扩展否则不建议改动cosem/obis和port才是日常要动的地方。我把这条经验总结成一句话骨架不动两头改。2.3 编译链与依赖纯 C 的取舍库本身基本不依赖第三方常见做法是只使用 C99 标准库的stdint.h、string.h、stdlib.h把内存分配做成可配置——要么用静态数组池要么注册malloc/free钩子。这个取舍对嵌入式很重要很多计量 MCU 的 RAM 只有几十 KB动态分配一多就碎片化跑几天就崩。我在板子上一般把接收缓冲开成 512 字节的静态数组发送缓冲 256 字节够跑单帧 I 帧和普通的 Get-Response。编译有两种常用方式# 方式一直接编静态库主程序链接 gcc -c src/*.c -Iinclude -DPORT_LINUX -O2 ar rcs libdlms.a *.o gcc examples/read_register.c -Iinclude -L. -ldlms -o read_register # 方式二带调试信息跑虚拟从站先把协议跑通再接硬件 gcc -g -O0 src/*.c examples/virtual_meter.c port/port_serial.c -Iinclude -o vmeter-DPORT_LINUX这类宏是给port层做条件编译用的Linux 下走 termios裸机上走寄存器。你要移植到新平台第一步就是port_serial.c里那两个函数——serial_open()和serial_write()把它实现完剩下的协议代码几乎不需要改动。这也是我比较看重这个库的原因移植点集中不用在几千行代码里找依赖。3. 核心链路拆解一条 Get-Request 从组帧到取值这一章是重点按“帧 → APDU → 对象 → 会话”的顺序走一遍每一步都落到代码上。如果你只想先跑通可以先跳到第 4 章回头再看这里。3.1 HDLC 成帧、转义与 FCS 校验HDLC 帧结构是固定套路起始标志0x7E接着帧格式字段、目的地址、源地址、控制字、头校验 HCS、LLC 头、APDU 载荷、帧校验 FCS、结束标志0x7E。其中 FCS 用的是 CRC-16/X.25多项式0x1021反序成0x8408初值0xFFFF结果取反后低字节先发。/* hdlc.c —— FCS 计算很多偶发丢帧就是这里移位方向写反了 */ static uint16_t hdlc_fcs16(const uint8_t *buf, uint16_t len) { uint16_t fcs 0xFFFF; while (len--) { fcs ^ *buf; for (int i 0; i 8; i) fcs (fcs 0x0001) ? ((fcs 1) ^ 0x8408) : (fcs 1); } return (uint16_t)(~fcs); /* 取反后低字节先发 */ } /* 转义0x7E、0x7D 和所有小于 0x20 的字节都要逃方法一律是 ^0x20 */ static uint16_t hdlc_escape(const uint8_t *in, uint16_t in_len, uint8_t *out) { uint16_t n 0; for (uint16_t i 0; i in_len; i) { if (in[i] 0x7E || in[i] 0x7D || in[i] 0x20) { out[n] 0x7D; out[n] (uint8_t)(in[i] ^ 0x20); } else { out[n] in[i]; } } return n; }hdlc_fcs16只有一个参数需要关注len是参与校验的字节数注意它不包括起始和结束标志但包括帧格式、地址、控制字和载荷。HCS 单独算一次覆盖范围只到控制字为止这是最容易漏的一步——漏了 HCS对端会在链路层直接把帧丢掉你的抓包工具看到的却是“帧完整”。hdlc_escape的两个条件不能少任何一个。只转义0x7E那么数据里恰好出现0x7D时接收端会把它当成转义引导符吞掉下一个字节表现为“长度对不上、偶发错帧”而不转义小于0x20的控制字符在某些串口驱动里会被当成流控或特殊字符吃掉。我曾在一个现场排查了三天最后发现是地址里有个0x0D没转义。组帧时还有个细节是长度字段的位宽帧格式字段0xA0表示用 2 字节表示帧长载荷短的话可以用 1 字节的0xA0变体。写的时候建议统一用 2 字节省得临界长度上出问题。3.2 APDU 与 A-XDR类型标签决定怎么切字节APDU 是应用层报文Get-Request 的字节序列是C0 01 81 invoke-id 05 class-id instance-id attr-id 00。逐段解释一下C0是 Get-Request 的标签01表示 normal普通读区别于02的 next 分块读81是 invoke-id-and-priority后面那一字节是递增的调用号用来把应答和请求对上05是 cosem-attribute-descriptor 的标签然后依次是类 ID、实例 ID、属性 ID最后的00表示不带选择性访问。实例 ID 这里有个坑完整的 OBIS 是六字节但当 A1、B0、F255 时可以压缩成两字节只保留 C 和 E比如 1.0.1.8.0.255 发出去就是01 08。我以前就是这样出错的——手动拼包时按六字节发对端理解成别的对象返回的应答看着正常数值却是另一个量测点。应答回来的是 A-XDR 编码的数据必须按类型标签分派解析/* axdr.c —— 按标签解数据标签错一位后面全乱 */ int axdr_decode(const uint8_t *in, uint16_t in_len, uint16_t *pos, dlms_value_t *out) { if (*pos in_len) return -1; uint8_t tag in[(*pos)]; switch (tag) { case 0x00: /* null */ out-type DLMS_NULL; return 0; case 0x06: /* double-long-unsigned4 字节大端 */ if (*pos 4 in_len) return -1; out-type DLMS_U32; out-u32 ((uint32_t)in[*pos] 24) | ((uint32_t)in[*pos 1] 16) | ((uint32_t)in[*pos 2] 8) | (uint32_t)in[*pos 3]; *pos 4; return 0; case 0x09: { /* octet-string长度前缀 内容 */ uint8_t len in[(*pos)]; if (*pos len in_len) return -1; out-type DLMS_OCTETS; out-octets.ptr in[*pos]; out-octets.len len; *pos len; return 0; } case 0x11: /* unsigned1 字节 */ out-type DLMS_U32; out-u32 in[(*pos)]; return 0; default: return -2; /* 未支持的类型交上层记账 */ } }函数签名里pos是读写游标这个设计很关键一个 Get-Response 可能带多个结果或者本身是个 array递归解的时候全靠游标推进不能靠返回指针。返回-2表示类型不认识这个分支千万别直接return 0当成功——我见过因为忽略它把 octet-string 当数字读最后电量显示成几亿度。还有一类常见编码是0x01array 和0x02structure这俩都是“长度前缀 N 个元素”数组元素本身又是完整的一段 A-XDR所以必须递归调用自己。写的时候记得对长度做上限保护否则对端发个长度 200、实际只有 3 字节的畸形帧你的解包会越界。3.3 OBIS 与 COSEMclass_id attribute_id 的寻址表COSEM 把每一类数据抽象成“类”每个对象由 OBIS 唯一定位属性下标决定读的是值、单位还是标度。实际项目里最常碰的几个类如下class_id类名典型 OBIS 实例常用属性1Data0.0.42.0.0.255设备逻辑名2 value3Register1.0.1.8.0.255正向有功总2 value, 3 scaler_unit4Extended Register1.0.1.8.1.2552 value, 5 capture_time7Profile Generic1.0.99.1.0.255负荷曲线2 buffer, 7 entries8Clock0.0.1.0.0.2552 time读 Register 拿到的不只是一个数属性 3 里的scaler_unit是个 structure里面第一个是 scale整数第二个是 unit枚举。真实电量 原始值 × 10^scale。这一步不算结果就差着量级也是电量读出巨大数值的主要原因。Profile Generic 的读法和普通属性不一样要用带选择性访问的 Get-Request指定起始索引和条数返回的是一个 array of structure。这里建议先读属性 7entries拿到总条数再分段读一次拉几百条会超过单帧长度被对端分成多帧你还得实现分块应答拼接。库里有dlms_get_profile()这类封装具体函数名各版本略有差异翻一下cosem.c里 Profile 那一段就知道。3.4 安全层LLS 与 HLS 往哪插会话建立时AARQ 里的mechanism-name决定用哪种认证。最常见的是 LLS低等级安全本质就是把口令当 octet-string 塞进 calling-authentication-value实现成本几乎为零dlms_client_t cli; dlms_link_t link { .type DLMS_LINK_HDLC, .addr_client 0x10, /* 客户端地址抓包里的源地址 */ .addr_server 0x01, /* 表地址低地址先发 */ .baudrate 9600, /* 老表多是 9600新表常见 2400 或 9600 */ }; dlms_client_init(cli, link, port_serial_write, port_serial_read); dlms_client_set_lls(cli, 00000001); /* 对口令不是明文密码文件 */ int rc dlms_client_connect(cli); /* SNRM - UA - AARQ - AARE */ if (rc ! DLMS_OK) { printf(associate failed: %d\n, rc); /* 负值基本是链路正值多是认证 */ return rc; }dlms_client_set_lls的第二个参数要按对端口令的实际长度传别硬补零有些实现对长度为 0 的口令会直接拒绝。返回值约定建议自己统一负数表示链路层失败超时、FCS 错正数表示会话层失败认证失败、一致性块不满足这样排障时看符号就知道往哪层查。HLS高等级安全要复杂一些服务端先下发一段随机挑战stoc客户端用事先约定好的密钥按算法算出应答ctos回过去部分实现还要再多一轮。算法一般是 MD5、SHA-1 或 HMAC 系列密钥由主站侧下发。这一步要求你确认三件事缺一个都过不去密钥是不是按十六进制解出来的、挑战是几段、算法是哪一个。现场最常出现的情况是密钥正确、算法用错然后表现为“AARE 返回认证失败”却查不出原因。4. 跑起来从编译到用虚拟表验证第一帧协议库这类东西最怕一上来就接真表。我的习惯是先让它在没有硬件的环境下跑通一轮完整交互再去现场。4.1 环境准备与最小可运行工程Windows 上装个 gcc 环境就能编Linux 一条命令的事。编译前确认两件事include/目录加到搜索路径里port层选对平台宏。下面是最小可运行例程的主干#include dlms_client.h #include obis.h #define OBIS_ACTIVE_ENERGY 0x010800FFUL /* 1.0.1.8.0.255正向有功总 */ int main(void) { dlms_client_t cli; dlms_link_t link { DLMS_LINK_HDLC, 0x10, 0x01, 9600 }; if (dlms_client_init(cli, link, port_serial_write, port_serial_read) ! DLMS_OK) return -1; if (dlms_client_connect(cli) ! DLMS_OK) /* 建链 关联 */ return -2; uint32_t raw 0; int rc dlms_get_u32(cli, 3, OBIS_ACTIVE_ENERGY, 2, raw); if (rc DLMS_OK) { int8_t scale 0; dlms_get_scaler(cli, 3, OBIS_ACTIVE_ENERGY, scale); /* 读属性 3 */ printf(energy %.3f kWh\n, raw * pow(10, scale)); } dlms_client_disconnect(cli); /* DISC - DM */ return rc; }dlms_get_u32的五个参数分别是客户端句柄、class_id、OBIS 压缩值、attribute_id、输出缓冲。OBIS 那个宏是把 C 和 E 两字节拼成0x0108再补00FF后缀读的时候按库里的obis_encode()反查一下更稳妥别自己手拼。编译跑起来如果串口没接东西会卡在dlms_client_connect里等到超时。想先验证逻辑把port_serial_write/read换成虚拟从站的收发函数即可见 4.3。4.2 建立关联SNRM、AARQ 与一致性块一次完整的会话建立是四步发 SNRM 协商链路参数最大信息长度、窗口大小收到 UA 后链路才算通接着发 AARQ 提出应用层关联里面带 xDLMS-InitiateRequest声明自己要用的服务对端回 AARE里面有个 conformance block告诉你它支持哪些服务。一致性块conformance常被忽略但它决定你能不能读某些对象。比如你想用get的 multiple references 一次读多个属性位没协商上AARE 表面成功后续请求全被拒。我在代码里一般加一段自检uint32_t conf dlms_client_conformance(cli); if (!(conf DLMS_CONF_GET)) /* 连最基本的 get 都没协商上 */ return -3; if ((conf DLMS_CONF_MULTIPLE_REFS) 0) printf(warn: multiple references unsupported, fallback to single get\n);DLMS_CONF_*这组宏对应一致性块里的各个位具体掩码位置照库里头文件抄别自己数位数。这段自检的用处是把“后面莫名失败”提前暴露成“建链时就知道能力不足”省掉大量猜谜时间。此外 SNRM 里协商的最大信息长度也要留意。默认值太小有些实现默认 128你会频繁遇到一帧装不下、被拆成多帧的情况而分块重组是另一套逻辑。把dlms_client_set_max_info_len(cli, 512)显式设一下多数表的支持都不成问题。4.3 用虚拟从站做回环验证包里通常带一个virtual_meter.c它扮演从站在你本机内存里造几个 COSEM 对象收发走本地回环或者命名管道。用法是把客户端和它编到一个进程里一收一发直接对接跳过所有硬件# 虚拟从站与客户端跑在一个进程业务侧不用改一行 gcc -g src/*.c examples/virtual_meter.c examples/read_register.c -Iinclude -o loopback ./loopback --server-addr 1 --client-addr 16 --obis 1.0.1.8.0.255跑通的标准是日志里能看到四件事SNRM/UA 完成、AARQ/AARE 完成、Get-Response 里 result 字段是00成功、最终打印的电量数值合理。这四步任何一个没到就说明协议栈内部还有问题先别接硬件。虚拟从站还有个更实用的功能故意返回畸形数据。比如把某个对象的类型标签改成0x09却只发两字节看客户端会不会越界把 FCS 改错一位看重发机制是否按预期触发。这套破坏性测试做完再去现场会省下大把时间。4.4 移植串口与定时器两个钩子真正换板子做的时候要动的只有port层。串口那边要实现的是一个“收够一帧返回”的函数以及一个“按字节发”的函数。收的难点在帧边界建议用状态机等待0x7E进入帧内遇到0x7D置转义标志遇到下一个0x7E结束同时用空闲超时兜底一帧内超过 20 ms 没有新字节就认为帧结束这比只靠长度判断稳。定时器那边给它一个毫秒级时间源就行dlms_client内部靠它算重发间隔和应答超时。我一般把应答超时设成 1200 ms、重发 2 次485 上跑 9600 波特率时这个值比较稳红外口慢得放宽到 3000 ms。这两个参数在dlms_client_set_timing()里改别去改内部宏。5. 避坑与排查联调阶段最费时间的五个问题下面这几条都是从实际现场整理出来的按现象、原因、解决三段写能对上号就别绕远路。5.1 链路层偶发丢帧与“半帧”现象连续读 100 次成功 80 多次失败的没有任何规律抓包工具显示帧完整对端就是不回。原因一是 FCS 计算范围错了把起始标志或者 LLC 头算进去了二是转义不完整数据里恰好出现0x7D被对端当成引导符三是半双工侧收发方向切换太快最后一字节还没走完就切到接收末位被削掉。解决把hdlc_fcs16单独写个单测用一组已知帧验证转义函数用全字节遍历测一遍0x00到0xFF都过一遍再拼帧方向切换加 1~2 ms 延时或者读串口的 TC发送完成标志而不是只等 TXE。现象一上电前几帧能通跑几分钟后彻底静默。原因接收缓冲没清零跨帧累积或者帧长度字段被上一帧残留影响游标越走越偏。解决每次收到起始标志就把游标和缓冲长度重置别依赖“上次收到的长度”。5.2 会话层认证过了却读不到数据现象AARE 返回成功紧接着的第一个 Get-Request 被拒返回的 result 是0x01hardware fault或0x03read-write denied。原因LLS 口令对上了但关联上下文里没有对应的访问权限有些表要求先读 Association LN 对象class 15里的object_list按里面的 access 权限来判还有一种是一直用同一个 invoke-id被对端判为重放。解决invoke-id 每次请求都递增回绕后从 1 重新开始确认关联时用的是不是正确的 association 对象公共客户端和读表客户端是两个不同的实例把 AARE 里返回的result-source-diagnostic打出来它能直接告诉你到底被拒在哪一步。现象建链时好时坏AARQ 经常超时。原因SNRM 的 UA 还没收完就开始发 AARQ两帧在同一段总线上撞了。解决严格串行收到 UA 再发 AARQ中间留 50 ms 间隔别图快。5.3 数据层电量读出天文数字现象读回来的值是4294967295或者一个几亿度的数。原因一是把 octet-string 当数字解了四字节内容拼出来正好是上限值二是没乘 scaler原始值直接当 kWh 用三是字节序搞反大端值按小端读。解决先看类型标签再取数0x06才能按 U32 解0x09一律按缓冲区处理scaler 一定要读属性 3 并参与计算大端小端的判断写个已知值比如0x01020304的自测用例别猜。现象负荷曲线读出来条数是 0或者一堆 0x00。原因Profile Generic 要用选择性访问直接读属性 2 拿到的是空 buffer或者读的起始索引从 0 开始而协议里是从 1 开始。解决先读属性 7entries确认条数再用带 from/to entry 的选择性访问去拉索引从 1 起算。6. 二次开发把库接到自己的采集程序里直接改库源码是最不明智的做法我一般是在外面包一层采集适配把“表参数、重试策略、数据落地”从协议层剥离开。这样换表、换协议版本时只改适配层。6.1 加一层采集适配适配层的核心是一张点位表加一个批量读函数。点位表描述“要读什么、怎么换算、存到哪”批量读函数负责串行调度和失败重试typedef struct { uint8_t class_id; uint32_t obis; /* 压缩后的 OBIS按库的 obis_encode() 生成 */ uint8_t attr_id; int8_t scaler; /* 从属性 3 读回来的缓存住免得每次重读 */ uint32_t last_val; uint8_t fail_cnt; } meter_point_t; int collect_once(dlms_client_t *cli, meter_point_t *pts, int n) { int ok 0; for (int i 0; i n; i) { uint32_t v 0; int rc dlms_get_u32(cli, pts[i].class_id, pts[i].obis, pts[i].attr_id, v); if (rc DLMS_OK) { pts[i].last_val v; pts[i].fail_cnt 0; ok; } else if (pts[i].fail_cnt 3) { dlms_client_disconnect(cli); /* 连续失败就重建会话 */ dlms_client_connect(cli); /* 比死等超时恢复得快 */ } } return ok; }fail_cnt这个计数的意义在于区分“偶发丢帧”和“链路真断了”。前两三次失败只管重试累计到三次就重建会话——现场经验是绝大多数通信卡死靠一次断链重连能救回来比在超时里反复等效率高得多。改造时还有两个点值得做一是把dlms_get_scaler的结果缓存进点位表这个值基本不变每次重读白白多一轮交互二是把读到的原始值和采集时间一起落库出了问题能回溯是哪一轮的数据异常而不是只看到一个错误数值。6.2 上线前的验证清单验证项方法不通过的典型表现FCS 与转义单测遍历0x00~0xFF拼帧再解偶发丢帧、对端不回会话建立连续建链释放 200 次中途 AARQ 超时断线恢复拔插 485 线 20 次恢复后一直超时类型健壮性喂畸形标签与超长长度域内存越界、数据错乱数值正确性与表计面板读数对拍差 10^n 倍或字节序反这张表我每次交付前都跑一遍尤其第三项。链路恢复后客户端内部状态没重置是这类库最常见的问题多半要在断链回调里把 context 整个清一遍而不是只关串口。释放内存方面也要注意A-XDR 解出来的 octet-string 一般是指向接收缓冲的切片不拥有内存别去free它。从那以后我每次接新表都强制先跑一遍“单点读 断链重连”的快速验证再去纠结数据准不准。协议这东西能通和能用是两件事先把通的路径固定下来剩下的都是细枝末节。希望帮到你。本文还有配套的精品资源点击获取