
简介工业自动化领域常需上位机与PLC等设备稳定通信Modbus TCP凭借可靠连接和良好兼容性成为广泛应用的工业以太网协议之一。整套C#源码包面向具备.NET开发基础、希望快速实现Modbus TCP客户端的工程师可解决从零搭建通信模块的重复造轮子问题。压缩包内共46个文件大小仅148KB除了cs核心源码还包含sln/csproj工程文件、exe/dll可执行程序、xml说明文档、pdb调试符号及chm帮助文档便于直接编译运行和对照学习。目前已有1072人学习下载适合入门与进阶参考。代码主体Modbus_TCP_class_demo类演示了完整通信流程通过TcpClient建立连接构造携带功能码与寄存器地址的请求报文经NetworkStream发送并接收响应再解析线圈、寄存器等数据类型同时覆盖超时、连接中断等异常处理采用async/await异步方式避免阻塞并加入重试机制提升稳定性。无论是学习协议细节还是移植到实际项目中这套源码都提供了清晰可用的范例。1. 从温控器调不通说起一个基于 TCP 的 Modbus C# 源码包能干什么前段时间为了对接一台温控器我在网上翻遍了各种 Modbus 库最后干脆把一个基于 TCP 的 ModbusModbus TCPC# 源码包拉下来自己改造。这份源码和网上那种只讲协议的 Demo 不一样它把主站客户端、从站服务端、MBAP 报文、寄存器读写、异常码返回都封装好了打开工程编译就能跑。它适合两类人一类是工控上位机开发者要快速把采集程序跑起来另一类是刚接触 Modbus 的人想直接在源码级别看 TCP 报文怎么拼接、怎么拆开。下面按我实际拆这份代码的顺序把项目结构、请求链路、踩坑记录和改造方法都写出来。2. 协议与项目结构MBAP 头、分层目录和 TCP 拆包逻辑先说一个反直觉的结论在 C# 里写 Modbus TCP最大的工作量不在功能码而在把 TCP 流切成一个个完整的 Modbus 帧。Modbus RTU 用 CRC 校验保证一帧一报文的边界Modbus TCP 去掉了 CRC却多了个 7 字节的 MBAP 头真正决定帧边界的是 MBAP 头里的长度字段。因此传输层代码写得健壮与否直接决定了上位机跑几个小时之后会不会出现“半包”崩溃。这份源码的定位很清晰它是一个完整的 C# 通信栈而不是一个写死在控制台里的示例脚本。协议解析、客户端、服务端分成三个工程可以单独引用到你自己项目里也可以把它当成参考手册来读。接下来先看工程长什么样再从头把报文结构拆出来。2.1 目录结构从 Socket 封装到寄存器读写全部解耦拿到的目录结构大致是这样的ModbusTcp/ ├─ ModbusTcp.sln ├─ src/ │ ├─ ModbusTcp.Core/ │ │ ├─ MBAPHeader.cs // MBAP 头拼接与解析 │ │ ├─ FunctionCode.cs // 功能码常量 │ │ ├─ ExceptionCode.cs // 异常码定义 │ │ ├─ ModbusPduBuilder.cs // PDU 组装 │ │ └─ ByteOrderConverter.cs // 字节序转换 │ ├─ ModbusTcp.Client/ │ │ ├─ ModbusTcpClient.cs // 主站客户端入口 │ │ ├─ SocketTransceiver.cs // 收发与事务 ID 配对 │ │ └─ RequestQueue.cs // 异步请求任务队列 │ └─ ModbusTcp.Server/ │ ├─ ModbusTcpServer.cs // 从站服务端 │ ├─ SessionContext.cs // 每个 TCP 连接一个会话 │ └─ RegisterTable.cs // 寄存器内存表 ├─ samples/ │ ├─ ReadRegisterDemo/ // 客户端示例读保持寄存器 │ └─ VirtualSlave/ // 虚拟从站方便本机联调 └─ tests/ └─ ModbusTcpTests/ // 单元测试粘包、字节序、异常码这个分层思路很值得抄。Core 不依赖任何 Socket 类型只做字节数组和协议结构之间的转换Client 和 Server 都只依赖 Core。所以我可以在没有硬件的情况下先用VirtualSlave把整条链路跑通再把SocketTransceiver换成真实设备的 TCP 地址。FunctionCode.cs里放的是 Modbus 常用的功能码常量读线圈 0x01、读离散输入 0x02、读保持寄存器 0x03、读输入寄存器 0x04、写单线圈 0x05、写单寄存器 0x06、写多寄存器 0x10。你会在后续客户端和服务端代码里反复看到这些常量。把常量集中在一个文件里比字符串魔法数字好维护得多。2.2 MBAP 头格式与请求帧的拼装方法Modbus TCP 报文由 MBAP 头和 PDU 组成。MBAP 头共 7 字节各字段如下字段长度说明事务处理 ID2请求与响应配对并发时靠它识别协议 ID2固定为 0x0000目前只有 Modbus 协议长度2后面所有字节数即 unitId PDU单元 ID1从站地址通常填 0x01 或设备地址事务处理 ID 是很多人第一次写 Modbus TCP 时忽略的字段。它解决的是“我在一条 TCP 连接上同时发了 5 个请求每个响应来的时候我怎么知道它是回答哪一个”的问题。只要你的客户端允许多个请求异步并发就得让每个请求分配一个不同的 ID解析响应时回头查 ID。拼 MBAP 头的代码可以这样写public static byte[] BuildMbapFrame(ushort transactionId, byte unitId, byte[] pdu) { // pdu 是完整的功能码 数据部分调用方负责保证 pdu 不为空 int pduLength pdu?.Length ?? 0; byte[] frame new byte[7 pduLength]; frame[0] (byte)(transactionId 8); // 事务 ID 高字节 frame[1] (byte)(transactionId 0xFF); // 事务 ID 低字节 frame[2] 0x00; // 协议 ID 固定高位 frame[3] 0x00; // 协议 ID 固定低位 int innerLength 1 pduLength; // unitId PDU 的总长度 frame[4] (byte)(innerLength 8); // 长度字段高字节 frame[5] (byte)(innerLength 0xFF); // 长度字段低字节 frame[6] unitId; // 单元 ID Array.Copy(pdu, 0, frame, 7, pduLength); return frame; }这段代码的核心参数有三个transactionId是每次请求唯一unitId是对端从站地址pdu里第一个字节必须是功能码。我习惯把pdu设计为“包含功能码”的完整 PDU这样BuildMbapFrame只负责套壳不关心具体功能读寄存器、写寄存器、读线圈都走同一个方法。有人会问长度字段为什么不用pduLength而是1 pduLength因为长度字段统计的是“单元 ID PDU”单元 ID 在 MBAP 头里但被计入长度。这是 Modbus TCP 协议里非常容易搞错的地方很多初学者在这里差一个字节导致响应被解析错位。2.3 拆包与粘包按长度字段切分 TCP 流TCP 是流式协议没有“一条消息”的边界。一次Read可能只返回半个 Modbus 帧也可能把两个帧黏在一起返回。所以接收端必须自己维护缓冲按 MBAP 头里的长度字段把帧切出来。下面的ReadFrame就是这份源码里的核心拆包函数public static byte[] ReadFrame(NetworkStream stream) { // 先读 6 字节事务 ID(2) 协议 ID(2) 长度(2) byte[] head new byte[6]; ReadExactly(stream, head, 0, 6); // 从 head 第 4、5 字节取出后续数据长度 int innerLength (head[4] 8) | head[5]; // innerLength 包含单元 ID PDU byte[] body new byte[innerLength]; ReadExactly(stream, body, 0, innerLength); // 返回完整帧head(6) body(innerLength) byte[] frame new byte[6 innerLength]; Array.Copy(head, 0, frame, 0, 6); Array.Copy(body, 0, frame, 6, innerLength); return frame; } private static void ReadExactly(Stream stream, byte[] buffer, int offset, int count) { int read 0; while (read count) { int n stream.Read(buffer, offset read, count - read); if (n 0) throw new IOException(连接已关闭或对端主动断开); read n; } }所有棉包问题几乎都出在ReadExactly这个循环上。有人直接stream.Read(buffer, 0, 4)然后拿返回值当完整数据这在本地环回上可能永远不报错一旦放到真实网络里一个小包被拆成两段解析就翻车。ReadExactly的逻辑是“不管Read这次返回多少字节我都接着读直到凑够需要的数量”。参数说明head先读 6 个字节是因为这 6 字节足够拿到长度信息innerLength是剩下的字节数读完body之后head加body就是一个完整 Modbus TCP 帧。拿到帧以后客户端根据transcationId找到等待中的请求任务服务端则根据功能码分发到处理函数。提示Modbus TCP 单帧 PDU 最长 253 字节加上 MBAP 头整帧最大不过 260 字节所以这里的长度字段用ushort就够了。不要因为 TCP 粘包就无脑加复杂状态机先做好“按长度循环读取”这一件事90% 的通信问题都能消失。3. 核心模块拆解一个 Modbus TCP 请求从进入到返回的完整链路这一章我们顺着一个完整请求走一遍上位机调用客户端读保持寄存器 → 组帧 → 发送 → 从站接收 → 查寄存器表 → 返回响应 → 客户端解析。链路中的每个环节都会看到你稍后改造时最关心的代码。3.1 主站客户端实现读保持寄存器 0x03 与写单寄存器 0x06客户端的核心方法很简单本质就是“组 PDU → 套 MBAP → 发送 → 等待响应 → 解析”。以读保持寄存器为例public async Taskushort[] ReadHoldingRegistersAsync( byte unitId, ushort startAddress, ushort count, CancellationToken ct) { // 03 功能码 PDU功能码 起始地址 寄存器数量 byte[] pdu new byte[5]; pdu[0] FunctionCode.ReadHoldingRegisters; // 0x03 pdu[1] (byte)(startAddress 8); // 起始地址高字节 pdu[2] (byte)(startAddress 0xFF); // 起始地址低字节 pdu[3] (byte)(count 8); // 数量高字节 pdu[4] (byte)(count 0xFF); // 数量低字节 ushort transactionId _nextId; byte[] request MBAPHeader.BuildMbapFrame(transactionId, unitId, pdu); byte[] response await _transceiver.ExchangeAsync(request, transactionId, ct); // 响应帧结构MBAP(7) 功能码(1) 字节数(1) 寄存器值(2N) int byteCount response[8]; ushort[] result new ushort[byteCount / 2]; for (int i 0; i result.Length; i) { result[i] ByteOrderConverter.ToUInt16( response[9 i * 2], response[10 i * 2]); } return result; }这里有两个关键参数startAddress是寄存器地址注意它是 0 起始的协议地址不是设备文档上写的 40001 这种物理地址count最大 125超过 125 从站会返回非法数据值异常。地址和数量都以大端序放在 PDU 里也就是高字节在前低字节在后。_transceiver.ExchangeAsync做的事情是把请求写入发送队列并注册一个以transactionId为 key 的任务接收线程拿到响应帧后根据 ID 找到任务并填充结果。这样多个异步请求可以共用一个 Socket不会互相阻塞。写单寄存器的代码更短public async Task WriteSingleRegisterAsync( byte unitId, ushort address, ushort value, CancellationToken ct) { // 06 功能码 PDU功能码 地址 值 byte[] pdu new byte[5]; pdu[0] FunctionCode.WriteSingleRegister; // 0x06 pdu[1] (byte)(address 8); pdu[2] (byte)(address 0xFF); pdu[3] (byte)(value 8); // 值高字节 pdu[4] (byte)(value 0xFF); // 值低字节 ushort transactionId _nextId; byte[] request MBAPHeader.BuildMbapFrame(transactionId, unitId, pdu); byte[] response await _transceiver.ExchangeAsync(request, transactionId, ct); }写完以后较真的项目一般还会校验响应帧里的功能码是否为 0x06以及地址和值是否和请求一致。Modbus 协议规定写单寄存器成功时从站会原样回显请求内容所以这个校验成本很低建议保留。3.2 从站服务端实现请求分发与异常码从站端核心是一个监听循环和一个会话处理循环。监听循环接收 TCP 连接每来一个连接就创建一个SessionContext之后这个上下文里的所有请求共享同一个从站寄存器内存表。private async Task HandleSessionAsync(TcpClient tcp, CancellationToken ct) { using NetworkStream stream tcp.GetStream(); while (!ct.IsCancellationRequested) { byte[] frame ReadFrame(stream); // frame[6] 是单元 IDframe[7] 开始是 PDU byte unitId frame[6]; byte[] pdu new byte[frame.Length - 7]; Array.Copy(frame, 7, pdu, 0, pdu.Length); byte[] responsePdu _dispatcher.Dispatch(unitId, pdu); // 从请求帧里取出事务 ID回给客户端时保持一致 ushort transactionId (ushort)((frame[0] 8) | frame[1]); byte[] responseFrame MBAPHeader.BuildMbapFrame( transactionId, unitId, responsePdu); await stream.WriteAsync(responseFrame, ct); } }分发器里是一个switch按pdu[0]功能码决定调用哪个处理器public byte[] Dispatch(byte unitId, byte[] pdu) { byte functionCode pdu[0]; switch (functionCode) { case FunctionCode.ReadHoldingRegisters: return HandleReadHoldingRegisters(pdu, unitId); case FunctionCode.WriteSingleRegister: return HandleWriteSingleRegister(pdu, unitId); default: // 异常响应的功能码最高位置 1 return new byte[] { (byte)(functionCode | 0x80), ExceptionCode.IllegalFunction }; } }这里最值得注意的是异常响应的构造方式。正常响应的功能码是 0x03异常响应则把功能码最高位置 1变成 0x83后面跟一个异常码。所以判断一个响应是不是错误不要只看字节数先看功能码最高位。常用异常码如下异常码含义常见场景0x01非法功能码从站不支持该功能码0x02非法数据地址寄存器地址超出从站范围0x03非法数据值寄存器数量超限或写入值非法0x04从站设备故障从站内部读写设备失败服务端还有一件容易被忽略的事如果同一个设备支持多个 TCP 客户端同时连接寄存器表要做线程安全处理。常见的做法是给RegisterTable加ReaderWriterLockSlim读操作走读锁写操作走写锁避免一个客户端正在写寄存器时另一个客户端读到一半的数据。3.3 寄存器映射表与字节序转换Modbus 的数据模型分四类线圈、离散输入、输入寄存器、保持寄存器。实际与设备打交道最多的是保持寄存器可读可写。寄存器地址映射大致如下类型物理地址范围PDU 起始地址线圈00001-099990x0000离散输入10001-199990x0000输入寄存器30001-399990x0000保持寄存器40001-499990x0000注意物理地址 40001 在 PDU 里对应地址 040002 对应地址 1。很多设备文档说“保持寄存器起始地址是 40001”你转换成 Modbus 协议地址时要去掉这个 4 的偏移。这份源码里的RegisterTable直接用 0 起始数组存储public sealed class RegisterTable { private readonly ushort[] _registers; private readonly ReaderWriterLockSlim _lock new(); public RegisterTable(int size) { _registers new ushort[size]; } public bool TryRead(ushort address, out ushort value) { _lock.EnterReadLock(); try { if (address _registers.Length) { value 0; return false; } value _registers[address]; return true; } finally { _lock.ExitReadLock(); } } public bool TryWrite(ushort address, ushort value) { _lock.EnterWriteLock(); try { if (address _registers.Length) return false; _registers[address] value; return true; } finally { _lock.ExitWriteLock(); } } }这种设计让你改造成真实设备时非常方便把TryRead和TryWrite内部从数组改成调用 PLC 或传感器的驱动接口即可上层协议逻辑完全不用动。字节序转换是另一个常被忽略的细节。Modbus 规定寄存器是大端序即高字节在前低字节在后。但 .NET 里的BitConverter在 x86 平台是小端序直接拿来用会出问题。源码里的ByteOrderConverter显式处理public static ushort ToUInt16(byte high, byte low) { return (ushort)((high 8) | low); } public static byte[] GetBytes(ushort value) { return new[] { (byte)(value 8), (byte)(value 0xFF) }; }如果设备用两个寄存器表示一个 32 位整数还需要约定字的顺序。常见的是“大端在前”也就是第一个寄存器是高 16 位第二个是低 16 位但有些国产设备习惯反过来。遇到这种情况不要改GetBytes而是在上层加一个“低字在前”的开关按设备文档配置。4. Modbus TCP 排查避坑手册超时、半包、大小端翻车与网关问题这个章节写的都是现场调试时真正会遇到的糟心事。每一条我都按“现象 → 原因 → 解决”的顺序记录方便你直接对照自己遇到的问题。4.1 现象TCP 连接成功请求发出去了但永远等不到响应现象很好判断ConnectAsync不报错WriteAsync也成功但读超时。抓包一看请求帧确实到了对端对端却没有回任何东西。原因通常有三个。第一个是单元 ID 不匹配从站配置的地址是 3但客户端请求里unitId填的是 1从站直接把请求丢弃。第二个是并发事务 ID 冲突多个请求同时发出时共用同一个transactionId响应回来以后客户端拿错的 ID 匹配看起来就像请求没被处理。第三个是从站用的不是标准 502 端口而是自定义端口中间被防火墙静默丢弃。解决方法是先把客户端unitId改成从站面板上的实际地址再用 Wireshark 过滤tcp.port 502确认请求帧里的事务 ID 是否重复。如果重复就要回到_nextId的分配逻辑给每个请求分配唯一 ID。大多数人遇到“连得上但无响应”八成是单元 ID 没对齐。4.2 现象能读到数据但寄存器值和设备面板对不上比如设备面板显示温度 25.6℃你读出来是 6400。这种翻车属于大小端转换问题而且最容易出现在读多寄存器时。原因一PDU 里寄存器值是大端序但你用BitConverter.ToUInt16(response, index)直接转x86 平台的小端序会把字节反过来。原因二设备用两个寄存器表示一个 32 位浮点数而两个寄存器的先后顺序和你的组合逻辑不一致。原因三地址映射错位设备文档说 40001 对应温度你却在解析时把地址 0 当成实际偏移多读或少读了一个寄存器。解决方法是做一次最小化自测。先用 0x06 往一个已知地址写一个值比如0x1234再用 0x03 读回来。如果写 0x12 34 读回 0x34 12说明字节序反了。32 位数据则先单独读两个寄存器打印原始值再按文档规定的顺序组合。我一般会在项目里保留一个“字节序自检”的控制台命令联调现场先用它验证字节是否顺了再谈业务逻辑。4.3 现象高频轮询时报“返回长度不对”或偶发异常现象是程序刚启动时一切正常跑了半小时之后开始随机抛IOException报错信息要么是“连接已关闭”要么是自己代码里抛的“读取长度不足”。原因基本可以锁定在 TCP 半包和粘包处理不完整。如果你在解析响应时直接Read一次就当作完整帧那么只要内核把一个 Modbus 帧拆成两个 TCP 段第一次Read只拿到半个帧长度字段还没凑齐解析直接就错位。另一个常见原因是把两个请求共用一个接收流接收线程刚读到一半就被另一个任务的等待逻辑拿走缓冲就乱了。解决方法是使用我在 2.3 节里写的ReadExactly按长度循环读取并且把接收逻辑收敛到一个独立线程或异步循环里所有响应都先进同一个解析器解析完成后再按事务 ID 分发。如果现场轮询频率很高还要给请求加超时重试超时时间大于从站响应时间即可。我从那以后给所有 Modbus TCP 客户端都强制套上这套逻辑再也没有出现过系统运行一天后偶发断连的问题。4.4 现象服务端在本地能连部署到网关后面就完全不通现象是虚拟机里跑VirtualSlave一切正常一旦把从站服务部署到边缘网关容器里外网设备连 502 端口就失败。原因有三类。第一是服务端监听地址错了监听的是127.0.0.1外部设备自然进不来。第二是网关的防火墙没有放行回传连接很多防火墙默认只允许内网主动外连不认外部回来的 TCP 数据包。第三是容器或网关 NAT 映射没做502 端口没有映射到宿主机。解决时先用netstat -ano | findstr :502或 Linux 的ss -lntp | grep 502确认监听地址是0.0.0.0。然后检查网关的端口映射把外部 502 映射到容器 502。如果是隔着负载均衡访问还要注意 Modbus TCP 是长连接负载均衡的空闲超时如果设置得太短连接会被静默回收客户端那边看起来就是超时。常见做法是在服务端开启 TCP KeepAlive或者在客户端加一个周期心跳功能码比如每隔 30 秒读一次保持寄存器维持连接活性。5. 二次开发实战把示例从站改造成带重连能力的现场网关拿这份源码做二次开发最值得动手的两个点是把寄存器表换成真实设备驱动以及给客户端加上断线重连外壳。先抽象一个设备后端接口把RegisterTable的读写替换成这个接口public interface IDeviceBackend { bool ReadHolding(ushort address, out ushort value); bool WriteHolding(ushort address, ushort value); Task FlushAsync(CancellationToken ct); }改造时只需要让Dispatch里的读保持寄存器处理器调用backend.ReadHolding写寄存器处理器调用backend.WriteHolding。你的 PLC、传感器、模拟量采集卡都对应一个IDeviceBackend实现协议层完全不用动。断线重连的通用壳子写成一个循环放在客户端启动代码里while (!ct.IsCancellationRequested) { try { await _client.ConnectAsync(ip, port, ct); await _client.RunAsync(ct); } catch (IOException ex) { _log.Warn(连接断开3 秒后重试{0}, ex.Message); } await Task.Delay(TimeSpan.FromSeconds(3), ct); }重连之后记得重置事务 ID 和内部接收缓冲否则旧的响应帧可能被新会话的错误解析。最后给一份验证清单每次改完都跑一遍再放现场验证项预期验证方式字节序自检写 0x1234 读回 0x1234用 0x06 写地址 0再 0x03 读回并发请求20 个并发读无异常客户端开多个 Task 同时读不同地址半包模拟高频轮询 5 万次无报错轮询周期 50ms连续跑 10 分钟断线重连从站重启后自动恢复杀掉从站进程观察客户端 3 秒后重连从那以后我每次改完别人家的 Modbus 报文代码都强制先跑一遍半包模拟和字节序自检确认协议层稳定了才开始写业务。这个习惯帮我避掉了不少现场连不上、数据错误的翻车事故希望也能帮到你。本文还有配套的精品资源点击获取