C#实现ModbusRTU详解【四】—— 实战通讯与报文解析

C#实现ModbusRTU详解【四】—— 实战通讯与报文解析

1. ModbusRTU通讯实战准备

在完成前三篇的报文生成方法后,我们现在要进入最激动人心的实战环节了。想象一下,你手里拿着精心准备的武器(报文生成方法),现在终于要上战场(实际通讯)了。不过在正式开战前,我们需要做好充分准备。

首先明确我们的目标:构建一个完整的ModbusRTU主站通讯模块。这个模块需要能够通过串口与从站设备(比如Modbus Slave仿真软件)进行数据交互。具体来说,我们要实现以下几个核心功能:

  • 发送生成的请求报文
  • 接收从站的响应报文
  • 解析响应数据(包括异常情况处理)
  • 验证读写操作的正确性

为了完成这个目标,我们需要准备以下工具和环境:

  1. 开发环境:Visual Studio(2017或更高版本)
  2. 串口调试工具:Modbus Slave(用于模拟从站设备)
  3. 串口虚拟工具(可选):如果物理串口不足,可以使用Virtual Serial Port Driver创建虚拟串口对
  4. 基础代码:前三篇已经完成的报文生成模块

在实际项目中,我强烈建议先搭建一个简单的测试环境。你可以这样操作:

  • 安装Modbus Slave并配置好从站参数
  • 确保串口连接正常(如果是虚拟串口,记住配对的端口号)
  • 准备好报文生成模块的代码

2. 串口通讯基础实现

2.1 串口配置与初始化

在C#中,我们使用System.IO.Ports命名空间下的SerialPort类来实现串口通讯。下面是一个基础的串口配置示例:

