ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

DLMS协议采集C#源代码实战:从HDLC帧到OBIS码

DLMS协议采集C#源代码实战:从HDLC帧到OBIS码 简介面向电能计量领域的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 62056DLMS/COSEM标准传到你的C#程序里。很多人第一次接触这块会觉得协议是一团黑匣子买来的电表通讯模块又贵又封闭本地调试全靠掌机。其实协议本身并不难难的是连接状态机和数据解析。这套C#源代码把DLMS客户端、HDLC帧封装、OBIS码解析做了分层落地适合要做变电站抄表、工厂能源管理系统、集抄后台的上位机工程师直接复用。下面从协议栈开始拆。2. 从HDLC帧到OBIS码C#侧协议栈结构与连接建立很多C#上位机工程师拿到的第一版源码是一大坨收发字节数组的代码。这样写不坏但是难维护。DLMS协议栈每一层都在处理不同的事情物理层管串口和Socket数据链路层管HDLC帧的分包、校验、重传应用层管AARQ/AARE握手和GET/SET服务。如果你的代码里这三层混在一起换一个电表型号就要动一遍底层逻辑。我拆过的项目里凡是能稳定跑上线的几乎都做了分层。下面把每一层对应的C#类说清楚。2.1 协议栈分层与C#实现的最小模型DLMS/COSEMIEC 62056是智能电表的数据交换标准。国内用的多数是LNLogical Name上下文也就是用OBIS码作为对象寻址。一个完整的读表操作会经过串口/TCP通道发出HDLC帧帧里装着APDUAPDU里是GET请求请求里是OBIS码和attributeId。返回链路则反着走一遍。C#源代码里建议做成四个类。Channel封装SerialPort或TcpClient负责读写原始字节只暴露Write和ReadFrameHdlcCoder把应用层APDU封装成HDLC帧负责校验HCS/FCS解帧时负责去标志位和校验ApduBuilder和DlmsApdu负责构造、解析AARQ、AARE、GET Request/ResponseDlmsConnection串联状态机对外提供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/0x73Info帧是0x10/0x11C#里不要手拼。参数说明_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, client0x10, server0x01。出问题的时候看日志停在哪一步基本就能定位是链路问题还是应用层认证问题。3. 电表数据采集OBIS对象读取与DLMS数据类型解析建立连接之后剩下的工作就是按OBIS码去读对象。这个阶段的问题不再是协议栈而是数据映射。C#源码里最值得重用的部分其实就是这一层对象表、GET请求构造、类型解析。3.1 OBIS对象表与attributeId规则每块电表都有一组对象用OBIS码作为ID。用对象表方式组织代码比散落的字符串强得多至少不会在十个地方引用同一个写错的字符串。OBIS码含义常见attributeId1.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 Dictionarystring, object ReadMeterObjects(DlmsConnection conn, Liststring obisList) { var result new Dictionarystring, 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]对应Abyte[5]对应F顺序反了的话电表会认为你访问的是另一个不存在的对象。参数说明BuildGetRequest的第三个参数是attributeId。读取电能量当前值用1取单位用2取标定值用3。电压、电流等电气量通常用attribute 1取瞬时值。这个方法一次读一个对象最适合做定时全量读取。如果想读曲线数据需要把attributeId换成3并通过ObjectList带起始时间逻辑上是一样的只是入参变复杂。3.3 数据类型还原和单位换算DLMS返回数据的tag很多00代表Null06代表OctetString09代表Integer/LongInteger0F代表Array11代表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比如原始值320000scaler是-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秒链路层等待UAAARQ超时5秒有中间转发给8秒应用层握手GET超时5秒单次读取等待重试次数23超过则跳过本轮这些参数不能照抄要按现场档案调整。C#上位机写死TCP 4059会栽跟头有的采集终端TCP用4060UDP才是4059。串口参数也一样部分电表支持自适应波特率但多数台区表出厂是9600。连接前先清空缓冲区避免残帧干扰。超时设计要遵守一条原则单块表抄读失败不能拖垮整个轮询。主站一般要求一块表在一分钟内完成抄读如果每块表都等满8秒超时20块表串行就要160秒后台已经开始报超时了。所以GET超时控制在5秒以内失败就跳过本轮下一轮再补。4.2 定时轮询实现避免轮询堆积C#里做定时轮询我一般用PeriodicTimer而不是System.Threading.Timer。区别在于PeriodicTimer不会回调重入上一次执行没结束下一次tick不会并发进来。这对抄表很重要因为读表操作是IO密集型的如果上一轮还没有结束下一轮又启动现场的串口会乱成一锅粥。private static readonly ConcurrentDictionarystring, 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控制并发上限推荐510。太多并发会让电表通信模块挂起尤其是RS-485总线共享的场景并发超过5就容易出现总线冲突。采集结果写入有界Channel落库失败时先存在内存队列里下轮再补。这个设计保证了采集主循环永远不被数据库拖住。参数说明ReadInterval是轮询周期工厂能源管理系统一般15秒到1分钟集抄后台可以5分钟。Retry次数3次超过后把电表标记为异常等下一轮再说。_dataQueue用一个Channel.CreateBoundedMeterData(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#里可以用一个ConcurrentDictionarystring, 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走40604059是UDP另一种情况是服务器地址用了短地址格式但集中器要求的是2字节长地址。解决先用Socket工具手动连一下确认端口然后用电表档案里的完整地址按2字节格式拼接。在C#代码里把服务器地址格式做成配置项不要写死现场换采集终端型号是一分钟的事。5.5 运行几天内存持续增长连接对象没有释放现象进程内存从200MB涨到1GB以上最后GC都拉不回来Windows服务偶发崩溃。原因每轮轮询都新建DlmsConnection但没有DisposeSerialPort或Socket句柄一直被占用另一个来源是落库队列积压消费者线程卡死在数据库连接上。解决连接对象放入单例连接池用完调用Dispose释放句柄。日志和落库队列用有界ChannelFullMode设置为DropOldest丢弃最旧的数据也要保证采集循环不阻塞。排查时可以打开性能计数器重点看句柄数和线程数这两个指标涨得不对劲基本就是资源泄漏。6. 把C#源码改造成断线重连服务重连退避与验证方法连接是会断的。轮询任务跑几天串口被拔、集中器重启、电表通信模块死机都会把连接打回Closed。所以采集服务必须有一个「断开后自动恢复」的重连策略。关键点是重连前必须Dispose旧连接释放SerialPort/Socket句柄再重新走一遍ConnectAsync。如果直接复用旧连接对象下一次读帧时你会发现要么收到一个异常要么永远等不到响应。public async Taskbool 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没释放跑两天操作系统就把端口句柄耗尽现场读表全超时。从那以后我每次发布前都会强制走一遍断线重连测试确认旧连接被彻底释放、退避时间符合预期再放上线。希望帮到你。本文还有配套的精品资源点击获取
返回列表