简介这份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 是反向写法初值 0xFFFFprivate 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; }注意很多现场设备会返回异常帧功能码最高位置 10x83第三字节是异常码。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]区间不要直接显示标记 Quality3。现场见过 85℃ 这种典型故障值——传感器断线时很多变送器输出满量程或者固定值如果直接入库历史曲线会被一个尖峰毁掉而且报警会被误触发。3.3 批量落库采集线程只入队写库交给单线程消费者SQLite 单条 INSERT 走一次隐式事务磁盘同步开销很大。10 秒一条看着不多但四个棚加起来频率就上来了遇到机械硬盘的工控机采集线程会被拖慢进而影响轮询周期。源码里用BlockingCollection做缓冲消费者按批提交// SampleWriter.cs —— 一个生产者多个、消费者只有一个避免 SQLite 写竞争 private readonly BlockingCollectionSample _queue new BlockingCollectionSample(20000); private long _dropped; public void Enqueue(Sample s) { if (!_queue.TryAdd(s)) Interlocked.Increment(ref _dropped); // 队列满说明落库跟不上先计数 } private void ConsumeLoop() { var batch new ListSample(500); using var conn new SQLiteConnection(Data Sourcedata\\greenhouse.db;CacheShared); conn.Open(); Exec(conn, PRAGMA journal_modeWAL;); // 读写不互斥界面查历史不会卡采集 Exec(conn, PRAGMA synchronousNORMAL;); 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_modeWAL是必须的。默认的回滚日志模式下写事务会阻塞读界面查三个月历史时采集就停了表现出来又是一次“曲线断档”。synchronousNORMAL是在 WAL 下的折中断电最多丢最后一个事务对监控数据可接受要绝对不丢就设 FULL但写入速度会掉一个量级。3.4 曲线刷新降采样加双缓冲别把两万个点丢给控件图表控件画两万个点界面一定卡。一天的数据 8640 个点直接绑上去鼠标拖动都费劲。做法是两层。第一层是查询降采样屏幕宽度 1200 像素最多需要 1200 个点按时间分桶每桶取 min、max、avg// 按像素分桶保留极值曲线形状不失真 public static ListPointF DownSample(ListSample src, long fromMs, long toMs, int pixelWidth) { var result new ListPointF(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, ActionAlarmState, 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 也不是 0x00FFpublic 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 秒尝试按名字重新打开 → 成功后重发一帧探测。重连期间所有采样点写 Quality1曲线画灰别让它看起来像正常数据。现象二关闭窗口时程序卡死任务管理器才能结束。原因窗体FormClosing里调用了_port.Close()而采集线程正在Read阻塞中或者反过来采集线程在DataReceived里Invoke到 UI 线程UI 线程正在等采集线程结束互相等。解决关闭顺序固定为“先停线程、再关串口、最后退窗体”。置_running falseJoin采集线程超时 2 秒再_port.Close()。如果用DataReceived事件事件里只用BeginInvoke绝不用InvokeInvoke是同步等待天生适合制造死锁。现象三偶尔 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读被写阻塞。解决连接字符串加CacheShared开 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 分布看。数据中断、自动重连、灰段标记、报警不误触发这四件事一次能全验出来。以前吃过亏——项目验收那天一切正常结果第二周雷击导致总线短路程序没有重连逻辑采集静默停了八个小时谁也没发现棚里的数据全断了。从那以后我每次交付上位机拔线演练都强制走一遍不做完不签字。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?