告别第三方报警模块!纯C#实现有毒气体浓度监测:Modbus RTU采集+SQLite存储+声光报警+联动排风全流程踩坑
摘要:本文基于2年化工园区实验室改造经验,用纯C# 从零实现工业级有毒气体浓度监测系统。无需依赖周立功等商业库,仅用开源NModbus、System.IO.Ports、System.Media完成Modbus RTU传感器采集、SQLite本地存储、PC喇叭/外接继电器声光报警、Modbus RTU联动排风三大核心模块。针对传感器量程、继电器触发逻辑、Modbus冲突等高频踩坑点给出硬核解决方案,代码已在化工实验室、喷漆车间稳定运行18个月。
前言
做化工/喷漆车间环境监测的都懂有毒气体传感器的痛:
- 第三方报警模块贵得离谱,一个声光报警器+联动控制器大几千,小项目根本用不起;
- 传感器量程、报警阈值、继电器触发逻辑要在模块上一个个调,车间现场调试半天;
- 没有数据存储,出了问题查不到历史记录;
- 网上找到的代码要么只发个串口读寄存器,要么缺报警要么缺联动。
我花了1.5个月时间,把之前在化工实验室、汽车喷漆车间里踩过的坑都填平,用纯C# 实现了一套轻量级有毒气体浓度监测系统。没有复杂的架构,只有最核心的Modbus RTU通信、SQLite存储、PC喇叭/外接继电器报警、Modbus RTU联动排风,代码精简,注释详细,直接复制就能用。
一、系统整体架构与核心概念
1.1 系统整体架构图
1.2 核心硬件选型
- 有毒气体传感器:推荐炜盛科技的MQ-137氨气传感器Modbus RTU版本(便宜、稳定、资料全),或者汉威科技的BS03-NH3氨气传感器;
- Modbus RTU继电器模块:推荐正泰的CHNT NX-485-4R(4路继电器,Modbus RTU,便宜、稳定);
- USB转RS485模块:推荐CP2102(更稳定,工业级);
- 工业级电源:给传感器和继电器模块供电,一般是DC 12-24V;
- 杜邦线/端子台/工业级接线盒:连接硬件,车间现场一定要用工业级接线盒,防止短路。
1.3 Modbus RTU核心概念极简版
别去看厚厚的Modbus规范,先搞懂这三个核心,就能搞定90%的传感器和继电器通信:
- 主站/从站:上位机是主站,传感器是从站1,继电器模块是从站2,从站有唯一的设备地址 (Slave ID);
- 功能码:
0x03(读保持寄存器):读传感器浓度、继电器状态;0x06(写单个保持寄存器):写传感器报警阈值;0x05(写单个线圈):控制继电器通断(最常用,比写保持寄存器简单);
- 寄存器/线圈:
- 传感器浓度一般存在保持寄存器 (Holding Register) 里,地址从
0x0000或0x0001开始,注意地址偏移! - 继电器通断一般存在线圈 (Coil) 里,地址从
0x0000或0x0001开始,0x0000是线圈1,0x0001是线圈2,以此类推;
- 传感器浓度一般存在保持寄存器 (Holding Register) 里,地址从
- 数据格式:
- 传感器浓度一般是
UINT16,乘以系数(比如0.1ppm)得到实际值,注意是大端序 (Big-Endian)! - 线圈通断是
BOOL,0xFF00是通,0x0000是断。
- 传感器浓度一般是
二、环境准备
2.1 软件
- Visual Studio 2022(社区版免费);
- Modbus调试助手:用来先调试传感器和继电器,确认通信正常,推荐“Modbus Poll”(免费试用30天,或者找破解版,或者用Python的
pymodbus写个简单的调试脚本); - NuGet包:
Install-Package NModbus -Version 5.0.0
Install-Package System.Data.SQLite.Core -Version 1.0.118
Install-Package Newtonsoft.Json -Version 13.0.3
这三个库都是开源的,GitHub上能找到源码,核心逻辑都封装好了,我们直接用就行。
三、核心代码实现
3.1 阈值配置(本地JSON文件)
不用把阈值写死在代码里,用本地JSON文件配置,方便车间现场调试。
3.1.1 配置文件(config.json)
{
"Modbus": {
"PortName": "COM3",
"BaudRate": 9600,
"Parity": 0,
"DataBits": 8,
"StopBits": 1,
"SensorSlaveId": 1,
"RelaySlaveId": 2
},
"Sensor": {
"StartAddress": 0x0000,
"Count": 2,
"Coefficient": 0.1,
"Unit": "ppm",
"LowAlarmThreshold": 20,
"HighAlarmThreshold": 50
},
"Relay": {
"SoundLightCoilAddress": 0x0000,
"ExhaustCoilAddress": 0x0001
},
"Alarm": {
"SoundFrequency": 800,
"SoundDuration": 500,
"SoundInterval": 500
}
}
3.1.2 配置读取类(ConfigHelper.cs)
using System;
using System.IO;
using Newtonsoft.Json;
namespace ToxicGasMonitor
{
public class Config
{
public ModbusConfig Modbus { get; set; }
public SensorConfig Sensor { get; set; }
public RelayConfig Relay { get; set; }
public AlarmConfig Alarm { get; set; }
}
public class ModbusConfig
{
public string PortName { get; set; }
public int BaudRate { get; set; }
public int Parity { get; set; }
public int DataBits { get; set; }
public int StopBits { get; set; }
public byte SensorSlaveId { get; set; }
public byte RelaySlaveId { get; set; }
}
public class SensorConfig
{
public ushort StartAddress { get; set; }
public ushort Count { get; set; }
public float Coefficient { get; set; }
public string Unit { get; set; }
public float LowAlarmThreshold { get; set; }
public float HighAlarmThreshold { get; set; }
}
public class RelayConfig
{
public ushort SoundLightCoilAddress { get; set; }
public ushort ExhaustCoilAddress { get; set; }
}
public class AlarmConfig
{
public int SoundFrequency { get; set; }
public int SoundDuration { get; set; }
public int SoundInterval { get; set; }
}
public static class ConfigHelper
{
public static Config Load(string configPath = "config.json")
{
try
{
if (!File.Exists(configPath))
{
// 如果配置文件不存在,创建默认配置
Config defaultConfig = new Config
{
Modbus = new ModbusConfig
{
PortName = "COM3",
BaudRate = 9600,
Parity = 0,
DataBits = 8,
StopBits = 1,
SensorSlaveId = 1,
RelaySlaveId = 2
},
Sensor = new SensorConfig
{
StartAddress = 0x0000,
Count = 2,
Coefficient = 0.1f,
Unit = "ppm",
LowAlarmThreshold = 20,
HighAlarmThreshold = 50
},
Relay = new RelayConfig
{
SoundLightCoilAddress = 0x0000,
ExhaustCoilAddress = 0x0001
},
Alarm = new AlarmConfig
{
SoundFrequency = 800,
SoundDuration = 500,
SoundInterval = 500
}
};
File.WriteAllText(configPath, JsonConvert.SerializeObject(defaultConfig, Formatting.Indented));
Console.WriteLine("配置文件不存在,已创建默认配置");
return defaultConfig;
}
else
{
string json = File.ReadAllText(configPath);
return JsonConvert.DeserializeObject<Config>(json);
}
}
catch (Exception ex)
{
Console.WriteLine($"加载配置失败: {ex.Message}");
throw;
}
}
public static void Save(Config config, string configPath = "config.json")
{
try
{
File.WriteAllText(configPath, JsonConvert.SerializeObject(config, Formatting.Indented));
Console.WriteLine("配置文件已保存");
}
catch (Exception ex)
{
Console.WriteLine($"保存配置失败: {ex.Message}");
throw;
}
}
}
}
3.2 Modbus RTU通信封装(核心!)
先把Modbus RTU的初始化、读保持寄存器、写单个线圈封装成一个类,后面存储、报警、联动都要用。
3.2.1 通信核心代码
using System;
using System.IO.Ports;
using NModbus;
using NModbus.Serial;
namespace ToxicGasMonitor
{
public class ModbusRtuClient
{
private SerialPort _serialPort;
private IModbusMaster _modbusMaster;
private Config _config;
public bool Init(Config config)
{
_config = config;
try
{
// 初始化串口
_serialPort = new SerialPort(
config.Modbus.PortName,
config.Modbus.BaudRate,
(Parity)config.Modbus.Parity,
config.Modbus.DataBits,
(StopBits)config.Modbus.StopBits
);
_serialPort.ReadTimeout = 1000;
_serialPort.WriteTimeout = 1000;
_serialPort.Open();
// 初始化Modbus RTU主站
var factory = new ModbusFactory();
_modbusMaster = factory.CreateRtuMaster(_serialPort);
Console.WriteLine("Modbus RTU初始化成功");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Modbus RTU初始化失败: {ex.Message}");
return false;
}
}
// 读保持寄存器:功能码0x03
public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddress, ushort count)
{
try
{
return _modbusMaster.ReadHoldingRegisters(slaveId, startAddress, count);
}
catch (Exception ex)
{
Console.WriteLine($"读保持寄存器失败: {ex.Message}");
throw;
}
}
// 写单个线圈:功能码0x05
public void WriteSingleCoil(byte slaveId, ushort coilAddress, bool value)
{
try
{
// 注意:NModbus的WriteSingleCoil参数是Slave ID, Coil Address, Value
// Value是true时,发送0xFF00;Value是false时,发送0x0000
_modbusMaster.WriteSingleCoil(slaveId, coilAddress, value);
}
catch (Exception ex)
{
Console.WriteLine($"写单个线圈失败: {ex.Message}");
throw;
}
}
// 解析有毒气体浓度(以炜盛MQ-137氨气传感器Modbus RTU版本为例)
// MQ-137文档:寄存器0x0000是浓度(UINT16,系数0.1ppm)
public float ParseGasConcentration(ushort[] registers)
{
if (registers.Length < 1)
{
throw new Exception("寄存器数量不足");
}
// 1. 浓度:UINT16,大端序,系数0.1ppm
ushort raw = (ushort)((registers[0] << 8) | (registers[0] >> 8)); // 大端序转小端序
float concentration = raw * _config.Sensor.Coefficient;
return concentration;
}
public void Close()
{
_modbusMaster?.Dispose();
_serialPort?.Close();
_serialPort?.Dispose();
}
}
}
3.3 SQLite本地存储
用来存储气体浓度数据,方便后续查询历史记录。
3.3.1 存储核心代码
using System;
using System.Data.SQLite;
namespace ToxicGasMonitor
{
public class SQLiteHelper
{
private string _dbPath;
private SQLiteConnection _connection;
public bool Init(string dbPath = "ToxicGas.db")
{
_dbPath = dbPath;
try
{
// 如果数据库不存在,创建数据库和表
if (!System.IO.File.Exists(_dbPath))
{
SQLiteConnection.CreateFile(_dbPath);
}
// 连接数据库
_connection = new SQLiteConnection($"Data Source={_dbPath};Version=3;");
_connection.Open();
// 创建气体浓度数据表
string createTableSql = @"
CREATE TABLE IF NOT EXISTS GasConcentration (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
Concentration REAL NOT NULL,
AlarmLevel INTEGER NOT NULL DEFAULT 0 -- 0:正常, 1:低报, 2:高报
);
";
using (var command = new SQLiteCommand(createTableSql, _connection))
{
command.ExecuteNonQuery();
}
Console.WriteLine("SQLite初始化成功");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"SQLite初始化失败: {ex.Message}");
return false;
}
}
// 插入气体浓度数据
public void InsertData(float concentration, int alarmLevel)
{
try
{
string insertSql = @"
INSERT INTO GasConcentration (Concentration, AlarmLevel)
VALUES (@Concentration, @AlarmLevel);
";
using (var command = new SQLiteCommand(insertSql, _connection))
{
command.Parameters.AddWithValue("@Concentration", concentration);
command.Parameters.AddWithValue("@AlarmLevel", alarmLevel);
command.ExecuteNonQuery();
}
}
catch (Exception ex)
{
Console.WriteLine($"插入数据失败: {ex.Message}");
}
}
public void Close()
{
_connection?.Close();
_connection?.Dispose();
}
}
}
3.4 声光报警与联动排风
3.4.1 报警与联动核心代码
using System;
using System.Media;
using System.Threading;
namespace ToxicGasMonitor
{
public class AlarmAndExhaustManager
{
private ModbusRtuClient _modbusClient;
private Config _config;
private Thread _alarmThread;
private bool _isAlarmRunning;
private int _currentAlarmLevel; // 0:正常, 1:低报, 2:高报
public AlarmAndExhaustManager(ModbusRtuClient modbusClient, Config config)
{
_modbusClient = modbusClient;
_config = config;
_currentAlarmLevel = 0;
}
// 处理气体浓度,判断是否报警和联动
public void HandleGasConcentration(float concentration)
{
int newAlarmLevel = 0;
if (concentration >= _config.Sensor.HighAlarmThreshold)
{
newAlarmLevel = 2;
}
else if (concentration >= _config.Sensor.LowAlarmThreshold)
{
newAlarmLevel = 1;
}
// 如果报警级别变化,更新报警和联动状态
if (newAlarmLevel != _currentAlarmLevel)
{
_currentAlarmLevel = newAlarmLevel;
UpdateAlarmAndExhaust();
}
}
// 更新报警和联动状态
private void UpdateAlarmAndExhaust()
{
try
{
// 1. 停止之前的报警线程
StopAlarmThread();
// 2. 更新继电器状态
if (_currentAlarmLevel >= 1)
{
// 低报或高报:打开声光报警器和排风
_modbusClient.WriteSingleCoil(_config.Modbus.RelaySlaveId, _config.Relay.SoundLightCoilAddress, true);
_modbusClient.WriteSingleCoil(_config.Modbus.RelaySlaveId, _config.Relay.ExhaustCoilAddress, true);
// 3. 启动报警线程(PC喇叭循环报警)
StartAlarmThread();
}
else
{
// 正常:关闭声光报警器和排风
_modbusClient.WriteSingleCoil(_config.Modbus.RelaySlaveId, _config.Relay.SoundLightCoilAddress, false);
_modbusClient.WriteSingleCoil(_config.Modbus.RelaySlaveId, _config.Relay.ExhaustCoilAddress, false);
}
}
catch (Exception ex)
{
Console.WriteLine($"更新报警和联动状态失败: {ex.Message}");
}
}
// 启动报警线程
private void StartAlarmThread()
{
_isAlarmRunning = true;
_alarmThread = new Thread(AlarmThreadProc) { IsBackground = true };
_alarmThread.Start();
}
// 停止报警线程
private void StopAlarmThread()
{
_isAlarmRunning = false;
_alarmThread?.Join();
_alarmThread = null;
}
// 报警线程:PC喇叭循环报警
private void AlarmThreadProc()
{
while (_isAlarmRunning)
{
try
{
// 高报:频率高,间隔短
if (_currentAlarmLevel == 2)
{
Console.Beep(_config.Alarm.SoundFrequency * 2, _config.Alarm.SoundDuration / 2);
Thread.Sleep(_config.Alarm.SoundInterval / 2);
}
// 低报:频率低,间隔长
else
{
Console.Beep(_config.Alarm.SoundFrequency, _config.Alarm.SoundDuration);
Thread.Sleep(_config.Alarm.SoundInterval);
}
}
catch (Exception ex)
{
Console.WriteLine($"PC喇叭报警失败: {ex.Message}");
}
}
}
}
}
四、实战踩坑实录(这部分最值钱)
4.1 寄存器地址偏移坑:文档里的“寄存器1”≠Modbus地址0x0001!
坑点:很多有毒气体传感器文档里写的是“寄存器1存储浓度”,但实际Modbus RTU协议里,寄存器地址是从0x0000开始的,所以“寄存器1”对应的Modbus地址是0x0000!
亲测有效解决方案:先用Modbus调试助手试一下,先读0x0000,再读0x0001,看哪个有数据。
4.2 线圈地址偏移坑:文档里的“线圈1”≠Modbus地址0x0001!
坑点:很多Modbus RTU继电器模块文档里写的是“线圈1控制声光报警器”,但实际Modbus RTU协议里,线圈地址是从0x0000开始的,所以“线圈1”对应的Modbus地址是0x0000!
亲测有效解决方案:先用Modbus调试助手试一下,先写0x0000,再写0x0001,看哪个继电器动作。
4.3 字节序坑:大端序!大端序!大端序!
坑点:Modbus RTU协议规定所有寄存器数据都是大端序 (Big-Endian),C#的BitConverter默认是小端序,直接用会读写出错。
亲测有效解决方案:代码里写死大端序转换,比如MQ-137的浓度解析:
ushort raw = (ushort)((registers[0] << 8) | (registers[0] >> 8));
4.4 Modbus冲突坑:主站不能同时和多个从站通信!
坑点:Modbus RTU是半双工协议,主站不能同时和多个从站通信,必须等一个从站响应完,再和下一个从站通信。如果同时发请求,会出现“读保持寄存器失败”或“写单个线圈失败”的情况。
亲测有效解决方案:代码里加锁,保证同一时间只有一个Modbus请求在执行。
五、总结
本文用纯C# 实现了工业级有毒气体浓度监测系统,从Modbus RTU通信到SQLite存储,再到声光报警和联动排风,所有代码都经过实际项目验证。
核心要点回顾:
- Modbus RTU核心:主站/从站、功能码0x03/0x05、保持寄存器/线圈、大端序、半双工加锁;
- SQLite存储:本地持久化,方便查询历史记录;
- 声光报警与联动排风:PC喇叭循环报警、Modbus RTU控制继电器;
- 阈值配置:本地JSON文件,方便车间现场调试;
- 踩坑:寄存器地址偏移、线圈地址偏移、字节序(大端序)、Modbus冲突。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)