C# WinForms 基于 Socket 手搓 Modbus TCP 调试助手(支持 01/02/03/04/05/06/0F/10)

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();
    }
}

几个关键点:

  1. BeginConnect 发起连接;
  2. WaitOne(connectTimeout) 最多等待指定时间;
  3. EndConnect 完成异步连接并抛出真实连接异常;
  4. SendTimeoutReceiveTimeout 只控制发送/接收,不控制连接;
  5. NoDelay = true 关闭 Nagle 算法,避免小报文等待合并;
  6. 使用 throw; 保留原始异常堆栈,不能先 return falsethrow,因为 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 时的状态,不能作为远端当前一定在线的绝对依据。真正的断线通常会在下一次 SendReceive 时暴露。


六、构造 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 响应的正确步骤是:

  1. 先准确读取 7 字节 MBAP;
  2. 从 MBAP 的第 4、5 字节解析 Length
  3. MBAP 已经读取了 Unit ID,因此继续读取 Length - 1 字节;
  4. 将 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 服务器");
}

LastRequestLastResponse 使用 Clone() 保存快照,避免后续修改原数组时影响界面上需要显示的历史报文。

读取离散输入只需把功能码改为 0x02;读取保持寄存器和输入寄存器则分别使用 0x030x04,最后调用 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 是十进制,对应十六进制功能码 0x0F0x10

输入格式建议

  • 05:输入 01
  • 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/仪表进行测试。

推荐流程:

  1. 确认电脑与设备处于同一网段;
  2. 使用 ping 检查 IP 是否可达;
  3. 检查设备是否启用 Modbus TCP Server;
  4. 确认端口,通常为 502;
  5. 确认 Unit ID;
  6. 先使用 03 读取少量保持寄存器;
  7. 再测试 01/02/04;
  8. 写操作前确认测试地址允许写入;
  9. 对照工具中的十六进制请求和响应定位问题。

常见问题 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 调试助手,完整梳理了以下关键点:

  1. MBAP 报头的 7 字节结构;
  2. Transaction ID、Protocol ID、Length 和 Unit ID 的含义;
  3. 01/02/03/04/05/06/0F/10 八种功能码的 PDU 构造;
  4. 多线圈低位优先打包;
  5. 寄存器高字节在前;
  6. Socket 完整发送;
  7. 先读 MBAP、再根据 Length 定长接收;
  8. 线圈和寄存器数据解析;
  9. WinForms 功能分发、报文显示和参数保存;
  10. 连接超时、异常响应与常见代码陷阱。

使用第三方库可以更快完成项目,但自己实现一次底层报文,对理解 Modbus TCP、排查现场通信问题以及后续封装工业通信组件都很有帮助。

提醒:写操作可能改变设备状态。连接真实 PLC、变频器或现场执行机构前,请先确认寄存器地址和设备安全状态,建议先在仿真从站中完成测试。


相关推荐
-银雾鸢尾-7 小时前
C#中的反射关键类-Type
开发语言·c#
星恒讯工业路由器9 小时前
2G/3G网络退网趋势解读:对工业物联网设备的影响与应对策略
物联网·信息与通信·工业物联网·工业通信·5g redcap·2g3g退网·4g/5g升级
淡海水9 小时前
15-07-YooAsset面试篇-Unity性能优化与内存管理
java·unity·面试·性能优化·c#·游戏引擎
离陌在学C#10 小时前
C# 多态:面向对象编程的核心特性详解
开发语言·c#
hez201021 小时前
.NET 11 Runtime Async 详解
c#·.net·.net core
猿长大人1 天前
C# | Serilog 新手入门
开发语言·c#·.net·log
在世修行1 天前
从零打造 C# 工业视觉检测系统(七):串口通信 SerialPort 封装与开发
开发语言·c#·modbus rtu·rs232/rs485
NoteStream1 天前
【C语言基础】分支和循环(上)
c语言·开发语言·c++·经验分享·笔记·算法·c#
lzhdim1 天前
C# 通过 Windows API 实现进程内存读写操作
开发语言·windows·c#