C# WinForms温室监控上位机源码解析与二次开发指南

发布时间:2026/11/2 18:16:33
C# WinForms温室监控上位机源码解析与二次开发指南

简介:这份源码面向学习C#上位机开发、嵌入式通信与农业自动化的开发者,提供一套由C#上位机与51普中开发板下位机组成的温室监控系统完整实现,可用于理解串口数据采集、环境参数显示与远程控制指令下发的整体链路。压缩包共1065个文件、约50.71MB,以298个dll依赖库、266个xml配置与文档、36个nupkg包及若干targets、cs源码、c与h底层文件为主,另含hex烧录文件、uvproj工程与sln解决方案,兼顾上位机界面与下位机固件两侧。已有2319人学习下载。读者可从中获取Windows Forms界面搭建、温湿度与光照数据采集传输、阈值判断与控制策略、历史数据存储分析等可运行代码,并借MVC与事件驱动结构体会软件工程实践,适合课程设计、毕业设计及自学参考。

1. 温室监控上位机:一个能直接跑在工控机上的 C# 桌面项目

去年帮一个做农业物联网的朋友救火,现场一台工控机上跑的上位机崩了,棚里的温湿度、光照、CO₂ 数据全断,种植户只能靠手摸叶子判断要不要开风机。翻出他们早期买的这套 C# 温室监控系统源码,改了两天重新编译部署,才算把数据链路接回来。这件事让我意识到,很多做嵌入式、做 PLC 的团队,缺的不是下位机采集能力,而是一个稳定、能改、界面不寒酸的上位机壳子。这套源码正好填这个坑:C# WinForms 桌面端,串口/网络双通道采集,实时曲线、阈值报警、历史数据落库、设备手动控制一应俱全,适合刚接触上位机开发的工程师拿来当骨架,也适合小团队直接改造成自己大棚、育苗室、组培间的监控界面。下面我按「它是什么 → 怎么跑起来 → 核心代码怎么读 → 怎么改成你自己的」这条线拆一遍。

2. 技术栈与目录结构:WinForms + 串口 + 数据库的三层骨架

2.1 为什么是 WinForms 而不是 WPF

拿到源码第一眼先看 csproj,目标框架是 .NET Framework 4.x,UI 层用的是 WinForms。这个选型在工业上位机场景里非常典型:工控机配置普遍不高,WinForms 启动快、内存占用低、拖控件就能出界面,现场调试时改个按钮位置不用重新学 XAML 绑定。WPF 当然更现代,数据绑定和动画更舒服,但对一个只需要「显示数字 + 画曲线 + 点按钮」的监控系统来说,属于杀鸡用牛刀,而且老工控机上 WPF 渲染偶尔会掉帧,现场运维人员看着卡顿的曲线会怀疑数据没上来。

源码的解决方案结构大致是这样,我按实际拆包看到的层级整理:

GreenhouseMonitor/ ├── GreenhouseMonitor.sln ├── GreenhouseMonitor.UI/ # WinForms 主界面、窗体、控件 │ ├── Forms/ │ │ ├── MainForm.cs # 主监控界面 │ │ ├── HistoryForm.cs # 历史数据查询 │ │ └── SettingForm.cs # 参数配置 │ └── Controls/ │ └── RealtimeChart.cs # 自绘实时曲线控件 ├── GreenhouseMonitor.Core/ # 业务逻辑、数据模型 │ ├── Models/ │ │ ├── SensorData.cs # 传感器数据结构 │ │ └── DeviceState.cs # 设备状态 │ ├── Services/ │ │ ├── SerialService.cs # 串口采集 │ │ ├── TcpService.cs # 网络采集 │ │ └── AlarmService.cs # 报警判定 │ └── Config/ │ └── AppConfig.cs # 配置读写 ├── GreenhouseMonitor.Data/ # 数据访问层 │ ├── DbHelper.cs # SQLite 封装 │ └── Repository.cs # 增删查 └── GreenhouseMonitor.Utils/ # 工具类 ├── Crc16.cs # 校验 └── Logger.cs # 日志

这个分层不是教科书式的炫技,而是有实际好处的:UI 层只负责显示和事件响应,Core 层管采集和判定,Data 层管落库。现场如果换通信协议,只动 Core 里的 Service;如果换数据库,只动 Data 层。我见过太多上位机项目把所有逻辑塞进 Form1.cs,三千行代码一个文件,改个报警阈值要翻半天,这套源码至少把边界划清楚了。

2.2 通信层:串口和 TCP 两条腿走路

