☰
C#上位机实现智能电表远程抄表:DL/T645协议解析与串口通信
2026/10/27 6:51:15 网站建设 项目流程

简介:这是一份面向C#初学者与物联网数据采集开发者的智能电表自动抄表实践项目,聚焦串口通信与协议解析核心技能,解决传统人工抄表效率低、易出错的工程痛点,适用于能源管理、智慧用电等场景的原型开发与教学实训。压缩包共22个文件,含6个C#源码文件(如Form1.cs、Program.cs)、3个可执行程序(exe)、1个Visual Studio解决方案(sln)及配套资源文件(resx、settings等),完整呈现Windows桌面端串口通信、DL/T645或MODBUS协议解析、电表读数实时显示与存储等关键模块实现。目前已有1406人学习下载。读者可直接编译运行,深入理解SerialPort类配置、校验位/波特率匹配、帧结构解析、异常重试机制等实操细节,并基于现有架构快速适配不同型号电表,是掌握工业现场数据采集全流程的优质入门范例。 做C#上位机的朋友,很多都接过智能电表抄表这类活儿。不管是自己家里装的光伏并网表、出租屋里的预付费电表,还是工厂配电房的动力表,凡是涉及电费结算、能耗统计、设备状态监测的,都绕不开“远程抄表”这四个字。前段时间我整理了一套C#写的智能电表抄表源码,从串口通信、DL/T645协议解析到数据展示,一条龙全齐了。这篇文章就把这套源码背后的事情完整拆一遍,数据采集的原理、C#的实现方式、实际踩过的坑,一次说透。

如果你正在做或打算做电力数据采集相关的项目,不管是刚入门的上位机新手,还是已经带过几个项目的熟手,这篇文章都会对你有实际帮助。读完你不仅能看懂这套抄表源码的逻辑,还能自己动手扩展出适合你现场设备的一套采集程序。

1. 抄表系统到底在解决什么问题

1.1 智能电表数据采集的三种典型形态

做抄表系统的第一步,不是写代码,是搞清楚你的电表是怎么联网的。智能电表的数据采集,根据现场条件不同,基本有三种形态。

第一种是本地串口直采。电表上往往带RS485接口,用双绞线把电表连到串口服务器或者USB转485模块上,上位机直接通过串口发命令去读数据。这种方案最简单,适合电表数量不多、距离不远的小场景,比如小型工厂、楼宇配电间、光伏电站。这套源码走的就是这种方案,我用一个USB转RS485的小盒子,插在工控机上就能跑。

第二种是集中器加主站。电表先通过RS485总线接到集中器,集中器再通过GPRS或4G网络把数据上传到主站系统。这种方案适合小区、台区这类电表数量上百上千的场景。主站不直接和电表打交道,而是和集中器打交道,协议变成了Q/GDW 1376或DL/T 698.45,复杂度和本地抄表不是一个量级。

第三种是物联网无线采集。现在很多新型智能电表本身内置了NB-IoT或WiSUN模组,电表直接上云,主站通过云平台API或者MQTT订阅就可以拿到数据。这种形态没有本地链路,基本上是从“抄表”变成了“订阅数据”。

这套源码解决的是第一种场景,但通信层、协议层、解析层的代码思路,后面两种照样能复用。因为你无论走哪条链路,最后要处理的还是那一包数据,怎么把电表寄存器里的值读出来、怎么解析成能看的数,底层逻辑不变。

1.2 为什么上位机选C#而不是别的

做上位机采集,语言选择无非那么几个:C#、LabVIEW、Python、C++。我长期用C#做这块,原因有三。

第一,串口通信做起来太方便了。System.IO.Ports.SerialPort类,几行代码就能打开一个串口,数据接收事件、超时管理、字节读写全都帮你封装好了。相比C++里自己撸CreateFile、ReadFile那一套,C#的开发效率高好几倍。而Python的pyserial虽然也很简洁,但部署到客户的Windows工控机上,打包、服务化、跟其它硬件交互,体验远不如C#顺手。

第二,界面开发效率高。抄表上位机不只是后台采集,还要给人看。WinForm或者WPF拖一拖控件就出来一个挺像样的界面,实时刷新数据、绘制曲线、导出报表都很成熟。做项目交付的时候,界面好看不好看直接关系到验收印象分。

第三,生态完整。C#对接数据库有EF Core和Dapper,对接MQTT有MQTTnet,对接Modbus有NModbus,网口通信用Socket或者TCPClient,协议解析自己做也不复杂。一套系统从采集到展示再到上云,全链路代码风格统一,维护成本低。

