Easy系列PLC MODBUSTCP通信交换数据
更多工业通信相关内容,大家也可以查看BLIBLI上博主 "RXXW_Dor" 推出的工业通信视频课程。
1、网络拓扑

2、NModbus4包安装

最新的是NModbus包,函数和使用会有所不同,需要注意。这篇文章都是基于NModbus4库的使用和测试。
3、写线圈测试

本地回环测试IP地址固定为127.0.0.1,和第三方设备通信需要准确填写设备IP地址端口号已经MODBUS站号。
4、读线圈测试

5、MainWindow.xaml(界面代码)
XML
<Window x:Class="ModbusNModbusDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="ModbusTcp线圈读写(NModbus4)" Height="380" Width="520">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="85"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="95"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<!--IP、端口、从站ID-->
<TextBlock Grid.Row="0" Grid.Column="0" Text="IP地址:" VerticalAlignment="Center"/>
<TextBox x:Name="txtIp" Grid.Row="0" Grid.Column="1" Text="127.0.0.1" Margin="0,4"/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="端口:" VerticalAlignment="Center"/>
<TextBox x:Name="txtPort" Grid.Row="0" Grid.Column="3" Text="502" Width="70" Margin="0,4"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="从站ID:" VerticalAlignment="Center"/>
<TextBox x:Name="txtSlaveId" Grid.Row="1" Grid.Column="1" Text="1" Margin="0,4"/>
<!--线圈地址-->
<TextBlock Grid.Row="2" Grid.Column="0" Text="线圈地址:" VerticalAlignment="Center"/>
<TextBox x:Name="txtCoilAddr" Grid.Row="2" Grid.Column="1" Text="0" Margin="0,4"/>
<!--批量数量-->
<TextBlock Grid.Row="2" Grid.Column="2" Text="批量点数:" VerticalAlignment="Center"/>
<TextBox x:Name="txtBatchCount" Grid.Row="2" Grid.Column="3" Text="8" Width="70" Margin="0,4"/>
<!--连接/断开+状态-->
<StackPanel Grid.Row="3" Grid.ColumnSpan="4" Orientation="Horizontal" Margin="0,8,0,8">
<Button x:Name="BtnConnect" Content="连接PLC" Width="90" Click="BtnConnect_Click"/>
<Button x:Name="BtnDisConnect" Content="断开连接" Width="90" Margin="8,0,0,0" Click="BtnDisConnect_Click"/>
<TextBlock x:Name="tbConnState" Text="未连接" Foreground="Red" Margin="15,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<!--单线圈操作按钮-->
<StackPanel Grid.Row="4" Grid.ColumnSpan="4" Orientation="Horizontal">
<Button x:Name="BtnSetSingle" Content="线圈置ON" Width="95" Click="BtnSetSingle_Click"/>
<Button x:Name="BtnResetSingle" Content="线圈置OFF" Width="95" Margin="8,0,0,0" Click="BtnResetSingle_Click"/>
<Button x:Name="BtnReadSingle" Content="读取单线圈" Width="95" Margin="8,0,0,0" Click="BtnReadSingle_Click"/>
<Button x:Name="BtnBatchWrite" Content="批量写线圈" Width="95" Margin="8,0,0,0" Click="BtnBatchWrite_Click"/>
</StackPanel>
<!--结果显示-->
<TextBlock Grid.Row="5" Grid.ColumnSpan="4" x:Name="tbResult" Margin="0,12,0,0" FontSize="13"/>
</Grid>
</Window>
6、MainWindow.xaml.cs(逻辑代码)
cs
using System;
using System.Net.Sockets;
using System.Threading.Tasks;
using System.Windows;
using Modbus.Device;
namespace ModbusNModbusDemo
{
public partial class MainWindow : Window
{
private TcpClient _modbusTcpClient;
private IModbusMaster _modbusMaster;
public bool IsConnected => _modbusTcpClient != null && _modbusTcpClient.Connected;
public MainWindow()
{
InitializeComponent();
tbConnState.Text = "未连接";
tbConnState.Foreground = System.Windows.Media.Brushes.Red;
}
#region 连接断开【异步】
private async void BtnConnect_Click(object sender, RoutedEventArgs e)
{
if (IsConnected)
{
MessageBox.Show("已经处于连接状态!");
return;
}
if (!CheckParams(out string ip, out int port, out byte slaveId, out _)) return;
try
{
// 异步连接不卡UI
_modbusTcpClient = new TcpClient();
await _modbusTcpClient.ConnectAsync(ip, port);
_modbusMaster = ModbusIpMaster.CreateIp(_modbusTcpClient);
tbConnState.Text = "连接成功";
tbConnState.Foreground = System.Windows.Media.Brushes.Green;
MessageBox.Show("ModbusTCP连接建立成功");
}
catch (Exception ex)
{
CloseConnect();
MessageBox.Show("连接失败:" + ex.Message);
}
}
private void BtnDisConnect_Click(object sender, RoutedEventArgs e)
{
CloseConnect();
MessageBox.Show("已断开通信连接");
}
void CloseConnect()
{
if (_modbusTcpClient != null)
{
_modbusTcpClient.Close();
_modbusTcpClient.Dispose();
_modbusTcpClient = null;
}
_modbusMaster = null;
tbConnState.Text = "未连接";
tbConnState.Foreground = System.Windows.Media.Brushes.Red;
}
#endregion
#region 单线圈操作 异步
private async void BtnSetSingle_Click(object sender, RoutedEventArgs e)
{
if (!CheckConnectState()) return;
await WriteSingleCoil(true);
}
private async void BtnResetSingle_Click(object sender, RoutedEventArgs e)
{
if (!CheckConnectState()) return;
await WriteSingleCoil(false);
}
async Task WriteSingleCoil(bool state)
{
if (!CheckParams(out _, out _, out byte slaveId, out ushort addr)) return;
try
{
// 同步Modbus方法套Task.Run脱离UI线程
await Task.Run(() =>
{
_modbusMaster.WriteSingleCoil(slaveId, addr, state);
});
MessageBox.Show($"线圈{addr} {(state ? "置位成功ON" : "复位成功OFF")}");
}
catch (Exception ex)
{
MessageBox.Show("写入异常:" + ex.Message);
CloseConnect();
}
}
private async void BtnReadSingle_Click(object sender, RoutedEventArgs e)
{
if (!CheckConnectState()) return;
if (!CheckParams(out _, out _, out byte slaveId, out ushort addr)) return;
try
{
bool[] resArr = await Task.Run(() =>
{
return _modbusMaster.ReadCoils(slaveId, addr, 1);
});
tbResult.Text = $"线圈{addr} 状态:{(resArr[0] ? "ON" : "OFF")}";
}
catch (Exception ex)
{
tbResult.Text = "读取失败:" + ex.Message;
CloseConnect();
}
}
#endregion
#region 批量写线圈
private async void BtnBatchWrite_Click(object sender, RoutedEventArgs e)
{
if (!CheckConnectState()) return;
if (!CheckParams(out _, out _, out byte slaveId, out ushort startAddr)) return;
if (!ushort.TryParse(txtBatchCount.Text, out ushort count) || count < 1)
{
MessageBox.Show("批量点数填写错误");
return;
}
bool[] batchData = new bool[count];
for (int i = 0; i < count; i++)
batchData[i] = i % 2 == 0;
try
{
await Task.Run(() =>
{
_modbusMaster.WriteMultipleCoils(slaveId, startAddr, batchData);
});
MessageBox.Show($"从地址{startAddr}开始,共{count}个线圈批量写入完成!");
}
catch (Exception ex)
{
MessageBox.Show("批量写入异常:" + ex.Message);
CloseConnect();
}
}
#endregion
#region 辅助方法
bool CheckConnectState()
{
if (!IsConnected)
{
MessageBox.Show("请先建立ModbusTCP连接!");
return false;
}
return true;
}
bool CheckParams(out string ip, out int port, out byte slaveId, out ushort coilAddr)
{
ip = txtIp.Text.Trim();
port = 0; slaveId = 0; coilAddr = 0;
if (!int.TryParse(txtPort.Text, out port) || port < 1 || port > 65535)
{
MessageBox.Show("端口数字错误");
return false;
}
if (!byte.TryParse(txtSlaveId.Text, out slaveId) || slaveId < 1)
{
MessageBox.Show("从站ID范围1~247");
return false;
}
if (!ushort.TryParse(txtCoilAddr.Text, out coilAddr))
{
MessageBox.Show("线圈地址非负整数");
return false;
}
return true;
}
#endregion
}
}
7、启用轮询

