ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C#搞Modbus通信,这事没你想的那么玄乎

2026/8/26 17:20:53 拓冰建站 浏览量
C#搞Modbus通信,这事没你想的那么玄乎 用C#写Modbus通信算下来五六年了。从刚开始对着NModbus源码发懵到现在自己撸一套轻量级协议栈也就半天功夫。网上教程一搜一大把但大多照着官方Demo抄真正现场跑起来该崩还是崩。今天不整花架子纯实战经验把C#里那点事儿捋清楚。先定个调C#做Modbus通信轮子不少但真正好使的还得自己动手撸。第三方库省事是省事但出问题你连调试的底气都没有。一、库的选择用现成的还是自己写市面上C#的Modbus库NModbus是祖师爷开源、稳定、功能全。但有个硬伤——它是基于.NET Framework写的.NET Core/.NET 5环境跑起来各种依赖报错。后来有个NModbus4的fork支持.NET Standard能用但异步支持很弱高并发场景卡得一塌糊涂。另外有个EasyModbus商业授权功能花哨但代码封装太严出问题你连日志都打不出来。我现在的策略是串口RTU用NModbus4改一版TCP自己写。TCP那点东西真没必要引个第三方库一个Socket加一个解析类两百行代码完事。二、串口通信SerialPort是基本功.NET自带的SerialPort类不好用但够用。最烦人的是DataReceived事件在子线程触发你想在回调里直接更新UI等着崩吧。必须Invoke切回主线程。关键参数设置SerialPort sp new SerialPort(COM3, 9600, Parity.None, 8, StopBits.One); sp.ReadTimeout 200; sp.WriteTimeout 200;超时时间必须设默认InfiniteTimeout会把你程序挂死。ReadBufferSize和WriteBufferSize别乱改默认值够了。有个隐藏坑DataReceived事件触发时不一定收全了一帧。你收到几个字节就开始解析那必然乱套。必须自己实现帧缓存等够了再处理。RTU要等3.5字符静默C#里没法精确到那么细通用做法是用超时判断收到第一个字节后启动计时器连续若干毫秒没有新数据就视为帧结束。三、CRC计算C#里就几行的事RTU的CRC-16-ModbusC#实现起来极其简单csharppublic static byte[] CalculateCRC(byte[] data) { ushort crc 0xFFFF; for (int i 0; i data.Length; i) { crc ^ data[i]; for (int j 0; j 8; j) { if ((crc 0x0001) ! 0) crc (ushort)((crc 1) ^ 0xA001); else crc 1; } } return new byte[] { (byte)(crc 0xFF), (byte)(crc 8) }; }注意返回顺序低字节在前高字节在后。很多人算出来是对的但发的时候顺序搞反从站不认。查表法快但占内存。工业现场就几百个寄存器轮询按位算那点CPU消耗根本不叫事别过度优化。四、TCP通信Socket用同步还是异步TCP这块分歧最大。有人说用异步BeginReceive/EndReceive有人说用同步Receive加超时。我的经验分场景场景一单连接轮询周期固定。同步Receive足够。把ReceiveTimeout设好一个循环里发请求、等回复、解析、Sleep逻辑清晰代码好维护。发多个请求时事务ID自己递增同步模式下不存在并发问题。场景二多连接并发或者需要同时处理多个从站。这时候必须上异步或者多线程。我的做法是每个连接开一个独立线程内部用同步Receive阻塞互不干扰。线程数控制在10个以内别开太多TCP连接数和线程上下文切换都是开销。异步模式的坑在于状态机管理复杂。BeginReceive回调里处理粘包一堆临时缓存和状态变量代码很快变得不可维护。除非你有经验否则别轻易碰。五、粘包处理这地方写不好就全盘崩TCP流式传输粘包必然存在。C#里解析标准流程csharpprivate byte[] _buffer new byte[4096]; private int _offset 0; public void OnDataReceived(byte[] data) { Array.Copy(data, 0, _buffer, _offset, data.Length); _offset data.Length; while (_offset 7) // 至少够MBAP头 { // 解析长度域第5-6字节 int len (_buffer[4] 8) | _buffer[5]; int totalLen 7 len; if (_offset totalLen) { // 截取一完整帧 byte[] frame new byte[totalLen]; Array.Copy(_buffer, 0, frame, 0, totalLen); ProcessFrame(frame); // 移除已处理数据 int remain _offset - totalLen; if (remain 0) Array.Copy(_buffer, totalLen, _buffer, 0, remain); _offset remain; } else break; } }这段逻辑是所有TCP Modbus解析的核心写对了后面就顺了。注意while循环缓冲区可能一次收到多帧要全部拆出来。六、异常处理设备不按套路出牌Modbus异常响应功能码最高位置1后面跟异常码。C#里判断csharpif ((response[1] 0x80) ! 0) // 功能码最高位为1 { byte exceptionCode response[2]; switch(exceptionCode) { case 0x01: throw new Exception(非法功能); case 0x02: throw new Exception(非法地址); case 0x03: throw new Exception(非法数据); case 0x04: throw new Exception(从站设备故障); default: throw new Exception($未知异常码:0x{exceptionCode:X2}); } }但现实比这复杂。有些设备不按标准来超时不回、回了长度不对、CRC校验错、功能码跟请求对不上。每一种都得有对应的容错不然一个异常把整个轮询线程搞死了。我的经验任何一次通信失败记录日志然后继续下一轮别重试太多次别阻塞。七、线程安全写寄存器时注意多线程环境下如果一个线程在轮询读数据另一个线程突然写寄存器可能引发问题。写操作是原子性的——发写请求、等回复这个过程中轮询线程不要发其他请求。最简单的方法读写共用一把锁任何操作前Lock住发完解锁。有更高性能的做法是读写分离读操作走一个连接写操作走另一个独立连接。但前提是从站支持多连接很多设备不支持。八、日志与调试这比代码本身重要做工业通信没有日志等于裸奔。每个请求和响应的完整报文必须以十六进制打印出来带时间戳和方向发/收。csharpLog($发送: {BitConverter.ToString(sendBuf)}); Log($接收: {BitConverter.ToString(recvBuf)});现场出了故障用户把日志发过来一看报文就知道是CRC错还是地址错还是设备没回。省下来的时间比你写代码的时间都多。Wireshark抓TCP包配合自己打的日志双保险。串口的话用虚拟串口工具或者硬件监听器把数据流截下来对比。九、实战框架结构推荐这么组织代码我自己用的结构三层底层通信层SerialPort或Socket封装只负责收发原始字节带超时和重试机制。中间层协议层负责拼装请求帧、解析响应帧、CRC校验、粘包处理。这一层跟通信介质无关RTU和TCP可以共用大部分代码。上层应用层提供业务接口比如ReadHoldingRegister(byte slaveId, ushort startAddr, ushort count)。上层不用管报文长什么样只管传参数拿结果。三层分离换通信介质只改底层换协议只改中间层应用层纹丝不动。十、说个实战案例去年做一套电池化成设备的数据采集系统128台设备每台要读30个寄存器。一开始用第三方库单线程轮询一圈下来要40多秒上位机嫌慢。后来自己改实现开4个TCP连接每个连接分担32台设备线程池调度事务ID逐请求递增。优化后一圈不到8秒用户满意了。但新问题来了偶尔有设备掉线程序重连时某些线程卡住连带其他设备也读不到数据。最后加了健康检查——每个线程维护一个连接状态读超时就标记异常单独启动重连不影响其他线程的正常轮询。这行的经验就是一个个现场坑踩出来的代码里每行异常处理背后都是加班到半夜的血泪史。