C#实现Modbus TCP主从站通信:源码解析与实战避坑指南
简介这份资源面向工业自动化与上位机开发方向的 C# 程序员提供基于 TCP 的 Modbus 通信协议完整实现源码可用于理解请求/响应报文构造、寄存器与线圈数据读写、连接管理与异常重试等核心环节适合具备一定网络编程基础、希望将 Modbus TCP 落地到实际项目的中级开发者参考。压缩包共 46 个文件、约 148KB以 cs 源码、csproj 与 sln 工程文件、xml 文档及 dll、pdb 编译产物为主另含少量 exe、inf 与资源文件工程结构完整可直接用 Visual Studio 打开调试。目前已有 1075 人学习下载。源码围绕 Modbus TCP 通信类展开涵盖 TcpClient 连接建立、NetworkStream 收发、功能码与字节数组封装、响应解析及异步调用等要点读者可据此掌握协议细节并借鉴其错误处理与重试思路快速搭建自己的采集程序。1. 从一台永远在线的设备说起这份 C# Modbus TCP 源码能解决什么车间里有一台老设备串口时代留下的协议现在要接进新系统。你打开调试工具发现它走的是 Modbus TCP端口 502报文结构简单到让人意外但真动手写客户端时字节序、事务标识、异常码、粘包处理一个接一个冒出来。这份基于 TCP 的 Modbus C# 源码解决的就是这类场景用纯 C# 实现 Modbus TCP 的主站客户端与从站服务端通信不依赖第三方协议库把请求封装、报文拼装、响应解析、异常处理这条链路完整摊开给你看。适合两类人一是需要把 PLC、仪表、采集模块接进 .NET 系统的后端或上位机开发者二是想搞懂 Modbus TCP 报文到底长什么样、不想只会调库的工程师。源码结构不复杂但该有的都有拿来就能跑改起来也不费劲。2. 协议与工程结构Modbus TCP 在 C# 里怎么落地2.1 先看清 Modbus TCP 的报文骨架Modbus TCP 和串口上的 Modbus RTU 最大的差别是前面多了 7 个字节的 MBAP 报文头。这 7 个字节决定了整个通信能不能对上号很多人第一次写就栽在这里。结构是这样字段长度说明事务标识符2 字节客户端自增服务端原样返回用来匹配请求与响应协议标识符2 字节Modbus 固定为 0x0000长度2 字节后续字节数含单元标识符单元标识符1 字节从站地址TCP 下常用于区分网关后的设备功能码1 字节读线圈 0x01、读保持寄存器 0x03、写单寄存器 0x06 等数据域变长起始地址、数量或写入值事务标识符是这套协议里最容易被忽视的字段。单线程同步调用时它无所谓一旦并发或者异步没有它你根本分不清哪个响应属于哪个请求。源码里把它做成一个自增计数器每次发请求前取一个值收到响应后比对对不上就丢弃。这个设计看着朴素但它是后面做并发的基础。2.2 工程目录与模块职责源码按职责拆成几个部分目录大致如下不同版本命名可能略有差异但分层逻辑一致ModbusTcp/ ├── Core/ │ ├── ModbusTcpMaster.cs # 主站连接、发送、接收、解析 │ ├── ModbusTcpSlave.cs # 从站监听、处理请求、返回响应 │ ├── ModbusRequest.cs # 请求对象封装 │ └── ModbusResponse.cs # 响应对象封装 ├── Protocol/ │ ├── MbapHeader.cs # MBAP 报文头拼装与解析 │ ├── FunctionCode.cs # 功能码枚举 │ └── ModbusException.cs # 异常码定义 ├── Data/ │ └── DataStore.cs # 从站寄存器/线圈数据存储 └── Demo/ └── Program.cs # 控制台示例入口主站负责主动发起连接和请求从站负责监听端口、解析请求、操作数据区并回包。协议层单独抽出来是为了让报文拼装和解析不跟网络 IO 搅在一起测试的时候可以脱离 socket 单独验证字节序列对不对。数据层用一个简单的数组或字典模拟寄存器和线圈从站读写都落在这里。这个分层不算精致但对一个协议实现来说够用也方便你按需替换。2.3 主站连接与请求发送的核心代码主站的核心是「连上、发出去、收回来、解析」四步。下面这段是发送读保持寄存器请求的典型写法// 建立 TCP 连接默认 Modbus 端口 502 var client new TcpClient(); client.Connect(192.168.1.10, 502); var stream client.GetStream(); // 构造读保持寄存器请求从站地址 1起始地址 0读 10 个寄存器 byte unitId 1; ushort startAddress 0; ushort quantity 10; ushort transactionId 1; // 每次请求自增用于匹配响应 // 拼装 MBAP 头 PDU var request new byte[12]; request[0] (byte)(transactionId 8); // 事务标识高字节 request[1] (byte)(transactionId 0xFF); // 事务标识低字节 request[2] 0x00; // 协议标识固定 0 request[3] 0x00; request[4] 0x00; // 长度高字节 request[5] 0x06; // 长度低字节单元标识1 功能码1 地址2 数量2 request[6] unitId; // 单元标识符 request[7] 0x03; // 功能码读保持寄存器 request[8] (byte)(startAddress 8); // 起始地址高字节 request[9] (byte)(startAddress 0xFF); request[10] (byte)(quantity 8); // 寄存器数量高字节 request[11] (byte)(quantity 0xFF); stream.Write(request, 0, request.Length); // 读取响应先读 7 字节 MBAP 头再按长度字段读剩余部分 var header new byte[7]; ReadExactly(stream, header, 7); int remaining (header[4] 8) | header[5]; // 长度字段 var body new byte[remaining - 1]; // 减去单元标识符 ReadExactly(stream, body, body.Length);这段代码有几个点值得说清楚。第一长度字段写的是 0x0006它统计的是单元标识符之后的字节数也就是功能码 1 字节加地址 2 字节加数量 2 字节再加单元标识符本身 1 字节共 6 字节。算错这个值服务端会直接不认。第二读响应必须分两段先读固定 7 字节的 MBAP 头从里面拿到长度再读剩余部分。一次性读一个大 buffer 然后猜边界是新手最常见的翻车点TCP 是流式的不保证一次 recv 就是一个完整报文。第三ReadExactly是个辅助方法循环读直到读满指定字节数源码里一般会封装成扩展方法或私有函数。2.4 响应解析与异常码处理响应回来之后先校验事务标识符和协议标识符再判断功能码。如果功能码最高位是 1即功能码 0x80 不为 0说明从站返回了异常后面跟一个异常码字节。常见异常码有0x01 非法功能、0x02 非法数据地址、0x03 非法数据值、0x04 从站设备故障。解析逻辑大致如下// 校验事务标识不匹配说明是过期响应直接丢弃 ushort respTransactionId (ushort)((header[0] 8) | header[1]); if (respTransactionId ! transactionId) throw new InvalidOperationException(事务标识不匹配响应可能已过期); byte functionCode body[0]; if ((functionCode 0x80) ! 0) { byte exceptionCode body[1]; throw new ModbusException(exceptionCode); // 自定义异常携带异常码 } // 正常响应功能码 0x03 返回字节数 寄存器值 int byteCount body[1]; var values new ushort[byteCount / 2]; for (int i 0; i values.Length; i) { // 寄存器是大端序高字节在前 values[i] (ushort)((body[2 i * 2] 8) | body[3 i * 2]); }寄存器值是大端序这一点在跨平台对接时尤其要注意x86 机器上直接按内存布局读会得到反过来的值。源码里统一用移位拼装不依赖BitConverter的机器字节序这是稳妥做法。异常码单独封装成ModbusException调用方可以按码做不同处理比如地址越界就调整读取范围设备故障就重试或告警。3. 从站实现与数据区让服务端真正响应起来3.1 监听循环与请求分发从站的职责是监听端口、接受连接、循环读请求、按功能码分发处理、拼响应回写。核心循环长这样var listener new TcpListener(IPAddress.Any, 502); listener.Start(); while (true) { var client listener.AcceptTcpClient(); // 每个连接单独开线程或 Task 处理避免阻塞 Accept Task.Run(() HandleClient(client)); } void HandleClient(TcpClient client) { var stream client.GetStream(); var header new byte[7]; while (true) { if (!TryReadExactly(stream, header, 7)) break; // 连接断开 int length (header[4] 8) | header[5]; var pdu new byte[length - 1]; if (!TryReadExactly(stream, pdu, pdu.Length)) break; byte unitId header[6]; byte functionCode pdu[0]; byte[] responsePdu Dispatch(functionCode, pdu); // 按功能码处理 // 回包时事务标识和协议标识原样带回 var response BuildMbap(header[0], header[1], unitId, responsePdu); stream.Write(response, 0, response.Length); } }这里的关键是每个连接独立处理AcceptTcpClient不能被单个连接的读写卡住。Dispatch按功能码走 switch读线圈、读离散输入、读保持寄存器、读输入寄存器、写单线圈、写单寄存器、写多寄存器各走各的分支。回包时事务标识必须原样带回这是主站匹配响应的唯一依据丢了它主站就没法工作。3.2 数据区设计与读写边界从站的数据区一般用几个数组模拟线圈bool 数组、离散输入只读 bool 数组、保持寄存器ushort 数组、输入寄存器只读 ushort 数组。读写时最要紧的是边界检查起始地址加数量不能越界否则要么抛异常要么读到脏数据。源码里的处理方式是先校验再操作// 读保持寄存器校验地址范围 if (startAddress quantity holdingRegisters.Length) { // 返回异常码 0x02非法数据地址 return new byte[] { 0x83, 0x02 }; } var result new byte[1 quantity * 2]; result[0] (byte)(quantity * 2); // 字节数 for (int i 0; i quantity; i) { result[1 i * 2] (byte)(holdingRegisters[startAddress i] 8); result[2 i * 2] (byte)(holdingRegisters[startAddress i] 0xFF); } return result;异常响应的功能码是原功能码加 0x80后面跟异常码。这个规则必须严格遵守主站靠最高位判断是不是异常。写操作同理写单寄存器要校验地址写多寄存器还要校验数量字段和实际数据长度是否一致不一致就返回 0x03 非法数据值。这些校验看着琐碎但少了任何一条对接真实设备时都会出问题。3.3 用控制台 Demo 跑通一次完整通信源码带的 Demo 一般是先起从站、再用主站去读。跑通步骤不复杂确认本机 502 端口没被占用Windows 上可以用netstat -ano | findstr 502查一下。先运行从站程序控制台会打印监听地址和端口。再运行主站程序填入从站 IP本机就是 127.0.0.1和要读的寄存器范围。观察主站打印的寄存器值和从站初始化时写入的测试数据比对。如果主站连不上先看防火墙如果连上但读不到数据看事务标识和长度字段如果返回异常码看地址范围。这个 Demo 的价值在于把两端都放在你手里出问题可以两边加日志对照比对着一个黑盒设备猜要快得多。4. 避坑与排查Modbus TCP 对接现场的血泪经验4.1 现象请求发出去了响应永远等不到原因通常是长度字段算错或者端口、单元标识符不对。长度字段统计的是单元标识符之后的字节数很多人把 MBAP 头也算进去服务端解析时直接丢弃。解决方法是拿抓包工具看一眼发出的字节对照协议规范逐字段核对尤其是第 5、6 字节。另外确认单元标识符和服务端配置一致有些网关要求特定值。4.2 现象偶尔能读到偶尔超时或数据错乱这是典型的粘包/半包问题。TCP 不保证一次读到一个完整报文如果按固定长度读或者假设一次 recv 就是一条消息并发或网络抖动时必然出错。解决办法是严格按「先读 7 字节头、再按长度读剩余」的两段式读取并且用循环读满。源码里的ReadExactly就是干这个的自己写的时候别偷懒用单次Read。4.3 现象寄存器值读出来是反的字节序问题。Modbus 寄存器是大端序而 x86 是小端。直接用BitConverter.ToUInt16读原始字节会得到高低字节颠倒的值。解决方法是手动移位拼装或者读出来后做一次字节交换。注意 32 位数据跨两个寄存器时还有字序问题高低字谁在前不同厂商不一样对接前要查设备手册。4.4 现象并发请求时响应串了没有用事务标识符做匹配或者用了但没校验。多个请求同时在途时响应回来的顺序不保证和请求一致必须靠事务标识符对应。解决办法是每个请求分配唯一标识收到响应先比对再解析对不上就丢弃或记录。如果做连接池还要保证同一连接上的请求响应严格配对。4.5 现象从站返回异常码 0x02 或 0x03地址越界或数据值非法。读写的起始地址加数量超出了数据区范围或者写入的值、数量字段与实际数据长度不匹配。解决办法是在从站侧加边界校验并返回正确异常码在主站侧捕获异常码后打印出具体地址和数量方便定位。别把异常吞掉异常码是排查的第一手信息。5. 二次开发把这份源码改成你自己的采集服务拿到源码之后最实际的改造方向是把它从一个 Demo 变成一个能长期跑的服务。我一般会做三件事。第一把主站的同步调用改成异步TcpClient的GetStream配合ReadAsync/WriteAsync避免阻塞线程尤其是在采集几十台设备的时候。第二加一层设备配置把 IP、端口、单元标识符、寄存器映射表放到配置文件里而不是硬编码在代码中这样加设备不用改代码重新编译。第三加超时和重试Modbus 设备响应慢是常态设置合理的读超时比如 2 到 3 秒超时后重试一到两次仍失败就标记设备离线并告警。下面是一个异步读取的改造示例把前面同步的发送逻辑包成可复用的方法public async Taskushort[] ReadHoldingRegistersAsync( string ip, int port, byte unitId, ushort startAddress, ushort quantity, int timeoutMs 3000) { using var client new TcpClient(); // 连接超时控制 var connectTask client.ConnectAsync(ip, port); if (await Task.WhenAny(connectTask, Task.Delay(timeoutMs)) ! connectTask) throw new TimeoutException($连接 {ip}:{port} 超时); var stream client.GetStream(); stream.ReadTimeout timeoutMs; ushort transactionId NextTransactionId(); // 线程安全的自增 var request BuildReadRequest(transactionId, unitId, startAddress, quantity); await stream.WriteAsync(request); var header new byte[7]; await ReadExactlyAsync(stream, header, 7); // 后续校验与解析同前略 return ParseRegisters(header, await ReadBodyAsync(stream, header)); }改造时要注意TcpClient每次请求新建连接开销不小如果采集频率高应该复用连接或者做连接池但复用就要处理好事务标识匹配和断线重连。断线重连的逻辑我踩过坑连接断了之后NetworkStream不会自动恢复必须捕获异常、销毁旧连接、重建并且把在途请求标记为失败不能让调用方一直等。验证改造是否成功我的习惯是起一个从站模拟器人为制造延迟、断连、返回异常码看主站能不能正确超时、重试和恢复。从那以后我每次改通信层都强制走一遍「正常、超时、断连、异常码」四种场景少一种都不敢上线。希望帮到你。本文还有配套的精品资源点击获取