我们更换了一个界面,界面里总共2个设备。在同一个端口下RS485总线挂接了2个从站设备。
8、监控的RS485总线数据帧

9、对应界面代码(MainWindow.xaml)
XML
<Window x:Class="ModbusNModbusDemo.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="卷绕机数据监控" Height="720" Width="980">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!--顶部:公用IP+连接断开日志按钮-->
<StackPanel Grid.Row="0" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,0,12">
<TextBlock Text="IP地址:" FontSize="13" VerticalAlignment="Center"/>
<TextBox x:Name="txtGlobalIp" Width="160" Text="192.168.1.125" Margin="6,2"/>
<Button x:Name="BtnConnect" Content="通信连接" Width="110" Click="BtnConnect_Click" Margin="15,0,8,0"/>
<Button x:Name="BtnDisConnect" Content="通信断开" Width="110" Click="BtnDisConnect_Click" Margin="0,0,8,0"/>
<Button x:Name="BtnLogOpen" Content="日志查看" Width="110" Click="BtnOpenLogFolder_Click"/>
</StackPanel>
<!--主体左右双从站布局-->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!--左侧从站1区域-->
<Grid Grid.Column="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="端口号:" VerticalAlignment="Center"/>
<TextBox x:Name="txtPort1" Width="90" Text="10010" Margin="5,0"/>
<TextBlock Text="ModbusID:" VerticalAlignment="Center" Margin="12,0,0,0"/>
<TextBox x:Name="txtSlaveId1" Width="60" Text="1" Margin="5,0"/>
</StackPanel>
<!--从站1参数监视框-->
<GroupBox Grid.Row="1" Header="监视参数" Margin="0,0,6,8">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="油轮频率" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS1_Freq" Grid.Row="0" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="0" Grid.Column="2" Foreground="Red" Text=" 41519"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="油轮加速时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS1_AccTime" Grid.Row="1" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="1" Grid.Column="2" Foreground="Red" Text=" 41533"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="GR1初始加速时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS1_GR1" Grid.Row="2" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="2" Grid.Column="2" Foreground="Red" Text=" 41534"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="GR2初始加速时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS1_GR2" Grid.Row="3" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="3" Grid.Column="2" Foreground="Red" Text=" 41535"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="GR3初始加速时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS1_GR3" Grid.Row="4" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="4" Grid.Column="2" Foreground="Red" Text=" 41536"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="爬升时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS1_Climb" Grid.Row="5" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="5" Grid.Column="2" Foreground="Red" Text=" 41568"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="备用1" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS1_Res1" Grid.Row="6" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="6" Grid.Column="2" Foreground="Red" Text=" 41569"/>
<TextBlock Grid.Row="7" Grid.Column="0" Text="备用2" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS1_Res2" Grid.Row="7" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="7" Grid.Column="2" Foreground="Red" Text=" 41570"/>
</Grid>
</GroupBox>
<!--从站1 IO状态面板-->
<GroupBox Grid.Row="2" Header="监视状态">
<Grid Margin="6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Right">
<TextBlock Text="运行状态(M300)" FontSize="13"/>
<Ellipse x:Name="ledS1_M300" Width="16" Height="16" Fill="Green" Margin="6,0,0,0"/>
</StackPanel>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="2" Background="Black" Width="1"/>
<TextBlock x:Name="tbS1_XLeft" Grid.Column="0" FontSize="13"/>
<TextBlock x:Name="tbS1_XRight" Grid.Column="1" FontSize="13"/>
<TextBlock x:Name="tbS1_YLeft" Grid.Column="3" FontSize="13"/>
<TextBlock x:Name="tbS1_YRight" Grid.Column="4" FontSize="13"/>
</Grid>
</Grid>
</GroupBox>
</Grid>
<!--右侧从站2区域-->
<Grid Grid.Column="1">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,8">
<TextBlock Text="端口号:" VerticalAlignment="Center"/>
<TextBox x:Name="txtPort2" Width="90" Text="10010" Margin="5,0"/>
<TextBlock Text="ModbusID:" VerticalAlignment="Center" Margin="12,0,0,0"/>
<TextBox x:Name="txtSlaveId2" Width="60" Text="2" Margin="5,0"/>
</StackPanel>
<GroupBox Grid.Row="1" Header="监视参数" Margin="6,0,0,8">
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="油轮频率" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS2_Freq" Grid.Row="0" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="0" Grid.Column="2" Foreground="Red" Text=" 41519"/>
<TextBlock Grid.Row="1" Grid.Column="0" Text="油轮加速时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS2_AccTime" Grid.Row="1" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="1" Grid.Column="2" Foreground="Red" Text=" 41533"/>
<TextBlock Grid.Row="2" Grid.Column="0" Text="GR1初始加速时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS2_GR1" Grid.Row="2" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="2" Grid.Column="2" Foreground="Red" Text=" 41534"/>
<TextBlock Grid.Row="3" Grid.Column="0" Text="GR2初始加速时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS2_GR2" Grid.Row="3" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="3" Grid.Column="2" Foreground="Red" Text=" 41535"/>
<TextBlock Grid.Row="4" Grid.Column="0" Text="GR3初始加速时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS2_GR3" Grid.Row="4" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="4" Grid.Column="2" Foreground="Red" Text=" 41536"/>
<TextBlock Grid.Row="5" Grid.Column="0" Text="爬升时间" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS2_Climb" Grid.Row="5" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="5" Grid.Column="2" Foreground="Red" Text=" 41568"/>
<TextBlock Grid.Row="6" Grid.Column="0" Text="备用1" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS2_Res1" Grid.Row="6" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="6" Grid.Column="2" Foreground="Red" Text=" 41569"/>
<TextBlock Grid.Row="7" Grid.Column="0" Text="备用2" HorizontalAlignment="Right"/>
<TextBox x:Name="tbS2_Res2" Grid.Row="7" Grid.Column="1" Background="#FFFCF000" IsReadOnly="True" Text="0.00"/>
<TextBlock Grid.Row="7" Grid.Column="2" Foreground="Red" Text=" 41570"/>
</Grid>
</GroupBox>
<GroupBox Grid.Row="2" Header="监视状态">
<Grid Margin="6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Right">
<TextBlock Text="运行状态(M300)" FontSize="13"/>
<Ellipse x:Name="ledS2_M300" Width="16" Height="16" Fill="Green" Margin="6,0,0,0"/>
</StackPanel>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="2" Background="Black" Width="1"/>
<TextBlock x:Name="tbS2_XLeft" Grid.Column="0" FontSize="13"/>
<TextBlock x:Name="tbS2_XRight" Grid.Column="1" FontSize="13"/>
<TextBlock x:Name="tbS2_YLeft" Grid.Column="3" FontSize="13"/>
<TextBlock x:Name="tbS2_YRight" Grid.Column="4" FontSize="13"/>
</Grid>
</Grid>
</GroupBox>
</Grid>
</Grid>
</Grid>
</Window>
10、对应逻辑代码(MainWindow.xaml.cs)
cs
using System;
using System.IO;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using Modbus.Device;
namespace ModbusNModbusDemo
{
/// <summary>轮询状态枚举:状态机四段流转控制轮询步骤</summary>
public enum PollState
{
/// <summary>空闲:未连接/停止轮询</summary>
Idle,
/// <summary>采集从站1数据</summary>
PollSlaveOne,
/// <summary>采集从站2数据</summary>
PollSlaveTwo,
/// <summary>一轮采集完成延时等待</summary>
LoopDelay
}
public partial class MainWindow : Window
{
#region 全局变量与常量定义
private TcpClient _modbusTcpClient; //ModbusTcp客户端对象
private IModbusMaster _modbusMaster; //NModbus主站通信实例
private CancellationTokenSource _pollCts; //轮询任务取消令牌
private Task _pollTask; //后台轮询任务
/// <summary>当前运行轮询状态,配合状态机切换</summary>
private PollState _curState = PollState.Idle;
/// <summary>轮询锁定从站ID,连接成功后固定不再修改</summary>
private byte _slaveId1 = 1;
private byte _slaveId2 = 2;
/// <summary>单条Modbus指令超时时间(毫秒)</summary>
private const int CmdTimeoutMs = 1000;
/// <summary>一轮所有从站采集完毕后的间隔延时(毫秒)</summary>
private const int LoopWaitMs = 200;
/// <summary>保持寄存器逻辑地址数组(UI地址=数组值+40001,已剔除40001偏移)</summary>
private readonly ushort[] _regAddrArr = { 1518, 1532, 1533, 1534, 1535, 1567, 1568, 1569 };
/// <summary>DI离散输入UI起始地址10001(X0),代码内部读取需-10001做地址偏移</summary>
private const ushort DiStart = 10001;
private const ushort DiCount = 16;
/// <summary>线圈输出UI起始地址11537(Y起始),代码内部读取需-10001做地址偏移</summary>
private const ushort CoilStart = 11537;
private const ushort CoilCount = 16;
//IO默认全OFF初始文本
private string _defXLeft, _defXRight, _defYLeft, _defYRight;
//寄存器显示缓存:通信异常时保留上次有效值,不刷新为0
private string[] _s1RegBuf = new string[8];
private string[] _s2RegBuf = new string[8];
//DI、线圈文本缓存:通信异常沿用旧文本
private string _s1XLeftBuf, _s1XRightBuf, _s1YLeftBuf, _s1YRightBuf;
private string _s2XLeftBuf, _s2XRightBuf, _s2YLeftBuf, _s2YRightBuf;
#endregion
#region 窗体初始化、日志相关方法
public MainWindow()
{
InitializeComponent();
CreateLogDir();
InitDefaultIoText();
InitUiDefaultValue();
}
/// <summary>创建日志保存文件夹ModbusLog</summary>
private void CreateLogDir()
{
if (!Directory.Exists("ModbusLog")) Directory.CreateDirectory("ModbusLog");
}
/// <summary>生成X/Y点位默认OFF字符串,用于界面初始化赋值</summary>
private void InitDefaultIoText()
{
_defXLeft = ""; _defXRight = ""; _defYLeft = ""; _defYRight = "";
for (int i = 0; i < 8; i++) _defXLeft += $"X{i}: OFF{Environment.NewLine}";
for (int i = 8; i < 16; i++) _defXRight += $"X{i + 2}: OFF{Environment.NewLine}";
for (int i = 0; i < 8; i++) _defYLeft += $"Y{i}: OFF{Environment.NewLine}";
for (int i = 8; i < 16; i++) _defYRight += $"Y{i + 2}: OFF{Environment.NewLine}";
//初始化缓存为默认OFF文本
_s1XLeftBuf = _defXLeft; _s1XRightBuf = _defXRight;
_s1YLeftBuf = _defYLeft; _s1YRightBuf = _defYRight;
_s2XLeftBuf = _defXLeft; _s2XRightBuf = _defXRight;
_s2YLeftBuf = _defYLeft; _s2YRightBuf = _defYRight;
}
/// <summary>初始界面:寄存器默认0.00,IO全部OFF</summary>
private void InitUiDefaultValue()
{
for (int i = 0; i < 8; i++)
{
_s1RegBuf[i] = "0.00";
_s2RegBuf[i] = "0.00";
}
tbS1_XLeft.Text = _defXLeft;
tbS1_XRight.Text = _defXRight;
tbS1_YLeft.Text = _defYLeft;
tbS1_YRight.Text = _defYRight;
tbS2_XLeft.Text = _defXLeft;
tbS2_XRight.Text = _defXRight;
tbS2_YLeft.Text = _defYLeft;
tbS2_YRight.Text = _defYRight;
}
/// <summary>分级日志写入:按日期生成txt日志,区分发送/接收/异常/系统</summary>
private void WriteLog(string logType, string msg)
{
try
{
string fname = $"{DateTime.Now:yyyy-MM-dd}.txt";
string path = Path.Combine("ModbusLog", fname);
string line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}]【{logType}】{msg}{Environment.NewLine}";
File.AppendAllText(path, line);
}
catch { }
}
/// <summary>打开当日日志文档按钮事件</summary>
private void BtnOpenLogFolder_Click(object sender, RoutedEventArgs e)
{
string fname = $"{DateTime.Now:yyyy-MM-dd}.txt";
string path = Path.Combine("ModbusLog", fname);
if (File.Exists(path))
System.Diagnostics.Process.Start("notepad.exe", path);
else
MessageBox.Show("今日无日志");
}
#endregion
#region 连接、断开按钮逻辑
/// <summary>连接按钮:建立TCP+Modbus主站,启动轮询状态机</summary>
private async void BtnConnect_Click(object sender, RoutedEventArgs e)
{
if (_curState != PollState.Idle)
{
MessageBox.Show("已处于连接轮询状态");
return;
}
string ip = txtGlobalIp.Text.Trim();
if (!int.TryParse(txtPort1.Text, out int port))
{
MessageBox.Show("端口格式错误");
return;
}
//连接瞬间锁定从站ID,后续轮询不再更改
byte.TryParse(txtSlaveId1.Text, out _slaveId1);
byte.TryParse(txtSlaveId2.Text, out _slaveId2);
try
{
_modbusTcpClient = new TcpClient { SendTimeout = CmdTimeoutMs, ReceiveTimeout = CmdTimeoutMs };
await _modbusTcpClient.ConnectAsync(ip, port);
_modbusMaster = ModbusIpMaster.CreateIp(_modbusTcpClient);
WriteLog("系统", $"TCP连接成功 IP:{ip} Port:{port} S1ID:{_slaveId1} S2ID:{_slaveId2}");
MessageBox.Show("连接成功,启动轮询");
StartStateMachinePoll();
}
catch (Exception ex)
{
WriteLog("异常", $"连接失败:{ex.Message}");
CloseConn();
MessageBox.Show("连接失败:" + ex.Message);
}
}
/// <summary>断开按钮:停止轮询、关闭通信链路</summary>
private void BtnDisConnect_Click(object sender, RoutedEventArgs e)
{
CloseConn();
WriteLog("系统", "手动断开通信,停止轮询,界面数据保持当前值");
MessageBox.Show("已断开");
}
/// <summary>断开链路,停止轮询,界面数据不恢复默认</summary>
private void CloseConn()
{
_curState = PollState.Idle;
_pollCts?.Cancel();
_pollCts?.Dispose();
_pollCts = null;
_modbusTcpClient?.Close();
_modbusTcpClient?.Dispose();
_modbusTcpClient = null;
_modbusMaster = null;
}
#endregion
#region 状态机轮询主体逻辑
/// <summary>启动轮询状态机,后台循环依次采集从站1→从站2→延时等待</summary>
private void StartStateMachinePoll()
{
_pollCts = new CancellationTokenSource();
_curState = PollState.PollSlaveOne;
_pollTask = Task.Run(async () =>
{
while (!_pollCts.Token.IsCancellationRequested && _curState != PollState.Idle)
{
switch (_curState)
{
case PollState.PollSlaveOne:
await PollSingleSlave(_slaveId1, true);
_curState = PollState.PollSlaveTwo;
break;
case PollState.PollSlaveTwo:
await PollSingleSlave(_slaveId2, false);
_curState = PollState.LoopDelay;
break;
case PollState.LoopDelay:
await Task.Delay(LoopWaitMs, _pollCts.Token);
_curState = PollState.PollSlaveOne;
break;
}
}
}, _pollCts.Token);
}
/// <summary>单个从站全量采集:寄存器+DI输入+线圈输出</summary>
/// <param name="sid">目标从站ID</param>
/// <param name="isSlave1">true=从站1,false=从站2,区分UI缓存赋值</param>
private async Task PollSingleSlave(byte sid, bool isSlave1)
{
await Task.Run(() =>
{
ushort[] regRes = new ushort[_regAddrArr.Length];
bool regReadOk = true;
bool[] diRes = null;
bool[] coilRes = null;
#region 1.逐个读取8路保持寄存器03,返回原始整数÷100转为两位小数
if (_curState == PollState.Idle) return;
WriteLog("发送", $"从站{sid}【03寄存器】开始逐个发送采集帧,共{_regAddrArr.Length}个地址");
for (int i = 0; i < _regAddrArr.Length; i++)
{
ushort addr = _regAddrArr[i];
try
{
WriteLog("发送", $"从站{sid}单点读寄存器逻辑地址:{addr}(UI标签={addr + 40001})");
var tmp = _modbusMaster.ReadHoldingRegisters(sid, addr, 1);
regRes[i] = tmp[0];
WriteLog("接收", $"从站{sid}逻辑{addr}原始整数:{tmp[0]},折算:{tmp[0] / 100.0:F2}");
}
catch (Exception ex)
{
WriteLog("异常", $"从站{sid}逻辑地址{addr}(UI{addr + 40001})读取异常:{ex.Message}");
regReadOk = false;
}
}
if (regReadOk)
WriteLog("接收", $"从站{sid}【03寄存器】全部读取正常");
#endregion
#region 2.读取DI输入02(x0-x15),UI10001需减10001偏移得到底层位地址
if (_curState == PollState.Idle) return;
WriteLog("发送", $"从站{sid}【02 DI】发送帧 起始:{DiStart} 点数:{DiCount}");
try
{
ushort diRealAddr = DiStart - 10001;
diRes = _modbusMaster.ReadInputs(sid, diRealAddr, DiCount);
WriteLog("接收", $"从站{sid}【02 DI】正常返回16点位");
}
catch (Exception ex)
{
WriteLog("异常", $"从站{sid}【02 DI异常】{ex.Message}");
}
#endregion
#region 3.读取线圈01,UI地址减10001偏移
if (_curState == PollState.Idle) return;
WriteLog("发送", $"从站{sid}【01 Coil】发送帧 起始:{CoilStart} 点数:{CoilCount}");
try
{
ushort coilRealAddr = CoilStart - 10001;
coilRes = _modbusMaster.ReadCoils(sid, coilRealAddr, CoilCount);
WriteLog("接收", $"从站{sid}【01 Coil】正常返回16点位");
}
catch (Exception ex)
{
WriteLog("异常", $"从站{sid}【01 Coil异常】{ex.Message}");
}
#endregion
#region UI刷新:成功数值÷100保留2位小数;异常沿用缓存原值
Application.Current.Dispatcher.Invoke(() =>
{
string[] buf = isSlave1 ? _s1RegBuf : _s2RegBuf;
if (regReadOk)
{
for (int i = 0; i < regRes.Length; i++)
{
double val = regRes[i] / 100.0;
buf[i] = val.ToString("0.00");
}
}
string xLText = "", xRText = "";
string yLText = "", yRText = "";
//DI拼接,ON后面带*
if (diRes != null)
{
for (int i = 0; i < 8; i++)
{
if (diRes[i])
xLText += $"X{i}: ON *{Environment.NewLine}";
else
xLText += $"X{i}: OFF{Environment.NewLine}";
}
for (int i = 8; i < 16; i++)
{
if (diRes[i])
xRText += $"X{i + 2}: ON *{Environment.NewLine}";
else
xRText += $"X{i + 2}: OFF{Environment.NewLine}";
}
if (isSlave1) { _s1XLeftBuf = xLText; _s1XRightBuf = xRText; }
else { _s2XLeftBuf = xLText; _s2XRightBuf = xRText; }
}
else
{
xLText = isSlave1 ? _s1XLeftBuf : _s2XLeftBuf;
xRText = isSlave1 ? _s1XRightBuf : _s2XRightBuf;
}
//线圈拼接,ON后面带*
if (coilRes != null)
{
for (int i = 0; i < 8; i++)
{
if (coilRes[i])
yLText += $"Y{i}: ON *{Environment.NewLine}";
else
yLText += $"Y{i}: OFF{Environment.NewLine}";
}
for (int i = 8; i < 16; i++)
{
if (coilRes[i])
yRText += $"Y{i + 2}: ON *{Environment.NewLine}";
else
yRText += $"Y{i + 2}: OFF{Environment.NewLine}";
}
if (isSlave1) { _s1YLeftBuf = yLText; _s1YRightBuf = yRText; }
else { _s2YLeftBuf = yLText; _s2YRightBuf = yRText; }
}
else
{
yLText = isSlave1 ? _s1YLeftBuf : _s2YLeftBuf;
yRText = isSlave1 ? _s1YRightBuf : _s2YRightBuf;
}
//赋值
if (isSlave1)
{
tbS1_Freq.Text = buf[0];
tbS1_AccTime.Text = buf[1];
tbS1_GR1.Text = buf[2];
tbS1_GR2.Text = buf[3];
tbS1_GR3.Text = buf[4];
tbS1_Climb.Text = buf[5];
tbS1_Res1.Text = buf[6];
tbS1_Res2.Text = buf[7];
tbS1_XLeft.Text = _s1XLeftBuf;
tbS1_XRight.Text = _s1XRightBuf;
tbS1_YLeft.Text = _s1YLeftBuf;
tbS1_YRight.Text = _s1YRightBuf;
}
else
{
tbS2_Freq.Text = buf[0];
tbS2_AccTime.Text = buf[1];
tbS2_GR1.Text = buf[2];
tbS2_GR2.Text = buf[3];
tbS2_GR3.Text = buf[4];
tbS2_Climb.Text = buf[5];
tbS2_Res1.Text = buf[6];
tbS2_Res2.Text = buf[7];
tbS2_XLeft.Text = _s2XLeftBuf;
tbS2_XRight.Text = _s2XRightBuf;
tbS2_YLeft.Text = _s2YLeftBuf;
tbS2_YRight.Text = _s2YRightBuf;
}
});
#endregion
});
}
#endregion
}
}
11、日志文件

从日志上可以看出单个数据帧的响应时间大概40MS
12、运行界面
