资讯动态

C# Windows服务远程抄表实现:URL接口与RS-485、GPRS通信实践

发布时间:2026/9/15 3:32:01 来源:尧图企业网站定制
简介这是一套基于C#编写的智能水表远程抄表系统源码采用B/S结构通过RS-485总线采集器数据并借助GPRS网络与后台抄表管理装置通信适合学习C#实战项目开发、物联网数据采集与远程管理的开发者参考。资源压缩包约17.58MB共186个文件涵盖38个dll运行库、23个pdb调试文件、22个cs核心代码、14个config配置文件、4个sql数据库脚本以及exe可执行程序、docx说明文档等还有安装/卸载批处理脚本和相关辅助文件便于直接运行和二次开发。目前已有64人学习下载。整个工程包含项目结构、配置文件、数据库脚本和生成输出能帮助读者了解水表抄表系统的模块划分与串口/GPRS通信流程适合C#初、中级开发者用于课程设计、毕业设计或实际项目改造是一份结构完整、可落地的实战源码案例。1. WaterMeterReadingSvc一个用C# URL接口把抄表任务交给后台的Windows服务做过远程抄表的人应该都有印象水表分布在几十个点位后台系统只需要一张报表但你不可能让后台直接拉串口线去读表。WaterMeterReadingSvc这个C#项目正好把中间的脏活接过去了。它通过RS-485总线抄收采集器里的用水量数据再通过GPRS网络和后台的B/S抄表管理装置通信同时对外暴露一套c# url接口源码后台只要按约定路径发一条HTTP请求就能查询任意一块水表的当前读数。对做设备接入、串口服务化、GPRS透传的工程师来说这份源码最有价值的部分是URL接口、串口帧处理、Windows服务安装三个环节被拆得很清楚能直接改造成自己的采集服务。下面按链路顺序从接口层往下拆。2. URL接口层把C# Windows服务暴露成抄表查询API2.1 为什么后台不直接操作RS-485总线如果后台系统是部署在Web服务器上的B/S结构它没有物理串口概念也不会接RS-485总线。更现实的做法是让后台只发HTTP请求由本机或局域网内的C#服务去操作串口和GPRS模块。这样有几个好处串口被单个进程独占不会出现两台机器同时抢采集器HTTP是后台最熟悉的东西换成PHP、Java或另一个C#后台都能对接数据读写集中在服务层方便加缓存和日志。这个思路在很多IoT项目里都能复用把“设备协议”和“业务系统”之间的鸿沟用一层URL接口填平。2.2 用HttpListener实现轻量级C# URL接口在Windows服务里开HTTP接口我一般优先用HttpListener而不是ASP.NET Core SelfHost。原因很简单抄表服务不需要完整的MVC生命周期只需要几十个路由和JSON响应HttpListener没有额外框架依赖随便一个.NET版本都能跑。下面这段代码是这个项目里最核心的启动逻辑public class UrlApiServer { private HttpListener _listener; private readonly SemaphoreSlim _serialGate new SemaphoreSlim(1, 1); public void Start(string prefix) { _listener new HttpListener(); // 监听所有网卡的8080端口后台通过 http://网关IP:8080/api/read 访问 _listener.Prefixes.Add(prefix); _listener.Start(); Task.Run(ListenLoop); } private async Task ListenLoop() { while (_listener.IsListening) { HttpListenerContext ctx await _listener.GetContextAsync(); _ HandleRequestAsync(ctx); // 不阻塞监听线程 } } private async Task HandleRequestAsync(HttpListenerContext ctx) { string action ctx.Request.Url.AbsolutePath.Trim(/); string meterNo ctx.Request.QueryString[meterNo]; try { await _serialGate.WaitAsync(); // 串口是独占资源请求必须排队 string result await MeterReader.ReadAsync(meterNo); WriteJson(ctx.Response, result); } catch (Exception ex) { WriteJson(ctx.Response, {\code\:500,\msg\:\ ex.Message \}); } finally { _serialGate.Release(); } } }这段代码说明了几件事。prefix必须以斜杠结尾例如http://:8080/表示监听本机所有网卡的8080口写成固定IP也可以但服务部署后网卡IP可能变化用更省心。请求进来后先取路径中的动作名和查询参数里的meterNo然后通过SemaphoreSlim把并发请求排队避免多个HTTP请求同时去操作串口。MeterReader.ReadAsync是实际走RS-485读水表的方法在下一章展开。这样接口层和抄表层就分开了后台不需要知道水表协议。2.3 URL路径、参数与返回值约定这套源码中的接口设计比较接近REST风格但又更面向内部。下面是我在类似项目里常用的路由规划和这个项目的方向一致URL路径参数说明/api/readmeterNo实时抄读指定水表返回当前读数/api/latestmeterNo读取该表最后一次成功上传的数据/api/historymeterNo, startTime, endTime查询时间段内的采集记录/api/upload无触发一次所有采集器的定时上报参数全部放在查询字符串里返回统一是JSON。/api/read会真正向采集器发抄读帧所以耗时取决于串口等待时间/api/latest不触发硬件操作直接查内存或SQLite毫秒级返回。/api/history适合后台做报表时使用。这样把实时查询和缓存查询分开可以明显减少RS-485总线上的报文数量。对于后台来说只有/api/read需要设置HTTP超时其余接口设置短超时即可。2.4 接口层常见的坑这里要提一个容易踩的坑返回JSON时中文编码。HttpListener默认响应头没有字符集如果直接response.OutputStream写byte[]后台收到的中文可能是乱码。我一般会在WriteJson里加上Content-Type: application/json; charsetutf-8并且用Encoding.UTF8.GetBytes序列化JSON。另一个坑是HTTP请求头部带了Expect: 100-continue客户端发送大数据时协议栈会先等服务端确认如果不处理会导致请求卡住。内网接口报文不大可以在HandleRequestAsync里直接读取并忽略这条头部。这两个问题的表现都挺隐蔽没有日志很难发现。注意Windows防火墙会默认拦截非本机访问部署服务后要放行监听端口例如netsh advfirewall firewall add rule nameWaterMeter dirin actionallow protocolTCP localport8080。3. RS-485采集器通信与GPRS上报抄表服务的两条腿3.1 串口参数与DL/T 645帧结构RS-485是一种半双工总线同一时刻只能有一端发送数据。采集器把所有水表接到这条总线上通过地址来区分具体是哪块表。读取水表数据时C#服务通过串口向采集器发送一帧指令采集器收到后把测量值回填到帧里。虽然项目正文没有明确指定协议版本但水表行业最常见的就是DL/T 645支持表号寻址、数据标识、分项电量/水量。我一般会先按这个协议来解帧。典型的DL/T 645读表帧包括起始符、地址域、控制码、数据域长度、数据标识、校验和、结束符。地址域是6字节的BCD码表示水表表号所以后台传过来的meterNo最终要转成这个格式。帧里没有CRC只有累加和这一点跟Modbus的CRC16完全不同排错时不要混用。帧字段长度说明起始符1字节固定68H地址域6字节BCD码表号例如 11 22 33 44 55 66控制码1字节读数据为01H数据域N字节数据标识加测量值校验和1字节从起始符到数据域的累加和结束符1字节固定16H3.2 C#实现CRC16校验的完整代码虽然DL/T 645用累加和但也有不少采集器厂家在自定义扩展帧里采用Modbus RTU的CRC16。项目里涉及协议适配时CRC代码是绕不开的工具。下面这段CRC16是我常用的标准实现public static class Crc16 { public static byte[] Compute(byte[] data) { ushort crc 0xFFFF; for (int i 0; i data.Length; i) { crc ^ data[i]; for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) { crc (ushort)((crc 1) ^ 0xA001); } else { crc 1; } } } // 低字节在前高字节在后这是RS-485设备的常见字节序 return new[] { (byte)(crc 0xFF), (byte)(crc 8) }; } }这段代码的逻辑是对每个字节做异或再按位右移多项式采用0xA001也就是Modbus RTU的标准多项式。低字节在前是因为多数串口仪表在帧尾先送CRC低字节。如果你在调试时收到错误应答最可能是地址域或数据标识打错了而不是CRC算法错了。把这段方法放进一个ProtocolHelper类里后续写Modbus设备也能直接复用。3.3 SerialPort读写采集器的代码骨架直接用.NET的SerialPort类就能完成RS-485收发。关键是要把读写超时设短不能等服务永远不返回。下面是一段简化后的读取方法public static async Taskbyte[] ReadMeterAsync(string portName, string meterNo) { byte[] command BuildReadCommand(meterNo); // 拼装68H...16H using var port new SerialPort(portName, 2400, Parity.None, 8, StopBits.One); port.ReadTimeout 1500; port.WriteTimeout 1000; port.Open(); port.DiscardInBuffer(); var sw new Stopwatch(); sw.Start(); while (sw.ElapsedMilliseconds 1200) { // 等待前置时间和接收完成 } port.Write(command, 0, command.Length); await Task.Delay(300); // 留给采集器采集和处理的时间 int available port.BytesToRead; if (available 0) return null; byte[] buffer new byte[available]; port.Read(buffer, 0, available); return buffer; }这段代码里有几个值得说的参数。波特率2400是DL/T 645最常用速率也有采集器用9600需要看设备铭牌DiscardInBuffer是为了清掉上次通信的残留数据延时300ms是根据抄表链路时间估算的如果总线挂了很多表可以放大到500ms。BytesToRead读取时注意RS-485可能把一帧分两次送来严谨做法是循环读直到收到16H帧尾或超时。这个项目直接阅读返回一帧在表少、链路短时是可以接受的。提示在WinForm上位机里做循环数据采集时不要直接在UI线程里调用SerialPort.Read阻塞时间会让界面刷新卡顿。正确做法是用后台线程封装取数事件或Task把结果切回UI。本题中的服务没有UI所以更依赖日志和队列。3.4 通过GPRS把数据送到后台抄表管理装置采集器读取完成后服务还要把数据传给后台。GPRS网络在这里扮演一条透明传输通道整条链路实际上是“后台 HTTP → 本地服务 → RS-485 → 采集器 → 水表”。反向数据流则通过与服务建立的TCP长连接实现。下面代码是发送一批抄读结果到后台服务器的例子using var tcp new TcpClient(); await tcp.ConnectAsync(Config.GprsServer, Config.GprsPort); using var stream tcp.GetStream(); string json JsonConvert.SerializeObject(new { meterNo, value, collectTime DateTime.Now }); byte[] payload Encoding.UTF8.GetBytes(json); // 4字节长度头避免粘包 byte[] length BitConverter.GetBytes(payload.Length); await stream.WriteAsync(length, 0, length.Length); await stream.WriteAsync(payload, 0, payload.Length); byte[] head new byte[4]; await stream.ReadExactAsync(head, 4); // 自定义扩展方法确保读满4字节 int responseLen BitConverter.ToInt32(head, 0); byte[] response new byte[responseLen]; await stream.ReadExactAsync(response, responseLen);这里的关键是长度头。GPRS网络不稳定TCP会出现粘包和半包如果按换行符切割JSON一旦网络把两个包粘在一起就会解析失败。用4字节长度头把每个JSON包明确划界后台也按同样方式读取能省掉大量调试时间。ReadExactAsync是扩展方法内部循环调用Read直到读取指定字节数不要直接用stream.Read一次拿完因为Read可能没读满就返回。GPRS长连接还要处理心跳通常每30秒发送一个空的长度头包防止空闲连接被回收。4. 项目文件与Windows服务安装把WaterMeterReadingSvc跑起来4.1 解决方案文件结构项目正文里可以看到几个关键文件_InstallService.bat和_UnInstallService.bat是服务安装与卸载脚本ResolveAssemblyReference.cache是Visual Studio构建过程中生成的引用解析缓存源码包里保留它能让重编译时少做一次引用扫描但它不是运行必需文件Utils.csproj说明解决方案里有一个工具类库通常负责配置读取、日志、字节转换。看到这个结构基本可以判断这是一个标准Windows服务项目入口程序是WaterMeterReadingSvc.exe工具方法在Utils里。4.2 使用InstallUtil安装C# Windows服务Windows服务不能像控制台程序那样直接双击运行必须先用InstallUtil注册到服务控制管理器。安装脚本一般长这样echo off %SystemRoot%\Microsoft.NET\Framework\v4.0.30319\InstallUtil.exe WaterMeterReadingSvc.exe net start WaterMeterReadingSvc pause卸载脚本则是echo off net stop WaterMeterReadingSvc %SystemRoot%\Microsoft.NET\Framework\v4.0.30319\InstallUtil.exe /u WaterMeterReadingSvc.exe pause说明一下第一行命令把exe里的ServiceInstaller配置写入系统服务第二行net start立即启动服务避免还要去服务管理器里点。路径里的v4.0.30319对应.NET Framework 4.x如果你的环境是64位系统并且服务是AnyCPU编译可能需要改用Framework64目录下的InstallUtil否则注册表视图不一致会报“找不到安装程序”错误。批处理里没有写sc create说明服务属性由ServiceInstaller代码控制这是更推荐的做法。另外安装脚本要在Windows服务exe所在目录里执行否则InstallUtil找不到文件。之前有人问过用VS2019编译的上位机源码能不能用VS2015打开。这个问题在这类C#源码包里经常出现。只要项目没有使用新版语言特性或被升级到更高框架版本VS2015能打开但如果项目文件被VS2019升级过.csproj里的ToolsVersion会变VS2015会拒绝加载。解决方案是不要急着升级先看目标框架是不是4.0或者4.5再决定用什么IDE。4.3 App.config中的抄表服务配置项服务运行时需要读配置。这个项目的配置集中在App.config中下面是常见的关键配置项配置键示例值作用ListenPrefixhttp://:8080/URL接口监听前缀SerialPortNameCOM3连接采集器的串口号BaudRate2400RS-485波特率DataBits8数据位ParityNone校验位StopBits1停止位GprsServer120.10.10.10后台抄表管理装置的GPRS服务器IPGprsPort9000GPRS服务器端口PollingInterval300定时抄收间隔单位秒配置读取代码通常写在Utils里。我一般会封装一个强类型配置类避免在业务逻辑里到处写ConfigurationManager.AppSettings[SerialPortName]读出来还全是字符串。下面是简化的实现public static class AppConfig { public static string ListenPrefix ConfigurationManager.AppSettings[ListenPrefix]; public static string SerialPortName ConfigurationManager.AppSettings[SerialPortName]; public static int BaudRate int.Parse(ConfigurationManager.AppSettings[BaudRate]); public static string GprsServer ConfigurationManager.AppSettings[GprsServer]; public static int PollingInterval int.Parse(ConfigurationManager.AppSettings[PollingInterval]); public static void Validate() { if (string.IsNullOrWhiteSpace(SerialPortName)) throw new ConfigException(串口号未配置); if (BaudRate 0) throw new ConfigException(波特率非法); } }Validate方法在服务启动时先跑一遍把配置问题直接暴露出来而不是等到抄表时才发现串口打不开。配置项越多越需要这么一道校验。另外ListenPrefix如果写成http://127.0.0.1:8080/外网后台将无法访问我见过不少部署现场卡在这改回就通了。4.4 服务启动失败时先看这三处第一处是事件查看器。Windows服务的未捕获异常会记录在“应用程序”日志里ServiceBase.OnStart里任何异常都会导致启动失败但控制台没有任何输出所以要在OnStart里包一层try-catch写日志文件。第二处是端口冲突。用netstat -ano | findstr 8080看有没有其他进程占用了监听端口。第三处是串口状态。如果采集器没有上电SerialPort.Open不会失败但第一次Read会超时服务却已经开始运行容易造成误判。常见做法是在服务启动后做一次自检主动向采集器发一帧测试命令返回失败就在日志里标记“采集器不在线”而不是继续假装正常工作。5. 进阶让定时抄收服务稳定得多5.1 用Timer加队列代替单纯的Thread.Sleep后台管理装置需要定时收到所有水表的数据最原始的做法是开一个while (true) { Thread.Sleep(interval); 抄表(); }。这个写法在串口操作比较快时可以工作但遇到某块表超时会立刻拖慢整体节奏。更可靠的做法是使用System.Timers.Timer加上一个待抄队列private readonly ConcurrentQueuestring _pendingMeters new ConcurrentQueuestring(); private void OnPollingTimer(object sender, ElapsedEventArgs e) { var meters GetMeterListFromConfig(); foreach (string m in meters) { _pendingMeters.Enqueue(m); } }这样每次定时器触发时只是把表号入队真正执行抄表的线程从队列里取表号逐个处理。如果某块表连续失败就把它放回队尾重试而不是卡住后面所有表。队列用ConcurrentQueue保证多线程安全也方便随时把后台指定表号的查询插进去。5.2 失败重试加指数退避抄表失败很常见GPRS抖动、采集器正在处理上一帧、水表被电磁干扰。直接把失败记录丢掉会让后台报表缺数据不丢又会造成总线拥塞。我一般会记录每块表的失败次数重试间隔按2^n秒增长最多退避到256秒public class MeterRetryInfo { public string MeterNo { get; set; } public int RetryCount { get; set; } public DateTime NextRetryTime { get; set; } } public bool NeedRetry(MeterRetryInfo info) { int intervalSeconds Math.Min(256, 2 * (int)Math.Pow(2, info.RetryCount)); return DateTime.Now info.NextRetryTime; }这里的关键是把这个状态保存在内存而不是数据库因为抄表服务重启后自动丢弃失败记录并不影响后续轮询。给每块表维护一个NextRetryTime就不会在总线繁忙时连续冲击采集器。之前有人直接固定5秒重试结果几块坏表把整个抄表周期拖到十分钟改成指数退避后才恢复。这个思路和TCP拥塞控制的退避类似放在C#物联网采集服务里非常实用。5.3 验证服务是否正常的手段最后给一个验证技巧在没有真实水表的环境里用串口调试工具模拟采集器。把电脑上的两个串口用USB转串口线连在一起服务读COM3调试助手监听COM4这样就能看到服务发出的帧是否合法。如果调试助手上收到68 11 22 33 44 55 66 01 02 43 C3 16这样的帧说明URL接口和串口链路都已打通。真正部署前再用后台的B/S抄表管理装置发一条查询指令观察服务日志里是否出现“查询成功”和上报时间。把调试助手收到的帧和DL/T 645标准逐字节对照帧头、地址、控制码、数据长度分别看马上能定位是地址拼错还是校验和写错。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价