☰
C# Winform开发Modbus温湿度监控上位机:从串口通信到SQLite存储
2026/10/8 3:58:53 网站建设 项目流程

简介:面向工业温湿度监控场景的C#WinForm上位机源码项目,完整覆盖Modbus RTU串口通信、实时数据展示、Chart趋势曲线绘制、阈值报警与SQLite持久化存储等关键环节,适合学习上位机开发与工业通信协议的开发者参考。项目以11个cs源码文件构成主逻辑,附带sln/csproj工程文件、json配置、resx界面资源及一份介绍PDF,压缩包共22个文件、整体仅326KB,结构清晰便于逐模块研读。代码中还可拆解报警列表记录、config.json配置恢复、跨线程UI更新、导出Excel等功能点,能帮助读者理解完整工业监控软件的工程组织方式,并掌握配置管理与异常处理思路。已有132人学习下载,适合有一定C#基础、希望系统掌握WinForm界面架构与串口协议解析的开发者。

1. 一台温湿度设备配一套上位机:这是设备交付的常规剧本

做设备配套的工程师手里几乎都有一个绕不开的活:给传感器写上位机。这份资源就是一台基于 C# Winform 开发的工业级温湿度监控上位机,走 Modbus 协议采集传感器数据,SQLite 落库,界面带实时曲线和历史查询。它能解决现场最朴素的那类需求:设备交付后,客户要求实时看到温湿度、能存历史数据、断电断网后还能查到记录。适合正在做车间环境监控、机房温湿度记录、实验室数据采集、冷库冷链这类项目的工程师;新手可以拿它当完整模板改出自己的交付件,熟手可以直接把通信层替换成自己设备的协议。

2. Modbus 通信层:先把报文格式吃透,再谈轮询逻辑

2.1 工业现场为什么默认走 Modbus:寄存器地址就是设备的数据字典

Modbus 在工业现场的地位类似串口界的普通话。温湿度传感器、PLC、电表、变频器,几乎都带 Modbus RTU 或 Modbus TCP 接口。对写上位机的人来说,Modbus 最大的价值在于把设备能力抽象成一张寄存器地址表:温度存在哪个地址、湿度存在哪个地址、数据按什么格式组织,查设备手册就能拿到。上位机要做的事就是按地址去读,完全不用关心传感器内部怎么测的,这层抽象让软件和硬件解耦得干干净净。

一台典型的温湿度探头会暴露保持寄存器或输入寄存器:功能码 03 读保持寄存器(可写),功能码 04 读输入寄存器(只读)。对纯采集场景,读哪个由设备手册决定,多数传感器两个都支持。寄存器里的数据格式有两种主流做法:一种直接用整数表示物理量,比如温度 25.6℃ 存成 256,程序里除以 10 还原,这种最常见也最好调试;另一种用两个连续寄存器拼一个 32 位浮点数,结构是 IEEE 754,但高字在前还是低字在前,不同厂家可能相反,踩过的人都知道这坑多恶心。

所以做上位机之前的第一件事不是打开 Visual Studio,而是找设备手册把寄存器映射表、功能码、波特率、站号全部确认清楚。资料到位,通信层半天就能写完;资料含糊,后面有得折腾。选 RTU 还是 TCP 也在这时候定:现场只有 RS485 双绞线、距离几十米到几百米,走 RTU;设备带网口、要走局域网或者上位机跟设备不在同一个机柜里,走 TCP。协议本身区别不大,TCP 少了 CRC 校验多了个事务号,组包解包的处理思路完全一致。

提示:同一批设备有的带 RS485、有的带网口,是完全正常的。写代码时把「收发字节」和「组包解包」两层分开,后面加一种传输方式只动底层那十几行。

2.2 串口参数与 CRC16 校验:这几十行代码决定通信成败

Modbus RTU 的物理层一般是 RS485,串口参数最常用的是 9600 波特率、8 数据位、无校验、1 停止位,也就是常说的 9600/8/N/1。遇到通信不稳定再去看手册里有没有要求偶校验或 19200。串口初始化本身不复杂,但现场交付时很容易翻车:COM3 是 USB 转串口在 Windows 上最常见的虚拟号,换一台电脑可能变成 COM5、COM11,固定配置只能靠用户手动改。所以端口号、波特率这类参数一定要做成配置项,不要写死在代码里。

serialPort.PortName = "COM3"; serialPort.BaudRate = 9600; serialPort.DataBits = 8; serialPort.Parity = Parity.None; serialPort.StopBits = StopBits.One; serialPort.ReadTimeout = 500; serialPort.Open();

