☰
C#上位机温室监控系统源码:串口通信与Modbus实战
2026/10/15 17:40:39 网站建设 项目流程

简介:这套C#温室监控系统源码由上位机与51普中开发板下位机组成,适合C#开发者、嵌入式学习者以及农业自动化方向的学生参考。上位机基于C#编写,完成实时数据展示、参数设置、控制指令下发与历史数据存储;下位机负责温度、湿度、光照等环境数据的采集和执行设备控制,双方通过串口进行通信。压缩包内含1065个文件,大小约50.71MB,包含Windows窗体与WPF相关dll、配置xml、C#源文件、单片机C程序、hex固件及工程配置等,目录结构较完整,可直接打开工程进行二次开发。部分文件类型如targets、nupkg等为Visual Studio依赖项,帮助还原编译环境。项目提供了从硬件采集、通信协议到人机交互、数据库记录的整体实现思路,还涉及事件驱动和分层设计等软件工程实践。已有2313人学习下载,对于理解上位机与下位机协同工作、掌握串口通信和传感器数据采集流程很有参考价值。

1. 温室监控上位机:C# 源码项目到底在解决什么问题

前年帮朋友改造一个小型温室,传感器装了一排,控制箱也有,就是缺一台上位机软件。数据全靠人工每天去记录仪上抄一次,夏季高温那天没及时发现,一垄苗全蔫了。后来我用 C# 写了一套温室监控系统,串口接上温湿度采集器,界面实时出曲线,超阈值弹窗报警,数据自动存进本地库。所以“c#上位机(温室监控系统源码)”这种东西,解决的从来不是“画界面”,而是把设备、数据、人工盯守这条链路补完整。下面就用一个最小可跑的源码结构,讲清楚串口通信、Modbus 解析、曲线展示、SQLite 存储这几块怎么写,以及哪些地方最容易翻车。适合已经有下位机、想自己做 Windows 监控端的人,也适合拿来改毕业设计的场景。

2. 温室监控系统的四层边界:通信、业务、存储与展示怎么分工

2.1 从传感器到界面,代码里应该划分成哪四层

没有经验的开发者拿到“温室监控系统”题目,第一反应是画界面。画界面是最小的一部分。我做过一个模拟项目X,前三天全在搭界面,最后通信协议一对接,界面全部推翻重来。一套能落地的 C# 上位机源码,代码结构上至少要分成四层:设备层、通信层、业务层、展示层。

设备层对应传感器和执行器,比如温湿度变送器、光照传感器、土壤湿度计、继电器控制的风机和水泵。通信层负责从这些设备把数据读上来,在温室场景里最常见的就是串口 + Modbus RTU,少部分设备用 TCP/IP 的 Modbus TCP。业务层做数据解析、阈值判断、报警触发、数据落盘、日志记录。展示层就是给人看的部分,包括实时数值、曲线、报警列表、历史查询界面。

这四层在 C# 源码里一般对应几个独立的类:SerialService负责串口打开关闭和数据收发;DataParser负责把 Modbus 报文拆成温度、湿度、光照、CO2 浓度;AlarmService负责阈值判断和报警输出;DataStore负责和 SQLite 打交道;MainForm只做界面绑定和用户交互。分层的核心原则是:通信层不接触控件,业务层不写 UI 逻辑。这样换传感器型号、换通信协议时,界面部分几乎不用动。

很多人图省事,把所有逻辑塞进Form1.cs的按钮事件里。前三个月没问题,等到要加第二种传感器、要改报警策略时,代码就成了一团乱麻。分层不是加分项,而是这个题目能不能继续改下去的前提。

2.2 通信协议选型:为什么温室设备绕不开 Modbus RTU

温室里用的下位机设备,不管是温湿度变送器、光照强度传感器还是 CO2 变送器,只要是工业级产品,绝大多数都支持 Modbus RTU 协议,少部分支持 Modbus TCP。Modbus RTU 是跑在串口上的,物理层常见 RS485 总线,一根两芯线可以挂几十个设备,每个设备一个地址,非常适合温室这种传感器分散但距离不远的场景。

选择 Modbus 而不是厂家自定义协议的另一个原因是可调试性。Modbus 的寄存器地址、功能码、CRC16 校验算法都是公开标准,用串口调试助手抓帧就能看明白。而自由协议每个厂家定义不同,有的校验用累加和,有的用异或,换一个传感器上位机就要重写解析。某开发者的真实经历:接手一个旧系统,用的是设备商私有的帧格式,设备停产之后协议文档也丢了,最后只能拿逻辑分析仪一点一点猜,非常痛苦。

