简介:本资源是一个基于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#读写欧姆龙PLC(Fins-TCP,UDP).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个字节:
| 偏移 | 字节数 | 内容 | 说明 |
|---|---|---|---|
| 0 | 4 | FINS | 固定ASCII字符串,0x46 0x49 0x4E 0x53 |
| 4 | 4 | 长度 | 后面FINS帧的字节数,大端排列 |
也就是说,你实际通过TCP发送的内容 =FINS+ 4字节长度 + FINS帧。接收响应时也要先读8字节,解析出长度,再读取指定长度的FINS帧。如果直接用UDP,就不要这个8字节前缀,直接把FINS帧发到9600端口。
2.2 FINS命令帧的固定头部
FINS帧前面有10个字节的固定头部,作用是告诉PLC“我是谁、我要发给谁”:
| 字节 | 名称 | 常用值 |
|---|---|---|
| ICF | 帧类型信息 | 0x80表示命令帧 |
| RSV | 保留 | 0x00 |
| GCT | 网关数 | 0x02 |
| DNA | 目标网络号 | 0x00 |
| DA1 | 目标节点号 | PLC的FINS节点号 |
| DA2 | 目标单元号 | 0x00 |
| SNA | 源网络号 | 0x00 |
| SA1 | 源节点号 | 上位机节点号,不要和PLC冲突 |
| SA2 | 源单元号 | 0x00 |
| SID | 会话ID | 每次请求递增,用来匹配响应 |
这里最关键的三个值:DA1是PLC节点号,SA1是上位机节点号,SID是请求标识。项目里有个经典问题就是上位机节点号设成了和PLC一样,导致PLC直接丢弃请求。
2.3 读写命令的数据区设计
固定头部后面是2字节命令码:
01 01:Memory Area Read,读内存区01 02:Memory Area Write,写内存区
命令码后面是数据区。读命令的数据区格式为:
内存区代码(1字节) + 起始地址(2字节大端) + 读取个数(2字节大端)写命令的数据区格式为:
内存区代码(1字节) + 起始地址(2字节大端) + 写入个数(2字节大端) + 数据(2字节每个字,大端)以CP1H/CJ2M实测,常见的区域代码我用下面这张表:
| 区域 | 区域码 | 说明 |
|---|---|---|
| CIO | 0xB0 | 直接I/O区 |
| WR | 0xB1 | 工作继电器W区 |
| HR | 0xB2 | 保持继电器H区 |
| DM | 0x82 | 数据寄存器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 0A82:DM区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拼接数据,比List<byte>可读性更好。
3.4 超时与心跳不能省
现场环境不是干净的Loopback,TCP连接可能因为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 什么场景必须用TCP
UDP虽然轻量,但在高频读写时有一个致命问题:没有拥塞控制,也没有重传。现场网络里有交换机拥塞、偶发丢包,UDP请求丢了,你的上位机要自己负责重发;响应丢了,也同样要重发。如果你的程序在一次写配方时要连续写几百个D区字,中途丢一个响应,可能导致配方写到一半,这是很危险的。
所以我的结论很直接:凡是设备联动、配方下发、连续采集,统一走TCP。UDP我只在两种情况下用:一是快速验证通信链路,二是写一个不需要常驻连接的小工具。
5. 实测中容易踩的五个坑:节点号、字节序、地址别名与响应码
5.1 节点号冲突是连接失败的第一大原因
刚用FINS的人最容易忽略节点号。TCP层你已经能连上PLC的9600端口,但发命令过去没响应,或者PLC直接回错误码。这多半是SA1和DA1的关系没配对。每台PLC在以太网单元配置里都有一个FINS节点号,默认可能是1、2、3这些值。上位机的SA1必须和所有PLC的节点号错开。比如现场PLC节点号是1,你上位机节点号也填1,PLC会认为你在跟它说话,逻辑上完全冲突。
建议在项目配置里单独放一个“本机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程序里叫D100,FINS报文里叫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调到能稳定跑的,希望这份整理也能让你的上位机少走一段弯路。
本文还有配套的精品资源,点击获取