土壤湿度传感器上位机系统
一、项目概述
本项目是一个基于C# WinForms开发的土壤湿度传感器数据采集上位机系统。系统通过串口(SerialPort)与土壤湿度传感器进行通信,采用Modbus RTU协议读取传感器保持寄存器中的数据,并将解析后的电导率等数值实时显示在界面上。
项目实现了 Modbus RTU 协议的核心功能,包括请求帧构造、CRC16 冗余校验、响应帧解析以及高低位数据合并,是学习工业串口通信与 Modbus 协议的典型实践案例。
二、系统架构
系统采用三层架构:PC 上位机 → 串口/RS485 转换器 → 土壤湿度传感器。上位机通过System.IO.Ports.SerialPort类操作串口,按照 Modbus RTU 协议格式构造 8 字节请求帧发送给传感器;传感器收到后返回 7 字节响应帧,上位机解析响应帧中的寄存器数据并显示。
通信参数:
| 参数 | 值 | 说明 |
|---|---|---|
| 端口 | COM3 | 串口名称(可在 App.config 中配置) |
| 波特率 | 9600 | 每秒传输位数 |
| 数据位 | 8 | 每个字节的数据位数 |
| 校验位 | None | 无校验 |
| 停止位 | 1 | 一个停止位 |
| 从站地址 | 1 | 传感器的 Modbus 设备地址 |
| 功能码 | 0x03 | 读保持寄存器 |
三、开发环境
| 项目 | 说明 |
|---|---|
| 开发语言 | C# |
| 框架 | .NET Framework 4.7.2 |
| 界面框架 | Windows Forms (WinForms) |
| 开发工具 | Visual Studio 2022 |
| 项目类型 | Windows 桌面应用程序(WinExe) |
| 外部依赖 | System.Configuration.ConfigurationManager 10.0.12 |
四、项目结构
土壤湿度传感器/ ├── 土壤湿度传感器.sln # 解决方案文件 ├── packages/ # NuGet 包目录 │ └── System.Configuration.ConfigurationManager.10.0.12/ └── 土壤湿度传感器/ # 项目主目录 ├── App.config # 应用配置文件(串口参数) ├── packages.config # NuGet 包引用配置 ├── Program.cs # 程序入口点 ├── Form1.cs # 主窗体逻辑(串口通信、协议解析) ├── Form1.Designer.cs # 窗体设计器自动生成代码 ├── Form1.resx # 窗体资源文件 ├── Properties/ │ ├── AssemblyInfo.cs # 程序集信息 │ ├── Resources.resx # 资源文件 │ ├── Settings.settings # 设置文件 │ └── ... └── bin/Debug/ # 编译输出目录 └── 土壤湿度传感器.exe # 可执行文件核心文件说明:
- Program.cs:应用程序主入口,调用
Application.Run(new Form1())启动主窗体。 - Form1.cs:核心业务逻辑,包含串口初始化、Modbus 请求帧构造、CRC16 校验、数据接收与解析。
- Form1.Designer.cs:界面布局定义,由设计器自动生成。
- App.config:存储串口配置参数,支持运行时修改无需重新编译。
五、核心功能实现
5.1 串口初始化与配置
程序启动时在Form1_Load事件中完成串口初始化。串口参数从App.config的appSettings节点读取,实现了配置与代码分离。
privatevoidForm1_Load(objectsender,EventArgse){_serialPort.PortName="COM1";_serialPort.BaudRate=9600;MessageBox.Show(ConfigurationManager.AppSettings["PortName"]);_serialPort.DataBits=int.Parse(ConfigurationManager.AppSettings["DataBits"]);_serialPort.Parity=(Parity)Enum.Parse(typeof(Parity),ConfigurationManager.AppSettings["Parity"]);_serialPort.StopBits=(StopBits)Enum.Parse(typeof(StopBits),ConfigurationManager.AppSettings["StopBits"]);try{_serialPort.Open();}catch(Exceptionex){MessageBox.Show($"{ex.Message}");}}注意:代码中
PortName被硬编码为"COM1",而App.config中配置的是COM3。实际使用时应将硬编码改为从配置读取:_serialPort.PortName = ConfigurationManager.AppSettings["PortName"];
5.2 Modbus RTU 协议帧构造
点击"读取"按钮后,系统调用Read方法向传感器发送读保持寄存器请求。请求帧共 8 字节,由ReadingData方法构造。
请求帧格式(8 字节):
| 字节序号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 含义 | 从站地址 | 功能码 | 起始地址高位 | 起始地址低位 | 寄存器数量高位 | 寄存器数量低位 | CRC低字节 | CRC高字节 |
| 示例值 | 0x01 | 0x03 | 0x01 | 0xF4 | 0x00 | 0x01 | CRC_L | CRC_H |
响应帧格式(7 字节):
| 字节序号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|
| 含义 | 从站地址 | 功能码 | 有效字节数 | 数据高位 | 数据低位 | CRC低字节 | CRC高字节 |
privatevoidbutton1_Click(objectsender,EventArgse){if(!_serialPort.IsOpen)return;Read(0x1F4,1);// 读取寄存器地址 0x01F4(十进制500),数量1个}privatevoidRead(ushortregisterAddress,ushortnum){byte[]_data=ReadingData(1,3,registerAddress,num);_serialPort.Write(_data,0,_data.Length);}privatebyte[]ReadingData(byteslaveAddress,bytefunction,ushortregisterAddress,ushortnumber){byte[]data1=newbyte[6];data1[0]=slaveAddress;data1[1]=function;// 起始地址:高位在前,低位在后data1[2]=Convert.ToByte(registerAddress>>8&255);data1[3]=Convert.ToByte(registerAddress&255);// 读取数量:高位在前,低位在后data1[4]=Convert.ToByte(number>>8&255);data1[5]=Convert.ToByte(number&255);// 附加 CRC16 校验码byte[]crc=Crc16(data1);byte[]data=newbyte[8];Array.Copy(data1,0,data,0,6);Array.Copy(crc,0,data,6,2);returndata;}关键位运算说明:
registerAddress >> 8:将 16 位数据右移 8 位,取高 8 位& 255:按位与11111111,确保结果为 0~255 的单字节- 本项目读取的寄存器地址为
0x01F4(十进制 500)
5.3 CRC16 校验算法
Modbus RTU 协议使用 CRC16 循环冗余校验确保数据完整性。本项目实现了标准的 Modbus CRC16 算法,多项式为0xA001(即0x8005的位反转形式),初始值为0xFFFF。
privatebyte[]Crc16(byte[]data){ushortcrc16=0xFFFF;// 初始化for(inti=0;i<data.Length;i++){crc16^=(ushort)data[i];// 与当前字节异或for(intj=0;j<8;j++)// 每个字节处理8位{if((crc16&0x0001)!=0)// 检查最低位{crc16>>=1;// 右移一位crc16^=0xA001;// 异或多项式}else{crc16>>=1;// 仅右移,不异或}}}// 返回结果:低字节在前,高字节在后(Modbus 字节序)returnnewbyte[]{(byte)(crc16&0xFF),(byte)(crc16>>8),};}算法要点:
- 初始化 CRC 寄存器为
0xFFFF - 将每个数据字节与 CRC 寄存器低 8 位异或
- 对每个字节循环 8 次,每次检查最低位
- 最低位为 1 时:右移后异或
0xA001;最低位为 0 时:仅右移 - 最终结果按低字节在前、高字节在后的顺序附加到数据帧末尾
5.4 数据接收与解析
传感器返回响应帧后,触发DataReceived事件。事件处理方法在后台线程中运行,因此需要通过Invoke切换到 UI 线程更新控件。
privatevoid_serialPort_DataReceived(objectsender,SerialDataReceivedEventArgse){byte[]buffer=newbyte[_serialPort.BytesToRead];intnum=_serialPort.Read(buffer,0,buffer.Length);if(num<=0)return;bytefunc=buffer[1];// 功能码bytecount=buffer[2];// 有效字节数if(func==3)// 功能码03:读保持寄存器响应{Invoke(newAction(()=>{if(count==2)// 一个寄存器占2字节{// 高低位合并:高位 * 256 + 低位stringdataStr=((buffer[3]*256+buffer[4])).ToString();txtwendu.Text=dataStr;}}));}}高低位合并原理:
Modbus 协议中每个保持寄存器为 16 位,分高字节和低字节传输。以数值 300 为例:
- 二进制:
0000 0001 0010 1100 - 高字节
buffer[3]:0000 0001= 十进制 1 - 低字节
buffer[4]:0010 1100= 十进制 44 - 正确合并:
1 × 256 + 44 = 300 - 错误做法:直接相加
1 + 44 = 45
六、界面设计
主窗体Form1采用分组式布局,背景色为RosyBrown(玫瑰棕色),包含两个主要分组框。
6.1 窗体属性
| 属性 | 值 |
|---|---|
| 窗体大小 | 817 × 556 |
| 背景色 | RosyBrown |
| 文本 | Form1 |
6.2 控件布局
groupBox1 — 串口信息(左侧)
| 属性 | 值 |
|---|---|
| 位置 | (39, 35) |
| 大小 | 189 × 425 |
| 背景色 | Chocolate(巧克力色) |
| 文本 | 串口信息 |
该分组框当前为空容器,预留用于显示串口状态、端口列表等信息。
groupBox2 — 设备数据读取(右侧)
| 属性 | 值 |
|---|---|
| 位置 | (256, 35) |
| 大小 | 189 × 425 |
| 背景色 | Salmon(橙红色) |
| 文本 | 设备数据读取 |
groupBox2 内包含以下控件:
| 控件 | 名称 | 位置 | 说明 |
|---|---|---|---|
| Label | label1 | (6, 35) | 显示"PM2.5:" |
| TextBox | textBox1 | (68, 32) | PM2.5 数据显示框(预留) |
| Label | label2 | (6, 77) | 显示"电导率:" |
| TextBox | txtwendu | (68, 74) | 电导率数据显示框(实际接收传感器数据) |
| Button | button1 | (109, 164) | "读取"按钮,点击发送读取指令 |
| RadioButton | radioButton1 | (109, 204) | "实时"单选按钮(预留自动读取功能) |
说明:界面中虽然有 PM2.5 和电导率两个数据项,但当前代码仅实现了电导率(
txtwendu)的数据接收与显示。radioButton1(实时模式)和_timer定时器已声明但尚未实现自动轮询逻辑。
七、配置文件说明
App.config中定义了串口通信的全部参数,修改后无需重新编译即可生效。
<?xml version="1.0" encoding="utf-8" ?><configuration><startup><supportedRuntimeversion="v4.0"sku=".NETFramework,Version=v4.7.2"/></startup><appSettings><addkey="PortName"value="COM3"/><addkey="BaudRate"value="9600"/><addkey="DataBits"value="8"/><addkey="Parity"value="None"/><addkey="StopBits"value="One"/><addkey="SlaveId"value="1"/></appSettings></configuration>| 配置项 | 值 | 说明 |
|---|---|---|
| PortName | COM3 | 串口号,需根据实际连接修改 |
| BaudRate | 9600 | 波特率,需与传感器一致 |
| DataBits | 8 | 数据位 |
| Parity | None | 校验位(None/Even/Odd/Mark/Space) |
| StopBits | One | 停止位(One/OnePointFive/Two) |
| SlaveId | 1 | Modbus 从站地址 |
八、使用说明
8.1 硬件连接
- 将土壤湿度传感器通过 RS485 转 USB 模块连接到电脑
- 在设备管理器中确认串口号(如 COM3)
- 修改
App.config中的PortName为实际串口号 - 确认传感器的波特率、从站地址与配置一致
8.2 运行步骤
- 用 Visual Studio 打开
土壤湿度传感器.sln - 按
F5编译并运行,或直接执行bin/Debug/土壤湿度传感器.exe - 程序启动后自动打开串口(会弹出消息框显示配置的端口名)
- 点击"读取"按钮,向传感器发送 Modbus 请求
- 电导率数值将显示在对应文本框中
8.3 注意事项
- 确保串口未被其他程序占用
- 代码中
PortName硬编码为COM1,如需使用配置文件中的COM3,需修改Form1_Load中的代码 - 当前仅实现了单次读取,"实时"单选按钮和定时器功能尚未完成
txtwendu控件命名为"温度"拼音,但实际显示的是电导率数据- Modbus 寄存器地址
0x01F4(500)需根据传感器手册确认是否正确
九、可扩展方向
- 自动轮询:利用已声明的
_timer定时器,结合"实时"单选按钮实现数据自动刷新 - 多寄存器读取:扩展
Read方法支持读取多个连续寄存器,同时显示湿度、温度、电导率等多组数据 - 串口动态选择:在 groupBox1 中添加串口列表下拉框和打开/关闭按钮,支持运行时切换端口
- 数据校验:在接收端增加 CRC 校验验证,确保响应帧数据完整性
- 数据记录:添加数据日志功能,将读取的历史数据保存到文件或数据库
- 异常处理:完善串口断开、数据超时等异常场景的处理机制
十、完整核心代码(Form1.cs)
usingSystem;usingSystem.IO.Ports;usingSystem.Configuration;usingSystem.Windows.Forms;namespace土壤湿度传感器{publicpartialclassForm1:Form{SerialPort_serialPort=newSerialPort();System.Timers.Timer_timer=newSystem.Timers.Timer();publicForm1(){InitializeComponent();_timer.Interval=1000;_serialPort.DataReceived+=_serialPort_DataReceived;}// 数据接收事件:解析Modbus响应帧privatevoid_serialPort_DataReceived(objectsender,SerialDataReceivedEventArgse){byte[]buffer=newbyte[_serialPort.BytesToRead];intnum=_serialPort.Read(buffer,0,buffer.Length);if(num<=0)return;bytefunc=buffer[1];bytecount=buffer[2];if(func==3){Invoke(newAction(()=>{if(count==2){// 高低位合并:高位 * 256 + 低位stringdataStr=((buffer[3]*256+buffer[4])).ToString();txtwendu.Text=dataStr;}}));}}// 窗体加载:初始化串口privatevoidForm1_Load(objectsender,EventArgse){_serialPort.PortName="COM1";_serialPort.BaudRate=9600;_serialPort.DataBits=int.Parse(ConfigurationManager.AppSettings["DataBits"]);_serialPort.Parity=(Parity)Enum.Parse(typeof(Parity),ConfigurationManager.AppSettings["Parity"]);_serialPort.StopBits=(StopBits)Enum.Parse(typeof(StopBits),ConfigurationManager.AppSettings["StopBits"]);try{_serialPort.Open();}catch(Exceptionex){MessageBox.Show($"{ex.Message}");}}// 读取按钮点击事件privatevoidbutton1_Click(objectsender,EventArgse){if(!_serialPort.IsOpen)return;Read(0x1F4,1);}// 发送读保持寄存器请求privatevoidRead(ushortregisterAddress,ushortnum){byte[]_data=ReadingData(1,3,registerAddress,num);_serialPort.Write(_data,0,_data.Length);}// 构造Modbus RTU请求帧(8字节)privatebyte[]ReadingData(byteslaveAddress,bytefunction,ushortregisterAddress,ushortnumber){byte[]data1=newbyte[6];data1[0]=slaveAddress;data1[1]=function;data1[2]=Convert.ToByte(registerAddress>>8&255);data1[3]=Convert.ToByte(registerAddress&255);data1[4]=Convert.ToByte(number>>8&255);data1[5]=Convert.ToByte(number&255);byte[]crc=Crc16(data1);byte[]data=newbyte[8];Array.Copy(data1,0,data,0,6);Array.Copy(crc,0,data,6,2);returndata;}// Modbus CRC16校验算法privatebyte[]Crc16(byte[]data){ushortcrc16=0xFFFF;for(inti=0;i<data.Length;i++){crc16^=(ushort)data[i];for(intj=0;j<8;j++){if((crc16&0x0001)!=0){crc16>>=1;crc16^=0xA001;}else{crc16>>=1;}}}returnnewbyte[]{(byte)(crc16&0xFF),(byte)(crc16>>8)};}}}