Modbus RTU 帧本身的参数也相对固定,默认组合是波特率 9600、数据位 8、停止位 1、无校验、CRC16 校验,功能码 03 读保持寄存器用得最多。下面是温室上位机最常见的参数表:

参数常见值说明
波特率9600 / 4800以设备说明书为准,不确定就两个都试
数据位8几乎不会变
停止位1个别老设备是 2
校验位None有校验时会干扰 CRC 计算
功能码03(读保持寄存器)部分传感器用 04 读输入寄存器
CRC16低字节在前Modbus 标准,多项式 0xA001

串口参数错了最常见的结果是能收到数据但解析全部乱码,或者干脆没响应。排查时先不要怀疑代码,先把串口调试助手打开,能收到正确报文再写解析逻辑。通信协议是温室监控系统里最不该自己想当然的部分,一切以抓到的帧为准。

2.3 寄存器地址映射表:拿到设备说明书后的第一步

拿到一台温湿度变送器的手册,第一件事不是写代码,而是画一张寄存器地址映射表。比如某型号的变送器,寄存器 0x0001 是温度,放大 10 倍存储,读到十六进制 0x0113 就是十进制的 275,实际温度是 27.5℃;寄存器 0x0002 是湿度,同样放大 10 倍。光照传感器可能从 0x0000 开始,CO2 变送器又在另一个地址。这些地址没有任何规律可循,只能逐个设备记录。

Modbus RTU 的请求帧格式是:从站地址 1 字节、功能码 1 字节、起始地址 2 字节、寄存器数量 2 字节、CRC16 校验 2 字节,一共 8 字节。比如要读地址 1 的设备,从寄存器 0x0001 开始读 2 个寄存器,请求帧就是01 03 00 01 00 02再加上两位 CRC。响应帧格式是:从站地址、功能码、数据字节数、数据、CRC。读 2 个寄存器,数据区就是 4 字节,所以完整响应帧是 1+1+1+4+2=9 字节。

这里有个温室项目里最常见的坑:字节序。温度传感器返回两字节,大部分设备是高字节在前、低字节在后,比如01 2C表示 0x012C 也就是 300,除以 10 得 30.0℃。但也有设备是低字节在前,不摸清就直接解析,显示出来的温度完全乱套。我一般会先用串口调试助手手动发一帧请求,然后对照手册看返回的数据,确认字节序后再写DataParser里的解析代码。这个步骤省不掉,跳过它后面全是玄学问题。

3. 用 C# 写一个能跑的串口采集器:参数、CRC 校验与曲线刷新

3.1 SerialPort 参数设置与打开串口的异常处理

C# 操作串口用的是System.IO.Ports.SerialPort类。温室监控上位机的串口初始化代码很固定,下面这段是我常用的写法,注意两点:超时要设,否则设备掉线时读取方法会一直阻塞;ReceivedBytesThreshold设为 8,表示缓冲区积攒到 8 字节才触发DataReceived事件,尽量凑齐一个完整的 Modbus 请求帧长度。

SerialPort sp = new SerialPort(); sp.PortName = "COM3"; // 根据设备管理器实际端口修改 sp.BaudRate = 9600; // 和设备说明书保持一致,常见 9600 sp.DataBits = 8; sp.Parity = Parity.None; sp.StopBits = StopBits.One; // 个别设备要试 Two sp.ReadTimeout = 1000; // 读超时 1 秒,防卡死 sp.WriteTimeout = 1000; sp.ReceivedBytesThreshold = 8; // 攒够 8 字节再触发事件 try { if (!sp.IsOpen) sp.Open(); sp.DtrEnable = true; // 部分 USB 转串口需要 DTR 信号 } catch (UnauthorizedAccessException ex) { // 端口被其他程序占用 } catch (IOException ex) { // 设备没上电、USB 转串口未识别 }

PortName是上位机运行现场最容易变的东西。同一台 USB 转串口设备插到不同 USB 口,COM 编号可能从 COM3 变成 COM5。正式一点的源码会把端口名做成下拉框,启动时枚举SerialPort.GetPortNames()让用户选,而不是写死。DtrEnable和RtsEnable这两个属性很多人不设,结果就是设备能通电但就是不回数据,这正是我在现场踩过的坑。