这段说的是最常用的串口参数组合。ReadTimeout 设 500ms 是为了避免读操作无限期阻塞;如果你的设备手册要求偶校验,把 Parity 改成 Parity.Even,同时 DataBits 通常是 8。

RTU 帧格式是:地址码 1 字节、功能码 1 字节、数据段 N 字节、CRC16 校验 2 字节,CRC 低字节在前。CRC16 是 Modbus 里最容易写错的地方,多项式是 0xA001、初始值 0xFFFF。下面这份查表法我沿用了很多年,稳定性和速度都够:

static ushort[] crcTable = BuildCrcTable(); static ushort[] BuildCrcTable() { ushort[] table = new ushort[256]; for (ushort i = 0; i < 256; i++) { ushort crc = i; for (int bit = 0; bit < 8; bit++) { if ((crc & 1) == 1) crc = (ushort)((crc >> 1) ^ 0xA001); else crc >>= 1; } table[i] = crc; } return table; } static ushort Crc16(byte[] data, int offset, int length) { ushort crc = 0xFFFF; for (int i = offset; i < offset + length; i++) { int index = (sbyte)((crc ^ data[i]) & 0xFF); crc = (ushort)((crc >> 8) ^ crcTable[index]); } return crc; }

BuildCrcTable 在程序启动时执行一次,把 0 到 255 对应的 CRC 中间值固化成表,后续每个字节的计算只是三次异或和移位。Crc16 是主计算函数,offset 和 length 用来指定数据段范围。查表法比逐位硬算快一个数量级,虽然串口本身才 9600 波特率,CPU 不在乎这点差异,但响应帧到达时是突发的一整包,查表法不容易在处理中拖慢 UI。

组请求帧时注意 CRC 的字节序,低字节在前发送:

