C# WinForms 基于 Socket 手搓 Modbus TCP 调试助手(支持 01/02/03/04/05/06/0F/10)
本文使用 C#、WinForms 和原生
Socket从零实现一个 Modbus TCP 调试助手,不依赖 NModbus 等第三方通信库。项目基于 .NET Framework 4.8,支持连接与断开、连接/收发超时、请求报文构造、响应报文解析,以及 01、02、03、04、05、06、0F、10 八种常用功能码。
一、项目功能
这个小工具主要用于学习 Modbus TCP 报文结构,也可以用于 PLC、远程 I/O、传感器、仪表等设备的联调。
实现的功能如下:
- 使用原生
Socket建立 Modbus TCP 连接; - 支持 IP、端口和 Unit ID 设置;
- 支持 3 秒连接超时;
- 支持 2 秒发送和接收超时;
- 支持功能码 01:读取线圈;
- 支持功能码 02:读取离散输入;
- 支持功能码 03:读取保持寄存器;
- 支持功能码 04:读取输入寄存器;
- 支持功能码 05:写单个线圈;
- 支持功能码 06:写单个保持寄存器;
- 支持功能码 0F:写多个线圈;
- 支持功能码 10:写多个保持寄存器;
- 显示发送报文、响应报文及解析结果;
- 退出程序时保存上次使用的 IP、端口和 Unit ID。
本文不是简单调用现成 Modbus 库,而是从 MBAP 报头、PDU 组装、TCP 完整收发和数据解析开始实现,因此比较适合理解 Modbus TCP 协议底层过程。
二、开发环境
- Visual Studio
- C#
- WinForms
- .NET Framework 4.8
- 通信方式:TCP Socket
- Modbus TCP 默认端口:502
项目主要由两个类组成:
text
modbusTCP
├─ Form1.cs // 界面事件、输入处理和数据显示
├─ Form1.Designer.cs // WinForms 界面设计代码
├─ ModbusTcpClient.cs // Socket 通信和 Modbus TCP 协议处理
├─ Program.cs
└─ Properties
└─ Settings.settings // 保存 IP、端口和 Unit ID
三、Modbus TCP 报文结构
Modbus TCP 的应用数据单元称为 ADU,其结构为:
text
Modbus TCP ADU = MBAP Header(7 字节)+ PDU
PDU = Function Code(1 字节)+ Data
1. MBAP 报头
| 字节位置 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0~1 | Transaction ID | 2 字节 | 事务标识符,用于匹配请求和响应 |
| 2~3 | Protocol ID | 2 字节 | Modbus TCP 固定为 0x0000 |
| 4~5 | Length | 2 字节 | 后续字节数,即 Unit ID + PDU 长度 |
| 6 | Unit ID | 1 字节 | 单元标识符,经过网关访问串口从站时尤其重要 |
需要特别注意:Length 字段不是整个 ADU 的长度,也不包含前面的 6 个字节,而是:
csharp
Length = 1 + pdu.Length; // 1 表示 Unit ID
2. Modbus TCP 为什么不需要 CRC
Modbus RTU 报文末尾包含 CRC16,而 Modbus TCP 不需要再附加 CRC。因为 TCP/IP 各层已经提供校验和、确认、重传等机制。
因此,不能把 RTU 报文末尾的 CRC 直接复制到 Modbus TCP 报文中。
四、创建 Socket 客户端
核心通信字段如下:
csharp
private Socket client;
private readonly object syncRoot = new object();
private int connectTimeout = 3000;
private int sendTimeout = 2000;
private int receiveTimeout = 2000;
private ushort transactionId = 0;
public byte[] LastRequest { get; private set; } = new byte[0];
public byte[] LastResponse { get; private set; } = new byte[0];
这里使用 lock 是为了避免同一个 Socket 被多个线程同时发送请求。Modbus TCP 虽然依靠 Transaction ID 区分事务,但这个简单助手采用"一问一答"的同步模式,同一时刻只处理一个请求更容易保证报文顺序。
五、实现带超时的 TCP 连接
普通的 Socket.Connect() 在网络异常时可能等待较长时间。本文使用 BeginConnect + WaitOne + EndConnect 实现连接超时:
csharp
public bool Connect(string host, int port)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("IP 地址不能为空", nameof(host));
if (port < 1 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port), "端口必须在 1~65535 之间");
Disconnect();
Socket newSocket = null;
IAsyncResult asyncResult = null;
try
{
newSocket = new Socket(
AddressFamily.InterNetwork,
SocketType.Stream,
ProtocolType.Tcp);
asyncResult = newSocket.BeginConnect(host.Trim(), port, null, null);
if (!asyncResult.AsyncWaitHandle.WaitOne(connectTimeout))
throw new TimeoutException("TCP 连接超时");
newSocket.EndConnect(asyncResult);
newSocket.SendTimeout = sendTimeout;
newSocket.ReceiveTimeout = receiveTimeout;
newSocket.NoDelay = true;
client = newSocket;
return true;
}
catch
{
if (newSocket != null)
newSocket.Close();
client = null;
throw;
}
finally
{
if (asyncResult != null && asyncResult.AsyncWaitHandle != null)
asyncResult.AsyncWaitHandle.Close();
}
}
几个关键点:
BeginConnect发起连接;WaitOne(connectTimeout)最多等待指定时间;EndConnect完成异步连接并抛出真实连接异常;SendTimeout和ReceiveTimeout只控制发送/接收,不控制连接;NoDelay = true关闭 Nagle 算法,避免小报文等待合并;- 使用
throw;保留原始异常堆栈,不能先return false再throw,因为return后面的代码永远不会执行。
如果希望按钮点击后界面完全不卡顿,可进一步改为 ConnectAsync 或把同步连接放入异步业务流程。本文为了突出 Socket 与 Modbus 报文原理,保留同步调用方式。
安全断开 Socket
csharp
public void Disconnect()
{
lock (syncRoot)
{
if (client == null)
return;
try
{
if (client.Connected)
client.Shutdown(SocketShutdown.Both);
}
catch (SocketException)
{
// 对端已经断开时,Shutdown 可能失败,继续释放即可。
}
finally
{
client.Close();
client = null;
}
}
}
注意:Socket.Connected 只反映最后一次 I/O 时的状态,不能作为远端当前一定在线的绝对依据。真正的断线通常会在下一次 Send 或 Receive 时暴露。
六、构造 MBAP 报头与完整 ADU
下面的方法把 Transaction ID、Unit ID 和 PDU 组合成完整的 Modbus TCP ADU:
csharp
private static byte[] BuildAdu(ushort transactionId, byte unitId, byte[] pdu)
{
if (pdu == null)
throw new ArgumentNullException(nameof(pdu));
ushort length = (ushort)(pdu.Length + 1);
byte[] adu = new byte[7 + pdu.Length];
adu[0] = (byte)(transactionId >> 8);
adu[1] = (byte)(transactionId & 0xFF);
adu[2] = 0x00;
adu[3] = 0x00;
adu[4] = (byte)(length >> 8);
adu[5] = (byte)(length & 0xFF);
adu[6] = unitId;
Array.Copy(pdu, 0, adu, 7, pdu.Length);
return adu;
}
其中 Array.Copy 的复制长度必须是 pdu.Length,不能使用包含 Unit ID 的 length,否则会发生数组越界。
事务号每发送一次请求递增一次:
csharp
private ushort GetNextTransactionId()
{
ushort current = unchecked(++transactionId);
if (current == 0)
current = unchecked(++transactionId);
return current;
}
ushort 超过 65535 后会回到 0。示例跳过 0 不是 Modbus TCP 的强制要求,只是便于调试时识别尚未初始化的事务号。
七、构造读取和单写 PDU
功能码 01、02、03、04 的请求结构相同:
text
功能码 + 起始地址(2 字节)+ 读取数量(2 字节)
功能码 05、06 也是 5 字节 PDU,只是最后两个字节表示写入值。
csharp
private static byte[] BuildSimplePdu(
byte functionCode,
ushort startAddress,
ushort quantityOrValue)
{
byte[] pdu = new byte[5];
pdu[0] = functionCode;
pdu[1] = (byte)(startAddress >> 8);
pdu[2] = (byte)(startAddress & 0xFF);
if (functionCode == 0x05)
{
// 写单线圈:ON = FF 00,OFF = 00 00。
pdu[3] = quantityOrValue == 1 ? (byte)0xFF : (byte)0x00;
pdu[4] = 0x00;
}
else
{
pdu[3] = (byte)(quantityOrValue >> 8);
pdu[4] = (byte)(quantityOrValue & 0xFF);
}
return pdu;
}
示例:读取 10 个保持寄存器
假设:
- Transaction ID:1;
- Unit ID:1;
- 起始地址:0;
- 数量:10;
- 功能码:03。
请求报文为:
text
00 01 00 00 00 06 01 03 00 00 00 0A
拆解如下:
text
00 01 Transaction ID
00 00 Protocol ID
00 06 Length = Unit ID 1 字节 + PDU 5 字节
01 Unit ID
03 读取保持寄存器
00 00 起始地址
00 0A 数量 10
八、构造 0F 写多个线圈 PDU
多个线圈按位打包,Modbus 规定从每个数据字节的最低位开始存放。
csharp
private static byte[] BuildWriteMultipleCoilsPdu(
ushort startAddress,
bool[] values)
{
if (values == null || values.Length == 0)
throw new ArgumentException("线圈数据不能为空", nameof(values));
ushort quantity = (ushort)values.Length;
byte byteCount = (byte)((quantity + 7) / 8);
byte[] pdu = new byte[6 + byteCount];
pdu[0] = 0x0F;
pdu[1] = (byte)(startAddress >> 8);
pdu[2] = (byte)(startAddress & 0xFF);
pdu[3] = (byte)(quantity >> 8);
pdu[4] = (byte)(quantity & 0xFF);
pdu[5] = byteCount;
for (int i = 0; i < values.Length; i++)
{
if (values[i])
pdu[6 + i / 8] |= (byte)(1 << (i % 8));
}
return pdu;
}
假设从地址 0 开始写入 8 个线圈:
text
ON, ON, OFF, OFF, ON, OFF, ON, ON
从低位到高位依次排列为 11001011,显示为高位到低位时是 11010011,对应数据字节 0xD3。
九、构造 10 写多个寄存器 PDU
每个寄存器占 2 字节,按照高字节在前、低字节在后的顺序发送:
csharp
private static byte[] BuildWriteMultipleRegistersPdu(
ushort startAddress,
ushort[] values)
{
if (values == null || values.Length == 0)
throw new ArgumentException("寄存器数据不能为空", nameof(values));
ushort quantity = (ushort)values.Length;
byte[] pdu = new byte[6 + quantity * 2];
pdu[0] = 0x10;
pdu[1] = (byte)(startAddress >> 8);
pdu[2] = (byte)(startAddress & 0xFF);
pdu[3] = (byte)(quantity >> 8);
pdu[4] = (byte)(quantity & 0xFF);
pdu[5] = (byte)(quantity * 2);
for (int i = 0; i < values.Length; i++)
{
pdu[6 + i * 2] = (byte)(values[i] >> 8);
pdu[7 + i * 2] = (byte)(values[i] & 0xFF);
}
return pdu;
}
高字节必须使用:
csharp
(byte)(values[i] >> 8)
不能写成:
csharp
(byte)(values[i] << 8)
因为 ushort 左移 8 位后再强制转换成 byte,低 8 位通常会变成 0,导致寄存器高字节丢失。
示例:写两个保持寄存器
条件如下:
- Transaction ID:1;
- Unit ID:1;
- 起始地址:2;
- 写入数量:2;
- 写入值:1、1。
完整请求报文为:
text
00 01 00 00 00 0B 01 10 00 02 00 02 04 00 01 00 01
十、TCP 发送为什么必须循环
Socket.Send() 的返回值表示本次真正发送的字节数。一次调用并不保证发送完整个数组,因此要循环发送:
csharp
private void SendAll(byte[] request)
{
int offset = 0;
while (offset < request.Length)
{
int sent = client.Send(
request,
offset,
request.Length - offset,
SocketFlags.None);
if (sent <= 0)
throw new SocketException();
offset += sent;
}
}
判断条件应使用 sent <= 0。如果返回 0 而代码只判断 < 0,循环无法继续推进,可能形成死循环。
十一、TCP 接收为什么不能只 Receive 一次
TCP 是字节流协议,没有 Modbus 报文边界。一次 Receive() 可能只收到一部分数据,也可能缓冲区中已经包含后续数据。
因此需要实现"指定多少字节,就循环接收多少字节":
csharp
private byte[] ReceiveExact(int count)
{
byte[] buffer = new byte[count];
int total = 0;
while (total < count)
{
int received = client.Receive(
buffer,
total,
count - total,
SocketFlags.None);
if (received == 0)
throw new IOException("远程设备已关闭连接");
total += received;
}
return buffer;
}
接收一个 Modbus TCP 响应的正确步骤是:
- 先准确读取 7 字节 MBAP;
- 从 MBAP 的第 4、5 字节解析
Length; - MBAP 已经读取了 Unit ID,因此继续读取
Length - 1字节; - 将 MBAP 和剩余数据合并成完整响应。
csharp
byte[] header = ReceiveExact(7);
ushort responseLength = (ushort)((header[4] << 8) | header[5]);
if (responseLength < 2 || responseLength > 254)
throw new IOException("MBAP Length 字段不合法:" + responseLength);
byte[] remaining = ReceiveExact(responseLength - 1);
byte[] response = Combine(header, remaining);
这里检查的是 responseLength,不能误检查用户输入的读取数量。
数组合并方法如下:
csharp
private static byte[] Combine(byte[] first, byte[] second)
{
byte[] result = new byte[first.Length + second.Length];
Array.Copy(first, 0, result, 0, first.Length);
Array.Copy(second, 0, result, first.Length, second.Length);
return result;
}
十二、校验响应报文
只收到字节并不代表响应有效,至少要校验以下字段:
csharp
private static void ValidateMbap(
byte[] header,
ushort expectedTransactionId,
byte expectedUnitId)
{
ushort responseTransactionId =
(ushort)((header[0] << 8) | header[1]);
ushort protocolId =
(ushort)((header[2] << 8) | header[3]);
ushort length =
(ushort)((header[4] << 8) | header[5]);
if (responseTransactionId != expectedTransactionId)
throw new IOException("事务标识符不一致");
if (protocolId != 0)
throw new IOException("Protocol ID 不是 0");
if (length < 2 || length > 254)
throw new IOException("MBAP Length 字段不合法:" + length);
if (header[6] != expectedUnitId)
throw new IOException("Unit ID 不一致");
}
还需要判断 Modbus 异常响应。异常响应的功能码等于原功能码加 0x80:
csharp
private static void ValidateFunction(
byte[] response,
byte expectedFunctionCode)
{
byte actualFunctionCode = response[7];
if (actualFunctionCode == (byte)(expectedFunctionCode | 0x80))
{
byte exceptionCode = response[8];
throw new IOException(
"Modbus 从站返回异常,异常码:0x" +
exceptionCode.ToString("X2"));
}
if (actualFunctionCode != expectedFunctionCode)
throw new IOException("响应功能码不一致");
}
常见异常码:
| 异常码 | 含义 |
|---|---|
| 01 | 非法功能 |
| 02 | 非法数据地址 |
| 03 | 非法数据值 |
| 04 | 从站设备故障 |
| 06 | 从站设备忙 |
十三、解析 01/02 线圈响应
读取线圈和离散输入时,数据从完整 ADU 的第 9 个下标开始:
text
0~6:MBAP
7:功能码
8:字节数
9~:线圈数据
解析代码如下:
csharp
private static bool[] ParseBits(byte[] response, ushort quantity)
{
int expectedByteCount = (quantity + 7) / 8;
if (response[8] != expectedByteCount)
throw new IOException("线圈响应字节数不正确");
bool[] values = new bool[quantity];
for (int i = 0; i < quantity; i++)
{
int byteIndex = 9 + i / 8;
int bitIndex = i % 8;
values[i] =
(response[byteIndex] & (1 << bitIndex)) != 0;
}
return values;
}
线圈仍然按照低位优先解析。例如数据字节为 0x01,表示第一个线圈为 ON,其余线圈为 OFF。
十四、解析 03/04 寄存器响应
保持寄存器和输入寄存器均为每个寄存器 2 字节,高字节在前:
csharp
private static ushort[] ParseRegisters(
byte[] response,
ushort quantity)
{
int expectedByteCount = quantity * 2;
if (response[8] != expectedByteCount)
throw new IOException("寄存器响应字节数不正确");
ushort[] values = new ushort[quantity];
for (int i = 0; i < quantity; i++)
{
int index = 9 + i * 2;
values[i] = (ushort)(
(response[index] << 8) |
response[index + 1]);
}
return values;
}
如果响应数据为:
text
00 0A 00 14 00 1E
解析结果就是:
text
10, 20, 30
十五、封装一次完整的读操作
以读取线圈为例,完整流程如下:
csharp
public bool[] ReadCoils(
byte unitId,
ushort startAddress,
ushort quantity)
{
lock (syncRoot)
{
EnsureConnected();
ushort currentTransactionId = GetNextTransactionId();
byte[] pdu = BuildSimplePdu(0x01, startAddress, quantity);
byte[] request = BuildAdu(currentTransactionId, unitId, pdu);
LastRequest = (byte[])request.Clone();
LastResponse = new byte[0];
SendAll(request);
byte[] header = ReceiveExact(7);
ValidateMbap(header, currentTransactionId, unitId);
ushort responseLength =
(ushort)((header[4] << 8) | header[5]);
byte[] remaining = ReceiveExact(responseLength - 1);
byte[] response = Combine(header, remaining);
ValidateFunction(response, 0x01);
LastResponse = (byte[])response.Clone();
return ParseBits(response, quantity);
}
}
private void EnsureConnected()
{
if (client == null || !client.Connected)
throw new InvalidOperationException(
"尚未连接 Modbus TCP 服务器");
}
LastRequest 和 LastResponse 使用 Clone() 保存快照,避免后续修改原数组时影响界面上需要显示的历史报文。
读取离散输入只需把功能码改为 0x02;读取保持寄存器和输入寄存器则分别使用 0x03、0x04,最后调用 ParseRegisters()。
十六、WinForms 功能码分发
界面使用一个下拉框选择功能码,一个"执行"按钮完成所有操作:
csharp
private void ExecuteFunction(
byte functionCode,
byte unitId,
ushort startAddress,
ushort quantity,
string input)
{
switch (functionCode)
{
case 0x01:
ExecuteReadCoils(unitId, startAddress, quantity);
break;
case 0x02:
ExecuteReadDiscreteInputs(unitId, startAddress, quantity);
break;
case 0x03:
ExecuteReadHoldingRegisters(unitId, startAddress, quantity);
break;
case 0x04:
ExecuteReadInputRegisters(unitId, startAddress, quantity);
break;
case 0x05:
ExecuteWriteSingleCoil(unitId, startAddress, input);
break;
case 0x06:
ExecuteWriteSingleRegister(unitId, startAddress, input);
break;
case 0x0F:
ExecuteWriteMultipleCoils(unitId, startAddress, quantity, input);
break;
case 0x10:
ExecuteWriteMultipleRegisters(unitId, startAddress, quantity, input);
break;
default:
throw new NotSupportedException("不支持的功能码");
}
}
界面下拉选项可以设置为:
text
01 读取输出线圈
02 读取离散输入
03 读取保持寄存器
04 读取输入寄存器
05 写单个线圈
06 写单个寄存器
15 写多个线圈
16 写多个寄存器
这里界面显示的 15 和 16 是十进制,对应十六进制功能码 0x0F 和 0x10。
输入格式建议
- 05:输入
0或1; - 06:输入一个
ushort数值; - 0F:输入
1,0,1,1,0; - 10:输入
10,20,30,40。
多个线圈输入转换:
csharp
bool[] values = input
.Split(',')
.Select(x => x.Trim() == "1")
.ToArray();
多个寄存器输入转换:
csharp
ushort[] values = input
.Split(',')
.Select(x => Convert.ToUInt16(x.Trim()))
.ToArray();
转换后必须检查数组长度是否与界面输入的数量一致。
十七、显示发送、响应和解析结果
字节数组可按十六进制显示:
csharp
private static string ToHex(byte[] data)
{
return string.Join(" ", data.Select(x => x.ToString("X2")));
}
调用示例:
csharp
txtRequest.Text = ToHex(modbusClient.LastRequest);
txtResponse.Text = ToHex(modbusClient.LastResponse);
线圈结果显示:
csharp
private static string FormatValues(
bool[] values,
ushort startAddress)
{
StringBuilder builder = new StringBuilder();
for (int i = 0; i < values.Length; i++)
{
builder.AppendFormat(
"地址 {0} = {1}",
startAddress + i,
values[i] ? "ON" : "OFF");
builder.AppendLine();
}
return builder.ToString();
}
寄存器结果显示:
csharp
private static string FormatValues(
ushort[] values,
ushort startAddress)
{
StringBuilder builder = new StringBuilder();
for (int i = 0; i < values.Length; i++)
{
builder.AppendFormat(
"地址 {0} = {1}",
startAddress + i,
values[i]);
builder.AppendLine();
}
return builder.ToString();
}
十八、保存上次连接参数
在项目属性的 Settings 中建立:
| 名称 | 类型 | 默认值 |
|---|---|---|
| LastIp | string | 127.0.0.1 |
| LastPort | int | 502 |
| LastUnitId | int | 1 |
窗体启动时加载:
csharp
private void LoadUserSettings()
{
txtIp.Text = Properties.Settings.Default.LastIp;
txtPort.Text = Properties.Settings.Default.LastPort.ToString();
txtUnitId.Text = Properties.Settings.Default.LastUnitId.ToString();
}
窗体关闭时保存:
csharp
private void Form1_FormClosing(
object sender,
FormClosingEventArgs e)
{
Properties.Settings.Default.LastIp = txtIp.Text.Trim();
Properties.Settings.Default.LastPort = Convert.ToInt32(txtPort.Text);
Properties.Settings.Default.LastUnitId = Convert.ToInt32(txtUnitId.Text);
Properties.Settings.Default.Save();
}
实际项目中建议把 Convert 换成 TryParse,避免输入为空或格式错误时程序退出。
十九、调试步骤
可以使用 Modbus Slave、Modbus Poll、网络调试助手,或者支持 Modbus TCP 的 PLC/仪表进行测试。
推荐流程:
- 确认电脑与设备处于同一网段;
- 使用
ping检查 IP 是否可达; - 检查设备是否启用 Modbus TCP Server;
- 确认端口,通常为 502;
- 确认 Unit ID;
- 先使用 03 读取少量保持寄存器;
- 再测试 01/02/04;
- 写操作前确认测试地址允许写入;
- 对照工具中的十六进制请求和响应定位问题。
常见问题 1:能连接,但是没有响应
可能原因:
- Unit ID 错误;
- 起始地址超出设备范围;
- 设备手册使用 40001 编号,而协议地址实际从 0 开始;
- 功能码不被设备支持;
- 设备只允许一个客户端连接;
- 防火墙或交换机丢弃连接;
- 端口并不是 502。
常见问题 2:一次 Receive 收不到完整报文
这是 TCP 字节流的正常现象,必须使用 ReceiveExact() 循环读取。不要把"一次 Receive"当作"一帧 Modbus TCP 报文"。
常见问题 3:写多个寄存器时数值异常
检查高字节代码是否错误写成了左移:
csharp
// 正确
pdu[index] = (byte)(value >> 8);
// 错误
pdu[index] = (byte)(value << 8);
常见问题 4:05 写线圈失败
Modbus 规定:
text
FF 00 = ON
00 00 = OFF
不能直接发送 00 01 代表 ON。
常见问题 5:WinForms 点击连接后短暂卡顿
当前示例是同步等待连接和收发。超时期间 UI 线程会被占用。可进一步升级为:
Socket.ConnectAsync();async/await;CancellationToken;- 异步
SendAsync/ReceiveAsync; - 操作期间禁用"执行"按钮;
- 使用取消按钮中止正在进行的连接或读取。
二十、原始实现中值得修正的几个细节
在手搓协议的过程中,下面几类错误非常常见:
1. 不要直接访问空 Socket
csharp
// 不够安全
if (!client.Connected)
// 建议
if (client == null || !client.Connected)
2. 不要在 return 后写 throw
csharp
// throw 永远不会执行
return false;
throw;
捕获异常后要么返回状态,要么抛出异常,两种错误处理方式选择一种。通信层更建议抛出异常,由 WinForms 界面统一显示错误消息。
3. finally 中先判断 IAsyncResult
如果 BeginConnect 自身就抛出异常,asyncResult 可能仍然为 null。
csharp
if (asyncResult != null && asyncResult.AsyncWaitHandle != null)
asyncResult.AsyncWaitHandle.Close();
4. 06 应调用语义正确的方法
虽然 05 和 06 的请求 PDU 长度相同,但界面处理 06 时应调用 WriteSingleRegister(),不要为了代码刚好可以运行而调用名为 WriteSingleCoil() 的方法。
5. 写操作需要校验响应回显
05、06 的正常响应应回显请求中的功能码、地址和值;0F、10 的正常响应应回显功能码、起始地址和数量。正式项目不能只"收到响应就认为写成功",还应逐字段比较。
二十一、项目后续可扩展方向
目前版本已经实现 Modbus TCP 的核心通信流程,后续可以继续增加:
- 异步连接、发送和接收;
- 取消正在执行的操作;
- 自动轮询功能;
- 通信日志和时间戳;
- 十六进制/十进制显示切换;
- 线圈 LED 状态显示;
- 浮点数、32 位整数和字符串解析;
- ABCD、BADC、CDAB、DCBA 字节序切换;
- 自动重连;
- 多设备连接管理;
- 将通信类封装为 DLL 或 NuGet 包;
- 自定义
ModbusException,区分网络异常与从站异常。
二十二、总结
本文使用 C# WinForms 和原生 Socket 实现了一个 Modbus TCP 调试助手,完整梳理了以下关键点:
- MBAP 报头的 7 字节结构;
- Transaction ID、Protocol ID、Length 和 Unit ID 的含义;
- 01/02/03/04/05/06/0F/10 八种功能码的 PDU 构造;
- 多线圈低位优先打包;
- 寄存器高字节在前;
- Socket 完整发送;
- 先读 MBAP、再根据 Length 定长接收;
- 线圈和寄存器数据解析;
- WinForms 功能分发、报文显示和参数保存;
- 连接超时、异常响应与常见代码陷阱。
使用第三方库可以更快完成项目,但自己实现一次底层报文,对理解 Modbus TCP、排查现场通信问题以及后续封装工业通信组件都很有帮助。
提醒:写操作可能改变设备状态。连接真实 PLC、变频器或现场执行机构前,请先确认寄存器地址和设备安全状态,建议先在仿真从站中完成测试。