打开串口的异常处理一定要区分清楚。UnauthorizedAccessException是端口被占用,比如调试助手还开着;IOException多半是设备掉线。这两种情况的处理方式完全不同,前者要提示用户关掉其他程序,后者要做自动重连。不要图省事一个catch (Exception)全包住,否则现场维护的人看到错误提示根本不知道从哪下手。

3.2 构造 Modbus 请求帧:CRC16 校验的完整实现

Modbus RTU 的核心就是 CRC16 校验。温室上位机源码里,这个函数建议单独放到CrcHelper静态类里,因为后面解析响应帧时还要用到。下面是完整实现,注意多项式常数0xA001不能写错,这是 Modbus 标准规定的。

public static byte[] BuildReadRequest(byte slaveAddr, ushort startAddr, ushort quantity) { byte[] frame = new byte[8]; frame[0] = slaveAddr; // 从站地址,比如 0x01 frame[1] = 0x03; // 功能码 03:读保持寄存器 frame[2] = (byte)(startAddr >> 8); // 起始地址高字节 frame[3] = (byte)(startAddr & 0xFF); frame[4] = (byte)(quantity >> 8); // 寄存器数量高字节 frame[5] = (byte)(quantity & 0xFF); ushort crc = Crc16(frame, 6); frame[6] = (byte)(crc & 0xFF); // CRC 低字节在前 frame[7] = (byte)(crc >> 8); return frame; } public static ushort Crc16(byte[] buffer, int len) { ushort crc = 0xFFFF; for (int i = 0; i < len; i++) { crc ^= buffer[i]; for (int j = 0; j < 8; j++) { if ((crc & 1) != 0) crc = (ushort)((crc >> 1) ^ 0xA001); else crc >>= 1; } } return crc; }

CRC16 的校验范围是从帧头到数据区结束,也就是frame[0]到frame[5]这 6 个字节,自己构造请求帧时参数传入 6。生成的 CRC 低字节放在前一位置,高字节放在后一位置,这就是标准里说的“低字节在前”。很多人在这一步把高低字节放反,设备会直接忽略请求不响应,因为 CRC 不通过。

温室里多台传感器挂在同一条 RS485 总线上,上位机需要按照地址轮询。比如地址 1 是温湿度,地址 2 是光照,地址 3 是 CO2,就每隔一定时间依次给每个地址发送请求帧,收到响应后解析,再问下一个。轮询周期我一般设在 2 到 3 秒一轮,太快了 RS485 总线容易冲突,太慢了界面曲线跟不上实时变化。

3.3 响应帧解析与温湿度换算:别把字节序搞反

DataReceived事件里拿到的数据不一定是完整一帧。串口是流式的,一个响应可能被拆成两段到达,也可能两帧粘在一起到达。下面是解析逻辑的简化版本,先假设收到完整一帧,实际项目要配合缓冲区队列处理拆包粘包,这一点在第 5 章会单独说。

private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { // 读 2 个寄存器时,响应帧固定是 9 字节:地址+功能码+字节数+4字节数据+2字节CRC int n = sp.BytesToRead; byte[] buffer = new byte[n]; sp.Read(buffer, 0, n); if (buffer.Length < 9) return; // 1. 先算 CRC,失败直接丢弃 ushort recvCrc = (ushort)(buffer[buffer.Length - 2] | (buffer[buffer.Length - 1] << 8)); ushort calcCrc = CrcHelper.Crc16(buffer, buffer.Length - 2); if (recvCrc != calcCrc) return; // 2. 解析数据区,温度/湿度都是放大 10 倍的短整型 short tempRaw = (short)((buffer[3] << 8) | buffer[4]); double temperature = tempRaw / 10.0; short humiRaw = (short)((buffer[5] << 8) | buffer[6]); double humidity = humiRaw / 10.0; // 3. 跨线程更新 UI,用 BeginInvoke 而不是直接赋值 BeginInvoke(() => { lblTemp.Text = $"{temperature:F1} ℃"; lblHumi.Text = $"{humidity:F1} %"; }); }

这段代码里最重的细节是短整型的还原方式。温度寄存器返回两字节buffer[3]和buffer[4],高字节在前,所以要左移 8 位再按位或。如果设备手册写明是低字节在前,就要改成buffer[4] << 8 | buffer[3]。这个顺序错了,读出来的温度值会差一个数量级。

另外注意tempRaw一定要用short,也就是有符号 16 位整数。温室里温度可能是负的,比如冬天大棚夜间降到 -5℃,设备返回的原始值是 -50,如果用了无符号的ushort,解析出来会变成 65486,再除以 10 就成了 6548.6℃,直接触发高温报警。这个坑不遇到一次真的想象不到,遇到一次就记住了。

解析完成后不要直接操作控件,Windows Forms 的串口事件是在后台线程触发的,直接赋值会给控件跨线程访问抛异常。用BeginInvoke回 UI 线程是比较稳妥的做法。

3.4 实时曲线刷新:Chart 控件的防卡顿做法

实时曲线是温室监控上位机的门面,也是最容易卡的地方。很多 C# 源码直接chart.Series["温度"].Points.AddXY()一个个追加,数据量大之后界面越来越卡。一个缓解思路是只保留最近一小段数据,并且用定时器批量刷新,而不是数据到了就刷。

Queue<double> tempQueue = new Queue<double>(); private void TimerTick(object sender, EventArgs e) { // 只保留最近 200 个点,内存和绘制压力都小 tempQueue.Enqueue(GlobalData.CurrentTemperature); if (tempQueue.Count > 200) tempQueue.Dequeue(); chart.Series["温度"].Points.Clear(); double[] arr = tempQueue.ToArray(); for (int i = 0; i < arr.Length; i++) chart.Series["温度"].Points.AddXY(i, arr[i]); }

这段代码的关键是用了Queue<double>做环形缓冲,超过 200 个点就从头挤掉。定时器间隔设成 500 毫秒,也就是每半秒刷新一次曲线,即使串口数据是每秒到达多次,界面也只会每秒重画两次。如果你的温室内有超过 4 个传感器要同时显示,建议改用Point.Checked或者用 OxyPlot 这类轻量级绘图库,Windows 自带的 Chart 控件点太多时绘制开销会明显上升。

GlobalData.CurrentTemperature是全局静态属性,由解析线程写入,UI 定时器读取。这种设计避免了跨线程频繁传参。注意这里读取的是double,读写原子性在 64 位系统上没问题,但如果要传递整个数据结构,建议加lock或者用volatile配合不可变类,否则可能读到半初始化状态。

4. 把数据落进 SQLite:表结构、批量写入与历史导出

4.1 为什么本地存储选 SQLite 而不是 SQL Server

温室监控上位机部署在现场,现场的电脑配置参差不齐,很多还是老旧的工控机。如果选 SQL Server,光安装数据库实例就是一个大工程,更别说 SQL Server 服务崩了之后没人会重启。SQLite 就一个 DLL 引用,数据库是单个.db文件,程序启动时没有发现数据库文件会自动创建,优点是零配置、单文件、掉电不丢数据。

温室现场还有个现实问题:断电是常态,而且不是每次都能正常关机。SQLite 的 WAL 模式在掉电恢复能力上比默认的 journal 模式强不少,后面我会专门说怎么开。C# 里用 SQLite 一般通过System.Data.SQLite这个库,NuGet 搜System.Data.SQLite直接装即可,注意选择和你项目平台匹配的版本,x86 和 x64 混了会报BadImageFormatException。

对比其他方案:Access 文件数据库在温室这种高湿度、频繁掉电的环境下经常出现“未识别的数据库格式”,因为 Jet 引擎对文件损坏几乎没有任何自愈能力。CSV 文件存储则面临并发写入问题,两个线程同时写一个文件很容易互相覆盖。SQLite 在可靠性和部署成本之间几乎是唯一平衡点。

4.2 数据表设计与参数化写入:WAL 模式怎么开

温室监控数据库至少要有两张表:一张存传感器采集数据,一张存报警记录。下面是我常用的建表语句,注意时间字段用 ISO 格式字符串存储,SQLite 的日期函数处理起来方便,而且直接拿来排序不会乱。

CREATE TABLE IF NOT EXISTS sensor_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, device_addr INTEGER NOT NULL, temperature REAL, humidity REAL, light REAL, co2 REAL, ts TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS alarm_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, alarm_type TEXT NOT NULL, alarm_value REAL, threshold REAL, ts TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) );

写入代码用参数化插入,不要拼 SQL 字符串。这里有两个细节:连接字符串加上Journal Mode=WAL;,这是掉电保护的关键;写入操作放在独立线程里,不要占用 UI 线程。

public void InsertSensorData(int addr, double temp, double humi, double light, double co2) { string connStr = "Data Source=greenhouse.db;Journal Mode=WAL;"; using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var cmd = conn.CreateCommand()) { cmd.CommandText = @" INSERT INTO sensor_data (device_addr, temperature, humidity, light, co2, ts) VALUES (@addr, @t, @h, @l, @c, @ts)"; cmd.Parameters.AddWithValue("@addr", addr); cmd.Parameters.AddWithValue("@t", temp); cmd.Parameters.AddWithValue("@h", humi); cmd.Parameters.AddWithValue("@l", light); cmd.Parameters.AddWithValue("@c", co2); cmd.Parameters.AddWithValue("@ts", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); cmd.ExecuteNonQuery(); } } }

