☰
DLMS协议采集C#源代码实战:从HDLC帧到OBIS码
2026/10/8 8:28:19 网站建设 项目流程

简介:面向电能计量领域的DLMS协议C#数据采集源代码,适合需要对接多种品牌电能表的电力自动化、能源管理系统开发者。DLMS协议是国际标准通信协议,通过该代码可在.NET环境下实现电表数据的读取、写入及安全认证,快速搭建统一抄表采集程序。资源包为zip压缩格式,共13个文件,包括11个C#源文件、1个JSON配置文件和1个csproj项目文件,压缩包整体仅14KB。C#代码覆盖协议通信的关键环节:HDLC链路层与LLC层的解析、FCS16校验与字节缓冲工具、DlmsHelper辅助类,以及BerType、PduType、Command等协议模型定义,JSON文件可用于保存通信参数,便于根据实际电表型号调整。目前已有527人学习,适合具备C#和.NET基础、希望理解DLMS协议实现细节的开发者参考并扩展。

1. DLMS协议采集C#源代码:先看懂这套协议再动手改代码

做用电信息采集的朋友,对DLMS协议采集C#源代码这套东西应该不陌生。它解决的是上位机软件和智能电表之间的通信问题:电表里存的正向有功电量、电压、电流、瞬时功率,通过IEC 62056(DLMS/COSEM)标准传到你的C#程序里。很多人第一次接触这块,会觉得协议是一团黑匣子,买来的电表通讯模块又贵又封闭,本地调试全靠掌机。其实协议本身并不难,难的是连接状态机和数据解析。这套C#源代码把DLMS客户端、HDLC帧封装、OBIS码解析做了分层落地,适合要做变电站抄表、工厂能源管理系统、集抄后台的上位机工程师直接复用。下面从协议栈开始拆。

2. 从HDLC帧到OBIS码:C#侧协议栈结构与连接建立

很多C#上位机工程师拿到的第一版源码,是一大坨收发字节数组的代码。这样写不坏,但是难维护。DLMS协议栈每一层都在处理不同的事情:物理层管串口和Socket,数据链路层管HDLC帧的分包、校验、重传,应用层管AARQ/AARE握手和GET/SET服务。如果你的代码里这三层混在一起,换一个电表型号就要动一遍底层逻辑。我拆过的项目里,凡是能稳定跑上线的,几乎都做了分层。下面把每一层对应的C#类说清楚。

2.1 协议栈分层与C#实现的最小模型

DLMS/COSEM(IEC 62056)是智能电表的数据交换标准。国内用的多数是LN(Logical Name)上下文,也就是用OBIS码作为对象寻址。一个完整的读表操作会经过:串口/TCP通道发出HDLC帧,帧里装着APDU,APDU里是GET请求,请求里是OBIS码和attributeId。返回链路则反着走一遍。

C#源代码里建议做成四个类。Channel封装SerialPort或TcpClient,负责读写原始字节,只暴露Write和ReadFrame;HdlcCoder把应用层APDU封装成HDLC帧,负责校验HCS/FCS,解帧时负责去标志位和校验;ApduBuilder和DlmsApdu负责构造、解析AARQ、AARE、GET Request/Response;DlmsConnection串联状态机,对外提供Connect、ReadObject、Disconnect三个方法。这样分层的好处是换通道时只换Channel,换电表型号时只改OBIS码和对象表。

实际写的时候,很多人会把帧头帧尾直接写在读写方法里,结果TCP通道下多出的4个字节没处理,串口下又少了两个转义字符。把HdlcCoder独立出来之后,这类问题就限定在一个类里,排查范围小很多。C#源码里还有一个容易漏的点:HDLC帧在信息字段里碰到0x7E是要转义的,否则会把帧尾识别提前。这个转义逻辑建议放在Encoder和Decoder两个入口统一做,不要在业务代码里到处判断。

2.2 建立连接:从SNRM到AARE的状态机

DLMS建立连接不是直接发GET。标准流程是先做数据链路层握手,再做应用层握手。第一段是通信方发送SNRM帧,电表回UA帧,表示物理链路和波特率已经对齐;第二段是发送AARQ,电表回AARE,里面带了认证结果。C#实现里必须等UA后再发AARQ,中间如果收到别的帧,要把状态机置为Closed重新来。

