Modbus通信集成:让你的软件跟设备说上话
这是「从零搭建工业控制系统」系列第4篇。前三篇搭了MVVM骨架、搞定了命令系统、理顺了依赖注入。从这篇开始,进入硬件通信。
第一次接设备的窘境
我第一次写设备通信代码的时候,直接在按钮点击事件里发了条 Modbus 请求。代码大概长这样:
private void ButtonStart_Click(object sender, RoutedEventArgs e)
{
var client = new TcpClient("192.168.1.100", 502);
var master = ModbusIpMaster.CreateIp(client);
master.WriteSingleCoil(1, 0, true);
client.Close();
}
能用吗?能用。但问题一大堆:
- 每次点按钮都新建连接,设备端连接数蹭蹭涨
- 没有超时处理,设备不响应就卡死
- 没有断线检测,网线拔了程序不知道
- 业务逻辑和通信代码混在一起,改一个动全身
后来重写了三版才稳定下来。这篇就把踩过的坑整理出来。
Modbus协议:30年前的"老古董"
Modbus 是1979年发明的工业通信协议,到现在还在用。原因很简单:够简单、够通用、几乎所有设备都支持。
核心概念就三个:
① 从站(Slave) — 设备端。每个设备有个 SlaveId(1-247),就像门牌号。
② 寄存器(Register) — 设备上的数据存储区。四种类型:
| 类型 | 读写 | 数据 | 典型用途 |
|---|---|---|---|
| Coil | 读/写 | bool | 开关量输出(阀门、灯) |
| Discrete Input | 只读 | bool | 开关量输入(传感器信号) |
| Holding Register | 读/写 | ushort | 参数设置(温度设定值) |
| Input Register | 只读 | ushort | 测量值(实际温度、压力) |

③ 主站(Master) — 你的软件。主动发请求,设备被动响应。
一次通信就是:主站说"从站1,把地址0的线圈写成true",从站回"好的"。