using System.IO.Ports; public class ModbusRTUCommunicator { private SerialPort _serialPort; public void InitializePort(string portName, int baudRate = 9600, Parity parity = Parity.None, int dataBits = 8, StopBits stopBits = StopBits.One) { _serialPort = new SerialPort(portName, baudRate, parity, dataBits, stopBits); _serialPort.ReadTimeout = 500; // 读取超时时间(ms) _serialPort.WriteTimeout = 500; // 写入超时时间(ms) // 其他可能需要的配置 _serialPort.Handshake = Handshake.None; _serialPort.RtsEnable = true; // 启用RTS信号线 } public bool OpenConnection() { try { if (!_serialPort.IsOpen) { _serialPort.Open(); return true; } return false; } catch (Exception ex) { Console.WriteLine($"打开串口失败: {ex.Message}"); return false; } } public void CloseConnection() { if (_serialPort != null && _serialPort.IsOpen) { _serialPort.Close(); } } }

在实际使用中,我发现有几个关键点需要注意:

  1. 波特率:必须与从站设备完全一致,常见的值有9600、19200、38400等
  2. 超时设置:ReadTimeout和WriteTimeout要根据实际网络状况合理设置
  3. RTS控制:有些设备需要RTS信号控制数据流,需要根据设备手册配置

2.2 报文发送与接收基础方法

有了串口连接后,我们需要实现基本的报文发送和接收方法。这里我分享一个经过实战检验的实现:

public byte[] SendMessage(byte[] message) { if (_serialPort == null || !_serialPort.IsOpen) { throw new InvalidOperationException("串口未初始化或未打开"); } try { // 清空输入输出缓冲区 _serialPort.DiscardInBuffer(); _serialPort.DiscardOutBuffer(); // 发送报文 _serialPort.Write(message, 0, message.Length); // 等待响应(根据设备响应时间调整) Thread.Sleep(100); // 读取响应 List<byte> response = new List<byte>(); while (_serialPort.BytesToRead > 0) { byte[] buffer = new byte[_serialPort.BytesToRead]; int bytesRead = _serialPort.Read(buffer, 0, buffer.Length); response.AddRange(buffer); // 小延迟防止读取不完整 Thread.Sleep(50); } return response.ToArray(); } catch (TimeoutException) { Console.WriteLine("读取响应超时"); return null; } catch (Exception ex) { Console.WriteLine($"通讯发生异常: {ex.Message}"); return null; } }

这个方法有几个值得注意的细节:

  1. 缓冲区清理:每次发送前清空缓冲区,避免残留数据干扰
  2. 延迟处理:适当延迟确保数据完整接收
  3. 异常处理:捕获可能出现的超时和其他异常

3. 报文解析与处理

3.1 正常响应解析

ModbusRTU的响应报文格式与功能码相关。我们先来看最常见的几种正常响应情况:

读取操作响应

  • 功能码:与请求相同
  • 数据:返回的字节数 + 实际数据

写入操作响应

  • 单个写入:返回与请求完全相同的报文
  • 多个写入:返回站地址、功能码、起始地址和写入数量

下面是一个通用的响应解析方法:

public static bool TryParseResponse(byte[] response, byte expectedFunctionCode, out byte[] data, out string errorMessage) { data = null; errorMessage = null; // 基本检查 if (response == null || response.Length < 5) // 最小长度:站地址+功能码+2字节CRC { errorMessage = "响应报文长度不足"; return false; } // 检查CRC校验 byte[] messageWithoutCrc = new byte[response.Length - 2]; Array.Copy(response, 0, messageWithoutCrc, 0, messageWithoutCrc.Length); byte[] calculatedCrc = CRC16(messageWithoutCrc); if (!response[response.Length - 2].Equals(calculatedCrc[0]) || !response[response.Length - 1].Equals(calculatedCrc[1])) { errorMessage = "CRC校验失败"; return false; } // 检查功能码 byte actualFunctionCode = response[1]; // 如果是异常响应(功能码最高位为1) if ((actualFunctionCode & 0x80) != 0) { byte errorCode = response[2]; errorMessage = $"从站返回异常,错误码: {errorCode} - {GetErrorDescription(errorCode)}"; return false; } // 检查功能码是否匹配 if (actualFunctionCode != expectedFunctionCode) { errorMessage = $"功能码不匹配,期望:{expectedFunctionCode},实际:{actualFunctionCode}"; return false; } // 提取数据部分 data = new byte[response.Length - 4]; // 减去站地址、功能码和CRC Array.Copy(response, 2, data, 0, data.Length); return true; } private static string GetErrorDescription(byte errorCode) { return errorCode switch { 0x01 => "非法功能码", 0x02 => "非法数据地址", 0x03 => "非法数据值", 0x04 => "从站设备故障", _ => "未知错误" }; }

3.2 异常响应处理

Modbus协议定义了标准的异常响应格式。当从站无法处理请求时,会返回异常响应,其特征是功能码的最高位被置为1(即原功能码+0x80),后面跟着异常代码。

在我们的代码中已经包含了异常响应处理,但为了更健壮的系统,我们可以专门为异常响应设计一个解析方法:

public static bool IsExceptionResponse(byte[] response, out byte originalFunctionCode, out byte errorCode) { originalFunctionCode = 0; errorCode = 0; if (response == null || response.Length < 5) { return false; } byte functionCode = response[1]; if ((functionCode & 0x80) == 0) { return false; } originalFunctionCode = (byte)(functionCode & 0x7F); errorCode = response[2]; return true; }

在实际项目中,我建议对每种可能的异常情况都做专门处理,比如:

if (IsExceptionResponse(response, out var originalFuncCode, out var errCode)) { switch (errCode) { case 0x02: Console.WriteLine($"地址 {requestAddress} 不存在或不可访问"); break; case 0x03: Console.WriteLine($"写入值 {writeValue} 超出允许范围"); break; // 其他异常处理... } return; }

4. 完整通讯流程实现

4.1 读取操作完整示例

现在我们把前面学到的所有内容整合起来,实现一个完整的读取保持寄存器(功能码03)的流程:

public short[] ReadHoldingRegisters(byte slaveAddress, ushort startAddress, ushort numberOfRegisters) { // 生成请求报文 byte[] request = MessageGenerationModule.GetMultipleDataReadMessage( slaveAddress, startAddress, numberOfRegisters, ReadType.Read03); // 发送报文并获取响应 byte[] response = SendMessage(request); // 解析响应 if (TryParseResponse(response, 0x03, out var data, out var errorMsg)) { // 数据格式:字节数 + 寄存器值(每个寄存器2字节) int byteCount = data[0]; if (byteCount != numberOfRegisters * 2) { throw new InvalidDataException("返回数据长度与预期不符"); } short[] result = new short[numberOfRegisters]; for (int i = 0; i < numberOfRegisters; i++) { int offset = 1 + i * 2; // 跳过字节数 result[i] = BitConverter.ToInt16(new byte[] { data[offset + 1], data[offset] }, 0); } return result; } else { throw new ModbusException(errorMsg); } }

这个方法展示了完整的处理流程:

  1. 使用之前实现的报文生成方法创建请求
  2. 通过串口发送请求
  3. 接收并解析响应
  4. 处理数据(注意字节序转换)

4.2 写入操作完整示例

同样地,我们实现一个写入多个寄存器的完整示例:

public bool WriteMultipleRegisters(byte slaveAddress, ushort startAddress, short[] values) { // 生成请求报文 byte[] request = MessageGenerationModule.GetArrayDataWriteMessage( slaveAddress, (short)startAddress, values); // 发送报文并获取响应 byte[] response = SendMessage(request); // 解析响应 if (TryParseResponse(response, 0x10, out var data, out var errorMsg)) { // 正常响应格式:站地址(1) + 功能码(1) + 起始地址(2) + 寄存器数量(2) if (data.Length != 4) { throw new InvalidDataException("响应数据长度异常"); } // 验证起始地址和数量是否匹配 ushort returnedStartAddress = (ushort)((data[0] << 8) | data[1]); ushort returnedQuantity = (ushort)((data[2] << 8) | data[3]); return returnedStartAddress == startAddress && returnedQuantity == values.Length; } else { throw new ModbusException(errorMsg); } }

在实际测试中,我发现有几个常见问题需要注意:

  1. 字节序问题:不同设备可能有不同的字节序要求
  2. 超时处理:要根据实际设备响应速度调整超时时间
  3. 重试机制:对于不稳定的串口连接,建议实现简单的重试逻辑

4.3 综合测试案例

下面是一个完整的控制台应用示例,演示如何测试我们的ModbusRTU通讯模块:

class Program { static void Main(string[] args) { var communicator = new ModbusRTUCommunicator(); try { // 初始化串口(根据实际情况修改参数) communicator.InitializePort("COM3", 9600, Parity.None, 8, StopBits.One); communicator.OpenConnection(); // 测试读取保持寄存器 Console.WriteLine("测试读取保持寄存器..."); short[] registers = communicator.ReadHoldingRegisters(1, 0, 5); Console.WriteLine("读取结果:"); for (int i = 0; i < registers.Length; i++) { Console.WriteLine($"寄存器 {i}: {registers[i]}"); } // 测试写入多个寄存器 Console.WriteLine("\n测试写入多个寄存器..."); short[] valuesToWrite = { 100, 200, 300, 400, 500 }; bool writeSuccess = communicator.WriteMultipleRegisters(1, 0, valuesToWrite); Console.WriteLine($"写入操作{(writeSuccess ? "成功" : "失败")}"); // 验证写入结果 Console.WriteLine("\n验证写入结果..."); registers = communicator.ReadHoldingRegisters(1, 0, 5); Console.WriteLine("读取结果:"); for (int i = 0; i < registers.Length; i++) { Console.WriteLine($"寄存器 {i}: {registers[i]} (期望值: {valuesToWrite[i]})"); } } catch (Exception ex) { Console.WriteLine($"发生异常: {ex.Message}"); } finally { communicator.CloseConnection(); } Console.WriteLine("\n测试完成,按任意键退出..."); Console.ReadKey(); } }

这个测试案例展示了完整的读写操作流程,包括:

  1. 初始化串口连接
  2. 读取寄存器当前值
  3. 写入新值
  4. 再次读取验证写入结果
  5. 异常处理和资源清理

在实际项目中,我建议为每个功能码都编写类似的测试案例,确保所有功能都能正常工作。同时,可以添加更多的错误处理和数据验证逻辑,使系统更加健壮。