
简介面向工业自动化领域开发者特别是需要使用C#与信捷PLC进行通信的工程师这份源码提供了完整的MODBUS TCP通信实现方案适合新手及有一定经验的开发人员参考学习可快速掌握上位机基于MODBUS TCP协议与信捷PLC交互的核心方法。整套工程基于Visual Studio 2017开发共100个文件约474KB包含16个C#源程序文件以及解决方案、工程配置sln/csproj/xml、可执行程序exe与依赖库dll等另附CHM帮助文档和演示动图辅助上手。源码覆盖了MODBUS TCP常用功能码01读开关量、05写开关量、03读单路寄存器、06写单路寄存器、10写多路寄存器基本涵盖PLC通信中的常见读写场景。目前已有1143人学习下载整套源码由工控老马出品并实测可用。通过阅读源码可以理解上位机与PLC建立TCP连接、构造MODBUS报文、解析返回值等完整过程对实际项目中快速实现通信功能有直接参考价值。 做C#上位机开发的人迟早会接到和PLC通信的需求。信捷PLC在小型自动化设备里出场率很高它自带以太网口最省事的通信方案就是走MODBUS TCP。这个方案不需要额外的转换器不需要厂家SDKC#这边直接用Socket就能实现。我一开始也觉得这事不难真正写代码才发现协议文档不算长但地址映射、字节序、超时处理这些细节相当绕人而且网上能查到的完整示例大多是针对三菱、西门子的信捷的反而少见。这篇文章把我实际用过的信捷PLC MODBUS TCP通信源码完整拆开讲从协议结构、地址对应、连接管理到UI刷新一次说清楚。适合刚接触上位机开发的C#程序员也适合需要和设备联调的电气工程师参考。1. 为什么信捷PLC选MODBUS TCP而不是串口或厂商私有协议1.1 三种通信方式怎么选信捷XC、XD系列主流型号都支持串口和以太网两种通信方式。串口默认走MODBUS RTU以太网走MODBUS TCP部分型号还支持私有协议。私有协议的文档不好找第三方库基本没人做除非迫不得已我一般不推荐。三种方式对比如下通信方式速率接线上位机开发成本适用场景串口MODBUS RTU9600/19200bpsRS232/RS485需自己处理CRC16校验老设备改造、点数少、距离短MODBUS TCP10/100Mbps网线/交换机直接Socket无CRC大部分新项目首选私有协议视型号而定网口或串口只能按厂家文档写特殊功能时才考虑我选MODBUS TCP的核心理由有三个第一不用做CRC16校验帧格式更简单解析代码少一半第二网口通信速率高200毫秒轮询几十个寄存器毫无压力第三PC端开发不依赖厂家SDK换不同型号的PLC通信代码基本不用动动的只是地址映射表。1.2 信捷PLC做服务器时的网络角色MODBUS TCP的角色关系很明确PLC端是服务器从站上位机是客户端主站。上位机主动发起连接、发送读写请求PLC收到后返回应答。整个交互是请求-响应式的PLC不会主动往PC塞数据所以上位机必须自己轮询。实际部署时注意几个点。PLC默认监听502端口PC端连上后保持长连接不要每读一次就断开重连——速度慢不说设备端的TCP资源也容易被耗尽。现场最好把PLC和PC独立放在一个网段比如192.168.1.x避免办公网的广播流量干扰通信。信捷大部分型号的IP地址在编程软件里可以设置但不同型号的默认值不一样有默认192.168.0.250的也有完全不带默认IP的拿到设备后第一时间在编程软件里读出来改成规划好的固定IP能省很多调试时间。还有一个必须确认的地方PLC的MODBUS TCP服务是否启用。在信捷编程软件里通常需要在以太网配置或通信参数中勾选Modbus TCP Server选项端口保持502不变。部分老型号不是默认启用的漏掉这一步上位机连上去会被拒绝。这个配置项很隐蔽我第一次就被坑过。2. 拿到协议文档后先抠这四个细节再动手写代码2.1 MBAP头的每个字节都要算清楚MODBUS TCP的报文分两部分前面7个字节是MBAP头后面是功能码加数据。MBAP头依次是事务处理标识符2字节、协议标识符2字节、长度2字节、单元标识符1字节。第一眼看会觉得简单真正写代码容易在长度字段上翻车。长度字段表示的是从单元标识符开始到报文结束的字节数并不是整个报文的字节数。举个例子一条读保持寄存器的请求帧是03 00 00 00 00 06 01 03 10 00 00 0A拆开看就是事务ID 0x0003协议ID 0x0000长度0x0006单元ID 0x01功能码0x03起始地址0x1000数量0x000A。长度6恰好等于单元ID加功能码加地址加数量也就是去掉MBAP头前6个字节之后的长度。事务ID由客户端生成每次请求递增响应里会原样带回上位机用它来配对请求和响应。轮询场景一般不会同时发多包但写代码时把事务ID的递增机制做好后面做并发请求也方便。2.2 功能码不用全学掌握这四个就够用MODBUS定义了几十个功能码日常跟信捷PLC打交道最常用的只有四个0x03读保持寄存器、0x06写单个寄存器、0x10写多个寄存器、0x05写单个线圈。0x01读线圈偶尔用到做设备状态监控时能碰到。信捷PLC的数据主要存在D区数据寄存器和M区内部继电器。D区对应保持寄存器用0x03和0x06/0x10来读写M区对应线圈用0x01和0x05读写。D区存字数据一个寄存器16位M区是位数据一个地址一位搞清楚这个对应关系选功能码就不会错。有一点需要提醒0x04读输入寄存器这个功能码很多PLC支持但信捷大多数型号读取模拟量通道用的是专用指令寄存器模式下0x04经常返回异常。别看到文档里有0x04就想用先用0x03测等确认了映射表再说。2.3 大端字节序是C#上位机最容易翻船的地方MODBUS协议规定寄存器数据按大端序传输也就是高字节在前、低字节在后。而C#的int、short这些基础类型在内存里是小端序直接用BitConverter转换经常会得到完全错乱的结果。处理方式很简单读完两个字节后手动调换顺序ushort value (ushort)((raw[0] 8) | raw[1]);这里raw[0]是响应数据里的第一个字节按大端序它是高位所以左移8位再和低位按位或得到的就是正确的16位值。如果用BitConverter就得先Array.Reverse再ToUInt16多几步不说还容易漏。我习惯统一用移位方式简单直接。32位数据更麻烦。一个32位数值占两个寄存器MODBUS规范默认高字在前也就是第一个寄存器是高16位、第二个是低16位。但不同品牌、不同系列的PLC对这个顺序有自己的习惯有的正好相反。我验证顺序的做法是在PLC里写入0x12345678上位机读两个寄存器拼一下看拼出来是0x12345678还是0x78563412一次就能确认比盯着文档猜快多了。2.4 信捷软元件地址和MODBUS地址不是一回事信捷编程软件里的D0、M0是软元件编号通信报文里用的是MODBUS地址两者之间存在映射。以常见的XD系列为例D区寄存器通常从0x1000开始对应即D0对应0x1000D1对应0x1001以此类推M区从0x0000开始M0就是0x0000。X输入、Y输出的偏移区域不同系列差异较大具体数值必须翻阅当台PLC的通信手册。这个映射是整个开发中最不能靠猜的部分。我见过有人把D100直接当成地址0x100填进报文结果PLC返回异常码查了半天才发现少加了0x1000偏移。稳妥流程是先查手册确认映射表再在PLC里写入已知数值最后用Modbus Poll从地址0开始扫描看看哪个地址段能读到内容双重确认。3. 通信源码拆解连接、请求、应答三层各写什么3.1 连接管理异步方式做超时控制TCP连接最怕Connect卡死。直接用TcpClient.Connect(ip, port)如果PLC没开机或网络不通可能阻塞十几秒现场调试时每一秒都是煎熬。我用ConnectAsync加超时等待的方式public class ModbusTcpClient { private TcpClient _tcp; private NetworkStream _stream; private ushort _transactionId 0; public bool Connect(string ip, int port 502, int timeout 3000) { _tcp new TcpClient(); var task _tcp.ConnectAsync(ip, port); if (Task.WaitAny(task, Task.Delay(timeout)) 0) { _tcp.Close(); return false; } _tcp.EndConnect(task.GetAwaiter().GetResult()); _stream _tcp.GetStream(); return true; } }Task.WaitAny返回先完成任务的索引返回-1说明超时任务先跑完连接还没建立直接关闭。实测下来PLC断电、网线松动、IP写错都能在3秒内得到明确结果不会卡住界面。3.2 请求帧构建统一封装BuildFrame所有请求帧都是MBAP头加PDU头部逻辑完全一样差异只在功能码和数据部分。把头部构建抽成一个方法后面写任何功能码都复用private byte[] BuildFrame(byte unitId, byte[] pdu) { _transactionId; byte[] frame new byte[7 pdu.Length]; frame[0] (byte)(_transactionId 8); frame[1] (byte)(_transactionId 0xFF); frame[2] 0x00; frame[3] 0x00; int len pdu.Length 1; frame[4] (byte)(len 8); frame[5] (byte)(len 0xFF); frame[6] unitId; Array.Copy(pdu, 0, frame, 7, pdu.Length); return frame; }最容易写错的是第4、5字节的长度计算。这里的长度是PDU长度加1因为MBAP头不算进去但单元标识符要算进去。写的时候忘了加1PLC那边解析就会错位响应要么没有要么数据全乱。读保持寄存器的PDU构造很简单public ushort[] ReadHoldingRegisters(byte unitId, ushort startAddr, ushort count) { byte[] pdu new byte[] { 0x03, (byte)(startAddr 8), (byte)(startAddr 0xFF), (byte)(count 8), (byte)(count 0xFF) }; byte[] frame BuildFrame(unitId, pdu); _stream.Write(frame, 0, frame.Length); // 后续解析响应 }3.3 应答解析必须处理TCP粘包和半包MODBUS TCP底层是TCP而TCP是字节流不是消息流。上位机一次Read拿到的可能只有半个响应也可能把两个响应黏在一起发给上层。这个问题对老手是常识对新手来说完全像玄学。解法是写一个ReadExactly方法严格按需要的字节数读取private byte[] ReadExactly(int count) { byte[] buffer new byte[count]; int offset 0; while (offset count) { int n _stream.Read(buffer, offset, count - offset); if (n 0) throw new IOException(连接已断开); offset n; } return buffer; }响应先读7字节头部从头部长度字段解析出PDU长度再ReadExactly剩余部分。这样不管底层怎么分包都能完整组装出一个响应帧。响应头部里的功能码、事务ID也要和请求对上这是防止串包的基本功。3.4 写多个寄存器的格式细节写多个寄存器比读多一层嵌套PDU里多了字节数、两级数据public void WriteMultipleRegisters(byte unitId, ushort startAddr, ushort[] values) { int byteCount values.Length * 2; byte[] pdu new byte[6 byteCount]; pdu[0] 0x10; pdu[1] (byte)(startAddr 8); pdu[2] (byte)(startAddr 0xFF); pdu[3] (byte)(values.Length 8); pdu[4] (byte)(values.Length 0xFF); pdu[5] (byte)byteCount; for (int i 0; i values.Length; i) { pdu[6 i * 2] (byte)(values[i] 8); pdu[7 i * 2] (byte)(values[i] 0xFF); } byte[] frame BuildFrame(unitId, pdu); _stream.Write(frame, 0, frame.Length); }这里高频翻车点是pdu数组长度。正确写法是6加byteCount不是4加byteCount少了两个字节后面全错。Data区每个寄存器的值同样按大端序放置跟前面说的字节序问题一脉相承。配合ReadExactly和BuildFrame这个读写模块就能跑通最小通信闭环。再把异常处理、日志加上项目的通信层基本够用了。4. 轮询采集和UI刷新不在一个线程干是底线4.1 为什么轮询一快界面就卡把TCP读数据的代码写在Timer里读完直接塞给TextBox这是很多人第一版的做法。小点数看不出问题轮询频率一高、界面控件一多卡顿就出现了。原因有两层。第一层是网络IO阻塞。ReadHoldingRegisters里的ReadExactly是同步阻塞的如果PLC响应慢或网络抖动这一个周期就拖长了UI线程被卡住界面失去响应。第二层是跨线程更新UI。子线程不能直接改控件必须通过Dispatcher或Control.Invoke切回UI线程。每100毫秒轮询一次每次更新几十个值UI线程光处理Invoke回调就忙不过来还容易积压界面越用越卡。4.2 用Channel把采集和显示拆成两端我的做法是生产者消费者模型用Channel做缓冲。后台采集线程只负责和PLC通信读到的数据写进Channel不碰任何UI控件。UI线程用固定周期定时器从Channel批量取出最新数据一次性刷新界面。ChannelDictionarystring, double dataChannel Channel.CreateBoundedDictionarystring, double(2); // 采集线程 _ Task.Run(async () { while (!cts.IsCancellationRequested) { Dictionarystring, double snapshot plc.ReadSnapshot(); // 一次读一批 dataChannel.Writer.TryWrite(snapshot); await Task.Delay(200); } });UI刷新用DispatcherTimer或System.Windows.Forms.Timer每500毫秒取一次while (dataChannel.Reader.TryRead(out var snap)) { txtTemp.Text snap[Temp].ToString(F1); txtSpeed.Text snap[Speed].ToString(F1); // 其余控件统一刷新 }Channel容量故意设成2用的是有界队列。UI处理不过来时采集线程写入会丢旧数据不会无限积压界面展示的始终是最近一次完整状态这才符合监控界面的语义。4.3 实测数据对比这套方案用在一个每200毫秒采集60个寄存器的项目上。改造前同步Timer驱动界面一动就卡CPU占用忽高忽低改造后采集线程稳定跑UI定时器500毫秒刷新一次CPU占用下降接近一半操作界面没有迟滞感。踩过这个坑之后我的经验是任何涉及连续采集的上位机一开始就按生产者消费者设计后期加功能也方便。5. 一次通信超时故障的完整排查过程5.1 工具准备Modbus Poll和Wireshark缺一不可排查通信问题光靠翻代码效率太低。我手头最常用的两件工具。Modbus Poll是主站模拟器可以模拟上位机连接PLC直接填地址、功能码、长度去读取。Modbus Slave是从站模拟器可以在PC上模拟一个MODBUS TCP服务器在没有真实PLC时验证上位机代码逻辑。核心排查原则是分而治之。上位机连不上、读不到数据先用Modbus Poll连真实PLC。Poll也读不到问题多半在PLC侧比如服务没启用、IP不对、地址映射错。Poll能读到问题就在上位机代码里。这个二分法能省掉大量无效排查。5.2 抓包确认问题在哪一层有一次现场报读D100超时我先跑Modbus Poll结果也超时。于是打开Wireshark过滤条件写tcp.port 502抓现场报文看到PC发出的SYN包一直在重传PLC侧根本没有SYN-ACK回应。这说明TCP连接压根没建立起来跟MODBUS协议层无关。顺链路查下去发现PLC的IP被改到了另一个网段PC和新网段不在同一子网里。把PC网卡IP调成同网段后TCP连接立刻建立Modbus Poll恢复正常。整个过程十分钟搞定如果没抓包我可能还在代码里找逻辑错误。那次之后我的排查顺序固定成三步先看Wireshark里TCP三次握手是否完成再确认有没有MODBUS请求包发出、有没有响应包回传最后对比响应的事务ID和请求是否一致。这三步走完问题基本能定位到网络层、协议层还是应用层。5.3 用Modbus Slave反向验证上位机代码没有真实PLC的场景也很常见比如设备还没到先写代码。Modbus Slave这时候能顶上在PC上批量创建寄存器模拟信捷PLC的D区内容然后让C#上位机连本机IP和502端口。上位机能正常读写说明通信代码没问题问题大概率出在PLC侧的地址映射或配置。我习惯在Slave里预置一组特征值比如0x1234、0xABCD上位机读出来校验一遍字节序问题一起验证掉一举两得。6. 实战里反复踩的三个坑和我的处理办法6.1 单元标识符不是永远等于1MODBUS TCP的MBAP头里有单元标识符标准文档示例里经常填0xFF或1很多教程代码默认填1。但信捷部分型号的默认单元ID可能是255PLC侧参数不同客户端填1就会收到异常码。我第一次联调时折腾了很久最后用Modbus Poll试出255才通。所以别写死把单元标识符做成连接参数默认值设1同时允许配置。联调第一阶段先用Modbus Poll确认PLC的实际单元ID确认后填进配置里免得在代码里反复编译修改。6.2 DWord和Float的组合顺序因设备而异温度、压力采集难免碰到32位数值D0和D1两个寄存器拼成一个float。有的PLC是D0高字、D1低字有的正好相反。C#端拼数顺序不对数值直接离谱而且没有任何报错。我的验证技巧前面提过在PLC里D0写入0x12345678上位机读两个寄存器看拼出来是0x12345678还是0x78563412。一次不够不同型号、不同固件都可能出现差异新项目联调时务必把这个测试放在第一轮。顺带说一句浮点转字节顺序同理也按这个思路先写已知值验证。6.3 断线重连别用死循环TCP连接不稳定是工控现场的常态PLC重启、程序下发、交换机断电都会导致断开。最简单的重连逻辑是catch到异常立刻Connect但PLC还没就绪时会陷入连接失败-立即重连-再失败的死循环日志刷屏CPU升高还会影响PLC侧资源释放。我用的策略是退避重连第一次失败等500毫秒第二次等1秒第三次等2秒最多到10秒封顶连接成功后恢复初始值。实测下来PLC重启期间上位机最多打印几条重连日志PLC一恢复几秒内自动续上操作员几乎无感。重连机制做好之后还有一个隐藏收益上位机的整体健壮性上去了现场维护不用老盯着通信中断的红色告警。其实把这套东西做扎实之后换其他品牌的PLC也就两三天的事。MODBUS TCP是标准化协议真正有差异的是地址映射和字节序。我后来把自己写的ModbusTcpClient类沉淀成了内部工具包新项目直接引用只改配置文件和地址映射表。建议你也把协议层和业务层拆开通信层保持与具体设备无关后续无论是换PLC还是加设备都能少折腾很多。本文还有配套的精品资源点击获取