
简介本资源是一个基于C#实现欧姆龙PLC通信的工业自动化开发示例面向自动化工程师、工控系统开发者及.NET学习者解决C#环境下通过FINS协议与欧姆龙PLC进行TCP/UDP双向数据交互的实际工程问题。压缩包共82个文件含14个核心C#源码如OmronPlc_UDP.cs、OmromPlcHelper.cs、Form1.cs等、14个编译生成的DLL含HslCommunication和Newtonsoft.Json依赖库、14个XML配置与文档文件以及EXE可执行程序、WinForm界面资源.resx、项目配置.config和解决方案文件.sln整体大小为13.65MB结构完整支持开箱即用与二次开发。已有504人学习下载提供从设备连接、FINS会话建立、寄存器读写到GUI状态反馈的全流程实现代码注释清晰涵盖错误处理与网络资源释放机制是理解工业协议封装、.NET网络编程与PLC集成实践的优质入门参考。 前几天收拾硬盘翻出一个压箱底的工程C#读写欧姆龙PLCFins-TCPUDP.7z。这包是两三年前做产线数据采集时留下的当时现场十几台欧姆龙CP1H、CJ2M上位机要实时读温度、写配方读写频率不低。后来不少朋友找我要这套代码干脆把报文解析、TCP和UDP两个Demo、常见踩坑笔记一起打包。今天把里面的核心内容整理成文给正在做C#上位机、刚接触欧姆龙PLC通信的朋友一个尽量少走弯路的参考。先说明一下文章定位不是照抄官方手册而是从“我要用一个C#程序把PLC里的D区数据读出来再把配方写进去”这个需求出发把FINS协议怎么拆、代码怎么写、坑在哪讲清楚。就算你用的不是CP1H而是CJ2M、NJ/NX只要PLC支持FINS以太网这套思路也一样适用。1. 为什么我选FINS而不是HostLink通信选型的现实考量1.1 HostLink是串口时代的产物欧姆龙PLC早期最常用的上位机通信协议是HostLink它承载在串口上命令走ASCII字符。比如读D100你要拼一条RD 地址 校验码的指令上位机和PLC之间一问一答速度受波特率限制而且基本是主从模式。HostLink最大的问题不是不能用而是“割裂”地址要转ASCII、数据要转ASCII、错误码处理也偏麻烦。FINS则不一样。FINS是欧姆龙面向控制网络的通信协议全称Factory Interface Network Service可以跑在串口、以太网、Controller Link等链路上。跑在以太网上时FINS报文直接承载在TCP或UDP里报文内容是二进制的读一个D字、写一串W区、操作CIO位都有统一的命令结构。用惯了之后再看HostLink就像放着网线不用非要回去拨号。1.2 FINS-TCP和FINS-UDP的取舍FINS-UDP是最简单粗暴的方式不需要建立连接把你的FINS命令帧直接扔到PLC的9600端口PLC处理完以后回一张UDP报文一次请求一次响应就这样。它无状态、开销小特别适合写个小工具临时读几个地址或者做广播搜索PLC节点。FINS-TCP则是先建立TCP连接再在连接上收发FINS报文。TCP能保证报文顺序和重传长时间连续采集、大量读写时不容易丢指令。代价是你得维护连接、处理断线重连还要在FINS帧前面加一个4字节FINS标志和4字节长度。正式设备我全用TCP调试工具里保留UDP两者报文主体完全一样只是多了个前缀和连接状态。1.3 到底选哪个按实际场景做决定我的选择逻辑很简单上位机要和PLC长期保持连接、周期采集选TCP。只需临时读写一条数据、又不希望PLC侧一直占着一个Socket选UDP。需要先探索PLC IP、节点号的工具优先UDP广播。项目里有多个PLC且通信频繁TCP每个PLC维护一个连接比UDP反复发报文更可控。如果你的PLC型号比较老或者现场网络环境很差优先测试TCP如果只是做一个监控页面几秒刷一次UDP完全够用。这个选择不该靠“哪个好用”拍脑袋而是看你的数据流是否要求可靠。2. FINS报文结构从帧头到数据区的一次完整拆解很多初学者卡在读写PLC的第一步不是不会用Socket而是看不懂报文。FINS报文说复杂也复杂但拆开看就是“固定头 命令码 数据区”三段式。2.1 FINS/TCP的八字节前缀FINS/TCP不是直接把FINS帧塞进TCP流它前面还有8个字节偏移字节数内容说明04FINS固定ASCII字符串0x46 0x49 0x4E 0x5344长度后面FINS帧的字节数大端排列也就是说你实际通过TCP发送的内容 FINS 4字节长度 FINS帧。接收响应时也要先读8字节解析出长度再读取指定长度的FINS帧。如果直接用UDP就不要这个8字节前缀直接把FINS帧发到9600端口。2.2 FINS命令帧的固定头部FINS帧前面有10个字节的固定头部作用是告诉PLC“我是谁、我要发给谁”字节名称常用值ICF帧类型信息0x80表示命令帧RSV保留0x00GCT网关数0x02DNA目标网络号0x00DA1目标节点号PLC的FINS节点号DA2目标单元号0x00SNA源网络号0x00SA1源节点号上位机节点号不要和PLC冲突SA2源单元号0x00SID会话ID每次请求递增用来匹配响应这里最关键的三个值DA1是PLC节点号SA1是上位机节点号SID是请求标识。项目里有个经典问题就是上位机节点号设成了和PLC一样导致PLC直接丢弃请求。2.3 读写命令的数据区设计固定头部后面是2字节命令码01 01Memory Area Read读内存区01 02Memory Area Write写内存区命令码后面是数据区。读命令的数据区格式为内存区代码(1字节) 起始地址(2字节大端) 读取个数(2字节大端)写命令的数据区格式为内存区代码(1字节) 起始地址(2字节大端) 写入个数(2字节大端) 数据(2字节每个字大端)以CP1H/CJ2M实测常见的区域代码我用下面这张表区域区域码说明CIO0xB0直接I/O区WR0xB1工作继电器W区HR0xB2保持继电器H区DM0x82数据寄存器D区注意DM区虽然是D区但区域码是0x82不是0xB3。不同CPU型号的手册可能会写不同的区域码NJ/NX单元上尤其是先查官方手册再填。2.4 一个完整的读D100命令假设我要用TCP读PLC的D100到D109共10个字。第一步构造FINS帧80 00 02 00 DA1 00 00 SA1 00 SID 01 01 82 00 64 00 0A82DM区00 64起始地址100也就是D10000 0A读取10个字第二步在前面加上FINS和长度得到最终的TCP字节流。应答帧的格式是固定头 FINS头 命令码 完成码 数据。完成码是2字节0x0000表示成功非零就是错误。成功时后面跟着的就是读取到的10个字每个字占2字节大端排列。这一步搞明白之后写代码就是体力活了。3. 用C#实现Fins-TCP读写一个可直接改用的客户端类3.1 先搭一个FINS帧构造函数我不建议在调用处手写每一个字节而是写一个基类方法把固定头部和长度前缀封装好。下面是示例代码的核心片段我按VS2019 .NET Framework 4.7.2写的如果你用.NET Core/.NET 5把TcpClient换成Socket也一样。public class OmronFinsTcp { private TcpClient _tcpClient; private NetworkStream _stream; private byte _plcNode 0x64; // 目标PLC节点号 private byte _localNode 0x00; // 上位机节点号需避开PLC private byte _sid 0x00; // 会话ID public void Connect(string ip, int port 9600) { _tcpClient new TcpClient(); _tcpClient.Connect(ip, port); _stream _tcpClient.GetStream(); } public void Close() { _stream?.Close(); _tcpClient?.Close(); } private byte[] BuildFinsFrame(byte commandHigh, byte commandLow, byte[] body) { byte[] frame new byte[10 2 body.Length]; frame[0] 0x80; // ICF frame[1] 0x00; // RSV frame[2] 0x02; // GCT frame[3] 0x00; // DNA frame[4] _plcNode; // DA1 frame[5] 0x00; // DA2 frame[6] 0x00; // SNA frame[7] _localNode; // SA1 frame[8] 0x00; // SA2 frame[9] _sid; // SID frame[10] commandHigh; frame[11] commandLow; Buffer.BlockCopy(body, 0, frame, 12, body.Length); return frame; } private byte[] BuildTcpFrame(byte[] finsFrame) { byte[] tcpFrame new byte[8 finsFrame.Length]; tcpFrame[0] (byte)F; tcpFrame[1] (byte)I; tcpFrame[2] (byte)N; tcpFrame[3] (byte)S; int length finsFrame.Length; tcpFrame[4] (byte)(length 24); tcpFrame[5] (byte)(length 16); tcpFrame[6] (byte)(length 8); tcpFrame[7] (byte)length; Buffer.BlockCopy(finsFrame, 0, tcpFrame, 8, finsFrame.Length); return tcpFrame; } }这个类已经能覆盖大部分FINS/TCP通信了。_sid每次加1是为了把请求和响应配对。虽然PLC并不强制检查SID但调试时能省很多事。3.2 读取DM区连续字的完整实现读D区是最高频操作我封装成ReadDmWords。核心思路构造读命令的数据区发送TCP帧再读取完整响应最后解析成ushort[]。public ushort[] ReadDmWords(int startAddress, int count) { if (startAddress 0 || count 0) throw new ArgumentException(参数不正确); byte[] body new byte[5]; body[0] 0x82; // DM区 body[1] (byte)(startAddress 8); body[2] (byte)(startAddress 0xFF); body[3] (byte)(count 8); body[4] (byte)(count 0xFF); byte[] request BuildTcpFrame(BuildFinsFrame(0x01, 0x01, body)); _stream.Write(request, 0, request.Length); byte[] response ReadTcpResponse(); int offset 8 10 2; // FINS前缀8 FINS头10 命令码2 int completionCode (response[offset] 8) | response[offset 1]; if (completionCode ! 0) throw new IOException($FINS错误: 0x{completionCode:X4}); int dataStart offset 2; ushort[] result new ushort[count]; for (int i 0; i count; i) { int index dataStart i * 2; result[i] (ushort)((response[index] 8) | response[index 1]); } return result; }ReadTcpResponse要注意TCP粘包和半包。不能想当然认为一次Read就能拿到整包响应必须先用8字节头解析长度再循环读够长度。我用一个简单方法private byte[] ReadTcpResponse() { byte[] header new byte[8]; int offset 0; while (offset 8) { int n _stream.Read(header, offset, 8 - offset); if (n 0) throw new IOException(连接已断开); offset n; } int length (header[4] 24) | (header[5] 16) | (header[6] 8) | header[7]; byte[] data new byte[length]; offset 0; while (offset length) { int n _stream.Read(data, offset, length - offset); if (n 0) throw new IOException(连接已断开); offset n; } return data; }读写操作在多线程环境下必须加锁否则两个线程同时写NetworkStream报文会互相穿插PLC根本解析不了。3.3 写入多字的代码写操作比读多一步要把写入的每个字拆成两个字节大端排列。下面是写入D100开始的连续字public void WriteDmWords(int startAddress, ushort[] values) { if (values null || values.Length 0) throw new ArgumentException(values不能为空); using (MemoryStream ms new MemoryStream()) { ms.WriteByte(0x82); // DM区 ms.WriteByte((byte)(startAddress 8)); ms.WriteByte((byte)(startAddress 0xFF)); ms.WriteByte((byte)(values.Length 8)); ms.WriteByte((byte)(values.Length 0xFF)); foreach (ushort value in values) { ms.WriteByte((byte)(value 8)); ms.WriteByte((byte)(value 0xFF)); } byte[] request BuildTcpFrame(BuildFinsFrame(0x01, 0x02, ms.ToArray())); _stream.Write(request, 0, request.Length); byte[] response ReadTcpResponse(); int offset 8 10 2; int completionCode (response[offset] 8) | response[offset 1]; if (completionCode ! 0) throw new IOException($FINS错误: 0x{completionCode:X4}); } }写操作的响应不需要返回数据只要检查完成码是0x0000就行。这里我用了MemoryStream拼接数据比Listbyte可读性更好。3.4 超时与心跳不能省现场环境不是干净的LoopbackTCP连接可能因为PLC重启、网线松动而断掉。建议在Connect时设置发送和接收超时_tcpClient.ReceiveTimeout 2000; _tcpClient.SendTimeout 2000;同时PLC不会永远为你保活TCP。有些欧姆龙PLC默认几十秒没有FINS请求就会断开连接。项目里我每隔5秒做一次心跳读读D0一个字读失败就尝试重连。心跳不只是保活还能让你第一时间发现PLC离线。4. Fins-UDP通信实现广播搜索和单播读写的差异4.1 UDP模式没有“连接”这个概念UDP版本的FINS帧和TCP版本除了少8字节前缀数据部分完全一致。因为无连接你不需要调用Connect只需要用UdpClient把报文发到PLC的IP和9600端口然后等着收响应。UDP适合做广播探索往255.255.255.255:9600发一条特殊的FINS请求网络里的欧姆龙PLC可能会回你它的节点号和型号。但这个功能在不同PLC固件上的表现不完全一致实际项目中我更习惯先用CX-Programmer或者PLC自带的网络配置工具把节点号查好再写死到配置里。搜索功能可以当作调试玩具别全指望它。4.2 UDP读D区的代码骨架如果你只是想快速验证PLC地址对不对UDP版比TCP更轻量。下面是一个典型的ReadDmWordsUDP实现using System.Net.Sockets; public static ushort[] ReadDmWordsByUdp(string ip, int startAddress, int count, byte plcNode 0x64, byte localNode 0x00) { byte[] body new byte[5]; body[0] 0x82; body[1] (byte)(startAddress 8); body[2] (byte)(startAddress 0xFF); body[3] (byte)(count 8); body[4] (byte)(count 0xFF); byte[] finsFrame BuildFinsFrame(0x01, 0x01, body, plcNode, localNode); using (UdpClient udp new UdpClient()) { udp.Client.ReceiveTimeout 2000; udp.Connect(ip, 9600); udp.Send(finsFrame, finsFrame.Length); IPEndPoint remote null; byte[] response udp.Receive(ref remote); // 解析逻辑和TCP一样只是没有8字节前缀 int offset 10 2; int completionCode (response[offset] 8) | response[offset 1]; if (completionCode ! 0) throw new IOException($FINS错误: 0x{completionCode:X4}); int dataStart offset 2; ushort[] result new ushort[count]; for (int i 0; i count; i) { int index dataStart i * 2; result[i] (ushort)((response[index] 8) | response[index 1]); } return result; } }这里BuildFinsFrame多传了plcNode和localNode是因为UDP无法像TCP那样先连接再确认节点号只能通过参数显式指定。UDP响应也可能乱序甚至丢包严格场景下要给每条命令带递增SID并做超时重发。4.3 什么场景必须用TCPUDP虽然轻量但在高频读写时有一个致命问题没有拥塞控制也没有重传。现场网络里有交换机拥塞、偶发丢包UDP请求丢了你的上位机要自己负责重发响应丢了也同样要重发。如果你的程序在一次写配方时要连续写几百个D区字中途丢一个响应可能导致配方写到一半这是很危险的。所以我的结论很直接凡是设备联动、配方下发、连续采集统一走TCP。UDP我只在两种情况下用一是快速验证通信链路二是写一个不需要常驻连接的小工具。5. 实测中容易踩的五个坑节点号、字节序、地址别名与响应码5.1 节点号冲突是连接失败的第一大原因刚用FINS的人最容易忽略节点号。TCP层你已经能连上PLC的9600端口但发命令过去没响应或者PLC直接回错误码。这多半是SA1和DA1的关系没配对。每台PLC在以太网单元配置里都有一个FINS节点号默认可能是1、2、3这些值。上位机的SA1必须和所有PLC的节点号错开。比如现场PLC节点号是1你上位机节点号也填1PLC会认为你在跟它说话逻辑上完全冲突。建议在项目配置里单独放一个“本机FINS节点号”字段默认给个0x10或0x20避开常用的1-10。5.2 字节序一半的错误都出在这里FINS协议里所有多字节数值都是大端序这和C#里默认的BitConverter小端刚好相反。很多新手写BitConverter.GetBytes(100)直接塞进报文结果PLC收到的地址变成了64 00而不是00 64。我的处理方式是不用BitConverter所有拼包都手动移位byte high (byte)(value 8); byte low (byte)(value 0xFF);解析响应时同样手动拼ushort value (ushort)((data[i] 8) | data[i 1]);这个习惯能避掉一半的通信问题。5.3 地址别名D区、DM区、CIO和W区别搞混PLC程序里叫D100FINS报文里叫DM区区域码是0x82起始地址是100。这没问题。但CIO区和W区在CP1H上不是同一个东西CIO是直接I/O映射区W区是内部工作继电器两者地址重叠但物理意义不同。如果你要从PLC读一组中间变量先确认它到底是W区还是CIO区再选区域码。还有一点位地址和字地址的处理方式不一样。FINS读写命令里字地址就是2字节地址位地址可能需要额外指定位号。本文示例都是按字处理的如果你要操作CIO0.01这种位不能简单把地址换算成0.01得查官方FINS位地址表格。5.4 FINS响应码常见清单响应里最常见的错误码我整理成一个速查表遇到直接对照完成码含义现场常见原因0x0000正常无0x0101本地节点错误报文头ICF/GCT错误0x0201目标节点错误目标节点号超出范围0x1101目标节点不存在PLC节点号填错或PLC侧连接未开放0x2003存储区域规格错误区域码填错0x2004指定地址不存在地址超出PLC实际范围0x2103访问权限异常PLC程序里对地址做了保护当收到0x1101时别急着改代码先去PLC的网络设置里看节点号是不是真的对。很多时候是PLC端节点号是10进制显示你在代码里却填了16进制一个0x64就变成了100。5.5 TCP空闲断开与自动重连TCP连接建立后如果长时间不发数据PLC侧可能的超时机制会掐掉连接。你不能等到下次读写时报错才处理因为现场采集进程可能刚好在等一条心跳响应一旦连不上整条生产线都停了。我的做法是做一个专门的看门狗线程private void KeepAliveLoop() { while (_running) { try { ReadDmWords(0, 1); } catch { Reconnect(); } Thread.Sleep(5000); } }Reconnect里先Close再Connect并把上次未完成的操作标记为失败。这个循环看起来简单但能实实在在扛住PLC断电重启。6. 把这套代码封装成通用库多PLC连接与线程安全6.1 抽象出FinsClient基类当你同时对接5台PLC时直接new五个OmronFinsTcp对象会失控。我的做法是先提取接口public interface IFinsClient { void Connect(string ip, int port 9600); void Close(); ushort[] ReadDmWords(int startAddress, int count); void WriteDmWords(int startAddress, ushort[] values); }TCP和UDP各自实现这个接口上层业务只依赖接口。每台PLC对应一个客户端实例实例内部自带锁、心跳和重连逻辑。配置文件里管理PLC的名称、IP、节点号、区域映射这样换设备时不用改代码。这个封装的好处是测TCP模式时同一个读写函数在UDP实现里也能跑现场某台PLC只允许UDP就把创建工厂的类换一下上层业务完全无感。6.2 和Modbus TCP、OPC UA的边界怎么划有些欧姆龙PLC硬件上也能配置成Modbus TCP从站或者你可以在PC上跑OPC UA服务器。那为什么还要自己写FINS从架构上看FINS是欧姆龙原生协议能最完整地访问PLC的CIO、WR、HR、DM、Timer、Counter等全量地址CPU占用和延迟都比Modbus中转要低。Modbus TCP虽然通用但很多PLC地址需要手动映射后期维护太痛苦。如果你们的设备已经跑了一套OPC UA统一数据平台那优先接入OPC UA不要重复造轮子。FINS这套类库更适合做嵌入式上位机、单机设备、或不想依赖大型中间件的场景。6.3 建议再补上的功能我在正式项目里还会加两样东西日志和自动化测试。日志记录每次报文的原始字节方便出问题时用Wireshark对照。自动化测试用一个模拟PLC的TCP服务端把FINS请求接进来返回固定数据确保通信类库改动不破坏上层逻辑。如果要把这套代码真正发布还要注意释放资源TcpClient和NetworkStream都要在Dispose里关掉心跳线程要用CancellationToken退出不能直接用Thread.Abort。最后再分享一条调试建议拿到PLC先别急着写代码。用串口调试助手或Socket工具连上PLC的9600端口手动发一条FINS前缀的读D0命令看PLC能不能回0x0000。先把这条命令调通再搬进C#代码里能省掉大量“代码看起来对但就是不工作”的排查时间。我当时就是这么一步步把压缩包里的Demo调到能稳定跑的希望这份整理也能让你的上位机少走一段弯路。本文还有配套的精品资源点击获取