
做工业上位机开发的谁手里没攒过几个Modbus第三方库NModbus、Modbus.Device还有各种论坛扒来的零散实现。刚用的时候确实省心几行代码就能跑通寄存器读写但项目做多了就会发现第三方库的坑一点也不比自己写的少.NET版本适配乱从Framework 4.0迁到.NET 8旧版库直接报依赖异常新版库又改了API裁剪不动明明只用到功能码03和06却要引入整个类库嵌入式边缘设备部署体积下不去出问题查不动黑盒封装通信异常只能瞎猜参数没法深入报文级排查非标扩展改不了现场设备有自定义功能码改开源库的源码比重新写一遍还费劲尤其是最近两年做跨平台边缘网关、Linux工控机项目很多Windows时代的Modbus库根本没法跨平台跑。与其在各种第三方库之间反复踩坑、缝缝补补不如彻底从底层手写一套核心实现——代码量并不大可控性却直接拉满。本文就用纯C#从零实现Modbus RTU与TCP双协议不依赖任何第三方NuGet包只用到系统自带的串口和Socket类完整覆盖工业现场90%以上的常用功能同时解决粘包、超时、校验、重连等工程化问题。一、先理清楚Modbus TCP和RTU到底差在哪很多人觉得Modbus协议多、版本杂其实核心的业务数据部分PDU是完全统一的区别只在外层封装方式一个跑在以太网上一个跑在串总线上。1.1 Modbus TCP 帧结构TCP模式在PDU外面套了一层MBAP头部固定7字节用来在TCP流里标识帧偏移长度名称说明02字节事务标识请求响应配对用防止并发乱序22字节协议标识固定为0x0000代表Modbus协议42字节长度后续所有字节的总长度单元标识PDU61字节单元标识对应RTU的从站地址TCP透传网关场景用后面紧跟PDU1字节功能码 N字节业务数据。整帧总长度 7(MBAP) 1(功能码) N(数据)1.2 Modbus RTU 帧结构RTU是串口总线的封装没有长度字段靠时间间隔区分帧偏移长度名称说明01字节从站地址总线上设备的唯一标识0~24711字节功能码和TCP完全一致2N字节数据段和TCP完全一致末尾2字节CRC16校验校验整帧数据低字节在前也就是说同一条读寄存器指令TCP和RTU的PDU部分是一模一样的只是外层封装不同。这也是我们可以把两套协议共用一套PDU解析逻辑的原因。工业现场最常用的功能码其实就6个01读线圈、02读离散输入、03读保持寄存器、04读输入寄存器、06写单寄存器、16写多寄存器。本文以最核心的03/06/16为例实现其他功能码都是同理扩展。二、基础打底纯C#实现Modbus CRC16校验CRC校验是RTU的基石也是新手最容易写错的地方。很多人手写出来的CRC和设备对不上本质是多项式和字节顺序搞错了。Modbus RTU使用的是CRC16-Modbus标准多项式0x8005逆序运算对应0xA001初始值0xFFFF结果输出低字节在前高字节在后小端模式注意这里的字节序和后面寄存器的大端模式刚好相反这是最容易踩的坑之一。/// summary /// Modbus RTU 专用CRC16校验 /// /summary public static class ModbusCrc16 { /// summary /// 计算CRC值返回低字节在前的2字节结果 /// /summary public static byte[] Calculate(byte[] data, int offset, int length) { ushort crc 0xFFFF; for (int i offset; i offset length; i) { crc ^ data[i]; for (int j 0; j 8; j) { if ((crc 0x0001) 1) { crc 1; crc ^ 0xA001; } else { crc 1; } } } return new byte[] { (byte)(crc 0xFF), (byte)(crc 8) }; } /// summary /// 校验一帧RTU数据的CRC是否正确 /// /summary public static bool Verify(byte[] frame, int offset, int length) { if (length 3) return false; byte[] calcCrc Calculate(frame, offset, length - 2); return calcCrc[0] frame[offset length - 2] calcCrc[1] frame[offset length - 1]; } }三、Modbus TCP 实现原生Socket零依赖TCP实现的核心是处理TCP粘包和请求响应匹配。很多新手写TCP版Modbus直接一次Read就当收到了完整帧现场网络一波动就出各种奇怪的解析错误。正确的做法是先读7字节MBAP头部从头部里拿到整帧长度再循环读取剩下的数据收满一帧再解析。核心客户端实现public class ModbusTcpClient : IModbusClient, IDisposable { private TcpClient _client; private NetworkStream _stream; private ushort _transactionId 0; private readonly int _timeout 3000; private readonly byte[] _recvBuffer new byte[2048]; private readonly object _lock new object(); public bool Connected _client?.Connected ?? false; public void Connect(string ipAddress, int port 502) { _client new TcpClient { ReceiveTimeout _timeout, SendTimeout _timeout, NoDelay true }; _client.Connect(ipAddress, port); _stream _client.GetStream(); } /// summary /// 读保持寄存器功能码0x03 /// /summary public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddr, ushort count) { if (count 125) throw new ArgumentException(单次读取最多125个寄存器); byte[] pdu BuildReadPdu(0x03, startAddr, count); byte[] respPdu SendReceiveInternal(slaveId, pdu); return ParseReadResponse(respPdu); } /// summary /// 写单个保持寄存器功能码0x06 /// /summary public void WriteSingleRegister(byte slaveId, ushort addr, ushort value) { byte[] pdu new byte[5]; pdu[0] 0x06; pdu[1] (byte)(addr 8); pdu[2] (byte)(addr 0xFF); pdu[3] (byte)(value 8); pdu[4] (byte)(value 0xFF); SendReceiveInternal(slaveId, pdu); } // 构建读类功能的PDU01/02/03/04通用 private byte[] BuildReadPdu(byte funcCode, ushort startAddr, ushort count) { byte[] pdu new byte[5]; pdu[0] funcCode; pdu[1] (byte)(startAddr 8); pdu[2] (byte)(startAddr 0xFF); pdu[3] (byte)(count 8); pdu[4] (byte)(count 0xFF); return pdu; } // 核心收发逻辑 private byte[] SendReceiveInternal(byte slaveId, byte[] pdu) { lock (_lock) // Modbus是半双工同一时间只能一个请求 { ushort currentId _transactionId; // 组装MBAP PDU byte[] sendFrame new byte[7 pdu.Length]; sendFrame[0] (byte)(currentId 8); sendFrame[1] (byte)(currentId 0xFF); sendFrame[2] 0x00; sendFrame[3] 0x00; sendFrame[4] (byte)((pdu.Length 1) 8); sendFrame[5] (byte)((pdu.Length 1) 0xFF); sendFrame[6] slaveId; Array.Copy(pdu, 0, sendFrame, 7, pdu.Length); _stream.Write(sendFrame, 0, sendFrame.Length); // 第一步读取7字节MBAP头部 int recvLen 0; while (recvLen 7) { int read _stream.Read(_recvBuffer, recvLen, 7 - recvLen); if (read 0) throw new IOException(TCP连接已断开); recvLen read; } // 第二步根据长度字段读取剩余数据 int pduTotalLen (_recvBuffer[4] 8) | _recvBuffer[5]; int remain pduTotalLen - 1; // 减去单元标识长度 while (recvLen 7 remain) { int read _stream.Read(_recvBuffer, recvLen, 7 remain - recvLen); if (read 0) throw new IOException(TCP连接已断开); recvLen read; } // 校验事务标识防止乱序 ushort respId (ushort)((_recvBuffer[0] 8) | _recvBuffer[1]); if (respId ! currentId) throw new IOException($事务标识不匹配预期{currentId}实际{respId}); // 提取PDU byte[] respPdu new byte[remain]; Array.Copy(_recvBuffer, 7, respPdu, 0, remain); // 检查异常响应 if ((respPdu[0] 0x80) ! 0) throw new IOException($Modbus异常响应异常码 0x{respPdu[1]:X2}); return respPdu; } } // 解析读响应PDU private ushort[] ParseReadResponse(byte[] pdu) { int dataLen pdu[1]; ushort[] result new ushort[dataLen / 2]; for (int i 0; i result.Length; i) { result[i] (ushort)((pdu[2 i * 2] 8) | pdu[3 i * 2]); } return result; } public void Dispose() { _stream?.Close(); _client?.Close(); } }TCP实现的几个关键细节加锁控制Modbus本质是半双工协议同一链路同一时间只能有一个请求必须加锁防止并发发送导致乱帧循环读取绝对不要相信一次Read能收到完整帧TCP是流协议粘包、分包是常态事务匹配通过事务标识配对请求响应避免网络重发、延迟导致的数据错位异常处理功能码最高位为1时是异常响应必须抛出明确的异常码方便排查四、Modbus RTU 实现串口帧分界是核心RTU比TCP难处理的地方在于没有帧长度字段完全靠时间间隔来区分帧。协议规定两帧之间的空闲时间必须大于3.5个字符的传输时间小于这个间隔的连续字节属于同一帧。帧间隔时间计算不同波特率对应的3.5字符时间不同公式帧间隔(ms) 3.5 × 每字符位数 × 1000 / 波特率常用的9600波特率、8数据位、1停止位、无校验每字符10位算下来约3.6ms工程上一般取5ms作为阈值避免误差。波特率越低需要的间隔时间越长。核心客户端实现我们用定时器来判断帧结束收到数据就重置定时器定时器超时没收到新数据就认为一帧结束。public class ModbusRtuClient : IModbusClient, IDisposable { private SerialPort _serialPort; private readonly int _frameGapMs; private readonly System.Timers.Timer _frameTimer; private readonly Listbyte _frameBuffer new Listbyte(); private readonly AutoResetEvent _frameWait new AutoResetEvent(false); private byte[] _completedFrame; private readonly object _lock new object(); public bool IsOpen _serialPort?.IsOpen ?? false; public ModbusRtuClient(int baudRate 9600, Parity parity Parity.None) { // 计算3.5字符时间最小5ms int bitsPerChar 1 8 (parity Parity.None ? 0 : 1) 1; _frameGapMs (int)Math.Ceiling(3.5 * bitsPerChar * 1000.0 / baudRate); if (_frameGapMs 5) _frameGapMs 5; _frameTimer new System.Timers.Timer(_frameGapMs); _frameTimer.AutoReset false; _frameTimer.Elapsed OnFrameTimerElapsed; } public void Open(string portName, int baudRate 9600, Parity parity Parity.None) { _serialPort new SerialPort(portName, baudRate, parity, 8, StopBits.One) { ReadTimeout 3000, WriteTimeout 3000 }; _serialPort.DataReceived OnSerialDataReceived; _serialPort.Open(); } private void OnSerialDataReceived(object sender, SerialDataReceivedEventArgs e) { int count _serialPort.BytesToRead; byte[] buffer new byte[count]; _serialPort.Read(buffer, 0, count); lock (_frameBuffer) { _frameBuffer.AddRange(buffer); } _frameTimer.Stop(); _frameTimer.Start(); } private void OnFrameTimerElapsed(object sender, ElapsedEventArgs e) { lock (_frameBuffer) { _completedFrame _frameBuffer.ToArray(); _frameBuffer.Clear(); } _frameWait.Set(); } /// summary /// 读保持寄存器功能码0x03 /// /summary public ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddr, ushort count) { if (count 125) throw new ArgumentException(单次读取最多125个寄存器); byte[] pdu BuildReadPdu(0x03, startAddr, count); byte[] respPdu SendReceiveInternal(slaveId, pdu); return ParseReadResponse(respPdu); } // 构建读PDU、解析响应和TCP完全共用逻辑 private byte[] BuildReadPdu(byte funcCode, ushort startAddr, ushort count) { byte[] pdu new byte[5]; pdu[0] funcCode; pdu[1] (byte)(startAddr 8); pdu[2] (byte)(startAddr 0xFF); pdu[3] (byte)(count 8); pdu[4] (byte)(count 0xFF); return pdu; } private byte[] SendReceiveInternal(byte slaveId, byte[] pdu) { lock (_lock) { _completedFrame null; lock (_frameBuffer) _frameBuffer.Clear(); // 组装RTU帧地址 PDU CRC byte[] sendFrame new byte[1 pdu.Length 2]; sendFrame[0] slaveId; Array.Copy(pdu, 0, sendFrame, 1, pdu.Length); byte[] crc ModbusCrc16.Calculate(sendFrame, 0, sendFrame.Length - 2); sendFrame[sendFrame.Length - 2] crc[0]; sendFrame[sendFrame.Length - 1] crc[1]; _serialPort.Write(sendFrame, 0, sendFrame.Length); // 等待一帧接收完成 if (!_frameWait.WaitOne(_serialPort.ReadTimeout)) throw new TimeoutException(RTU接收超时未收到设备响应); // CRC校验 if (!ModbusCrc16.Verify(_completedFrame, 0, _completedFrame.Length)) throw new IOException(CRC校验失败数据可能损坏); // 提取PDU去掉1字节地址 2字节CRC byte[] respPdu new byte[_completedFrame.Length - 3]; Array.Copy(_completedFrame, 1, respPdu, 0, respPdu.Length); // 异常响应检查 if ((respPdu[0] 0x80) ! 0) throw new IOException($Modbus异常响应异常码 0x{respPdu[1]:X2}); return respPdu; } } private ushort[] ParseReadResponse(byte[] pdu) { int dataLen pdu[1]; ushort[] result new ushort[dataLen / 2]; for (int i 0; i result.Length; i) { result[i] (ushort)((pdu[2 i * 2] 8) | pdu[3 i * 2]); } return result; } public void Dispose() { _serialPort?.Close(); _frameTimer?.Dispose(); _frameWait?.Dispose(); } }RTU实现的踩坑点帧间隔不能硬编码要根据波特率动态计算低波特率下硬编码5ms会导致一帧被拆成多帧缓冲区加锁串口接收事件和定时器不在同一个线程操作缓冲区必须加锁485总线延时RS485总线收发切换需要时间部分设备响应慢超时时间要适当放宽干扰重传工业现场串口干扰多CRC失败很常见业务层建议加自动重传机制五、工程化封装统一接口无缝切换TCP/RTU实际项目中我们不会让业务层直接依赖具体的客户端类。定义一个统一的IModbusClient接口让TCP和RTU都实现它业务层只依赖接口切换传输方式不需要改任何业务代码。public interface IModbusClient { bool Connected { get; } ushort[] ReadHoldingRegisters(byte slaveId, ushort startAddr, ushort count); void WriteSingleRegister(byte slaveId, ushort addr, ushort value); void WriteMultipleRegisters(byte slaveId, ushort startAddr, ushort[] values); }在此基础上还可以扩展断线重连、自动重试、日志输出等工程化能力。比如TCP客户端可以封装重连逻辑发送失败时自动尝试重连重连成功后重试当前请求大幅提升现场稳定性。六、一线踩坑最容易犯的8个错误做了这么多年工业通信见过太多人在Modbus上栽跟头把最高频的坑列在这里能帮你少走很多弯路。字节序搞反Modbus寄存器是大端模式C#的BitConverter默认小端直接用会导致数据完全错位必须手动移位转换TCP粘包不处理以为一次Read对应一帧本地测试没问题现场一跑就乱码CRC字节序反了CRC是低字节在前很多人按大端输出导致设备不响应地址从0还是1开始Modbus协议地址从0开始有些设备文档写的是1起始要减1再用单次读取超限标准Modbus单次最多读125个寄存器超了设备会返回异常RTU帧间隔太短波特率低的时候帧间隔不够导致帧拆分解析失败并发发送不加锁多线程同时发请求帧会乱设备根本识别不了异常响应不处理收到异常帧还强行解析得到一堆错误数据七、最后说句实在话手写Modbus协议不是为了重复造轮子也不是说第三方库一无是处。成熟的第三方库功能全、覆盖广适合快速交付的项目。但如果你遇到下面这些场景手写一套的收益会非常高项目对部署体积敏感不想带一堆依赖DLL需要跨平台运行老的第三方库不支持Linux/ARM现场有非标协议扩展需要改协议逻辑想深入调试通信问题不想被黑盒卡脖子而且实话实说核心功能的代码量也就几百行一下午就能写完测通。换来的是完全的可控性、零依赖、极致的兼容性这笔账怎么算都值。做工业软件开发永远不要只停留在调用API的层面。理解协议底层知道每一个字节的含义才是解决现场疑难杂症的底气。