ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# WPF + Modbus TCP 工控大屏看板源码实战

C# WPF + Modbus TCP 工控大屏看板源码实战

简介:面向物联网工控可视化方向的C# WPF开发者与学习者,这份大屏看板源代码围绕MODBUS TCP设备数据采集、实时数据绑定与大屏界面呈现展开,可用于学习工控数据接入WPF桌面端并完成动态刷新的完整实现思路,适合具备一定C#基础、希望进阶WPF界面与工业通信实践的中高级读者参考。压缩包为zip格式,共453个文件、约3.06MB,其中101个cs源文件承载采集、处理与业务逻辑,4个xaml文件对应主界面与自定义可视化控件,另有21个dll依赖、15个json配置及exe运行文件,并包含构建缓存与工程配置,便于直接打开解决方案梳理项目结构。已有1557人学习下载,说明该案例在WPF大屏与工控通信主题下具有一定参考热度。读者可从中了解MODBUS TCP客户端接入、数据清洗聚合、图表仪表盘绑定、定时刷新与Dispatcher线程更新等关键环节,也能借鉴异常处理与界面布局组织方式,用于课程设计、项目原型或二次开发。

1. 工业大屏不是贴壁纸:C# WPF + Modbus TCP 源码能省掉哪三块重复活

接手一个车间看板的需求,最费时间的从来不是画界面。这套 C# WPF 写的物联网工控大数据大屏看板源码,把 Modbus TCP 采集、点位配置、时序落盘和 WPF 数据可视化渲染四段先串通了,多设备轮询、断线重连、全屏适配都有现成实现。适合手里已经有 PLC 或模拟从站、要在两三周内交一版能上墙跑的看板的人:上位机新手能照着改点位,熟手能直接拿走采集层和渲染节流的骨架。后面按接入层、数据流、渲染、故障排查现场、二次开发五段拆开讲。

2. Modbus TCP 接入层:功能码、地址偏移、字节序的三次对齐

工控看板的需求里,采集段往往只占代码量的三成,但它决定了剩下七成能不能跑起来。Modbus TCP 的报文结构本身并不复杂:7 字节 MBAP 头(事务号 2、协议号 2、长度 2、单元标识 1)加一段 PDU,功能码 0x03 读保持寄存器、0x04 读输入寄存器、0x01/0x02 读线圈与离散量、0x10 写多寄存器。真正让人反复返工的是三件事:轮询模型怎么定、4xxxx 的显示地址怎么换成报文地址、32 位量到底按 ABCD 还是 CDAB 拼。这三件事没对齐,代码写得再漂亮,屏上的数也是错的。

2.1 先定轮询模型,再动手写代码

Modbus 是请求-应答式协议,从站不会主动推数据(部分厂商有扩展,但不能指望现场设备都支持),所以看板的数据来源只能是周期性轮询。而轮询设计里最容易犯的错,是把「一个点位一次请求」当成默认写法。一个中等规模的看板,8 台设备、每台 30 个点位,如果逐点请求,一轮就是 240 次往返,按每次 15ms 算要 3.6 秒,屏上的数永远是滞后的,从站的通信口也一直在超负荷工作。

正确的做法是按地址连续性打包。功能码 0x03 单次最多读 125 个寄存器,实际工程里我一般控制在 60~80 个,留出裕量给从站的处理能力。把点位表按起始地址排序,相邻且间隔不超过 8 个寄存器的点位合并成一次请求,中间的空洞照读不误——多读几个寄存器的成本远低于多一次往返。

场景点位规模打包策略一轮请求数建议周期
单机台看板20~40 点按段合并,允许 8 寄存器空洞1~2 次200ms
车间级看板8 台 × 30 点每台 2~3 段16~24 次500ms
产线级看板30 台以上分段 + 分设备轮转,不追求一轮全采60+ 次1~2s 分批

这里有个容易被忽略的边界:请求之间必须留间隔。常见的做法是每次请求后Thread.Sleep(20),或者用SemaphoreSlim限制每秒请求数不超过 20。小型 PLC 的通信任务优先级很低,被请求淹没时先崩的往往是它自己的扫描周期。

2.2 读保持寄存器的封装:重试、退避和上限

采集层的代码不该散落在各个 ViewModel 里,我一般会先封一个薄适配层。接口命名沿用多数 .NET Modbus 库的写法(ReadHoldingRegisters),换库时只需要改这一层。

