C# WPF工控大屏看板:Modbus TCP采集、MVVM与断线重连实战
简介:面向物联网工控可视化开发者的 C# WPF 大屏看板源代码,围绕 Modbus TCP 协议实现设备数据采集、实时刷新与图表化展示,适合具备一定 WPF、C# 基础并希望掌握工控大屏落地实现的中级开发者参考。压缩包共 453 个文件、约 3.06MB,以 cs 源码、xaml 界面、dll 依赖库、json 配置及 cache 构建缓存为主,同时含有 exe、sln 和项目说明文档,便于直接打开工程梳理模块关系与编译流程。已有 1557 人学习下载。代码涉及 Modbus TCP 通信封装、数据清洗转换、数据绑定、MVVM 分层、仪表盘和看板布局、定时刷新、异常处理与性能测试等环节,可对照学习设备数据接入、界面组织、运行调试和问题排查。其样式模板、Storyboard 动画及 Dispatcher 更新机制也可为完善大屏交互与实时性提供可复用思路,项目结构、通信参数配置、数据模型与视图模型的组织方式亦有参考价值。
1. 从一条卡死的产线看板说起
凌晨两点,班组群里弹出一条消息:大屏上的产能数字停在 1280 不动了,现场以为设备停机。远程连上去翻日志,Modbus TCP 那一侧全是读超时,UI 线程被同步等待拖住,整个看板进入事实上的假死状态。这个场景基本能概括 C# WPF 做物联网工控大数据大屏看板最容易翻车的地方:数据可视化只是最后一公里,前面还得有一条扛得住断线、错帧和字节序的 Modbus TCP 采集链路。这套源代码把两层捏在一个工程里,WPF 负责大屏渲染与交互,后台服务按固定节拍轮询 PLC 和仪表,适合做产线看板、能耗监控、设备状态墙的上位机开发者,也适合刚接手工控项目、需要一份能跑起来的骨架的人。
2. 工程骨架与目录结构:MVVM 把采集和界面彻底拆开
2.1 分层方式与目录长什么样
工控上位机最常见的坏味道是把 Modbus 读写直接写在窗体的按钮事件里,一开始只有三个点位时看不出问题,等到点位涨到两百个、还要加历史曲线和报警,代码就会变成一团没法测的东西。这套工程的拆法是按依赖方向切的:Core 层只依赖 Modbus 通讯库和 JSON 序列化,一行 WPF 的引用都没有;App 层才引入 WPF,负责窗口、样式和绑定。好处很直接,现场怀疑某个寄存器解析不对时,可以把 Core 单独拎出来写个控制台程序跑一遍,不用启动整个大屏。
IotDashboard/ ├─ IotDashboard.sln ├─ src/ │ ├─ IotDashboard.App/ # WPF 主程序 │ │ ├─ App.xaml / App.xaml.cs # 启动装配 │ │ ├─ Views/ │ │ │ ├─ MainWindow.xaml │ │ │ └─ DashboardView.xaml # 1920x1080 设计分辨率的大屏主体 │ │ ├─ ViewModels/ │ │ │ ├─ DashboardViewModel.cs │ │ │ └─ DeviceCardViewModel.cs │ │ ├─ Controls/ # 仪表盘、状态灯、数码管等自绘控件 │ │ ├─ Themes/ # 深色主题与报警色阶 │ │ └─ Assets/ │ ├─ IotDashboard.Core/ # 采集与业务,无 UI 依赖 │ │ ├─ Modbus/ │ │ │ ├─ ModbusTcpClient.cs # 连接、超时、重连 │ │ │ ├─ ModbusPoller.cs # 轮询调度与结果分发 │ │ │ └─ ByteOrder.cs # 字节序/字序重组 │ │ ├─ Models/ │ │ │ ├─ PointConfig.cs │ │ │ └─ Sample.cs │ │ ├─ Services/ │ │ │ ├─ PointConfigLoader.cs │ │ │ └─ AlarmEvaluator.cs │ │ └─ Storage/ │ │ └─ SampleRepository.cs │ └─ IotDashboard.Tests/ └─ config/ └─ points.json # 点位表,现场改这个文件即可这个结构里有两个约束值得说明。第一,IotDashboard.Core的 csproj 里不要出现任何UseWPF为 true 的引用,否则很快就会有Dispatcher混进采集逻辑,后面想把这套采集搬到 Windows 服务里就搬不动了。第二,config/points.json放在可执行文件旁边的 config 目录,而不是打进程序集资源,现场工程师改点位不需要重新编译,这是工控项目里省掉大量返工的一条血泪经验。
2.2 启动装配与 MVVM 绑定骨架
很多 WPF 项目一上来就挂 Prism 或者完整的 DI 容器,对小规模看板其实是负担。这套代码用的是手工装配,入口在App.xaml.cs,装配顺序是配置 → 通讯客户端 → 轮询器 → ViewModel → 窗口,整个过程一目了然,出问题的时候断点打在哪很清楚。
// App.xaml.cs —— 手工装配,避免为一个看板引入完整 DI 框架 protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); // 1. 读点位表:主机、端口、超时、点位列表全在这里 var config = PointConfigLoader.Load("config/points.json"); // 2. 通讯客户端:只负责连接与单次读写,不含调度 var client = new ModbusTcpClient(config.Host, config.Port, config.TimeoutMs); // 3. 轮询器:按 intervalMs 的节拍调度读取,结果通过事件抛出 var poller = new ModbusPoller(client, config.Points, intervalMs: 200); // 4. ViewModel 订阅轮询结果,窗口只认 DataContext var vm = new DashboardViewModel(poller, new AlarmEvaluator(config.Points)); var window = new MainWindow { DataContext = vm }; window.Show(); // 5. 采集异步启动,UI 线程不等它,避免启动时白屏 _ = poller.StartAsync(); }这里intervalMs: 200意味着 5Hz 轮询,是工控大屏里比较稳妥的取值:刷新肉眼够快,同时对 PLC 的通讯负载可控。config.TimeoutMs一般设 1000 到 3000 之间,设太小会在网络抖动时疯狂超时,设太大则断线后要等很久才能感知。第 5 步用_ =丢弃 Task 是刻意的,采集循环内部自己兜异常,不让一个未观察的 Task 异常把进程带崩。
ViewModel 侧用 CommunityToolkit.Mvvm 的源生成器写法,属性声明短,绑定路径清晰:
// DashboardViewModel.cs 片段 public partial class DashboardViewModel : ObservableObject { [ObservableProperty] private double _spindleSpeed; // 绑定到仪表盘指针 [ObservableProperty] private int _outputCount; // 绑定到数码管 [ObservableProperty] private bool _isOnline; // 绑定到通讯状态灯 private readonly ModbusPoller _poller; public DashboardViewModel(ModbusPoller poller, AlarmEvaluator alarm) { _poller = poller; // 注意:这个事件在采集线程上触发,不能直接改绑定属性 _poller.SamplesReady += OnSamplesReady; } private void OnSamplesReady(object? sender, IReadOnlyList<Sample> samples) { // 交给 UI 线程后再更新属性,Dispatcher 部分见第 3 章 Application.Current.Dispatcher.Invoke(() => { foreach (var s in samples) { switch (s.Key) { case "spindleSpeed": SpindleSpeed = s.Value; break; case "outputCount": OutputCount = (int)s.Value; break; } } IsOnline = _poller.IsConnected; }); } }[ObservableProperty]会在编译期生成SpindleSpeed属性和PropertyChanged通知,XAML 里直接写{Binding SpindleSpeed}就行。Sample是一个极简结构,包含Key、Value、Timestamp三个字段,Key与点位表里的key一一对应,这层映射做完之后 UI 就完全不关心寄存器地址了。
2.3 点位配置化:把寄存器地址从代码里赶出去
现场最常见的需求变更不是改界面,而是加一个点位。如果地址写死在 ViewModel 里,每加一个传感器就要重新发版,产线还不一定允许你停。把这部分抽成 JSON 之后,加点位就是编辑文本文件再重启看板。
{ "host": "192.168.1.10", "port": 502, "timeoutMs": 1500, "points": [ { "key": "spindleSpeed", "name": "主轴转速", "slaveId": 1, "address": 0, "type": "UInt16", "scale": 1, "unit": "rpm", "alarmHigh": 8000, "alarmLow": 0 }, { "key": "totalPower", "name": "总功率", "slaveId": 1, "address": 10, "type": "Float32", "wordOrder": "CDAB", "scale": 0.1, "unit": "kW", "alarmHigh": 120 }, { "key": "outputCount", "name": "累计产量", "slaveId": 1, "address": 20, "type": "UInt32", "wordOrder": "ABCD", "scale": 1, "unit": "pcs" } ] }几个字段的实际含义要交代清楚:address填的是协议地址,不是手册上常见的 40001 那种编号,两者的差值 1 是新手最容易踩的坑,下一章会细说。scale是工程量系数,寄存器里存的是放大十倍的整数、显示时要除以十的情况在仪表里非常普遍,这里做成乘法因子,0.1就表示原值乘 0.1。wordOrder只对 32 位类型生效,ABCD是高字在前,CDAB是低字在前,不同品牌的 PLC 默认值不一样,试错的时候先改这个字段比改代码快。alarmHigh和alarmLow交给AlarmEvaluator消费,越限时把对应的卡片状态改成告警色,这块逻辑和采集解耦,后面加声光报警或者推送都从这里挂。
配置加载用一个带默认值的反序列化,缺字段时不要抛异常,宁可让看板带着默认值起来:
// PointConfigLoader.cs public static PointConfig Load(string path) { if (!File.Exists(path)) throw new FileNotFoundException($"点位配置缺失:{path}"); var json = File.ReadAllText(path); var cfg = JsonSerializer.Deserialize<PointConfig>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); // 兜底:端口和超时给默认值,现场写漏了也能起来 cfg ??= new PointConfig(); cfg.Port = cfg.Port == 0 ? 502 : cfg.Port; cfg.TimeoutMs = cfg.TimeoutMs <= 0 ? 1500 : cfg.TimeoutMs; return cfg; }3. Modbus TCP 采集链路:从建连到寄存器解析的完整实现
3.1 连接管理、超时与断线重连
工控现场的网络没有实验室那么听话,交换机端口松动、设备重启、IP 冲突都会让连接断掉。通讯层如果只做一次Connect就再也不管,看板会安静地停在最后一帧数据上,这种“静默失效”比报错更难查。这套代码把连接状态显式暴露出来,并且把超时压在通讯层,不让它传导到 UI。
// ModbusTcpClient.cs public sealed class ModbusTcpClient : IDisposable { private readonly string _ip; private readonly int _port; private readonly int _timeoutMs; private TcpClient? _tcp; private IModbusMaster? _master; public bool IsConnected => _tcp?.Connected == true && _master is not null; public async Task<bool> ConnectAsync(CancellationToken ct = default) { try { _tcp = new TcpClient(); // 连接本身也要超时,否则拔网线时会挂很久 using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(_timeoutMs); await _tcp.ConnectAsync(_ip, _port, cts.Token); _master = ModbusIpMaster.CreateIp(_tcp); _master.Transport.ReadTimeout = _timeoutMs; _master.Transport.WriteTimeout = _timeoutMs; _master.Transport.Retries = 1; // 重试次数别贪多,会放大阻塞 return true; } catch (Exception) { DisposeConnection(); return false; } } }Retries = 1是刻意的保守值。设成 3 的时候,一次读失败要经历三轮超时,按 1500ms 算就是 4.5 秒,这期间如果轮询器还在排队,任务会越堆越多。重连策略放在轮询器里做,而不是在客户端内部做无限重试,这样上层能感知到“这一轮没数据”,把看板切到离线态而不是假装正常。
3.2 批量读取与字节序解析
Modbus 单次读保持寄存器的数量上限是 125 个,这是协议规定的,超了设备会直接回异常码。实际工程里点位往往分散在不同地址段,正确做法是按地址排序后合并连续区间,把多次小读取合并成少量大读取。合并逻辑本身不复杂,但能显著降低 PLC 的通讯负担。
// ModbusPoller.cs —— 把点位按地址排序后合并连续段,减少请求次数 private static List<(ushort Start, ushort Count)> BuildBlocks( IEnumerable<PointConfig> points, ushort maxPerRead = 120) { var blocks = new List<(ushort, ushort)>(); // 只处理占 1 个寄存器的点位,32 位类型单独按 2 个寄存器算 var ranges = points .Select(p => (Start: p.Address, Len: p.Type is "Float32" or "UInt32" ? 2 : 1)) .OrderBy(r => r.Start) .ToList(); ushort curStart = ranges[0].Start, curEnd = (ushort)(ranges[0].Start + ranges[0].Len); foreach (var r in ranges.Skip(1)) { // 地址相邻且合并后不超限,就并进当前块 if (r.Start <= curEnd + 2 && (r.Start + r.Len - curStart) <= maxPerRead) { curEnd = (ushort)Math.Max(curEnd, r.Start + r.Len); } else { blocks.Add((curStart, (ushort)(curEnd - curStart))); curStart = r.Start; curEnd = (ushort)(r.Start + r.Len); } } blocks.Add((curStart, (ushort)(curEnd - curStart))); return blocks; }r.Start <= curEnd + 2里的2是允许的空洞大小:中间空一两个寄存器不值得再发一次请求,读回来丢掉就行。maxPerRead取 120 而不是 125,是给某些实现不严格的设备留点余量,这个数字是我在几个现场被异常码教育之后固定下来的。
读回来的原始寄存器是ushort[],32 位浮点要按字序重组,这一步错了会出现1.7e38这种明显不合理的数值,或者更隐蔽的、看起来像正常但差了一个数量级的值:
// ByteOrder.cs —— 32 位浮点的字序重组 public static float ToFloat32(ushort first, ushort second, string wordOrder) { // CDAB 表示低字在前,先交换 ushort hi = wordOrder == "CDAB" ? second : first; ushort lo = wordOrder == "CDAB" ? first : second; // Modbus 传输时是高字节在前,这里按大端拼字节再交给 BitConverter var bytes = new[] { (byte)(hi >> 8), (byte)(hi & 0xFF), (byte)(lo >> 8), (byte)(lo & 0xFF) }; if (BitConverter.IsLittleEndian) Array.Reverse(bytes); return BitConverter.ToSingle(bytes, 0); } // UInt32 同理,只是最后一步不同 public static uint ToUInt32(ushort first, ushort second, string wordOrder) { ushort hi = wordOrder == "CDAB" ? second : first; ushort lo = wordOrder == "CDAB" ? first : second; return ((uint)hi << 16) | lo; }BitConverter.IsLittleEndian这个判断不能省,虽然绝大多数工控机都是小端,但代码里显式处理一次的成本很低。解析完的结果乘上配置里的scale,再和key一起装进Sample,这一轮采集就算完成。
3.3 采集节拍与 UI 线程的数据交接
采集线程和 UI 线程的交接是整个链路里最容易出事的地方。采集频率 5Hz,UI 只需要 2 到 3Hz 的有效刷新,中间差出来的数据全部丢掉也不影响看板观感。所以中间应该有一个只保留最新值的缓冲,而不是把每一帧都往ObservableCollection里塞。
// ModbusPoller.cs —— 固定节拍轮询,异常不中断循环 public async Task StartAsync(CancellationToken ct = default) { while (!ct.IsCancellationRequested) { var sw = Stopwatch.StartNew(); try { if (!_client.IsConnected && !await _client.ConnectAsync(ct)) { RaiseOffline(); await Task.Delay(2000, ct); // 重连退避,避免重连风暴 continue; } var blocks = BuildBlocks(_points); var result = new List<Sample>(_points.Count); foreach (var (start, count) in blocks) { // 同步 API 放到线程池,避免阻塞采集循环的调度精度 var regs = await Task.Run(() => _client.ReadHolding(_slaveId, start, count), ct); foreach (var p in _points.Where(p => p.Address >= start && p.Address < start + count)) { result.Add(Parse(p, regs, start)); } } SamplesReady?.Invoke(this, result); } catch (Exception ex) { _logger.Warn($"采集异常:{ex.Message}"); _client.Dispose(); // 出错就断开,下一轮重连 } // 补齐节拍:本轮耗时从间隔里扣掉,保持稳定频率 var delay = _intervalMs - (int)sw.ElapsedMilliseconds; if (delay > 0) await Task.Delay(delay, ct); } }Stopwatch扣减耗时这一步很关键。如果只写await Task.Delay(200),实际周期会变成 200ms 加本轮读取耗时,点位多的时候周期会漂到 500ms 以上,曲线看起来就会一卡一卡的。SamplesReady事件在采集线程触发,ViewModel 里必须通过Dispatcher.Invoke或Dispatcher.BeginInvoke切回 UI 线程,否则改绑定属性时会抛跨线程访问异常。这里用BeginInvoke比Invoke更合适,异步投递不会让采集线程等 UI 画完。
4. 大屏可视化与自适应布局:数据怎么稳稳地刷到界面上
4.1 设计分辨率与 Viewbox 等比缩放
工控大屏的物理分辨率五花八门,1920×1080、2560×1440、3840×2160 都见过,还有拼接屏做的超宽比例。如果按百分比布局去适配,每换一块屏就要调一次间距和字号,维护成本很高。这套代码用的是固定设计分辨率加整体缩放:所有控件按 1920×1080 摆放,外层套一个Viewbox,宿主窗口多大就缩放到多大。
<!-- DashboardView.xaml 根节点 --> <Viewbox Stretch="Uniform" StretchDirection="Both"> <Grid Width="1920" Height="1080" Background="#0B1220"> <Grid.RowDefinitions> <RowDefinition Height="120"/> <RowDefinition Height="*"/> <RowDefinition Height="220"/> </Grid.RowDefinitions> <!-- 顶部:标题、时间、通讯状态灯 --> <local:HeaderBar Grid.Row="0"/> <!-- 中部:四个设备卡片 + 一条趋势曲线 --> <UniformGrid Grid.Row="1" Columns="4" Margin="24"> <local:DeviceCard DataContext="{Binding Cards[0]}"/> <local:DeviceCard DataContext="{Binding Cards[1]}"/> <local:DeviceCard DataContext="{Binding Cards[2]}"/> <local:DeviceCard DataContext="{Binding Cards[3]}"/> </UniformGrid> <!-- 底部:实时趋势图 --> <local:TrendChart Grid.Row="2" Margin="24" Series="{Binding TrendSeries}"/> </Grid> </Viewbox>Stretch="Uniform"保证不变形,StretchDirection="Both"允许在小窗口里也缩小预览,方便开发时不用全屏调试。这套方案的代价是文字会跟着缩放,在 4K 屏上如果系统缩放不是 100%,字体边缘会发虚。缓解办法是在App.xaml.cs里声明 DPI 感知模式,让 WPF 按物理像素渲染而不是让系统做位图拉伸:
// App.xaml.cs —— 声明 Per-Monitor DPI 感知,避免 4K 屏文字发虚 protected override void OnStartup(StartupEventArgs e) { // 必须在任何窗口创建之前调用 RenderOptions.ProcessRenderMode = RenderMode.Default; base.OnStartup(e); }严格来说 DPI 感知是通过应用程序清单声明的,代码里的ProcessRenderMode只是关掉软件渲染回退。清单里加dpiAwareness为PerMonitorV2之后,Viewbox的缩放才会落在矢量层,字才不会糊。
4.2 趋势图与实时刷新的节流
趋势图用 LiveCharts2 的LineSeries<DateTimePoint>,它是 SkiaSharp 渲染的,几万点以内性能都够看。真正要控制的是点数:一个 5Hz 的采集跑 8 小时就是 14 万个点,不裁剪的话内存和渲染都会出问题。
// DashboardViewModel.cs —— 环形窗口,只保留最近 300 个点 private const int MaxPoints = 300; private void AppendPoint(DateTime time, double value) { // ObservableCollection 的增删必须回到 UI 线程 Points.Add(new DateTimePoint(time, value)); // 超出窗口就从头削,保证集合长度恒定 while (Points.Count > MaxPoints) Points.RemoveAt(0); } // 曲线配置里关掉动画,5Hz 刷新开动画会明显掉帧 public ISeries[] TrendSeries { get; } = new ISeries[] { new LineSeries<DateTimePoint> { Values = Points, GeometrySize = 0, LineSmoothness = 0.3, AnimationsSpeed = TimeSpan.Zero, Fill = null } };GeometrySize = 0去掉数据点标记,只留线,视觉更干净也更快。AnimationsSpeed = TimeSpan.Zero是必须的,LiveCharts2 默认每次更新都做插值动画,高频刷新时动画队列会积压,表现出来就是曲线越跑越慢、最后像卡住一样。LineSmoothness给 0.3 左右,太高会让曲线看起来过度平滑,反而不像真实的采样数据。
节流放在 ViewModel 这一侧,采集侧给多少帧都先存进一个字段,UI 定时器按 300ms 的间隔取最新值刷新图表:
// 用一个 DispatcherTimer 控制 UI 刷新率,与采集频率解耦 private readonly DispatcherTimer _uiTimer = new() { Interval = TimeSpan.FromMilliseconds(300) }; private void OnUiTick(object? sender, EventArgs e) { var latest = _latestSamples; // 采集线程写入的最新一帧 if (latest is null) return; AppendPoint(DateTime.Now, latest.SpindleSpeed); IsOnline = _poller.IsConnected; }这种“采集快、渲染慢”的分离在大屏项目里基本是标配,采集频率决定数据精度,UI 频率决定观感,两者没有必要一致。
4.3 状态灯、仪表盘与报警色阶
看板上的颜色不是装饰,是信息层级。这套代码把颜色收敛成三档:正常用青色、预警用琥珀、告警用红色,背景统一深色,避免车间强光下反光看不清。状态灯的绑定直接吃IsOnline和AlarmEvaluator的输出:
<!-- 状态灯:用 DataTrigger 切换填充色,不用转换器 --> <Ellipse Width="16" Height="16"> <Ellipse.Style> <Style TargetType="Ellipse"> <Setter Property="Fill" Value="#3DDC97"/> <!-- 正常 --> <Style.Triggers> <DataTrigger Binding="{Binding IsOnline}" Value="False"> <Setter Property="Fill" Value="#FF5C5C"/> <!-- 离线 --> </DataTrigger> <DataTrigger Binding="{Binding IsWarning}" Value="True"> <Setter Property="Fill" Value="#FFB020"/> <!-- 预警 --> </DataTrigger> </Style.Triggers> </Style> </Ellipse.Style> </Ellipse>用DataTrigger而不是值转换器,原因是现场调试时在 XAML 里能直接看到条件,改色值不用翻 C# 代码。仪表盘这类控件建议自己用Path加RotateTransform画,第三方仪表库的样式往往太重,而且缩放之后刻度文字容易重叠,自绘的可控性更好。
5. 联调排查:五个把新手卡住的现场问题
5.1 从零跑起来的最小步骤
先把环境对齐再谈调试。这套代码用的是 .NET 6 以上的 WPF,需要 Visual Studio 2022 或者dotnetCLI 配合 Windows SDK。通讯库在IotDashboard.Core.csproj里以 NuGet 包形式引入,还原之后就能编译。
# 1. 还原依赖 dotnet restore src/IotDashboard.App/IotDashboard.App.csproj # 2. 编译(WPF 项目需要指定 Windows 目标框架) dotnet build src/IotDashboard.App/IotDashboard.App.csproj -c Debug # 3. 先改 config/points.json 里的 host 和 port,指向你的设备或模拟器 # 没有真机时可以在本机起一个 Modbus 从站模拟器,从站地址填 1 # 4. 启动 dotnet run --project src/IotDashboard.App/IotDashboard.App.csproj启动后先看左上角的通讯状态灯。绿灯说明 TCP 连上了并且至少读成功过一轮;红灯或者黄灯就要按下面的顺序排查:先确认ping通不通,再用 Modbus 调试工具单独读一次目标地址,最后才怀疑代码。这个顺序能省掉大量时间,因为大部分问题出在网络和地址配置上,不在程序本身。
5.2 五个高频坑的现象、原因与解决
坑一:地址差 1,读到的永远是隔壁寄存器。现象是数值看着像正常范围,但和设备的本地显示对不上,或者读出来是个恒定值。原因是设备手册上标的40001是保持寄存器的人为编号,协议里的实际地址是 0,两者差 1。解决方式是在points.json里统一填协议地址,配置项注释里写清楚“此处为协议地址,非手册编号”,避免下一个人再踩。
坑二:32 位浮点读出来是 1.7e38 或者 0。现象是功率、温度这类浮点量全是离谱数值。原因基本是字序不对,设备是高字在前(ABCD),配置里写了 CDAB,或者反过来。解决方式是先临时把wordOrder两个值都试一遍,看哪个落在合理区间;更稳的办法是拿设备本地显示的值反推,比如本地显示 12.5,读到的原始寄存器是0x0000和0x41A4,那就说明低字是0x0000、高字是0x41A4,字序是 CDAB。
坑三:跨线程更新集合直接抛异常。现象是运行几秒后崩溃,异常信息里带This type of CollectionView does not support changes to its SourceCollection from a thread different from the Dispatcher thread。原因是采集线程直接往绑定的ObservableCollection里Add。解决办法是统一在OnSamplesReady里Dispatcher.BeginInvoke,或者在采集侧只写一个volatile字段,由 UI 定时器去读,后者对大屏场景更合适。
坑四:单次读太多寄存器被设备拒绝。现象是某些地址段读失败,异常码是Illegal Data Address或者直接超时。原因是合并后的块超过了 125 个寄存器,或者跨过了设备不存在的地址区。解决办法是把maxPerRead降到 120 以下,并且在合并逻辑里加一条规则:地址间隔超过 16 个寄存器的不要合并,宁可多发一次请求。
坑五:断线后看板停在最后一帧,没人发现。现象是网络恢复后数据不刷新,必须重启程序。原因是重连逻辑写在ConnectAsync里但轮询循环没有捕获断线状态,IsConnected一直返回缓存值。解决办法是每轮读取前检查连接,失败时先Dispose掉旧连接再走退避重连,同时把IsOnline置为 false 让状态灯变红。这条是最值得做的一件事,看板的价值在于“出问题能被看见”,静默失效比崩溃危害更大。
6. 二次开发:加点位、换协议、接历史库的三个切入点
拿到这套代码之后,改动量最大的三个方向基本都集中在 Core 层,UI 层反而很少动,这也是前面坚持分层的回报。
加点位是最轻的改造,只动points.json。但要注意两点:新增点位的address落在已有合并块的空洞里时,不需要改代码,BuildBlocks会自动把它并进去;落在很远的地址段时,会多一次请求,这时候要评估 PLC 能不能扛住。加完之后跑一次,看采集循环的单轮耗时,如果超过intervalMs的一半,就把间隔调大或者把点位拆到第二个从站。
换协议是中等改造。Core 里的ModbusPoller只依赖IModbusClient这个接口的三个方法:ConnectAsync、ReadHolding、IsConnected。要接 OPC UA 或者 MQTT 网关,写一个实现类替换掉ModbusTcpClient就行,ViewModel 和 XAML 一行不用改。我当时做这个抽象的时候犹豫过要不要加,后来发现现场有一半设备是 Modbus,另一半走网关,这个接口省掉了大量复制粘贴。
接历史库是重一点的改造。SampleRepository现在是个空壳,实际项目里通常要落时序数据。写入策略建议按批攒,不要一条一条写:
| 方案 | 适用场景 | 注意点 |
|---|---|---|
| SQLite + 批量插入 | 单机、数据量中等、需要离线可用 | 用事务包住一批写入,单条提交会慢一个数量级 |
| 时序库(如 InfluxDB) | 多设备、需要按时间范围聚合查询 | 写入走批量接口,字段用 tag 区分设备 |
| 只存变化点 | 点位多、变化慢的状态量 | 相邻两次值相同就跳过,能省掉大量存储 |
我一般会先上 SQLite,跑一周看数据量再决定要不要换,过早引入时序库往往是在给自己找运维负担。
还有一个容易被忽略的改造点:看板的“离线假数据”模式。演示或者培训时没有真机,可以在IModbusClient上加一个FakeClient实现,按正弦函数生成数据,切换只改一行装配代码。这个技巧在做客户演示时特别省事,也是我每次交付前都会留的一个后门。
最后说个习惯。从那以后我每次交付工控看板,都会强制走一遍“拔网线—等 30 秒—插回去”的流程,确认状态灯变红再变绿、数据能自动恢复,才敢把程序留在现场。这个动作花不到一分钟,但能挡掉后面最容易被投诉的一类问题。希望帮到你。
本文还有配套的精品资源,点击获取