WAL 模式的作用是写入数据时只追加到日志文件,不直接修改主库文件,掉电后主库不会处于半写入状态。代价是会多生成greenhouse.db-wal和greenhouse.db-shm两个文件,这是正常的,不要看到多文件就去删。项目交付时如果把数据库文件拷走,要先把 WAL checkpoint 掉,方式是连接数据库后执行PRAGMA wal_checkpoint(TRUNCATE);。

参数化插入除了防 SQL 注入,还有一个好处:SQLite 会缓存相同格式的插入语句,每次执行只换参数值,不需要重新解析 SQL。对于温室这种每 2 秒一次的写入频率,这个性能差异能明显降低 CPU 占用。如果一分钟要写几十条数据,建议改成事务批量提交,BeginTransaction包住,提交一次能快一个数量级。

4.3 历史查询与 CSV 导出:Excel 打开乱码的补救

历史查询界面是温室监控上位机的基本功能,用于查看过去几天甚至几个月的温湿度变化。查询逻辑比较简单,按时间段从sensor_data表里取数据,然后绑定到DataGridView。

public DataTable QueryHistory(DateTime start, DateTime end) { string connStr = "Data Source=greenhouse.db;Journal Mode=WAL;"; using (var conn = new SQLiteConnection(connStr)) { conn.Open(); using (var cmd = conn.CreateCommand()) { cmd.CommandText = @" SELECT ts, temperature, humidity, light, co2 FROM sensor_data WHERE ts BETWEEN @start AND @end ORDER BY ts"; cmd.Parameters.AddWithValue("@start", start.ToString("yyyy-MM-dd HH:mm:ss")); cmd.Parameters.AddWithValue("@end", end.ToString("yyyy-MM-dd HH:mm:ss")); var dt = new DataTable(); using (var reader = cmd.ExecuteReader()) dt.Load(reader); return dt; } } }