当然,C#也不是没有毛病。比如串口接收底层那一套,偶尔会出现丢字节的问题,后面我会专门讲这个坑。但总体而言,在工业上位机这个领域,C#是投入产出比最高的选择。

2. 核心协议与关键原理

2.1 DL/T645协议是绕不开的门槛

国内智能电表最常用的通信规约是DL/T645,2007版是目前的主流。这套协议规定了电表怎么响应主站命令、数据怎么编码、校验怎么做。网上很多抄表源码其实核心就是围绕DL/T645展开的。

DL/T645的帧结构不算复杂,一条完整的读帧长这样:

68 A0 A1 A2 A3 A4 A5 C L DATA0...DATAn CS 16

拆开来看:

  • 0x68:帧起始符,固定值。相当于每帧消息的“前导头”。
  • A0到A5:电表地址域,6个字节。注意这个地址是从表号倒过来排的,这一点新手特别容易搞反。比如表号是123456789012,那么地址域发出去就是12 90 78 56 34 12,低字节在前。
  • C:控制码。0x11表示主站请求读数据,0x91表示电表正常应答,0xD1表示电表异常应答。
  • L:数据域长度。只算DATA部分的字节数,不包含前面那些固定字段。
  • DATA0到DATAn:数据域,真正干活的内容。读数据命令里放的是数据标识,电表应答里放的是数据标识加实际读数。
  • CS:校验和。从0x68开始到数据域结束,所有字节累加,超过0xFF的溢出部分不要,只保留低8位。
  • 0x16:帧结束符,固定值。

理解了这帧结构,再去看网上的源码就清楚多了。不管代码封得再花哨,本质上就是三件事:拼帧、发帧、拆帧。

2.2 通信参数与地址编码细节

DL/T645协议默认的通信参数是波特率2400、偶校验、8个数据位、1个停止位。这个参数一定要设对,否则电表根本不理你。现在部分新电表也支持1200或4800波特率,但默认值是最稳的。

这里额外提醒一句:有些电表在RS485接口旁边会标A和B两个接线端子。A接485+,B接485-。你要是接反了,经常出现能收到电表数据但内容乱码的情况,因为RS485是差分信号,极性反了信号就反相了。

地址域还有一个细节:全0地址0xAA是广播地址,主要用于广播对时。正常抄表时不要用这个地址,必须是具体的表号。而且不同厂家的电表,表号位数可能不一样,有的12位有的8位。地址域只有6个字节,最多表示12位十进制数。

数据标识这块,DL/T645-2007里有一套编码体系。我举一个最常用的例子,读取当前正向有功总电能:

  • 数据标识按DI0 DI1 DI2 DI3的顺序发送,常见编码是00 00 01 00或01 00 00 00,具体要看电表厂家的规约说明书。
  • 电表应答时,数据域前4个字节是数据标识,后面跟着电能值。电能值通常用4个字节BCD码表示,单位是kWh(千瓦时),有两位小数,比如收到00 00 12 34,实际值就是1234.56 kWh?不对,这里要看厂商定义小数位数,我遇到的正向有功总电能一般是两位小数,读到的BCD字节要自己除以100。

说实话,DL/T645协议文档写得很晦涩,我第一次看的时候对着PDF翻了两天才彻底搞明白。但当你把第一块电表的数据成功读出来的时候,那种感觉是相当爽的。

3. C#实现抄表采集的完整过程

3.1 串口连接与设备发现

第一步先把电表连到电脑上。我用的是USB转RS485模块,接好电表的A和B端子,插上电脑后会在设备管理器里多出一个COM口。打开串口之前,先看下波特率和校验位。

using System.IO.Ports; SerialPort sp = new SerialPort("COM3", 2400, Parity.Even, 8, StopBits.One); sp.ReadTimeout = 1000; sp.WriteTimeout = 1000; sp.Open();

如果Open抛异常,要么是COM口号不对,要么是串口被别的程序占用了。这种事我在现场遇到太多次了,排查顺序永远是:先看设备管理器COM口号,再看是不是有别的调试助手开着串口,最后才怀疑硬件连接问题。

打开串口后,第一步是探测电表地址。如果你知道表号,可以直接把表号转成地址域发命令。如果不知道表号,用广播地址是读不到数据的。这时可以先用厂家默认地址,或者通过电表铭牌上的表号来组装地址域。

