本文将带你从零开始,使用 C# 和 NModbus 库实现一个完整的 Modbus RTU 工业通信系统。
第一章:项目背景与需求分析
1.1 为什么选择 Modbus?
Modbus 是工业领域应用最广泛的通信协议之一,由 Modicon(现 Schneider Electric)于 1979 年推出。
**核心优势:**
– 🔓 **完全开放**:免费使用,无需授权费
– 🔧 **简单可靠**:主站-从站架构,易于实现
– 🌐 **广泛支持**:几乎所有 PLC、传感器都支持
– 📡 **多种介质**:支持 RS-232、RS-485、TCP/IP
**应用场景:**
– 🏭 工业自动化生产线
– ⚡ 电力监控系统
– 🌡️ 温湿度传感器数据采集
– 🤖 机器人控制系统
1.2 Modbus RTU vs TCP
| 对比项 | Modbus RTU | Modbus TCP |
|——–|———–|————|
| **物理层** | RS-232 / RS-485 | 以太网 |
| **传输介质** | 串口线 | 网线 |
| **传输速度** | 最高 115200 bps | 100Mbps+ |
| **网络拓扑** | 总线型 | 星型 |
| **CRC 校验** | 需要 | 不需要 |
| **距离限制** | 1200 米(RS-485) | 100 米(单段) |
| **适用场景** | 短距离、强干扰 | 长距离、大数据量 |
1.3 本项目功能清单
**功能列表:**
1. ✅ 串口打开/关闭管理
2. ✅ 读取线圈状态(开关量输入)
3. ✅ 读取保持寄存器(模拟量)
4. ✅ 写入单个寄存器
5. ✅ 批量写入多个寄存器
6. ✅ 实时日志显示
7. ✅ 异常自动处理
1.4 开发环境
| 工具 | 版本 | 用途 |
|——|——|——|
| Rider | 2016 | IDE 开发环境 |
| .NET Framework | 4.8 | 运行时框架 |
| NModbus | 3.0.83 | Modbus 通信库 |
| Modbus Slave | 7.x | 调试模拟器 |
第二章:技术选型与环境准备
2.1 NModbus 库简介
[NModbus](https://github.com/NModbus/NModbus) 是一个纯托管的 .NET Modbus 实现,支持 RTU、TCP、UDP 等多种传输方式。
主要特性:
✨ 支持 .NET Framework 4.6+ / .NET 6+ / .NET Standard 1.3+
✨ 完整的 Modbus 功能码支持
✨ 异步 API 支持
✨ 线程安全设计
2.2 NuGet 包安装
在 Rider 中通过 NuGet 包管理器安装:
Install-Package NModbus
Install-Package NModbus.Serial
2.3 串口参数配置
// 9600:最常用,兼容性好
// 19200:工业标准
// 115200:高速传输
// 标准工业串口配置:9600-8-N-1
serialPort1 = new SerialPort(
portName: "COM3", // 串口号
baudRate: 9600, // 波特率
parity: Parity.None, // 校验位
dataBits: 8, // 数据位
stopBits: StopBits.One // 停止位
)
{
ReadTimeout = 1000, // 读超时 1 秒
WriteTimeout = 1000 // 写超时 1 秒
};
第三章:工程架构设计
3.1 关键字段说明
// 串口对象:负责物理层通信
private SerialPort serialPort1;
// Modbus 主站:负责协议层通信
private IModbusMaster _master;
// 线程锁:防止多线程并发访问串口
private readonly object _lockObj = new object();
第四章:代码

using System; // 系统基础命名空间
using System.Collections.Generic; // 泛型集合(List等)
using System.ComponentModel; // 组件模型
using System.Data; // 数据相关
using System.Drawing; // 绘图/颜色/字体
using System.IO.Ports; // 串口通信 SerialPort
using System.Linq; // LINQ查询
using System.Text; // 字符串/编码处理
using System.Threading.Tasks; // 异步任务 Task
using System.Windows.Forms; // WinForm窗体控件
using NModbus; // NModbus 核心库
using NModbus.Device;
using NModbus.Message; // NModbus 主站/从站设备
using NModbus.Serial; // NModbus 串口实现(RTU)
namespace WindowsFormsApp2 // 项目命名空间
{
public partial class Form2 : Form // Form2 窗体类
{
private SerialPort serialPort1; // 串口对象,用于485/232物理通信
private IModbusMaster _master; // Modbus RTU 主站对象,负责发指令
private readonly object _lockObj = new object(); // 锁对象,防止多线程并发读串口
// ============================================================
// 构造函数:初始化窗体、串口参数、文本框样式
// ============================================================
public Form2()
{
InitializeComponent(); // 设计器生成的控件初始化(button1、textBox1等)
// ———- 初始化串口参数(COM3, 9600, N, 8, 1)———-
serialPort1 = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One)
{
ReadTimeout = 1000, // 读超时 1秒(从站没响应就抛异常,避免卡死)
WriteTimeout = 1000 // 写超时 1秒
};
// ———- 初始化 textBox1 为多行日志显示 ———-
textBox1.Multiline = true; // 允许多行
textBox1.ScrollBars = ScrollBars.Vertical; // 只显示垂直滚动条
textBox1.Font = new Font("Consolas", 9f); // 等宽字体,数字对齐好看
textBox1.Size = new Size(337, 300); // 改高度,原设计器只有21px看不全
}
// ============================================================
// Log:统一的日志输出方法
// msg = 日志内容
// isError = true 时用 [错误] 前缀,否则用 [信息]
// 特点:自动处理跨线程、自动加时间戳、自动滚到最底部
// ============================================================
private void Log(string msg, bool isError = false)
{
// 如果当前不是UI线程(比如从Task里调用),就切回UI线程再执行
if (textBox1.InvokeRequired)
{
textBox1.Invoke(new Action(() => Log(msg, isError)));
return;
}
// 前缀:[错误] 或 [信息]
string prefix = isError ? "[错误]" : "[信息]";
// 追加一行:时间 + 前缀 + 内容 + 换行
textBox1.AppendText($"{DateTime.Now:HH:mm:ss} {prefix} {msg}{Environment.NewLine}");
// 光标移到最后 → 自动滚动到底部
textBox1.SelectionStart = textBox1.TextLength;
textBox1.ScrollToCaret();
}
// ============================================================
// OpenPort:打开串口并创建 Modbus 主站
// 特点:已打开就直接跳过,不会重复 Open 抛异常
// 返回值:true=打开成功,false=打开失败
// ============================================================
private bool OpenPort()
{
try
{
if (!serialPort1.IsOpen)
{
serialPort1.Open(); // 物理打开串口(占用COM口)
// 用 NModbus 工厂创建 RTU 主站,绑定到串口
_master = new ModbusFactory().CreateRtuMaster(serialPort1);
Log($"串口 {serialPort1.PortName} 打开成功");
}
return true;
}
catch (Exception ex)
{
Log($"串口打开失败: {ex.Message}", true); // COM口不存在或被占用会走到这里
return false;
}
}
// ============================================================
// ReadCoilsAsync:读取线圈(Modbus 功能码 0x01)
// slaveId = 从站地址(1~247)
// startAddr = 起始地址(0~65535)
// length = 读取数量(建议 ≤125,太大容易超时)
// 返回值 = 线圈状态 bool[],失败返回 null
// 特点:异步不卡UI + 锁防并发 + 异常不崩
// ============================================================
private async Task<bool[]> ReadCoilsAsync(byte slaveId = 1, ushort startAddr = 0, ushort length = 10)
{
// ———- 第一步:打开串口(加锁,避免并发打开)———-
lock (_lockObj)
{
if (!OpenPort()) return null; // 打开失败直接返回
}
try
{
// 记录开始日志
Log($"读线圈: 从站={slaveId}, 起始={startAddr}, 数量={length}");
// ———- 第二步:读取线圈 ———-
// Task.Run:把同步阻塞的 ReadCoils 放到后台线程池执行,不卡UI
// 内层加 lock:保证同一时刻串口只发一条指令(否则485总线会乱)
bool[] coils = await Task.Run(() =>
{
lock (_lockObj)
{
// return _master.ReadCoils(slaveId, startAddr, length);
return _master.ReadInputs(slaveId, startAddr, length);
}
});
// ———- 第三步:格式化输出结果 ———-
StringBuilder sb = new StringBuilder("结果: ");
for (int i = 0; i < coils.Length; i++)
{
// 格式示例:[0]=True [1]=False …
sb.Append($"[{startAddr + i}]={coils[i]} ");
}
Log(sb.ToString());
return coils; // 返回结果数组,方便外部业务逻辑使用
}
catch (Exception ex)
{
// 超时、CRC错误、从站无响应等异常都会走到这里
Log($"读线圈失败: {ex.Message}", true);
return null;
}
}
private async Task<ushort[]> ReadRegistersAsync(byte slaveId = 1, ushort startAddr = 0, ushort length = 10)
{
// ———- 第一步:打开串口(加锁,避免并发打开)———-
lock (_lockObj)
{
if (!OpenPort()) return null; // 打开失败直接返回
}
try
{
// 记录开始日志
Log($"读线圈: 从站={slaveId}, 起始={startAddr}, 数量={length}");
// ———- 第二步:读取线圈 ———-
// Task.Run:把同步阻塞的 ReadCoils 放到后台线程池执行,不卡UI
// 内层加 lock:保证同一时刻串口只发一条指令(否则485总线会乱)
ushort[] coils = await Task.Run(() =>
{
lock (_lockObj)
{
return _master.ReadHoldingRegisters(slaveId, startAddr, length);
// return _master.ReadInputRegisters(slaveId, startAddr, length);
}
});
// ———- 第三步:格式化输出结果 ———-
StringBuilder sb = new StringBuilder("结果: ");
for (int i = 0; i < coils.Length; i++)
{
// 格式示例:[0]=True [1]=False …
sb.Append($"[{startAddr + i}]={coils[i]} ");
if ( i==1 ){
label1.Text=coils[i].ToString();
if (coils[i] == 22)
{
// 写入寄存器示例:向地址 2 写入值 99(有效范围 0-9)
Log("检测到寄存器[1]值为22,准备写入寄存器…");
// 使用 Task.Run 在后台线程执行写操作,避免阻塞 UI
Task.Run(async () =>
{
// 写单个寄存器:向地址 2 写入值 99
// await WriteRegisterAsync(slaveId: 1, addr: 2, value: 99);
// 写多个寄存器:向地址 2 开始写入 3 个值
await WriteRegistersAsync(slaveId: 1, startAddr: 2, values: new ushort[] { 99, 98, 97 });
});
}
}
}
Log(sb.ToString());
return coils; // 返回结果数组,方便外部业务逻辑使用
}
catch (Exception ex)
{
// 超时、CRC错误、从站无响应等异常都会走到这里
Log($"读线圈失败: {ex.Message}", true);
return null;
}
}
// ============================================================
// WriteRegisterAsync:写单个保持寄存器(Modbus 功能码 0x06)
// slaveId = 从站地址(1~247)
// addr = 寄存器地址(0~65535)
// value = 要写入的值(ushort,0~65535)
// 返回值 = true=写入成功,false=写入失败
// 特点:异步不卡UI + 锁防并发 + 异常不崩
// ============================================================
private async Task<bool> WriteRegisterAsync(byte slaveId = 1, ushort addr = 0, ushort value = 0)
{
// ———- 第一步:打开串口(加锁,避免并发打开)———-
lock (_lockObj)
{
if (!OpenPort()) return false; // 打开失败直接返回
}
try
{
// 记录开始日志
Log($"写单个寄存器: 从站={slaveId}, 地址={addr}, 值={value}");
// ———- 第二步:写单个寄存器 ———-
// Task.Run:把同步阻塞的 WriteSingleRegister 放到后台线程池执行,不卡UI
// 内层加 lock:保证同一时刻串口只发一条指令(否则485总线会乱)
await Task.Run(() =>
{
lock (_lockObj)
{
_master.WriteSingleRegister(slaveId, addr, value);
}
});
Log($"写寄存器成功: [{addr}]={value}");
return true;
}
catch (Exception ex)
{
// 超时、CRC错误、从站无响应等异常都会走到这里
Log($"写寄存器失败: {ex.Message}", true);
return false;
}
}
// ============================================================
// WriteRegistersAsync:写多个保持寄存器(Modbus 功能码 0x10)
// slaveId = 从站地址(1~247)
// startAddr = 起始地址(0~65535)
// values = 要写入的值数组(建议 ≤125,太大容易超时)
// 返回值 = true=写入成功,false=写入失败
// 特点:异步不卡UI + 锁防并发 + 异常不崩
// ============================================================
private async Task<bool> WriteRegistersAsync(byte slaveId = 1, ushort startAddr = 0, ushort[] values = null)
{
// 参数检查:values 为空或长度为 0 直接返回
if (values == null || values.Length == 0)
{
Log("写多个寄存器失败: 写入数据为空", true);
return false;
}
// ———- 第一步:打开串口(加锁,避免并发打开)———-
lock (_lockObj)
{
if (!OpenPort()) return false; // 打开失败直接返回
}
try
{
// 记录开始日志
Log($"写多个寄存器: 从站={slaveId}, 起始={startAddr}, 数量={values.Length}");
// ———- 第二步:写多个寄存器 ———-
// Task.Run:把同步阻塞的 WriteMultipleRegisters 放到后台线程池执行,不卡UI
// 内层加 lock:保证同一时刻串口只发一条指令(否则485总线会乱)
await Task.Run(() =>
{
lock (_lockObj)
{
_master.WriteMultipleRegisters(slaveId, startAddr, values);
}
});
// ———- 第三步:格式化输出结果 ———-
StringBuilder sb = new StringBuilder("写入成功: ");
for (int i = 0; i < values.Length; i++)
{
sb.Append($"[{startAddr + i}]={values[i]} ");
}
Log(sb.ToString());
return true;
}
catch (Exception ex)
{
// 超时、CRC错误、从站无响应等异常都会走到这里
Log($"写多个寄存器失败: {ex.Message}", true);
return false;
}
}
// ============================================================
// button1 点击事件:触发读线圈
// ============================================================
private async void button1_Click(object sender, EventArgs e)
{
button1.Enabled = false; // 按钮置灰,防止用户快速连点触发多次
try
{
// 从站1,地址0开始,读10个线圈
// await ReadCoilsAsync(slaveId: 1, startAddr: 0, length: 10);
await ReadRegistersAsync(slaveId: 1, startAddr: 0, length: 10);
}
finally
{
button1.Enabled = true; // 无论成功失败,最后都恢复按钮可点击
}
}
// ============================================================
// 窗体加载事件
// ============================================================
private void Form2_Load(object sender, EventArgs e)
{
Log("初始化完成");
}
// ============================================================
// 窗体关闭事件:释放串口资源
// 必须释放!否则下次打开会报"COM口已被占用"
// ============================================================
protected override void OnFormClosing(FormClosingEventArgs e)
{
try
{
_master?.Dispose(); // 释放 Modbus 主站
if (serialPort1 != null && serialPort1.IsOpen)
serialPort1.Close(); // 关闭串口
}
catch { } // 关闭时异常直接忽略,不影响窗体关闭
base.OnFormClosing(e);
}
}
}
第五章:工程化亮点深度解析
5.1 异步编程:async/await
// ❌ 传统同步方式:阻塞 UI 线程
private void SyncMethod()
{
// 这里会阻塞界面 ~1 秒
var data = _master.ReadRegisters(1, 0, 10);
textBox1.Text = $"读取完成"; // 要等上面完成才执行
}
// ✅ 异步方式:不阻塞 UI
private async Task AsyncMethod()
{
// 这里不会阻塞界面
var data = await ReadRegistersAsync(1, 0, 10);
textBox1.Text = $"读取完成"; // 读取完成后自动执行
}
async/await 原理:
UI 线程: [等待 async 完成] ←→ [继续执行其他 UI 操作]
↘
后台线程: [Task.Run 执行 Modbus 操作]
↗
UI 线程: [await 返回结果] ← [恢复执行后续代码]
5.2 Task.Run 线程池
// Task.Run 将工作放到线程池执行
await Task.Run(() =>
{
// 这里的代码在线程池执行,不占用 UI 线程
bool result = _master.WriteSingleRegister(1, 0, 100);
return result;
});
// await 完成后回到 UI 线程
为什么要用 Task.Run?
– `_master.WriteSingleRegister()` 是同步阻塞方法
– 直接调用会卡住 UI 线程
– Task.Run 将其放到后台线程执行
5.3 lock 锁:485 总线并发保护
private readonly object _lockObj = new object();
// 两个地方都要加锁:打开串口和 Modbus 操作
lock (_lockObj)
{
if (!OpenPort()) return false; // 第一步:打开串口
}
// 第二步:异步执行 Modbus 操作
await Task.Run(() =>
{
lock (_lockObj) // 内层必须再加锁!
{
_master.WriteSingleRegister(slaveId, addr, value);
}
});
为什么需要双层锁?
时间线:
T1: 线程A lock(_lockObj) → OpenPort() [占用锁]
T2: 线程B lock(_lockObj) → 等待… [等待锁释放]
T3: 线程A OpenPort() 完成 → 释放锁
T4: 线程B 获得锁 → OpenPort() [安全执行]
T5: 线程A lock(_lockObj) → Write… [占用锁]
T6: 线程B lock(_lockObj) → 等待… [等待锁释放]
RS-485 总线规则:
– RS-485 是半双工总线
– 同一时刻只能有一个设备发送
– 如果多个指令同时发送,会导致数据冲突
5.4 Invoke 机制:跨线程 UI 操作
private void Log(string msg, bool isError = false)
{
// 检查是否跨线程
if (textBox1.InvokeRequired)
{
// 跨线程:使用 Invoke 切换到 UI 线程
// Invoke 是同步方法,会阻塞当前线程直到 UI 线程执行完成
textBox1.Invoke(new Action(() => Log(msg, isError)));
return;
}
// UI 线程:直接操作控件
textBox1.AppendText(msg);
}
为什么需要 Invoke?
– WinForm 控件只能在 UI 线程操作
– Task.Run 中的代码在线程池执行
– 直接操作控件会抛异常:`Cross-thread operation not valid`
Invoke vs BeginInvoke:
– `Invoke`:同步,等待 UI 线程完成
– `BeginInvoke`:异步,立即返回
网硕互联帮助中心



评论前必须登录!
注册