摘要:本文基于2年化工园区实验室改造经验,用纯C# 从零实现工业级有毒气体浓度监测系统。无需依赖周立功等商业库,仅用开源NModbusSystem.IO.PortsSystem.Media完成Modbus RTU传感器采集、SQLite本地存储、PC喇叭/外接继电器声光报警、Modbus RTU联动排风三大核心模块。针对传感器量程、继电器触发逻辑、Modbus冲突等高频踩坑点给出硬核解决方案,代码已在化工实验室、喷漆车间稳定运行18个月。


前言

做化工/喷漆车间环境监测的都懂有毒气体传感器的痛:

  • 第三方报警模块贵得离谱,一个声光报警器+联动控制器大几千,小项目根本用不起;
  • 传感器量程、报警阈值、继电器触发逻辑要在模块上一个个调,车间现场调试半天;
  • 没有数据存储,出了问题查不到历史记录;
  • 网上找到的代码要么只发个串口读寄存器,要么缺报警要么缺联动。

我花了1.5个月时间,把之前在化工实验室、汽车喷漆车间里踩过的坑都填平,用纯C# 实现了一套轻量级有毒气体浓度监测系统。没有复杂的架构,只有最核心的Modbus RTU通信、SQLite存储、PC喇叭/外接继电器报警、Modbus RTU联动排风,代码精简,注释详细,直接复制就能用。


一、系统整体架构与核心概念

1.1 系统整体架构图

有毒气体传感器 (Modbus RTU从站1)

USB转RS485模块 (CH340G/CP2102)

Modbus RTU继电器模块 (从站2)

C#上位机 (Modbus RTU主站)

Modbus RTU通信 (NModbus库)

SQLite本地存储 (数据持久化)

声光报警 (PC喇叭/外接继电器)

联动排风 (Modbus RTU控制继电器)

阈值配置 (本地JSON文件)

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) 里,地址从0x00000x0001开始,注意地址偏移
    • 继电器通断一般存在线圈 (Coil) 里,地址从0x00000x0001开始,0x0000是线圈1,0x0001是线圈2,以此类推;
  • 数据格式
    • 传感器浓度一般是UINT16,乘以系数(比如0.1ppm)得到实际值,注意是大端序 (Big-Endian)
    • 线圈通断是BOOL0xFF00是通,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存储,再到声光报警和联动排风,所有代码都经过实际项目验证。

核心要点回顾:

  1. Modbus RTU核心:主站/从站、功能码0x03/0x05、保持寄存器/线圈、大端序、半双工加锁;
  2. SQLite存储:本地持久化,方便查询历史记录;
  3. 声光报警与联动排风:PC喇叭循环报警、Modbus RTU控制继电器;
  4. 阈值配置:本地JSON文件,方便车间现场调试;
  5. 踩坑:寄存器地址偏移、线圈地址偏移、字节序(大端序)、Modbus冲突。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