☰
C#实现Modbus TCP通信:从报文解析到上位机采集实战
2026/9/30 5:46:45 网站建设 项目流程

简介: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的Modbus,C#源码:从最小读写到能上线的采集类

车间里新上一台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响应PDU
0x03读保持寄存器起始地址2B + 数量2B(数量1~125)字节数1B + 数据N×2B
0x06写单寄存器地址2B + 值2B原样回显请求PDU
0x10(16)写多寄存器起始地址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+数量2=6 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循环而不是直接调用Read?TCP三次握手建立的是一条字节流,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在低地址就是场景A,0x000041A0就是场景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打印利落了,排查速度能翻倍。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询