private int _state = ConnectionState.Closed; public async Task ConnectAsync(CancellationToken ct) { _channel.ClearBuffer(); // 清残帧,防止旧数据被误认为UA byte[] snrm = HdlcCoder.EncodeSnrm(_clientAddr, _serverAddr); _channel.Write(snrm); HdlcFrame ua = await _channel.ReadFrameAsync(TimeSpan.FromSeconds(3), ct); if (ua.Control != HdlcControl.UA) throw new DlmsException("未收到UA,链路建立失败"); _state = ConnectionState.LinkReady; byte[] aarq = ApduBuilder.BuildAarq(_jointContext, _authLevel); byte[] infoFrame = HdlcCoder.EncodeInfo(_clientAddr, _serverAddr, aarq); _channel.Write(infoFrame); DlmsApdu aare = await _channel.ReadApduAsync(TimeSpan.FromSeconds(5), ct); if (aare.Tag != ApduTag.AARE || aare.AssociationResult != 0) throw new DlmsException($"AARE失败: 结果={aare.AssociationResult}, 原因={aare.AssociationError}"); _state = ConnectionState.Ready; }

逻辑说明:ClearBuffer处理的是半包残留。你发SNRM之前,串口缓冲里可能还躺着一帧上次断链留下的数据,不清理的话,ReadFrame会先读到这帧,然后UA永远等不到。EncodeSnrm和EncodeInfo是HdlcCoder的两个静态方法,区别在于控制字段不同:SNRM是0x93/0x73,Info帧是0x10/0x11,C#里不要手拼。

参数说明:_clientAddr和_serverAddr分别对应HDLC帧里的客户端地址和电表服务器地址。客户端地址一般固定为0x10,服务器地址每一块表不一样,要在档案里下发。注意有的电表支持短地址格式(1字节),有的要2字节长地址,HdlcCoder内部要用_useLongAddress标志切换。_authLevel可以选None、Low、High,工程现场调试先用None,能少一个认证环节;等数据链路通了再往上加密码。SNRM等UA给3秒,AARQ等AARE给5秒,经过集中器转发时把AARQ超时放大到8秒。

这里有个容易被忽略的点:状态机必须严格单向流转。我在实际项目里见过把SNRM和AARQ合在一个帧里发的写法,部分电表能容忍,但另一部分直接不回包,排查起来非常玄学。所以C#源代码里建议把Connect拆成两个阶段打日志,第一阶段过了才进第二阶段,日志格式类似[Dlms] LinkReady -> SendAARQ, client=0x10, server=0x01。出问题的时候,看日志停在哪一步,基本就能定位是链路问题还是应用层认证问题。

3. 电表数据采集:OBIS对象读取与DLMS数据类型解析

建立连接之后,剩下的工作就是按OBIS码去读对象。这个阶段的问题不再是协议栈,而是数据映射。C#源码里最值得重用的部分,其实就是这一层:对象表、GET请求构造、类型解析。

3.1 OBIS对象表与attributeId规则

每块电表都有一组对象,用OBIS码作为ID。用对象表方式组织代码,比散落的字符串强得多,至少不会在十个地方引用同一个写错的字符串。

OBIS码含义常见attributeId
1.0.1.8.0.255正向有功总电量1=当前值,2=单位,3=标定值
1.0.2.8.0.255反向有功总电量1=当前值,2=单位,3=标定值
1.0.32.7.0.255A相电压1=瞬时值,2=单位
1.0.1.0.0.255电表当前时间2=时间对象
1.0.1.1.0.255正向有功尖费率电量1=当前值

OBIS码格式是A-B:C.D.E.F。A是能源类型,1代表电;B是通道,0代表总;C是测量对象,8代表电量,32代表电压;D是处理方式,0代表总计;E是费率,0代表总费率;F是存储,255代表当前值。写错一位,读出来的不是0就是错误响应。C#里建议把OBIS码定义成常量类,比如ObisCodes.ActiveEnergyTotal = "1.0.1.8.0.255",比在调用点裸写字符串靠谱。

attributeId也建议定义常量:CurrentValue = 1、Unit = 2、Scaler = 3。因为同一个OBIS码可以带不同attributeId读,比如读电压瞬时值用的是attribute 1,读标定值却要用attribute 3。一旦混用,电表会返回数据不匹配的错误。

3.2 构造GET请求并发送

public Dictionary<string, object> ReadMeterObjects(DlmsConnection conn, List<string> obisList) { var result = new Dictionary<string, object>(); ushort invokeId = 0x10; foreach (var obis in obisList) { byte[] obisBytes = ObisHelper.FromString(obis); byte[] request = ApduBuilder.BuildGetRequest(invokeId, obisBytes, attributeId: 1); byte[] envelope = HdlcCoder.EncodeInfo(conn.ClientAddr, conn.ServerAddr, request); conn.Channel.Write(envelope); DlmsApdu response = conn.Channel.ReadApduAsync(TimeSpan.FromSeconds(5)).Result; invokeId = (ushort)((invokeId + 1) & 0xFF); // 每次自增,不能重复 if (response.Tag == ApduTag.GetResponse) { result[obis] = DlmsDataDecoder.Decode(response.Data, response.DataTag); } else { result[obis] = $"错误{response.Result}:{response.ResultDescription}"; } } return result; }

逻辑说明:invokeId是事务标识,用来匹配请求和响应。如果两次GET用同一个invokeId,电表会当成重发,直接返回旧响应或错误。这个细节是翻车高发区。每读完一个对象后invokeId加1,到0xFF后回到0x10,避开0x00和0x01这些保留值。OBIS字符串解析成6字节后,注意byte[0]对应A,byte[5]对应F,顺序反了的话,电表会认为你访问的是另一个不存在的对象。

参数说明:BuildGetRequest的第三个参数是attributeId。读取电能量当前值用1,取单位用2,取标定值用3。电压、电流等电气量通常用attribute 1取瞬时值。这个方法一次读一个对象,最适合做定时全量读取。如果想读曲线数据,需要把attributeId换成3并通过ObjectList带起始时间,逻辑上是一样的,只是入参变复杂。

3.3 数据类型还原和单位换算

DLMS返回数据的tag很多:00代表Null,06代表OctetString,09代表Integer/LongInteger,0F代表Array,11代表DateTime。这些集中在DlmsDataDecoder里处理,业务层拿到的应该是已经还原好的对象,而不是裸字节。

switch (tag) { case 0x06: // Octet String,可能是ASCII串或二进制串 value = Encoding.ASCII.GetString(data).TrimEnd('\0'); break; case 0x09: // Integer 与 LongInteger,可能带符号 value = SignedBytesToLong(data); break; case 0x0F: // Array,读取负荷曲线时是数组 value = ParseDlmsArray(data); break; case 0x11: // DateTime value = ParseDlmsDateTime(data); break; }

逻辑说明:0x09是DLMS的整数类型,数据长度可能是1、2、4字节,转成long时必须做符号扩展,否则会读到负数或巨大值。例如电表返回0xFB 0x55,如果按ushort转,得到64341,正确结果是-1195。符号扩展可以这样处理:把最高字节转成sbyte再参与移位,这样C#会保留符号位。

单位换算也是常见的坑。部分电表返回的电能量带scale,比如原始值320000,scaler是-2,实际电量是320000乘以10的负2次方,也就是3200.00 kWh。源代码解析返回后,把原始值、缩放后的值、单位都存到结果对象里,现场核对数据的时候一眼就能看出是解析问题还是电表设置问题。我一般会在结果类里放三个字段:RawValue、ScaledValue、Unit,缺了哪个都不好排查。

4. 定时轮询与多表并发:参数配置和运行边界

代码跑通单表读取之后,接下来就是让它按业务需求稳定跑起来:定时轮询、多表并发、数据落库。这个环节拼的不是协议,而是对超时、并发、线程调度的控制。

4.1 连接参数表与超时设计

参数推荐值说明
客户端地址0x10DLMS标准中客户端一般填16
服务器地址1(需按电表档案)每块表的地址,注意1字节/2字节
波特率(串口)9600 8E1常见电表出厂默认
TCP端口4059集中器/采集终端的TCP端口
SNRM超时3秒链路层等待UA
AARQ超时5秒(有中间转发给8秒)应用层握手
GET超时5秒单次读取等待
重试次数2~3超过则跳过本轮

这些参数不能照抄,要按现场档案调整。C#上位机写死TCP 4059会栽跟头:有的采集终端TCP用4060,UDP才是4059。串口参数也一样,部分电表支持自适应波特率,但多数台区表出厂是9600。连接前先清空缓冲区,避免残帧干扰。

超时设计要遵守一条原则:单块表抄读失败不能拖垮整个轮询。主站一般要求一块表在一分钟内完成抄读,如果每块表都等满8秒超时,20块表串行就要160秒,后台已经开始报超时了。所以GET超时控制在5秒以内,失败就跳过本轮,下一轮再补。

4.2 定时轮询实现:避免轮询堆积

C#里做定时轮询,我一般用PeriodicTimer而不是System.Threading.Timer。区别在于PeriodicTimer不会回调重入:上一次执行没结束,下一次tick不会并发进来。这对抄表很重要,因为读表操作是IO密集型的,如果上一轮还没有结束,下一轮又启动,现场的串口会乱成一锅粥。

private static readonly ConcurrentDictionary<string, MeterData> _cache = new(); private readonly SemaphoreSlim _gate = new(5, 5); // 并发上限 using var timer = new PeriodicTimer(TimeSpan.FromSeconds(ReadInterval)); while (await timer.WaitForNextTickAsync()) { var readTasks = _meters.Select(async meter => { await _gate.WaitAsync(); try { var data = await ReadMeterWithRetryAsync(meter, 3); _cache[meter.Id] = data; await _dataQueue.Writer.WriteAsync(data); } finally { _gate.Release(); } }); await Task.WhenAll(readTasks); }

逻辑说明:SemaphoreSlim控制并发上限,推荐5~10。太多并发会让电表通信模块挂起,尤其是RS-485总线共享的场景,并发超过5就容易出现总线冲突。采集结果写入有界Channel,落库失败时先存在内存队列里,下轮再补。这个设计保证了采集主循环永远不被数据库拖住。

参数说明:ReadInterval是轮询周期,工厂能源管理系统一般15秒到1分钟;集抄后台可以5分钟。Retry次数3次,超过后把电表标记为异常,等下一轮再说。_dataQueue用一个Channel.CreateBounded<MeterData>(new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.DropOldest }),防止内存被慢消费方撑爆。

4.3 数据落库与断点续采

落库用UPSERT比先查后插更省事。建表的时候把(meter_id, obis, read_time)作为唯一键,重复读不会插脏行,而是覆盖。

INSERT INTO meter_data (meter_id, obis, value, unit, read_time) VALUES (@MeterId, @Obis, @Value, @Unit, @ReadTime) ON DUPLICATE KEY UPDATE value = VALUES(value), unit = VALUES(unit);

逻辑说明:这里采用按时间覆盖的策略,同一时刻的重复抄表数据不会累计成多行。断点续采的做法是:每块表记录lastReadTime和lastObisIndex,启动时从上一次失败的位置继续,而不是把整个档案重采一遍。C#里可以用一个ConcurrentDictionary<string, ReadProgress>维护进度,落库成功后更新。

如果现场要求数据必须可追溯,就把约束改成(meter_id, obis, read_time),允许同表同对象多次记录,每次插入都保留时间戳。这个取舍由业务决定,但底层代码结构是一样的,只是SQL里的ON DUPLICATE策略不同。

5. 采集过程中的常见问题与避坑指南

这个资源里最难的不是协议,是现场五花八门的情况。我整理五条血泪经验,每一条都是从真实项目里扒出来的,按现象、原因、解决三部分写,方便大家对号入座。

5.1 SNRM 等不到 UA:先查地址和串口接线

现象:发送SNRM后,串口调试工具能看到发出的数据,但一直收不到UA帧,直到超时。

原因:最常见的是服务器地址配置错误,HDLC帧到了电表但被丢弃,不会回任何数据。其次是串口A、B两根线接反了,信号没进到电表里。

解决:先拿电表铭牌或档案里的地址替换_serverAddr;然后在串口物理层面把A、B线对调试一次。如果还不行,用串口助手直接发SNRM裸帧,看电表是否有响应。这一步能把问题定位在「地址错」还是「接线错」上。

5.2 读到的电能量为负或0:解析符号位和OBIS费率问题

现象:正向有功总电量读取成功,但值为负数,或者所有费率电量都是0,只有总电量正常。

原因:0x09类型解析时没有做符号扩展,把高位符号位当成普通数据;或者OBIS码里的E位写错了,比如1.0.1.8.0.255写成了1.0.1.8.1.255,后者是尖峰费率,平段没有数据自然为0。

解决:解析代码里对最高字节做unchecked((sbyte)data[0])扩展,再参与移位。OBIS码逐个字段核对:尖峰、峰、平、谷四个费率在E位上分别是1、2、3、4,总费率是0。用掌机读一次同一块表对比,是验证OBIS码最快的方式。

5.3 轮询跑一段时间后偶发超时:残帧和并发超标

现象:刚上线的几分钟正常,跑十几分钟后开始零星超时,手动再读又正常。

原因:串口缓冲里残留了半包数据,下一轮读帧时先读到了残帧,导致当前请求等不到响应;另一部分原因是并发数开太高,电表的通信模块处理不过来。

解决:每次发起SNRM前强制ClearBuffer,这个操作成本极低但能消除大部分残帧问题。并发上限降到5,如果现场是共享RS-485总线,建议直接串行读表,速度慢一点但稳定。单表超时后跳过本轮,不要阻塞整个轮询。

5.4 TCP/IP 抄表连不上:端口和服务器地址双向确认

现象:TCP连接能建立,但发出去的GET请求没有响应,或者连接直接被重置。

原因:集中器/采集终端的TCP端口并不是所有厂家统一用4059,有的TCP走4060,4059是UDP;另一种情况是服务器地址用了短地址格式,但集中器要求的是2字节长地址。

解决:先用Socket工具手动连一下,确认端口;然后用电表档案里的完整地址,按2字节格式拼接。在C#代码里把服务器地址格式做成配置项,不要写死,现场换采集终端型号是一分钟的事。

5.5 运行几天内存持续增长:连接对象没有释放

现象:进程内存从200MB涨到1GB以上,最后GC都拉不回来,Windows服务偶发崩溃。

原因:每轮轮询都新建DlmsConnection,但没有Dispose,SerialPort或Socket句柄一直被占用;另一个来源是落库队列积压,消费者线程卡死在数据库连接上。

解决:连接对象放入单例连接池,用完调用Dispose释放句柄。日志和落库队列用有界Channel,FullMode设置为DropOldest,丢弃最旧的数据也要保证采集循环不阻塞。排查时可以打开性能计数器,重点看句柄数和线程数,这两个指标涨得不对劲,基本就是资源泄漏。

6. 把C#源码改造成断线重连服务:重连退避与验证方法

连接是会断的。轮询任务跑几天,串口被拔、集中器重启、电表通信模块死机,都会把连接打回Closed。所以采集服务必须有一个「断开后自动恢复」的重连策略。关键点是:重连前必须Dispose旧连接,释放SerialPort/Socket句柄,再重新走一遍ConnectAsync。如果直接复用旧连接对象,下一次读帧时你会发现要么收到一个异常,要么永远等不到响应。

public async Task<bool> ReadWithReconnectAsync(DlmsConnection conn, string obis, int maxRetry = 3) { for (int i = 0; i < maxRetry; i++) { try { if (conn.State != ConnectionState.Ready) await conn.ConnectAsync(cts.Token); return TryRead(conn, obis); } catch (DlmsException ex) { // 断链后必须释放旧句柄,否则串口或Socket会被占住 conn.Dispose(); conn = new DlmsConnection(conn.Options); await Task.Delay(1000 * (i + 1), cts.Token); } } return false; }

逻辑说明:重连前先Dispose旧连接,再重建DlmsConnection。退避间隔用1秒、2秒、4秒的递增方式,避免断链后所有线程同时重连把集中器打崩。如果连续3次都失败,说明不是瞬时抖动,最好把这块表标记为离线,等下一轮再试。

验证方法我是这样做的:在测试环境用一对虚拟串口把上位机和模拟电表连起来,跑一轮读表后强制关闭一端串口,观察重连日志是否按退避时间递增出现;接着恢复串口连接,确认下一轮轮询数据能自动续上。如果没有虚拟串口设备,就在真实电表和上位机之间串一个网络开关,断网5秒再恢复。重连日志里必须能看到ConnectAsync的完整状态流转,从Closed走到Ready才算通过。

之前我把重连逻辑放在轮询外层,结果SerialPort没释放,跑两天操作系统就把端口句柄耗尽,现场读表全超时。从那以后我每次发布前都会强制走一遍断线重连测试,确认旧连接被彻底释放、退避时间符合预期,再放上线。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询