简介:这是一份面向工业自动化与工控软件开发者的C# Modbus TCP通信协议实现源码,适合需要在.NET平台下快速集成PLC、传感器等设备通信功能的中高级开发人员。资源围绕Modbus_TCP_class_demo核心类展开,完整演示了从TCP连接建立、请求报文构造、NetworkStream数据传输到响应报文解析的全过程,并涵盖超时重试、异常码检查、async/await异步非阻塞通信等工程化细节,可直接迁移到实际项目中。
压缩包共46个文件、约148KB,既包含ModbusTCP.cs、modbus.cs等核心源码及sln/csproj工程配置,也附带了编译生成的exe可执行程序、dll类库、pdb调试符号,以及CHM帮助文档和ndoc生成的API说明,便于直接运行验证和对照学习。项目目录中ModbusSampleCommon、ModbusTester等模块划分清晰,配套XML注释文档可辅助阅读源码。目前已有1072人学习下载,对工控协议入门与.NET通信开发均有直接帮助。
1. 基于 TCP 的 Modbus 通信:一份能直接落地的 C# 源码拆解
做过上位机开发的工程师基本都遇到过这种场景:现场有一台支持 Modbus TCP 的 PLC 或仪表,上位机需要用 C# 把设备数据读上来,再下发给执行机构。协议本身不复杂,但真正开始写的时候,报文拼装、字节序、超时处理、单位 ID 这些细节哪个都能让你调试到怀疑人生。这份基于 TCP 的 Modbus C# 源码,把客户端、服务端、报文封装、轮询机制都整理成了可以直接拿去用的模块,适合正在做数据采集、设备对接、上位机开发的 .NET 工程师。我按自己平时拆项目的习惯,把协议结构、核心代码、调试方法和踩过的坑完整过一遍。
2. Modbus TCP 协议靠什么跑通:MBAP 头与功能码是全部基础
2.1 MBAP 头让 Modbus TCP 走稳:七个字段拆开看
Modbus TCP 相比传统的 Modbus RTU,最大的区别是去掉了 CRC 校验,换上了 MBAP 报文头。RTU 里依赖字节间的时序来定界,但在 TCP 里不需要担心粘包半包之外的帧定界问题,因为 MBAP 头里的长度字段已经把帧界画清楚了。MBAP 头一共 7 个字节,依次是事务处理标识符 2 字节、协议标识符 2 字节、长度 2 字节、单元标识符 1 字节。
事务处理标识符是用来匹配请求和响应的,自己维护一个计数器,每发一帧请求就加一,收到响应时用这个字段来判断是不是自己等的那条。协议标识符在 Modbus TCP 里固定是 0x0000,表示这是 Modbus 协议。长度字段是本帧中从单元标识符开始到报文末尾的总字节数,很多新手在这里算错,导致解析位置偏移。单元标识符的功能是寻址链路上的具体设备,有的设备固定填 1 或 0xFF,有的需要与设备侧设置保持一致。
这套源码里做的事情,本质上就是把业务层的读写操作翻译成符合这个结构的字节流,再通过 TcpClient 发出去。不存在任何黑匣子操作,整个报文构造和解析都在代码里直接可见,这也是我看中这份源码的原因。
2.2 功能码决定你在读还是写:03 和 16 最常用
Modbus TCP 的功能码和 RTU 完全一致,常用的是 03 读保持寄存器、04 读输入寄存器、06 写单个寄存器、16 写多个寄存器。在设备数据采集场景里,用 03 和 16 的频率最高。功能码是报文的第二个字节,跟在单元标识符后面,在不同功能码下,数据区的格式完全不同。
以 03 为例,请求帧的数据区包含起始地址 2 字节和寄存器数量 2 字节,响应帧的数据区包含字节数 1 字节和寄存器数据 N 字节。16 功能码的请求帧更复杂一些,包含起始地址 2 字节、寄存器数量 2 字节、字节数 1 字节,后面才是实际的寄存器数据。源码里把这些结构封装成了独立的方法,调用方只需要传入起始地址、长度和数值数组,不需要关心底层每一个字节怎么排列。
这里需要特别注意寄存器地址的单位。很多设备手册里写的地址是 40001、40002 这种带偏移的表示法,但在报文里传输的实际地址要减 1,也就是对应 0x0000、0x0001。如果你拿手册上的 40001 直接填进报文,设备一般会回一个非法地址的异常码,这是刚接触 Modbus 的人最常出现的翻车现场之一。
2.3 字节序和单位 ID 是绕不过去的两个基础坑
Modbus TCP 的字节序统一是大端模式,高字节在前,低字节在后。C# 里用 BitConverter 取出来的多字节数值在部分平台上默认是小端,如果不做转换,解析出来的寄存器数值对不上是必然结果。源码里对读写数据都做了大端序处理,实测过直接把 BitConverter 结果丢给设备的行为,拿到的数据完全无法解释。
单位 ID 在 TCP 模式下经常被忽略,但有些设备就是认这个字段。一台 TCP 服务器后面带多个从站时,单位 ID 就相当于内部总线的选址信号。源码把单位 ID 作为可配置参数暴露了出来,默认填了 1,同时提醒需要根据设备手册确认,有些老设备填错了会直接丢弃请求,表现为连接正常但永远收不到响应。
3. C# 实现 Modbus TCP 客户端:从连接封装到读写方法
3.1 封装一个 Modbus TcpClient:连接与超时控制
C# 的 TcpClient 是底层 Socket 的高层封装,Modbus TCP 报文就在这个连接上发收。源码里把连接逻辑单独抽了一个类,允许调用方配置 IP、端口、超时时间。端口默认 502,这是 Modbus TCP 的标准端口,不过和真实设备对接的时候,很多设备支持自定义端口,也遇到过现场把端口改成 1502 或 8000 的情况。
public class ModbusTcpClient { private TcpClient _tcpClient; private int _transactionId = 0; public string IpAddress { 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 bool Connect() { try { _tcpClient = new TcpClient(); // 连接操作要设置超时,避免设备 IP 不可达时一直卡住 var task = _tcpClient.ConnectAsync(IpAddress, Port); return task.Wait(TimeoutMs) && _tcpClient.Connected; } catch { return false; } } private byte[] BuildMbapHeader(int pduLength) { _transactionId++; // 事务ID占2字节,协议ID固定0x0000,长度字段记录单元ID和PDU的总长 byte[] header = new byte[7]; header[0] = (byte)(_transactionId >> 8); header[1] = (byte)(_transactionId & 0xFF); header[2] = 0x00; header[3] = 0x00; header[4] = (byte)((pduLength + 1) >> 8); header[5] = (byte)((pduLength + 1) & 0xFF); header[6] = UnitId; return header; } }这段代码把连接和报文头构造放到了一起,目的是让调用方不需要关心事务编号怎么维护。Connect 方法里用了 ConnectAsync 加 Wait 的方式,把连接超时强制压到指定毫秒数内。如果不这么做,TcpClient 的默认连接超时可能长达几十秒,在设备掉线的现场会直接拖垮整个采集线程。
BuildMbapHeader 里,长度字段的值是 PDU 长度加一,加上的这个一就是单元标识符本身。很多刚上手的开发者在算长度时容易漏掉这个细节,导致设备侧解析错位。事务 ID 每次构造报文前自增,建议用 0 到 65535 自然循环,不要刻意清零。
3.2 读保持寄存器:发一条 03 功能码的请求
读保持寄存器是实现数据采集的核心操作。源码里对应的方法是 ReadHoldingRegisters,传入起始地址和读取数量,返回一个 ushort 数组。寄存器在 Modbus 里是按 16 位一个单位存储的,所以 C# 侧用 ushort 对应。现场遇到 32 位浮点数或 32 位整型时,需要读多个寄存器再拼装,后面细说。
public ushort[] ReadHoldingRegisters(ushort startAddress, ushort quantity) { if (_tcpClient == null || !_tcpClient.Connected) throw new InvalidOperationException("连接已断开"); // 组装PDU,功能码03固定在第1个字节 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); byte[] request = BuildMbapHeader(pdu.Length).Concat(pdu).ToArray(); // 发送原始字节流 _tcpClient.GetStream().Write(request, 0, request.Length); // 读取响应并解析 byte[] response = ReadResponse(9 + quantity * 2); int byteCount = response[8]; ushort[] values = new ushort[quantity]; for (int i = 0; i < quantity; i++) { // 大端序转换:高位在前,低位在后 values[i] = (ushort)((response[9 + i * 2] << 8) | response[10 + i * 2]); } return values; }这段代码展示的是最直观的请求构造和响应解析逻辑。请求 PDU 只有 5 个字节,功能码 03 后面跟起始地址和数量,都是大端排列。读取响应的行数需要算明白:7 字节 MBAP 头加 1 字节功能码加 1 字节字节数,再加 N 个数据字节,整体读满再解析,避免半包问题。
字节数的计算是规规矩矩按 quantities 乘以 2 来估的,因为 03 响应里每个寄存器固定占 2 字节。如果设备返回的数据比预期短,解析出来就会出现缺位,需要靠响应里的 byteCount 字段再校验一次。
3.3 写寄存器:用 16 功能码一次写多个
写单个寄存器用 06,写连续多个寄存器用 16。源码里提供了一个通用的写多寄存器方法,内部自动处理数据区拼接。实际项目里,下发参数、设定目标值、复位报警这类操作往往需要一次写多个寄存器,16 功能码是更高效的做法。
public bool WriteMultipleRegisters(ushort startAddress, ushort[] values) { int quantity = values.Length; // 功能码16 + 起始地址2字节 + 数量2字节 + 字节数1字节 + 数据 N*2 字节 int pduLength = 6 + quantity * 2; byte[] pdu = new byte[pduLength]; pdu[0] = 0x10; pdu[1] = (byte)(startAddress >> 8); pdu[2] = (byte)(startAddress & 0xFF); pdu[3] = (byte)(quantity >> 8); pdu[4] = (byte)(quantity & 0xFF); pdu[5] = (byte)(quantity * 2); for (int i = 0; i < quantity; i++) { pdu[6 + i * 2] = (byte)(values[i] >> 8); pdu[7 + i * 2] = (byte)(values[i] & 0xFF); } byte[] request = BuildMbapHeader(pdu.Length).Concat(pdu).ToArray(); _tcpClient.GetStream().Write(request, 0, request.Length); // 16功能码的响应会回显起始地址和数量,长度固定为12字节 byte[] response = ReadResponse(12); return response[7] == 0x10 && response[9] == (byte)(startAddress >> 8); }PDU 结构里有一个很微妙的点:pdu[5] 是字节数,它的值是 quantity 乘以 2。这个字段用于告诉设备后面跟了多少个数据字节,和 MBAP 头里的长度字段不是一回事。代码里在拼数据区时依然坚持大端序,如果不转换,写入设备的值会变成交换高低字节后的结果,设备上读到的就是一个不成逻辑的数值。
16 功能码的响应帧长度固定,格式是事务 ID、协议 ID、长度、单元 ID、功能码、起始地址、数量。判断写入成功的方式就是比对回显的起始地址,这种方法简单但可靠,不需要额外去读回寄存器验证。
3.4 解析响应的通用套路:先验长度,再拆数据
Modbus TCP 报文存在粘包和半包两种风险。粘包是多个响应帧连在一起到达,半包是一帧被拆成多个 TCP 段。源码里写了一个 ReadResponse 方法,先读取固定长度的 MBAP 头,从长度字段计算出响应剩余字节数,再循环读取到凑满为止。这样的做法把粘包和半包都处理掉了,避免了解析到一半发现数据不够的情况。
private byte[] ReadResponse(int expectedLength) { // 先读7字节MBAP头,从长度字段推算响应总长 byte[] header = new byte[7]; int offset = 0; while (offset < 7) { int read = _tcpClient.GetStream().Read(header, offset, 7 - offset); if (read == 0) throw new IOException("连接已断开"); offset += read; } int bodyLength = (header[4] << 8) | header[5]; byte[] body = new byte[bodyLength]; offset = 0; while (offset < bodyLength) { int read = _tcpClient.GetStream().Read(body, offset, bodyLength - offset); if (read == 0) throw new IOException("连接已断开"); offset += read; } return header.Concat(body).ToArray(); }这里的关键是先从流里读完 7 个字节的 MBAP 头,拿到长度字段,再按长度字段去读剩余部分。调用方传进来的 expectedLength 参数是已知的功能码对应的最小长度,用于校验,真正的读取长度还是以报文内部字段为准。网络流里的 Read 方法不保证一次调用就能把数据读满,必须循环直到凑齐目标长度,这是用 TCP 做协议解析的基本功。
4. 服务端与调试:自己搭一个虚拟从站来验流程
4.1 自己当从站:一个简版 Modbus TCP Server
调试上位机的时候最痛苦的是没有真实设备,或者设备的寄存器地址和手册对不上。源码里附带了一个简版服务端,用 TcpListener 监听端口,收到请求后按功能码回包。这样可以在没有硬件的情况下先把读写流程跑通,等真机到场后直接切 IP 就能用。
public class ModbusTcpServer { private TcpListener _listener; private ushort[] _holdingRegisters; public ModbusTcpServer(int port, int registerCount) { _listener = new TcpListener(System.Net.IPAddress.Any, port); _holdingRegisters = new ushort[registerCount]; } public async Task StartAsync() { _listener.Start(); while (true) { var client = await _listener.AcceptTcpClientAsync(); _ = Task.Run(() => HandleClient(client)); } } private void HandleClient(TcpClient client) { using (client) { var stream = client.GetStream(); while (true) { // 这里同样先读MBAP头再读内容 byte[] header = ReadFull(stream, 7); int pduLength = (header[4] << 8) | header[5]; byte[] pdu = ReadFull(stream, pduLength - 1); byte unitId = header[6]; byte functionCode = pdu[0]; if (functionCode == 0x03 && pdu.Length >= 5) { int startAddress = (pdu[1] << 8) | pdu[2]; int quantity = (pdu[3] << 8) | pdu[4]; byte[] response = BuildReadResponse(unitId, startAddress, quantity); stream.Write(response, 0, response.Length); } } } } }这个服务端最简单的作用是验证客户端的报文构造是否正确。如果客户端发过来的起始地址超出寄存器范围,服务端应该回异常码。源码里保留了这个分支,但测试时可以临时放宽范围,方便先验证正常流程。整个调试过程中,服务端这边不需要太多业务逻辑,只要能把请求解析出来并且按格式回包就够了。
4.2 用抓包工具验证请求和响应对不对
代码跑通只是第一步,真正和现场设备对接时,抓包分析报文是必备的排错手段。Wireshark 过滤 tcp.port == 502,就能看到完整的请求和响应帧。我一般会和设备厂家确认报文格式时直接把抓包结果截图发过去,比文字描述高效很多。
抓包时重点看几个位置:请求帧的 MBAP 头中事务 ID 是否逐帧递增,协议 ID 是否 0x0000,长度字段是否等于单元 ID 加 PDU 长度。响应帧异常时,设备会回一个功能码带最高位置 1 的异常帧,后面跟一个异常码。0x01 是非法功能码,0x02 是非法数据地址,0x03 是非法数据值。看到 0x02 大概率是起始地址算错了,退回检查地址偏移量。
用虚拟从站抓包还有一个好处,可以验证自己的读取逻辑和解析逻辑。先往虚拟从站的寄存器里写一组已知的值,然后客户端去读,如果读出来的数值和写入不一致,问题一定出在字节序上。
4.3 和真实 PLC 对接前的检查清单
拿这份源码和真实设备对接前,建议先做几个检查。首先是确认设备的 Modbus TCP 端口,502 是标准端口,但国产设备常见的还有 8502、8000 等。其次是确认寄存器地址区间,有的设备只开放部分地址,读超出范围的地址会直接异常。然后是单位 ID,部分网关设备要求填 0xFF,部分 PLC 要求填实际从站号。
还需要看一眼设备手册里寄存器内部的排列方式。很多设备把一个 32 位浮点数放在两个连续寄存器里,但要确认高 16 位在前还是低 16 位在前。这份源码的 ReadHoldingRegisters 返回的是原始 ushort 数组,拼装浮点数的逻辑放到了调用方。在不同的设备上,这个拼装顺序可能相反,代码里留下的注释明确提到了这一点。
5. Modbus TCP 实战避坑:五个真实踩过的坑
5.1 事务 ID 不递增导致的行为不稳定
现象:设备有时正常响应,有时完全不应答,重启程序后又恢复。抓包发现每次请求的事务 ID 都是同一个值。
原因:事务处理标识符的作用是让设备侧区分不同请求,如果一直不递增,部分设备会把后面的请求当作重复报文直接丢弃,或者响应结果与请求错位。
解决:每个请求前强制递增事务 ID,同时注意溢出后自然回绕。源码里用 ushort 类型的计数器,每一步都校验自增逻辑。
5.2 寄存器数值整体漂移,高低字节错位
现象:读出来温度从 25.5 变成 65305,数值明显不对,但曲线和变化趋势和真实值一致。
原因:Modbus 是大端序,设备返回的原始字节是先高后低。C# 的 BitConverter.ToInt16 在大多数 Windows 平台上按小端解析,直接把两个字节拼起来就会得到错误的值。
解决:解析寄存器时手动做移位拼装,用(byte)(data[i] << 8) | data[i + 1],不要用 BitConverter。源码里所有寄存器数值的读取都走了这条路径。
5.3 单位 ID 填了 0 直接被设备无视
现象:连接 TCP 端口正常,发送请求后一直收不到响应,抓包确认数据已经发到设备。
原因:部分设备将单位 ID 为 0 视为广播地址,拒绝返回任何响应。设备侧配置的单站地址一般是 1 到 247,报文里的单位 ID 必须和设备侧一致。
解决:把单位 ID 默认值从 0 改成 1,对接新设备时先查手册确认站号配置。源码里 UnitId 属性暴露为公开可配项,接入不同设备时不需要改代码。
5.4 MBAP 长度字段算错导致解析卡死
现象:抓包看到请求发出去了,设备也回了响应,但客户端一直收不满数据,线程阻塞在 Read 方法上。
原因:请求帧的 MBAP 长度字段应该等于单元 ID 加上 PDU 的长度,如果少算一个字节,设备可能把响应帧少回一个字节或直接把请求丢弃。
解决:构造 MBAP 头时把长度字段的赋值逻辑单独抽出来,统一在 BuildMbapHeader 里计算。所有读操作都先读满 7 字节头,再依据长度字段循环读取剩余字节。
5.5 频繁建立新连接导致 IO 线程爆炸
现象:设备偶尔响应超时,程序里每个线程都新建 TcpClient,运行几个小时后连接数涨到几千,现场设备也开始拒绝连接。
原因:TCP 连接建立需要完成握手,频繁短连接还会触发 TIME_WAIT 状态堆积。Modbus TCP 长连接本身开销很小,没必要每次读写都重连。
解决:客户端做成连接复用模式,只在连接断开时重连,平时共用一个 TcpClient 实例。源码里提供了 KeepAlive 机制,检测到异常状态后主动重连,避免连接池膨胀。
6. 进阶用法:长连接保活与 32 位浮点数解析
6.1 连接保活与自动重连机制
现场环境网络波动很常见,设备重启、交换机端口抖动都能让 TCP 连接断开。一个合格的上位机程序必须处理这种场景,自动重连并恢复到正常采集状态。源码里实现了心跳检测机制,如果一次请求超过超时阈值没反应,就主动销毁旧连接,排空缓冲区,然后重新连接。
public ushort[] ReadWithRetry(ushort startAddress, ushort quantity, int retryCount = 3) { for (int i = 0; i < retryCount; i++) { try { if (_tcpClient == null || !_tcpClient.Connected) Connect(); return ReadHoldingRegisters(startAddress, quantity); } catch (Exception ex) { // 强制断开旧连接,下次循环会触发重连 _tcpClient?.Close(); _tcpClient = null; Thread.Sleep(200 * (i + 1)); } } throw new TimeoutException("多次重连后仍然无法读取数据"); }这个重试模式看起来简单,但解决了实际工作中最常见的问题:连接断开后不感知,继续向死连接写数据,等超时才发现。每次重试间隔递增,避免设备刚重启时立即发起大量连接请求。现场遇到过设备断电后需要十几秒才能重新监听端口,指数级重试间隔能显著提高恢复成功率。
6.2 32 位浮点数的两种字节序拼装
Modbus 寄存器是 16 位的,32 位浮点数需要占两个寄存器。不同厂家设备有两种排列方式,一种是高地址寄存器放高 16 位(AB CD),另一种是低地址寄存器放高 16 位(CD AB)。源码里把两种模式都封装好了,根据设备的实际情况切换。
public float ReadFloat(ushort startAddress, bool bigEndian32) { ushort[] regs = ReadHoldingRegisters(startAddress, 2); uint raw; if (bigEndian32) { // 高字在前:寄存器n放高16位,寄存器n+1放低16位 raw = ((uint)regs[0] << 16) | regs[1]; } else { // 低字在前:寄存器n放低16位,寄存器n+1放高16位 raw = ((uint)regs[1] << 16) | regs[0]; } return BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); }这里选的拼装方式是由设备厂商决定的,必须通过实测或手册确认。我一般会在对接初期往设备里写入一个已知数值,然后读出多个地址段的数据做交叉验证,确认字节序后不会再动。从那以后我每次对接新设备,都会强制走一遍写已知值再读回的流程,确认单位 ID、字节序和地址偏移全部正确后才开始写业务代码。希望这篇拆解能帮你少走几步弯路,顺利把设备数据跑通。
本文还有配套的精品资源,点击获取