C#上位机与PLC通信:车间设备监控报警系统架构实战
前阵子接了个车间设备监控改造的活儿需求很朴素车间里有七八台设备每台都是独立 PLC 控制现场有按钮能启停故障了也有红灯会亮但中控室那边什么也看不到。设备一停操作工得跑过去看灯、看触摸屏再打电话汇报等管理人员知道的时候可能已经停了半小时。所以诉求就一句话用 C# 上位机把 PLC 数据统一收上来做一块大屏盯着报警第一时间推送出来。这类项目做起来其实不复杂但坑不少。我走了几趟弯路之后把一个能稳定跑的架构给沉淀下来了PLC 负责采信号和就地控制上位机负责集中监控、报警判断和历史记录。这篇文章会把整个项目的设计思路、通信层代码、报警逻辑、调试过程全部拆开讲适合正在做类似改造的 C# 开发也适合想搞懂上位机到底怎么跟 PLC 打交道的电气工程师。1. 项目背景与架构选型为什么必须上位机盯数据而不是靠触摸屏1.1 车间里那些半自动化设备真正缺的是什么先说一下现场实际情况。车间里每台设备都有自己的 PLC程序里已经做了不少逻辑电机启停、气缸动作、温度控制甚至有些设备还带简单的触摸屏。单看每一台设备自动化程度不算低但它们之间是完全孤立的没有任何数据汇总。生产主管真正想要的是三个东西一是设备状态实时可见二是停机原因可追溯三是报警信息能第一时间到达责任人。这三个需求触摸屏给不了。触摸屏只能站在设备旁边看而且它本身就是 PLC 的一个从站你再从触摸屏里把数据抠出去二次利用既别扭又会被屏厂家的通讯协议限制住。上位机这个时候的价值就出来了它可以通过以太网同时连接所有 PLC把数据汇总到一个界面上还能往数据库里写、往外推送消息。这个架构选择不是炫技而是需求倒推的。现场有中控室、有值班人员、有事后分析需求那就必须有独立的信息汇总层。PLC 继续安心做它的实时控制上位机做它不擅长的数据存储和界面展示分工明确。1.2 通信方案取舍Modbus TCP、OPC UA、串口该怎么选通信方案是整个系统的地基这个选错后面全得返工。我当时列了三个备选串口、Modbus TCP、OPC UA。串口最简单RS485 两根线协议也是现成的但串口天生不适合多设备同时轮询波特率 9600 的情况下读几十个寄存器就要几十毫秒七八台设备轮一圈实时性基本没法看。而且现在很多中小型 PLC 的以太网口已经是标配没必要硬走串口。OPC UA 是工业通信里的高级货跨平台、安全模型强、信息模型丰富但问题是很多小型 PLC 根本不直接支持 OPC UA你得在中间加网关或者靠 PLC 厂商的软件做数据转发这套东西一上项目周期和成本立刻上去。说实话项目里只要不是做大型产线级集成拿 OPC UA 对一个车间级监控系统是杀鸡用牛刀。最后选的就是 Modbus TCP。理由很务实它已经是工业以太网上事实上的标准协议几乎所有带网口的 PLC 都支持要么原生支持要么通过扩展模块支持。报文格式简单抓包一眼就能看懂调试成本极低。网上随便搜就能找到协议文档代码也能自己写不依赖商业组件。对一个要长期维护的项目来说协议完全可控本身就是巨大的优势。对比下来这张表基本说明了我的取舍逻辑方案实时性实施难度跨设备通用性适合场景串口 Modbus RTU低低中单台设备、点位少、无以太网Modbus TCP高低高车间级多设备集中监控OPC UA高高高产线级、跨厂商系统集成1.3 报警逻辑放在上位机还是 PLC先想清楚职责边界做这种项目很容易犯一个毛病恨不得把所有逻辑都写进上位机让 PLC 只当一个数据采集器。反过来的毛病也有就是把所有报警都写在 PLC 里上位机只负责显示。我的建议是分三层。第一层设备自身的安全联锁急停、限位、过载必须放在 PLC 里因为这些要求毫秒级响应靠上位机轮询是不可能做到安全响应的这也符合现场安全规范。第二层设备状态判断和生产过程报警比如某个工位节拍超时、某条产线连续停机超过 10 分钟这种时间敏感性不强的逻辑放上位机更灵活改报警阈值不用动 PLC 程序更不用申请停机。第三层纯粹的记录类信息操作日志、报警发生时间、恢复时间、确认人这本来就是上位机的活。一句话安全相关的留在 PLC管理相关的拿到上位机。职责边界清楚了后面写代码才有章法。2. PLC 侧准备工作寄存器地图和通信参数是整条系统的地基2.1 梳理设备状态量哪些信号必须进上位机通信代码写得再好点位清单没梳理清楚也是白搭。我的习惯是先跟电气工程师把每台设备的信号点表过一遍按只看不控的原则筛出上位机要读的数据。状态量大概分这么几类开关量运行/停止、自动/手动、故障/正常、还有各个工位的到位信号。这些信号在 PLC 里通常对应一个位元件比如 M 线圈。模拟量温度、压力、电流、速度这些存在数据寄存器里一般是 16 位整数也有 32 位的。计数类产量、运行时长、故障次数这类是只增不减的累计值用于报表。报警代码PLC 程序里通常会维护一个当前报警编号上位机直接读这个编号就能显示报警原因。这里有一个容易被忽略的点别把 PLC 内部的中间变量一股脑全给上位机。只挑状态量和报警量就行内部计算用的临时寄存器你读了也没意义还平白增加通信负担。2.2 寄存器规划与地址映射M 线圈、D 寄存器、Modbus 地址的换算PLC 内部有自己的一套地址体系比如位元件叫 M数据寄存器叫 D但 Modbus TCP 协议里只有线圈、离散输入、保持寄存器、输入寄存器四种对象。所以上位机在发送读取指令之前必须先把 PLC 的地址翻译成 Modbus 地址。通信层的翻译规则是请求帧里的地址是从 0 开始的协议地址而现场工程师习惯说的可能是 40001 这种从 1 开始的人类友好地址。这两个之间差 1经常出问题。举个例子工程师说数据在 D100如果这台 PLC 的 Modbus 映射里 D 区从协议地址 0 开始那么 D100 对应的协议地址就是 100Modbus 请求帧里填的 startAddress 就是 100。但如果你用的上位机控件或者第三方软件里写的是 40101那就错了因为 40001 对应的协议地址是 0。这种差一个数的问题新手能查一整天。我建议在项目一开始就做一张地址映射表列清楚 PLC 地址、协议地址、上位机显示名、数据类型这张表既是开发依据也是将来运维排障的文档。2.3 轮询周期怎么定算一次扫描时间你就懂了轮询周期不是一个拍脑袋定的值它跟 PLC 的扫描周期、通信处理能力、以及你读的数据量都有关系。假设一台设备的 PLC 扫描周期是 5ms你上位机每 100ms 读一次 20 个保持寄存器一次请求的耗时包括网络往返时间加上 PLC 通信模块处理时间通常 10 到 30ms 这个量级。这样算下来100ms 的周期给通信留了充足余量不会挤压 PLC 本体的控制逻辑。那能不能把周期压到 20ms能但没必要。你想想上位机界面上的显示实时性做到 100ms 级别人眼已经完全感觉不到延迟了。而报警判断要的是稳定性不是微秒级响应。周期压得太短反而会让 PLC 的通信模块忙得冒烟影响它跟触摸屏、其他从站的正常通信。我实际的配置是普通状态量 500ms 轮询一次模拟量 500ms报警状态量单独 200ms 快速轮询。为什么报警量要单独快一拍因为报警需要尽快弹出来而普通显示晚个几百毫秒无所谓。多设备的情况还要做轮询相位错开别让七八台设备都在同一毫秒发起请求否则交换机端口大概率会瞬时拥堵丢包。2.4 先把 PLC 的通信时间片预留出来这个坑我栽过一次。某台设备的 PLC 原本就带了触摸屏通信已经很忙我再加上位机之后触摸屏偶尔出现卡顿。原因是这个 PLC 的通信处理模块处理能力有限上位机轮询和触摸屏刷新抢资源。后面通过两个手段解决一是拉长轮询周期从 200ms 改到 400ms二是把需要读的寄存器做了合并原本分开读的多个连续寄存器改成一次性批量读。所以做这类项目你要提前评估 PLC 自身通信负载别把它的通信能力吃满至少留 30% 的余量。这也是为什么我强烈建议协议层尽量自己写因为第三方库虽然省事但你能控制的细节就少了很多。3. C# 通信层实现手写 Modbus TCP 客户端比想象中简单3.1 自己写还是引第三方库我的结论在这个项目里Modbus TCP 客户端我是自己写的没有用现成的开源库。原因有两个。一是 Modbus TCP 报文结构非常简单核心逻辑一个下午就能写完用第三方库反而要多学一套 API。二是报警和数据采集系统对超时、重连、异常处理的要求很特殊现成库往往把重试机制写死出了问题你很难介入。自己做通信层整个链路都是自己的代码出问题看日志就能定位。当然如果你只是做个 Demo 验证一下通信不想折腾找个成熟库是最快的。但只要是长期跑生产环境的监控项目我建议自己写一个极简的 Modbus TCP 客户端维护成本很低出问题的概率完全可控。3.2 MBAP 报文结构读懂 12 个字节就够了Modbus TCP 的请求帧叫 MBAPModbus Application Protocol别看它有那么多英文缩写拆开就几个字段。先把最常用的读多个保持寄存器功能码 0x03 讲明白。请求报文一共 12 个字节排列如下字节序号字段说明0-1事务处理标识每次请求自增用于匹配请求和响应2-3协议标识固定 0x00004-5长度字段后面所有字节的总长度6单元标识符相当于旧时代的从站号TCP 模式下一般填 17功能码0x03 表示读保持寄存器8-9起始地址要读的寄存器起始地址10-11寄存器数量读多少个寄存器比如要读起始地址 100 的 10 个保持寄存器请求帧就是00 01 00 00 00 06 01 03 00 64 00 0A长度字段是 0x0006因为从单元标识符开始到末尾一共 6 个字节。响应帧的结构是前 8 个字节还是 MBAP 头部第 8 字节是功能码 0x03第 9 字节是数据字节数后面跟着实际数据。如果从地址 100 开始读 10 个寄存器响应数据就是 20 个字节。C# 里构造请求报文可以这样写public static byte[] BuildReadHoldingRegistersRequest( ushort transactionId, byte unitId, ushort startAddress, ushort registerCount) { byte[] frame new byte[12]; frame[0] (byte)(transactionId 8); frame[1] (byte)(transactionId 0xFF); frame[2] 0x00; // 协议标识高字节 frame[3] 0x00; // 协议标识低字节 frame[4] 0x00; // 长度高字节 frame[5] 0x06; // 长度低字节 frame[6] unitId; frame[7] 0x03; // 功能码 frame[8] (byte)(startAddress 8); frame[9] (byte)(startAddress 0xFF); frame[10] (byte)(registerCount 8); frame[11] (byte)(registerCount 0xFF); return frame; }注意 Modbus TCP 和串口的 Modbus RTU 有个关键区别TCP 模式不需要 CRC 校验因为 TCP/IP 协议本身已经保证了数据传输的正确性。很多从串口转过来的工程师会下意识补一个 CRC多算了反而没事但理解上容易绕弯。3.3 连接管理、超时控制和断线重连通信层最核心的不是报文构造而是连接管理和超时控制。设备在现场网线松了、交换机断电、设备重启都是家常便饭。上位机必须做到连不上就重试连上之后突然断线要能感知并且自动恢复。我用的是 TcpClient 加 async/await 的方式每台设备独立维护一个连接状态。读取一包数据的核心逻辑大致是这样public async Taskbyte[] ReadHoldingRegistersAsync( PlcConnection conn, ushort startAddress, ushort count, CancellationToken ct) { using var timeoutCts CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(2000); // 单次通信超时 2 秒 ushort txId conn.NextTransactionId(); byte[] request BuildReadHoldingRegistersRequest( txId, conn.UnitId, startAddress, count); await conn.Stream.WriteAsync(request, timeoutCts.Token); await conn.Stream.FlushAsync(timeoutCts.Token); // 先读 8 字节 MBAP 头长度字段在偏移 4-5 byte[] header new byte[8]; await ReadExactlyAsync(conn.Stream, header, timeoutCts.Token); int bodyLength (header[4] 8) | header[5] - 2; byte[] body new byte[bodyLength]; await ReadExactlyAsync(conn.Stream, body, timeoutCts.Token); return body; }这里有个技巧一定要说TCP 是流式协议你发送一个请求之后读到的数据可能比预期的多也可能一条响应被拆成两次到达所以必须读满指定长度才算一帧结束。我上面写的ReadExactlyAsync就是循环读直到凑够长度很多新手直接用stream.ReadAsync读一次结果数据不完整就解析各种奇奇怪怪的问题就来了。断线重连我用的策略是首次连接失败或者通信超时后不立刻疯狂重连而是进入退避状态比如先等 1 秒失败再等 3 秒、5 秒最多等 30 秒。这样既能在设备重启后尽快恢复又不会因为频繁重连把交换机的端口表塞满。3.4 多台设备轮询用 async/await 而不是一堆线程车间里设备多了之后最容易犯的错是给每台设备开一个 Thread 循环去读数据。线程本身没问题但线程数量多了锁和共享变量的复杂度会成倍上升。更好的方式是每台设备一个轮询任务用 async/await 写配合 CancellationToken 实现优雅停止。整体结构类似private async Task PollDeviceLoopAsync(PlcDevice device, CancellationToken ct) { while (!ct.IsCancellationRequested) { try { await PollDeviceOnceAsync(device, ct); } catch (OperationCanceledException) { break; } catch (Exception ex) { Log.Error($设备 {device.Name} 读取失败: {ex.Message}); await Task.Delay(device.ReconnectDelay, ct); } finally { await Task.Delay(device.PollInterval, ct); } } }要提醒一点对同一台 PLC 的 Modbus 请求必须串行也就是一个请求等到响应之后再发下一个请求。虽然 Modbus TCP 协议本身允许在一个连接上并发多个请求用事务标识区分响应但很多中小型 PLC 的实现并不规范并发一高就丢包。我自己踩过这个坑之后就统一改成单连接串行轮询了简单可靠轮询周期足够短完全感知不到串行带来的延迟。4. 数据解析与报警判定别把 PLC 寄存器当成普通数组去读4.1 字节序和 32 位数据拼接一个反直觉的坑Modbus 协议规定寄存器内是高位字节在前、低位字节在后也就是大端序这一点大多数人都知道。但到了 32 位数据也就是两个寄存器拼一个值的时候坑就来了。32 位整数和浮点数在 Modbus 里占据两个连续寄存器但是哪一个是高 16 位、哪一个是低 16 位不同 PLC 厂商的实现并不统一。有些是高字在前也就是第一个寄存器是高 16 位有些是低字在前正好相反。你如果默认一种去读另一种出来的数值会大得离谱或者小得离谱比如读到个几百万的温度值那基本就是字节序不对了。我的做法是在解析层做一个可配置的字节序开关。先用 PLC 调试软件主动写一个已知数值比如把 1.5 这个浮点数写入数据区然后上位机读出来看是多少。如果读出来是乱码把字节序开关拨到另一种再看。确定了之后写死在配置里全项目统一。C# 里做字节序转换时我会先把两个 16 位寄存器拼成 32 位整数再用BitConverter转成浮点。但不要依赖系统默认的BitConverter.IsLittleEndian直接手动拼最稳妥public static float ToSingleByOrder(ushort high, ushort low) { byte[] bytes new byte[4]; bytes[0] (byte)(high 8); bytes[1] (byte)(high 0xFF); bytes[2] (byte)(low 8); bytes[3] (byte)(low 0xFF); return BitConverter.ToSingle(bytes, 0); }这里high/low的顺序由配置决定你可以在读取寄存器之后根据配置调换两个寄存器再套这个函数。4.2 位报警、模拟量报警、速率报警的判定逻辑报警判定是整个系统的灵魂而且它不只是超过阈值就报警这么简单。先把种类分一下。第一类是位报警比如设备故障信号PLC 里是一个 M 线圈Modbus 读线圈可以读寄存器再取某一位也可以。位报警的判定很简单但要考虑抖动和边界。一个信号在一个轮询周期里是 1下个周期又变回 0这种瞬态抖动不应触发报警。我通常用连续两个及以上轮询周期都为 1 才确认报警这个滤波策略代价是报警会延迟一点换来的是几乎不误报。第二类是模拟量报警比如电机电流超过额定电流 1.2 倍。模拟量报警的坑在死区。你设一个上限 80A到了 80A 报警恢复时如果还是按 80A 算电流在 79.8 和 80.2 之间来回抖动报警就会反复触发和恢复。正确的做法是上限和恢复阈值之间留一个死区比如报警阈值 80A恢复阈值 75A只有降到 75A 以下才解除报警。这样 76 到 80 之间变成一个稳定的滞回区间,系统不会被抖动烦死。第三类是速率报警比如压力上升过快这类要用历史数据算斜率(当前值 - 前一次值) / 时间间隔超过设定斜率就报警。速率报警对采集的时间戳一致性要求比较高不要用 UI 刷新回调里的时间要用采集循环里的定时器时间否则抖动很大。4.3 报警死区、滤波和确认机制报警状态不是只有有和没有两种。实际项目里至少要有四种状态产生未确认、产生已确认、恢复未确认、恢复已确认。这四种状态对应操作人员的处理流程报警响起人看到并确认然后设备恢复最后在系统里留下一个完整的闭环记录。状态转换的逻辑有点像状态机产生报警状态从无变成产生未确认。操作工点击确认变成产生已确认。信号恢复正常变成恢复未确认。系统检测到恢复后自动标记或者操作工再次确认变成恢复已确认。这个状态机的好处是能回答管理上一个关键问题这台设备现在停了有人知道吗如果有人确认过报警说明他在处理如果一直是未确认状态那就是漏报。报表里查这个字段值班管理的责任就落到位了。4.4 报警记录该存哪我用 SQLite 的理由报警记录必须持久化不能只画在界面上。我在这类项目里用 SQLite理由是现场环境大多没有专职的数据库管理员也不一定有现成的数据库服务。SQLite 是嵌入式数据库一个文件就是一个库程序启动时打开不用装服务安全性也不错。表结构我大致是这样设计的AlarmHistory - Id INTEGER 自增主键 - DeviceName TEXT - AlarmCode TEXT - Message TEXT - StartTime TEXT - EndTime TEXT - AckTime TEXT - Status INTEGER 0未确认 1已确认 2已恢复未确认 3已恢复已确认一台设备一天最多几十条报警一个车间一年也就几万条用 SQLite 完全扛得住。查询也方便按时间范围和设备名筛一下就出来了。要注意的是 SQLite 的写入最好走单线程队列避免多个采集任务同时写同一个文件导致锁冲突这个细节我在后面项目里吃过亏。5. 界面联动与消息推送报警必须看得见、听得到、传得出5.1 WinForms 跨线程更新 UI 的规范动作上位机界面我用的 WinForms别嫌它老做车间监控这种对硬件要求不高、部署环境零碎的活WinForms 依然非常省心。但 WinForms 有个绕不开的问题轮询任务在后台线程更新界面必须回到 UI 线程。最简单的做法是在控件赋值前判断InvokeRequired然后通过BeginInvoke切回 UI 线程。我用一个统一的方法处理private void UpdateDeviceView(DeviceState state) { if (InvokeRequired) { BeginInvoke(new Action(() UpdateDeviceViewCore(state))); return; } UpdateDeviceViewCore(state); }这里有个经验不要在后台线程里频繁调用Invoke这种同步方式它会让后台线程阻塞等待 UI 执行完。用BeginInvoke异步投递消息后台采集不用等界面刷新完两边互不拖累。如果你用的是 .NET 新版本也可以考虑用System.Threading.Channels做生产者消费者队列采集线程只把数据丢进队列UI 线程统一消费更新这种模式在数据量大时更稳。5.2 声光报警和画面闪红怎么实现报警不能只靠一个状态文字的变色现场值班人员可能根本不在屏幕前。声光报警是标配。声音我直接用 Windows 自带的System.Media.SystemSounds.Exclamation.Play()简单方便。如果要求更明显的报警音可以用SoundPlayer加载一个 wav 文件循环播放直到报警被确认。注意及时停止声音否则车间里全是噪音值班人员的耳朵也受不了。我的做法是一个全局的报警声音控制器记录当前报警数量数量从 0 变 1 时播放一个个确认完才停止。画面闪红是用一个Timer定时切换报警设备的背景色比如红白交替闪烁。闪烁频率建议控制在 1Hz 左右太快了眼睛看着累太慢又起不到警示作用。5.3 外部推送不依赖第三方服务器也能做报警推送是老板最关心的功能前面说的一切到最后都要落到人能被通知到。最省事的方案是邮件。用 SMTP 协议发邮件到值班组邮箱代码简单不用额外部署。但邮件有延迟而且邮件很容易被淹没在各种系统通知里。如果公司内部消息工具提供机器人 Webhook那是最理想的。只需要在报警产生时往 Webhook 发一个 HTTP POST就能把报警内容推送到群里或者个人。但具体服务商我不在这里点名了原理都一样就是往一个固定接口提交 JSON。比较保守的做法是把报警写入一个网络共享目录值班机上的一个小程序定时扫描目录一旦有新报警立即弹窗。这个方案不依赖任何云服务内网就能跑特别适合信息安全要求严的车间。推送内容我建议包含设备名、报警代码、报警描述、发生时间。如果是恢复信息也要推一条这样相关人员才能判断要不要去现场。5.4 历史曲线与操作日志给值班人员留后路监控界面上最出效果的是实时趋势曲线。温度、电流这些模拟量画成折线比摆一串数字直观得多。我用的做法是维护一个最近 500 个点的环形缓冲区每次收到新数据就追加然后整个重绘。500 个点对 GDI 完全没压力刷新不会卡。曲线绘制时有几点要注意坐标轴刻度要自适应不然数据变化范围大会留大量空白超过显示范围的数据要自动滚动曲线颜色尽量跟设备状态关联正常绿色、偏高黄色、报警红色值班人员扫一眼就能看出来。操作日志是另一块同样重要的东西。谁在几点几分确认了报警、谁修改了报警阈值、谁关闭了消息推送这些都要留痕。不然出了问题查责任全是烂账。界面上的所有关键按钮在点击事件里写一条数据库日志就行成本极低价值极大。6. 现场调试踩过的坑从能连上到稳定跑半年6.1 轮询队列过深把 PLC 通信拖死项目第一次联调的时候我犯了个特别蠢的错误。当时为了追求数据显示的实时性把每类数据都单独建了个 Task 循环比如温度一个任务、电流一个任务、状态量一个任务每 100ms 各跑一次。结果一台设备同时要处理三个请求队列PLC 的通信模块忙不过来触摸屏直接卡死PLC 本体差点死机。排查的过程很简单把上位机停掉触摸屏立刻恢复正常一打开上位机过几秒就又卡。这说明瓶颈在通信负载。解决方法是把每台设备的读取合并成一个任务一次请求把所有需要的保持寄存器全部读回来然后本地分发。原先是三个任务三个请求现在一个任务一个请求通信量直接降了七成。这个坑给我的教训是不要用并发请求换实时性要用批量读取换实时性。能一次读 20 个寄存器就不要分 20 次读一个寄存器。6.2 设备断电重连的两次握手问题设备完全断电再上电网络连接会经历一个半开的状态。上位机的 TcpClient 可能还认为连接是好的但实际上对端已经没了。这个时候发送数据不会立刻报错要等超时才会暴露问题。更麻烦的是设备恢复后 IP 地址可能因为 DHCP 而改变。车间里如果路由器没有做 IP 绑定设备重启后拿到新 IP上位机还盯着旧 IP 重连永远连不上。这个问题的解法有两个层面一是在 PLC 侧要求电气工程师把设备 IP 固定要么静态配置要么在路由器上做 DHCP 绑定二是在上位机里把设备离线状态做成明确的界面提示不要默默地重试。现在很多项目的现场交换机还会开端口隔离或者 VLANIP 冲突的排查尤其费劲。我还加了一个看门狗逻辑连续 5 次通信失败就判定设备离线界面上设备状态立即变灰同时把离线事件写入日志并尝试以指数退避的方式重连。恢复连接之后不是直接继续下一个轮询周期而是先做一次全量快照读取把设备所有状态刷新一遍避免界面显示的还是断电前的假数据。6.3 报警恢复和人为确认怎么区分这个问题的本质是报警恢复是一种外部事实确认是一种人为操作两者必须分开记录。我第一次实现时把确认和恢复混在一起结果报表上经常出现报警恢复了但确认时间是空的这种怪数据。后来才把状态机理顺报警产生之后不管有没有人确认只要信号恢复状态就会变成恢复未确认如果人先确认了之后信号再恢复就变成恢复已确认。这两种情况在报表里都能准确反映现场处置是否及时。这里最需要跟现场管理人员对齐的是一种特殊场景设备报警了但操作工先去现场把故障处理了设备恢复了才回来点确认。这种情况下报警产生和报警恢复的时间差才是真正影响生产的停线时间而确认时间只代表管理效率。报表统计时这两类指标要分开不要混在一起算。6.4 上线前必须做的三件事这套系统上线之前我强制自己做了三件事确保不会再丢人第一协议抓包验证。用抓包工具把上位机和 PLC 之间的报文抓下来人工核对事务标识、功能码、寄存器地址、数据值是否完全正确。这一步能发现很多解析层的隐性错误比如字节序反了、地址差一。第二连续 72 小时跑机测试。期间故意做各种故障注入拔网线、重启 PLC、停交换机、改 IP看上位机能不能自动恢复报警推送会不会误报或漏报。这个测试一定要在真实设备上做模拟器测不出网络层面的问题。第三跟电气工程师一起做报警逐条清单测试。把 PLC 程序里能产生的每条报警都触发一遍对照上位机的报警描述和推送内容一条一条核对。这个环节最枯燥但也是最能保证上线后不被现场人员吐槽的环节。整套系统上线之后我最大的感受是这类项目难的不是写代码而是把现场那些说不清、道不明的隐形需求吃透。寄存器映射、通信超时、报警状态机这些才是决定系统能不能真正拿来就能用的细节。做完这一个项目对我来说真正的收获不是那套代码而是摸清了从协议层到管理层之间这整条链路的工作方式。以后再遇到类似的监控改造我会直接在章节目录上写清楚架构再动手写第一行代码。