C#与欧姆龙PLC通信:FINS/TCP协议源码实现与现场调试指南
简介一套C#与欧姆龙PLCFINS协议通信的完整源代码工程面向工业自动化上位机开发者适合新手入门及有经验工程师参考。工程演示通过以太网调用FINS协议与PLC交换数据覆盖TCP/IP通信、命令帧构造、响应解析、多线程界面刷新及异常重试机制。压缩包共21个文件大小仅26KB以12个.cs源码文件为主涵盖FINS协议封装、界面交互、程序入口等模块另配.csproj工程文件、.resx资源文件、.sln解决方案及README说明结构完整。已有908人学习浏览在同类资源中具备一定参考热度。读者可获得可运行的通信程序骨架深入理解FINS协议数据交互原理和C#网络编程技巧代码中保留注释与文档方便二次开发或移植到其他PLC协议。1. 为什么需要一手C#通信源码现场调设备的第一个坎接过一个产线项目设备清一色欧姆龙PLC上位机是C#现场调试第一天就被卡住不知道PLC里的D区数据怎么才能读进WinForm界面。翻官方手册全是协议术语网上找的代码要么调不通要么只支持串口。折腾到半夜才明白设备通讯这种事只有手上有一份真正能改、能复现、能排错的C#通信源码才算把主动权握在自己手里。这篇文章要说的就是围绕“C#和欧姆龙PLC通信程序源代码”这个方向把FINS/TCP这条最常用的网口通信链路从协议原理到代码落地、再从现场排错到回归验证完整讲透。适合正在被欧姆龙PLC和上位机联动折磨的C#开发也适合刚接手设备接入项目的新手照着抄作业。2. 读懂FINS协议网口、数据帧与内存区映射C#连欧姆龙PLC常见做法就是用FINS协议走以太网也就是FINS/TCP。你不需要把协议手册背下来但三个事情必须在写代码之前落地连接参数、一帧报文长什么样、PLC地址和C#变量怎么对应。这三个点认不清后面的代码就算跑起来也是撞运气。2.1 FINS/TCP连接三要素IP、端口与节点号欧姆龙PLC的FINS/TCP通信和普通Socket通信一样IP和端口是第一道参数。PLC侧通常在CX-Programmer或Sysmac Studio的“单元设置”里绑定一个固定IP端口默认是9600这是FINS/TCP的标准端口。若有人改过端口需要先从PLC侧读取别想当然。另一个容易被忽略的参数是FINS节点号。节点号不等于IP但很多现场喜欢把两者设成一样的值比如IP是192.168.1.10节点号也填10。这个值在FINS报文里叫DA1也就是目标节点地址。上位机电脑也有自己的源节点号一般填0。如果你用CX-Simulator在线模拟器DA1和SA1的对应关系又不一样。我的习惯是把节点号做成类的构造参数换现场只改配置文件不碰逻辑代码。2.2 一封FINS请求帧长什么样从“帧头”到“命令数据”FINS/TCP的报文结构分两层。内层是FINS报文本身外层是TCP封包时前边追加的4字节长度字段这4字节用网络字节序保存整个内层报文的字节数也就是大端序。C#里如果直接BitConverter转int多半会得到反过来的数字。内层FINS报文从固定10字节的帧头开始ICF、RSV、GCT、DNA、DA1、DA2、SNA、SA1、SA2、SID。其中ICF用于区分命令帧和响应帧GCT固定为0x02DNA表示目标网络号一般为0x00DA1和SA1是我们上面提到的节点号SID是发送序号响应帧会原样带回。这个序号在排查“丢包”、“串包”时特别有用发出去的是几号回来的必须是几号。真正我要读DM区数据需要在10字节帧头之后追加命令码和参数。命令码0x01 0x01表示内存区读后面跟内存区代码、起始地址、读取字数。这个结构看着简单现场翻车多是因为地址或者字数写错。2.3 内存区映射D100在C#里到底对应什么欧姆龙PLC的D区在FINS协议里不是简单写“100”就能读。FINS报文里的地址由“内存区代码 2字节地址”组成。以CP1H、CJ2M这些常见型号为例DM区的内存区代码是0x82D100对应的地址就是0x00 0x64也就是十六进制的100。你读的是字不是字节所以后续解析C#数值时还要注意每个字占2字节。很多新手在这里翻车用BitConverter.ToUInt16直接解析收到的字节出来的数值和PLC监视画面差了一大截就是因为欧姆龙走的是大端序。你收到的0x01 0x02在C#里会被读成0x0201不是0x0102。这个问题后面避坑章会单独讲但写代码时最好从一开始就统一用BinaryPrimitives.ReadUInt16BigEndian这类方法别等真机调试时再来猜。提示COIO区、W区、H区的内存区代码都不一样本文后面代码以DM区为演示换区域时请核对对应型号手册中的“FINS内存区代码表”不要直接复用0x82。3. 用C#实现FINS/TCP收发从建工程到拿回第一个字原理说清了这一章直接动手。目标是在Visual Studio里建一个控制台项目用一份能编译的C#源码连上PLC读出D100和D101两个字。代码会拆成三部分连接与基础收发类、读命令的组装、主程序调用。每一步后面都有参数说明照着抄就能跑通。3.1 新建工程并封装FinsTcpClient基类在Visual Studio里新建一个.NET控制台项目.NET 6或.NET Framework 4.7.2都可以。下面这一段是通信核心类只负责连接、发FINS帧、收响应、解析响应头using System; using System.IO; using System.Net.Sockets; namespace PlcMonitor { public class FinsTcpClient : IDisposable { private TcpClient _tcp; private NetworkStream _stream; private readonly string _ip; private readonly int _port; private readonly byte _da1; // 目标节点号PLC的FINS节点 private readonly byte _sa1; // 源节点号本机FINS节点 private byte _sid 0x01; // 请求序号每次自增 public FinsTcpClient(string ip, int port 9600, byte da1 0, byte sa1 0) { _ip ip; _port port; _da1 da1; _sa1 sa1; } public void Connect() { _tcp new TcpClient(); _tcp.Connect(_ip, _port); _stream _tcp.GetStream(); _stream.ReadTimeout 3000; // 读超时3秒避免卡死 _stream.WriteTimeout 3000; // 写超时3秒 } public byte[] SendFinsCommand(byte[] command) { using (var ms new MemoryStream()) { // 10字节FINS帧头 ms.WriteByte(0x80); // ICF命令帧 ms.WriteByte(0x00); // RSV保留 ms.WriteByte(0x02); // GCT网关数 ms.WriteByte(0x00); // DNA目标网络号 ms.WriteByte(_da1); // DA1目标节点 ms.WriteByte(0x00); // DA2目标单元 ms.WriteByte(0x00); // SNA源网络号 ms.WriteByte(_sa1); // SA1源节点 ms.WriteByte(0x00); // SA2源单元 ms.WriteByte(_sid); // SID请求序号 ms.Write(command, 0, command.Length); var body ms.ToArray(); // FINS/TCP要求在报文前加4字节长度大端序 byte[] lenBytes new byte[4]; lenBytes[0] (byte)(body.Length 24); lenBytes[1] (byte)(body.Length 16); lenBytes[2] (byte)(body.Length 8); lenBytes[3] (byte)body.Length; _stream.Write(lenBytes, 0, 4); _stream.Write(body, 0, body.Length); } // 读取响应先读4字节长度再读响应体 byte[] lenBuf ReadExactly(4); int respLen (lenBuf[0] 24) | (lenBuf[1] 16) | (lenBuf[2] 8) | lenBuf[3]; byte[] resp ReadExactly(respLen); // FINS结束码在“帧头10字节 命令码2字节”之后也就是偏移12和13 if (resp[12] ! 0 || resp[13] ! 0) { throw new IOException($FINS错误码 0x{resp[12]:X2} 0x{resp[13]:X2}); } // 响应数据从偏移14开始 byte[] result new byte[respLen - 14]; Array.Copy(resp, 14, result, 0, result.Length); _sid; // 序号递增用于匹配请求和响应 return result; } private byte[] ReadExactly(int count) { byte[] buf new byte[count]; int offset 0; while (offset count) { int n _stream.Read(buf, offset, count - offset); if (n 0) throw new IOException(连接已关闭); offset n; } return buf; } public void Dispose() { _stream?.Dispose(); _tcp?.Dispose(); } } }这段代码的逻辑很清晰Connect建立TCP连接并设置收发超时SendFinsCommand负责把一段FINS命令拼上帧头发送再读回响应如果结束码不为0就直接抛异常省得主程序里到处判断错误。参数方面port默认9600da1和sa1默认都填0但真机调试时DA1一定要跟PLC侧节点号一致否则PLC收到帧也可能不响应。3.2 组装读DM区命令01 01命令的地址与数量有了发送通道接下来写真正读数据的命令。FINS内存区读命令码是01 01后面依次是内存区代码、2字节起始地址、2字节读取字数。下面是读DM区连续多字的封装public ushort[] ReadDm(int startAddress, int length) { if (length 0 || length 128) throw new ArgumentOutOfRangeException(nameof(length), FINS单次读取上限通常是128个字); byte[] cmd new byte[7]; cmd[0] 0x01; // 命令码高位内存区读 cmd[1] 0x01; // 命令码低位 cmd[2] 0x82; // 内存区代码DM区 cmd[3] (byte)(startAddress 8); // 起始地址高8位 cmd[4] (byte)(startAddress 0xFF); // 起始地址低8位 cmd[5] (byte)(length 8); // 读取字数高8位 cmd[6] (byte)(length 0xFF); // 读取字数低8位 byte[] result SendFinsCommand(cmd); // 每个字2字节解析时统一用大端序 ushort[] values new ushort[length]; for (int i 0; i length; i) { values[i] (ushort)((result[i * 2] 8) | result[i * 2 1]); } return values; }这里的参数有一个容易踩的边界起始地址不是字节地址是字地址length单位是“字”不是“字节”。比如读D100和D101startAddress传100length传2PLC返回4字节每一对字节组成一个字。单次读取字数我限制到128这是保守值不同PLC型号上限不同但别一次读几千个字TCP包太大反而容易错。3.3 组装写DM区命令01 02命令的载荷结构通信程序只读不写等于少了一条腿。FINS写单个字的命令码是01 02报文比读命令多了要写入的数据。下面是一个连续写多字的实现public void WriteDm(int startAddress, ushort[] values) { if (values null || values.Length 0) throw new ArgumentException(写入内容不能为空); // 命令码2 内存区代码1 地址2 字数2 数据N*2 byte[] cmd new byte[7 values.Length * 2]; cmd[0] 0x01; // 命令码高位内存区写 cmd[1] 0x02; // 命令码低位 cmd[2] 0x82; // 内存区代码DM区 cmd[3] (byte)(startAddress 8); cmd[4] (byte)(startAddress 0xFF); cmd[5] (byte)(values.Length 8); cmd[6] (byte)(values.Length 0xFF); for (int i 0; i values.Length; i) { int offset 7 i * 2; cmd[offset] (byte)(values[i] 8); // 高位在前 cmd[offset 1] (byte)(values[i] 0xFF); // 低位在后 } SendFinsCommand(cmd); }很多人会问写命令有没有返回值。FINS写操作的正常响应不带数据区只有结束码所以SendFinsCommand返回的数组长度是0只要不抛异常就代表写成功。这里有个坑写地址范围越界时结束码会返回非0值然后被基类的IOException挡住看异常信息里的十六进制错误码再对症排查。3.4 主程序调用10行代码跑通首轮读写把上面的类放进项目里在Program.cs里写一个最小调用接上真机或者仿真器就能看到效果using System; namespace PlcMonitor { class Program { static void Main(string[] args) { // 参数说明IP、端口、PLC节点号、本机节点号 using (var plc new FinsTcpClient(192.168.1.10, 9600, da1: 10, sa1: 0)) { plc.Connect(); ushort[] data plc.ReadDm(100, 2); Console.WriteLine($D100 {data[0]}, D101 {data[1]}); plc.WriteDm(100, new ushort[] { 123 }); data plc.ReadDm(100, 1); Console.WriteLine($写入后 D100 {data[0]}); Console.ReadLine(); } } } }这段代码先把D100和D101读出来再往D100写入123最后重新读一次验证。如果你连的是模拟器这一步不出意外就能看到数字变化。真机调不通的时候优先检查前面三步网线物理连接、PLC端口配置、节点号是否匹配。4. 让通信程序扛得住现场轮询、超时与自动重连跑通单次读写只是开始。现场环境不是实验室PLC断电重启、网线松脱、交换机死机都是常态。通信程序要能连续几个月挂在工控机上必须在轮询策略、超时处理和重连机制上做足文章。4.1 多台PLC轮询一个连接跑到底别给每台设备开一个线程常见做法是在一个定时循环里挨个访问现场PLC而不是给每台设备单独new一个线程去死循环读。原因很简单PLC的通信口可能同时只处理有限数量的连接上位机线程多了反而会把自己电脑的资源耗尽。下面的代码展示了用一个后台任务顺序轮询两台PLC的骨架using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; class PlcPoller { private readonly ListFinsTcpClient _plcs new ListFinsTcpClient(); public async Task RunAsync(CancellationToken token) { while (!token.IsCancellationRequested) { foreach (var plc in _plcs) { try { ushort[] data plc.ReadDm(100, 10); Console.WriteLine($读取成功第1个值: {data[0]}); } catch (Exception ex) { Console.WriteLine($读取失败: {ex.Message}); // 这里交给重连逻辑处理不要吞掉异常 } } await Task.Delay(200, token); // 轮询间隔200ms现场可调到500ms } } }轮询间隔需要根据工艺要求调整。200毫秒在快速检测场景够用但如果你PLC里的数据是温度这类慢变化量1秒一次更稳妥。间隔短不完全是坏事但通信压力会成倍增加PLC响应不过来时反而会丢帧。4.2 断线自动恢复先退避再重连别死磕通信异常最怕程序卡死在Connect里。Socket超时设了3秒但断网时重连也可能持续报错。我习惯在重连逻辑里加入指数退避第一次失败等1秒第二次2秒第三次4秒最多等10秒。这样可以避免PLC还没恢复时程序像个疯子一样每200毫秒撞一次门。private int _failCount 0; public bool TryReconnect(FinsTcpClient plc) { int delay Math.Min(1000 * (1 _failCount), 10000); _failCount; Console.WriteLine($等待 {delay}ms 后重连); Thread.Sleep(delay); try { plc.Connect(); _failCount 0; return true; } catch { return false; } }这里的_failCount要连续失败才递增一旦重连成功就清零。还有个细节FinsTcpClient对象在重连失败后不能直接复用要把旧连接释放掉重新new一个再Connect。TCP连接断开后底层Socket状态不一定马上转为Closed直接复用容易踩“连接未完全释放”的坑。4.3 心跳检测读PLC时钟比什么判断都可靠判断PLC是否在线发送自定义测试帧也行但最省事的是读一个不可能不变的地址。常见做法是读PLC的年的数据每年递增一次说明链路活着。用FINS命令读DM区时地址和数量不变开销极小。心跳和业务数据读取可以在同一个轮询循环里完成不需要额外再开线程。心跳还有一个重要作用防止上位机和PLC之间长时间没有数据通信被交换机或PLC自身的空闲连接机制断开。在轮询间隔超过1秒的场景专门每5秒读一次D0能有效维持TCP连接活跃避免现场“第一包正常第二包开始超时”的诡异故障。4.4 UI线程与数据刷新跨线程更新控件要冷静WinForm或WPF里直接在工作线程里更新TextBox会报跨线程异常。新手通常用Invoke一层层套代码越来越乱。更稳妥的做法是让工作线程只负责产出数据界面层用System.Timers.Timer定时从缓存变量里取最新值。下面的代码演示了最简单的数据缓存模式private volatile ushort[] _latestData new ushort[10]; // 在轮询线程里写 _latestData plc.ReadDm(100, 10); // 在UI定时器里读 private void Timer_Tick(object sender, EventArgs e) { label1.Text _latestData[0].ToString(); }参数volatile保证读写线程之间的可见性。注意_latestData数组本身要保持引用不变每次更新是替换整个数组而不是在循环里改数组元素否则其他线程可能读到写了一半的数据。5. FINS通信现场避坑5个半夜让你去查线的常见坑通信程序代码本身往往没大问题真正让人头疼的是现场环境和配置偏差。这里列出的5条都是我自己反复踩过的坑按“现象、原因、解决”的方式记录希望能帮你少走几个来回。5.1 现象TCP能连上指令发出去却一直超时现场最常见的故障就是上位机TcpClient.Connect成功但SendFinsCommand读不到响应。原因通常是PLC侧根本没有启用FINS/TCP服务或者端口不是9600。Socket连上只能说明IP通了PLC的应用层并没有监听这个端口。解决步骤是进PLC的系统设置找到内置以太网或通信单元选项确认FINS/TCP服务为启用状态端口号记下来填到C#程序的port参数里。用工具查一下PLC端口有没有LISTENING状态比反复改程序快得多。5.2 现象读到的数值和PLC监视画面不一致经常乘以16或除以16这种偏差十有八九是地址映射错了。FINS的地址是按字偏移的不是按字节偏移。比如监控软件里看到D1000程序里startAddress写500那就把D500读回来了。尤其是D区地址较大的时候十六进制换算一定要自己拿计算器按一遍。解决方法是先在PLC里给目标地址写一个固定值比如十进制12345上位机读到后确认二进制值对不对再把地址偏移表逐条核对。5.3 现象响应报错 FINS错误码 0x11 0x010x1101是FINS里很经典的内存区错误码意思是内存区不存在或访问越界。最常见的原因是DM区代码或起始地址超出了PLC型号的允许范围。比如CP1H的D区只有32768个字你读D40000就会触发这个错。解决方法是查一下PLC的数据区范围把读取范围限制在有效地址之内。另一个容易被忽略的原因是单次读取的字数超过上限比如一次读200个字不少PLC直接拒绝。5.4 现象程序运行几小时后突然卡死之后再也不读取通信程序最常见的心跳卡死就是ReadTimeout没有设置或者设置得太长。现场网络偶尔抖动一次Socket读取会一直等着看起来就是整个程序“冻住”了。我在基类里把读写超时都设为3000毫秒读不到数据就抛异常进入重连逻辑程序就不会死。设置超时后要注意异常处理顺序先释放旧连接再重新建立连接别在同一个Socket上无脑重试。5.5 现象上位机重启后PLC通信要等几分钟才能恢复PLC作为服务端当上位机突然断电或软件崩溃时TCP连接可能处于半开状态PLC侧不知道自己已经失联。重新连接时PLC还在等旧连接的超时释放。解决方法是把轮询和心跳做成程序启动后主动重连并且在PLC侧把连接确认时间调短如果都没有效果可以在中断恢复时先手动触发一次PLC的网络复位。这个坑在很多升级改造项目中特别容易遇到原因是老设备保留着很多历史连接。注意遇到FINS错误码时先别急着改代码。用日志把“指令发出时间、原始请求帧、响应结束码”都记录下来往往能直接看出是哪一类问题省得反复试错。6. 用仿真器做回归把通信代码变成长期可靠的通信服务看过现场排坑还要有一个能长期维持质量的习惯用仿真环境做回归验证。没有真机的时候欧姆龙的CX-Simulator或Sysmac Studio自带的模拟功能能提供虚拟PLC它支持FINS/TCP连接地址通常指向本机的仿真映射端口。你只需要把FinsTcpClient的IP指向本机仿真地址节点号按要求改一下就能把整个读写流程在办公室跑起来。逻辑层关键在收发帧的日志记录。在SendFinsCommand里把请求帧和响应帧都转成十六进制字符串打印出来private void LogFrame(string tag, byte[] frame) { var sb new StringBuilder(); foreach (var b in frame) sb.Append(b.ToString(X2)).Append( ); Console.WriteLine(${tag}: {sb}); }调用位置放在发送结束后和读取结束后各打一次。别小看这个习惯很多时候你以为程序逻辑写错了把日志翻出来一看其实是第二次发送时SID没有递增或响应的字节序跟预期不符。一个可靠的通信服务先要有可靠的观测能力代码只是让它跑起来的手段。自动化冒烟测试也是个值得投入的方向。用脚本连续执行100次“连接、读D100、写D100、断开”的操作中途不允许出现卡死和异常退出。这个过程能逼出很多偶发问题比如Socket释放不彻底、序号溢出在高频调用时可能被忽略但跑满100轮就藏不住了。每次发版前跑一遍长期下来你会发现自己对通信代码的信心明显不一样。我现在的习惯是办公室里放一台仿真PLC常开任何改动先在仿真环境连续跑一个周末确认没有异常后再上真机。通信程序出问题的时间成本太高要么白天产线停线要么半夜被人打电话叫醒这两样我都不太想再体验了。希望帮到你。本文还有配套的精品资源点击获取