地址域倒序逻辑,我写了个函数来处理:

public static byte[] BuildAddressFromMeterNo(string meterNo) { // 电表表号是12位数字,例如 123456789012 // DL/T645地址域6字节,低字节在前,即倒序排列 byte[] addr = new byte[6]; string padded = meterNo.PadLeft(12, '0'); for (int i = 0; i < 6; i++) { string part = padded.Substring(i * 2, 2); addr[5 - i] = Convert.ToByte(part, 16); } return addr; }

这个函数在很多项目里可以直接复用。不过要注意,表号里的A-F是十六进制字符,如果表号里有非十六进制字符,说明你的表号格式不对。

3.2 协议帧拼接与校验计算

拼帧是抄表源码里最核心的环节。我按DL/T645的帧结构,封装了一个通用的读数据命令生成器。

public static byte[] BuildReadFrame(byte[] addr, byte[] dataId) { // 固定起始符 byte[] frame = new byte[2 + 6 + 1 + 1 + dataId.Length + 2]; int index = 0; frame[index++] = 0x68; // 地址域 Array.Copy(addr, 0, frame, index, 6); index += 6; // 控制码:0x11 为读数据 frame[index++] = 0x11; // 数据域长度 frame[index++] = (byte)dataId.Length; // 数据域(数据标识) Array.Copy(dataId, 0, frame, index, dataId.Length); index += dataId.Length; // 校验和:从帧起始符到数据域结束累加,取低8位 byte cs = 0; for (int i = 0; i < index; i++) { cs += frame[i]; } frame[index++] = cs; // 结束符 frame[index++] = 0x16; return frame; }

校验和的计算最容易出错。DL/T645的规定是从0x68这个起始符就开始累加,一直到结束符之前的最后一个数据字节。很多网上流传的代码片段漏了起始符,结果发出去的电表根本不响应。这个坑我实测过,调试了很久才反应过来。

拼好帧之后,直接写串口:

sp.Write(frame, 0, frame.Length);

然后等电表响应。响应时间一般几十毫秒到几百毫秒不等,个别老表会慢一点。读取响应时,先读帧起始符0x68,再读地址域、控制码、长度、数据域、校验、结束符。如果一上来就读完整一包,很容易因为缓冲区里残留字节导致解析错位。

这里给一个读取完整响应帧的示例:

public static byte[] ReadFrame(SerialPort sp) { using (MemoryStream ms = new MemoryStream()) { // 先找起始符 int count = 0; while (count < 100) { int b = sp.ReadByte(); if (b == 0x68) { ms.WriteByte((byte)b); break; } count++; } // 读地址域6字节 + 控制码1字节 + 长度1字节 byte[] header = new byte[8]; for (int i = 0; i < header.Length; i++) { header[i] = (byte)sp.ReadByte(); } ms.Write(header, 0, header.Length); // 长度字节是header的最后一位 int len = Convert.ToInt32(header[7]); // 读数据域 + 校验 + 结束符 byte[] body = new byte[len + 2]; for (int i = 0; i < body.Length; i++) { body[i] = (byte)sp.ReadByte(); } ms.Write(body, 0, body.Length); return ms.ToArray(); } }

这段代码看似简单,但有几个地方值得注意。一是先找起始符的逻辑,为什么不能直接从头读?因为串口缓冲区里可能有上一次残留的数据或者干扰字节,如果你默认第一字节就是0x68,很容易把脏数据当帧头。二是读取响应期间要维护一个总超时,否则电表没响应的时候,程序会卡死在ReadByte上。

3.3 数据解析与展示

电表回帧里的数据域,前4个字节是数据标识,后面才是实际数据。以读取当前正向有功总电能为例,电表应答的数据域通常是这样的:

01 00 00 00 12 34 56 78

前4字节是数据标识,后面4字节是BCD码。BCD码的意思是一个字节拆成高低两个十六进制位,比如0x12代表数值12,0x34代表数值34,合在一起就是12345678,这是带两位小数的数值,所以要除以100,最终结果是123456.78 kWh。

数值解析的代码:

public static decimal ParseBcd(byte[] data, int startIndex, int length) { decimal result = 0; for (int i = 0; i < length; i++) { byte b = data[startIndex + i]; result = result * 100 + (b >> 4) * 10 + (b & 0x0F); } return result; }

这里注意,BCD解析出来的值是十进制形式的,直接按位计算就行。这个函数在处理电能量数据时很常用,电压、电流、功率这些不同数据类型,小数位不一样,解析的时候要按协议文档中约定的小数位来除。

解析完数据,界面上可以用DataGridView或ListView把多块电表的读数实时刷新。我习惯用DataGridView绑一个DataTable,因为后面要导出Excel或者报表的时候,直接就能用。

3.4 多表轮询与并发采集

现场往往不止一块电表。比如一个小型光伏电站可能有七八块电表,一条生产线上也有好几块。这时候就需要轮询采集。

最简单的轮询逻辑就是循环遍历所有电表地址,依次发命令读数据。但串口是半双工的,同一时刻只能往总线上发一帧命令,所以严格意义上说,多表采集是串行的,不存在真正的并发。

实际项目中,我会用定时器控制轮询节奏,每块表之间加一点延时,避免命令过于密集导致电表来不及响应。

private async Task PollAllMetersAsync() { foreach (var meter in meterList) { try { byte[] cmd = BuildReadFrame(meter.Address, dataIdPositiveActiveEnergy); sp.Write(cmd, 0, cmd.Length); byte[] resp = await ReadFrameAsync(sp); decimal value = ParseBcd(resp, 9, 4); meter.CurrentValue = value / 100m; Log($"读取成功:{meter.Name} = {meter.CurrentValue} kWh"); } catch (Exception ex) { Log($"读取失败:{meter.Name},原因:{ex.Message}"); } await Task.Delay(200); } }

轮询间隔怎么定?我一般设3秒到5秒一轮。如果电表数量超过20块,一轮下来可能就要十几秒,这时候要考虑用多个串口分组采集,或者改用集中器方案。另外,电表内部存储的数据有时效性,频繁读取会导致电表内部计量芯片的负荷增加,虽然影响不大,但要避免无意义的死循环读取。

我遇到过一种情况,某块电表老是读取超时,但用厂家自带的调试软件却能读到。检查发现是电表地址和配置文件里的地址差了最后一位。这种问题就是典型的“程序没问题,数据配置错了”,排查的时候一定要先确认地址。

4. 实战中的坑与排查思路

4.1 常见问题速查表

我把这几年做电表采集踩过的坑整理成了一张表,按问题频率排序:

现象可能原因排查方法
串口打不开COM口号不对、端口被占用设备管理器查看COM号,关掉调试助手;检查USB转485驱动是否正常
完全无响应接线错误、地址不对、波特率不匹配先用串口调试助手发手工帧测试;检查A/B接线是否反了
有响应但校验失败校验和算法不对、半包数据用串口助手抓原始字节,手工计算校验和比对
偶尔丢数据串口缓冲溢出、干扰严重加硬件延时、降低波特率、给RS485加终端电阻
读出的数值明显不对小数位没对齐、BCD解析错误对照协议文档检查数据标识和数据长度,确认小数位数
多块表轮询时某块总超时该表地址重复、从站地址冲突单独给那台表发命令测试,确认地址唯一性

排查的第一步永远是抓包。我说的抓包不是Wireshark那种网络抓包,而是用一个串口调试助手,把软件发出的字节和电表返回的字节全部以十六进制显示出来。这个工具哪怕是最简单的SSCOM都行。很多问题只要看到原始字节流,原因立刻就能判断出来。

4.2 日志记录与线上排查技巧

写采集程序,日志系统一定要从一开始就做上,别等出了问题再补。我在这套源码里加了一个简单的日志类,按天滚动写入文本文件,记录每一帧发送和接收的原始数据。

public static void LogFrame(string direction, byte[] frame) { string hex = string.Join(" ", frame.Select(b => b.ToString("X2"))); Log($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [{direction}] {hex}"); }

记录原始帧有什么好处?有一次客户反馈说数据偶尔不准,现场又没人盯着看。我远程拿到日志文件,发现电表返回的字节流里偶尔混入了一个0xFF的字节。这个字节一看就不是DL/T645的合法内容,估计是485线路上的干扰信号。后来我让客户给RS485总线加上了终端电阻,问题就解决了。没有日志,这种偶发故障根本无从下手。

还有一种情况是,程序跑了一整天后,突然某块表开始一直超时。我看了日志才发现是某块表没有响应,然后定时器就一直在重试这块表,把后面所有表都堵住了。后来我把轮询逻辑改成了“连续失败3次就跳过,等到下一轮再试”,问题才解决。

4.3 串口数据错位与字节丢失的深层处理

串口接收这块,C#的DataReceived事件是工作在线程池线程上的,如果你在事件里直接做解析,很容易出问题。而且DataReceived事件并不保证数据一次性全到,可能分几次触发。这也是很多C#串口程序不稳定的根源。

我推荐的做法是用一个缓冲队列,把所有收到的字节先丢进队列,解析线程再从队列里取数据做帧校验。

ConcurrentQueue<byte> recvQueue = new ConcurrentQueue<byte>(); void sp_DataReceived(object sender, SerialDataReceivedEventArgs e) { int count = sp.BytesToRead; byte[] buffer = new byte[count]; sp.Read(buffer, 0, count); foreach (byte b in buffer) { recvQueue.Enqueue(b); } }

然后解析线程专门负责处理队列。这样做的好处是把“接收数据”和“解析数据”解耦,哪怕接收到了不完整的一帧,只要队列不丢数据,下一批字节到了照样能拼出完整帧。

还有一个小技巧:当帧解析失败时,不要立刻丢弃,先把缓冲区里已有的数据按“找0x68起始符”的方式重新对齐。因为干扰字节可能让你一开始就找错了帧头,直接放弃的话,整帧就浪费了。

5. 这套源码还能往哪里扩展

5.1 从单机采集到云端上报

本地抄表只是第一步。现在很多项目要求数据上云,让管理人员在手机上就能看到用电情况。数据上云最常用的方式是MQTT协议,C#有现成的MQTTnet库,使用起来非常简单。

思路是:轮询程序每采到一条数据,就直接通过MQTT发布到云端Broker,云端服务订阅之后把数据存库和展示。这样本地程序只负责采集,业务逻辑全部上云,后期要加多站点集中管理也方便。

// 使用MQTTnet发布数据示例 var mqttFactory = new MqttFactory(); using (var client = mqttFactory.CreateMqttClient()) { var options = new MqttClientOptionsBuilder() .WithTcpServer("your-mqtt-broker-address", 1883) .WithCredentials("username", "password") .Build(); await client.ConnectAsync(options, CancellationToken.None); string payload = JsonSerializer.Serialize(new { meterNo = "123456789012", activeEnergy = meter.CurrentValue, timestamp = DateTime.Now }); await client.PublishAsync("meters/data/123456789012", payload); }

这块我踩过一个大坑:MQTT的QoS等级。如果用QoS0,网络抖动时消息会丢;如果用QoS2,Broker端压力大而且重传逻辑复杂。做电表数据上报,我建议用QoS1,正好在可靠性和性能之间取一个平衡。

5.2 对接MES或EMS能源管理系统

工厂场景里,抄表数据通常要对接MES(制造执行系统)或EMS(能源管理系统)。C#做这块很顺手,你可以用EF Core对接数据库,或者用Web API把数据暴露出去。

我做过一个项目,车间里有六十多块电表,每5分钟采集一轮,数据存到SQL Server里,报表页面用ECharts展示各产线的实时功率曲线和日用电量。整个系统的核心代码,就是这套源码里Protocol层的代码换了个调用方式,采集服务和Web服务分开部署而已。

这种架构下,采集服务专注数据采集,Web服务专注业务展示,数据落到数据库后两边互不干扰。后面你要加告警功能、预测功能,都是顺势而为的事情。

我个人做抄表项目的经验是,协议解析和稳定性是最花时间的部分,界面反而是最不重要的。先在串口调试助手里手工把电表调通,再写代码,成功率能提高很多。千万别一上来就敲代码,先手工验证协议。

5.3 从C#上位机到跨平台采集

如果你以后要考虑跨平台部署,C#倒也不是不能做。用. NET 6以上的版本,串口编程的API基本是通用的,代码稍微改改就能跑在Linux小主机上。我最近一个项目就是把采集程序部署在一块ARM开发板上,用. NET运行时跑,性能和稳定性都还不错。

不过Linux下需要注意,串口名称变成了/dev/ttyUSB0、/dev/ttyS0这样的形式,而且当前用户需要加入dialout组才有权限访问串口。这些细节在Windows下开发时完全碰不到,但换到Linux下就是第一道门槛了。

做采集这一行,硬件、协议、软件、网络,哪个环节都可能出问题。但反过来想,也正是因为问题多,能把这个领域的东西搞透彻的人,到哪都有饭吃。这套C#抄表源码是一个起点,把里面的协议层、通信层、解析层真正理解透,换到别的设备采集项目上,也就是换个协议的事。

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

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

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

立即咨询