NModbus:C#的Modbus库
.NET 平台用得最多的是 NModbus 库。安装:
Install-Package NModbus
最基础的用法:
using NModbus;
// 建立TCP连接
var client = new TcpClient("192.168.1.100", 502);
var factory = new ModbusFactory();
var master = factory.CreateMaster(client);
// 读线圈(SlaveId=1, 从地址0开始, 读8个)
bool[] coils = await master.ReadCoilsAsync(1, 0, 8);
// 写单个线圈
await master.WriteSingleCoilAsync(1, 0, true);
// 读保持寄存器
ushort[] registers = await master.ReadHoldingRegistersAsync(1, 0, 10);
// 写多个寄存器
await master.WriteMultipleRegistersAsync(1, 0, new ushort[] { 1, 2, 3 });
看起来不复杂。但直接在业务代码里用,迟早要出事。
封装通信服务
我把 Modbus 操作封装成一个接口,隔离底层细节:
public interface IModbusTransport : IDisposable
{
bool IsConnected { get; }
event EventHandler<bool> ConnectionStateChanged;
IModbusMaster Master { get; }
Task<bool> ConnectAsync(string ipAddress, int port, int timeout = 3000);
Task DisconnectAsync();
Task<ushort[]> ReadHoldingRegistersAsync(byte slaveAddress, ushort startAddress, ushort numberOfPoints);
Task<ushort[]> ReadInputRegistersAsync(byte slaveAddress, ushort startAddress, ushort numberOfPoints);
Task WriteHoldingRegistersAsync(byte slaveAddress, ushort startAddress, ushort[] values);
Task WriteSingleRegisterAsync(byte slaveAddress, ushort address, ushort value);
Task<bool[]> ReadCoilsAsync(byte slaveAddress, ushort startAddress, ushort numberOfPoints);
Task<bool[]> ReadDiscreteInputsAsync(byte slaveAddress, ushort startAddress, ushort numberOfPoints);
Task WriteCoilsAsync(byte slaveAddress, ushort startAddress, bool[] values);
Task WriteSingleCoilAsync(byte slaveAddress, ushort address, bool value);
}
好处是:业务代码只依赖接口,不依赖 NModbus。以后换通信库(比如换成 OPC UA),只改实现,不改业务。
设备连接管理
工业现场网络不稳定,断线是家常便饭。连接管理必须做好:
public class ModbusTransport : IModbusTransport
{
private TcpClient _tcpClient;
private IModbusMaster _master;
private string _ipAddress;
private int _port;
private bool _isConnected;
public bool IsConnected => _isConnected;
public IModbusMaster Master => _master;
public event EventHandler<bool> ConnectionStateChanged;
public async Task<bool> ConnectAsync(string ipAddress, int port, int timeout = 3000)
{
_ipAddress = ipAddress;
_port = port;
try
{
_tcpClient = new TcpClient();
var connectTask = _tcpClient.ConnectAsync(ipAddress, port);
// 超时控制:设备不响应就别等了
if (await Task.WhenAny(connectTask, Task.Delay(timeout)) != connectTask)
{
_tcpClient.Dispose();
return false;
}
var factory = new ModbusFactory();
_master = factory.CreateMaster(_tcpClient);
// 设置读写超时
_master.Transport.ReadTimeout = 2000;
_master.Transport.WriteTimeout = 2000;
_master.Transport.Retries = 3;
_isConnected = true;
ConnectionStateChanged?.Invoke(this, true);
return true;
}
catch
{
_isConnected = false;
return false;
}
}
public async Task DisconnectAsync()
{
_isConnected = false;
ConnectionStateChanged?.Invoke(this, false);
_master?.Dispose();
_tcpClient?.Dispose();
}
}
几个关键设计:
① 超时控制 — Task.WhenAny + Task.Delay,3秒连不上就放弃。不设超时,设备离线时UI能卡一分钟。
② 重试机制 — Transport.Retries = 3,偶尔丢包自动重试,不用每次失败都报错。
③ 连接状态事件 — ConnectionStateChanged,断线时通知上层。UI可以据此显示"设备离线"。

多设备管理
实际项目不只一台设备。我项目里有两台:DigitalIO(数字IO控制器)和 ValveModule(阀组)。封装一个统一管理服务:
public interface IDeviceConnectionManager
{
/// <summary>
/// 连接单个设备
/// </summary>
Task<bool> ConnectDeviceAsync(string deviceName, string ipAddress, int port, byte slaveId);
/// <summary>
/// 批量连接所有设备
/// </summary>
Task<bool> ConnectAllAsync(Dictionary<string, (string ipAddress, int port, byte slaveId)> deviceConfigs);
/// <summary>
/// 断开所有设备
/// </summary>
Task DisconnectAllAsync();
}
设备配置放在 Config.ini 里:
[DigitalIO]
IpAddress = 192.168.1.100
Port = 502
SlaveId = 1
[ValveModule]
IpAddress = 192.168.1.101
Port = 502
SlaveId = 2
启动时读取配置,批量连接:
var deviceConfigs = new Dictionary<string, (string, int, byte)>
{
{ "DigitalIO", ("192.168.1.100", 502, (byte)1) },
{ "ValveModule", ("192.168.1.101", 502, (byte)2) }
};
await _connectionService.ConnectAllAsync(deviceConfigs);
命令执行:不只是读写寄存器
工业设备的通信不只是简单读写。我项目里,执行一个命令的流程是:
- 写命令参数到参数区寄存器
- 写命令索引到命令区寄存器
- 轮询状态区寄存器,等设备返回完成标志
- 读取结果
封装成 CommandTaskHandler:
public async Task<CommandHandleResult> ExecuteCommandAsync(
IModbusMaster master, byte slaveId,
DigitalIOCmdIndex cmdIndex,
List<ushort> parameters,
int timeout = 10000)
{
// 1. 写参数
await Task.Run(() => master.WriteMultipleRegisters(
slaveId, paramAddress, parameters.ToArray()));
// 2. 写命令索引(触发设备执行)
await Task.Run(() => master.WriteMultipleRegisters(
slaveId, cmdAddress, new ushort[] { (ushort)cmdIndex }));
// 3. 写状态标志(告诉设备:命令已下发)
master.WriteMultipleRegisters(slaveId, statusAddress, new ushort[] { 1 });
// 4. 轮询等待完成
return await WaitForCompletionAsync(master, slaveId, timeout);
}
private async Task<CommandHandleResult> WaitForCompletionAsync(
IModbusMaster master, byte slaveId, int timeout)
{
var deadline = DateTime.Now.AddMilliseconds(timeout);
while (DateTime.Now < deadline)
{
ushort[] status = await master.ReadHoldingRegistersAsync(slaveId, statusAddress, 1);
if (status[0] == 2) // 2 = 完成
return CommandHandleResult.Success();
if (status[0] == 3) // 3 = 错误
return CommandHandleResult.Error("设备返回错误");
await Task.Delay(100); // 100ms轮询间隔
}
return CommandHandleResult.Timeout();
}
轮询间隔不能太短 — 我设的100ms。设成1ms的话,Modbus请求会把设备通信口打满,反而更慢。