byte[] BuildReadFrame(byte slaveAddr, ushort startAddr, ushort count) { // 0x03 读保持寄存器;改成 0x04 即读输入寄存器 byte[] frame = new byte[8]; frame[0] = slaveAddr; frame[1] = 0x03; frame[2] = (byte)(startAddr >> 8); frame[3] = (byte)(startAddr & 0xFF); frame[4] = (byte)(count >> 8); frame[5] = (byte)(count & 0xFF); ushort crc = Crc16(frame, 0, 6); frame[6] = (byte)(crc & 0xFF); // 低字节在前 frame[7] = (byte)(crc >> 8); return frame; }

参数说明:slaveAddr 是设备站号,范围 1~247,同一总线上不能重复;startAddr 是起始寄存器地址,比如温度寄存器是 0x0000;count 是要读的寄存器个数,读两个寄存器响应正好是 7 个字节。startAddr 用 ushort,是因为寄存器地址是 16 位寻址,直接位移拆高低字节最安全。

2.3 多从站轮询与超时:别再用 Thread.Sleep 硬等

一条 RS485 总线上常常挂好几个从站。上位机的常规做法是轮询:从站 1 读温度、读湿度,等响应,处理完再问从站 2,如此循环。新手最容易犯的错是发完请求直接 Thread.Sleep(500) 等着,这有两个隐患:一是 Sleep 期间响应到了但没人读,下一条请求发出去后,缓冲区里残留的脏数据会被当成新响应,造成数据错位;二是 Sleep 固定时长,设备响应快慢不均,要么白等、要么读得太早,超时处理形同虚设。

更稳的骨架是后台线程加基于时间的响应等待,把轮询状态机控制在收发线程内部:

void PollLoop(CancellationToken token) { byte slaveAddr = 1; while (!token.IsCancellationRequested) { byte[] request = BuildReadFrame(slaveAddr, 0, 2); serialPort.DiscardInBuffer(); // 清掉上一轮可能遗留的旧数据 serialPort.Write(request, 0, request.Length); DateTime sentAt = DateTime.Now; bool gotResponse = false; while ((DateTime.Now - sentAt).TotalMilliseconds < 500) { if (serialPort.BytesToRead >= 7) { gotResponse = true; byte[] buf = new byte[256]; int n = serialPort.Read(buf, 0, serialPort.BytesToRead); ThreadPool.QueueUserWorkItem(_ => ProcessFrame(buf, n)); break; } Thread.Sleep(10); } if (!gotResponse) { Logger.Warn($"slave {slaveAddr} timeout"); } Thread.Sleep(100); // 轮询间隔,不能小于设备手册给的最小间隔 } }

几个参数是按现场经验定的:响应超时 500ms,串口 9600 波特率下最大一帧 7 字节约需要 12ms 传完,500ms 已经留了充足余量;轮询间隔 100ms,是一台设备两次请求之间的最小冷却时间,如果总线上挂了 10 台从站,每台设备的周期就是约 1 秒,对温湿度这种慢变信号完全够用。如果你接的是高速采集设备,这里的间隔要缩到 10ms 甚至更低,但轮询结构本身不用动。

3. 实时数据链路:从串口字节到界面曲线的一整套管线

3.1 帧解析与温湿度换算:字节序和缩放系数决定数值对不对

响应帧的格式是固定的:地址、功能码、字节数、数据区、CRC16。比如读两个寄存器,正常响应是 7 字节:1 字节地址加 1 字节功能码加 1 字节数据长度(值为 4)加 4 字节数据加 2 字节 CRC。解析步骤不能反:先校验 CRC,再取数据区拼接,最后做物理量换算。如果先解析再校验,帧里任何一位被干扰,你会在一个错误数值上做半天分析。

温湿度换算的代码:

bool TryParseResponse(byte[] buf, int len, out double temp, out double humi) { temp = 0; humi = 0; if (len < 9) return false; // 帧尾 2 字节是 CRC,低字节在前 ushort crcRecv = (ushort)(buf[len - 2] | (buf[len - 1] << 8)); ushort crcCalc = Crc16(buf, 0, len - 2); if (crcRecv != crcCalc) return false; // 响应帧内:buf[0]=地址, buf[1]=功能码, buf[2]=字节数, // buf[3..6] 是 4 字节数据区,对应两个寄存器 ushort rawTemp = (ushort)((buf[3] << 8) | buf[4]); // 常规大端,高字节在前 ushort rawHumi = (ushort)((buf[5] << 8) | buf[6]); temp = rawTemp / 10.0; // 缩放系数 10,具体以手册为准 humi = rawHumi / 10.0; return true; }

说明:buf[3] 和 buf[4] 组成第一个寄存器的原始值,高位在前是 Modbus 的规范字节序。如果读出的值明显是几千几万而不是二十几,把拼接顺序换成 (buf[4] << 8) | buf[3] 再试。缩放系数必须来自手册,有的传感器能显示两位小数,那系数就是 100;有的湿度直接返回 0~1000,就要除以 10。这里最容易出的问题就是把系数抄错,比如某型号湿度传感器原始值 536 表示 53.6%,手册写的是除以 10,有人随手写成 100,界面上的湿度就从五六十变成几点了。

3.2 跨线程更新 UI:BeginInvoke 是 Winform 的唯一合法入口

SerialPort.DataReceived 事件触发在系统缓冲线程,后台轮询线程又占一个线程,这两个线程拿到数据都不能直接碰控件。Winform 控件由 UI 线程创建,跨线程修改属性会抛 InvalidOperationException,或者表现为界面数据不刷新但后台明明在跑。处理方式是用 Control.BeginInvoke 把更新动作丢到 UI 线程的消息队列里:

void ShowData(double temp, double humi, string time) { if (!lblTemp.IsHandleCreated) return; // 控件还没创建完,直接放弃 if (lblTemp.InvokeRequired) // 当前不在 UI 线程 { lblTemp.BeginInvoke(new Action(() => { lblTemp.Text = temp.ToString("0.00") + " ℃"; lblHumi.Text = humi.ToString("0.00") + " %RH"; lblTime.Text = time; })); } else { lblTemp.Text = temp.ToString("0.00") + " ℃"; lblHumi.Text = humi.ToString("0.00") + " %RH"; lblTime.Text = time; } }

这里有个高频刷新下容易踩的性能坑:每收到一帧就调一次 BeginInvoke,如果传感器 100ms 上报一次,UI 队列里会堆积大量委托,界面反而越来越卡。常见做法是显示层限频,后台线程把最新值写进一个普通字段,UI 上用 200ms 的 Timer 拉取一次再刷新。数据链路改成「采集线程 → 共享缓冲区 → UI Timer」,比直接跨线程调用优雅得多,也方便以后加日志。

3.3 实时曲线:用 Chart 控件作出滚动窗口,千万别逐点 Invalidate

实时曲线是这类上位机的门面,客户验收第一眼就看它。Winform 自带的 Chart 控件够用,核心是把窗口设置成滚动模式:X 轴只显示最近 N 个点,旧点往左移动,而不是把整条数据全部重画。初始化代码:

void InitChart() { chart1.Series.Clear(); var series = new Series("温度") { ChartType = SeriesChartType.Line, BorderWidth = 2 }; chart1.Series.Add(series); var area = chart1.ChartAreas[0]; area.AxisX.Minimum = 0; area.AxisX.Maximum = 600; area.AxisX.ScaleView.Size = 600; // 可见窗口 600 个点 area.AxisX.ScaleView.Scroll = true; // 允许往回拖 area.AxisX.ScaleView.Position = 0; area.AxisX.IsMarginVisible = false; chart1.Legends[0].Enabled = true; chart1.AntiAliasing = AntiAliasingStyles.None; }

绘图推进用 Timer 批量加数据,不要每来一帧就调一次 Points.Add。下面的代码把缓冲区里的点一次性追加到曲线上,同时清理超出窗口范围的旧点,避免内存无限增长:

private void timerDraw_Tick(object sender, EventArgs e) { List<DataPoint> batch; lock (pointBufferLock) { batch = new List<DataPoint>(pointBuffer); pointBuffer.Clear(); } if (batch.Count == 0) return; series.Points.AddRange(batch.ToArray()); // 超出窗口范围时,把左侧旧点清掉 while (series.Points.Count > 1200) series.Points.RemoveAt(0); }

参数说明:窗口设 600 点,如果采集频率是每秒 1 次,代表显示最近 10 分钟。缓冲区只保留 1200 点,多出的部分自动丢弃,防止运行几天后内存被点对象撑爆。Timer 的 Interval 建议在 200 到 500ms,太高曲线会跳变,太低 CPU 和重绘压力大。Chart 的 AntiAliasing 在低配工控机上关掉,抗锯齿在数据密集时非常费 CPU。

提示:曲线闪不闪烁,跟数据量关系不大,跟是否跨线程重绘关系最大。只要做到「数据线程只进缓冲区、UI Timer 统一画图」,闪烁问题基本就消失一半。

4. SQLite 存储层:单机监控场景下最省心的数据库选择

4.1 为什么选 SQLite 而不是 Access 或 SQL Server

决定上位机数据库时,很多新手第一反应用 Access,觉得 Windows 自带;或者用 SQL Server,觉得正式。这里有个大坑:Windows 10 和 11 默认不装 Access 驱动,客户机器上写不了 Jet 连接字符串;SQL Server 光安装和账密配置就够交付时吵一架。SQLite 是嵌入式数据库,一个 .db 文件扛全部数据,System.Data.SQLite 这个 ADO.NET 驱动一引用就能用,部署零成本。

对比一下最关心的几个点:

维度SQLiteAccess (.accdb)SQL Server Express
部署免安装,单文件依赖 Office 驱动需安装实例和账密配置
并发支持单写多读够用写锁明显高并发强
备份方式复制 .db 文件需压缩数据库需备份/还原 SQL
上位机适用度高中,历史遗留低,除非多客户端共享

对一台设备配一套上位机的温湿度监控来说,存储特点很明确:写入频率不高、数据量不大、必须断电后还能打开,SQLite 全中。如果你的场景是 MES 系统里几十台上位机要往同一个库里写,才需要考虑 SQL Server,那是另一个架构话题。

4.2 建表与批量写入:一条条 Insert 会拖慢收数流程

建表语句固定放在程序启动时执行,保证换一台电脑数据库也是现成的:

CREATE TABLE IF NOT EXISTS th_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_id INTEGER NOT NULL, temp REAL NOT NULL, humi REAL NOT NULL, recv_time TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_recv_time ON th_data(recv_time);

recv_time 用 TEXT 存 ISO 格式字符串,比如 2024-06-01 08:30:00,比 unix 时间戳直观。SQLite 的字符串比较对 ISO 格式天然有序,按时间范围查询直接 BETWEEN 字符串即可。device_id 留给多设备场景,同一张表里隔离不同传感器数据。

写入要批量。每 100ms 收一帧数据就开一条 Insert 连接,SQLite 的单写锁会不断拿锁放锁,磁盘也受不了。常见做法是攒批:采集线程把数据推入内存队列,一个后台线程每 2 秒取 50 条左右,用事务一次性提交:

using (var conn = new SQLiteConnection("Data Source=th_data.db")) { conn.Open(); using (var tx = conn.BeginTransaction()) using (var cmd = conn.CreateCommand()) { cmd.Transaction = tx; cmd.CommandText = "INSERT INTO th_data(device_id,temp,humi,recv_time) VALUES(@d,@t,@h,@rt)"; foreach (var item in batch) { cmd.Parameters.Clear(); cmd.Parameters.AddWithValue("@d", item.DeviceId); cmd.Parameters.AddWithValue("@t", item.Temp); cmd.Parameters.AddWithValue("@h", item.Humi); cmd.Parameters.AddWithValue("@rt", item.TimeText); cmd.ExecuteNonQuery(); } tx.Commit(); } }

说明:BeginTransaction 把一批 Insert 包进同一个事务,提交时一起落盘,速度比逐条 Insert 快一个数量级,而且数据一致性更好。cmd.Parameters.Clear() 后再 AddWithValue 是循环复用命令对象的推荐写法,避免每次 new 参数对象。连接串没带密码,本地上位机没有必要,反而徒增维护负担。

这里有个关键点:写入要放在独立的后台队列线程,不能放在 SerialPort DataReceived 里。串口回调本身要尽快返回,数据库落盘是慢操作,在回调里同步写会把串口缓冲的后续数据饿死,丢帧率飙升。

4.3 历史查询与导出 CSV:现场工程师最需要的两个功能

历史查询用 DataGridView 绑查询结果最直接。核心 SQL 要固定走索引:

string sql = @"SELECT recv_time, temp, humi FROM th_data WHERE recv_time BETWEEN @start AND @end ORDER BY recv_time";

EXPLAIN QUERY PLAN 显示这条查询会走 idx_recv_time 索引,时间范围过滤在几万条数据下毫秒级返回。注意用参数化查询,字符串拼接时间条件不仅慢,还会引入 SQL 注入这种没想到的风险。

导出 CSV 时有个现场常见的坑:直接写成 UTF-8 无 BOM,客户发到 Excel 打开中文表头全乱码。用 StreamWriter 时明确用 UTF8 with BOM 编码:

using (var writer = new StreamWriter(exportPath, false, new UTF8Encoding(true))) { writer.WriteLine("时间,温度,湿度"); while (reader.Read()) { writer.WriteLine($"{reader["recv_time"]},{reader["temp"]},{reader["humi"]}"); } }

加 BOM 后 Excel 双击就能识别中文,不用教客户「导入时选编码」,那套说辞在现场对甲方不好使。导出的目的通常是复盘或者交接,所以路径要给用户弹 SaveFileDialog,别写死到安装目录。

SQLite 默认的 rollback journal 模式下,读写不能同时进行,在上位机这种「采集线程写、查询线程读」的场景容易遇到 database is locked。建议连接串里加 WAL 模式:

Data Source=th_data.db;Journal Mode=Wal;Busy Timeout=3000;

WAL 模式下写不影响读,崩溃后自动重放日志,对长时间无人值守的设备更友好。注意 WAL 模式要求同一时间只有一个进程能写库,把 .db 文件放到 Windows 共享目录里属于高危操作,别干。

5. 上位机排查手册:五个必踩的坑与处理办法

5.1 串口打不开或打开后收不到任何响应

现象:SerialPort.Open() 直接抛异常;或者 Open 成功但 BytesToRead 永远是 0,设备一点反应没有。

原因:端口号配置和实际 COM 号不一致;USB 转 RS485 驱动没装好;串口被其他软件占用,最常见的是 Modbus Slave 模拟器、串口助手或者上一个调试实例没释放端口。

解决:先用 Windows 设备管理器确认实际 COM 号,再检查代码里 PortName 是否匹配。建议把端口号、波特率写进配置文件,不要在代码里硬编码。被占用时关掉所有调试工具,检查是否有残留进程占着 COM 口。调试时永远开着 Modbus 测试工具和真实设备对打,才能快速定位是物理链路问题还是代码问题。

5.2 CRC 校验频繁失败,10 帧错 7 帧

现象:本地计算 CRC 和响应帧尾 CRC 对不上,丢帧率极高,曲线断断续续。

原因:三个方向排查。第一,波特率、数据位、停止位和设备手册不一致,帧被拆得支离破碎;第二,RS485 的 A/B 接线反了,或者没有共地,信号全是噪声;第三,代码里 Crc16 计算时把帧尾的 CRC 也算进去了,或者多项式用了别的变体,比如 CRC-16/IBM 而不是 Modbus 的 CRC-16/ARC。

解决:先用串口工具裸抓一次响应,看帧头帧尾是否完整。再用 Modbus Poll 或者 Modbus Slave 这类工具配置同样的功能码、地址、寄存器去读,如果第三方工具能读到,那一定是代码问题,重点查 CRC 计算范围,应该是排除最后 2 字节 CRC,以及字节序低字节在前。

5.3 界面卡死,窗口一拖就无响应

现象:程序运行起来后,标题栏转圈,窗口拖不动,点关闭也没反应。

原因:常见两种。一种是把读取 Modbus 的 while 循环直接写在按钮 Click 事件里,主线程被阻塞;另一种是 DataReceived 回调里加了数据库同步写,串口线程卡在磁盘 IO 上,虽然主线程没阻塞,但数据不刷新,看起来跟死机一样。

解决:所有串口收发、数据库写入、CSV 导出一律放后台线程,UI 线程只做控件更新。线程间通信用前面说的 BeginInvoke 方式。如果查询历史数据很大,DataGridView 赋值也尽量用 BeginInvoke,或者先分页再拉取。

5.4 Chart 曲线闪烁、CPU 飙高

现象:实时曲线一刷新就闪,CPU 占用长期在 20% 以上,工控机风扇呼呼转。

原因:Chart 控件的默认绘制路径包含抗锯齿和逐点动画;更常见的是每个数据点到达就更新一次边界区域,导致整张图频繁重绘;数据点上万不清理,Chart 内部集合越来越大。

解决:把 Points 上限控制住,滚动窗口固定 600 点可见、缓冲 1200 点上限;关闭 AntiAliasing;用 Timer 批量追加而不是逐点追加。如果还不够,检查是不是把 chart1.Invalidate() 写在了数据线程里,跨线程强制重绘是闪烁的元凶。

5.5 SQLite 数据库文件损坏报 malformed

现象:设备断电或进程被杀后重启,读取历史时报 database disk image is malformed,或者查询到一半直接抛异常。

原因:断电瞬间正在 WAL 或 journal 里写数据,元数据和数据页不一致;另一个场景是客户直接拷贝 .db 文件时程序还在写库,拷贝出来的是半个事务。

解决:连接串加 Journal Mode=Wal,断电恢复概率会大很多;备份时用 SQLite 提供的 backup API,比如 System.Data.SQLite 里的 SQLiteConnection.BackupDatabase 方法,不要用文件拷贝;对监控上位机来说,还可以定一个整点备份任务,把 .db 复制到另一个磁盘分区。注意 WAL 模式要求同一时间只有一个进程能写库,Windows 共享目录放 .db 文件属于高危操作,别干。

6. 进阶:给上位机加一道自恢复看门狗,让程序自己爬起来

6.1 全局异常捕获与心跳文件

现场设备最怕的不是功能不完整,而是半夜运行两小时后悄悄崩了,第二天客户来电质问。Winform 程序在工控机上崩溃原因很多:USB 转串口掉线、SQLite 写库瞬间断电、第三方驱动异常。解决办法有两个方向:一是把所有异常源都处理干净,二是接受现实,让程序崩溃后自己起来。

我一般会给交付版本加两道保险。第一道是全局异常捕获:

AppDomain.CurrentDomain.UnhandledException += (s, e) => File.AppendAllText("crash.log", DateTime.Now + " " + e.ExceptionObject.ToString() + Environment.NewLine); Application.ThreadException += (s, e) => File.AppendAllText("winform.log", DateTime.Now + " UIThread: " + e.Exception.ToString() + Environment.NewLine);

全局异常捕获不是让程序不崩,而是让崩溃原因能留下记录,不会现场失忆。第二道是看门狗:主程序每隔 30 秒更新一个心跳文件的时间戳,另开一个轻量 watchdog 进程,每 60 秒检查一次心跳文件,如果超过 3 分钟没更新,说明主程序卡死或已退出,watchdog 就把主进程拉起,并记录一次重启。

这个套路比在 Main 里 try-catch 整个程序要可靠得多,因为内存溢出、栈溢出这类错误 catch 了也没用,直接起来一个干净的新进程才是正道。配合 Windows 计划任务里设置系统启动时运行 watchdog、工控机配置自动登录,就能做到无人值守自恢复。

另外提醒一句:看门狗拉起后要判断串口是否还在,如果硬件被别的进程占用,新实例会不断 Open 失败又崩溃,形成重启风暴。所以稳妥的做法是 watchdog 只负责拉起,新实例起来后第一件事是检查串口,不可用就等一分钟再试。

从那以后,我每交付一台设备,都要强制自己把崩溃日志补上、心跳文件放好、做一次「拔串口线加杀进程」的模拟演练才敢签字。这套自恢复逻辑帮我在现场省下过不止一次熬夜。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询