// 采集适配层:把"读一段寄存器"收敛成一个带重试和退避的方法 public sealed class ModbusReader { private readonly IModbusMaster _master; // 由连接管理器注入,断线时会被替换 private readonly byte _unitId; // 单元标识,Modbus TCP 里常见 1 或 0xFF public ModbusReader(IModbusMaster master, byte unitId) { _master = master; _unitId = unitId; } // startAddress 是 0 基的报文地址,不是 40001 这种显示地址 // count 单次不超过 125(协议上限),工程上建议 ≤ 80 public ushort[] ReadHolding(ushort startAddress, ushort count, int retry = 2) { for (var attempt = 0; ; attempt++) { try { return _master.ReadHoldingRegisters(_unitId, startAddress, count); } catch (Exception ex) when (attempt < retry) { // 退避而不是立刻重试,避免把已经吃紧的从站彻底打崩 Thread.Sleep(50 * (attempt + 1)); Trace.TraceWarning($"读 {startAddress}/{count} 第 {attempt + 1} 次失败:{ex.Message}"); } } } }

retry = 2意味着最多三次尝试,这个值不要调大。现场经验是:如果连续三次都读不回来,问题一定在链路或在从站配置,继续重试只会让采集线程卡死,连带整个看板的刷新都停摆。异常处理的写法上,catch ... when过滤掉最后一次尝试,让最后一次异常原样抛出,交给上层的状态机去处理连接状态,而不是在这里吞掉。

_unitId这个参数值得单独说一句。Modbus TCP 走的是 IP 寻址,单元标识理论上失去了路由意义,但很多网关和串口服务器仍然靠它区分下挂的 RTU 从站,有些设备则要求填 0xFF。源码里把它做成了配置项,就是为了在现场能改而不重新编译。

2.3 点位表:把 4xxxx 翻译成报文地址

新手最容易翻车的地方在这里。PLC 编程软件里显示的40001、40103是给人看的编号,4表示保持寄存器区,后面是 1 基序号;报文里传的是 0 基偏移,所以40001对应报文地址0,40103对应102。有些库在 API 上又做了 1 基封装,于是「差一」的问题会以各种面目出现:读到的数永远比实际晚一个点位,或者第一个点位恒为 0。

我的做法是点位表里同时存两个字段,显示地址给人看、报文地址给程序用,落盘时用一个转换函数保证一致。

点位名PLC 显示地址报文地址数据类型寄存器数换算
主轴转速400010UInt161×1
出口温度400032Float(ABCD)2×1
累计产量400054UInt322×1
报警字400076UInt161按位解析
设定压力40101100Float(CDAB)2×0.01
// 显示地址 → 报文地址:只处理保持寄存器区(4xxxx) // 输入寄存器区是 3xxxx,同样减 30001,别混用 public static ushort ToProtocolAddress(int displayAddress) { if (displayAddress is < 40001 or > 49999) throw new ArgumentOutOfRangeException(nameof(displayAddress), "只接受 4xxxx 区间的地址"); return (ushort)(displayAddress - 40001); }

这个函数短得不像话,但它是整个采集层的地基。我见过不止一个项目,因为一半点位手填了 0 基、一半填了 1 基,排查了两天才发现是地址表本身不统一。把它写成唯一的入口,比写十行注释管用。

2.4 32 位量的字节序与字序

16 位寄存器拼 32 位浮点,是 Modbus 采集里最经典的坑。协议规定寄存器内部按大端传输,但两个字(高字、低字)谁在前,协议没管,各家设备各写各的。同一个温度值,按 ABCD 解出来是 26.5,按 CDAB 解出来可能是 1.0E-38 这种一眼假的数,也可能恰好是个看起来合理的数——后者更危险,因为它不会报错,只会让工艺参数悄悄偏移。

// 32 位量的解析:先确认字序,再确认字节序,两步都不能省 public static class RegisterCodec { // ABCD:高字在前,寄存器内部大端,多数 PLC 的默认序 public static float ToFloatAbcd(ushort hi, ushort lo) { Span<byte> buf = stackalloc byte[4]; BinaryPrimitives.WriteUInt16BigEndian(buf[..2], hi); BinaryPrimitives.WriteUInt16BigEndian(buf[2..], lo); return BinaryPrimitives.ReadSingleBigEndian(buf); } // CDAB:字交换,仪表和部分变频器常用 public static float ToFloatCdab(ushort hi, ushort lo) => ToFloatAbcd(lo, hi); // 无符号 32 位(累计产量这类),同样要区分字序 public static uint ToUInt32Abcd(ushort hi, ushort lo) => ((uint)hi << 16) | lo; }