温室现场的设备连接方式很杂:老一点的温湿度传感器走 RS485 转串口,新一点的网关直接走网口 Modbus TCP,还有些客户用 LoRa 网关再转串口。源码里 SerialService 和 TcpService 都实现了同一个采集接口,主界面启动时根据配置文件决定用哪条通道。这种「接口 + 多实现」的写法值得保留,后面加 MQTT 或者 HTTP 轮询也只需要再写一个实现类。

串口部分用的是 System.IO.Ports.SerialPort,这是 .NET Framework 自带库,不用额外装包。关键参数在配置文件里:波特率 9600、数据位 8、停止位 1、无校验,这是大多数 Modbus RTU 传感器的默认值。网络部分用 TcpClient 长连接,超时重连逻辑写在 Service 内部,UI 层不感知断线细节。

2.3 数据层:SQLite 轻量落库

历史数据用 SQLite 存,文件就放在程序目录下的 data 文件夹里。选 SQLite 而不是 SQL Server 或 MySQL,理由很实在:工控机不一定有数据库服务,装个 SQL Server Express 又重又占资源,SQLite 一个 dll 搞定,备份就是复制文件,现场运维人员培训五分钟就能学会。源码里 DbHelper 封装了连接管理和参数化查询,Repository 里按表做了增删查,表结构大致是 sensor_data(时间、温度、湿度、光照、CO₂)和 device_log(时间、设备名、动作、结果)两张。

3. 跑起来:从零编译到看到第一条曲线

3.1 环境准备和编译步骤

先确认开发机装的是 Visual Studio 2019 或 2022,安装时勾选「.NET 桌面开发」工作负载。源码是 .NET Framework 4.7.2 目标,如果本机没装这个运行时,VS 会提示你装,跟着点就行。打开 sln 后先还原 NuGet 包,源码依赖不多,主要是 System.Data.SQLite.Core 和 Newtonsoft.Json,前者管数据库,后者管配置文件序列化。

编译前检查两个地方:一是项目属性里的输出路径,默认是 bin\Debug,正式部署建议改成 bin\Release;二是 app.config 里的连接字符串和串口配置,源码默认串口是 COM3,如果你的机器没有 COM3,程序启动时串口打开会抛异常,但不会崩,会在日志里记一条然后继续跑,这点做得比较稳。

# 用 MSBuild 命令行编译,适合没有装完整 VS 的构建机 msbuild GreenhouseMonitor.sln /p:Configuration=Release /p:Platform="Any CPU"

编译成功后,Release 目录下会生成 GreenhouseMonitor.UI.exe,双击就能跑。第一次启动如果没有接真实传感器,界面上的数值会是 0 或者显示「--」,这是正常的,因为采集线程在等串口数据。想先看界面效果,可以在设置里打开「模拟模式」,源码里留了一个 SimulateService,按正弦波生成假数据,方便调界面和曲线。

3.2 配置文件的字段含义

app.config 里几个关键配置项,改之前先搞清楚:

配置项默认值说明
CommTypeSerial采集通道,Serial 或 Tcp
PortNameCOM3串口号,设备管理器里看
BaudRate9600波特率,必须和传感器一致
TcpHost192.168.1.100网关 IP
TcpPort502Modbus TCP 默认端口
SampleInterval2000采集间隔,单位毫秒
TempHigh35温度上限报警阈值
HumiLow40湿度下限报警阈值
DbPathdata\monitor.db数据库文件相对路径

这些值在 SettingForm 里也能改,改完写回配置文件,不用手动编辑 XML。注意 SampleInterval 不要设得太小,串口 9600 波特率下,一次 Modbus 读多个寄存器大概要几十毫秒,设成 200 毫秒以下容易出现读超时,现场我一般设 2000 到 5000 毫秒,温室环境变化慢,没必要高频采集。

3.3 第一次运行的验证路径

跑起来之后按这个顺序验证:先看主界面左上角的状态灯,绿色表示采集线程正常,红色表示串口没打开或网络没连上;再看实时曲线有没有数据点进来,模拟模式下应该是一条正弦波;然后手动改一下温度阈值,把上限调到当前温度以下,看报警灯和声音有没有触发;最后点历史查询,选今天的时间段,看有没有记录。这四步走完,说明采集、判定、落库、查询四条链路都通了,可以接真实设备。

4. 核心代码拆解:采集线程、报警判定和曲线绘制

4.1 采集线程怎么做到不卡界面

WinForms 的 UI 线程很娇气,任何耗时操作放上去都会让界面假死。源码里采集跑在独立线程上,采到的数据通过事件回传给 UI,UI 层再用 Invoke 切回主线程更新控件。这个模式是上位机的标准做法,但细节上容易翻车,看这段:

// SerialService.cs 采集主循环(简化后) public class SerialService : ISensorService { private SerialPort _port; private Thread _worker; private volatile bool _running; public event Action<SensorData> DataReceived; public void Start() { _port = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One); _port.ReadTimeout = 1000; _port.Open(); _running = true; _worker = new Thread(CollectLoop) { IsBackground = true }; _worker.Start(); } private void CollectLoop() { while (_running) { try { var frame = ReadModbusFrame(); // 发指令、读响应、校验 var data = ParseFrame(frame); // 按协议解析成 SensorData DataReceived?.Invoke(data); // 事件回传,不直接碰 UI } catch (TimeoutException) { Logger.Warn("采集超时,等待下一轮"); } Thread.Sleep(2000); // 采集间隔 } } }

这段代码有三个点值得说。第一,_running用 volatile 修饰,保证停止信号能被工作线程及时看到,不然点关闭按钮时线程还在睡,程序退不干净。第二,异常只捕获 TimeoutException 并记日志,不让单次超时把整个线程干掉,现场传感器偶尔丢包是常态,线程必须能自愈。第三,DataReceived 事件里不直接操作控件,UI 层订阅后自己 Invoke,职责分开。我见过把label1.Text = ...直接写进采集线程的代码,调试时偶尔报跨线程异常,release 下又时好时坏,这种玄学问题查起来很费劲。

4.2 报警判定:别把阈值比较写死在 UI 里

报警逻辑放在 AlarmService 里,输入是 SensorData 和当前配置,输出是报警列表。核心就是阈值比较,但工程上要考虑三件事:防抖、分级、恢复。防抖是指温度在阈值附近抖动时不要疯狂报警,源码里用了一个简单的计数器,连续三次超限才触发;分级是分预警和严重两级,预警只亮黄灯,严重才响声音;恢复是指数值回到正常范围后要自动解除报警状态,不能一直挂着。

// AlarmService.cs 核心判定 public List<AlarmItem> Check(SensorData data, AppConfig cfg) { var result = new List<AlarmItem>(); if (data.Temperature > cfg.TempHigh) { _tempOverCount++; if (_tempOverCount >= 3) // 连续三次才报,防抖 { result.Add(new AlarmItem { Level = data.Temperature > cfg.TempHigh + 5 ? AlarmLevel.Critical : AlarmLevel.Warning, Message = $"温度超上限:{data.Temperature:F1}℃" }); } } else { _tempOverCount = 0; // 恢复正常,计数清零 } // 湿度、光照、CO₂ 同理 return result; }

参数上,cfg.TempHigh + 5这个 5 是严重级别的缓冲带,意思是超过上限 5 度才算严重,这个值我建议做成配置项而不是硬编码,不同作物耐受度不一样,育苗和成株的阈值差很多。另外_tempOverCount是实例字段,注意 AlarmService 要单例使用,如果每次 new 一个,计数器永远归零,防抖就失效了。

4.3 实时曲线:自绘控件比 Chart 控件更可控

源码没用 WinForms 自带的 Chart 控件,而是自己写了一个 RealtimeChart 继承 Control,重写 OnPaint 用 GDI+ 画。原因有两个:Chart 控件在数据点多了之后刷新慢,而且样式定制麻烦;自绘可以精确控制缓冲区长度、坐标轴范围和刷新频率。核心思路是维护一个固定长度的队列,新数据进、旧数据出,OnPaint 里遍历队列画折线。

// RealtimeChart.cs 绘制逻辑(简化) protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 画坐标轴和网格 DrawAxis(g); // 画折线,_points 是固定容量队列 if (_points.Count > 1) { var pen = new Pen(_lineColor, 2); for (int i = 1; i < _points.Count; i++) { var p1 = MapToScreen(i - 1, _points[i - 1]); var p2 = MapToScreen(i, _points[i]); g.DrawLine(pen, p1, p2); } } }

MapToScreen负责把数据值映射到控件坐标,这里要注意 Y 轴是反的,屏幕坐标向下增大,所以映射时要取反。缓冲区长度我一般设 300 个点,按 2 秒采集一次算,正好显示最近 10 分钟,太长没必要,太短看不出趋势。刷新用 Timer 每 500 毫秒调一次 Invalidate,不要每来一个数据就刷,那样 CPU 占用会上去。

5. 避坑与排查:现场最容易翻车的五个地方

5.1 串口被占用,程序启动就报错

