简介:这份C#温室监控系统源码面向学习上位机开发、嵌入式采集与农业自动化的开发者,围绕“PC端监控+51单片机现场采集”的完整链路,解决环境参数实时显示、远程控制与数据记录等问题。压缩包共1065个文件,约50.71MB,包含dll依赖库、xml文档、nupkg包、cs源码、c/h驱动与单片机程序、uvproj/hex固件工程以及png界面截图和exe可运行程序等,主体为Visual Studio上位机方案与普中51开发板下位机工程。已有2317人学习下载,说明其在课程设计、毕业设计和入门实践中较受关注。读者可从中梳理C#界面层、串口通信、传感器数据解析、阈值判断与控制指令下发的关系,也能对照下位机采集程序理解RS-232/485或网络传输流程;源码还涉及历史数据存储、参数配置和事件驱动写法,便于按模块拆解、复现实验并形成自己的温室监控方案。文件数量较多,需注意其中大量为依赖与编译产物,核心代码集中在cs、c/h及工程文件中。
1. 凌晨三点那条断掉的曲线:这份 C# 源码到底解决什么问题
去年冬天帮人排查一个温室项目,现象很怪:曲线在凌晨三点断了一截,四十分钟后自己接上了,采集程序没崩,日志里也没有异常堆栈。最后定位到的是串口拔插后对象失效、采集线程还在空转、界面上的曲线控件却一直用着最后一次的值在画。这类“半死不活”的状态,在温室监控上位机里比崩溃更常见,也更难查。
这份 C# 上位机源码,做的就是温室监控这套完整链路:串口/Modbus RTU 读温湿度、数据落库、实时曲线、阈值报警、继电器联动、历史查询。它是 WinForm 桌面程序,不依赖云平台,一台工控机或者普通 PC 插上 USB 转 485 就能跑。适合三类人:想摸清工业上位机分层写法的初学者、拿到硬件要快速二次开发的集成方、带课设需要一个能跑通全链路参照物的。下面按选型、采集、报警、排错、验收一路拆。
2. 选型先钉死:WinForm + 串口 + Modbus RTU 这套组合的边界在哪
2.1 三层切开:通信层不碰控件,界面层不碰字节
不少入门版上位机的写法是把 SerialPort 直接拖到窗体上,DataReceived 事件里既解析协议、又更新 Label、又插数据库。这么写前期出活快,后期加一个传感器就得改三处,排错时更是黑匣子——你分不清是串口没收到、协议解析错了,还是界面没刷新。
比较稳的切法是三层,且边界必须是真的边界,不是文件夹分层:
| 层 | 职责 | 硬约束 |
|---|---|---|
| 通信层 | 拼帧、发帧、收帧、CRC 校验、超时重试 | 不允许using System.Windows.Forms |
| 数据层 | 解析后的采样值落库、历史查询、报警事件记录 | 不持有 SerialPort,不认识控件 |
| 界面层 | 定时拉取最新值、画曲线、弹报警、下发控制 | 不出现任何字节操作 |
通信层只对外暴露两个方法:ReadHoldingRegisters(slaveId, startAddr, count)和WriteSingleCoil(slaveId, addr, on)。它返回的是ushort[],不是温度,也不是湿度。量程换算、定点数缩放放在数据层,因为温室项目里传感器会换,换一个量程从 ×10 变 ×100 的变送器,通信层一行都不用动。
这个边界还有一个好处:通信层可以脱离界面单独写单元测试。我给一个虚拟从站对象喂字节进去,就能验证 CRC、异常码、超时三个分支,不用真接硬件。
2.2 串口参数与帧拼装:一次读两个寄存器的完整代码
温室现场常见的接法是 485 总线挂若干变送器,一主多从。串口参数基本是固定的:9600 波特率、8 数据位、1 停止位、无校验,也就是常说的 8N1。部分设备支持 19200,但总线越长、节点越多,越建议往低走,9600 在 200 米左右的棚里比较稳。
Modbus RTU 读保持寄存器的请求帧只有 8 字节:从站地址、功能码 0x03、起始地址高字节、起始地址低字节、寄存器数量高字节、寄存器数量低字节、CRC 低字节、CRC 高字节。温度放 0x0000,湿度放 0x0001,一次要两个寄存器,帧就定死了。
// ModbusRtuClient.cs —— 只负责拼帧、发帧、验帧,不引用任何控件 public sealed class ModbusRtuClient : IDisposable { private readonly SerialPort _port; private readonly object _sync = new object(); // 一问一答必须串行,多线程共用一个口会串帧 public ModbusRtuClient(string portName, int baudRate = 9600) { _port = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One) { ReadTimeout = 500, // 单次读超时,现场 9600 波特率下 300~800 都合理 WriteTimeout = 500, ReadBufferSize = 256, // 帧长固定,缓存开小更容易发现脏数据 WriteBufferSize = 256 }; _port.Open(); } public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddr, ushort count) { lock (_sync) { var frame = new byte[8]; frame[0] = slaveId; 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, 6); frame[6] = (byte)(crc & 0xFF); // 低字节在前,这一行错了两天都查不出来 frame[7] = (byte)(crc >> 8); _port.DiscardInBuffer(); // 清掉上一轮残留,避免错位解析 _port.Write(frame, 0, frame.Length); int expect = 5 + count * 2; // 地址 1 + 功能码 1 + 字节数 1 + 数据 2n + CRC 2 var resp = new byte[expect]; int got = 0; int deadline = Environment.TickCount + _port.ReadTimeout; while (got < expect && Environment.TickCount < deadline) got += _port.Read(resp, got, expect - got); if (got != expect) throw new TimeoutException($"期望 {expect} 字节,实际 {got}"); if (resp[1] == 0x83) throw new IOException($"从站异常码 0x{resp[2]:X2}"); ushort recvCrc = (ushort)(resp[expect - 2] | (resp[expect - 1] << 8)); if (recvCrc != Crc16(resp, expect - 2)) throw new InvalidDataException("CRC 校验失败"); var result = new ushort[count]; for (int i = 0; i < count; i++) result[i] = (ushort)((resp[3 + i * 2] << 8) | resp[4 + i * 2]); // 寄存器是大端 return result; } } }lock (_sync)是关键。485 是半双工,一问一答,如果报警线程和轮询线程同时往同一个串口写帧,从站回的数据会交错,表现出来就是“偶尔 CRC 错、偶尔超时”,非常像硬件问题,实际是软件并发。
expect = 5 + count * 2这个公式要记牢:读 2 个寄存器,返回 9 字节;读 10 个,返回 25 字节。按固定 8 字节去收,多出来的部分会留在缓冲区里污染下一帧,这是错位解析最常见的来源。
CRC 单独拆一个静态方法,注意多项式 0xA001 是反向写法,初值 0xFFFF:
private static ushort Crc16(byte[] data, int len) { ushort crc = 0xFFFF; for (int i = 0; i < len; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { bool lsb = (crc & 0x0001) != 0; crc >>= 1; if (lsb) crc ^= 0xA001; // 写 0xA001 而不是 0x8005,否则高位先算 } } return crc; }注意,很多现场设备会返回异常帧,功能码最高位置 1(0x83),第三字节是异常码。01 表示非法功能、02 表示非法地址、03 表示非法数据值。源码里必须把这个分支单独抛出来,否则异常帧会被当成正常帧去校验 CRC,报出来的错误信息完全是误导。
2.3 存储模型:三张表撑起原始值、分钟统计与报警记录
温室数据的特点是写入密、查询跨度大。10 秒采一次,一个点一天 8640 条,四个棚、温湿度两个量,一天七万条。用一张表存到底,三个月后查一周的历史曲线会明显卡。
源码里的做法是三张表分工:
-- 原始采样:只追加不更新,异常也用 Quality 标记,不删数据 CREATE TABLE SampleRaw ( Id INTEGER PRIMARY KEY AUTOINCREMENT, DeviceId TEXT NOT NULL, TsMs INTEGER NOT NULL, -- Unix 毫秒,UTC,不存本地时间 Temp REAL NOT NULL, Humidity REAL NOT NULL, Quality INTEGER NOT NULL DEFAULT 0 -- 0 正常 1 超时 2 CRC错 3 超量程 ); CREATE INDEX IX_SampleRaw_Dev_Ts ON SampleRaw(DeviceId, TsMs); -- 分钟聚合:长周期曲线直接查这张,别去扫原始表 CREATE TABLE SampleMinute ( DeviceId TEXT NOT NULL, TsMs INTEGER NOT NULL, -- 对齐到整分钟 TempAvg REAL, TempMin REAL, TempMax REAL, HumAvg REAL, HumMin REAL, HumMax REAL, Cnt INTEGER NOT NULL, PRIMARY KEY (DeviceId, TsMs) ); -- 报警事件:只记状态跃迁,不记每一条越界采样 CREATE TABLE AlarmEvent ( Id INTEGER PRIMARY KEY AUTOINCREMENT, DeviceId TEXT NOT NULL, Point TEXT NOT NULL, -- Temp 或 Humidity Level INTEGER NOT NULL, -- 1 预警 2 报警 BeginMs INTEGER NOT NULL, EndMs INTEGER, -- NULL 表示尚未恢复 Peak REAL );三个设计点值得说清楚。TsMs用 Unix 毫秒而不是DateTime字符串,一是省空间,二是排序和范围查询快,三是避开了时区问题——工控机被 NTP 校时、或者用户手动改了系统时间,用本地时间存的曲线会出现“时间倒流”或者重复点。显示的时候再转本地时区,只在界面层做一次。
Quality字段是给排错留的后路。串口超时那一条,温度湿度填什么?很多写法是直接跳过不写,结果曲线断了却查不出原因。填 0 并把 Quality 置 1,曲线控件上画成灰色断点,一眼就知道是通信问题还是传感器问题。
分钟聚合表由后台任务每 60 秒跑一次INSERT ... SELECT,只处理上一分钟的数据,幂等(主键冲突就 REPLACE)。这样即使程序中途重启,补一次也能补上。
3. 从字节流到入库:采集线程、CRC 校验与曲线刷新的四道关
3.1 线程模型:不要用 UI 的 Timer 去拨串口
第一个坑就在线程模型上。WinForm 工具箱里的System.Windows.Forms.Timer跑在 UI 线程,Tick里如果调用_port.Read阻塞 500 毫秒,整个界面就卡 500 毫秒;棚里挂四五个从站,界面基本就废了。
源码里用的是“一个采集线程 + 一个落库线程 + 界面定时拉取”的三段式。采集线程用Thread起,循环里睡眠,不用Timer,因为要控制“上一轮没打完不叠加下一轮”:
// AcquisitionWorker.cs —— 采集线程只做三件事:读、解析、入队 private volatile bool _running = true; private void Loop() { while (_running) { int begin = Environment.TickCount; foreach (var dev in _devices) // 从站逐个轮询,不要并发 { try { var regs = _client.ReadHoldingRegisters(dev.SlaveId, 0x0000, 2); double temp = Signed(regs[0]) / 10.0; // 定点 ×10,负数要按补码还原 double hum = regs[1] / 10.0; _writer.Enqueue(new Sample(dev.Id, NowMs(), temp, hum, 0)); } catch (TimeoutException) { _writer.Enqueue(Sample.Bad(dev.Id, 1)); } catch (InvalidDataException) { _writer.Enqueue(Sample.Bad(dev.Id, 2)); } catch (IOException) { /* 串口本身出问题,交给重连状态机 */ } } int cost = Environment.TickCount - begin; int sleep = _periodMs - cost; if (sleep > 0) Thread.Sleep(sleep); // 周期从本轮开始算,避免累积漂移 } } private static int Signed(ushort raw) => raw > 0x7FFF ? raw - 0x10000 : raw; // 温度可能为负,寄存器是补码Signed这行别省。温室夜里零下是常态,寄存器 0xFFF6 表示 -10(×10 定点),直接当无符号数读会变成 6552.6℃,然后触发一条莫名其妙的报警。
sleep = period - cost而不是固定Thread.Sleep(period),是为了不让轮询周期随着采集耗时累积漂移。四个从站一轮 200 毫秒,固定睡 10 秒的话,实际周期会变成 10.2 秒,一天下来时间戳就对不上分钟聚合了。
采集线程里绝不能碰控件。哪怕是label.Text = ...这种一行代码,也会抛跨线程异常,或者更糟——在某些环境下不抛异常但界面不刷新。
3.2 解析:定点数、符号位、量程三件事必须分开写
原始寄存器到工程值,中间有三层变换,最容易混在一起写的是量程和符号。
第一层是符号。16 位寄存器,最高位为 1 表示负数,按补码还原,就是上面的Signed。有些变送器用“偏移量”表示负数,比如实际值 = 寄存器值 - 400,那就不该走补码,得看设备手册。这两种在字节层面都长得一样,只能靠手册区分。
第二层是缩放。常见温湿度变送器是 ×10 定点,也就是寄存器 253 表示 25.3℃;也有 ×100 的。源码里把缩放系数放在设备配置里,不写死:
public sealed class DeviceConfig { public string Id { get; set; } = ""; public byte SlaveId { get; set; } public ushort TempAddr { get; set; } public ushort HumAddr { get; set; } public double TempScale { get; set; } = 10.0; // ×10 填 10,×100 填 100 public double HumScale { get; set; } = 10.0; public double TempOffset { get; set; } = 0.0; // 现场比对后的修正量 public double TempMin { get; set; } = -20; public double TempMax { get; set; } = 60; }第三层是越界判定。解析出来的值如果落不到[TempMin, TempMax]区间,不要直接显示,标记 Quality=3。现场见过 85℃ 这种典型故障值——传感器断线时很多变送器输出满量程或者固定值,如果直接入库,历史曲线会被一个尖峰毁掉,而且报警会被误触发。
3.3 批量落库:采集线程只入队,写库交给单线程消费者
SQLite 单条 INSERT 走一次隐式事务,磁盘同步开销很大。10 秒一条看着不多,但四个棚加起来频率就上来了,遇到机械硬盘的工控机,采集线程会被拖慢,进而影响轮询周期。
源码里用BlockingCollection做缓冲,消费者按批提交:
// SampleWriter.cs —— 一个生产者多个、消费者只有一个,避免 SQLite 写竞争 private readonly BlockingCollection<Sample> _queue = new BlockingCollection<Sample>(20000); private long _dropped; public void Enqueue(Sample s) { if (!_queue.TryAdd(s)) Interlocked.Increment(ref _dropped); // 队列满说明落库跟不上,先计数 } private void ConsumeLoop() { var batch = new List<Sample>(500); using var conn = new SQLiteConnection("Data Source=data\\greenhouse.db;Cache=Shared"); conn.Open(); Exec(conn, "PRAGMA journal_mode=WAL;"); // 读写不互斥,界面查历史不会卡采集 Exec(conn, "PRAGMA synchronous=NORMAL;"); while (!_queue.IsCompleted) { batch.Clear(); // 先阻塞拿一条,再尽量多捞,凑够 200 条或等 1 秒就提交 if (_queue.TryTake(out var first, 1000)) batch.Add(first); while (batch.Count < 200 && _queue.TryTake(out var more)) batch.Add(more); if (batch.Count == 0) continue; using var tran = conn.BeginTransaction(); using var cmd = conn.CreateCommand(); cmd.CommandText = "INSERT INTO SampleRaw(DeviceId,TsMs,Temp,Humidity,Quality) " + "VALUES(@d,@t,@tp,@h,@q)"; var pd = cmd.Parameters.Add("@d", SqliteType.Text); var pt = cmd.Parameters.Add("@t", SqliteType.Integer); var pp = cmd.Parameters.Add("@tp", SqliteType.Real); var ph = cmd.Parameters.Add("@h", SqliteType.Real); var pq = cmd.Parameters.Add("@q", SqliteType.Integer); foreach (var s in batch) { pd.Value = s.DeviceId; pt.Value = s.TsMs; pp.Value = s.Temp; ph.Value = s.Humidity; pq.Value = s.Quality; cmd.ExecuteNonQuery(); } tran.Commit(); } }参数上几个取舍。队列容量 20000,按 10 秒一轮四个点算,能缓冲十几个小时,实际上队列满了基本等于磁盘出问题了,这时候丢新数据比阻塞采集线程好——采集线程一阻塞,串口那边会堆积,恢复后更难处理。
journal_mode=WAL是必须的。默认的回滚日志模式下,写事务会阻塞读,界面查三个月历史时采集就停了,表现出来又是一次“曲线断档”。
synchronous=NORMAL是在 WAL 下的折中,断电最多丢最后一个事务,对监控数据可接受;要绝对不丢就设 FULL,但写入速度会掉一个量级。
3.4 曲线刷新:降采样加双缓冲,别把两万个点丢给控件
图表控件画两万个点,界面一定卡。一天的数据 8640 个点,直接绑上去,鼠标拖动都费劲。
做法是两层。第一层是查询降采样:屏幕宽度 1200 像素,最多需要 1200 个点,按时间分桶,每桶取 min、max、avg:
// 按像素分桶,保留极值,曲线形状不失真 public static List<PointF> DownSample(List<Sample> src, long fromMs, long toMs, int pixelWidth) { var result = new List<PointF>(pixelWidth * 2); if (src.Count == 0) return result; double bucketMs = Math.Max(1.0, (toMs - fromMs) / (double)pixelWidth); int i = 0; for (int b = 0; b < pixelWidth && i < src.Count; b++) { long bEnd = fromMs + (long)((b + 1) * bucketMs); double min = double.MaxValue, max = double.MinValue, sum = 0; int n = 0; long bucketStart = fromMs + (long)(b * bucketMs); while (i < src.Count && src[i].TsMs < bEnd) { var v = src[i].Temp; if (v < min) min = v; if (v > max) max = v; sum += v; n++; i++; } if (n == 0) continue; result.Add(new PointF(bucketStart, (float)min)); result.Add(new PointF(bucketStart, (float)max)); result.Add(new PointF(bucketStart, (float)(sum / n))); } return result; }只取平均值会把尖峰抹平,温度骤降这种关键事件就看不见了,所以要保留 min 和 max 两个极值点。
第二层是控件层面开双缓冲。WinForm 的 Chart 或者自绘 Panel 都要设:
// 自绘面板构造时打开双缓冲,否则拖窗会闪 this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint, true); this.UpdateStyles();刷新频率也别跟采集频率绑。采集 10 秒一次,界面 1 秒刷一次就够了,用System.Windows.Forms.Timer(这个可以,因为只做绘制,不碰串口),每次只取“最新值 + 增量点”追加,不要每次重查全量。
4. 报警与联动:回差 0.5℃、延时 3 个周期这套判定怎么写
4.1 阈值判定没那么简单:回差、延时、去抖
最朴素的写法是if (temp > 35) Alarm()。这套在温室里会把人逼疯:温度在 35.0 上下浮动,报警一秒钟弹三次,蜂鸣器响个不停,操作员最后直接把声音关了——等于没有报警。
源码里的判定带三个机制。回差(死区):超过 35.0 触发报警,必须降到 34.5 以下才解除,中间 0.5℃ 的带内不动作。延时确认:连续 3 个采样周期都越界才真正报警,单次毛刺不算。状态机:正常、预警、报警、恢复四个状态,只在跃迁时写AlarmEvent表。
// AlarmEngine.cs —— 每个测点一个实例,状态自己维护 public enum AlarmState { Normal, Warning, Alarm, Recovering } public sealed class AlarmPoint { public double HighLimit { get; init; } public double Hysteresis { get; init; } = 0.5; // 回差,按量程的 1%~2% 取 public int ConfirmCount { get; init; } = 3; // 连续越界周期数 private int _over, _under; public AlarmState State { get; private set; } = AlarmState.Normal; public AlarmState Update(double v, long tsMs, Action<AlarmState, double, long> onChanged) { if (v > HighLimit) { _over++; _under = 0; } else if (v < HighLimit - Hysteresis) { _under++; _over = 0; } else { /* 落在回差带内,两个计数器都不清零也不加,保持现状 */ } var next = State; if (State == AlarmState.Normal && _over >= ConfirmCount) next = AlarmState.Alarm; else if (State == AlarmState.Alarm && _under >= ConfirmCount) next = AlarmState.Normal; if (next != State) { State = next; onChanged(next, v, tsMs); } return State; } }回差带内“既不计数也不清零”这一行是有意为之。如果带内清零,温度在 35.0 和 34.6 之间来回跳,确认计数永远攒不满,报警就永远不触发,比误报更难查。
ConfirmCount的取法跟采样周期挂钩:10 秒采一次,3 次就是 30 秒确认,适合温度这种惯性大的量;湿度变化快,可以设 2 次。别设 1,那就等于没有去抖。
4.2 下行控制:写线圈之后必须回读
报警解除后要联动开风机、开湿帘,走的是 Modbus 写单个线圈,功能码 0x05。开是 0xFF00,关是 0x0000,注意不是 0x0001 也不是 0x00FF:
public void WriteSingleCoil(byte slaveId, ushort addr, bool on) { lock (_sync) { var frame = new byte[8]; frame[0] = slaveId; frame[1] = 0x05; frame[2] = (byte)(addr >> 8); frame[3] = (byte)(addr & 0xFF); frame[4] = on ? (byte)0xFF : (byte)0x00; // 开 0xFF00,关 0x0000 frame[5] = 0x00; ushort crc = Crc16(frame, 6); frame[6] = (byte)(crc & 0xFF); frame[7] = (byte)(crc >> 8); _port.DiscardInBuffer(); _port.Write(frame, 0, 8); var resp = new byte[8]; int got = 0, deadline = Environment.TickCount + _port.ReadTimeout; while (got < 8 && Environment.TickCount < deadline) got += _port.Read(resp, got, 8 - got); if (got != 8) throw new TimeoutException("写线圈无应答"); } }写线圈的成功响应是原帧回显,所以判断成功不能只看“收到 8 字节”,要逐字节比对前 6 字节;CRC 也要重算。
更关键的一步是写后回读。485 总线上继电器模块偶发丢帧、或者线圈地址配错,写成功了但设备没动作,程序却以为开了。源码里在写完之后隔 200 毫秒读一次线圈状态(功能码 0x01)确认:
提示:联动设备的动作确认一定要回读,不能以“发送成功”当“执行成功”。温室的风机没开,一个下午就能把苗烤坏。
回读的另一个用途是初始化对齐。程序启动时要先读一遍所有线圈状态,把界面开关位置同步成设备实际状态,否则界面显示“关”、设备实际“开”,操作员一按就反了。
4.3 配置持久化:阈值改了要能追溯
温湿度阈值、回差、联动地址、串口参数,这些都不该写死在代码里。源码用的是System.Text.Json存一个devices.json,程序启动读,界面改完立即写回:
{ "serial": { "port": "COM3", "baudRate": 9600, "periodMs": 10000 }, "devices": [ { "id": "GH-A-01", "slaveId": 1, "tempAddr": 0, "humAddr": 1, "tempScale": 10.0, "humScale": 10.0, "tempHigh": 35.0, "tempHysteresis": 0.5, "humHigh": 90.0, "humHysteresis": 2.0, "fanCoilAddr": 16 } ] }id用字符串而不是自增主键,是因为现场会换设备。传感器坏了换个新的,从站地址可能变,但“A 棚 1 号点”这个身份得延续,历史曲线才连得上。这也是为什么SampleRaw表里存的是DeviceId而不是地址。
配置文件写入要做原子替换:先写devices.json.tmp,再File.Replace。工控机断电频繁,直接覆写有概率留下半截 JSON,下次启动直接解析失败。
5. 避坑排查:串口、时间戳、界面假死这三处最容易翻车
5.1 串口类:拔插、关闭、粘包,三个高频故障
现象一:程序开着,USB 转 485 拔了一次再插上,采集全停,但程序不报错,曲线停在最后一个值。
原因:SerialPort对象绑的是打开时的句柄,设备重新枚举后句柄失效,后续Read抛IOException或者直接返回 0 字节。如果采集循环里只 catch 了TimeoutException,异常被吞掉,循环继续跑,但每次都是空读。
解决:采集循环里单独 catchIOException和UnauthorizedAccessException,触发重连状态机。重连流程是:Close()并置空 → 每 2 秒尝试按名字重新打开 → 成功后重发一帧探测。重连期间所有采样点写 Quality=1,曲线画灰,别让它看起来像正常数据。
现象二:关闭窗口时程序卡死,任务管理器才能结束。
原因:窗体FormClosing里调用了_port.Close(),而采集线程正在Read阻塞中;或者反过来,采集线程在DataReceived里Invoke到 UI 线程,UI 线程正在等采集线程结束,互相等。
解决:关闭顺序固定为“先停线程、再关串口、最后退窗体”。置_running = false,Join采集线程(超时 2 秒),再_port.Close()。如果用DataReceived事件,事件里只用BeginInvoke,绝不用Invoke,Invoke是同步等待,天生适合制造死锁。
现象三:偶尔 CRC 校验失败,换个时间又好了。
原因:三种可能,一是Read没有按期望长度收全,读到一半就返回,剩下的字节留在缓冲区,下一帧从头开始读就错位了;二是总线上有别的设备在应答,地址冲突;三是波特率偏高、线太长、没有终端电阻。
解决:先看错误是否集中在某个从站,是的话查地址冲突;散发的错就先把波特率降到 9600 试,485 总线两端各加 120Ω 终端电阻。代码侧每次发帧前DiscardInBuffer(),收帧严格按5 + 2n长度循环读满,读不满就算超时,不要拿半截数据去校验。
5.2 数据类:时间戳和浮点显示
现象四:历史曲线出现时间倒流,或者同一分钟有两条聚合数据。
原因:TsMs用了DateTime.Now转的毫秒,工控机被 NTP 校时或者操作员手动改时间,本地时间会跳。另外跨夏令时的地区也会出问题。
解决:全链路用 UTC 毫秒(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),只在界面显示时转本地时间。分钟聚合的主键用(DeviceId, TsMs)并且INSERT OR REPLACE,重复执行不会产生两条。
现象五:界面显示 26.499999,跟传感器读数差一点点。
原因:ushort / 10.0得到的是二进制浮点,26.5这种十进制小数在 double 里本来就不能精确表示,再经过几次运算和格式化就露出来了。
解决:解析完立刻按精度四舍五入,Math.Round(v, 1, MidpointRounding.AwayFromZero),入库存这个值;显示统一ToString("F1")。别在界面上直接格式化原始 double。
5.3 界面与资源类:假死与磁盘
现象六:跑一个月后程序越来越慢,最后打开就卡。
原因:SQLite 数据库文件没做清理,原始表几百万行,加上没有VACUUM,索引碎片化;另外曲线控件每次都查全量数据重绘。
解决:加一个每日维护任务,把 90 天前的SampleRaw归档到SampleRaw_Archive后删除,再VACUUM。查询一律带时间范围和LIMIT,默认只查最近 24 小时。
现象七:界面偶发卡顿一两秒,日志里没异常。
原因:落库线程和界面查询线程抢同一个 SQLite 连接,或者没开 WAL,读被写阻塞。
解决:连接字符串加Cache=Shared,开 WAL,读连接和写连接分开创建。界面查询放Task.Run里,回来再BeginInvoke更新控件。
这几条踩坑记录基本覆盖了现场八成的问题。判断顺序我一般固定为:先看 Quality 字段分布,再看串口错误率,最后看时间戳是否连续——这三步能定位到通信、协议、存储哪一层出的问题。
6. 没硬件也能验收:仿真从站、边界清单和一次拔线演练
拿到源码第一件事不是接线,是让它脱离硬件跑起来。通信层既然不依赖控件,就可以写一个内存版从站替身,把温湿度装在一个ushort[]里,CRC 用同一套方法算。这样协议对不对、超时分支走没走、异常码处理有没有问题,全都能在半小时内验完。常见的做法是实现一个最小接口IModbusTransport,真实串口和虚拟从站各实现一份,采集线程只认接口。虚拟从站里故意加两个开关:一个让某次请求不返回(验超时),一个返回错误的 CRC(验校验失败),跑一遍看 Quality 字段是不是按预期变成 1 和 2。
硬件到了之后,别急着上生产棚,先按这张清单过一遍:
| 验证项 | 操作 | 通过标准 |
|---|---|---|
| 地址扫描 | 从站地址 1~8 逐个发读寄存器 | 只有实际存在的地址应答,其余超时 |
| 量程核对 | 用标准温度计与程序读数比对 | 偏差在 ±0.3℃ 内,否则调 TempOffset |
| 负温 | 传感器放冰水混合物附近 | 显示 -0.5 左右,不是 6553.1 |
| 断线 | 拔掉 485 线 30 秒再接回 | 曲线灰段,接回后自动恢复,无需重启 |
| 报警回差 | 加热到阈值上 0.2℃,再降到阈值下 0.6℃ | 上穿不报,下穿才解除,只产生一条事件 |
| 联动 | 手动触发报警 | 风机继电器动作,界面状态与回读一致 |
| 断电恢复 | 直接断电再上电 | 配置不丢,历史曲线连续,无重复分钟数据 |
最后一步是我现在的固定习惯:上线前做一次“拔线演练”,把 485 线拔掉两分钟再接上,中途不停程序,然后盯着曲线和 Quality 分布看。数据中断、自动重连、灰段标记、报警不误触发,这四件事一次能全验出来。以前吃过亏——项目验收那天一切正常,结果第二周雷击导致总线短路,程序没有重连逻辑,采集静默停了八个小时,谁也没发现,棚里的数据全断了。从那以后我每次交付上位机,拔线演练都强制走一遍,不做完不签字。希望帮到你。
本文还有配套的精品资源,点击获取