线圈写入的安全机制
直接写线圈是最危险的操作——一个误写可能打开错误的阀门。所以封装了多层保护:
[RelayCommand(CanExecute = nameof(CanWriteValue))]
public async Task WriteValueManually(bool value)
{
// 第一层:设备连接检查
if (!IsDeviceConnected)
{
MessageBox.Show("设备未连接");
return;
}
// 第二层:CheckBox保护检查
if (IsCoilProtectedByCheckBox())
{
MessageBox.Show($"线圈 {coilCode} 受保护,请先取消CheckBox");
return;
}
// 第三层:用户确认
bool confirmed = await ShowConfirmationDialog(value);
if (!confirmed) return;
// 第四层:实际写入
await _modbusService.WriteSingleCoilAsync(slaveAddress, address, value);
}
四层检查,任何一层不通过都不写。宁可多确认,不可误操作。
通信踩坑清单
| 坑 | 现象 | 解决 |
|---|---|---|
| 不设超时 | 设备离线时卡死 | Transport.ReadTimeout = 2000 |
| 不做重试 | 偶尔丢包就报错 | Transport.Retries = 3 |
| 每次new连接 | 设备端连接数爆满 | 保持长连接,复用Master |
| 轮询太快 | 通信口打满 | 间隔100ms以上 |
| 不检测断线 | 拔了网线不知道 | 监听连接状态事件 |
| 主从站在同一线程 | UI卡死 | 所有Modbus操作用async |
本篇小结
| 知识点 | 关键代码 |
|---|---|
| Modbus四种寄存器 | Coil / Discrete Input / Holding / Input Register |
| NModbus基本操作 | master.ReadCoilsAsync() / master.WriteSingleCoilAsync() |
| 超时控制 | Task.WhenAny(task, Task.Delay(timeout)) |
| 连接管理 | 长连接 + 状态事件 + 断线通知 |
| 命令执行流程 | 写参数 → 写命令 → 轮询状态 → 读结果 |
| 安全写入 | 连接检查 → 保护检查 → 用户确认 → 写入 |
Modbus不难,难的是把"能用"变成"稳定"。工业现场网络环境比你想象的差得多。
下期预告
第5篇:实时数据监控与UI更新
设备连上了,数据怎么读?下一篇讲定时轮询、ObservableProperty驱动UI刷新,以及压力、温度、流量这些实时数据怎么在界面上丝滑显示。
:Modbus通信实战,让你的软件跟设备说上话&spm=1001.2101.3001.5002&articleId=163782273&d=1&t=3&u=aac7907b837f4267b12b28a2f05ab051)
1358

被折叠的 条评论
为什么被折叠?