判断字序不需要猜:让 PLC 侧把一个已知值(比如 123.5)写进寄存器,在调试工具里看原始字,两个字的数值一对比就清楚了。源码里把字序做成了点位属性,每个点位独立配置,而不是全局开关——一条产线上混着三四个品牌的仪表是常态,全局配置一定会踩雷。

3. 从采集线程到 UI:把 200ms 轮询压成 1s 刷新的中间层

采集跑通之后,第二道坎是数据怎么送到界面上。WPF 的DependencyObject有线程亲和性,采集线程直接改绑定属性会抛InvalidOperationException;退一步用Dispatcher.BeginInvoke把每次采样都扔回 UI 线程,看似安全,实际上是把 UI 线程的消息队列当成了缓冲区——采集 200ms 一次、20 个点位就是每秒 100 条消息,界面会肉眼可见地变得不跟手,鼠标拖动窗口都卡。中间层的意义就是把「采得快」和「刷得稳」解耦。

3.1 采集线程只写数据层,不碰 ViewModel

这条规矩听起来像教条,但违反它的代价很具体:一旦某个属性变更触发了图表的全量重绘,而重绘又发生在采集线程的调度里,你会看到一个越跑越卡的看板,而且卡顿出现的时间点和采集频率强相关,排查方向很容易跑偏到图表控件上。

我一般把链路切成三段:采集线程按自己的节奏写快照;一个固定 1 秒的DispatcherTimer在 UI 线程读快照并赋值;图表只做增量追加。三段各自有独立的频率,互不阻塞。为什么是 1 秒?因为人眼看大屏上的数字,超过每秒两次的刷新基本感知不到差别,但渲染开销是实打实的翻倍。

3.2 快照用不可变字典做原子替换

快照的关键要求是「读的人永远看到一致的一批数据」,不能出现转速已经更新、温度还是上一轮的情况。用锁能实现,但锁会把采集线程和 UI 线程绑在一起。更省事的做法是用不可变字典做整体替换。

// 快照存储:写入方整体替换引用,读取方拿到的永远是一份完整、不可变的视图 public sealed class SnapshotStore { private ImmutableDictionary<string, double> _current = ImmutableDictionary<string, double>.Empty; // 无锁读取,UI 线程每帧调用也没有竞争开销 public IReadOnlyDictionary<string, double> Current => Volatile.Read(ref _current); // 一批点位一次性合入,保证同批次数据的时间一致性 public void Apply(IEnumerable<KeyValuePair<string, double>> batch) { ImmutableInterlocked.Update(ref _current, old => old.SetItems(batch)); } }

ImmutableInterlocked.Update内部用 CAS 循环,写入失败会自动重来,不需要自己写自旋。SetItems在键已存在时是覆盖语义,正好对应「同一个点位的新采样替换旧采样」。这里有个成本要提前算:不可变字典每次更新都会复制路径上的节点,点位在几百个量级时开销可以忽略,上千个点位、每秒十几次更新就要考虑分片(按设备 ID 拆成多个字典),否则 GC 压力会上来。

3.3 落盘走有界队列,慢的时候丢旧不丢新

历史曲线和报表需要把采样存下来。采集线程直接同步写数据库是最常见的性能事故:某次磁盘抖动或者一次大事务,写操作阻塞几百毫秒,采集周期被拖长,看板上的数据开始跳变,而日志里什么都看不到。用有界通道把写操作挪到独立消费者,是最小代价的解法。

// 有界通道做背压:容量 512 批,满了丢最旧的,保证内存不无限增长 private readonly Channel<Sample[]> _writeQueue = Channel.CreateBounded<Sample[]>(new BoundedChannelOptions(512) { FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = true }); // 消费端:批量提交 + 定时兜底,避免数据少时长时间不落盘 private async Task DrainAsync(CancellationToken ct) { var buffer = new List<Sample>(200); var flushTimer = new PeriodicTimer(TimeSpan.FromSeconds(2)); while (!ct.IsCancellationRequested) { while (_writeQueue.Reader.TryRead(out var batch)) { buffer.AddRange(batch); if (buffer.Count >= 200) await FlushAsync(buffer, ct); } // 队列空但有残留,等定时器到点再写,兼顾吞吐和实时性 await Task.WhenAny(flushTimer.WaitForNextTickAsync(ct).AsTask(), Task.Delay(200, ct)); if (buffer.Count > 0) await FlushAsync(buffer, ct); } }

