尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C#实现欧姆龙PLC通信:Fins-TCP/UDP协议解析与实战

C#实现欧姆龙PLC通信:Fins-TCP/UDP协议解析与实战 简介本资源是一个基于C#实现欧姆龙PLC通信的完整工业自动化开发示例面向工业控制领域初学者与.NET开发者解决C#环境下通过FINS协议远程读写PLC数据的核心问题。项目支持TCP与UDP双模式通信涵盖连接管理、会话建立、寄存器读写、错误响应解析及WinForm可视化交互等关键环节适用于设备监控、产线数据采集等实际场景。压缩包共82个文件含14个核心C#源码如OmromPlc_UDP.cs、OmromPlcHelper.cs、14个DLL依赖含HslCommunication 11.5.2等工业通信库、14个XML配置与文档、以及exe可执行程序、sln解决方案和配套资源文件整体大小为13.65MB结构清晰便于模块化学习与二次开发。目前已有504人学习下载提供开箱即用的调试环境、完整的协议封装逻辑、GUI操作界面及典型FINS报文构造范例是理解工业协议与.NET网络编程融合实践的优质入门参考。 做上位机、搞数据采集的八成绕不过欧姆龙PLC。我在好几个项目里都需要用C#读写欧姆龙PLC的数据通信协议选的就是Fins-TCP和UDP。接触多了以后干脆把常用的通信功能抽成了一个独立的库也就是压缩包里的这套源码。今天把整个东西从设计到实现、从源码到实战完整捋一遍包括报文格式、连接流程、踩坑记录希望能帮你少走弯路。先说这库能干什么通过以太网对欧姆龙PLC的DM区、CIO区、WR区、HR区做字读写、位读写支持Fins-TCP和Fins-UDP两种通道拿到手可以直接在WinForm、WPF、控制台里用。适合的场景包括产线数据采集、设备参数下发、MES对接、简单的SCADA点位控制。如果你正准备用C#和欧姆龙PLC做通信或者已经写了但老是超时、丢包、数据错位这篇文章可以直接拿来参考对照。1. 项目概述与整体设计思路1.1 解决什么问题、为什么要自己写最开始接项目时我用的还是网上流传的老代码抄来抄去问题一大堆有的是只能读DM区、写CIO就报错有的是TCP握手都过不去还有的代码连粘包都不处理通信一频繁就乱码。后来换过收费的通信库功能确实全但一个授权好几个项目费用而且底层封装得太黑盒出了问题不好排查。自己写这套库的核心动机其实就两个一是可控每一个字节都清楚是什么出问题能自己抓包定位二是干净不依赖第三方组件纯Socket实现拷几个文件就能用。对于搞上位机开发的来说通信协议这种东西一旦吃透了后面换PLC型号、加功能都是顺手的。设计目标也很明确支持Fins-TCP和Fins-UDP两种方式接口尽量统一业务层不感知底层差异提供字读写和位读写覆盖DM、CIO、WR、HR等常用区域处理粘包拆包、超时重发、断线检测这东西看着基础但大部分通信问题都出在这保持单文件类库风格包含注释和Demo方便二次开发。1.2 TCP和UDP两个版本怎么选很多刚开始接触的人会纠结这个问题。Fins-TCP和Fins-UDP差异不只在传输层使用体验上也有明显区别。Fins-TCP需要先建立TCP连接然后做一次“节点地址发送”握手告诉PLC上位机的FINS节点号。之后每次读写数据都要包一层FINS/TCP帧。它的优势是TCP本身有确认、重传、排序机制局域网环境下基本不会丢数据适合轮询点位多、数据量大、要求完整的场景。Fins-UDP就简单很多不需要握手直接把FINS命令帧扔给PLC的9600端口PLC处理后原路返回结果。它的优势是单次交互延迟更低、代码更简单没有连接维护。但UDP不可靠极端情况下会丢包所以需要自己做超时重发。我的实际选型经验是场景推荐方式原因点位多、轮询密度高、数据不能丢Fins-TCP可靠传输不用自己处理重传单点读取、指令控制、响应要求快Fins-UDP延迟低简单直接无线网络、跨交换机、链路不稳定Fins-TCPTCP重传机制扛得住嵌入式上位机、资源受限Fins-UDP无连接状态少内存占用小有个项目我在一个车间里头同时用了两种设备实时状态用UDP快速轮询工艺参数下发走TCP确保不丢。这样做的好处是高频小数据量走UDP不占TCP连接资源关键下发改走TCP可靠传输。这个思路你也可以参考。2. Fins协议原理与报文结构解析2.1 FINS帧的10个字节头FINS协议是欧姆龙定义的应用层协议报文结构不复杂核心就是10个字节的帧头 命令码 数据区。这10个字节的作用我一个个解释清楚偏移字段说明0ICF信息控制字段0x80表示需要响应1RSV保留字段固定0x002GCT网关计数固定0x003DNA目标网络号通常0x004DA1目标节点号即PLC的FINS节点号5DA2目标单元号通常0x006SNA源网络号通常0x007SA1源节点号即上位机的FINS节点号8SA2源单元号通常0x009SID服务ID每次请求递增用来匹配请求和响应刚开始搞这个协议的时候我总觉得ICF像是固定写死的后来才明白ICF 0x80的意思是“这个帧需要对方响应”如果你发0x00PLC收到就不回你等着干瞪眼。帧头里最容易写错的就是DA1和SA1很多超时问题都是节点号填错导致的。2.2 读命令、写命令的报文模板FINS最常用的两个命令是0101读内存区域和0102写内存区域。读命令的完整报文是这样的ICF RSV GCT DNA DA1 DA2 SNA SA1 SA2 SID 01 01 内存区代码 起始地址(2字节) 位号 读取次数(2字节)举例用TCP方式读PLC的D100开始的10个字PLC节点号是10上位机节点号也是10。报文展开就是80 00 00 00 0A 00 00 0A 00 01 01 01 82 00 64 00 00 0A内存区代码0x82是DM区起始地址0x0064就是100位号0x00表示按字访问读取次数0x000A表示10个字写命令的报文要带上数据区... SID 01 02 内存区代码 起始地址(2字节) 位号 写入次数(2字节) 数据...写D100、D101两个字的值为0x1234和0x5678报文是80 00 00 00 0A 00 00 0A 00 02 01 02 82 00 64 00 00 02 12 34 56 78注意写入次数是2对应两个字的长度。有个容易踩的坑把写入次数写成数据字节数4PLC会直接报参数错误。写入次数和读取次数的单位是“点数”不是“字节数”。字访问时一个点是2字节位访问时一个点是1位。2.3 Fins-TCP比UDP多出来的封装层Fins-TCP不是简单地把FINS帧塞进TCP流里而是包了一层自己的帧格式FINS(4字节ASCII) 长度(4字节) 命令码(2字节) 错误码(2字节) 数据区连接建立后第一步是握手。上位机发一个命令码为0x0000的帧数据区带上自己的节点号PLC回复命令码0x0001表示握手成功。之后每次读写命令码都是0x0002PLC的响应命令码是0x0003。握手帧的实际发送内容大概是这样的节点号0x0A46 49 4E 53 00 00 00 05 00 00 00 00 0A46 49 4E 53 是ASCII码的FINS00 00 00 05 是后续字节长度00 00 是命令码握手请求00 00 是错误码0A 是上位机节点号如果你用的是老款PLC或者某些协议栈版本比较严格的设备握手不通过时可以尝试在节点号后面补3个0x00把数据区凑成4字节再发。这个我在CJ2M上遇到过补了就能过不补就一直超时。UDP方式没这层封装直接把FINS帧发给PLC的9600端口就行简单粗暴。但正因为没有握手PLC对你的节点号完全靠FINS帧头里的SA1来识别所以UDP模式下SA1必须填对否则PLC根本不知道是谁在跟它说话自然也不会回。3. C#核心代码实现3.1 工程结构与公共约定整个库我分成了两个核心类FinsTcpClient和FinsUdpClient另外加了一个FinsHelper静态类处理数据转换。工程结构如下FinsLibrary/ ├── FinsTcpClient.cs ├── FinsUdpClient.cs ├── FinsHelper.cs └── Demo/ ├── Program.cs └── FormMain.cs公共约定所有方法命名统一为ReadDm、WriteDm、ReadCiO、WriteCiO、ReadWr、ReadHr、ReadBit、WriteBit字读写返回short数组或byte[]原始数据位读写返回bool[]所有公共方法内部都带了锁多线程环境下可以直接调用不用额外同步。为什么要统一成这套命名因为PLC工程师和上位机开发之间沟通时说的就是“读D100”“写CIO0.01”方法名和现场叫法一一对应不容易出错。3.2 FinsTcpClient完整实现先看核心的连接和握手部分。public class FinsTcpClient : IDisposable { private TcpClient _tcp; private NetworkStream _stream; private readonly object _lock new object(); private byte _sid 0; /// summary上位机FINS节点号必须和PLC端配置一致/summary public byte LocalNode { get; set; } 0x0A; /// summaryPLC的FINS节点号/summary public byte PlcNode { get; set; } 0x0A; public bool Connect(string ip, int port 9600) { _tcp new TcpClient(); _tcp.ReceiveTimeout 3000; _tcp.SendTimeout 3000; _tcp.Connect(ip, port); _stream _tcp.GetStream(); return Handshake(); } private bool Handshake() { byte[] frame new byte[13]; frame[0] 0x46; frame[1] 0x49; frame[2] 0x4E; frame[3] 0x53; frame[4] 0x00; frame[5] 0x00; frame[6] 0x00; frame[7] 0x05; frame[8] 0x00; frame[9] 0x00; frame[10] 0x00; frame[11] 0x00; frame[12] LocalNode; _stream.Write(frame, 0, frame.Length); byte[] resp ReadResponse(); // 响应帧FINS头 长度 命令码0001 错误码0000 if (resp.Length 14) return false; return resp[8] 0x00 resp[9] 0x01 resp[12] 0x00 resp[13] 0x00; } private byte[] ReadResponse() { byte[] header ReadExactly(8); int length (header[4] 24) | (header[5] 16) | (header[6] 8) | header[7]; byte[] body ReadExactly(length); byte[] result new byte[8 length]; Buffer.BlockCopy(header, 0, result, 0, 8); Buffer.BlockCopy(body, 0, result, 8, length); return result; } private byte[] ReadExactly(int count) { byte[] buffer new byte[count]; int offset 0; while (offset count) { int read _stream.Read(buffer, offset, count - offset); if (read 0) throw new IOException(连接已断开); offset read; } return buffer; } }这里有个很重要的细节ReadResponse不能只读一次因为TCP是流协议一次Read返回的字节数不一定等于你想要的长度。我见过太多初版代码就是_stream.Read(buf, 0, buf.Length)然后直接解析结果数据一多就出现半包、粘包数据全是乱的。ReadExactly循环读满指定长度是解决这个问题的标准姿势。然后是构建FINS帧和发送private byte[] BuildFinsFrame(byte mrc, byte src, byte[] data) { using MemoryStream ms new MemoryStream(); ms.WriteByte(0x80); // ICF 需要响应 ms.WriteByte(0x00); // RSV ms.WriteByte(0x00); // GCT ms.WriteByte(0x00); // DNA ms.WriteByte(PlcNode); // DA1 ms.WriteByte(0x00); // DA2 ms.WriteByte(0x00); // SNA ms.WriteByte(LocalNode); // SA1 ms.WriteByte(0x00); // SA2 ms.WriteByte(_sid); // SID ms.WriteByte(mrc); // 主命令码 ms.WriteByte(src); // 子命令码 if (data ! null) ms.Write(data, 0, data.Length); return ms.ToArray(); } private byte[] SendCommand(byte mrc, byte src, byte[] data) { lock (_lock) { byte[] fins BuildFinsFrame(mrc, src, data); // 包一层FINS/TCP帧 using MemoryStream ms new MemoryStream(); ms.WriteByte(0x46); ms.WriteByte(0x49); ms.WriteByte(0x4E); ms.WriteByte(0x53); byte[] len new byte[4]; int bodyLen 2 2 fins.Length; // 命令码2 错误码2 FINS帧 len[0] (byte)(bodyLen 24); len[1] (byte)(bodyLen 16); len[2] (byte)(bodyLen 8); len[3] (byte)bodyLen; ms.Write(len, 0, 4); ms.WriteByte(0x00); ms.WriteByte(0x02); // 命令发送 ms.WriteByte(0x00); ms.WriteByte(0x00); // 错误码 ms.Write(fins, 0, fins.Length); byte[] sendBuf ms.ToArray(); _stream.Write(sendBuf, 0, sendBuf.Length); byte[] resp ReadResponse(); // 校验命令码是0003 if (resp[8] ! 0x00 || resp[9] ! 0x03) throw new Exception(非预期的响应命令码); return resp; } }读DM区的方法public short[] ReadDm(int startAddress, int count) { byte[] data new byte[6]; data[0] 0x82; // DM区 data[1] (byte)(startAddress 8); data[2] (byte)(startAddress 0xFF); data[3] 0x00; // 位号0按字 data[4] (byte)(count 8); data[5] (byte)(count 0xFF); byte[] resp SendCommand(0x01, 0x01, data); // 去掉FINS/TCP帧头后FINS帧的响应结构 // 10字节FINS头 01 01 错误码2字节 数据 int offset 8 2 10; if (resp[offset - 2] ! 0x00 || resp[offset - 1] ! 0x00) throw new Exception($PLC返回错误码: {resp[offset - 2]:X2}{resp[offset - 1]:X2}); short[] result new short[count]; for (int i 0; i count; i) { int p offset i * 2; result[i] (short)((resp[p] 8) | resp[p 1]); } return result; }注意读响应里的偏移计算。FINS/TCP响应帧的结构是8字节头 2字节命令码 2字节错误码 10字节FINS头 2字节命令码 2字节FINS错误码 数据。很多人直接在resp里找数据偏移量算错读出来的数全是错的。我建议你拿到响应后先把FINS命令码和错误码解析出来再根据错误码判断要不要继续解析数据。3.3 FinsUdpClient实现要点UDP版本简单很多不需要握手和帧封装直接把FINS帧发给PLCpublic class FinsUdpClient { private UdpClient _udp; private readonly object _lock new object(); private byte _sid 0; private IPEndPoint _remoteEp; public byte LocalNode { get; set; } 0x0A; public byte PlcNode { get; set; } 0x0A; public int Timeout { get; set; } 1000; public void Connect(string ip, int port 9600) { _remoteEp new IPEndPoint(IPAddress.Parse(ip), port); _udp new UdpClient(); _udp.ReceiveTimeout Timeout; } private byte[] SendCommand(byte mrc, byte src, byte[] data) { lock (_lock) { byte[] fins BuildFinsFrame(mrc, src, data); byte[] resp null; for (int retry 0; retry 3; retry) { _udp.Send(fins, fins.Length, _remoteEp); try { IPEndPoint remote _remoteEp; resp _udp.Receive(ref remote); if (resp ! null resp.Length 10) break; } catch (SocketException ex) { if (ex.SocketErrorCode ! SocketError.TimedOut) throw; // 超时重发 } } if (resp null) throw new TimeoutException(UDP通信超时PLC无响应); return resp; } } }UDP有一个关键问题如果没有收到响应没有任何办法区分是请求丢了还是响应丢了所以只能靠超时重发。重发次数一般2到3次就够了再多会加重网络负担而且可能造成PLC侧重复执行写命令。如果你做的是写操作重发前一定要考虑幂等性——写同一个值没问题但如果是类似“累计加1”的指令重发会导致重复执行。另外一个UDP特有的注意点接收响应时remote可能不是你发的PLC地址比如有人往你端口发垃圾包所以最好校验一下返回的源地址是不是PLC的IP。稳一点的写法是在Receive之后比较remote.Address和_remoteEp.Address不一致就不处理继续等。3.4 数据转换的坑字节序、位、字符串FINS协议用的是大端字节序也就是高字节在前。C#的BitConverter默认用的是本机字节序Intel平台都是小端直接拿来用会得到错误结果。正确做法是手动组装public static short ToShort(byte[] data, int offset) { return (short)((data[offset] 8) | data[offset 1]); } public static int ToInt32(byte[] data, int offset) { return (data[offset] 24) | (data[offset 1] 16) | (data[offset 2] 8) | data[offset 3]; } public static byte[] GetBytes(short value) { return new byte[] { (byte)(value 8), (byte)(value 0xFF) }; }32位数据还有一个坑欧姆龙PLC里DINT和REAL占两个字低地址存的是低16位高地址存的是高16位。也就是说D100和D101组合成一个32位整数时D100是低字D101是高字。如果你直接用byte[]转int必须注意字顺序public static int ReadDInt(byte[] highWord, byte[] lowWord) { return (highWord[0] 24) | (highWord[1] 16) | (lowWord[0] 8) | lowWord[1]; }字符串处理也经常踩坑。欧姆龙PLC里字符串一般有两种存法一种是一个字放两个ASCII字符先放高位再放低位另一种是Unicode格式。实际项目里我建议你先确认PLC程序里字符串是怎么组装的再做对应转换不要想当然。见过不少项目因为字符串编码格式没对齐读出来全是乱码后面排查半天才发现是PLC侧用的是Unicode上位机按ASCII解析了。位读写的实现本质上是把FINS帧里的“位号”字段从0改成具体的位号。欧姆龙的位地址写法是“字.位”比如CIO 0.01对应字地址0、位号1。读取时返回值一个点占1字节0x00是False0x01是Truepublic bool[] ReadBit(byte areaCode, int wordAddress, int bitNumber, int count) { byte[] data new byte[6]; data[0] areaCode; data[1] (byte)(wordAddress 8); data[2] (byte)(wordAddress 0xFF); data[3] (byte)bitNumber; // 位号非0表示按位访问 data[4] (byte)(count 8); data[5] (byte)(count 0xFF); byte[] resp SendCommand(0x01, 0x01, data); // 解析... bool[] result new bool[count]; for (int i 0; i count; i) { int p offset i; result[i] resp[p] ! 0x00; } return result; }写位则是把每一位打包成1字节的0x00或0x01放在写命令的数据区。4. 实操演示与性能调优4.1 连接PLC前的通信参数核对清单我每次到现场调试第一步从来不是写代码而是先把通信参数核对一遍。顺序固定好了不容易漏PLC的IP地址和上位机是否在同一网段用ping验证物理链路PLC的FINS节点号是多少是否和IP最后一段一致强烈建议一致省得后面绕PLC的FINS服务是否启用UDP和TCP端口是不是默认的9600上位机防火墙是否放行了9600端口Windows的防火墙默认会拦UDP入站如果交换机有端口隔离、VLAN设置确认上位机所在VLAN能访问PLC所在VLAN。有一次在现场折腾了两小时连不上最后发现是笔记本连了Wi-Fi有线网口和PLC根本不在一个网段ping都不通。这种低级错误占了调试时间的很大比例一定先查网络再查协议。PLC侧的节点号设置一般在CX-Programmer或Sysmac Studio的“单元设置”里具体路径每个型号多少有点差异以你手上的PLC型号手册为准。设完需要复位PLC或者重启以太网单元才能生效这点容易被忽略。4.2 WinForm演示读D区、写CIO位下面是一段可以直接放进WinForm按钮事件的代码功能是读取D100开始的50个字显示在DataGridView里private void btnReadDm_Click(object sender, EventArgs e) { try { using var plc new FinsTcpClient { LocalNode 0x0A, PlcNode 0x0A }; plc.Connect(192.168.250.10); short[] values plc.ReadDm(100, 50); dataGridView1.Rows.Clear(); for (int i 0; i values.Length; i) { dataGridView1.Rows.Add($D{100 i}, values[i].ToString()); } } catch (Exception ex) { MessageBox.Show($读取失败: {ex.Message}); } }写CIO位的例子private void btnWriteBit_Click(object sender, EventArgs e) { try { using var plc new FinsUdpClient { LocalNode 0x0A, PlcNode 0x0A }; plc.Connect(192.168.250.10); // 写CIO 0.01 True0.02 False plc.WriteBit(0x80, 0, 1, true); plc.WriteBit(0x80, 0, 2, false); } catch (Exception ex) { MessageBox.Show($写入失败: {ex.Message}); } }注意例子里的CIO内存区代码是0x80。不同区域的代码我整理成了表方便你直接查区域二进制模式代码说明CIO0x80最常用I/O和内部继电器WR0xB1工作区HR0x84保持区断电保持DM0x82数据内存按字访问IR0x85索引寄存器每次连接用完记得Dispose释放资源。如果程序里需要长时间频繁通信不需要每次读都重连保持着连接就行但要注意PLC复位后TCP连接会断得捕获异常后重连。4.3 批量读写与轮询策略通信性能优化里最大的坑就是一条一条读。比如要读D100到D200共101个字新手经常写个for循环去读101次每次只读1个字。这样单条指令的网络往返开销全被浪费了101次请求至少几秒钟PLC那边也会被频繁中断搞得不胜其烦。正确做法是一次FINS请求把连续区域全读回来short[] values plc.ReadDm(100, 101); // 一次请求读101个字FINS协议的单次读写上限和PLC型号有关一般建议不要超过960字节的数据区字访问也就是480个点。如果点位分散在不同区域可以分条指令读但每条指令尽量读连续地址。轮询策略上我一般遵循这几个原则轮询周期不要小于PLC扫描周期的2到3倍否则你读到的数据可能跨越多个扫描周期逻辑上不一致用System.Timers.Timer或者BackgroundWorker别用Thread.Sleep死循环关窗都关不干净失败重试策略做好退避连续失败3次后再判定断线断线后每5本文还有配套的精品资源点击获取
返回列表