简介:工业自动化领域常需上位机与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 的 Modbus(Modbus TCP)C# 源码包拉下来自己改造。这份源码和网上那种只讲协议的 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 字节,各字段如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| 事务处理 ID | 2 | 请求与响应配对,并发时靠它识别 |
| 协议 ID | 2 | 固定为 0x0000,目前只有 Modbus 协议 |
| 长度 | 2 | 后面所有字节数,即 unitId + PDU |
| 单元 ID | 1 | 从站地址,通常填 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 Task<ushort[]> 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] 是单元 ID,frame[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-09999 | 0x0000 |
| 离散输入 | 10001-19999 | 0x0000 |
| 输入寄存器 | 30001-39999 | 0x0000 |
| 保持寄存器 | 40001-49999 | 0x0000 |
注意,物理地址 40001 在 PDU 里对应地址 0,40002 对应地址 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 报文代码,都强制先跑一遍半包模拟和字节序自检,确认协议层稳定了才开始写业务。这个习惯帮我避掉了不少现场连不上、数据错误的翻车事故,希望也能帮到你。
本文还有配套的精品资源,点击获取