Modbus TCP通信C#源码:MBAP头、组包拆包与主从站联调 简介这份资源是一套基于 TCP 的 Modbus 通信协议 C# 实现源码面向已具备一定 C# 基础、希望快速掌握工业以太网通信的开发者与自动化方向学生。示例围绕 Modbus TCP 的类封装展开涵盖连接管理、请求报文构造、NetworkStream 数据收发、异常码检查、响应报文解析以及 async/await 异步通信与异常重试思路可直接对照理解 PLC、寄存器读写等典型工业场景的落地方式。压缩包共 46 个文件、约 148KB以 cs 源码为主体辅以 csproj、sln 工程配置dll、pdb 依赖与调试文件以及 xml 文档注释、chm 帮助和 exe 示例程序构成可直接打开调试的完整解决方案。目前已有 1076 人学习下载读者可通过阅读与断点调试源码理清 Modbus TCP 报文结构与通信流程并将其中的类模块迁移到自建上位机或设备联调项目中减少从协议文档到可用代码之间的摸索成本。1. 从一次车间联调说起调试工具能读自己的程序读不到前阵子帮人看一个产线采集的小上位机现场情况特别典型拿现成的 Modbus 调试工具连 PLC功能码 03 一按就出数换成自己用 C# 写的程序TCP 连接明明建立了读回来要么是空数组要么直接超时。折腾半天最后发现是 MBAP 头里的长度字段算错了一个字节。这就是一份「基于 TCP 的 Modbus C# 源码」最真实的价值——它不是教你 Modbus 是什么而是把组包、拆包、地址换算、异常码这些容易在车间里翻车的地方用能编译运行的代码摆在你面前。适合做上位机、设备网关、数据采集的开发也适合刚接手一个老 PLC 项目、需要自己写通信层的人。下面按协议、结构、跑通、排查、改造五步拆。2. 协议底盘拆解MBAP 头与 PDU 的逐字节组包Modbus TCP 相比 RTU 版本最大的变化是把串口那套 CRC 校验换成了一个 7 字节的 MBAP 头帧边界靠长度字段而不是靠 3.5 个字符时间的静默来判断。这个设计对写代码的人是好事——不用再算 CRC也不用做定时器分隔只要老老实实按长度字段读完就行。但坏处是长度字段写错一个数字对面就会一直等表现出来就是连接正常、没有返回非常难查。2.1 一条读保持寄存器请求长什么样先看一条完整的请求字节流这是主站发给从站、要求从 0 号地址开始读 10 个保持寄存器的报文00 01 00 00 00 06 01 03 00 00 00 0A拆开看就是两段前 7 个字节是 MBAP 头后面是 PDU。字段字节位置示例值说明事务标识符0-10x0001主站自增响应必须原样带回协议标识符2-30x0000Modbus 固定 0非 0 可以直接丢弃长度4-50x0006后续字节数含单元标识符单元标识符60x01站号TCP 下常用于区分网关后面的串口设备功能码70x03读保持寄存器起始地址8-90x00000 基地址寄存器数量10-110x000A一次最多 125 个对应的组包代码using System.Buffers.Binary; public static class ModbusCodec { // 组一条读保持寄存器(0x03)请求返回 12 字节完整帧 public static byte[] BuildReadHoldingRegisters(ushort transactionId, byte unitId, ushort startAddress, ushort quantity) { if (quantity is 1 or 125) throw new ArgumentOutOfRangeException(nameof(quantity), 0x03 单次最多读 125 个寄存器); var frame new byte[12]; // MBAP 头事务号 协议号 长度 单元标识 BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(0), transactionId); BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2), 0x0000); // 长度 单元标识(1) PDU(功能码1 起始地址2 数量2) 6 BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(4), 6); frame[6] unitId; // PDU frame[7] 0x03; BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(8), startAddress); BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(10), quantity); return frame; } }这里有几个参数必须说清楚。transactionId由主站维护每发一帧自增一次它的作用是让响应能对上请求在并发场景下尤其重要unitId在纯 TCP 直连设备时通常填 1 或者设备手册指定的站号很多网关设备会用它来路由到后面的串口从站填错了就会出现网关在线但不转发的现象startAddress是 0 基地址和 PLC 手册上常见的 40001 这种 1 基编号差 1这是新手最常栽的地方后面第 5 章会专门说。再看响应帧00 01 00 00 00 05 01 03 02 00 0A长度字段变成了 5因为 PDU 从 5 字节缩成了 3 字节加上数据 2 字节单元标识 1 功能码 1 字节数 1 数据 2 5。字节数0x02表示后面跟着 2 个字节的数据读 10 个寄存器时这个字段就是0x1420 字节。解析时先读 7 字节头拿到长度再按长度减 1减掉单元标识读 PDU这个顺序不能反。2.2 功能码与四个数据区的对应关系Modbus 把数据分成四个区两个可读写的、两个只读的功能码和区是一一对应的。写代码的时候如果把这层映射搞混就会出现我明明写进去了、读出来还是旧值——因为你写的是线圈读的是保持寄存器。功能码名称数据区单位单次上限0x01读线圈Coilsbool2000 位0x02读离散输入Discrete Inputsbool2000 位0x03读保持寄存器Holding Registersushort125 个0x04读输入寄存器Input Registersushort125 个0x05写单个线圈Coilsbool1 个0x06写单个寄存器Holding Registersushort1 个0x0F写多个线圈Coilsbool1968 位0x10写多个寄存器Holding Registersushort123 个从站侧的数据区我一般用一个类装起来四个数组都是 65536 长度索引就是协议地址省掉各种偏移换算public sealed class DataStore { // 索引即 Modbus 协议地址从 0 开始 public bool[] Coils { get; } new bool[65536]; public bool[] DiscreteInputs { get; } new bool[65536]; public ushort[] HoldingRegisters { get; } new ushort[65536]; public ushort[] InputRegisters { get; } new ushort[65536]; }用满 65536 个槽位ushort[]是 128KB四个数组加起来 320KB 左右对现代机器完全不是负担换来的是地址直接当下标用不用写任何偏移逻辑。如果你要接的真实设备只映射几百个点可以把长度收小但收小之后一定要在读写前做边界检查否则越界异常会在协议层变成一个莫名其妙的连接断开。2.3 大端序和位打包C# 里最容易翻车的地方Modbus 是网络字节序也就是大端x86 和 ARM 上的 .NET 跑的是小端。一个ushort值0x1234直接BitConverter.GetBytes出来是34 12写到线上就错了对面读到的会是0x3412。这个错误在数值较小的时候不容易发现因为0x0001反过来是0x0100一读就露馅但如果刚好是个对称的值你可能要查一下午。正确做法是用BinaryPrimitives里的 BigEndian 系列方法或者把BitConverter的结果反转一次// 推荐语义明确不用自己管字节顺序 BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(offset), value); ushort v BinaryPrimitives.ReadUInt16BigEndian(buffer.AsSpan(offset)); // 老项目里常见的写法也能跑但多一次分配 byte[] raw BitConverter.GetBytes(value); if (BitConverter.IsLittleEndian) Array.Reverse(raw);线圈方向的坑更隐蔽。读线圈返回的数据是位打包的1 个字节装 8 个线圈规则是第一个字节的最低位对应第一个线圈// 把读到的字节按位展开成 boolbit0 对应起始地址 public static bool[] UnpackBits(ReadOnlySpanbyte data, int count) { var result new bool[count]; for (int i 0; i count; i) { // i / 8 决定用哪个字节i % 8 决定取哪一位 result[i] (data[i / 8] (1 (i % 8))) ! 0; } return result; }还有一个寄存器拼 32 位整数的顺序问题。Modbus 本身只定义到 16 位寄存器32 位浮点或整数要占两个寄存器先高后低还是先低后高各家设备厂商做法不一样有的叫 word swap有的叫 byte swap。这个不写死在协议层最好在业务层配置一个枚举我一般叫它WordOrder读出来先按寄存器顺序拿到两个ushort再按配置拼装改设备的时候只改配置不改代码。3. 源码结构主站与从站两条线怎么分一份能用的 Modbus TCP C# 源码不管规模大小本质上都跑不出两条线一条是主动发请求的主站Master也叫 Client一条是应答请求的从站Slave也叫 Server。把这两条线和协议编解码分开是判断这份代码能不能拿去二次开发的第一标准。见过太多项目把 Socket 读写、报文拼装、业务逻辑全塞在一个 Form 的按钮事件里改一个功能码要动三百行。3.1 目录骨架与工程拆分合理的拆法是四个工程用.sln串起来ModbusTcp.sln ├── Modbus.Protocol/ # 纯计算无 IO编解码、功能码、异常码 │ ├── ModbusCodec.cs # 组包/拆包 │ ├── FunctionCode.cs # 功能码常量 │ └── ModbusException.cs # 异常码封装 ├── Modbus.Transport/ # Socket 层连接、收满一帧、重连 │ ├── IModbusTransport.cs │ └── TcpModbusTransport.cs ├── Modbus.Master/ # 主站请求/响应匹配、超时重试 │ └── ModbusMaster.cs ├── Modbus.Slave/ # 从站监听、请求分发、数据区 │ ├── ModbusSlave.cs │ └── DataStore.cs └── Modbus.Demo/ # 控制台用来联调验证 └── Program.csModbus.Protocol之所以要独立是因为它可以完全脱离网络做单元测试——给一段字节断言解析出来的功能码和地址对不对。这份代码里最值钱的部分就在这只要转发层不出大问题协议层测通了剩下的就是连不连得上的问题。环境上.NET 6及以上都行用dotnet new console起项目System.Buffers.Binary和System.Net.Sockets都是框架自带不需要任何第三方依赖。如果你的上位机还停在.NET Framework 4.7.2BinaryPrimitives需要引一个System.Memory包SpanT也能用代码基本不用改。业界也有一些成熟的开源 Modbus 库可以直接NuGet引用如果只是临时采个数用它更快但要在别人的框架上做定制、或者要嵌到一个已有的通信中间件里自己掌握这份源码会舒服很多。3.2 传输层TcpClient 长连接、分包与重连Modbus TCP 用 502 端口长连接。这里最容易做错的是以为Read一次就能拿到一帧——TCP 是字节流一帧 12 字节可能分两次到两帧也可能粘在一起到。所以传输层必须提供一个读满 N 字节的方法public sealed class TcpModbusTransport : IModbusTransport, IDisposable { private readonly TcpClient _client; private readonly NetworkStream _stream; public TcpModbusTransport(string host, int port, int timeoutMs 2000) { _client new TcpClient(); _client.NoDelay true; // 关掉 Nagle小包立刻发 _client.Connect(host, port); _client.ReceiveTimeout timeoutMs; _client.SendTimeout timeoutMs; _stream _client.GetStream(); } // 读满 buffer.Length 个字节才返回处理半包 public async Taskbool ReadExactAsync(Memorybyte buffer, CancellationToken ct) { int read 0; while (read buffer.Length) { int n await _stream.ReadAsync(buffer[read..], ct); if (n 0) return false; // 对端关闭连接 read n; } return true; } public ValueTask SendAsync(ReadOnlyMemorybyte frame, CancellationToken ct) _stream.WriteAsync(frame, ct); }NoDelay true这一行值得单独说Modbus 的请求帧通常只有十几个字节如果开着 Nagle 算法系统会攒一攒再发表现到应用层就是偶尔多出几十毫秒延迟在高频轮询场景下很扎眼。ReceiveTimeout设成 2 秒是个经验值产线设备响应一般在几十毫秒内超过 2 秒基本可以判定为设备掉线或者报文错了。重连不要写在传输层里面。传输层只管给我一个能用的连接重连由主站层用一个带退避的循环来做连不上就等 1 秒、2 秒、4 秒最多退到 30 秒并且在重连成功后把transactionId重置。把重连塞进传输层会导致上层无法区分是首次连接失败还是用着用着断了日志会很难看。3.3 从站侧一个字节流如何走到寄存器数组从站的循环是接一个连接起一个任务每个连接内部循环读 7 字节头 → 按长度读剩余 → 分发 → 回响应private async Task HandleClientAsync(TcpClient client, CancellationToken ct) { using var stream client.GetStream(); var header new byte[7]; while (!ct.IsCancellationRequested) { // 第 1 步读满 7 字节 MBAP 头 if (!await ReadExactAsync(stream, header, ct)) return; ushort protocolId BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(2)); ushort length BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4)); // 协议标识非 0 说明不是 Modbus直接断 if (protocolId ! 0 || length is 2 or 260) return; // 第 2 步长度字段含单元标识PDU 长度 length - 1 var pdu new byte[length - 1]; if (!await ReadExactAsync(stream, pdu, ct)) return; // 第 3 步分发并组响应 byte[] responsePdu Dispatch(pdu, _store); var response BuildMbap(header, (ushort)(responsePdu.Length 1), responsePdu); await stream.WriteAsync(response, ct); } }length is 2 or 260这个边界检查不是摆设。真实网络里偶尔会收到扫描包或者上一条连接残留的脏数据长度字段可能是 0 也可能是几万如果不拦住new byte[length - 1]要么抛异常要么直接吃掉几百 KB 内存。上限 260 对应 Modbus 规范的最大 PDU 长度 253 加单元标识超过就不是合法帧。Dispatch是个switch把功能码映射到具体处理函数每个处理函数负责三件事参数合法性检查、数据区读写、组装响应 PDU。读请求的响应 PDU 是功能码 字节数 数据写请求的响应 PDU 基本是原样回显前 5 个字节功能码 地址 数量。异常响应的规则是功能码最高位置 1比如 0x03 变成 0x83后面跟一个异常码字节。4. 跑通全流程起一个从站再用主站读写理论讲完落地要能一次跑通。这一节按先起从站再用主站打的顺序来因为从站是自己写的可控主站打自己的从站报文对不对一眼就能看出来。全部代码在控制台里跑不依赖任何界面。4.1 从站最小实现与启动参数从站的入口就是一个TcpListener加一个接受循环public sealed class ModbusSlave { private readonly TcpListener _listener; private readonly DataStore _store new(); public ModbusSlave(int port 502) { // 502 是标准端口非管理员身份在 Windows 上可能绑不上调试期建议换 1502 _listener new TcpListener(IPAddress.Any, port); } public async Task StartAsync(CancellationToken ct) { _listener.Start(); Console.WriteLine($从站已监听 {_listener.LocalEndpoint}); while (!ct.IsCancellationRequested) { var client await _listener.AcceptTcpClientAsync(ct); _ Task.Run(() HandleClientAsync(client, ct), ct); } } }启动前先在数据区里塞几个值方便验证var slave new ModbusSlave(1502); // 预置保持寄存器 0~9 放 100,101,...线圈 0~7 前四个为 true for (int i 0; i 10; i) slave.Store.HoldingRegisters[i] (ushort)(100 i); for (int i 0; i 4; i) slave.Store.Coils[i] true; _ slave.StartAsync(cts);端口这里有个现实问题502 是 Modbus TCP 的约定端口但在 Windows 上绑定 1024 以下的端口需要管理员权限直接跑会抛SocketException: Access is denied。调试阶段用 1502 或者 5020 完全可以主站连的时候把端口一起改掉就行协议本身跟端口无关。真到部署的时候再用管理员权限或者配一个端口转发。AcceptTcpClientAsync后面跟_ Task.Run(...)是故意不 await 的每个连接跑自己的任务。这里要注意异常处理HandleClientAsync内部必须把IOException和SocketException兜住并打日志否则一个客户端异常断开就能让整个进程崩掉——这种问题在调试期很少遇到一上现场就集中爆发。4.2 主站读写线圈和保持寄存器主站的核心是发一帧、等一帧、校验、返回用SemaphoreSlim保证同一时刻只有一个请求在飞因为 Modbus TCP 虽然支持多事务并发但绝大多数设备内部是串行处理的并发发过去反而会乱序public async Taskushort[] ReadHoldingRegistersAsync(byte unitId, ushort start, ushort count, CancellationToken ct default) { await _lock.WaitAsync(ct); try { ushort tid _transactionId; byte[] request ModbusCodec.BuildReadHoldingRegisters(tid, unitId, start, count); await _transport.SendAsync(request, ct); // 读 7 字节 MBAP 头 var header new byte[7]; if (!await _transport.ReadExactAsync(header, ct)) throw new IOException(从站关闭了连接); ushort respTid BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(0)); if (respTid ! tid) throw new ModbusException($事务标识不匹配期望 {tid}实际 {respTid}); ushort length BinaryPrimitives.ReadUInt16BigEndian(header.AsSpan(4)); var pdu new byte[length - 1]; if (!await _transport.ReadExactAsync(pdu, ct)) throw new IOException(PDU 读取不完整); // 功能码最高位为 1 表示异常响应 if ((pdu[0] 0x80) ! 0) throw new ModbusException($从站返回异常码 0x{pdu[1]:X2}); int byteCount pdu[1]; var result new ushort[byteCount / 2]; for (int i 0; i result.Length; i) result[i] BinaryPrimitives.ReadUInt16BigEndian(pdu.AsSpan(2 i * 2)); return result; } finally { _lock.Release(); } }调用起来就是一行var master new ModbusMaster(127.0.0.1, 1502); ushort[] regs await master.ReadHoldingRegistersAsync(unitId: 1, start: 0, count: 10); Console.WriteLine(string.Join(,, regs)); // 期望输出 100,101,...,109 await master.WriteSingleRegisterAsync(1, 0, 999); ushort[] again await master.ReadHoldingRegistersAsync(1, 0, 1); // 期望 999unitId: 1是站号跟从站里frame[6]读出来的值对应start: 0是 0 基地址对应从站HoldingRegisters[0]count: 10会被校验在 1~125 之间。三个参数任意一个不对返回的就不是你期望的数组而且往往是不报错但数据错位比抛异常更难查。4.3 联调验证报文抓包与返回码对齐跑通之后建议立刻做两件事。第一件是把主站和从站放在同一台机器上跑127.0.0.1:1502排除网络因素第二件是打开一个报文抓包工具或者干脆在SendAsync前后打十六进制日志对着前面的报文结构表逐字节核对。日志里最该看的是三个数事务标识符是否一一对应、长度字段是否等于实际后续字节数、功能码是否被原样返回。这三个对上了剩下的基本就是数据区的地址问题。如果从站返回的是0x830x03 最高位置 1那就说明请求到达了从站但被拒绝了后面的异常码字节会告诉你是地址越界0x02还是数量超限0x03。写多个寄存器0x10的联调容易被忽略。它的请求 PDU 比读请求多一个字节数字段长度 单元标识 1 功能码 1 起始地址 2 数量 2 字节数 1 数据 2n很多人第一次写会把长度算成7 2n之外的数结果从站一直等数据。稳妥的做法是让从站和主站共用同一套ModbusCodec里的常量长度只在一个地方算。5. 排查看这里连接正常但读不到数的四类坑这几种情况在车间里出现的频率远高于协议不懂。它们的共同特征是连接是通的、程序不崩、但数据就是不对靠猜是猜不出来的得知道往哪儿看。5.1 事务标识和单元标识错位现象主站收到响应但解析出来的数据整体偏移或者直接在事务标识不匹配上抛异常。原因有两个方向。一是主站里_transactionId用了byte而不是ushort超过 255 之后回绕和老响应撞号二是从站组响应时直接复用了请求的前 6 个字节但忘了长度字段要按响应的实际长度重算结果长度字段还停留在请求的值。解决事务标识用ushort并且只增不减回绕就回绕只要不在同一时刻重号就没问题从站组 MBAP 头时长度字段必须重新计算成responsePdu.Length 1不要复用请求头。单元标识则要在主站侧做校验响应里的单元标识和请求不一致时直接丢弃这一帧否则网关环境下会把别的从站的数据当成自己的。5.2 半包、粘包与超时设置现象跑了几个小时没问题忽然某一次读超时重连之后又好了或者调试工具里数据刷得飞快自己的程序每隔几十次就错一次。原因TCP 是流式协议一次ReadAsync不保证读到你想要的字节数。如果代码里写的是int n await stream.ReadAsync(buffer);然后就直接解析buffer那么当一帧被拆成两段到达时第二段会被当成下一帧的开头整个字节流从此错位。粘包则是相反的一次读回来两帧你只解析了第一帧第二帧的字节被丢掉。解决所有读取都必须走读满 N 字节的循环就像ReadExactAsync那样。还要注意ReadAsync返回 0 表示对端正常关闭不是暂时没数据必须当成连接断开处理。超时值不要设得太短2 秒是个稳妥的起点PLC 在扫描周期的某些时刻会短暂不响应设成 200 毫秒会误判。5.3 地址基数与寄存器偏移现象想读 40001结果读到的是 40002 的值或者读 10 个寄存器第一个是想要的后面全部错位。原因PLC 手册上的地址是 1 基的而且带区号前缀——40001 表示保持寄存器区的第 1 个协议里对应的地址是 0。30101 表示输入寄存器区第 101 个协议地址是 100。有些设备手册干脆写寄存器地址 40001~40100很多人直接把这个数当协议地址填进去整体偏移了 1 甚至 40000。解决写死一条规则——进协议层之前所有地址统一换算成 0 基。在业务配置表里存原始地址和区类型读的时候做一次映射保持寄存器减 40001输入寄存器减 30001线圈减 1。这份映射单独写一个函数并加注释不要散落在各个调用点。5.4 异常码 0x02 / 0x03 的定位方法现象从站返回0x83 0x02主站抛地址越界但代码里的地址看着没问题。原因0x02 是非法数据地址0x03 是非法数据值。最常见的是数量超限——0x03 一次最多 125 个写了 200 就会返回 0x03其次是起始地址加数量超过了从站实际映射的区间。这两个错误由从站判定主站侧只能根据异常码反推。解决在主站的ModbusException里把异常码翻译成人话日志里直接打非法数据地址检查起始地址是否超出从站映射范围和非法数据值检查数量是否超过 125。同时把请求原文和响应原文一起打到日志里十六进制方便对照。有这个日志现场排查时间能从半天压到十分钟。6. 二次开发把协议层换成可替换的接口这份源码真正要用在自己的项目里第一步不是加功能而是把传输层抽成接口。原因是现场环境五花八门今天是 TCP 直连明天可能是串口转 TCP 的网关后天又要换成 RTU 走 485。如果ModbusMaster里直接new TcpClient()每换一种就得改一遍业务代码。6.1 抽 IModbusTransport 接口接口只需要两个方法SendAsync和ReadExactAsync加一个Dispose。public interface IModbusTransport : IDisposable { ValueTask SendAsync(ReadOnlyMemorybyte frame, CancellationToken ct); Taskbool ReadExactAsync(Memorybyte buffer, CancellationToken ct); }上层ModbusMaster只依赖这个接口构造函数注入。RTU 版本实现里SendAsync加 CRC、ReadExactAsync按帧长读MBAP 头换成从站地址加 CRCTCP 版本保持原样。业务层一行都不用改只换一个工厂方法。这个改造我做过好几次每次都是半小时的事但省下的是后面几个月的返工。6.2 批量读与缓存轮询几十个点位的时候别一个点发一帧。把地址连续的寄存器合并成一次读请求是收益最大的优化——读 50 个寄存器合并成 1 次 0x03 请求比发 50 次快一个数量级。合并的时候注意两点一次不超过 125 个地址不连续的不能硬合。合并不了的点再单独发。再往上一层加个缓存把上次读到的值和时间戳存起来业务层按需取通信层用固定周期刷新。这样即使某一帧超时业务层拿到的也是上一次的有效值不至于整个界面全变红。6.3 回环测试与验证改完协议层验证方法很土但有效起一个从站起一个主站让主站对所有功能码各跑一遍写入再读回断言值一致。线圈写true读回true寄存器写0x1234读回0x1234多寄存器写 100 个值读回 100 个值。这个回环测试跑通说明组包、拆包、字节序、位打包四件事全对。我从那次长度字段算错之后落下一个习惯每次改协议层先跑回环再连真实设备绝不跳步。真实设备的不确定性太多用它来验证自己的代码等于同时排查两个变量血泪经验。这套代码你拿去之后建议也先把回环测试建起来后面加多少功能码都不慌。希望帮到你。本文还有配套的精品资源点击获取