FullMode选DropOldest还是Wait取决于业务:趋势曲线丢几批采样只是曲线少几个点,但如果队列反压到采集线程,整个看板都会跟着停,所以展示型数据选丢旧;报警和事件数据必须选Wait甚至单独开一条不丢弃的通道,这两类数据丢了就没法追责。批量大小 200 和定时 2 秒是我在机械硬盘和普通 SATA SSD 上测下来比较稳的组合,换成 NVMe 可以把批量调大到 500。

3.4 断线重连做成状态机,别写成 if

现场的网络抖动、交换机重启、PLC 断电下载程序,都会让连接断掉。如果重连逻辑散落在各个 catch 块里,典型表现是:断线后日志疯狂刷屏,重连请求每 100ms 发一次,把刚重启的从站又打死一次,然后陷入「连上就崩」的循环。

// 连接状态机:指数退避,封顶 30 秒,成功一次就清零 public enum LinkState { Disconnected, Connecting, Online, Faulted } public sealed class LinkMonitor { private int _failCount; public LinkState State { get; private set; } = LinkState.Disconnected; public int FailCount => _failCount; public void OnSuccess() { _failCount = 0; State = LinkState.Online; } public TimeSpan OnFailure() { _failCount++; State = LinkState.Faulted; // 1、2、4、8、16、30 秒封顶,给对端留出恢复时间 var seconds = Math.Min(30, Math.Pow(2, Math.Min(_failCount, 5))); return TimeSpan.FromSeconds(seconds); } }

状态机还有个隐性收益:它把「当前连没连上」变成了一个可以绑定的属性,界面上的连接指示灯、数据置灰、最后一次成功采集时间,全都能直接读这个状态,不用再维护一堆散落的布尔变量。FailCount也顺手解决了「重连几次才告警」的阈值判断。

4. WPF 大屏渲染:设计稿缩放、图表降采样和 7×24 的内存曲线

看板类应用对渲染的要求和其他桌面软件不太一样:它要在 1920×1080、3840×2160、拼接屏的异形分辨率上都正常显示,要连续跑几十天不重启,还要在没有鼠标键盘的环境下开机即用。这三条要求分别对应布局、渲染和部署三个层面的具体做法,跟「界面好不好看」关系不大。

4.1 用固定设计稿 + Viewbox 做等比缩放

大屏项目最怕的是「换个分辨率就错位」。我一般按 1920×1080 出一版设计稿,所有元素用绝对坐标摆在 Canvas 上,外层套一个 Viewbox 做等比缩放。

<!-- 设计稿固定 1920×1080,Viewbox 负责等比缩放,换分辨率不动坐标 --> <Viewbox Stretch="Uniform" StretchDirection="Both"> <Canvas Width="1920" Height="1080" Background="#0B1220"> <TextBlock Canvas.Left="64" Canvas.Top="40" FontSize="42" Foreground="#E6F1FF" Text="{Binding BoardTitle}"/> <!-- 趋势卡片:固定尺寸,内部图表自适应 --> <Border Canvas.Left="64" Canvas.Top="140" Width="880" Height="360" Background="#121C2E" CornerRadius="8"> <local:TrendChart Margin="16" Series="{Binding SpeedSeries}"/> </Border> <!-- 关键指标卡:数值用等宽字体,避免刷新时宽度跳动 --> <Border Canvas.Left="976" Canvas.Top="140" Width="880" Height="360" Background="#121C2E" CornerRadius="8"> <TextBlock Text="{Binding OutputText}" FontFamily="Consolas" FontSize="96" Foreground="#38E1B0"/> </Border> </Canvas> </Viewbox>

这个方案的代价要提前知道:Viewbox 是整体缩放,1 像素的边框在 4K 上会被拉成 2 像素,细线会发虚;字体缩放后如果开了TextOptions.TextFormattingMode="Display",小字号会变形。我的处理是把TextFormattingMode设成Ideal,边框统一用 2 的整数倍宽度,并且在 4K 屏上单独调一版设计稿尺寸而不是靠缩放硬扛。StretchDirection="Both"是为了让 1080p 的调试机也能看到完整内容,正式部署时可以改成UpOnly。

4.2 趋势图选型与降采样

图表是看板里最先崩的组件。一万个点的折线,用 XAML 的Polyline直接绑点集合,每次更新都要重建整个几何体,刷新一次上百毫秒,界面直接失去响应。几种方案的实际差别大致如下。

