凌晨两点被电话叫到车间,屏幕上一条扭矩曲线直冲红线,操作工一口咬定压机没毛病,是上位机在乱报——这种事做过液压压机上位机的人基本都遇过。WCS-HY 这套设备的 C# 上位机,说白了就是把两件苦力活干扎实:一是跟控制器之间的实时通信,二是把扭矩这条线采准、画清、判对。前者决定程序在车间里能不能活过三班倒,后者决定质检部门认不认你这套系统。这篇内容会把通信层的地址对齐、连接自愈、轮询节拍,扭矩采集的采样节奏、滤波参数、降采样显示,以及工艺判定与现场排错的完整链路摊开讲,适合刚接手工业上位机项目、手里有 C# 基础但没怎么碰过现场设备的开发者,也适合已经在写但总被"时通时断""曲线跳变"折磨的人对照排查。
1. 压机不等上位机:先算清时间账再动手写代码
1.1 一个压装周期里,上位机真正能用的时间窗口
液压压机的节拍通常比很多人想象的快。一套中小吨位的压装设备,快下、慢下、压装、保压、卸压、回程走完一个完整周期,短的五六秒,长的二三十秒。真正产生有效扭矩数据的,只有"压装 + 保压"那一段,往往只占整个周期的三分之一到一半。如果采样周期设成 200ms,一个 8 秒的压装段你只能拿到 40 个点,这条曲线拿去做工艺分析是够呛的——拐点在哪、峰值保持了几百毫秒、有没有二次冲击,全都看不出来。
这就是为什么我一开始就把采样周期定到 20ms 甚至 10ms。听起来不高,但你算一下:50Hz 采样,一个 20 秒的周期就是 1000 个点,一天三班按 2000 个周期算,光扭矩曲线就是 200 万个 float。这个量级直接决定了后面存储方案和图表方案的选型,不是拍脑袋定的。所以我习惯在动手写第一行代码之前,先把这张时间账算出来:
| 项目 | 典型取值 | 对上位机的约束 |
|---|---|---|
| 完整压装周期 | 6~25 s | 周期状态机要能识别起止 |
| 有效数据段 | 2~12 s | 判定窗口必须落在这段内 |
| 扭矩采样周期 | 10~20 ms | 采集线程不能被 UI 阻塞 |
| 通信轮询周期 | 50~200 ms | 状态量够用,扭矩量另走一路 |
| 单日周期数 | 1500~3000 | 存储与归档策略要提前设计 |
注意最后一行的坑:很多人把扭矩值也塞进通信轮询里一起读,觉得省事。控制器那边的模拟量输入刷新本身就有自己的扫描周期,你 50ms 去读一次,读到的很可能是同一个值重复两次,或者干脆是上一次的缓存。所以我的做法是把"状态量轮询"和"扭矩采集"彻底拆成两条路径,下面第 3 章会细讲。
1.2 扭矩监控盯的到底是哪个扭矩:液压侧与机械侧的区分
这一点必须先讲清楚,因为我在现场见过不止一次概念混淆导致的判定逻辑写错。液压压机上出现"扭矩"这个词,可能指两个完全不同的东西:
一种是液压马达输出轴的扭矩,属于液压系统侧,很多时候是从系统压力换算出来的,因为马达排量固定,扭矩 roughly 正比于压差。这种"扭矩"本质上是一个派生量,精度受压力传感器和换算系数影响,适合做趋势监控和过载保护,不适合做精确的工艺判定。
另一种是压装工位上的拧紧/旋压扭矩,由装在工装上的应变式扭矩传感器直接测量,输出的是 mV/V 级的微弱信号,经变送器放大成 0~10V 或 4~20mA,或者走数字接口。这个才是真正参与质量判定的量,因为它直接反映了工件之间的配合状态。
这两条路的采集方式、量程换算、滤波参数、判定阈值全都不一样。我一般会在代码里用两个独立的数据通道去承载,命名上严格区分(比如HydTorque和SpindleTorque),绝不共用同一个变量名。这算是给自己和后来接手的人留条活路——半年后回来看代码,至少知道哪个数是"算出来的",哪个数是"测出来的"。
1.3 技术栈定型:为什么最后落在 C# + WPF + 自研通信层
框架选择上,这类项目我基本固定用 .NET 6/8 的 WPF,不用 WinForms。不是因为 WPF 更"高级",而是因为这个场景里有一个硬需求:实时曲线。WinForms 里画动态曲线,要么用第三方控件(性能参差不齐),要么自己 GDI+ 双缓冲,一不小心就闪屏。WPF 的DrawingVisual或者成熟的实时图表组件在数据量大时表现更稳,而且数据绑定配合INotifyPropertyChanged写状态面板非常省事。
通信层我没有用现成的封装库直接调,而是自己封了一层IPlcChannel接口,底下挂两种实现:Modbus TCP 和厂商私有协议的 Socket 帧解析。这么做的原因很实际——现场设备五花八门,今天这台压机走 Modbus,明天旁边那台老设备只有串口,后天客户要求接 OPC UA 做数据上抛。如果通信逻辑散落在各个窗体的按钮事件里,换一次协议就要改一遍业务代码,那是灾难。
public interface IPlcChannel : IDisposable { bool IsConnected { get; } Task<bool> ConnectAsync(CancellationToken ct); Task<ushort[]> ReadHoldingAsync(ushort start, ushort count, CancellationToken ct); Task WriteSingleAsync(ushort addr, ushort value, CancellationToken ct); event EventHandler<ChannelStateChangedEventArgs> StateChanged; }把这四个方法抽出来,业务层只认接口,换协议只换实现类,窗体代码一行不动。这套抽象我在三个不同项目上复用,改动量最大的一次也只花了半天。
存储方面,工艺参数、配方、报警记录这类结构化数据我用嵌入式单文件数据库,扭矩曲线的原始点则按周期打包成二进制块存文件,只在数据库里留索引。理由很简单:一条 1000 点的曲线转成行存进去,查询和写入都慢,而且数据库文件几个月就膨胀到几个 G,备份都费劲。
2. 通信层落地:从点表到能扛住三班倒的长连接
2.1 点表核对:地址偏移、字序、量纲这三处最容易翻车
拿到设备厂商给的点表,第一件事不是写代码,是核对。我踩过的坑里,地址偏移问题占了差不多一半。Modbus 的寄存器地址有两套编号体系:一套是协议报文里的 0 基地址,一套是工程文档里常见的 4xxxx 编号(1 基)。点表上写着"40001,压机当前压力",实际报文里要访问的是偏移 0。如果点表是从别的系统抄来的,很可能已经偏移过一次,你再偏一次,读到的就是隔壁那个寄存器的值——而且往往也是个合理范围内的数字,不会报错,只会让你怀疑人生。
我的核对方法是:先只读一个已知的、有明确物理意义的寄存器(比如设备就绪标志位),手动让设备切换状态,看这个位有没有跟着变。确认无误后再批量验证其他地址。这个笨办法能省下大量后期排查时间。
字序问题同样隐蔽。32 位浮点数跨两个 16 位寄存器存储,不同厂商的排列方式有四种组合(高低字交换与否、字节交换与否)。读出来如果是1.4E-45这种极小值或者NaN,八成就是字序反了。我一般会准备一个小工具窗,把读到的原始两个 ushort 按四种组合都拼一遍显示出来,对着设备面板上的实际数值一比,立刻就知道该用哪种。
重头戏是量纲。点表上写"压力:0~10000",你以为单位是 0.01MPa,实际是 0.001MPa,整个判定阈值全部偏差十倍。这类错误在调试阶段往往看不出来(因为数值范围看起来"像那么回事"),等到试产阶段批量误判才暴露。所以我现在会在配置表里强制加一列"单位"和"换算系数",代码里所有物理量都统一换算成基本单位(MPa、N·m、mm)再进业务层,界面上要显示什么单位再单独转回去。
2.2 长连接、心跳与退避重连的骨架实现
车间网络环境你没法指望,交换机放在电柜里、旁边就是变频器,网线跟动力线捆在一个线槽里走的情况我都见过。所以通信层的第一原则是:假设它随时会断,并且断了之后能自己爬起来。
我的实现骨架是这样的:一个常驻的连接管理任务,内部维护TcpClient和连接状态,主循环里做三件事——确保连接、周期性心跳(读一个固定寄存器,通常用设备运行状态字)、异常时按退避策略重连。
private async Task RunLoopAsync(CancellationToken ct) { int retry = 0; while (!ct.IsCancellationRequested) { try { if (!_client.Connected) { await ConnectCoreAsync(ct); retry = 0; RaiseState(true); } // 心跳:读设备状态字,超时即判定链路异常 using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(_options.HeartbeatTimeoutMs); await ReadHoldingCoreAsync(0x0064, 2, cts.Token); await Task.Delay(_options.HeartbeatIntervalMs, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) { RaiseState(false, ex.Message); SafeClose(); retry++; // 指数退避 + 上限,避免疯狂重连把交换机打满 int delay = Math.Min(500 * (1 << Math.Min(retry, 5)), 15000); await Task.Delay(delay, ct); } } }退避这一步很重要。我早期图省事写成固定 200ms 重试,结果有一次现场网线被叉车压断了,程序一晚上重连了几十万次,日志文件涨到 2G,还把旁边的交换机搞得半死。改成指数退避、最长 15 秒一次之后,世界清净了。
还有一个细节:TcpClient.Connected这个属性极度不可靠,它反映的是"上一次操作的状态",连接被对端静默断开后它可能一直是 true。所以判断链路健康不能靠它,只能靠"心跳有没有按时成功返回"。这个认知我花了两个项目才彻底建立起来。
2.3 批量读与轮询节拍:别让上位机变成总线上的噪声源
Modbus 一条 03 功能码报文最多读 125 个寄存器,但实际能不能一次读满,取决于设备固件的实现质量和寄存器地址是否连续。我的策略是:把点表按地址排序,做区间合并,把连续或间隔小于 8 个寄存器的地址合并成一次批量读,超过 60 个寄存器的区间再切开。这样一次轮询通常只需要 2~4 条报文,而不是几十条。
轮询周期的设定有个反直觉的结论:不是越快越好。设备侧的通信任务通常跟控制逻辑跑在同一个 CPU 上,你上位机每 10ms 发一条报文,设备那边就要频繁中断去处理,轻则控制周期抖动,重则通信任务挤占控制任务导致动作延迟。我见过一个案例,上位机轮询调到 20ms 之后,压机的慢下速度明显不稳——原因就是这个。
所以我的默认配置是:状态量轮询 100~200ms,报警位单独用 200ms 轮询(不跟状态量合并,因为报警需要独立优先级),扭矩走独立通道(第 3 章详述)。这个节拍在绝大多数压装场景下都够用,而且对设备侧的压力可以忽略不计。
| 轮询分组 | 周期 | 说明 |
|---|---|---|
| 状态字/模式字 | 100 ms | 影响状态机推进,稍快 |
| 实时压力/位移 | 100 ms | 用于显示和轻量判定 |
| 报警与故障码 | 200 ms | 独立报文,避免被大包阻塞 |
| 配方与参数区 | 按需 | 只在切换配方时读 |
| 扭矩数据 | 10~20 ms | 独立通道,见第 3 章 |
2.4 数据可信度校验:时间戳、序号与坏值过滤
从设备读回来的数,不是每个都值得相信。我在这套系统里加了三层过滤:
第一层是新鲜度校验。每批读回来的数据带上本地打的时间戳,如果业务层拿到的数据超过 3 个轮询周期还没更新过,直接标记为"数据陈旧",判定逻辑暂停而不是拿旧值继续算。这一条救过我很多次——通信悄悄卡死但界面还在显示最后一条曲线的时候,如果判定逻辑继续跑,出来的合格品可能是假的。
第二层是范围校验。压力、位移、扭矩都配置了物理量程上下限,超出直接丢弃并计数,连续丢弃超过阈值就触发通信异常报警。注意这里不能简单地 clip 到量程内,那会把真实的超量程故障(比如传感器断线导致的满偏)掩盖掉。
第三层是变化率校验。扭矩在 20ms 内的变化量如果超过物理上可能的极限(比如超过满量程的 30%),基本可以判定是干扰尖峰或者通信错位,直接丢弃这个点。这一条对后面画曲线和判定峰值特别有用,能过滤掉绝大部分因为电磁干扰造成的单点跳变。
3. 扭矩采集链路:从传感器信号到一条能看懂的曲线
3.1 模拟量与数字量:两条采集路径的取舍
扭矩传感器出来的信号,在进入上位机之前通常已经被变送器处理过,落地的形态无非两种:一种是通过设备的模拟量输入模块变成数字量存在寄存器里,上位机读寄存器就行;另一种是变送器直接输出 4~20mA 或 0~10V,上位机通过自己的采集卡或者串口模块读。
这两条路差别很大。走设备模拟量模块的好处是接线简单、隔离做得好、跟其他工艺量同源,缺点是采样率被设备扫描周期限制死了,而且分辨率往往只有 12 位,对于需要看细节的曲线来说不够。走独立采集卡的好处是采样率和分辨率都能自己控,缺点是要自己处理接地、屏蔽、共模干扰这一堆麻烦事,接线稍有不慎就是一堆噪声。
WCS-HY 这套我最后选的是走设备模拟量模块,理由是它的压装段持续时间有 2 秒以上,12 位分辨率配合 ±0.5% 的传感器精度已经够用,多引入一套采集硬件反而增加了故障点。如果后面要做更高速的旋压成型监控,我会考虑独立采集卡,那时候采样率需求会上到 1kHz 以上,模块路线就走不通了。
量程换算这块给个具体例子。传感器量程 0~500 N·m,变送器输出 4~20mA,设备模拟量通道配置为 4~20mA 对应 0~27648(这是一个常见的 16 位整数满量程约定,不同设备可能不同,务必查手册)。那么代码里的换算就是:
private const double SensorMax = 500.0; // N·m private const double RawMin = 0.0; private const double RawMax = 27648.0; public double ConvertTorque(ushort raw) { // 先钳位,防止断线时满偏导致曲线爆表 double r = Math.Clamp(raw, RawMin, RawMax); return (r - RawMin) / (RawMax - RawMin) * SensorMax; }注意Math.Clamp这里是有争议的。如果传感器断线,4~20mA 变成 0mA,对应原始值是负数区间,钳位之后会显示 0 N·m,看起来像"没受力",反而掩盖了故障。所以我在钳位之前会先判断原始值是否落在有效区间(比如低于 -1000 就判定断线),先报故障再钳位。
3.2 采样定时用 Stopwatch 循环,而不是 WinForms 的 Timer
这是我在第一个项目上栽的跟头。当时用System.Windows.Forms.Timer设 20ms 去采数据,结果实测下来周期在 15~60ms 之间乱跳,曲线画出来全是毛刺,一度以为是传感器问题,查了两天才发现是定时器精度问题。WinForms Timer 的精度本来就只有 15ms 左右,而且它会跟 UI 消息队列抢时间,UI 一卡它就停。
正确的做法是独立线程 +Stopwatch自旋补偿:
private void SampleLoop(CancellationToken ct) { Thread.CurrentThread.Priority = ThreadPriority.AboveNormal; var sw = Stopwatch.StartNew(); long nextTick = 0; const long periodTicks = 20 * Stopwatch.Frequency / 1000; // 20ms while (!ct.IsCancellationRequested) { nextTick += periodTicks; var sample = ReadTorqueOnce(); // 内部走自己的通道 _ringBuffer.Publish(sample); long wait = (nextTick - sw.ElapsedTicks) * 1000 / Stopwatch.Frequency; if (wait > 1) Thread.Sleep((int)(wait - 1)); else if (wait < -periodTicks) nextTick = sw.ElapsedTicks; // 落后太多就重置基准 } }这里的nextTick累加是关键,它保证长期平均周期严格等于设定值,而不是"每次睡 20ms"导致的累积漂移。最后那行重置基准也很重要——如果某次采集耗时特别长(比如发生了一次重连),继续累加会让后面一串采样全部挤在一起,不如直接放弃补课、重置节奏。
3.3 滤波参数怎么定:一阶低通与滑动平均的实测对比
原始扭矩信号一定有噪声,直接判定会误报,直接显示会难看。我用过两种滤波,效果差异挺明显:
滑动平均(窗口 N=5)对白噪声压制效果好,计算简单,代价是引入固定延迟 (N-1)/2 个采样点。20ms 采样、N=5 的话延迟 40ms,对于要捕捉峰值保持时间的场景来说还能接受。
一阶低通y = α·x + (1-α)·y延迟更小(近似为 1/α 个采样点),但在阶跃响应上会有拖尾,峰值会被削掉一部分。对于扭矩峰值判定这种场景,削峰是致命的。
我最后的方案是混着用:显示层用一阶低通,α 取 0.25 左右,曲线平滑好看;判定层用滑动平均 N=3,尽量保留峰值;原始值完整存盘,事后追溯时可以用原始数据重新算。这个分工很重要——不要拿显示用的平滑数据去做判定,那等于自己给自己挖坑。
还有一个必须做的处理:零点校准。扭矩传感器有温漂,压机连续跑几个小时后,空载时读数可能从 0.0 漂到 2.3 N·m。我的做法是在每个周期开始前(压头处于上位、确认无负载的窗口)采 20 个点取平均作为本周期零点,全部数据减去这个值再进判定。这个操作只花 400ms,但对判定准确性的提升非常明显。
3.4 曲线显示的降采样:MinMax 分桶法
一条 20 秒周期、20ms 采样的曲线有 1000 个点,显示在 800 像素宽的图区里还好说。但如果操作工想看最近 10 分钟的趋势,那就是 3 万个点,直接往图表控件里塞,界面立刻卡死。
我的处理是 MinMax 分桶降采样:目标像素宽度 W,把原始数据分成 W 个桶,每个桶只取最大值和最小值两个点画出来。这样点数固定为 2W,无论原始数据多长,绘制开销都是恒定的,而且曲线的包络形状完全保留——尖峰不会丢,这正是扭矩曲线最需要保住的视觉特征。
public static IReadOnlyList<Point> MinMaxDecimate(ReadOnlySpan<Point> src, int buckets) { if (src.Length <= buckets * 2) return src.ToArray(); var result = new List<Point>(buckets * 2); double step = (double)src.Length / buckets; for (int i = 0; i < buckets; i++) { int s = (int)(i * step), e = Math.Min((int)((i + 1) * step), src.Length); double min = double.MaxValue, max = double.MinValue; int minIdx = s, maxIdx = s; for (int j = s; j < e; j++) { if (src[j].Y < min) { min = src[j].Y; minIdx = j; } if (src[j].Y > max) { max = src[j].Y; maxIdx = j; } } // 按时间顺序输出,保证曲线不乱 if (minIdx < maxIdx) { result.Add(src[minIdx]); result.Add(src[maxIdx]); } else { result.Add(src[maxIdx]); result.Add(src[minIdx]); } } return result; }最后那个"按时间顺序输出"的细节很多人会漏,不按序输出的话曲线会画出锯齿状的交叉线,看起来像信号在剧烈振荡,实际上是绘制顺序错了。
4. 工艺判定:把扭矩曲线翻译成可执行的结论
4.1 三种判定模型的适用边界
扭矩曲线拿到手,怎么判合格不合格,取决于工艺类型。我在这套系统里实现了三种模型,可以按配方切换:
峰值判定最简单,取整个压装段的最大扭矩,落在 [下限, 上限] 区间内即合格。适用于单纯的压装到位检测,比如轴承压装,扭矩过小说明配合松、过大说明压歪了。缺点是它对过程不敏感,中间出现一次异常冲击但峰值恰好合格,就漏判了。
包络判定更严格,预先录一条标准曲线作为模板,并给出一条上下包络带,实际曲线必须全程落在带内才算合格。这个模型对过程异常非常敏感,能捕捉到"先冲击后正常"这种隐蔽问题。代价是模板的建立需要足够的样本,而且对来料批次差异敏感,包络带定太窄会大量误判。我的做法是先跑 50 件取统计,用均值 ±3σ 作为初始包络,再根据实际良率微调。
拐点判定用在需要识别"贴合点"的场景。压装过程中扭矩会先缓升,工件贴合瞬间出现明显拐点,然后陡升。拐点位置对应压入深度,是判断配合质量的关键。实现上用一阶差分找斜率突变点,配合滑动窗口平滑,避免噪声造成假拐点。
| 模型 | 敏感度 | 建立成本 | 典型适用 |
|---|---|---|---|
| 峰值判定 | 低 | 低 | 简单压装、通止规式判断 |
| 包络判定 | 高 | 中(需样本) | 精密压装、装配质量严控 |
| 拐点判定 | 中 | 高(需标定) | 需识别贴合点/压入深度 |
4.2 判定窗口的时间基准:别用系统时间做工艺时钟
这是个血泪教训。我早期的实现里,判定窗口的起止是用DateTime.Now打的时间戳,然后按时间差去截取数据。上线后发现偶发性的判定结果漂移——同一批工件,判定结果时好时坏。查了很久才定位到原因:设备在夜班做了一次系统时间同步,时间往回跳了 3 秒,导致那段时间的窗口计算全乱。
正确做法是:工艺时钟一律用单调递增的计数器,比如Stopwatch.ElapsedTicks或者自增的采样序号。系统时间只用于日志记录和数据显示,绝不参与任何判定逻辑。我现在的实现里,每个采样点带一个long seq序号和一个long ticks(Stopwatch 基准),周期起止用序号标记,判定窗口用序号区间表示,彻底跟墙钟解耦。
顺带一提,周期起止的识别也有讲究。不能用"扭矩从 0 变大"这种简单条件,因为空载时的噪声就会触发。我的做法是结合设备状态字:状态字进入"压装中"的那一帧作为周期起始参考,再往前回溯 200ms 作为数据窗口起点(把接近段也算进来);状态字退出"压装中"后再等 500ms 收尾,确保保压段的尾部数据完整收进来。
4.3 报警分级与防抖:别让操作工对红色麻木
报警设计上我走过两个极端:一开始所有异常都弹窗 + 蜂鸣,结果噪声引起的单点超限一天报几百次,操作工直接把蜂鸣器拔了;后来改成只记日志不提示,结果真出问题时没人知道。现在的方案是三级:
一级是提示,只记日志和状态栏变黄,比如单点超限但未持续;二级是告警,界面提示 + 记录到报警表,需要操作工确认,比如判定不合格;三级是故障,弹窗 + 蜂鸣 + 联锁停机建议,比如传感器断线、通信中断超过 3 秒。
防抖是必须的。判定为不合格之前,要求连续 N 个周期(默认 2)都超限才确认,避免单次干扰造成误判。但注意防抖不能用在安全类报警上——通信中断、传感器断线这类必须立即响应,晚一个周期都可能出事。
还有一个细节:报警要有"锁存"和"复位"的概念。超限条件消失后,报警不能自己消失,必须等操作工确认复位,否则会出现"报警闪一下就没了,事后查不到"的情况。我在数据库里对每条报警记录都存了触发时间、清除时间、确认人(工位号)三个字段,追溯时能完整还原。
4.4 一个压装周期该存哪些数据
存储设计上,我按"够用且能追溯"的原则定了几张表。判定结果表存周期号、时间、配方号、峰值扭矩、各判定模型的结论、报警码;曲线数据表不存点,只存文件路径和元信息(起始序号、点数、采样周期、量程);参数变更表存配方修改记录,谁改的、改前改后各是什么值。
扭矩原始数据我按周期存成二进制文件,一个文件就是一段连续的 float 数组,加上一个 32 字节的头(魔数、版本、点数、采样周期、量程、校验和)。这么设计的理由是查询快、体积小、解析简单。一个 1000 点的曲线文件大约 4KB,一天 3000 个周期也就 12MB,随便存。
// 文件头结构:魔数(4) + 版本(2) + 保留(2) + 点数(4) + 采样周期ms(4) + 量程(8) + 校验(4) public static void SaveCurve(string path, float[] data, int periodMs, double fullScale) { using var fs = new FileStream(path, FileMode.Create, FileAccess.Write); using var bw = new BinaryWriter(fs); bw.Write(0x57594353); // "WYCS" bw.Write((ushort)1); bw.Write((ushort)0); bw.Write(data.Length); bw.Write(periodMs); bw.Write(fullScale); bw.Write(Crc32.Compute(MemoryMarshal.AsBytes(data.AsSpan()))); foreach (var v in data) bw.Write(v); }校验和这一项别省。车间里断电是常事,写到一半的文件很常见,没有校验的话读出来就是一堆乱码数值,判定逻辑可能据此得出完全错误的结论。
5. 现场排错实录:四类典型故障的完整排查链路
5.1 通信"时通时断":从物理层一路查到应用层
现象是这样的:程序跑起来能连上,读数据也正常,但每隔十几分钟就会断一次,断几秒又自己连上。日志里全是超时异常,但设备面板显示通信正常。
我的排查顺序是固定的,从下往上:
第一步查物理层。让现场把网线从动力线槽里拿出来临时走地面,问题依旧,排除强电干扰。用交换机端口统计看错包和 CRC 错误计数,正常,排除线缆质量问题。
第二步查网络层。ping 设备 1000 个包,丢包率 0,延迟稳定在 1ms 以内,排除网络拥塞。这时候基本可以确定问题在应用层或设备侧。
第三步查连接生命周期。加了详细日志,记录每次连接建立和断开的时间点、断开前的最后一次操作。发现一个规律:断开前的那次操作,恰好是读一个大块参数区(一次读 100 个寄存器)。而设备固件对单帧长度有限制,超过 64 个寄存器的请求会被静默丢弃,TCP 层没有 RST,所以客户端只能等到超时。
第四步验证。把批量读的最大长度从 100 降到 60,问题消失。
这个坑的教训是:点表合并策略不能拍脑袋定上限,必须以设备手册标称的最大帧长为准,而且要留余量。很多设备的 Modbus 实现是"标称 125,实际 64",你按标称来就会踩雷。现在的默认配置里我把单帧上限设成 60,并且在配置文件里留了可调项。
5.2 扭矩零点漂移与尖峰跳变的两种不同成因
这两个现象经常被混为一谈,但成因完全不同,处理方式也不一样。
零点漂移是慢变过程,几十分钟到几小时才明显。成因主要是传感器温漂和变送器零点漂移,车间温度从早上 15 度升到中午 30 度,漂个几牛米很正常。处理方式就是前面说的周期前零点校准,配合一个长期趋势监控——如果连续多个周期的零点偏移超过阈值(比如满量程的 3%),说明传感器可能有问题了,提前预警。
尖峰跳变是突变,单点或连续两三个点突然跳到满量程附近再跳回来。成因通常有两个:一是电磁干扰,变频器、伺服驱动器动作的瞬间耦合进信号线;二是通信错位,寄存器读到了相邻通道的值。
区分方法很简单:看跳变点的原始值。如果跳变值恰好等于相邻通道(比如压力通道)的值,那就是通信错位,要从地址映射查起;如果跳变值是个没有规律的极端值,且总是伴随着某个设备的启停时刻,那就是干扰,要从屏蔽和接地查起。
我遇到过一次典型案例:每次压机回程启动的瞬间,扭矩曲线就冒一个尖峰。查下来是回程电磁阀的线圈跟扭矩信号线走在同一个线束里,阀动作时的反向电动势耦合进来了。换了屏蔽线、单端接地、并且加了一级 RC 滤波之后解决。软件侧的尖峰过滤(变化率校验)只能治标,物理隔离才是治本。
5.3 连续运行几天后内存和句柄一路涨
这个问题的表现是:程序刚启动时占 200MB 内存、200 个句柄,跑三天之后变成 1.8GB 内存、3000 个句柄,最后卡到无法操作。
排查用两步。先用任务管理器看句柄类型分布,发现大量Event和File句柄,基本指向事件订阅未取消和文件流未释放。再翻代码,找到两处:
第一处是图表控件。每次切换配方时,代码会new一个图表对象替换掉旧的,但旧对象上挂的事件处理器(比如鼠标移动、图例点击)没有解绑,导致旧对象一直被事件源引用着,GC 收不掉。修正方式是切换时先-=解绑所有事件再替换,或者干脆复用同一个控件只换数据源。
第二处是日志文件流。日志组件每次写文件都new StreamWriter,用完没Dispose,靠 GC 的终结器回收。终结器回收不及时,句柄就一直挂着。改成用using包裹,或者让日志组件长期持有一个流对象,问题解决。
修完之后我做了一次 72 小时压测,内存稳定在 250MB 上下波动(GC 正常回收的锯齿),句柄数恒定在 220 左右。这里有个经验:长期运行的工控程序,判断内存是否泄漏的标准不是"数值不涨",而是"涨了之后能回落到基线"。完全不涨是不可能的,GC 有它自己的节奏。
5.4 界面白屏未响应:Invoke 风暴与锁竞争
现象:压机高速运行时界面会卡个一两秒,鼠标变成等待状态,日志里没有任何异常。
这个问题的根源在于 UI 线程被两件事夹击:一是采样线程每秒 50 次通过Control.Invoke往 UI 推数据,每次 Invoke 都是一次同步的消息泵等待;二是判定逻辑里有个lock,UI 线程和采样线程抢同一把锁。
修法是三管齐下:
第一,用 Channel 替代 Invoke。采样线程只往Channel<Sample>里写,UI 侧用一个独立的消费任务批量取,每 100ms 取一次,一次取走所有积压的点,一次性更新控件。这样 UI 线程的唤醒频率从 50 次/秒降到 10 次/秒,而且在 UI 忙的时候数据不会丢,只是延后处理。
// 采样侧 _channel.Writer.TryWrite(sample); // UI 侧 private async Task UiConsumerAsync(CancellationToken ct) { var batch = new List<Sample>(64); while (await _channel.Reader.WaitToReadAsync(ct)) { batch.Clear(); while (batch.Count < 512 && _channel.Reader.TryRead(out var s)) batch.Add(s); UpdateUi(batch); // 一次更新,复用画笔和几何对象 await Task.Delay(100, ct); } }第二,缩小锁的粒度。原来判定逻辑锁的是整个周期数据列表,改成只锁"结果写入"那一小段,数据读取走无锁的环形缓冲区。
第三,禁用不必要的界面刷新。动画、渐变、透明度这些视觉效果在工控界面上一个都不要,AllowsTransparency=true的窗口在低配工控机上性能损失非常明显。
这套改完之后,界面在压机 60 次/分钟的节拍下依然流畅。
6. 交付前的打磨:让这套程序在现场自己扛住
6.1 日志分级与一键取证
现场出问题时,最怕的是"用户说不清,你也看不到"。所以我在交付前一定要做两件事:日志按级别分文件、界面上有一个"导出诊断包"的按钮。
日志分三级:Info只记周期判定结果和状态切换,一天几 MB;Warn记通信重连、坏值丢弃、参数越界;Error记异常堆栈。三级分别写不同文件,并且按天滚动、默认保留 30 天。这样平时磁盘占用可控,出问题时又能翻到足够的上下文。
诊断包是个 zip,里面打包最近三天的日志、当前配置快照、最近 200 个周期的判定结果、以及一份环境信息(系统版本、运行时版本、设备通信参数)。操作工点一下,桌面上生成一个文件,发过来就能分析。这个功能做完之后,我远程定位问题的平均耗时从一天多缩短到一两个小时,属于投入产出比极高的一个功能。
这里有个安全注意点:诊断包里的配置快照要脱敏,工艺参数、配方这类客户敏感数据不能原样打包,我一般是打包参数的结构和校验值,具体数值单独确认后再要。
6.2 参数外置与版本可回溯
所有判定阈值、滤波系数、通信参数全部外置到配置文件,代码里不写任何魔数。这件事的意义不只是"方便改",更重要的是可回溯。现场调整过一次阈值,后来判定出问题,如果没有版本记录,根本说不清是设备变了还是参数变了。
我的做法是配置文件带一个版本号,每次保存都自动备份一份带时间戳的历史版本,保留最近 50 份。界面上提供"参数对比"功能,选两个版本能直接看差异。这个功能在一次客户投诉中救了场——对比发现是夜班把上限调高了 15%,不是设备问题。
配置的加载也要做健壮性处理:文件损坏或者字段缺失时,不能直接崩溃,要回退到内置的默认配置并且报警提示。我见过配置文件被断电写坏、程序开机直接起不来的情况,那个场景下操作工毫无办法,只能等人来处理。
6.3 上线自检清单
最后分享一份我在每次上线前都会过一遍的清单,都是被坑出来的:
| 检查项 | 判定标准 |
|---|---|
| 断网恢复 | 拔网线 1 分钟再插回,30 秒内自动恢复且数据连续 |
| 断电恢复 | 直接断电重启,程序自动启动并进入待机 |
| 长时间运行 | 连续 72 小时,内存回落正常、句柄数稳定 |
| 时钟跳变 | 手动改系统时间,判定结果不受影响 |
| 满量程测试 | 用标准源加满量程,显示值误差在 0.5% 内 |
| 零点漂移 | 空载运行 8 小时,零点偏移小于满量程 1% |
| 权限验证 | 非授权账号无法修改工艺参数 |
| 数据完整性 | 随机抽查 20 个历史周期,曲线文件可正常解析 |
这个清单我用了三年,每加一条背后都有一个具体的故障。比如"时钟跳变"那条,就是前面说的时间同步导致判定漂移那次留下的。
最后说个实操体会:这类项目里,真正难的不是把功能做出来,是把功能做出来之后在车间那个环境里稳定活一年。功能开发可能占三成时间,剩下七成都在处理通信抖动、信号干扰、内存泄漏、界面卡顿这些"不体面"的问题。所以我在写第一版的时候就会把日志、重连、异常兜底这些"看不见"的东西先搭好,看起来进度慢,实际上省下了后面无数个被电话叫起来的夜晚。扭矩曲线的采样、滤波、判定这套东西,我现在的习惯是先在一个模拟数据源上把整条链路跑通、把边界情况测完,再接真实设备——用模拟器能复现的异常,比在现场碰运气要高效得多。