尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
C#水表抄表服务:URL接口与串口Modbus并发采集实战
简介这套C#项目源码围绕智能水表远程抄表场景构建采用B/S架构通过RS-485总线采集水表数据并借助GPRS网络与后台管理系统通信支持按指令查询任意水表信息与定时上报数据。适合正在学习C#网络编程、物联网数据采集或希望了解行业级抄表系统设计的开发者参考。资源包共186个文件大小17.58MB其中包含22个.cs源码文件、38个dll程序集、14个config配置文件、4个sql数据库脚本、5个exe可执行程序及23个pdb调试符号同时提供安装/卸载服务脚本和项目工程文件便于直接编译与调试。已有64人学习浏览可作为C#实战编程案例重点理解URL接口如何封装设备数据读写、GPRS通信链路如何处理以及定时任务与后台接口协作的实现思路对完整跑通一个真实物联网项目很有帮助。1. C# 的 WaterMeterReadingSvc先弄清楚这个服务在解决什么水表抄表服务这种 C# URL 接口项目真正的难点不在 URL 本身而在 URL 背后的串口链路一条 RS485 总线上挂 32 块表抄一块表最少要 50100ms串口天然串行HTTP 请求却可以并发十几个请求同时打过来接口层稍不设防就是 408、502。这个服务要处理三件事把 URL 参数翻译成 Modbus 指令、用正确的串口参数按时序读回数据、再把寄存器字节换算成吨数落库。下文按“接口边界 → 采集模块 → 并发改造 → 排错 → 自测”展开适合被 WinForms 刷新卡顿、Modbus 抄表超时折腾过的工程师直接抄作业。2. URL 接口层与串口采集层C# 服务的边界拆法和 URL 路由设计先讲边界。WaterMeterReadingSvc 这个名字继承自 .svc 时代但完全没必要用 WCF 重写把它当普通 ASP.NET Core Web API 看待就行.svc 只是一个资源命名习惯。接口层和采集层是两拨不同的工程边界接口层负责 URL 解析、参数校验、状态码表达采集层负责串口打开、组帧、等待响应、超时重试。两层之间用一个后台单例的 MeterReader 盒子隔开。2.1 数据链路RS485 水表、串口服务和 URL 接口如何对接最常见的部署形态是本地表水表走 RS485 总线接到一个 485 转串口模块USB 转 485 或 PCIe 串口卡服务部署在工控机或服务器上通过 COM 口读取。另一类 NB-IoT 表走运营商平台服务通过 HTTP 回调收数协议完全不同。这里先说本地表。总线拓扑决定了设计RS485 是半双工同一时刻只能有一个主站发出请求从站依次应答。所以抄表服务在同一链路上只能串行访问比如 32 块表轮询一遍按每块表 100ms 算就是 3.2 秒起。这个数字直接决定你 URL 接口的超时语义一个/api/meters/{id}/reading请求可能要在队列里等 3 秒以上接口不能像普通 CRUD 那样默认为 30 秒也不能设成 1 秒就扔异常。采集层必须暴露给接口层的不是“串口对象”而是一个带信号量的异步方法public class MeterReader { private readonly SemaphoreSlim _gate new(1, 1); private readonly SerialPort _port; public async Taskfloat ReadAsync(byte slaveId, ushort start, ushort length) { await _gate.WaitAsync(); // 串行化整条总线访问 try { // 打开串口、发命令、读回执、解析寄存器都在这里 } finally { _gate.Release(); } } }这里用 SemaphoreSlim 而不是 lock是因为 ReadAsync 内部要配合等待串口数据到达必然用到 async/awaitlock 在 await 上下文里不能持有。SemaphoreSlim.WaitAsync() 让并发请求在队列里排队而不是同时去抢一个 COM 口这是整套服务并发安全的基石。至于串口是否常开我一般常开只在异常时 Close 再重试。频繁 Open/Close 会使 485 芯片的状态机不稳定而且水表在收到乱帧后会有一段时间不响应开关串口很容易把“总线上有数据”误判成“端口坏了”。2.2 用最小 API 把读取动作暴露成 GET 接口ASP.NET Core 6 的最小 API 足够支撑这个场景不需要 Controller。一个 GET 接口只有几十行适合做内部工具型服务如果要加鉴权、OpenAPI 文档、版本化再往 Controller 迁移也不迟。下面是一个最小可跑的入口var builder WebApplication.CreateBuilder(args); builder.Services.AddSingleton(new MeterReader(COM3, 2400, Parity.Even)); var app builder.Build(); app.MapGet(/api/meters/{slaveId}/reading, async (byte slaveId, MeterReader reader) { if (slaveId is 1 or 247) return Results.BadRequest(new { error slaveId must be 1..247 }); try { var value await reader.ReadAsync(slaveId, 0, 2) .WaitAsync(TimeSpan.FromSeconds(3)); return Results.Ok(new { slaveId, value, unit m³, readTime DateTime.Now }); } catch (TimeoutException) { return Results.Json(new { error slave timeout }, statusCode: 504); } }); app.Run();参数说明slaveId是 Modbus 站号范围 1247WaitAsync的 3 秒是接口整体兜底比内部串口超时500ms大一个量级捕获 TimeoutException 返回 504 而不是 500前端和监控系统就能区分“从站不回帧”和“程序异常”。注意 Results.Json 直接用匿名对象保持返回结构一致不要一会儿 Ok 一会儿 BadRequest 返回不同字段结构。2.3 URL 接口的三个约定可查、可调、可订阅内部接口不要设计太多花样三个语义就够了。下表是三种典型 URL 形态和它们的数据来源语义路径数据来源适用场景可查GET /api/meters内存缓存列表展示、看板可调GET /api/meters/{id}/reading实时串口单表调试、人工抽检可订阅GET /api/meters/{id}/readings数据库历史查询、报表约定补一句实时抄读的接口要有频率限制常见做法是给同一个 slaveId 加一个简单的滑动窗口比如 10 秒内最多 1 次因为人工点按钮不会比这更快但脚本循环可以一秒钟打几十次请求直接把总线打满。实现滑动窗口最省事的是用System.Threading.RateLimiting的固定窗口限流器按 slaveId 分桶避免所有表共享一个限流池导致一块表超时拖累全部接口。这一层不写的话等上位机接入后出现大面积 504再回头加就要改不少东西。3. 把水表读取封装成可复用的 C# 采集模块Modbus RTU 与寄存器换算3.1 用 NModbus4 或手写帧直读水表最省事的方式是从 NuGet 装 NModbus4这是老牌 Modbus 串口库网上搜索“c# nmodbus4”能翻到大量现成示例稳定压倒一切。API 的核心对象是 ModbusSerialMaster.CreateRtu(serialPort)创建后直接调 ReadHoldingRegisters 读保持寄存器。水表绝大多数用 Modbus RTU、功能码 03少数用功能码 04 读输入寄存器采购时确认一下表型手册。NModbus4 的读寄存器的完整调用如下using Modbus.Device; using System.IO.Ports; public class MeterReader { private readonly SerialPort _port; private readonly object _syncRoot new(); public MeterReader(string portName, int baudRate 2400, Parity parity Parity.Even) { _port new SerialPort(portName, baudRate, parity, 8, StopBits.One) { ReadTimeout 500, WriteTimeout 500 }; _port.Open(); } public ushort[] ReadRegisters(byte slaveId, ushort start, ushort length) { lock (_syncRoot) { var master ModbusSerialMaster.CreateRtu(_port); return master.ReadHoldingRegisters(slaveId, start, length); } } }这段代码的关键参数有三处波特率必须和表一致常见是 2400少数是 9600校验位绝大多数是 Even但个别表用 NoneReadTimeout 设 500ms水表响应比 PLC 慢太短会误报超时太长会让轮询周期拉长。注意 NModbus4 的历史遗留问题它的内部用了同步串口读写所以外面套 lock 保证同一时刻只有一个线程访问串口不要改成 async 版本否则盖子底下还是同步阻塞而且 lock 和 async 混用容易死锁。另一个常见坑是包装反射ModbusSerialMaster.CreateRtu 每次调用都创建一个新的 master 对象但底层是同一个 SerialPort。驱动内核对读写有内部缓冲不需要每次重建 master同一个对象反复用即可如果每次调用都 new偶发收到错帧。3.2 关键参数站号、功能码、起始寄存器与长度参数常见值说明站号 slaveId1247出厂默认多为 1总线上有多块表时需用厂家工具改功能码03 / 0403 读保持寄存器多数水表用这个04 读输入寄存器起始寄存器0 或厂商定义表头、瞬时流量、累计流量、状态字对应不同地址寄存器长度2 或 4累计流量一般是 2 个寄存器4 字节 float或 4 个寄存器抄表业务里最常踩的坑就是把其它电表、气表的协议硬套到水表上。水表厂商一般不公开完整寄存器表采购时要问厂家要“Modbus 通信协议说明”里面会标注每个地址对应的意义比如 0x0000 是累计流量、0x0002 是瞬时流量。我一般会把寄存器定义做成一个字典配置public record MeterPoint(byte SlaveId, ushort Register, ushort Length, string Name); var points new ListMeterPoint { new(1, 0, 2, total_flow), new(1, 2, 2, instant_flow), new(2, 0, 2, total_flow) };把协议描述从代码里抽出来后面厂家的寄存器表有细微差异时只改配置不用编译发布。这个模式和写上位机一样协议配置一定要和数据展示解耦。3.3 从寄存器字节到吨数两种数据格式的处理NModbus4 返回 ushort[]两个寄存器组成 32 位数据。水表常见两种编码方式一个是 IEEE 754 单精度浮点数另一个是无符号 32 位整数乘以倍率。下面是两段对应处理float FromIeee754(ushort[] regs) { // 大端序高寄存器在前低寄存器在后 uint raw ((uint)regs[0] 16) | regs[1]; return BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); } decimal FromUInt32(ushort[] regs, int scale) { uint raw ((uint)regs[0] 16) | regs[1]; return raw / (decimal)scale; // scale1000 表示寄存器的单位是升 }说明IEEE 754 的 float 常见于品牌超声水表但存在字节序因厂商而异的情况如果读出来是小端序把 regs[0] 和 regs[1] 调换再拼。无符号整数模式里 scale 通常 1000 或 100直接看协议文档的单位定义。拿到原始数据后再分发给 UI、数据库和接口层不要让字节数组越过采集层否则每个用数据的模块都要重新处理一遍字节序就是灾难。4. C# 循环数据采集与 UI 刷新卡顿异步化改造的关键参数4.1 为什么循环采集一加 UI 就卡线程模型问题搜索“c# 循环数据采集和ui刷新卡顿”能发现大量同类问题上位机界面打开后开始采数据界面像死了一样点按钮没反应鼠标变转圈。根因几乎都是把串口读取放到了 UI 线程里。WinForms 的主线程负责消息泵消息循环在它里面执行 ReadTimeout 为 500ms 的同步读界面就卡 500ms如果轮询里还有数据库插入和记录集刷新卡顿会叠加到秒级。这个问题的正确解法是把采集循环从 UI 线程剥离放到后台线程或 BackgroundService 里采集完成后通过 SynchronizationContext 把数据回发到 UI 线程。WinForms 的 Control.BeginInvoke 可以做到但用ProgressT更干净因为它在构造时捕获当前同步上下文Report 时自动调度到主线程代码里不用持有任何控件的引用。4.2 用 Channel BackgroundService 做异步采集单线程轮询和 UI 刷新之间需要一个缓冲区和明确的调度边界标准做法是 Channel BackgroundServicepublic class ReadingCollector : BackgroundService { private readonly ChannelReadingRecord _channel Channel.CreateBoundedReadingRecord(new BoundedChannelOptions(2000) { FullMode BoundedChannelFullMode.DropOldest }); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // 生产者按轮询周期/定时器把读取结果写入 channel while (!stoppingToken.IsCancellationRequested) { var reading await ReadOneAsync(); // 串口读取 _channel.Writer.TryWrite(reading); // 非阻塞写不进去就丢弃旧记录 } } public IAsyncEnumerableReadingRecord Subscribe( [EnumeratorCancellation] CancellationToken ct) _channel.Reader.ReadAllAsync(ct); }UI 侧订阅await foreach (var reading in _collector.Subscribe(ct)) { dataGridView1.Rows.Add(reading.MeterId, reading.Value, reading.ReadTime); // 这里已经回到 UI 线程因为 Subscribe 是在 UI 上下文里被 await 的 }参数说明BoundedChannelFullMode.DropOldest表示当消费者处理不过来时丢弃最旧数据保留新数据这对抄表场景是合理的相反Wait模式会让写数据卡住间接把串口读取循环拖慢不合理。容量 2000 条意味着即使 UI 卡顿好几秒数据也不会立即丢失但如果数据只看最新值容量可以降到几百。注意 Channel 的 Reader 被多个消费者同时迭代时不安全UI 和数据归档各用一个 Channel不要共享同一个。Channel 的三种满员策略直接决定“卡”的表现形态FullMode行为适合场景Wait写端阻塞直到有空间一条都不能丢的归档DropOldest丢弃最旧数据实时显示最新仪表数DropNewest丢弃最新数据保留早到的记录4.3 写库策略按批 INSERT 而不是逐条执行采集高频写库时逐条 INSERT 是性能杀手。32 块表、每 10 秒一轮一天会产生 27 万条记录逐条执行在三五年后的历史库上会越来越痛。常见的轻量方案是 Dapper 分批 INSERTINSERT INTO readings (meter_id, value, read_time) VALUES (MeterId, Value, ReadTime)foreach (var batch in records.Chunk(200)) { await connection.ExecuteAsync(sql, batch); }参数说明Chunk(200) 把 200 条记录合成一次数据库往返MeterId、Value、ReadTime 是 Dapper 按参数名自动绑定的对象属性。真正要长期存的数据建议按天分区SQL Server 的表分区或者简单用CREATE TABLE readings_20260318 AS ...的方式都行查询历史读数时扫分区而不是全表。5. URL 接口排错从 404、502 到串口超时的排查顺序5.1 URL 标准格式与路由匹配先排除这两类低级错误接口一旦报 404先别急着怀疑服务挂了。URL 标准格式由 scheme、host、port、path、query 组成常见的低级错误有三种一是路由模板写的是/api/meters/{slaveId}/reading调用方却把参数拼在 query 上/api/meters/reading?slaveId1路由对不上直接 404二是路径里带了空格或中文没有做 URL 编码导致 400三是端口号写错比如 Copy 了别人的配置但服务实际监听的是 5210结果连 127.0.0.1 都连不通。这类问题可以用一个简单的 C# 校验器先挡一道bool IsValidUrl(string raw) { if (string.IsNullOrWhiteSpace(raw) || raw.Contains( )) return false; return Uri.TryCreate(raw, UriKind.Absolute, out var uri) (uri.Scheme Uri.UriSchemeHttp || uri.Scheme Uri.UriSchemeHttps); }说明Uri.TryCreate会顺带检查端口号范围端口非法时返回 false避免走到 socket 层才报错。空格检查放在前面是因为 URI 里空格必须编码成 %20但Uri.TryCreate对空格的容忍度在不同 .NET 版本上表现不一致显式检查更稳。浏览器端做输入校验时用 JS 的 URL 构造函数拦截C# 服务端用 Uri.TryCreate两边校验都通过再放行错误 URL 根本到不了串口层。5.2 用 curl 和 Invoke-RestMethod 看 404、502、504 的区别本地调试时用 curl 看状态码和响应头curl -i http://127.0.0.1:5210/api/meters/1/reading # 200 OK正常返回 # 404 Not Found路由没有命中或服务端口听的不是这个 # 504 Gateway Timeout下游串口没回帧接口层按超时兜底 # 502 Bad Gateway网关YARP/Nginx拿不到下游响应服务进程可能退了-i参数会输出响应头重点看Content-Type和响应体里的 error 字段。如果接口前面套了 YARP502 多数是下游应用崩溃或端口没监听先看 Windows 事件日志和进程是否存活504 则是请求确实进了服务但执行超过了网关超时这时候要去查串口日志看是不是从站没有应答。一个有用的做法是接口返回时把 readTime 和耗时也带上app.MapGet(/api/meters/{slaveId}/reading, async (byte slaveId, MeterReader reader) { var sw Stopwatch.StartNew(); try { var value await reader.ReadAsync(slaveId, 0, 2); return Results.Ok(new { slaveId, value, elapsedMs sw.ElapsedMilliseconds }); } catch (TimeoutException) { return Results.Json(new { error slave timeout, elapsedMs sw.ElapsedMilliseconds }, statusCode: 504); } });把elapsedMs放进响应体压测时只看这个数字变化就能判断是总线上轮询拥堵还是某个从站本身慢。日志和响应体各写一份线上排查不用翻文件也能从接口返回里看到耗时。5.3 串口超时日志与 502/504 的关联分析排查顺序应该固定成一条链先确认 URL 拼写、再确认端口与服务进程、最后才看协议栈。真正进入协议层后看串口日志要抓三个信息从站号、重试次数、发包到收包的毫秒数。日志里出现“slave 5 timeout after 500ms”且反复出现说明 5 号表掉线或地址不对出现“port COM3 access denied”说明串口被另一个进程占用常见是 Modbus 调试工具没关出现“parse error: crc mismatch”说明链路干扰或波特率不匹配优先检查 485 屏蔽层接地和总线终端电阻。把串口层和 HTTP 层的日志放在一起看能很快定位 502/504 是哪个环节现象优先排查定位手段404路由模板和 URL 编码浏览器直接访问接口路径502服务进程/监听端口看 Windows 事件日志504串口从站超时看采集日志的 timeout 记录503限流或依赖服务不可用看 RateLimiter 日志和下游连接池如果 HTTP 层耗时接近 3 秒上限且下游日志有 timeout就是设备层问题如果 HTTP 层 502 且没有任何请求日志就是应用进程根本没接到请求问题在网络层和网关层。6. 验证与参数固化WaterMeterReadingSvc 上生产前的自测清单最后给出一个可以立刻照抄的自测脚本和参数表。我一般会把它们放在项目根目录的 tools/ 下每次调整协议参数或路由后手动跑一遍再塞进 CI 冒烟测试里把它们当成上生产前的固定动作。PowerShell 批量自测脚本$base http://127.0.0.1:5210 1..5 | ForEach-Object { $id $_ try { $r Invoke-RestMethod -Uri $base/api/meters/$id/reading -TimeoutSec 5 slave $id - $($r.value) m³, elapsed $($r.elapsedMs) ms } catch { slave $id - ERROR $($_.Exception.Message) } }-TimeoutSec 5一定要大于接口内部兜底超时3 秒否则 Invoke-RestMethod 先抛异常掩盖服务端的真实响应。脚本输出里同时打 value 和 elapsedMs看单块表耗时是否随并发升高而增加如果 slave 1 的耗时从 50ms 涨到 1500ms说明 SemaphoreSlim 队列已经积压要么减少轮询频率要么把实时抄读接口拆到独立串口通道。串口与接口的关键参数建议按下面的表固化到 appsettings.json参数推荐值备注串口波特率2400 / 9600以水表铭牌为准改动需重启服务数据位 / 停止位 / 校验8 / 1 / Even少数表用 None按厂家手册调整ReadTimeout500 ms表越老响应越慢调大后轮询周期变长轮询间隔1000 ms 以上总线上有 32 块表时轮询周期约 32 秒Channel 容量2000FullMode 用 DropOldest批 INSERT 大小200 行/批大批量会拖长事务HTTP 整体超时3000 ms比 ReadTimeout 大一个量级参数固化用 IOptions 绑定比如builder.Services.ConfigureMeterBusOptions(builder.Configuration.GetSection(MeterBus));然后在 MeterReader 构造时注入。把波特率、站号列表、轮询间隔全部放配置后现场调协议时不需要重新编译只改 appsettings.json 重启服务即可。最后一个小技巧把每轮采集结果写成一份带序号和耗时的心跳文件比如heartbeat.json记录本轮完成时间、成功块数、超时块数、最长单表耗时。UI 界面刷新和后台采集各自独立心跳文件只由采集服务写UI 侧读这个文件就能知道采集是否还活着比看“数据在不在动”更可靠。部署到现场后一旦出现抽象的网络超时先看这份心跳文件马上就能判断是链路断了还是采集循环死了。本文还有配套的精品资源点击获取
RELATED