这个查询的潜在问题是数据量大时DataTable加载很慢。温室监控如果跑一个月,sensor_data表轻松超过十万行,一次性全查出来界面会卡住好一会儿。常见做法是查询界面强制带时间范围,默认只查当天的数据;更大规模的数据要做好分页查询,每次只取 500 行。

CSV 导出是最讨喜的功能,因为它能直接塞给 Excel 做进一步分析。但这里有一个强烈的坑:直接用StreamWriter写 UTF-8 编码的 CSV,Excel 打开会乱码。原因是 Excel 默认用 ANSI 编码读取 CSV 文件,除非文件带 UTF-8 BOM 头。解决办法是把编码显式设成带 BOM 的 UTF-8:

var sw = new StreamWriter("history.csv", false, new UTF8Encoding(true));

new UTF8Encoding(true)的true表示生成 BOM,这样 Excel 双击打开就不会乱码。如果还要考虑字段里包含逗号和换行的情况,导出前要对字段做转义,用双引号包住整个字段,内部双引号替换成两个双引号。这个细节不处理好,导出的文件在 Excel 里列会错位。

5. 温室监控上位机避坑指南:串口、滤波与内存的 5 个排查记录

5.1 串口间歇性掉线,程序直接崩

现象是温室监控程序运行几个小时之后,串口突然报错,提示“端口 COM3 不存在”或者“设备未连接”,然后整个程序就卡在那里,界面上的数据不再刷新。