方案万点刷新量级适合场景主要代价
XAML Polyline / Path百毫秒,点数越多越慢单曲线、500 点以内每次更新重建视觉树
DrawingVisual 自绘十毫秒级多曲线滚动趋势坐标轴、图例、命中测试全自己写
通用图表控件取决于是否开启降采样快速出活、报表类版本升级行为和性能都可能变

不管选哪种,降采样都要做。常用的两种是等间隔抽稀(简单但会丢毛刺)和按桶保留极值(点数减半但不丢峰谷)。工控场景我更倾向后者,因为报警往往就藏在那一两个尖峰上。

// 按桶保留极值:桶内最大最小都留下,视觉上不丢毛刺 public static IReadOnlyList<Point> Downsample(IReadOnlyList<Point> src, int buckets) { if (src.Count <= buckets * 2) return src; var result = new List<Point>(buckets * 2); var size = (double)src.Count / buckets; for (var b = 0; b < buckets; b++) { var start = (int)(b * size); var end = Math.Min(src.Count, (int)((b + 1) * size)); int minI = start, maxI = start; for (var i = start; i < end; i++) { if (src[i].Y < src[minI].Y) minI = i; if (src[i].Y > src[maxI].Y) maxI = i; } // 按时间序放回,否则折线会自己打结 if (minI <= maxI) { result.Add(src[minI]); result.Add(src[maxI]); } else { result.Add(src[maxI]); result.Add(src[minI]); } } return result; }

buckets一般取绘图区像素宽度的一半,再多就是给显卡添堵。还有个细节:降采样之后的点集要缓存,不要每次刷新都重算——曲线数据没变的时候,重算纯属浪费。

4.3 渲染循环里不要 new

7×24 运行的应用,内存曲线比启动速度重要得多。WPF 里几个高频的隐形分配点:每帧new SolidColorBrush、每帧拼字符串、每帧创建PointCollection、事件订阅没解绑。前三个会让 GC 的 Gen0 回收频率飙高,表现为每隔十几秒界面轻微顿一下;第四个是内存缓慢上涨,跑一天涨几百兆,重启就好,然后第二天继续涨。

// 画笔静态化并 Freeze,避免每帧分配和跨线程校验 private static readonly Brush AccentBrush = CreateFrozen(Color.FromRgb(0x38, 0xE1, 0xB0)); private static Brush CreateFrozen(Color color) { var brush = new SolidColorBrush(color); brush.Freeze(); // 冻结后可跨线程共享,也不再参与变更通知 return brush; }

Freeze()这一步值得养成习惯。冻结后的Freezable不再有变更通知的开销,也不会被 WPF 的线程校验拦住,在采集线程里做数据预处理时引用它是安全的。至于字符串,看板上那些每秒刷新的数值,尽量用格式化缓存或者直接绑double加StringFormat,别在 tick 里做拼接。

4.4 全屏、置顶和开机自启

这三件事加起来不到二十行代码,但漏一件就要现场跑一趟。全屏不要用WindowState=Maximized,那会留出任务栏;WindowStyle=None+WindowState=Maximized+ResizeMode=NoResize才是真全屏,代价是要自己处理 Alt+Tab 之后窗口被顶掉的情况,通常再补一个Topmost=true。

开机自启我一般用计划任务而不是注册表启动项:计划任务可以设置「用户登录后延迟 30 秒启动」,等网络和 PLC 都就绪,避免程序启动时连不上设备、直接进故障状态。多屏场景下还要用System.Windows.Forms.Screen把窗口定位到指定显示器,这个逻辑放在SourceInitialized事件里做,Loaded里做会被 DPI 缩放算错位置。

5. 避坑与排查:连接、字节序、卡顿、内存、部署的五类翻车现场

以下五条都是我在不同项目里真实撞过的,按「现象 → 原因 → 解决」记下来,方便对着现场快速定位。

现象一:能 ping 通 PLC,程序就是连不上。原因通常有三个层次:一是 502 端口被现场防火墙或安全策略拦了,ICMP 通不代表 TCP 通;二是从站只允许有限数量的并发连接,调试工具没关、旧进程还在后台跑,占满了连接槽;三是单元标识填错,某些网关要求 0xFF 而默认填了 1,报文能发出去但回的是异常码 0x0B。解决顺序是先用 telnet 或端口探测确认 502 可达,再关掉所有调试客户端只留一个连接,最后逐个试单元标识。