相关推荐

STM32智能注射泵:电机控制与脉搏监控系统详解

STM32智能注射泵:电机控制与脉搏监控系统详解

简介:面向医疗电子与嵌入式开发者的智能注射控速系统工程资料,以STM32为主控核心,结合电机控制、脉搏监测与LCD人机交互,实现药物注射速度的精确自动调节,适合需要学习STM32外设驱动、PWM调速及传感器信号处理的人群。…

📅 2026/9/15 23:16:37
爱普生L8058与L8168对比:ICC校色文件安装验证全指南

爱普生L8058与L8168对比:ICC校色文件安装验证全指南

最近被问得最多的两台打印机,一个是爱普生L8058,一个是L8168。问的人基本都带着同一个问题:差价摆在那里,贵的到底值不值?我自己的答案是:如果只打彩色A4照片,L8058完全够用;如果你经…

📅 2026/9/15 23:16:37
交直流混联电网潮流计算:统一迭代法的原理与Matlab实现

交直流混联电网潮流计算:统一迭代法的原理与Matlab实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/9/15 23:16:36
MORE NEWS

更多资讯

📰

Flame 跨平台支持与 Web 部署指南:GitHub Pages、itch.io 与 Cloudflare Pages 全流程实战

Flame 跨平台支持与 Web 部署指南:GitHub Pages、itch.io 与 Cloudflare Pages 全流程实战 【免费下载链接】flame A Flutter based game engine. 项目地址: https://gitcode.com/GitHub_Trending/fl/flame Flame 作为运行在 Flutter 之上的游戏引擎&#xf…

📰

awesome-codex-skills 实战:基于 Notion 高级搜索技术,为 Codex 研究文档工作流精准定位信息源

awesome-codex-skills 实战:基于 Notion 高级搜索技术,为 Codex 研究文档工作流精准定位信息源 【免费下载链接】awesome-codex-skills A curated list of practical Codex skills for automating workflows across the Codex CLI and API. 项目地址: …

📰

Spring全家桶高效学习路线:从IoC/DI到微服务实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

UART回环测试假通过:寄存器配置与电气鲁棒性深度解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

一文读懂有线通信标准:以太网、RS-485、光纤等选型与排查指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

LifeOS Migrate 技能深度解析:外部存量内容智能摄入、分类路由与带溯源入库实战

LifeOS Migrate 技能深度解析:外部存量内容智能摄入、分类路由与带溯源入库实战 【免费下载链接】LifeOS ⛰️ The Life Operating System — an intent engineering platform that moves you from your current state to your ideal state, in life and work. 项…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