原因有两个。一是 USB 转串口线本身的休眠机制,Windows 电源管理会自动挂起闲置的 USB 设备,导致串口句柄失效。二是设备端电源不稳,RS485 收发器瞬间掉电后,串口读取就超时了。很多温室现场用普通 USB 转串口线而不是工业级串口卡,这个问题更容易出现。

解决分两步:先治标,打开设备管理器,找到 USB 转串口设备,在电源管理里取消“允许计算机关闭此设备以节约电源”。再治本,在 C# 源码里给串口收发加上异常捕获和自动重连。重连逻辑不复杂:捕获异常后关闭串口,等待 5 秒,再重新尝试打开。注意重连时要更新界面的连接状态提示,否则现场的人以为程序没死,其实数据已经停了。

5.2 温度曲线出现毛刺,显示 -40℃ 或 85℃

现象是曲线整体正常,但时不时蹦出一个离谱值,比如 -40℃ 或 85℃,过几秒又恢复正常。如果这个数据触发了报警,还会引发大量误报。

原因是温室大棚内湿度高,RS485 线路长,信号容易受到干扰;另外传感器上电瞬间读到的值还没稳定,直接拿来展示就会出现尖峰。还有一个很容易被忽略的原因:接收数据时没校验 CRC,或者没有等完整一帧就开始解析,把上一帧的尾巴和下一帧的头拼在一起,解析出来的值自然不对。

解决办法是三层过滤。第一层是 CRC 校验,失败的数据帧直接丢弃,不进解析流程。第二层是限幅滤波,如果当前值和上一秒的差值超过设定阈值,比如温度突变超过 5℃,就认为无效,保留上一秒的值。第三层是滑动平均,取最近 5 个有效值做平均,曲线会平滑很多。下面是限幅滤波的示意:

public double LimitFilter(double newValue, double lastValue, double maxDelta) { if (Math.Abs(newValue - lastValue) > maxDelta) return lastValue; // 突变过大,认为是毛刺 return newValue; }

限幅阈值要根据温室实际环境设定。正常温室内温度每秒变化不会超过 0.5℃,受到开门、通风影响也不会超过 2℃,设 5℃ 已经相当宽松了。如果设得太小,比如 0.1℃,反而会把真实变化当成毛刺滤掉,出现曲线长时间不动的假死现象。

5.3 内存持续上涨,运行两天后卡死

现象是程序刚启动时内存只占几十 MB,运行两天后涨到五六百 MB,界面开始卡顿,最后直接无响应。

原因是某个对象或事件没有被正确释放。最常见的是DataReceived事件重复订阅:如果打开串口的代码里写了sp.DataReceived += OnDataReceived,而关闭重连时没有先-=,每重连一次就多挂一个事件处理器,旧的方法不会回收,内存自然越涨越高。另外一个常见原因是 Chart 控件的Series里点太多,Points.Clear()之后底层并没有立刻释放所有资源。

解决这件事有几个习惯。事件订阅只在初始化时做一次,串口关闭重连时只调用sp.Close()和sp.Open(),不要重新订阅事件。用using语句包裹所有实现了IDisposable的对象,比如 SQLiteConnection、StreamWriter。定期用GC.GetTotalMemory打点记录,如果发现内存单日涨幅超过 100MB,基本可以断定有泄漏。

在温室的无人值守场景下,程序连续运行几周是基本要求。写完后不要只测一两个小时,起码挂机跑 24 小时看内存曲线,这是最直观的验收手段。

5.4 界面假死,点按钮没反应

现象是点击界面上的按钮没有任何反应,窗口标题栏显示“无响应”,但是鼠标还能移动。一般是第一次串口通信之后就出现。

原因是串口读写操作阻塞了 UI 线程。很多人图方便,直接在按钮点击事件里调用sp.Write()然后Thread.Sleep(200)等响应,结果Sleep把界面线程也堵住了。Windows 的界面消息循环被阻塞时间太长,系统就会把窗口标记为无响应。还有一种情况是在DataReceived事件里做了耗时逻辑,比如写数据库或解析大量数据,虽然没 Sleep,但同样会拖住后台线程,间接影响 UI 响应。

解决原则是任何可能超过几十毫秒的操作都不在 UI 线程执行。串口发送用await sp.BaseStream.WriteAsync()代替同步方法;响应等待用await Task.Delay配合超时判断;数据库写入放到独立线程。界面更新统一走BeginInvoke。如果用的是 .NET 6 以上的版本,SerialPort的读写已经支持异步,代码要比传统写法干净不少。

5.5 断电后最后几分钟数据丢失

现象是温室突然断电,重启程序后,发现断电前几分钟的数据没了。看起来数据的写入频率是每秒一次,但丢的恰好是最后几分钟。

原因是 SQLite 默认的 journal 模式在写入过程中断电,回滚日志可能没有正确落盘,数据库为了安全会回滚到最后一次完整事务。如果写入线程是每 2 秒提交一次,那么断电时就有 2 秒窗口期的数据丢失。但丢“最后几分钟”更常见的原因是写入线程没有实时落盘,数据还在内存缓冲区就断电了,缓冲区里的数据全部消失。

解决是开启 WAL 模式,并且把写入改成事务批量提交。实际操作中,我给写入线程设置了每 5 秒批量提交一次,连接字符串加Journal Mode=WAL;Synchronous=NORMAL;。这个配置在性能和可靠性之间比较均衡。如果你严格要求不漏数据,可以设Synchronous=FULL,但这会让每秒写入慢不少,温室监控这种低频写入场景其实没必要那么激进。

另外一个被忽略的点是传感器数据除了入库,还要在内存里保留一份最近一小时的副本。这样即使数据库出现异常,界面上仍然能显示当前状态,不至于整个监控黑屏。

6. 进阶:给监控端加 Web API 与报警推送,顺手解决远程查看

6.1 用 30 行 Web API 把实时数据暴露给手机

温室现场往往没人在跟前,上位机装好后,人可能在办公室而不是在大棚旁边。远程查看的常见做法是给上位机加一个轻量 Web API,手机浏览器直接访问就能看到当前温湿度。.NET 6 以后有 Minimal API,代码量非常小。

var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/api/status", () => new { temperature = GlobalData.CurrentTemperature, humidity = GlobalData.CurrentHumidity, light = GlobalData.CurrentLight, ts = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") }); app.Run();

