产线上位机的崩溃和办公软件不是一个性质:没人在屏幕前点"确定",崩了之后数据断采、画面冻死,可能几小时后才被发现;更糟的是 Windows 弹出错误报告对话框,进程挂在那儿不退出,连重启都不会触发。一套合格的崩溃防护要解决四件事:异常有兜底、死前留证据、系统能自恢复、恢复后状态安全。这篇按这四层给完整方案和代码。

一、先认清:.NET 有三个未处理异常入口,少注册一个就漏一片

入口捕获范围进程能否继续
Application.ThreadExceptionUI 线程(消息循环)上未被 catch 的异常能,吞掉后消息循环继续
AppDomain.UnhandledException所有线程的未处理异常(含后台线程、线程池)不能,事件结束后进程默认终止
TaskScheduler.UnobservedTaskExceptionasync/await 与 Task 中未观察的异常(GC 回收 Task 时触发)能,设置 Observed 可防进程崩溃

▲ 只注册 ThreadException 是新手最常见错误:采集线程、定时器回调里的异常根本不走它,直接闪退。

Program.cs 里的标准注册三件套(必须在任何窗体/业务代码之前):

// ① UI线程异常:可恢复,记录后让界面继续跑
Application.ThreadException += (s, e) =>
{
    CrashGuard.Report("UI", e.Exception, recoverable: true);
};
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);

// ② 非UI线程未处理异常:默认致命,这里是"留遗言"的最后机会
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
    CrashGuard.Report("AppDomain", e.ExceptionObject as Exception,
                      recoverable: false);
    Thread.Sleep(800);  // 等日志刷盘
};

// ③ Task未观察异常:标记为已观察,避免后台任务偶发异常拖垮进程
TaskScheduler.UnobservedTaskException += (s, e) =>
{
    CrashGuard.Report("Task", e.Exception, recoverable: true);
    e.SetObserved();
};
ThreadException 能吞异常不代表该无脑吞。采集、写库、下发控制指令的代码必须自己 try/catch 并决定业务补偿;全局兜底只处理"没想到会抛"的异常。兜底里吞掉的每个异常都要记日志+计数,同类异常 10 分钟内出现超过阈值,主动退出进程让看门狗重启——界面还活着但业务逻辑已经半残时,硬撑比重启更危险。

二、死前留证:崩溃报告要让远程的人能定位

UnhandledException 里能执行的时间只有几百毫秒到几秒,别做网络请求,只做最稳的本地写盘。报告内容清单:

  • 异常全文:ToString() 含完整堆栈和内部异常链,一行都不能截;
  • 环境快照:程序版本(从 assembly 读)、操作系统、.NET 版本、机器名、崩溃时间;
  • 最近日志尾巴:把内存环形日志的最后 200 行附进报告(所以日志器要有内存缓冲,光写文件遇到磁盘问题就瞎了);
  • 业务现场:当前工单号、各设备连接状态、采集水位——这几样是判断"崩溃时在干什么"的关键;
  • 可选 dump:疑难问题用 procdump 注册为实时调试器(procdump -ma -i),崩溃时自动落完整 dump,平时不开,dump 文件大且涉及现场数据。

报告文件命名 crash_20260913_031255_机器名.txt,单独目录、最多保留 20 个;程序每次启动时检查上次是否异常退出(见后文标记文件),把积压报告在启动后网络空闲时打包上报服务器。

三、关掉 Windows 错误报告:无人值守的必做项

.NET 程序遇到致命异常,系统默认可能弹出"程序已停止工作"对话框并挂起进程——操作工不会点,看门狗发现进程还"活着"也不会重启,系统就此冻死。必须在部署时关掉 WER 弹窗(注册表,安装包里执行):

// 注册表路径 HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting
// DontShowUI = 1 (DWORD),同时可设 Disabled = 1
// 效果:崩溃直接退出、不弹窗,看门狗才能接管

四、看门狗:独立进程,心跳判定,崩溃即拉起

看门狗的关键原则是它必须独立于主程序进程——主程序自己卡死时,内部任何定时器都不会再触发。我们用一个几十 KB 的控制台小程序 Watchdog.exe,开机自启,职责只有三件事:启动主程序、监控心跳、异常时重启。

心跳落文件:主程序每 2 秒更新 heartbeat.txt 的时间戳(文件写入比命名管道/内存映射稳,主程序 GC 卡死时时间戳自然停住,连"假死"都能判出来);
看门狗巡检:每 5 秒读一次:进程不存在 → 立即拉起;进程在但心跳超过 15 秒没更新 → Kill 后拉起(覆盖"界面冻死但没闪退");
重启退避:连续崩溃要退避——1 分钟内重启超 3 次,停止自动重启并报警(蜂鸣器/给服务器发消息),说明是必现故障,无限重启只会刷屏;
看门狗自保:Watchdog 注册为 Windows 服务或放启动项+崩溃计划任务;它自己代码极简(只做文件读写和进程启动),不引用任何业务库,保证自身几乎不可能崩。
// Watchdog 核心循环(独立进程)
while (true)
{
    var procs = Process.GetProcessesByName("ScadaMain");
    var stale = procs.Length == 0
        || (DateTime.Now - File.GetLastWriteTime(heartPath)).TotalSeconds > 15;
    if (stale)
    {
        procs.ToList().ForEach(p => { p.Kill(); p.WaitForExit(5000); });
        if (RecentRestarts() < 3) Process.Start(exePath);
        else Alarm("主程序1分钟内连续崩溃3次,已停止自动重启");
    }
    Thread.Sleep(5000);
}
正常退出要会"请假"。操作员主动退出/升级时,主程序先写一个 shutdown.flag,看门狗看到标记不重启并自己待命;主程序被看门狗拉起时携带命令行参数 --watchdog-restart,用于区分"人工启动"和"崩溃恢复",恢复策略不同。

五、重启之后:状态恢复的安全原则

能自动重启不等于能自动接着干。工业现场的恢复必须保守,三条原则:

  1. 采集可以自动恢复,控制指令不能自动补发。重启后自动重连设备、恢复数据采集没问题;但崩溃前未完成的下发指令(配方、启停)一律作废,不猜不补,由 PLC 侧或人工确认后重新下发——程序无法知道指令执行到了哪一步;
  2. 工单状态要对账。启动时发现"进行中"的工单,读 PLC/设备侧实际状态对账,界面弹醒目提示"程序异常退出后恢复,请确认工单 X 当前状态",确认前只读不写;
  3. 首屏必须显示崩溃事实。带 --watchdog-restart 启动时主界面顶部挂红色横幅"系统于 03:12 异常退出后自动恢复,报告已生成",直到操作员确认。自动恢复要让人知道它发生过——静默恢复会掩盖系统性故障。

最后把整套体系的设计哲学说清楚:兜底不是为了让程序永不崩,而是让崩溃的代价可控。异常分层处理,能恢复的就地恢复、不能恢复的体面退出;退出前把证据留在本地;独立看门狗保证服务尽快回来;回来后用保守策略恢复现场。配合日志系统(现场证据链)和掉电检查点(数据不丢),上位机才能真正达到产线要求的"无人值守、长期运行"。