工业级稳定性:C#通信程序异常处理、看门狗机制与容错设计

📅 2026/8/1 3:02:43 👁️ 阅读次数 📝 编程学习
工业级稳定性:C#通信程序异常处理、看门狗机制与容错设计

工业现场的通信程序,从来不是“能收发数据”就算合格。电磁干扰、线路老化、设备偶发死机、电源波动都是常态,一次意外崩溃可能导致整条产线停摆、批次报废。

普通业务程序的思路是“遇到异常报错,人工处理”,但工业级程序的核心要求是:默认一切都会出问题,出问题先自愈,实在不行再告警,绝对不能轻易停生产

本文从实战角度拆解工业通信程序的三层稳定性体系:分级异常处理、多级看门狗机制、全链路容错设计,所有方案均经过7×24小时产线验证。


一、先建立认知:工业程序的稳定性四原则

在写任何代码之前,先要明确工业场景和普通桌面程序的本质区别:

  1. 可用性优先:宁可返回兜底值、降级运行,也不能直接崩溃退出。
  2. 故障可自愈:80%的偶发故障(网络闪断、干扰丢包)要能自己恢复,不需要人工干预。
  3. 全链路可追溯:任何异常必须留痕,时间、原始数据、错误堆栈、现场状态全部记录,事后可复盘。
  4. 故障不扩散:一个模块出问题,只能影响自己,不能拖垮整个程序;一个工位出问题,不能连累整条产线。

二、分层异常处理体系:从“崩了再说”到“分级兜底”

很多人处理异常的方式只有两种:要么全吞掉什么都不做,要么直接抛出去让程序崩。工业程序必须建立分级异常机制,不同级别的错误对应不同的处理策略。

2.1 异常三级分类

先把所有异常按影响程度分成三类,处理方式完全不同:

异常等级典型场景处理策略是否告警
临时性异常网络闪断、干扰丢包、单次超时、设备忙自动重试,失败次数少则不告警否/累计失败才告警
业务性异常指令不支持、参数越界、设备返回错误码丢弃本次请求,记录日志,按业务规则兜底低级别告警
致命性异常内存溢出、句柄泄漏、线程死锁、资源耗尽保存现场,触发复位/重启,启动降级模式紧急告警

2.2 分层捕获原则

异常不能在同一个地方全处理,要按架构分层各司其职:

  1. 通信底层(串口/TCP层):只捕获IO异常、超时异常,负责自动重试、重连,对上层屏蔽临时性故障。上层调用者感知不到偶尔的闪断。
  2. 业务协议层:捕获校验错误、设备返回异常码,负责丢弃脏数据、按业务规则返回默认值,不把协议错误抛给UI。
  3. 应用顶层(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 进程级守护:独立进程监控主程序

主程序如果整个崩掉(比如非托管异常、堆栈溢出),内部的看门狗也一起死了。这时候需要一个独立的守护进程,专门盯着主程序。

实现要点:

  1. 守护程序是独立exe,体积小、逻辑简单,本身不容易崩。
  2. 定期检测主进程是否存在,不存在就立即重启。
  3. 监控主程序的内存、CPU占用,持续超标就强制重启。
  4. 主程序定期给守护进程发心跳,进程活着但卡死了也要重启。
  5. 守护进程自己也要做自监控,避免自己先死。

极简版守护进程核心逻辑:

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 看门狗避坑指南

  1. 绝对不要在主线程/UI线程喂狗:UI卡死后主线程不动,看门狗还在正常喂,等于白设。喂狗逻辑必须放在独立的高优先级线程。
  2. 不要把看门狗超时设太短:太短容易误重启,一般业务线程设5~10秒,进程级设30秒以上。
  3. 重启必须能自动恢复状态:重启后要自动重连设备、加载参数、恢复运行模式,不能停在登录界面等人点。
  4. 看门狗要有旁路开关:调试的时候可以临时关闭,不然调个断点程序就被重启了。

四、全链路容错设计:出错了也能正常干活

容错不是“出了错怎么报错”,而是“出了错怎么还能继续干活”。工业程序的容错要贯穿通信、数据、业务、模块四个层面。

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小时无人值守稳定运行。

记住一句话:好的工业程序,不是永远不出错,而是出了错也不会影响生产,并且事后能查清楚为什么错。