
简介面向需要为PLC、变频器等工业自动化设备编写上位机程序的.NET开发者这套基于TCP的Modbus通信协议C#源码提供了完整的通信实现参考。工程包含ModbusTCP核心类、Windows窗体测试程序和三个Visual Studio解决方案完整演示了TcpClient连接管理、请求报文构造、NetworkStream收发数据、功能码与异常码解析、超时重试机制及async/await异步非阻塞通信等关键环节。资源共46个文件涵盖cs源程序、sln解决方案、xml接口注释、dll依赖库与pdb调试符号等类型压缩包仅148KB结构紧凑便于直接阅读、调试和移植。已有1073人学习浏览。通过研读这套源码既能系统掌握Modbus TCP协议在C#中的落地方式也能积累网络通信程序设计与异常处理的工程经验。1. 基于TCP的Modbus C#源码一个不用PLC也能调试的工业通信库前两个月接了个设备数据采集的活儿现场PLC只开Modbus TCP服务手里拿到的上位机代码要么串口版要么耦合了太多业务没法拆。后来干脆把一套基于TCP的Modbus C#源码整理出来从MBAP报文头拼装到03、06功能码解析全部自己写再配一个模拟器联调整个链路就通了。这个项目适合三类人做上位机集成要给PLC或DTU做数据对接的写网关服务想把Modbus TCP封装成内部接口的以及刚接触工业协议想搞清楚报文怎么收发的新手。它的核心价值不在代码量而是把Modbus TCP里最容易出错的字节序、事务ID、响应分包问题都摊开写清楚了改起来不用猜。2. 技术选型与整体架构从TCP Socket到功能码的完整分层2.1 为什么选C#和TCP工业上位机里的常见组合Modbus协议有两类承载方式RTU走串口TCP走以太网。现场设备现在越来越多直接带网口Modbus TCP在报文结构上比RTU少了CRC校验多了一个MBAP头反而更好拼。C#做这个事很顺手TcpClient和NetworkStream直接操作字节流配合async/await处理工业场景几十个设备并发连接也不费劲。如果以后要迁移到Linux把System.Net.Sockets这套换成Socket类代码改动量也很小。这套源码在设计的时候只有三个原则请求报文只负责拼字节响应报文只负责解析字节TCP收发不掺业务逻辑。三个类各干各的测试的时候只测报文组装和解析连接部分用真实TcpClient做集成测试。2.2 目录结构与项目分层拿到的源码结构是这样ModbusTcpSolution/ ├── ModbusTcp.Core/ │ ├── ModbusTcpClient.cs │ ├── MbapHeader.cs │ ├── ModbusPdu.cs │ ├── ModbusException.cs │ ├── ModbusExceptionCodes.cs │ └── IModbusTransport.cs ├── ModbusTcp.Demo/ │ ├── Program.cs │ └── appsettings.json └── ModbusTcp.Tests/ ├── MbapHeaderTests.cs └── ModbusPduTests.cs各文件职责我整理了一张表文件职责ModbusTcpClient.cs连接管理、发送请求、接收响应、超时控制MbapHeader.csMBAP事务ID、协议ID、长度、单元ID的拼装与解析ModbusPdu.cs各功能码PDU的构建与解析ModbusException.cs携带异常码的专用异常类型ModbusExceptionCodes.cs异常码到中文消息的映射表IModbusTransport.cs传输层抽象方便替换成Mock测试从分层上看Core项目不依赖任何界面库拿给控制台、WinForm、WebAPI引用都不冲突。Demo项目只是演示调用方式实际集成时通常只引用Core项目。2.3 通信核心TcpClient连接管理与报文收发通信层最关键的代码是收发的那几行我把它单独抽出来看public sealed class ModbusTcpClient : IAsyncDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private ushort _transactionId 0; private byte _unitId 1; private readonly SemaphoreSlim _lock new SemaphoreSlim(1, 1); public async Task ConnectAsync(string ip, int port, int timeoutMs 3000) { _tcpClient new TcpClient(); using var cts new CancellationTokenSource(timeoutMs); try { await _tcpClient.ConnectAsync(ip, port, cts.Token); _stream _tcpClient.GetStream(); _stream.ReadTimeout timeoutMs; } catch (OperationCanceledException) { throw new TimeoutException($连接 {ip}:{port} 超时); } } public async Taskbyte[] SendReceiveAsync(byte[] pdu) { await _lock.WaitAsync(); try { byte[] mbap MbapHeader.Build(_transactionId, _unitId, pdu.Length); byte[] request new byte[mbap.Length pdu.Length]; Buffer.BlockCopy(mbap, 0, request, 0, mbap.Length); Buffer.BlockCopy(pdu, 0, request, mbap.Length, pdu.Length); await _stream.WriteAsync(request); return await ReadResponseAsync(); } finally { _lock.Release(); } } }这里有一个容易被忽略的点多个请求复用同一个TcpClient时如果并发发送响应回来没法区分是哪条请求的。所以我用信号量把收发锁成串行虽然牺牲了一点吞吐但换来报文不会串。_transactionId自增让每条请求的MBAP头都有唯一标记排查问题时能直接对照抓包工具。ReadResponseAsync的实现我做了严格的分包处理因为TCP流不像UDP那样一次一包一个响应可能拆成两次到达也可能两个响应黏在一起private async Taskbyte[] ReadResponseAsync() { // 先读MBAP头7个字节 byte[] header new byte[7]; await ReadExactlyAsync(header, 7); // 从MBAP长度字段得知PDU总长 int pduLength (header[4] 8) | header[5]; byte[] body new byte[pduLength]; await ReadExactlyAsync(body, pduLength); // 拼完整响应MBAP头里的功能码部分 PDU byte[] response new byte[7 pduLength]; Buffer.BlockCopy(header, 0, response, 0, 7); Buffer.BlockCopy(body, 0, response, 7, pduLength); return response; } private async Task ReadExactlyAsync(byte[] buffer, int count) { int offset 0; while (offset count) { int read await _stream.ReadAsync(buffer, offset, count - offset); if (read 0) throw new IOException(连接被关闭); offset read; } }ReadExactlyAsync这个方法值得多说一句。NetworkStream.ReadAsync返回的字节数不一定是你要的长度底层收到多少给多少。要是不循环读到为止碰到分片就直接解析出半截报文后面全是乱的。这也是网上很多人说Modbus TCP不稳定的真实原因——协议本身没毛病是对TCP流式传输没做封装。3. 核心模块拆解一个Modbus请求从构造到响应的完整链路3.1 MBAP报文头怎么拼事务ID、协议ID、长度字段Modbus TCP对比RTU多了7个字节的报文头。前两个字节是事务处理标识符用来匹配请求和响应接着两个字节是协议标识符Modbus规范里固定是0再两个字节是长度表示后续还有多少字节最后一个是单元标识符相当于RTU的从站地址。public static class MbapHeader { public const int HeaderLength 7; public static byte[] Build(ushort transactionId, byte unitId, int pduLength) { // 长度字段 单元标识符1字节 PDU长度 ushort length (ushort)(pduLength 1); byte[] header new byte[HeaderLength]; header[0] (byte)(transactionId 8); // 事务ID高字节 header[1] (byte)(transactionId 0xFF); // 事务ID低字节 header[2] 0x00; // 协议ID高字节 header[3] 0x00; // 协议ID低字节 header[4] (byte)(length 8); // 长度高字节 header[5] (byte)(length 0xFF); // 长度低字节 header[6] unitId; // 单元ID return header; } }这里最容易翻车的点是长度字段。很多初学者把PDU长度直接填进去忘了还要加上单元ID那一个字节。假设PDU只有5个字节长度字段应该是6不是5。发现PLC端无响应或者报错时优先检查这个值。协议ID字段固定为0表示当前报文是Modbus协议。我见过有人在这里填成TCP端口号结果对端直接把报文丢弃。规范就是规范别自作聪明。3.2 功能码实现读保持寄存器03与写单寄存器06底层收发封装完成后接下来就是功能码的拼装。03功能码读保持寄存器是用的最多的参数有起始地址和寄存器数量。响应里会先有一个字节表示后面总共多少字节然后每个寄存器占两个字节高位在前。public static byte[] BuildReadHoldingRegisters(ushort startAddress, ushort quantity) { if (quantity 1 || quantity 125) throw new ArgumentOutOfRangeException(nameof(quantity), 一次最多读取125个寄存器); byte[] pdu new byte[5]; pdu[0] 0x03; // 功能码 pdu[1] (byte)(startAddress 8); // 起始地址高字节 pdu[2] (byte)(startAddress 0xFF); // 起始地址低字节 pdu[3] (byte)(quantity 8); // 数量高字节 pdu[4] (byte)(quantity 0xFF); // 数量低字节 return pdu; } public static ushort[] ParseReadHoldingRegistersResponse(byte[] response) { // response[0]是功能码response[1]如果最高位为1表示异常 if (response[1] 0x80) throw ModbusException.FromResponse(response); int byteCount response[2]; if (byteCount % 2 ! 0) throw new InvalidDataException(寄存器字节数必须是偶数); ushort[] registers new ushort[byteCount / 2]; for (int i 0; i registers.Length; i) { // 每个寄存器两字节大端序合并 registers[i] (ushort)((response[3 i * 2] 8) | response[4 i * 2]); } return registers; }Modbus寄存器的通用限制是单次最多125个这个数字不是随便来的协议规定PDU里数据字节数上限是252252除以2就是126个寄存器再刨掉一个字节的字节计数实际能用的就是125。你要是硬填126有些设备直接回异常码另外一些设备会截断数据返回解析就错位了。写单寄存器走的是06功能码报文固定5个字节功能码、寄存器地址、寄存器值。响应是原样回显请求所以解析时只需要核对地址和值是否一致。public static byte[] BuildWriteSingleRegister(ushort address, ushort value) { byte[] pdu new byte[5]; pdu[0] 0x06; // 功能码 pdu[1] (byte)(address 8); pdu[2] (byte)(address 0xFF); pdu[3] (byte)(value 8); pdu[4] (byte)(value 0xFF); return pdu; } public static void ValidateWriteSingleRegisterResponse(byte[] response, ushort expectedAddress, ushort expectedValue) { if (response[1] 0x80) throw ModbusException.FromResponse(response); ushort echoAddress (ushort)((response[2] 8) | response[3]); ushort echoValue (ushort)((response[4] 8) | response[5]); if (echoAddress ! expectedAddress || echoValue ! expectedValue) throw new InvalidDataException(写寄存器响应回显不匹配); }写操作比读操作多了一步校验响应可能是在网络传输中被篡改的也可能对端虽然回了正常报文但内容不对所以必须回显核对。工业场景里写寄存器往往控制着电机之类的设备宁可多这个判断也不能信数据。3.3 异常码处理从响应里把错误信息捞出来Modbus协议的异常响应有个明显特征功能码会把最高位置1。比如正常读请求功能码是0x03异常响应里就是0x83。异常信息存在紧接着的功能码后一个字节用异常码表就能翻译出具体原因。public sealed class ModbusException : Exception { public byte ExceptionCode { get; } private ModbusException(byte code) : base(BuildMessage(code)) { ExceptionCode code; } public static ModbusException FromResponse(byte[] response) { if (response.Length 3) throw new InvalidDataException(异常响应长度不足); return new ModbusException(response[2]); } private static string BuildMessage(byte code) { return ModbusExceptionCodes.TryGetValue(code, out string msg) ? $[{code}] {msg} : $[{code}] 未知异常码; } } public static class ModbusExceptionCodes { private static readonly Dictionarybyte, string Map new() { [0x01] 非法功能码设备不支持此操作, [0x02] 非法数据地址地址越界或不对齐, [0x03] 非法数据值参数超出允许范围, [0x04] 从站设备故障内部错误, [0x05] 确认中设备正在处理长任务, [0x06] 设备忙稍后再试, [0x0A] 网关路径不可用, [0x0B] 网关目标设备响应超时 }; public static bool TryGetValue(byte code, out string message) { return Map.TryGetValue(code, out message); } }解析响应时先判断功能码最高位再决定走正常解析还是抛异常这个顺序不能反过来。我看到过有人先解析数据再判断异常结果读一个异常响应直接把内存踩越界了。异常响应比正常响应短正常响应的报文长度解析逻辑套上去必然翻车。4. 运行与调试把Demo跑起来连上模拟器4.1 环境说明与依赖跑这个项目的前提环境我列在下面依赖版本要求用途.NET SDK6.0及以上编译与运行整个解决方案Modbus Poll / Slave任意版本模拟Modbus TCP从站设备Wireshark任意版本抓包分析报文格式项目本身不依赖第三方NuGet包就用了System.Net.Sockets和System.Threading.Tasks两个内置库。这样有个好处离线环境下只要把源码拷进去就能编译不用从NuGet拉包。4.2 启动Modbus模拟器与验证读写没有实体PLC也能联调。用Modbus Slave开一个TCP从站监听502端口然后运行Demo程序class Program { static async Task Main() { using var client new ModbusTcpClient(); await client.ConnectAsync(127.0.0.1, 502); // 读取从地址0开始的10个保持寄存器 ushort[] registers await client.ReadHoldingRegistersAsync(0, 10); Console.WriteLine(读取结果: string.Join(, , registers)); // 向地址5写入值1234 await client.WriteSingleRegisterAsync(5, 1234); Console.WriteLine(写入完成); Console.ReadKey(); } }从站那边能直观看到寄存器值的变化如果写操作成功地址5的值会变成1234。这里有个点值得注意读写操作都走同一个SendReceiveAsync所以单测时可以把TcpClient替换成一个预置响应的假传输层专门测报文拼装逻辑不用每次连真设备。4.3 用Wireshark抓包验证报文格式联调阶段最有效的工具是Wireshark。抓到Modbus TCP报文后展开看TCP payloadTransaction ID: 0x0001 Protocol ID: 0x0000 Length: 0x0006 Unit ID: 0x01 Function Code: 0x03 Start Address: 0x0000 Quantity: 0x000A对照源码看基本能确认哪一层的字节拼错了。我之前遇到过一次PLC返回乱码抓包一看发现协议ID填了502整个报文直接被设备丢弃。这种东西靠眼睛看代码很难发现但抓包后一眼就漏出马脚。调Modbus TCP有没有抓包工具调试效率差一个量级。5. 常见问题与避坑记录连接断、报文错、延迟高的排查5.1 现象连接偶尔超时重试后又正常原因PLC端的Modbus TCP服务一般只接受有限数量的并发连接。调试过程中代码异常退出没有释放连接旧的连接还挂在那里新连接挤不进去。解决每次ConnectAsync前先主动Dispose旧的TcpClient程序退出时务必走到DisposeAsync。另外给连接加一个保活机制超过30秒没有数据交互就主动断开重连防止PLC会话表被无效连接占满。5.2 现象读到的寄存器值全部是0但设备面板上明明有数值原因寄存器起始地址对不上。PLC组态软件里显示的地址通常是4xxxx格式比如40001对应协议地址0。如果你直接把40001填到请求里实际访问的地址是40001早就超出寄存器范围了。解决把PLC显示的地址减去40001再填入请求。地址0对应40001、地址5对应40006这个换算关系要牢记。我踩过一回坑现场设备半天没读到数据最后翻PLC手册才发现协议地址和面板地址差了40001。5.3 现象多个请求并发收发响应和请求对不上原因同一时刻发了两条请求事务ID没有做唯一性控制或者信号量没锁住收发全流程。响应回来后不知道对应的逻辑是哪个功能码发出的。解决信号量的WaitAsync要放在事务ID自增之前整个读写流程都锁住。请求发出前记录请求类型和事务ID的映射关系响应回来后先校验事务ID再校验功能码两者都对上才往下走。这也是我在2.3节里用SemaphoreSlim锁全流程的原因。5.4 现象写寄存器操作偶发失败返回异常码0x06设备忙原因PLC的程序扫描周期内可能刚好在读写同一块寄存器区对端暂时锁住了对寄存器的访问权限。写入频率太高也容易触发。解决写操作加一个指数退避重试策略。第一次失败等100ms再试第二次等200ms最多三次。不要死循环重试会加重PLC负担。如果设备支持功能码0x05写单个线圈和0x0F写多个线圈能走线圈就走线圈有些设备线圈区的并发冲突比寄存器区少。5.5 现象程序跑一晚上后内存越涨越大原因SendReceiveAsync返回的响应没被回收不对——真正的问题往往是日志里把整个响应数组打成字符串日志框架还缓存了引用数组就一直堆在内存里。解决日志只记事务ID、功能码、响应长度不要把报文内容完整打印。除非抓包时才临时放开详细日志平时保持最小输出。我之前调试时把响应全打到日志文件一晚上日志文件涨到几个GB后面就没再干这种傻事。6. 二次开发改造把固定功能码请求改成通用通信网关这个源码拿来做二次开发我一般建议从三个方向入手扩展功能码、抽象设备通道、改造成异步流式读写。如果要做成通用通信网关核心改造点是引入一个IModbusFunction接口把每种功能码的构建和解析封装成一个策略类public interface IModbusFunction { byte FunctionCode { get; } byte[] BuildRequest(ModbusRequestContext context); object ParseResponse(byte[] response); } public class ReadHoldingRegistersFunction : IModbusFunction { public byte FunctionCode 0x03; // BuildRequest和ParseResponse实现略 }新增功能码时不用动ModbusTcpClient的收发逻辑只要注册一个新的策略类就行。配合一个简单工厂按功能码查字典取实例新增协议支持就从改逻辑变成加映射。改造完成后整个调用方式就成了这样var request context.Build(03, startAddress: 0, quantity: 10); object result await gateway.ExecuteAsync(request);从设备侧看网关和服务器的关系是透明的从上层系统看拿到的是一个统一的请求响应模型Modbus细节全部隔离在内部。把这套网关封装成REST接口后前端浏览器都能直接读PLC数据整个上位机的架构就活泛了。我后来自己又在这个基础上加了连接池和多从站路由网关同时管理多个PLC连接按设备号分发请求。调试的时候突然想起来报文的锁粒度还得再细分不然一个慢设备把整个网关的吞吐拖死。从那以后我每加一个功能码都强制自己先跑一遍异常码注入测试——直接构造异常响应喂给解析函数确保抛出来的异常带完整上下文而不是只有一个干巴巴的未知异常。这个习惯帮我挡掉了好多次现场故障排查的尴尬。希望这份源码和拆解思路能让你在对接Modbus TCP设备的时候少走几步弯路。本文还有配套的精品资源点击获取