ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于C#的欧姆龙PLC FINS TCP通信协议详解与实现

2026/9/2 4:49:53 拓冰建站 浏览量
基于C#的欧姆龙PLC FINS TCP通信协议详解与实现 简介面向欧姆龙PLC工控开发人员这套C#示例工程演示了通过FINS TCP协议与NX102-9000 PLC进行稳定通信的完整过程。工程不仅包含实时监测网络断线、读取D0至D1000与W0至W500寄存器数据还提供将本机数据写入D11000至D12000寄存器的示例直接回应工业上位机与PLC数据交换、上传MES系统的典型需求。压缩包共32个文件大小仅84KB包含10个C#源码文件、3个可执行程序、3个配置文件、PDB调试符号以及界面资源文件等解决方案组织清晰可快速定位通信核心代码与界面逻辑。目前已有1788人学习下载适合刚开始接触欧姆龙FINS通信的工控工程师既能据此理解报文交互流程也能直接复用通信模板减少实际项目中的调试时间与踩坑成本。1. 为什么是C#和FINS TCP我的选型思路先交代一下背景。我手头有个项目需要把欧姆龙CP1H和NX系列PLC的数据实时读到上位机里用于产线状态监控。一开始摆在面前的选择其实不少可以用官方提供的FINS库也可以走OPC UA甚至直接用欧姆龙自带的CX-Server组件。但最终我选了C#自己写FINS TCP通信原因很现实上位机是WinForm团队最熟的语言就是C#而不想引入一堆商业授权组件也不想被第三方库的版本兼容问题绑住手脚。FINS是欧姆龙自己的通信协议全称是Factory Interface Network Service。它不光支持TCP还支持UDP、串口等多种载体。我在项目里使用的是FINS/TCP也就是基于TCP/IP传输的FINS协议。相比串口方式TCP方式最大的好处是速度和稳定性而且不需要关心串口占用和波特率匹配问题只要PLC侧开了以太网端口上位机通过网络就能直接读写。关键一点欧姆龙PLC的FINS TCP服务默认监听9600端口。这是固定端口不像Modbus TCP那样可以随意改。你连不上时第一件事就该检查是不是端口写错了。这篇文章适合谁看我觉得主要有三类人第一类是刚接触欧姆龙PLC通信的C#开发者想快速跑通一个最小demo第二类是已经在用第三方通信库但遇到问题想深入理解协议原理的人第三类是把通信模块做进正式项目需要处理异常、超时、多PLC连接的工程向开发人员。阅读本文后你能独立写一个不依赖任何第三方库的FINS TCP客户端并且知道怎么排查那些文档里不会写的坑。2. 上手前必须搞懂的FINS TCP帧结构不少人一开始就急着写代码结果报文格式不对被返回错误码折腾半天。我建议先花20分钟把FINS的帧结构看明白后面所有代码都是基于这个结构的理解了它你连官方文档都不用翻。2.1 FINS/TCP的请求帧组成一个完整的FINS TCP请求帧分两部分FINS/TCP帧头和FINS命令帧。FINS/TCP帧头固定10个字节结构如下字段长度说明FINS Header4字节固定为十六进制46494E53对应ASCII字符FINS命令码4字节通信命令常见00000000表示发送FINS命令帧00000001表示测试命令错误码4字节正常为00000000帧长度4字节后面FINS命令帧的字节长度等于10 命令数据长度FINS命令帧也分两部分FINS报文头和命令数据。FINS报文头共10个字节字段长度说明信息控制字段ICF1字节通常设为0x80表示需要响应系统保留RSV1字节固定0x00网关计数字段GCT1字节固定0x02表示允许经过两级网关目标网络号DNA1字节PLC在同一个网络内通常为0x00本地网络目标节点号DA11字节PLC的FINS节点号默认是0x01需确认目标单元号DA21字节PLC的CPU单元通常0x00源网络号SNA1字节通常0x00源节点号SA11字节上位机节点号自己定义一个即可如0x1117源单元号SA21字节通常0x00服务标识SID1字节0~255自增用于匹配请求与响应看到这一堆字段不用怕日常绝大多数场景下你只需要改两个值DA1是PLC的节点号SA1是上位机节点号。其他照抄标准值就能跑通。2.2 两类最常用的命令码我实际项目中最常用的是内存区读取命令码0101和内存区写入命令码0102。读取命令格式0101 内存区代码(1字节) 起始地址(2字节) 地址位(1字节) 读取次数(2字节)写入命令格式0102 内存区代码(1字节) 起始地址(2字节) 地址位(1字节) 写入次数(2字节) 写入数据这里有个新手特别容易搞混的概念地址位。它表示你操作的地址类型。如果是字地址比如DM区地址位就是0x00如果是位地址比如CIO区的某个bit地址位就是0x01。这个区分很关键因为读写线圈和读写寄存器用的命令数据长度完全不同。2.3 内存区代码与地址换算欧姆龙不同系列PLC内存区代码不通用。我项目里接触的是CP1H和NJ/NX系列常用代码整理如下内存区代码(十六进制)说明CIO区0x30读写整个字的输入输出区WR区工作区0x31仅部分系列支持HR区保持区0x32断电保持DM区0x82数据存储器EM区0xA0扩展数据存储器需指定bank直接访问CIO位0x30配合地址位0x01使用地址换算有一条铁律协议里的地址是十六进制字地址不是你在编程软件里看的那种十进制字地址。比如你想操作DM区的第100个字在软件里显示的是D100但协议里起始地址要填0x0064即十进制100的十六进制。很多新手直接把100换成十六进制还是100想当然地填了0x0100结果读出来的数据永远是错的。另外CIO区如果操作W0.05这样的位要从W0字地址的bit5去访问起始地址填0x0000位地址填0x05。具体规则建议查阅对应PLC的通信命令参考手册不同系列细节有差异。3. 从零搭建C#通信测试Demo的完整过程理论说再多不如代码跑一遍。下面我带着你从建项目开始把读取DM区数据的流程完整走一遍。开发环境我用的.NET Framework 4.7.2 Visual Studio 2019新版.NET 6/8也同样适用因为底层就是Socket没什么版本依赖。3.1 创建TcpClient并建立连接FINS TCP和普通Socket通信没有本质区别你完全可以用System.Net.Sockets.TcpClient。连接PLC时注意三点IP地址要填对端口填9600连接超时时间不能太长因为在生产环境PLC可能离线界面卡死是绝对不能容忍的。为了不卡界面我习惯用异步连接再等待using System.Net.Sockets; using System.Net; public static bool Connect(string ip, int port, int timeoutMs) { TcpClient client new TcpClient(); IAsyncResult result client.BeginConnect(IPAddress.Parse(ip), port, null, null); bool success result.AsyncWaitHandle.WaitOne(timeoutMs, false); if (!success) { client.Close(); throw new TimeoutException(连接PLC超时); } client.EndConnect(result); _client client; return true; }你可能会问为什么要用BeginConnect而不是直接Connect因为直接Connect在PLC不可达时会卡几十秒这在做上位机界面时是灾难。3.2 按FINS帧格式构造读取请求连接建立后下一步就是组装请求。这段代码我封装成了方法参数传起始地址和读取字数返回读取到的寄存器列表。public byte[] BuildReadDmCommand(int startAddr, int wordCount) { // FINS/TCP header Listbyte frame new Listbyte(); frame.AddRange(new byte[] { 0x46, 0x49, 0x4E, 0x53 }); // FINS frame.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); // 正常命令 frame.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); // 错误码 // 先留出4字节长度位后面再填 frame.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); // FINS command header frame.Add(0x80); // ICF frame.Add(0x00); // RSV frame.Add(0x02); // GCT frame.Add(0x00); // DNA 目标网络 frame.Add(0x01); // DA1 目标节点号根据PLC配置修改 frame.Add(0x00); // DA2 目标单元号 frame.Add(0x00); // SNA 源网络 frame.Add(0x11); // SA1 源节点号 frame.Add(0x00); // SA2 源单元号 frame.Add(0x01); // SID 服务标识可自增 // FINS command frame.Add(0x01); // 命令码高位 frame.Add(0x01); // 命令码低位 (0101 读内存) frame.Add(0x82); // DM区代码 frame.Add((byte)(startAddr 8)); // 起始地址高字节 frame.Add((byte)(startAddr 0xFF)); // 起始地址低字节 frame.Add(0x00); // 地址位 frame.Add((byte)(wordCount 8)); // 读取字数高字节 frame.Add((byte)(wordCount 0xFF)); // 读取字数低字节 // 填帧长度之后所有字节数 int len frame.Count - 8 - 4; // 8是前面固定头命令错误码4是长度字段本身 byte[] lenBytes BitConverter.GetBytes(len); if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes); frame[8] lenBytes[0]; frame[9] lenBytes[1]; frame[10] lenBytes[2]; frame[11] lenBytes[3]; return frame.ToArray(); }这里最容易被坑的就是大端还是小端。FINS协议规定所有多字节数据都要按大端高位在前传输而C#的BitConverter在Windows上默认是小端。所以帧长度、起始地址、字数这些字段都必须手动把高位放前面。3.3 发送请求并解析响应发送时要注意FINS/TCP的请求和响应没有像Modbus那样的严格的帧结束标志你必须依靠帧头里的长度字段来确定要读多少字节。接收时我习惯先接收12字节FINS/TCP头从第8到11字节取出后续命令帧长度然后继续接收剩余字节。public byte[] ReadDm(int startAddr, int wordCount) { byte[] cmd BuildReadDmCommand(startAddr, wordCount); NetworkStream stream _client.GetStream(); stream.Write(cmd, 0, cmd.Length); // 接收FINS/TCP头 byte[] header new byte[12]; int read 0; while (read 12) { int n stream.Read(header, read, 12 - read); if (n 0) throw new Exception(PLC连接已断开); read n; } // 检查FINS头 if (header[0] ! 0x46 || header[1] ! 0x49 || header[2] ! 0x4E || header[3] ! 0x53) throw new Exception(响应帧头错误); int dataLen (header[8] 24) | (header[9] 16) | (header[10] 8) | header[11]; // 接收FINS响应帧 byte[] fins new byte[dataLen]; read 0; while (read dataLen) { int n stream.Read(fins, read, dataLen - read); if (n 0) throw new Exception(PLC连接已断开); read n; } // 检查FINS命令错误码 int errCode (fins[4] 8) | fins[5]; if (errCode ! 0) throw new Exception($PLC返回错误码 0x{errCode:X4}); // 从fins[6]开始是数据区 byte[] data new byte[dataLen - 6]; Array.Copy(fins, 6, data, 0, data.Length); return data; }注意fins[4]和fins[5]是FINS响应报文头里的命令响应标志和结束码其中结束码两字节代表通信是否成功。0x0000表示正常非0值时一定要把它转成十六进制去查手册。3.4 解析读取到的数据读取DM区返回的字节序也是大端即一个字占两字节高字节在前、低字节在后。要转成C#的ushort或short需要手动转换public static ushort[] ToUshortArray(byte[] data) { ushort[] result new ushort[data.Length / 2]; for (int i 0; i result.Length; i) { result[i] (ushort)((data[i * 2] 8) | data[i * 2 1]); } return result; }如果你要读的是浮点数欧姆龙里默认是两个连续字存一个32位浮点数字顺序是低位字在前高位字在后且每个字内部大端。这又是一个容易晕的点。我建议遇到浮点数时先读回字节再手动拼接例如地址D100和D101存一个FLOAT。字节流可能是: D100高字节、D100低字节、D101高字节、D101低字节。按小端组float时要组合成D101低字节、D101高字节、D100低字节、D100高字节。这个顺序不同PLC系列可能不一致最好拿已知数值验证一下。4. 实测踩坑那些协议文档不会告诉你的细节按上面的Demo跑通不代表万事大吉联调时我踩过不少坑有些坑会让你怀疑人生。这里挑几个影响最大的分享。4.1 连接超时后Socket无法立即重连第一次写完代码我发现PLC断电重启后程序一直报连接超时但等了很久再次连接才成功。原因是我每次连接失败直接Close()但TCP连接并未真正释放底层TIME_WAIT状态占用了端口导致快速重连时被系统拒绝。解决方法是重连前调用client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)并把LingerState设置成主动关闭_client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _client.Client.LingerState new LingerOption(true, 0);这样断线后能快速重建连接。另外建议应用层做重试机制断线后间隔1秒、2秒、4秒递增重试不要狂点。4.2 FINS响应里的错误码到底在说什么用ReadDm方法时如果地址超出范围PLC会返回错误码而不是断开连接。我遇到最多次的是0x1101内存区不存在和0x1103地址超出范围。下面是我整理过的一些高频错误码错误码含义常见原因0x0000正常无0x0101节点号错误目标节点号DA1超出PLC配置范围0x0103目标节点不存在PLC节点号配错或未登录0x1101内存区不存在当前PLC型号不支持该内存区代码0x1103地址越界起始地址读取字数超过上限0x2002数据长度错误发送的字节数和帧长度不一致0x2101命令格式错误命令码或参数长度不对出现错误码时别急着重查网络先检查PLC实际型号的内存范围。比如CP1H的DM区最大地址是D32767十六进制0x7FFF你如果从D32760读10个字就会越界。4.3 PLC的FINS节点号不是IP地址很多刚接触的人会把PLC的IP地址和FINS节点号搞混。在FINS协议中TCP连接本身用IP地址定位但应用层寻址用的是FINS节点号DA1。这个节点号在PLC的“以太网设置”里单独配置默认可能是1也可能被改成其他值。如果你用默认节点号1连不上打开CX-Programmer或Sysmac Studio在“节点设置”里确认一下实际值。4.4 数据长度字段少算了4个字节我的第一个版本构建帧时长度字段把帧头12字节也算了进去结果PLC一直不响应。后来对照手册发现FINS/TCP头里的长度字段是指“后续FINS命令帧的字节数”不包含前12字节的FINS/TCP头本身。这个坑在官方示例代码里也不容易看出来我算错后抓包才明白。建议调试时用Wireshark抓包看下报文如果你看到PLC回了一个TCP ACK但没有任何数据那基本都是请求帧长度算错了。5. 从联调到落地工程化改造的几个关键点Demo能跑通了但要放到正式项目里还有一堆问题要处理。我把自己项目里沉淀下来的经验列出来你可以按需使用。5.1 通信类封装成线程安全的单例上位机界面可能多个窗口同时读写PLC如果不做同步NetworkStream并发写会直接抛异常。我的做法是定义一个PlcFinsClient类内部用一个SemaphoreSlim控制每次只能有一个读写操作在执行private SemaphoreSlim _semaphore new SemaphoreSlim(1, 1); public async Taskbyte[] ReadDmAsync(int startAddr, int wordCount) { await _semaphore.WaitAsync(); try { // 发送和接收 ... } finally { _semaphore.Release(); } }同时把连接、断开、重连都封装成方法界面上只关心业务数据不直接看到Socket细节。5.2 加入心跳和自动重连机制PLC和上位机之间如果长时间没有通信中间交换机可能会断开空闲TCP连接。这时你再去读写就会异常。我采用的方案是如果超过3秒没有业务通信就发送一个测试命令FINS/TCP命令码0x00000001附带4字节测试数据来维持连接活性。测试命令帧很短和正常FINS命令一样带上10字节FINS/TCP头即可。只要PLC有响应就说明链路还在。如果连续三次心跳无响应就触发重连逻辑。5.3 日志、数据缓存与告警正式项目里通信日志尤其重要。我之前遇到一个偶发性的读数据超时排查了很久最后靠日志发现是某段时间内上位机同时重启了多个通信线程导致并发读写冲突。建议把每次请求的时间、目标地址、返回数据、耗时都写进日志出了问题能快速回溯。另外不要每次刷新界面都去读PLC那样压力很大。我一般用定时器每100ms读一次需要展示的数据中间加一个缓存层。只有缓存里数据变化时才通知界面更新这样能大幅减少UI线程的调用频率。5.4 多PLC并发连接如果项目里有多个欧姆龙PLC你需要为每台PLC创建独立的TcpClient实例并且用IP作为Key保存到字典里。每台PLC的FINS节点号可能相同但这不影响因为TCP连接已经通过IP区分开了。只需要注意连接池不要断断续续建立销毁连接保持长连接是效率最高的方案。我还遇到过交换机端口不够或网线松动导致PLC频繁掉线的情况这不是代码能解决的建议上位机界面上加一个通信状态指示把每个PLC的状态显示成绿灯/红灯这样现场维护人员能第一时间发现问题。最后分享的一点经验我做了这么多年上位机最大体会是工业通信协议的难点不在写代码而在出问题时怎么定位。FINS TCP的报文格式并不复杂但如果你不抓包、不读错误码、不查PLC内部设置光靠猜很难找到原因。开始项目前先把PLC的型号、节点号、内存区范围、字地址换算这几项确认清楚能省下一大半排查时间。调试时我习惯用串口服务器或者虚拟网卡模拟PLC端响应先用固定的十六进制报文做回环测试确认上位机收发逻辑没问题之后再连真机效率会高很多。如果你刚开始接触建议照着文中的Demo一步步跑通然后试着去读写一个实际的DM区地址把地址换算、大小端、响应解析这些基础操作练到条件反射的程度后面做再复杂的项目也不会慌。本文还有配套的精品资源点击获取