现象是双击 exe 后弹窗「拒绝访问 COM3」,或者日志里一直记串口打开失败。原因通常是上一个进程没退干净,或者串口调试助手还开着占着端口。解决方法是先在任务管理器里结束残留的 GreenhouseMonitor.UI 进程,再检查有没有其他串口工具在跑。代码层面可以在打开串口前加一个重试机制,失败后等 500 毫秒再试三次,还不行再提示用户,比直接抛异常友好。

5.2 曲线画出来是锯齿或者断线

现象是实时曲线一卡一卡的,或者中间有断点。原因多半是采集线程和 UI 刷新节奏没对齐,数据来得比刷新快,队列被覆盖;或者跨线程更新控件时丢了数据。解决办法是把数据入队和绘制分开,采集线程只负责往线程安全队列里塞,UI 的 Timer 定时从队列取一批再画,取的时候加锁。另外检查_points队列的容量,太小会导致刚画上就被挤掉。

5.3 历史查询查不到当天的数据

现象是当天采集正常,但历史查询选今天却空着。原因是 SQLite 写入用了事务但没提交,或者查询条件的时间格式和存储格式不一致。源码里时间存的是yyyy-MM-dd HH:mm:ss字符串,查询时如果传的是 DateTime 直接拼 SQL,格式会变成带毫秒的,匹配不上。解决方法是查询参数统一用ToString("yyyy-MM-dd HH:mm:ss"),并且确认写入时调用了transaction.Commit()。这个坑我踩过,数据其实在库里,就是查不出来,白排查半天。

5.4 报警声音一直响关不掉

现象是温度恢复后报警灯灭了,但声音还在循环播放。原因是播放声音用了异步方式,停止时没有正确释放音频资源,或者报警状态清除了但播放线程还在跑。解决方法是把声音播放封装成一个类,内部维护播放状态,解除报警时先停播放再清标志,并且用 try-finally 保证资源释放。现场如果用的是工控机小喇叭,还要注意音量别设太大,半夜报警吓到值班人员。

5.5 部署到工控机后提示缺少 dll

现象是开发机上跑得好好的,拷到工控机上双击报「无法加载 System.Data.SQLite」。原因是 SQLite 的 native dll 分 x86 和 x64,工控机是 32 位系统但编译用的是 Any CPU 加 64 位依赖。解决办法是把项目的平台目标明确设成 x86 或 x64,和工控机系统一致,然后把对应的 native dll 一起拷过去。我一般会在发布目录里放一个 readme,写清楚依赖哪些文件,省得现场人员漏拷。

6. 二次开发:把通用骨架改成你自己的温室系统

这套源码的价值不在功能多全,而在骨架干净,改起来不费劲。我拿它做过两个变体,一个是草莓大棚,一个是组培室,改动量都不大,分享几个实操思路。

第一个改造点是传感器协议。源码默认按 Modbus RTU 解析,功能码 03 读保持寄存器,寄存器地址和数据类型写死在 ParseFrame 里。如果你的传感器是自定义协议,比如 ASCII 逗号分隔,只需要重写 ParseFrame,把字节流转成 SensorData 就行,采集线程和 UI 都不用动。改之前先用串口助手抓一帧原始数据,确认帧头、长度、校验方式,别对着手册硬猜。

第二个改造点是报警策略。默认是固定阈值,实际种植中不同生长阶段阈值不同,我加了一个「生长阶段」配置,每个阶段一套阈值,界面上加个下拉框切换。改动涉及 AppConfig 加一个字典结构,AlarmService 里按当前阶段取阈值,SettingForm 加几个输入框,半天工作量。

第三个改造点是数据上报。很多客户要求数据同步到云平台,我在 Core 层加了一个 UploadService,定时把最新数据 POST 到指定接口,失败重试三次后落本地队列,下次启动补传。这里注意别在采集线程里直接发 HTTP,网络慢会拖累采集节奏,单独开线程或者用队列解耦。

改造方向改动文件工作量估计
换传感器协议SerialService.ParseFrame半天
多阶段阈值AppConfig、AlarmService、SettingForm一天
云端上报新增 UploadService、AppConfig一天
换数据库DbHelper、Repository半天

验证改造是否成功,我习惯用「模拟数据 + 真实设备」两轮跑:先用 SimulateService 灌一批边界值,看报警和曲线对不对;再接真实传感器跑一整天,第二天查历史数据完整性。最后提醒一句,改完一定要在工控机上实测,开发机的环境和现场差很多,串口驱动、系统位数、屏幕分辨率都可能出问题。从那以后我每次交付前都强制在目标机器上跑满 24 小时,确认没有内存泄漏和线程堆积才敢签字。希望这套骨架能帮你少走点弯路。

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