这段代码跑起来之后,上位机本身就变成了一个局域网内的 HTTP 服务,手机连同一个 WiFi,浏览器访问http://上位机IP:5000/api/status就能拿到 JSON 数据。注意GlobalData里存放的是最新一帧解析完的数值,由串口线程写入,Web API 线程读取。这个设计避免了 Web API 直接依赖串口对象,模块之间解耦。

如果你的上位机在公网环境,后面还要考虑身份验证和端口映射,这部分属于网络安全的范畴,建议只在可信局域网内用这种方式。加了公网映射之后,任何暴露的接口都要做访问令牌验证,否则任何人都能读到温室的实时数据。

6.2 超阈值报警的 Webhook 推送:一个最小实现

报警是温室监控系统里真正值钱的功能。温度超过上限或者掉线超过一定时间,系统应该主动推消息到手机上,而不是等人工去看屏幕。在 C# 源码里实现 Webhook 推送并不复杂,就是用一个HttpClient往消息通道的 Webhook 地址 POST 一个 JSON。

using var client = new HttpClient(); var payload = new { msg = $"温度异常:当前 {temp:F1}℃,阈值 {threshold:F1}℃" }; var json = System.Text.Json.JsonSerializer.Serialize(payload); var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); var resp = await client.PostAsync(webhookUrl, content); resp.EnsureSuccessStatusCode();

注意HttpClient不要在每次报警时都 new 一个新的,应该做成单例,否则频繁报警会导致连接堆积。Webhook 地址通常是一个带密钥的 URL,逻辑上等价于一个私有回调地址,不经过任何中间服务器。如果你的使用场景是企业内网,也可以改成邮件推送或 Windows 服务通知,实现思路完全一样。

我第一版做温室监控上位机时,只做了弹窗报警,没有做任何远程推送。结果晚上设备掉线,直到第二天早上现场的人打电话问“怎么数据不动了”才知道。后来在轮询循环里加了一个“最后心跳时间”字段,超过 2 分钟没有收到任何有效帧就触发掉线报警,推送到手机。这个细节让整套系统的可用性提升了一个档次。报警推送不是锦上添花,是温室监控真正意义上从“软件”变成“系统”的那一步。希望这里的思路能帮你把 C# 上位机做成一个真正敢离开人也能自己运行的监控端。

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

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

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

立即咨询