工业级稳定性:C#通信程序异常处理、看门狗机制与容错设计
工业现场的通信程序,从来不是“能收发数据”就算合格。电磁干扰、线路老化、设备偶发死机、电源波动都是常态,一次意外崩溃可能导致整条产线停摆、批次报废。
普通业务程序的思路是“遇到异常报错,人工处理”,但工业级程序的核心要求是:默认一切都会出问题,出问题先自愈,实在不行再告警,绝对不能轻易停生产。
本文从实战角度拆解工业通信程序的三层稳定性体系:分级异常处理、多级看门狗机制、全链路容错设计,所有方案均经过7×24小时产线验证。
一、先建立认知:工业程序的稳定性四原则
在写任何代码之前,先要明确工业场景和普通桌面程序的本质区别:
- 可用性优先:宁可返回兜底值、降级运行,也不能直接崩溃退出。
- 故障可自愈:80%的偶发故障(网络闪断、干扰丢包)要能自己恢复,不需要人工干预。
- 全链路可追溯:任何异常必须留痕,时间、原始数据、错误堆栈、现场状态全部记录,事后可复盘。
- 故障不扩散:一个模块出问题,只能影响自己,不能拖垮整个程序;一个工位出问题,不能连累整条产线。
二、分层异常处理体系:从“崩了再说”到“分级兜底”
很多人处理异常的方式只有两种:要么全吞掉什么都不做,要么直接抛出去让程序崩。工业程序必须建立分级异常机制,不同级别的错误对应不同的处理策略。
2.1 异常三级分类
先把所有异常按影响程度分成三类,处理方式完全不同:
| 异常等级 | 典型场景 | 处理策略 | 是否告警 |
|---|---|---|---|
| 临时性异常 | 网络闪断、干扰丢包、单次超时、设备忙 | 自动重试,失败次数少则不告警 | 否/累计失败才告警 |
| 业务性异常 | 指令不支持、参数越界、设备返回错误码 | 丢弃本次请求,记录日志,按业务规则兜底 | 低级别告警 |
| 致命性异常 | 内存溢出、句柄泄漏、线程死锁、资源耗尽 | 保存现场,触发复位/重启,启动降级模式 | 紧急告警 |
2.2 分层捕获原则
异常不能在同一个地方全处理,要按架构分层各司其职:
- 通信底层(串口/TCP层):只捕获IO异常、超时异常,负责自动重试、重连,对上层屏蔽临时性故障。上层调用者感知不到偶尔的闪断。
- 业务协议层:捕获校验错误、设备返回异常码,负责丢弃脏数据、按业务规则返回默认值,不把协议错误抛给UI。
- 应用顶层(UI/主逻辑):全局兜底,捕获所有未处理异常,记录崩溃日志,能恢复则恢复,不能则安全重启。绝对不能让异常直接打爆程序。
2.3 工业级异常日志:比堆栈更重要的是上下文
很多人记日志只记一句“连接失败”+堆栈,事后根本查不出原因。工业通信的异常日志必须包含完整上下文:
- 精确时间戳(毫秒级)
- 通信链路标识(哪个串口、哪个IP的设备)
- 原始收发字节(十六进制)
- 当前执行的指令类型
- 累计失败次数
- 系统资源状态(内存、CPU)
2.4 C#实现:通信基类统一异常封装
把重试、日志、超时逻辑封装到基类里,所有具体协议(Modbus、自定义协议)都继承它,业务层不用重复写异常处理。
publicabstractclassCommBase{protectedreadonlyILogger_logger;publicstringLinkName{get;}publicintMaxRetry{get;set;}=3;publicintTimeoutMs{get;set;}=1000;protectedCommBase(stringlinkName,ILoggerlogger){LinkName=linkName;_logger=logger;}/// <summary>/// 带重试的安全发送/// </summary>protectedbyte[]SafeSend(byte[]cmd,stringcmdDesc){intretry=0;while(retry<MaxRetry){try{_logger.Debug($"[{LinkName}] 发送{cmdDesc}:{BitConverter.ToString(cmd)}");byte[]reply=SendAndReceive(cmd,TimeoutMs);if(!VerifyChecksum(reply))thrownewInvalidDataException("校验失败");returnreply;}catch(TimeoutException){retry++;_logger.Warn($"[{LinkName}]{cmdDesc}超时,第{retry}次重试");}catch(IOExceptionex){retry++;_logger.Warn($"[{LinkName}]{cmdDesc}IO异常:{ex.Message},第{retry}次重试");// IO异常触发一次重连TriggerReconnect();}}// 重试全部失败_logger.Error($"[{LinkName}]{cmdDesc}连续失败{MaxRetry}次");OnLinkFault();thrownewCommException($"{cmdDesc}失败",LinkName);}// 子类实现具体收发、校验逻辑protectedabstractbyte[]SendAndReceive(byte[]cmd,inttimeout);protectedabstractboolVerifyChecksum(byte[]data);protectedabstractvoidTriggerReconnect();protectedabstractvoidOnLinkFault();}核心原则:临时性异常在底层消化掉,不要往上抛。上层业务只需要知道“成功了”或者“彻底失败了”,不需要知道中间重试了几次。
三、看门狗机制:最后一道防线,死了也能自己活过来
再完善的异常处理,也挡不住死锁、内存泄漏、非托管崩溃这些极端情况。看门狗就是最后一道保险:程序真死了,能自动复活。
工业程序的看门狗是三级体系,从细到粗层层兜底:
3.1 线程级看门狗:监控业务线程心跳
最细粒度的看门狗,用来监控采集线程、计算线程是否卡死。每个关键业务线程都有自己的心跳计数器,看门狗线程定期检查,超时没更新就判定线程挂死。
C#实现示例:
publicclassThreadWatchdog:IDisposable{privatereadonlyDictionary<string,WatchdogItem>_items=new();privatereadonlyThread_watchThread;privatevolatilebool_running;publicvoidRegister(stringthreadName,inttimeoutMs,ActiononTimeout){lock(_items){_items[threadName]=newWatchdogItem{TimeoutMs=timeoutMs,OnTimeout=onTimeout,LastBeat=Environment.TickCount64};}}/// <summary>/// 业务线程调用,喂狗/// </summary>publicvoidBeat(stringthreadName){lock(_items){if(_items.TryGetValue(threadName,outvaritem)){item.LastBeat=Environment.TickCount64;}}}privatevoidWatchLoop(){while(_running){Thread.Sleep(500);// 500ms检查一次lock(_items){foreach(varitemin_items){longelapsed=Environment.TickCount64-item.Value.LastBeat;if(elapsed>item.Value.TimeoutMs&&!item.Value.Triggered){item.Value.Triggered=true;_logger.Error($"线程[{item.Key}]超时未心跳,触发超时回调");// 异步执行回调,避免阻塞看门狗_=Task.Run(item.Value.OnTimeout);}}}}}privateclassWatchdogItem{publicintTimeoutMs;publiclongLastBeat;publicActionOnTimeout;publicboolTriggered;}}使用方式:采集线程每次循环末尾调用Beat()方法,正常运行时持续喂狗;一旦线程死锁、卡死在某个操作上,超时后自动触发回调,执行线程复位、资源释放、重启线程等操作。
3.2 进程级守护:独立进程监控主程序
主程序如果整个崩掉(比如非托管异常、堆栈溢出),内部的看门狗也一起死了。这时候需要一个独立的守护进程,专门盯着主程序。
实现要点:
- 守护程序是独立exe,体积小、逻辑简单,本身不容易崩。
- 定期检测主进程是否存在,不存在就立即重启。
- 监控主程序的内存、CPU占用,持续超标就强制重启。
- 主程序定期给守护进程发心跳,进程活着但卡死了也要重启。
- 守护进程自己也要做自监控,避免自己先死。
极简版守护进程核心逻辑:
staticvoidMain(string[]args){stringtargetExe="主程序.exe";stringprocessName=Path.GetFileNameWithoutExtension(targetExe);while(true){Process[]procs=Process.GetProcessesByName(processName);if(procs.Length==0){Console.WriteLine("主程序已退出,正在重启...");Process.Start(targetExe);}else{// 检查内存占用,超过1G就重启longmemory=procs[0].WorkingSet64;if(memory>1024*1024*1024){Console.WriteLine("主程序内存超标,强制重启");procs[0].Kill();Thread.Sleep(2000);Process.Start(targetExe);}}Thread.Sleep(3000);// 3秒检查一次}}3.3 硬件/系统级看门狗:终极兜底
工控机一般都自带硬件看门狗,程序死机到系统级都动不了的时候,硬件会自动断电重启。可以通过串口或者IO控制硬件看门狗,程序正常运行时持续喂狗,程序死了超时就复位整机。
Windows系统也可以配置“系统失败后自动重启”,作为系统级的最后兜底。
3.4 看门狗避坑指南
- 绝对不要在主线程/UI线程喂狗:UI卡死后主线程不动,看门狗还在正常喂,等于白设。喂狗逻辑必须放在独立的高优先级线程。
- 不要把看门狗超时设太短:太短容易误重启,一般业务线程设5~10秒,进程级设30秒以上。
- 重启必须能自动恢复状态:重启后要自动重连设备、加载参数、恢复运行模式,不能停在登录界面等人点。
- 看门狗要有旁路开关:调试的时候可以临时关闭,不然调个断点程序就被重启了。
四、全链路容错设计:出错了也能正常干活
容错不是“出了错怎么报错”,而是“出了错怎么还能继续干活”。工业程序的容错要贯穿通信、数据、业务、模块四个层面。
4.1 通信层容错:链路冗余与幂等控制
- 双链路冗余:关键设备同时走串口+TCP,或者主备双网口,一条断了自动切另一条,切换过程业务无感知。
- 指令幂等性:所有控制指令设计成可重复发送的,重复执行不会导致误动作。比如用“写寄存器+触发位”的方式,而不是发一次脉冲就执行一次。
- 超时退避:连续失败不要疯狂重试,会把设备砸挂。采用指数退避:第一次失败等100ms重发,第二次等500ms,第三次等2s,避免风暴式请求。
4.2 数据层容错:脏数据过滤与兜底值
工业现场干扰多,读上来的数据偶尔会跳变、越界,直接用会导致控制事故。
- 物理边界校验:温度不可能是-100℃,压力不可能是9999,超出量程直接丢弃,用上一次有效值代替。
- 跳变过滤:两次采样值差值超过物理可能的最大变化率,判定为干扰,丢弃本次数据。
- 默认兜底值:通信完全中断时,输出安全的默认值,比如停机状态、关闭输出,而不是保持最后一个值或者输出0。
4.3 业务层容错:降级运行与旁路模式
- 功能分级:把功能分成核心功能和辅助功能。资源不足时自动关闭非核心功能(比如历史曲线、统计报表),保证采集、控制等核心功能正常。
- AI旁路:AI检测、智能优化这类增强功能出问题时,自动切回传统规则模式,或者直接旁路,不影响基础生产。
- 手动优先:任何时候人工指令优先级最高,自动逻辑出问题,切手动就能立刻接管。
4.4 模块级容错:故障隔离,不扩散
一个模块崩了,不能把整个程序带走。
- 线程级隔离:每个通信端口、每个设备对应独立线程,一个线程崩了只影响一个设备,其他照常运行。
- 异常边界包裹:每个模块的入口都包一层try-catch兜底,异常只在模块内消化,不往外扩散。
- 资源自动回收:模块退出时必须释放所有串口、连接、文件句柄,支持热重启模块,不用重启整个程序。
五、工程化加固:细节决定稳定性
很多稳定性问题不是大架构错了,而是细节没做到位。
5.1 资源安全释放:杜绝句柄泄漏
工业程序跑几个月出问题,大多是资源泄漏:串口句柄、TCP句柄、文件句柄没释放,越用越少,最后打不开新连接。
- 所有实现
IDisposable的对象必须正确释放,优先用using。 - 重连的时候必须先彻底释放旧实例,再新建,不能直接
new SerialPort()覆盖旧的。 - 非托管资源要用
SafeHandle包装,避免忘记释放。 - 定时统计句柄数、GDI对象数,持续增长就是泄漏了。
5.2 死锁预防:锁的正确打开方式
多线程通信程序死锁是重灾区:
- 锁的粒度要小,只锁必要的共享数据,不要在锁里做IO、调用事件、Sleep。
- 加锁顺序要全局一致,永远按A→B→C的顺序加锁,避免交叉锁。
- 用
tryEnter带超时的锁,拿不到锁超时就放弃,不要死等。 - 不要在回调事件里再申请锁,很容易形成环路死锁。
5.3 内存管控:避免大对象堆碎片化
高频通信会产生大量字节数组,进入大对象堆(LOH),时间长了碎片化,内存越跑越高,最后触发Full GC卡顿。
- 用
ArrayPool<byte>复用缓冲区,不要每次收发都new数组。 - 大尺寸数据尽量用
Span<byte>、Memory<byte>操作,减少拷贝。 - 低峰期(比如夜班、换班)主动执行一次完整GC并压缩LOH:
GCSettings.LargeObjectHeapCompactionMode=GCLargeObjectHeapCompactionMode.CompactOnce;GC.Collect(2,GCCollectionMode.Forced,true,true);
5.4 绝对不能阻塞UI线程
工业上位机的UI线程卡死,操作员就看不到状态、没法操作,等同于生产事故。
- 所有通信、计算、IO操作全部放后台线程,绝对不能在按钮点击、定时器事件里同步执行耗时操作。
- 不要用
MessageBox做异常提示,弹出来没人点就会一直卡住程序。异常用日志+界面状态栏+声光告警提示。 - UI更新用
BeginInvoke异步回调,不要用Invoke同步等待,避免死锁。
六、现场高频踩坑实录
1. 异常全吞,死都不知道怎么死的
- 现象:程序突然不干活了,没有任何报错,日志也没记录。
- 原因:为了不崩溃,写了空的
catch { }把所有异常吃掉了,连日志都不记。 - 解决:任何catch至少要记日志,绝对不能空吞。宁可崩溃留堆栈,也不要静默死亡。
2. 看门狗和业务线程一起死
- 现象:程序卡死了,但看门狗没触发重启。
- 原因:喂狗逻辑写在主线程里,主线程一起卡死,还在持续喂狗。
- 解决:看门狗必须是独立线程,优先级高于业务线程;心跳要反映真实业务状态,不是简单的计数器。
3. 无限重试把设备干挂
- 现象:通信失败后疯狂重发指令,设备直接死机,必须断电重启。
- 原因:没有退避机制,失败了立刻重试,一秒钟发几十条指令,设备处理不过来直接挂。
- 解决:连续失败指数退避,达到最大次数后进入故障状态,间隔很久再尝试恢复,不要死循环轰炸。
4. 重连成功但数据不更新
- 现象:断线重连后显示“已连接”,但数据一直不刷新。
- 原因:只重连了端口,没有恢复业务状态,比如没有重新订阅数据、重启采集线程、恢复寄存器轮询。
- 解决:重连成功后执行完整的初始化流程,和第一次连接做完全一样的操作。
5. 异常弹窗卡死程序
- 现象:半夜程序出异常弹了个MessageBox,早上来发现停了一整夜。
- 原因:后台线程抛异常弹MessageBox,没有人工点击就一直阻塞。
- 解决:禁止在非UI线程弹消息框;所有异常用日志+界面状态提示;生产环境禁用所有阻塞式弹窗。
写在最后
工业级程序的稳定性,从来不是靠“写得好不出Bug”,而是靠“默认会出问题,并且准备好所有退路”。
异常处理解决“小故障怎么自愈”,看门狗解决“死了怎么复活”,容错设计解决“坏了怎么继续干活”。三层体系叠加,才能真正做到7×24小时无人值守稳定运行。
记住一句话:好的工业程序,不是永远不出错,而是出了错也不会影响生产,并且事后能查清楚为什么错。