资讯动态

C#实现Modbus TCP通信:从报文封装到上位机实战源码解析

发布时间:2026/10/17 12:26:34 来源:尧图企业网站定制
简介这是一份面向工业自动化与工控软件开发者的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位寄存器n1放低16位 raw ((uint)regs[0] 16) | regs[1]; } else { // 低字在前寄存器n放低16位寄存器n1放高16位 raw ((uint)regs[1] 16) | regs[0]; } return BitConverter.ToSingle(BitConverter.GetBytes(raw), 0); }这里选的拼装方式是由设备厂商决定的必须通过实测或手册确认。我一般会在对接初期往设备里写入一个已知数值然后读出多个地址段的数据做交叉验证确认字节序后不会再动。从那以后我每次对接新设备都会强制走一遍写已知值再读回的流程确认单位 ID、字节序和地址偏移全部正确后才开始写业务代码。希望这篇拆解能帮你少走几步弯路顺利把设备数据跑通。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