
简介Modbus TCP是工业自动化中最常用的以太网通信协议之一基于C#的实现能帮助开发者快速构建上位机与PLC的数据交互模块。这份源码示例围绕Modbus_TCP_class_demo展开完整演示了从TcpClient建立连接、构造读写请求帧到利用NetworkStream收发数据、解析响应报文的全过程并覆盖保持寄存器读写、输入寄存器读取、线圈状态读写等常见功能码以及异常码处理、超时重试等实用场景代码同时展示了async/await异步通信模式对提升上位机响应性能很有参考价值。压缩包共46个文件主体为cs源代码与xml注释配合sln解决方案、csproj工程文件以及编译生成的exe、dll和调试用的pdb还有chm帮助文档、resources资源文件与说明txt源码工程包含ModbusSampleCommon与ModbusTester等多个子项目便于分层阅读和二次扩展整体仅148KB轻量且结构清晰。已有1070人学习下载适合正在学习Modbus协议、准备课程设计或在C#项目中集成工业通信功能的开发者。1. 基于TCP的ModbusC#源码从最小读写到能上线的采集类车间里新上一台PLC触摸屏已经在显示数据但做报表和告警还得靠上位机。设备支持Modbus TCP网线插上就是502端口看起来比串口干净——没有波特率、没有校验位。可真把C#源码写起来才发现坑全在网络边界上粘包、半包、超时、设备突然返回异常码任何一个都能让采集线程静默死掉。这套基于TCP的Modbus C#实现核心就是解决这些问题从MBAP报文头到可复用的客户端类从功能码拼装到重连机制跑通一个最小采集服务大约需要一个下午。适合正在写C#上位机、对接PLC或网关的开发者也适合刚转工控的桌面端程序员照着改自己的轮询服务。2. Modbus TCP协议骨架MBAP报文头、功能码与寄存器地址的三种约定2.1 MBAP报文头7字节里藏着事务ID和长度边界Modbus TCP的帧结构分成两段MBAP头7字节后面跟PDU。很多C#新手直接按串口Modbus的思路写先发从站地址再发功能码结果设备完全不回。原因就是MBAP头里的前7个字节和串口帧结构不一样。MBAP字段字节数说明事务处理标识符2请求方自增响应原样带回用于匹配请求与响应协议标识符2Modbus固定为0x0000非0说明这个报文不是Modbus长度2从单元标识符开始到报文末尾的字节数单元标识符1相当于串口Modbus里的从站地址网关带多设备时区分长度字段是误会最多的地方它统计的范围不是整个帧而是去掉事务ID、协议ID、长度字段自己之后的部分。读10个保持寄存器的请求单元ID占1字节、功能码1字节、起始地址2字节、数量2字节一共6字节长度字段就是0x0006。响应里单元ID1字节、功能码1字节、字节数1字节、数据20字节一共23字节长度字段就是0x0017。抓包时拿着整个帧长度去反推永远差7个字节。PDU部分才是真正表达操作的地方功能码1字节加数据。采集场景里功能码03读保持寄存器、06写单寄存器、16写多寄存器用得最多01、05这类线圈操作在设备控制里也会碰到。单元标识符在多从站网关后面才有实际意义直接连PLC时填1就行但很多PLC不校验它照样返回。2.2 功能码03、06、16读保持寄存器与两种写操作的报文对照功能码03是读保持寄存器请求和响应格式如下表。请求PDU依次是功能码、起始地址高字节、起始地址低字节、寄存器数量高字节、寄存器数量低字节。响应PDU是功能码、字节数、寄存器数据每个寄存器2字节。功能码含义请求PDU响应PDU0x03读保持寄存器起始地址2B 数量2B数量1~125字节数1B 数据N×2B0x06写单寄存器地址2B 值2B原样回显请求PDU0x1016写多寄存器起始地址2B 数量2B 字节数1B 数据N×2B起始地址2B 数量2B一次读多个连续寄存器时数量上限是125个这是Modbus规范的限制PDU里数量字段只有1字节而0x7D到0xFF属于保留区。写多寄存器一次最多写123个因为响应要占用更多长度。做采集服务时优先用03批量读连续地址比一条条读快一个数量级后面第6章会展开说。为什么只用这三个功能码就够保持寄存器是仪表和PLC最常用的数据区实时值、累计值、设定值都映射在这里。输入寄存器04读模拟量输入结构和03完全一样只是寄存器的语义不同代码里把功能码从0x03换成0x04即可复用同一条读写链路。2.3 寄存器地址从0开始还是从1开始协议地址与组态显示的换算Modbus协议层的寄存器地址永远从0开始这是规范给出的数据模型。但在触摸屏、组态软件和多数设备手册里保持寄存器从40001开始编号。于是产生了三种说法协议地址、组态显示的4x区地址、某些手册自己的1基址。三者的换算关系是固定的协议地址 显示地址 - 40001或者协议地址 手册地址 - 1。举个例子手册写“温度保存在40003”那么报文的起始地址应该填0x0002而不是0x0003。反过来你在C#界面里让用户填“寄存器号40001~49999”发报文前要先减去40001。如果手册直接把寄存器编号成1、2、3就减1。判断基准点的方法是看手册里有没有一句话“地址从0开始”或“对应的Modbus映射地址为xxx”没有的话就先用Modbus Poll或Modbus Slave读写一次验证别凭感觉猜。还有一类设备比如部分西门子PLC的Modbus映射40001对应的不是协议地址0而是别的DB块偏移此时需要在驱动层做一张寄存器映射表把上位机的逻辑地址翻译成协议地址。这个翻译逻辑最好单独放一个方法不要散落在业务代码里。3. C#实现Modbus TCP客户端从TcpClient到可复用的轮询类3.1 最小可跑的读命令二十行代码读回PLC保持寄存器先把一个最小请求跑通再谈封装。下面这段代码用TcpClient连PLC发出功能码03的请求读10个保持寄存器并打印。using System.Net; using System.Net.Sockets; var client new TcpClient(); client.Connect(IPAddress.Parse(192.168.1.10), 502); var stream client.GetStream(); ushort transId 1; // 事务ID响应里会原样返回 byte unitId 1; // 单元ID直连PLC填1 ushort startAddr 0; // 协议地址0对应组态里的40001 ushort count 10; // 读10个寄存器 byte[] request new byte[12]; request[0] (byte)(transId 8); // 事务ID高字节 request[1] (byte)(transId 0xFF); // 事务ID低字节 request[2] 0x00; request[3] 0x00; // 协议标识符固定0 request[4] 0x00; request[5] 0x06; // 长度单元ID1功能码1地址2数量26 request[6] unitId; request[7] 0x03; // 功能码读保持寄存器 request[8] (byte)(startAddr 8); request[9] (byte)(startAddr 0xFF); request[10] (byte)(count 8); request[11] (byte)(count 0xFF); stream.Write(request, 0, request.Length); byte[] header new byte[7]; int read 0; while (read 7) // 先收满7字节MBAP头 { int n stream.Read(header, read, 7 - read); if (n 0) throw new IOException(连接被对端关闭); read n; } int length (header[4] 8) | header[5]; // 长度字段 byte[] body new byte[length - 1]; // 去掉单元ID read 0; while (read body.Length) // 再按长度收满PDU { int n stream.Read(body, read, body.Length - read); if (n 0) throw new IOException(连接被对端关闭); read n; } for (int i 0; i count; i) { ushort value (ushort)((body[2 i * 2] 8) | body[3 i * 2]); Console.WriteLine($寄存器 {startAddr i} {value}); } client.Close();这段代码有两个关键参数值得停顿一下长度字段计算公式是1单元ID加PDU长度读10个寄存器的请求PDU是5字节所以长度填6响应里body[0]是功能码body[1]是数据字节数从body[2]开始才是寄存器值。事务ID我每次请求加1后续封装时必须用它校验响应归属否则超时重发后收到旧响应会拿错数据。为什么要用两个while循环而不是直接调用ReadTCP三次握手建立的是一条字节流Read一次不保证把整个响应读完尤其请求和响应紧挨着时可能一次只回来半个包这是Modbus TCP最典型的半包问题。后面3.3会专门展开。3.2 封装成ModbusClient类连接管理、超时与重连最小命令跑通之后日常使用要封装成类。核心设计是一个TcpClient对应一条连接连接上用lock串行化请求避免多个线程同时写一个流导致报文交错。public class ModbusClient : IDisposable { private TcpClient? _tcp; private NetworkStream? _stream; private readonly object _lock new object(); private ushort _transId 0; public string Host { get; set; } 192.168.1.10; public int Port { get; set; } 502; public int TimeoutMs { get; set; } 2000; public byte UnitId { get; set; } 1; public void Connect() { _tcp new TcpClient(); _tcp.ReceiveTimeout TimeoutMs; _tcp.SendTimeout TimeoutMs; _tcp.Connect(Host, Port); _stream _tcp.GetStream(); } public ushort[] ReadHoldingRegisters(ushort startAddr, ushort count) { lock (_lock) { EnsureConnected(); byte[] pdu new byte[5]; pdu[0] 0x03; pdu[1] (byte)(startAddr 8); pdu[2] (byte)(startAddr 0xFF); pdu[3] (byte)(count 8); pdu[4] (byte)(count 0xFF); var response Execute(pdu); if (response[0] ! 0x03) throw new ModbusException($功能码异常: 0x{response[0]:X2}); ushort[] values new ushort[count]; for (int i 0; i count; i) { values[i] (ushort)((response[1 i * 2] 8) | response[2 i * 2]); } return values; } } private byte[] Execute(byte[] pdu) { _transId (ushort)((_transId 1) 0xFFFF); byte[] header new byte[7]; header[0] (byte)(_transId 8); header[1] (byte)(_transId 0xFF); header[2] 0x00; header[3] 0x00; header[4] (byte)((pdu.Length 1) 8); header[5] (byte)((pdu.Length 1) 0xFF); header[6] UnitId; byte[] request new byte[7 pdu.Length]; Buffer.BlockCopy(header, 0, request, 0, 7); Buffer.BlockCopy(pdu, 0, request, 7, pdu.Length); _stream!.Write(request, 0, request.Length); byte[] respHeader ReadBytes(7); if (respHeader[0] ! header[0] || respHeader[1] ! header[1]) throw new ModbusException(事务ID不匹配可能收到迟到响应); int length (respHeader[4] 8) | respHeader[5]; byte[] body ReadBytes(length - 1); if ((body[0] 0x80) ! 0) throw new ModbusException($异常响应功能码0x{body[0]:X2}异常码{body[1]:X2}); return body; } private byte[] ReadBytes(int count) { byte[] buffer new byte[count]; int read 0; while (read count) { int n _stream!.Read(buffer, read, count - read); if (n 0) throw new IOException(对端关闭连接); read n; } return buffer; } private void EnsureConnected() { if (_tcp null || !_tcp.Connected) Connect(); } public void Dispose() _tcp?.Close(); } public class ModbusException : Exception { public ModbusException(string message) : base(message) { } }这里把读和写统一成了Execute(byte[] pdu)PDU里只放功能码和数据MBAP头由Execute自动拼装。事务ID每调用一次加1并在响应里回读校验这是防止迟到响应混入的关键。超时设置体现在TcpClient的ReceiveTimeout和SendTimeout上到达时间后Read会抛IOException调用方捕获后决定重连还是重发。重连策略我一般这样处理捕获SocketException或IOException后先关闭旧连接然后等待指数退避例如100毫秒、200毫秒、500毫秒最多重试3次还不行就上报到上层业务。注意不要在循环里用Thread.Sleep阻塞UI线程轮询服务里用await Task.Delay更合适。3.3 粘包、半包与缓存响应为什么read一下就翻车Modbus TCP的报文是有长度字段的但TCP协议本身不认这个字段它只负责把字节流可靠地送过来。于是出现两类问题半包是响应还没到齐read先返回了粘包是多个响应连在一起到达第二次read把第一个响应的尾巴和第二个响应的头一起读出来。C#上位机里最常见的翻车现场是直接new一个固定长度的byte[]去接收读到了半个响应解析出来的寄存器值全是错乱的隔一段时间又自己恢复了。唯一可靠的收包方式就是刚才代码里的做法第一步先读满7字节MBAP头第二步从长度字段算出PDU长度再循环读满剩余字节。注意长度字段是7字节头之后的长度取的时候读header[4]和header[5]去掉单元ID那一字节。有些设备在响应异常时长度字段依然正常只是PDU里功能码带了最高位置位所以解析时先判断body[0]的最高位出现异常码就抛异常而不是当正常数据处理。还有一个隐蔽问题某个请求超时后你重发了同样的请求但第一次的迟到响应还在流里。如果只用固定事务ID不去校验就会把这批旧数据当成新数据。解决方法是事务ID自增并校验响应头不匹配就继续读流里剩下的字节直到匹配或流超时。4. 参数与边界超时、字节序、事务ID与多连接的四个必调项4.1 超时与重试ReceiveTimeout设多少、重连要不要退避上位机轮询PLC超时参数直接决定故障恢复速度。ReceiveTimeout太短PLC在忙的时候正常响应也会被误判超时太长设备真的掉线时要干等好几秒才能触发重连。常见做法是把读写超时设在1000到3000毫秒之间具体看设备规格西门子S7-200 SMART的Modbus TCP响应通常在几十毫秒内国产仪表可能到200毫秒以上留5到10倍余量比较稳。重试次数设为2到3次第一次超时马上重发同一事务ID第二次超时重连再发第三次还超时就置设备离线。不要无限重试否则设备断电后采集线程会一直卡在等待上其他设备也跟着停摆。指数退避比固定间隔好连不上时把重连间隔从100毫秒倍增到1秒设备恢复供电后能在一次心跳周期内自动回来。这里特别要提TCP KeepAlive。Windows默认的空闲检测周期是2小时工控设备很多在30到60秒没有应用层流量就断开连接。轮询本身是一种天然心跳但如果轮询周期超过设备闲置超时要给Socket启用KeepAlive或者每30秒发一个读单寄存器的请求保活。保活请求的响应正常就说明链路还活着。4.2 字节序与数据拼接int32和float为什么读出来是天文数字Modbus协议规定寄存器数据是大端序也就是高字节在前。单个寄存器的16位值按高位字节、低位字节拼。但32位数据跨两个寄存器时这里出现第一个分歧规范建议低地址寄存器存放高16位但不少设备尤其是西门子系反着放。// 场景A低地址寄存器是高位字 ushort highWord ReadRegister(0); ushort lowWord ReadRegister(1); uint raw ((uint)highWord 16) | lowWord; float value BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); // 场景B低地址寄存器是低位字 raw ((uint)lowWord 16) | highWord; value BitConverter.ToSingle(BitConverter.GetBytes(raw), 0);两个场景的差异只在拼接顺序上。BitConverter.GetBytes(raw)在小端机器上会把raw转成低字节在前的字节数组而ToSingle正好需要小端字节序所以这里不需要手动反转byte数组。判断设备是A还是B最可靠的办法是让PLC写一个已知的浮点数比如10.0抓包看寄存器原始值0x41A00000在低地址就是场景A0x000041A0就是场景B。除了字序还有字节倒序的情况两个寄存器每个字节都是反的比如AB CD EF GH变成了BA DC FE HG。遇到这种设备要在驱动层加一个字节反转开关不要写死在业务代码里。我一般把字节序策略做成枚举Normal、WordSwap、ByteReverse、Both由设备配置文件指定。4.3 事务ID与单元ID一对一的校验别用固定值事务ID的作用是匹配请求与响应。上位机并发轮询多台设备时如果事务ID永远是1两个请求的响应无法区分晚到的响应会把另一台设备的数据覆盖掉。包一层lock锁住连接是最省事的方案保证同一时刻只有一个请求在飞事务ID天然是一对一的。单元ID在直连场景没什么存在感但通过网关接多台串口从站时它就是Modbus TCP里的从站地址。例如一个网关下面挂了5块电表单元ID分别是1到5请求里的单元ID决定了网关去轮询哪块表。网关会把响应里的单元ID原样带回所以收到响应后不仅校验事务ID还要校验单元ID是否与请求一致防止网关配置错误串台。如果有多台独立设备我的习惯是每台设备建一个独立的TcpClient各自有独立的事务ID序列。不要在一个连接上并发发请求虽然事务ID能在协议层区分但很多PLC的从站处理器是串行的并发请求会导致响应顺序错乱设备直接丢弃后面的请求。5. Modbus TCP避坑指南异常码、断连与缓存读数的五种翻车现场5.1 返回0x83异常响应地址越界还是功能码不支持现象请求读保持寄存器收到一个PDU长度为3的响应功能码是0x83后面跟着一个异常码。原因功能码最高位置1表示这是异常响应。0x01是非法功能码从站不支持这个功能0x02是非法数据地址起始地址加数量越过了寄存器区边界0x03是非法数据值写入的值超出允许范围0x04是从站设备故障。解决把异常码解析成可读文本打日志。排查时先查功能码是不是从站支持的再看请求地址加数量有没有超过寄存器范围最后查设备有没有报故障。这里最容易翻车的是地址越界很多PLC寄存器区只有几百个一次读125个地址末尾就超了要按设备手册的寄存器范围限制单次请求数量。5.2 数据一直不变缓存了旧响应或读数太早现象PLC里的值已经改了读取结果始终是旧值过几秒又突然变新值。原因响应按长度字段收满后把字节数组放在类字段里复用下次响应比上次短数组末尾残留上一次的数据或读取线程和处理线程共享buffer处理还没完成数据就被覆盖。解决每次请求都分配新数组或者收到响应后立刻把数据拷贝到独立的结果对象里。不要在类里放一个公用的responseBuffer反复填。日志里同时打印hex格式的响应报文能快速看出到底是收到了新数据还是复用旧buffer。5.3 连接几小时后断开设备空闲掉线现象上位机开机跑几个小时都正常某时刻开始所有读写都超时重连一次又好了。原因PLC或网关设置了空闲超时连接没有应用层流量就被关闭。Windows的TCP KeepAlive默认2小时才探活一次根本保不住工控设备的心跳。解决在Connect方法里给Socket设置KeepAlive或者用轮询周期保活。轮询周期小于设备闲置超时就没有这个问题。如果轮询周期大于60秒单独开一个定时器每30秒发一次功能码03读一个寄存器响应正常就继续。_tcp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);KeepAlive只是OS层面的保活不保证工控设备一定买账最稳妥的还是应用层心跳读一个已知不变的寄存器顺便验证链路数据正确性。5.4 寄存器数值差一位地址基数没对齐现象手册说40001是温度值报文里填了地址0x0001读回来的是另一路信号填0x0000才对。原因协议地址从0开始40001对应的协议地址是0x0000。组态软件里的显示编号和协议地址差一个基准点。解决收到用户输入或配置文件的寄存器号后统一先减去基准值再拼报文。基准值通常是40001如果手册直接给协议地址就不用减。把换算逻辑写在ModbusClient外面不要让业务代码到处减40001否则排查时满地都是魔法数字。5.5 float读出来全是NaN字序和大小端叠加现象读int16正常读float全是NaN或巨大数值同一个值在触摸屏上显示正常。原因32位浮点的寄存器顺序或字节顺序与代码假设不一致。Modbus大端规范只约束单寄存器内的字节序跨寄存器的字序由设备厂商决定。解决抓包或打印hex对照设备写入的已知数值反推规则。我踩过最狠的一次是某电力仪表float字节序和字序都反了最后做成配置项Normal、WordSwap、ByteReverse、Both四种组合齐活换设备只改配置不用改代码。验证方法是用第6章的Modbus Poll对拍先确认工具的字节序选项再对照自己的换算结果。6. 从能跑到能用用Modbus Poll对拍、批量读与抓包三板斧把客户端类跑通只是第一步真正让它稳定工作我每次都会先做三件事。第一件事本地验证。装一个Modbus Slave模拟从站在电脑上监听502端口填入寄存器初始值再用Modbus Poll或自己的客户端去读写。如果自己的C#客户端能和Modbus Poll读到一致的值说明MBAP封装和长度计算没问题。对拍时要格外注意数据格式在Modbus Poll里选Float还是High-Word-First决定了它显示值的字节序来源和C#代码里的字序配置必须一致。第二件事批量读优化。轮询采集不要逐寄存器读一次功能码03最多读125个连续寄存器。把设备参数表按寄存器地址分块每块合并成一个批量请求吞吐量能提升一个数量级。这个批量请求里有一个参数要提前协商如果某块地址中间有空洞宁可分段跳过也不要硬拼地址越界直接触发异常码0x02。第三件事抓包对照。遇到对不上数据时不要急着改代码先看报文hex。我通常直接在Execute方法里把请求和响应的BitConverter.ToString打出来和设备的Modbus地址表一行行对照比盲改参数快得多。有一次查了半天float不对打印hex才发现设备只是把两个寄存器顺序调换了。这套方案我前后用了三四年从最开始的裸TcpClient改到带事务校验和指数退避的类最大的教训就一条Modbus TCP看起来是协议问题实际是报文边界和字节序问题先把hex打印利落了排查速度能翻倍。希望帮到你。本文还有配套的精品资源点击获取