现象二:温度显示 1.0E-38,或者数值看着合理但和现场表头对不上。前者是字序错了,ToFloatAbcd和ToFloatCdab调换一下就能验证;后者更麻烦,是字节序在寄存器内部被交换了(BADC),解出来的数恰好落在量程内。判断方法是让 PLC 侧写一个特征值,比如 123.456,四种组合各解一遍,只有一种会得到干净的结果,其余三种不是溢出就是带一长串尾数。

现象三:跑两三个小时后界面越来越卡,重启就恢复。八成是事件订阅泄漏或者图表点数无限累积。检查两处:DispatcherTimer有没有在窗口关闭时Stop()并置空;图表的数据集合有没有做长度上限(滚动趋势保留最近 3000 点就够,多出来的必须移除)。还有一种隐蔽情况是PropertyChanged挂到了长生命周期的单例上,窗口重建一次就多一份订阅,绑定更新次数翻倍。

现象四:任务管理器里内存一路涨到 1.5G 然后被系统杀掉。除了上面说的订阅泄漏,常见的是大对象堆碎片:每次刷新都new一个大的PointCollection或者BitmapSource,超过 85KB 就进 LOH,LOH 不压缩,跑久了就是千疮百孔。解决办法是复用集合对象、就地Clear()再Add(),图片走WriteableBitmap复用缓冲区,而不是每次新建。

现象五:开发机一切正常,现场机黑屏或者界面被裁掉一半。现场机的缩放比例常常是 125% 或 150%,而清单里没声明 DPI 感知,系统会做位图拉伸,Viewbox 的等比缩放再叠一层,最终显示就乱了。在应用清单里声明PerMonitorV2,并在App.xaml.cs里对OnDpiChanged做处理,是基本操作。另一个高频原因是现场机没装对应版本的 .NET 运行时,发布时用自包含模式能一次性绕开。

排查项快速验证手段典型结论
网络可达telnet 目标 IP 502不通则是防火墙或端口配置
报文正确抓包看 MBAP 头长度字段长度与实际 PDU 不符说明库封装有问题
地址正确单点读一个已知寄存器全 0 或全 65535 多为地址偏移错
字序正确写特征值 123.456 反解四种组合只有一种干净
性能瓶颈关掉图表只留数字刷新变流畅则是渲染而非采集问题

这张表的价值在于把「猜」变成「二分」:先确认链路,再确认报文,再确认解析,最后才怀疑性能和渲染。顺序反了,就会在图表控件上浪费一整天。

6. 二次开发与验证:模拟从站 + 8 小时压测的交付前清单

拿到源码之后第一件事不是改界面,是把采集链路在没有真实 PLC 的情况下跑起来。我一般会先起一个本地模拟从站,把点位表里的地址全映射成变化的随机值,让看板先「动」起来;这一步能验证地址表、字序、刷新节奏三件事,而且不占用现场设备。模拟从站用任意一个支持 Modbus TCP 的服务端实现都行,关键是把保持寄存器区按点位表铺满,并且让 32 位量按你配置的字序写入,否则验证不出问题。

链路跑通之后,交付前我会强制走一遍 8 小时压测:模拟从站按正常周期响应,同时人为制造两次断线(拔网线或者停掉模拟器,间隔 30 秒再恢复),观察三件事——重连是否在 30 秒内恢复、断线期间界面是否还响应鼠标、内存曲线是否回到基线。第二件事最容易被忽略,断线时如果采集线程阻塞在主线程上,看板会整个僵住,现场看起来就像程序崩了。

发布环节别在开发机上点「生成」,用命令行固定参数,避免哪天换了机器发布出不一样的东西:

# 自包含发布,现场机不需要预装 .NET 运行时 dotnet publish -c Release -r win-x64 --self-contained true \ -p:PublishSingleFile=true \ -p:IncludeNativeLibrariesForSelfExtract=true \ -p:DebugType=None -p:DebugSymbols=false

--self-contained true让包体涨到 100MB 以上,但换掉了「现场机缺运行时」这一整类问题,对只跑一趟的工控现场非常值。-p:DebugType=None去掉 pdb,顺便避免把源码路径带进发布目录。

最后说个习惯:从那以后我每次交付看板类项目,都会在点位配置里留一个「调试点位」——一个只读的寄存器,专门用来在不动任何工艺参数的前提下验证链路是否活着。现场出问题时,先看这个点位有没有在变,就能一刀切开是采集的问题还是业务逻辑的问题,省掉无数次远程电话。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进