简介:面向智能电表、水表及计量终端开发者的DLMS通信协议实现库,适合需要理解协议栈落地、参与认证测试或进行嵌入式移植的初中级工程师参考。压缩包共2个文件,以一个C源文件和一个头文件承载核心实现,整体约41KB,体量轻便,便于快速阅读结构并集成到现有工程中。已有1107人学习下载。其代码已在欧盟及东南亚多国电表、水表项目中批量应用,并通过CCT软件测试取得DLMS证书,同时通过MID与KEMA认证,符合最新CTT测试规范,覆盖DLMS协会蓝皮书、绿皮书与黄皮书的相关要求。读者可从中梳理协议交互、对象模型与认证测试要点,借鉴成熟项目的实现思路,用于通信模块开发、问题排查与协议裁剪。
1. 一块表抄不上数的那晚:DLMS 协议库到底解决什么问题
凌晨两点,一块三相多功能表挂在测试网口上,TCP 连得上,帧也发得出去,对端硬是一个字节都不回。抓包看了半小时才发现,FCS 算成了 CRC-16/CCITT,而这条链路要的是 CRC-16/X.25,两个校验只差一次取反和一次高低字节顺序。这就是我拆这份 DLMS 协议库的起因,也是我后来愿意把它留在工具箱里的原因。
DLMS/COSEM 这套东西,反直觉的地方在于:难点从来不是“把帧发出去”,而是 A-XDR 的类型标签、HDLC 的 FCS 与转义、HLS 的挑战应答、AES-GCM 的帧计数,任何一处差一个字节,对端都不会给你报错,只会沉默。这份通信协议库的价值,就是把这些沉默的错误收敛成一套分层的 API,让你写业务逻辑时只关心 OBIS 码和对象属性。
它适合谁?做用电信息采集终端、能源网关、充电桩计量、水气表集抄的开发者。你不需要从零啃 IEC 62056 那几千页,拿着这份库,先跑通一条 GET,再回头补协议细节,效率差好几倍。
2. 目录结构与技术选型:一份协议库的骨架长什么样
在写第一行业务代码之前,我先把这个库的目录整个看了一遍。判断一份协议库值不值得用,我的标准很土:看它有没有把“协议层”和“移植层”切开。切开意味着你换 MCU、换模组、换 RTOS 时不用动协议代码;没切开,那就是个跑在特定板子上的 Demo。
2.1 四层模型:库为什么按这个顺序切分
DLMS/COSEM 的协议栈天然是四段,库的目录基本就是照这个切:
| 层 | 职责 | 对应目录 | 典型文件 |
|---|---|---|---|
| 应用层 | xDLMS 服务(GET/SET/ACTION/EventNotification)、ACSE 关联 | src/app | xdlms.c、acse.c |
| COSEM 对象层 | 对象类模型、OBIS 寻址、属性访问 | src/cosem | objects/*.c |
| 安全层 | HLS 认证、AES-GCM 加解密、密钥管理 | src/sec | hls.c、gcm.c |
| 链路层 | HDLC 分帧、Wrapper(TCP/UDP) 封装 | src/link | hdlc.c、wrapper.c |
| 编码层 | A-XDR 序列化/反序列化 | src/core | axdr.c、buffer.c |
| 移植层 | 串口、Socket、定时器、CRC 硬件加速 | port | port_posix.c、port_baremetal.c |
这么切的直接好处是:把库从 Linux 挪到裸机,你只需要实现port/port_baremetal.c里那五六个函数(读、写、毫秒计时、互斥、随机数),协议主体一行不改。我见过不少自己攒的协议栈,串口读写直接写死在xdlms_send()里,换个模组就得全局搜索替换,那种就是血泪经验的开端。
2.2 目录结构:每个目录对应哪一层
解压后大致是这么一棵树:
dlms/ ├── include/ │ ├── dlms.h # 顶层客户端/服务端句柄与总入口 │ ├── axdr.h # A-XDR 编解码 │ ├── hdlc.h # HDLC 帧结构与收发 │ ├── wrapper.h # TCP/UDP 封装 │ ├── cosem.h # 对象类注册与属性访问 │ └── security.h # 安全上下文与密钥类型 ├── src/ │ ├── core/ # axdr.c buffer.c crc.c │ ├── link/ # hdlc.c wrapper.c │ ├── app/ # xdlms.c acse.c │ ├── cosem/objects/ # register.c profile_generic.c clock.c association_ln.c │ └── sec/ # hls.c gcm.c ├── port/ │ ├── port_posix.c # Linux/Windows 下用 socket + 串口 │ └── port_baremetal.c# 裸机模板,填空即可 ├── samples/ │ ├── client_tcp.c # 最小 TCP 客户端,先跑这个 │ └── server_meter.c # 模拟表端,用来对拷 └── CMakeLists.txtinclude/和src/是一一对应的,头文件只暴露句柄和函数指针,内部结构体藏在.c里。samples/server_meter.c这个文件很多人会忽略,但它其实是整份资源里最值钱的东西之一——没有真实表计的时候,你可以让客户端对着模拟服务端跑,把链路层和安全层先验证干净。
2.3 交叉编译与最小客户端跑通
构建用 CMake,Linux 下三步:
mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release -DDLMS_PORT=posix make -j4 # 产物:samples/dlms_client_tcp、samples/dlms_server_meter-DDLMS_PORT=posix决定编哪份移植文件,裸机上换成-DDLMS_PORT=baremetal并把工具链文件传进来。头文件路径由target_include_directories统一带出去,所以你在自己的工程里find_package或者直接add_subdirectory都行。
最小客户端的关键参数就那几个,源码里是这么用的:
/* samples/client_tcp.c —— 最小可运行客户端 */ dlms_client_t cli; dlms_config_t cfg = { .addr_type = DLMS_ADDR_HDLC_1BYTE, /* 一字节地址:客户端 0x10,表端 0x01 */ .client_addr = 0x10, .server_addr = 0x01, .wport_src = 0x0001, /* Wrapper 端口,双方约定 */ .wport_dst = 0x0001, .auth_level = DLMS_AUTH_HLS, .security = DLMS_SEC_NONE, /* 先关加密,只验认证,链路清楚了再开 */ .hls_hash = DLMS_HLS_SHA256, .system_title = {0x4D,0x45,0x54,0x45,0x52,0x30,0x30,0x30,0x31}, /* 8 字节 */ .secret = "public_secret", /* 现场换成真实密钥 */ .secret_len = 13, }; dlms_client_init(&cli, &cfg); if (dlms_connect(&cli, "192.168.1.100", 4059) != DLMS_OK) { printf("connect fail: %d\n", dlms_last_error(&cli)); return -1; }addr_type是第一个要确认的,HDLC 支持一字节、两字节、四字节地址,表端配的是几字节你就要给几字节,给错了就是“帧发出去了但没回应”的经典症状。wport_src/dst在公网侧常见 0x0001 对 0x0001,某些集抄系统里是 0x0010,这个必须跟对端文档核对。
security我建议先设DLMS_SEC_NONE。先把 HDLC 建链、A-XDR 解码、GET 一个0.0.42.0.0.255电量跑通,再往上叠加密。反过来做,一旦不通你根本分不清是链路错了还是密钥错了,两层的黑匣子叠在一起,排查成本翻三倍。
提示:
dlms_last_error()返回的是库内部错误码,不是对端返回的 DataAccessResult。要对端的结果,得读cli.result里的枚举。
3. 核心模块拆解:A-XDR 编码与 HDLC/Wrapper 帧的收发闭环
中间这一层是整份库最硬的部分。我把链路层和安全层分开看:链路层决定“字节怎么排、怎么切”,安全层决定“谁能读、读得懂不懂”。这一章拆链路和编码,下一章拆安全。
3.1 A-XDR:类型标签决定你读到的是 1234 还是 0x000004D2
DLMS 的数据不是裸的字节流,每个值前面都挂一个类型标签,这就是 A-XDR。最常踩的坑是有符号和无符号搞混:integer标签是0x11(有符号一字节)、long-unsigned是0x12(无符号两字节)、double-long-unsigned是0x06(无符号四字节)。电量这种累计量基本都是0x06,你用0x11去解,读出来是负数也别奇怪。
编码侧的写法:
/* src/core/axdr.c —— 写一个 double-long-unsigned */ int axdr_put_u32(uint8_t *buf, size_t cap, uint32_t v) { if (cap < 5) return -1; buf[0] = 0x06; /* tag: double-long-unsigned */ buf[1] = (v >> 24) & 0xFF; /* A-XDR 一律大端,别用 memcpy 直接怼 */ buf[2] = (v >> 16) & 0xFF; buf[3] = (v >> 8) & 0xFF; buf[4] = v & 0xFF; return 5; /* 返回写入字节数,调用方负责 buf 偏移 */ } /* 解码侧:按 tag 分派,返回消耗的字节数,<0 表示错误 */ int axdr_get_value(const uint8_t *buf, size_t len, dlms_value_t *out) { if (len < 1) return -1; out->tag = buf[0]; switch (buf[0]) { case 0x06: /* double-long-unsigned */ if (len < 5) return -2; out->u32 = ((uint32_t)buf[1] << 24) | ((uint32_t)buf[2] << 16) | ((uint32_t)buf[3] << 8) | (uint32_t)buf[4]; return 5; case 0x11: /* integer,注意强转有符号 */ if (len < 2) return -2; out->i32 = (int8_t)buf[1]; return 2; default: return -3; /* 类型不认识就赶紧退出去,别硬解 */ } }返回“消耗字节数”而不是“0/成功”,是这类库的标准做法。因为一个 GET 响应里往往一次带回好几个属性,你必须靠偏移往前推。如果函数只返回成功失败,调用方就得自己算长度,算错一次后面全乱。默认分支直接返回错误也很关键——A-XDR 的类型标签有几十个,不认识的标签说明要么是私有类型,要么是数据错位了,硬解只会把错误放大。
3.2 HDLC 分帧:FCS、转义与窗口
HDLC 帧的结构是固定的:标志位0x7E+ 格式域 + 目的地址 + 源地址 + 控制字节 + 头校验 HCS + 信息域 + 帧校验 FCS + 标志位0x7E。控制字节在库里用枚举区分:SNRM 建链、I 帧带 N(S)/N(R) 序号收发、RR 做接收就绪应答、DISC 断链。
FCS 是这个环节最容易翻车的地方,实现如下:
/* src/core/crc.c —— HDLC 用的 CRC-16/X.25 */ uint16_t dlms_fcs16(const uint8_t *p, size_t n) { uint16_t fcs = 0xFFFF; /* 初值 0xFFFF,不是 0x0000 */ while (n--) { fcs ^= *p++; for (int i = 0; i < 8; i++) { /* 0x8408 是 0x1021 的反序,右移实现 */ fcs = (fcs & 1) ? (fcs >> 1) ^ 0x8408 : (fcs >> 1); } } return (uint16_t)~fcs; /* 结果取反,发的时候低字节在前 */ }两个参数值得单独说:初值必须是0xFFFF,多项式用0x8408(反向的 CCITT),最后结果取反、低字节先发。这四条错一条,对端就是不理你。我见过有人用现成的CRC-16/MODBUS(初值 0xFFFF、多项式 0xA001)来算,多项式对了,但取反和字节序没对,照样不通。
转义在发送前做:
/* src/link/hdlc.c —— 0x7E/0x7D/0x7F 需要转义,避免和标志位撞车 */ size_t hdlc_stuff(const uint8_t *in, size_t n, uint8_t *out, size_t cap) { size_t o = 0; if (cap < n + 2) return 0; out[o++] = 0x7E; /* 起始标志 */ for (size_t i = 0; i < n; i++) { if (in[i] == 0x7E || in[i] == 0x7D || in[i] == 0x7F) { if (o + 2 >= cap) return 0; out[o++] = 0x7D; /* 转义符 */ out[o++] = in[i] ^ 0x20; } else { out[o++] = in[i]; } } out[o++] = 0x7E; /* 结束标志 */ return o; }^ 0x20这个操作把0x7E变成0x5E、0x7F变成0x5F。有些实现为了对称,连0x5E/0x5D/0x5F也一起转义,这属于兼容性写法,两边一致就不会出问题;两边不一致,就会出现“握手能过、传大数据必挂”的玄学现象,因为只有凑巧字节流里出现了被转义的字节才会触发。
3.3 Wrapper 通道:TCP 上的 8 字节头与粘包
走 TCP 的时候不需要 HDLC,改用 Wrapper。它就是在应用层 APDU 前面加 8 字节头:
/* src/link/wrapper.c —— Wrapper 头:版本 2B + 源端口 2B + 目的端口 2B + 长度 2B */ int wrapper_pack(uint8_t *buf, size_t cap, uint16_t src_wport, uint16_t dst_wport, const uint8_t *apdu, size_t apdu_len) { if (cap < 8 + apdu_len) return -1; buf[0] = 0x00; buf[1] = 0x01; /* version = 1 */ buf[2] = (src_wport >> 8) & 0xFF; buf[3] = src_wport & 0xFF; buf[4] = (dst_wport >> 8) & 0xFF; buf[5] = dst_wport & 0xFF; buf[6] = (apdu_len >> 8) & 0xFF; buf[7] = apdu_len & 0xFF; memcpy(buf + 8, apdu, apdu_len); return (int)(8 + apdu_len); }长度字段是关键。TCP 是字节流,一次recv()可能给你半帧,也可能给你两帧粘在一起。库里的收包逻辑是:先收 8 字节头,解析出length,再循环收到length个字节为止,这才是完整的一帧。不做这一步,跑几分钟没问题,长连接挂一晚上必错位——这是我在现场熬过一整晚才定位到的问题,后面避坑那章还会再提一次。
3.4 一次 GET 请求的完整调用链
把上面几块串起来,一次读电量的链路是这样的:
/* 1) 组 GET 请求:class_id=3(Register), obis=0.0.42.0.0.255, attr=2(值) */ dlms_get_request_t req = { .class_id = 3, .attr_id = 2, .obis = {0,0,42,0,0,255} }; uint8_t apdu[64]; int n = xdlms_build_get(&cli, &req, apdu, sizeof(apdu)); /* 出 Invoke-Id + 描述符 */ /* 2) 过安全层(未加密时原样透传),再过链路层 */ uint8_t frame[128]; int m = dlms_link_send(&cli, apdu, n, frame, sizeof(frame)); /* Wrapper 或 HDLC 都在这里分派 */ /* 3) 收响应,反向解一遍,拿到值 */ dlms_value_t val; if (xdlms_parse_get_response(&cli, rx_apdu, rx_len, &val) == DLMS_OK) { printf("A+ = %u (0.01kWh)\n", val.u32); /* 单位看 obis 对应的 scaler_unit */ }Invoke-Id是请求和响应的关联号,请求带 1,响应回来也必须是 1,库内部用一个自增计数器维护。断线重连后计数器要不要清零,取决于对端实现,保守做法是重连后从 1 重新开始。attr_id也要对:Register 的attr=2才是当前值,attr=3是 scaler_unit(单位和小数位),很多人只读 2 不读 3,结果电量少两位小数,还以为表计不准。
4. 安全会话:HLS 认证与 AES-GCM 加密的落地细节
链路层通了只是进门,真正的门槛在安全层。这块我在不同厂家的表上见过太多“参数填对了但不通”的情况,问题往往出在对“认证”和“加密”这两件事的混淆上——它们是两条独立的开关,可以只开一个。
4.1 三种关联等级与密钥分工
库里的关联等级是个枚举,实际上对应的是 DLMS 的三档:
| 等级 | 认证 | 加密 | 密钥用途 |
|---|---|---|---|
| Low | 有(HLS) | 无 | 只验身份,报文明文 |
| High | 有 | 有 | 全局密钥,所有帧加密+认证 |
| HighGMAC | 有 | 有 | 专用 GMAC 密钥,只认证部分帧 |
密钥一共三把:encryption_key(加密)、authentication_key(认证/GMAC)、master_key(KEK,用于密钥替换)。都是 16 字节 AES-128。现场最常见的错误是只配了secret没配encryption_key,结果认证过了,第一个加密帧就被对端丢弃——因为对端根本解不开。
4.2 HLS 挑战应答:四步握手与哈希选择
High Level Security 的流程是固定的四步:客户端发AARQ(带HLS机制标识和CtoS挑战)→ 服务端回AARE(带StoC挑战)→ 客户端算f(StoC)回ACTION→ 服务端验过回结果。
挑战应答的算法核心就一行哈希:
/* src/sec/hls.c —— 算 f(StoC),哈希强度的选择由关联表决定 */ int hls_build_response(uint8_t *out, const uint8_t *challenge, size_t challenge_len, /* 服务端下发 */ const uint8_t *system_title, /* 8 字节 SAS */ const uint8_t *secret, size_t secret_len, /* 口令/密钥 */ dlms_hls_hash_t hash) { uint8_t tmp[96]; size_t n = 0; memcpy(tmp + n, challenge, challenge_len); n += challenge_len; memcpy(tmp + n, system_title, 8); n += 8; switch (hash) { case DLMS_HLS_MD5: return md5(tmp, n, out); /* 16 字节,HLS=3 */ case DLMS_HLS_SHA1: return sha1(tmp, n, out); /* 20 字节,HLS=4 */ case DLMS_HLS_SHA256: return sha256(tmp, n, out); /* 32 字节,HLS=5 */ default: return -1; } }注意三件事。第一,system_title是 8 字节,长度错了整个哈希就废了,有些实现用 16 字节的system_title,那是 DLMS 的另一种变体,得看对端文档。第二,哈希选错了不会返回“算法不支持”,只会返回一个UserError,看起来像密钥错。第三,算完的摘要不是裸着发,要按 DLMS 的 octet-string 编码包一层再塞进ACTION,包错了长度字段,服务端一样只回一个失败。
4.3 AES-GCM 帧加密:IV 怎么拼,SC 字节怎么看
加密用的是 AES-128-GCM,标准做法是:IV 由system_title(8 字节)+frame_counter(4 字节)拼成 12 字节,frame_counter从 0 递增;附加认证数据(AAD)用安全控制字节 SC 加头信息;GMAC 用authentication_key算,密文用encryption_key算。
SC 字节是要读的,它告诉你这一帧到底做了什么:
/* src/sec/gcm.c —— 解帧前先看安全控制字节 */ int sec_decrypt(dlms_sec_ctx_t *ctx, uint8_t *apdu, size_t len) { uint8_t sc = apdu[0]; if (sc & 0x10) { /* 加密位:后面还跟着 frame_counter */ uint32_t fc = ((uint32_t)apdu[1] << 24) | ((uint32_t)apdu[2] << 16) | ((uint32_t)apdu[3] << 8) | (uint32_t)apdu[4]; if (fc <= ctx->last_fc) /* 帧计数必须严格递增,回绕=重放攻击 */ return -1; ctx->last_fc = fc; return aes_gcm_decrypt(ctx->enc_key, ctx->sys_title, fc, apdu + 5, len - 5, apdu + 5); } if (sc & 0x20) /* 只认证不加密 */ return aes_gmac_verify(ctx->auth_key, apdu, len); return 0; /* 明文,直接过 */ }last_fc这个检查是防重放的核心。有的实现为了“好用”把这个检查去掉,结果设备端一直拒绝——因为设备端在检查,客户端不检查只说明你自己不设防,不代表对端也放行。
4.4 密钥更换与掉线重连
密钥替换(Key Change)走ACTION,作用在安全建立对象上(class 64,OBIS0.0.43.0.0.255)。流程是用 KEK 加密新密钥,发过去,对端解密生效。这里最坑的是时序:替换完必须立刻更新本地上下文,否则下一帧还用旧密钥,直接被丢。库里的做法是替换成功后原地刷新ctx->enc_key/auth_key,同时把frame_counter重置为 0。
掉线重连则要清干净三样东西:last_fc、invoke_id计数器、HDLC 的N(S)/N(R)序号。少清一样,重连后的前几帧就会莫名其妙失败。这类问题最难查,因为现象是“偶发失败”,每次重启都能好一会儿,等你追到是计数器没清,已经熬了两天了。
5. 避坑与排查:联调时最容易翻车的五个点
这一章是我自己踩过的,也是带人时必讲的。五个坑,按“现象 → 原因 → 解决”写,遇到对不上的现象,先去这几条里对一遍,能省很多时间。
5.1 帧发出去了,对端一个字节不回
现象:串口抓包能看见完整的0x7E...0x7E,逻辑分析仪上电平也对,表端静默,连个 NAK 都没有。
原因:八成是 FCS 算错。用成CRC-16/MODBUS(多项式 0xA001)、初值给了 0x0000、或者忘了最后取反,这三种错法现象完全一样。其次是地址字节数不对,表端配两字节地址,你发一字节的,帧格式域里的地址长度位不匹配,对端直接丢。
解决:拿一段已知正确的报文反算 FCS 验证函数,别自己推。库里crc.c有个#ifdef DLMS_SELFTEST的自测用例,用cmake -DDLMS_SELFTEST=ON编出来跑一遍,几秒钟的事。地址字节数必须在对接文档里确认,别猜。
5.2 认证走到第三步被拒,返回 UserError
现象:AARQ发出去了,AARE也回来了,第三个包发完,服务端回ACTION-Response里带result=13(UserError)。
原因:HLS 的哈希算法、system_title长度、密钥类型三者之一不对。密钥类型最容易错——有的表要octet-string,有的要visible-string,长度和编码都不一样,但报错都是同一个UserError,完全看不出区别。
解决:先把AARE里的mechanism-name和system_title打印出来,对照厂家文档逐字节比。再单独验证哈希:用同样的challenge + system_title + secret手算一遍,和库算出来的比。实在不行,把库的hls_hash换一遍试,MD5/SHA1/SHA256 挨个走,能过说明是算法选错,不是密钥错。
注意:有些表在连续三次认证失败后会锁定一段时间,调试时别写死循环重试,每次失败等几秒再试。
5.3 读 Profile Generic 缓冲,长度对但内容全 0
现象:读1.0.99.1.0.255(负荷曲线),返回成功,长度也对,解析出来全是 0。
原因:属性索引错了。Profile Generic 的attr=2是 buffer,attr=3是 capture_objects(采集对象列表),attr=7是 entries(条数)。读成 3 就是一组 OBIS 索引,你按值去解当然是垃圾数据。另一种情况是attr=2带选择性访问,range_descriptor里的起止时间用本地时间而不是 UTC,对端按 UTC 去筛,筛出来是空窗口。
解决:先读attr=7拿条数,确认有数据;再读attr=3拿采集对象列表,按列表顺序去切 buffer 里的每一列;最后才读attr=2。时间统一用 UTC +deviation字段,别塞本地时间。这几步顺序别跳,跳过任意一步都会让你怀疑是协议库的问题。
5.4 TCP 长连接跑几小时之后解析错位
现象:刚连上一切正常,跑几个小时之后开始出现A-XDR tag unknown或者invoke_id mismatch,重连就好。
原因:没做粘包处理。recv()返回多少就当一帧用,第一次两次响应粘在一起时还凑巧能解,等到某个响应被 TCP 拆成两段,缓冲区就永久错位了。
解决:严格按 Wrapper 头的length字段收满再解析,收满之前把数据攒在环形缓冲区里。库里wrapper.c的wrapper_recv_frame()就是这个逻辑,直接调用别自己重写。环形缓冲区大小按最大 APDU 算,读负荷曲线时一个响应可能好几 KB,别只开 512 字节。
5.5 重启后设备端开始拒绝加密帧
现象:程序重启,认证能过,第一个带加密的 GET 就被丢,反复重连都不行;把设备断电重启一下又能好一阵。
原因:frame_counter没有持久化。设备端记录了上次见到的最大帧计数,你重启后从 0 开始,它判定为重放攻击,直接丢弃。
解决:把frame_counter存到非易失存储里,启动时读出上次的值继续递增;或者在重连时走一次密钥协商,让双方重新对齐计数。前者简单粗暴,后者更规范,看你的设备有没有 EEPROM 决定。这一条是我认为最值得写进 checklist 的,因为没有日志、没有报错,只有沉默。
6. 二次开发:把库接到自己的采集服务里
真正落地的时候,你不会只用samples,而是要把这个库塞进自己的采集服务。我的做法是分两步:先加对象,再做回归。
6.1 加一个自定义 COSEM 对象类
业务上经常遇到厂家私有对象,比如某个自定义的 tariff 表。做法是用库提供的注册接口,把 class_id、属性读写回调挂进去:
/* 注册一个 class_id=102 的私有对象,attr 2 可读可写 */ static int priv_get(dlms_obj_t *obj, uint8_t attr, dlms_value_t *out) { if (attr == 2) { out->tag = 0x06; out->u32 = obj->priv_u32; return 0; } return DLMS_ERR_ATTR; /* 属性不支持就明确返回 */ } static const dlms_class_t priv_class = { .class_id = 102, .attr_cnt = 3, .get = priv_get, .set = NULL, .action = NULL, }; dlms_register_class(&cli, &priv_class); /* 客户端侧注册,用于解析未知对象 */关键在get回调的返回码。返回DLMS_ERR_ATTR和对端返回AttributeNotSupported是两件事,前者是本地没实现,后者是远端不支持,日志里要分得清,不然排查时方向全错。
6.2 用录制的帧做回归测试
协议库最怕改一处坏一片。我的习惯是把现场抓到的正常帧和异常帧都存成二进制文件,写个回放测试:
# tests/cases/ 下放好抓包文件,回放时逐帧喂给解码器 ./build/tests/replay tests/cases/get_register_ok.bin ./build/tests/replay tests/cases/hls_fail_usercode13.bin ./build/tests/replay tests/cases/wrapper_split_twice.bin # 模拟 TCP 拆包最后那个拆包用例最有用,它专门把一次响应拆成两段喂进去,验证粘包逻辑。我改链路层代码之前一定先跑一遍,改完再跑一遍,两次结果一致才敢提交。
从那以后我每次接新表,都强制走一遍“明文 GET → HLS 认证 → 加密 GET → 长稳跑两小时”这四步,不管对方说得多肯定。这套流程帮我省下的时间,比任何调试技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取