ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#上位机读取欧姆龙PLC数据:FINS协议报文解析与实现

C#上位机读取欧姆龙PLC数据:FINS协议报文解析与实现 简介一份面向C#开发者和工业自动化工程师的FINS协议与欧姆龙PLC通信实战代码包。资源围绕TCP/IP网络通信、FINS帧结构构造与解析展开提供可直接运行的示例程序帮助读者快速掌握从建立TcpClient连接到读写PLC寄存器的完整流程。压缩包共45个文件约4.38MB主要包含C#源码、项目配置文件、可执行程序及调试文件另附通讯手册PDF、配套演示图片和网络调试工具结构清晰适合有一定C#基础、需要对接工业设备的开发者学习参考。目前已有1114人学习下载。包内含教学课件、源代码与直播课程素材并附带NetAssist等协议调试工具可结合代码逐行理解FINS请求/响应帧的组装过程也能直接修改IP和端口对接实际PLC设备有效降低翻阅官方文档的入门门槛便于快速落地工业通信项目。 有朋友问我C#上位机怎么读欧姆龙PLC的数据。好几年前我第一次做这种项目时也吃过亏网上能找到的代码大多是半截子要么报错要么读出来的数据全是乱的。后来我把FINS协议的手册翻了好几遍又用Wireshark抓包对比才真正把这条链路彻底打通。这篇文章就把我实际使用的方案整理出来从TCP通信、FINS报文结构到C#代码实现再到现场调设备时的常见坑一次性讲完。适合刚接触欧姆龙PLC与上位机通信的C#开发也适合已经在做但总被奇奇怪怪问题卡住的朋友。1. FINS协议选型与通信方式1.1 TCP还是UDP怎么选FINS是欧姆龙自己的一套工业通信协议全称Factory Interface Network Service可以跑在串口上也可以跑在以太网上。以太网环境下就分两种FINS/TCP和FINS/UDP。默认端口号都是9600但行为完全不一样。TCP是面向连接的协议三次握手、重传、顺序这些事都帮你处理好了报文丢没丢、顺序错没错底层会兜底。对绝大多数上位机项目来说TCP是首选代码写起来也省心。UDP则没有这些保障发出去就完了网络稍不稳定就可能丢包需要自己在上层做重传和超时判断开发复杂度高不少。唯一的好处是转发时延稍低对那种几百微秒级实时控制有要求的场合可能有一丁点优势但普通数据采集场景完全没必要自找麻烦。所以我的结论很直接没有特殊理由就用FINS/TCP稳定优先。后面所有代码也都是基于TCP写的。1.2 FINS与Modbus TCP、EtherNet/IP的区别很多项目里会遇到协议选型问题。Modbus TCP是通用协议第三方设备、组态软件、MES系统都认如果对接方不是欧姆龙生态优先考虑Modbus TCP。但欧姆龙PLC对FINS的原生支持最好内存区类型非常丰富CIO、WR区、HR区、DM区都能直接按区读取地址体系跟PLC编程时的地址格式几乎一一对应写代码时不用做太多换算。EtherNet/IP基于CIP协议面向对象建模功能确实强但实现和调试成本明显高于FINS。对于“我就读几个寄存器、写几个字”这种需求用EtherNet/IP属于杀鸡用牛刀反而把简单问题搞复杂了。选型建议只跟欧姆龙PLC通信就用FINS/TCP要跟第三方系统互通走Modbus TCP项目要求跟AB、欧姆龙混用且点位多、数据结构复杂再考虑EtherNet/IP。2. FINS报文结构拆解2.1 FINS/TCP帧头写代码前先把报文结构搞清楚这个非常重要。FINS/TCP一帧数据由两部分组成8字节的FINS/TCP帧头加上一段FINS消息体。帧头结构如下字段长度说明FINS4字节固定ASCII字符0x46 0x49 0x4E 0x53长度4字节后面所有数据的字节数大端命令字2字节0x0001表示命令0x0002表示响应错误码2字节通信层错误码0x0000正常也就是说发一条FINS命令时前8个字节是固定的框架真正干活的是后面的消息体。响应帧也是同样结构接收时先从流里读8字节解析出长度字段再按长度把剩余部分读完这样能有效避免粘包问题。2.2 FINS消息头与内存读取命令FINS消息体本身也分两部分10字节的FINS消息头加具体的命令数据。消息头各字节含义如下字节名称常见值ICF信息控制0x80需要响应RSV保留0x00GCT网关计数0x02DNA目标网络号0x00本地网络DA1目标节点号PLC的IP地址最后一段DA2目标单元号0x00CPU单元SNA源网络号0x00SA1源节点号电脑的IP地址最后一段SA2源单元号0x00SID服务标识号每次发送递增用于匹配响应我刚开始用FINS时就踩过一次坑SA1和DA1都填成PLC的IP结果响应一直对不上。后来才明白SA1是本机节点号DA1才是PLC的节点号这两个是不同设备。识别帧是否是自己要的响应主要靠SID所以这个字节一定要每次加1。接下来的命令数据以常用的内存区读取命令为例命令码是0x01 0x01。后面跟的参数依次是内存区域代码2字节、起始地址2字节、读取字数2字节。常用内存区代码整理如下内存区区域代码说明CIO区0x00 0x80输入输出继电器区DM区0x00 0x82数据内存区WR区0x00 0xB0工作区HR区0x00 0xB2保持区注意地址和字数都是按“字”算的而且是16位二进制大端。读DM100开始的两个字起始地址就是0x0064字数就是0x0002。这里千万不要把CIO区的位地址直接套进来位读写是另一套命令。2.3 响应帧结构与数据解析响应帧的消息头结构与命令帧一样只是FINS/TCP帧头里的命令字变成了0x0002。消息体里紧跟命令码之后的是2字节的FINS错误码0x0000表示无错误然后是实际数据。拿读内存区命令来说响应数据就是每个字两个字节高字节在前、低字节在后。比如读回来0x12 0x34那这个字的值就是0x1234十进制就是4660。C#里的BitConverter是x86小端序直接把字节转UInt16会得到反的所以要么手动拼一下要么把高字节和低字节换位再转。3. C#通信类实现3.1 连接与超时处理C#与PLC通信最常用的就是TcpClient。连接倒不难难的是超时控制。PLC没开机、网线没插、IP不对这时如果直接New一个TcpClient去Connect默认会等很久才报错非常影响用户体验。所以连接一定要设置超时建议3秒左右。我习惯把整个通信封装成一个类方便复用public class OmronFinsClient : IDisposable { private TcpClient _client; private NetworkStream _stream; private readonly string _ip; private readonly int _port; private byte _sid; private readonly object _lock new object(); public OmronFinsClient(string ip, int port 9600) { _ip ip; _port port; } public bool Connect() { _client new TcpClient(); var result _client.BeginConnect(_ip, _port, null, null); if (!result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(3))) { throw new TimeoutException(${_ip}:{_port} 连接超时); } _client.EndConnect(result); _stream _client.GetStream(); _stream.ReadTimeout 2000; // 响应超时 return _client.Connected; } }连接成功后把读超时设置成2秒。这个值可以根据现场情况调在普通局域网内2秒完全够用。如果PLC负载高、响应慢可以适当调大一点但别超过5秒否则界面就像死了一样。3.2 组包内存区读取命令组包是整个通信的核心。我先把FINS帧头拼好再拼消息头最后拼命令码和参数。下面的代码读的是DM区区域代码0x82起始地址和字数通过参数传入private byte[] BuildReadCommand(byte areaCode, int startAddress, int wordCount) { using var ms new MemoryStream(); // FINS/TCP 帧头 ms.Write(new byte[] { 0x46, 0x49, 0x4E, 0x53 }); // FINS ms.WriteByte(0x00); ms.WriteByte(0x00); ms.WriteByte(0x00); ms.WriteByte(0x1A); // 后面数据共26字节2命令2错误码10消息头8命令码参数2字数 // 这里要注意长度 // 实际命令体长度计算后面说明先用占位再回填 int lengthPosition 4; ms.Write(new byte[] { 0x00, 0x00, 0x00, 0x00 }, 0, 4); // FINS/TCP 命令 ms.Write(new byte[] { 0x00, 0x01 }); // 命令帧 ms.Write(new byte[] { 0x00, 0x00 }); // 错误码预留 // FINS 消息头 ms.WriteByte(0x80); // ICF ms.WriteByte(0x00); // RSV ms.WriteByte(0x02); // GCT ms.WriteByte(0x00); // DNA ms.WriteByte((byte)PlcLastIpPart); // DA1 ms.WriteByte(0x00); // DA2 ms.WriteByte(0x00); // SNA ms.WriteByte((byte)LocalLastIpPart); // SA1 ms.WriteByte(0x00); // SA2 ms.WriteByte(_sid); // SID // 命令码内存区读取 ms.WriteByte(0x01); ms.WriteByte(0x01); // 内存区域代码高位在前 ms.WriteByte(0x00); ms.WriteByte(areaCode); // 起始地址大端 ms.WriteByte((byte)(startAddress 8)); ms.WriteByte((byte)(startAddress 0xFF)); // 字数大端 ms.WriteByte((byte)(wordCount 8)); ms.WriteByte((byte)(wordCount 0xFF)); // 回填长度 var bytes ms.ToArray(); int bodyLength bytes.Length - 8; // 去掉FINS和长度字段本身 bytes[4] (byte)(bodyLength 24); bytes[5] (byte)(bodyLength 16); bytes[6] (byte)(bodyLength 8); bytes[7] (byte)(bodyLength); return bytes; }我要特意说明一下长度字段的计算。FINS/TCP帧头里“长度”指的是第9字节开始到结尾的字节数不包含FINS字符和长度字段本身。实际算下来读命令的完整报文是30字节8字节帧头加22字节消息体。上边的代码用了回填方式就不容易算错。3.3 收包与解析注意字节序和粘包发送和接收必须在同一把锁里否则多线程同时调Socket响应会串。很多轮询项目越写越乱就是没注意这一步。下面是读取并解析响应的方法public ushort[] ReadDMArea(int startAddress, int wordCount) { lock (_lock) { var cmd BuildReadCommand(0x82, startAddress, wordCount); _stream.Write(cmd, 0, cmd.Length); _stream.Flush(); // 读响应帧头 var header ReadBytes(8); int length (header[4] 24) | (header[5] 16) | (header[6] 8) | header[7]; var body ReadBytes(length); // 校验 FINS 层错误码位置消息头10字节 命令码2字节之后 int errorCode (body[10] 8) | body[11]; if (errorCode ! 0) throw new Exception($FINS错误码:0x{errorCode:X4}); int dataStart 12; // 跳过消息头10字节和命令码2字节 var result new ushort[wordCount]; for (int i 0; i wordCount; i) { result[i] (ushort)((body[dataStart i * 2] 8) | body[dataStart i * 2 1]); } return result; } } private byte[] ReadBytes(int count) { var 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; }读响应时不要一上来就调Read等固定字节数因为网络流不一定一次把所有数据都吐出来。上面这个ReadBytes方法循环读到目标长度才退出把拆包问题直接解决掉。解析数据时手动做大小端转换避开BitConverter的坑。4. 高频轮询下的UI刷新与性能优化4.1 为什么轮询会卡UI很多新手写轮询直接在按钮点击事件里搞一个while循环读PLC然后每读一次就把值赋给TextBox。这样肯定会卡死界面因为UI线程被循环占住了根本没有机会处理Windows消息。就算用了Timer控件如果读取耗时比Timer间隔还长事件会不断堆积界面照样假死。核心原则是耗时通信操作绝不能在UI线程串行执行UI控件更新也不能过于频繁。4.2 后台轮询加定时刷新我现在的做法是生产者消费者模式后台线程或Task负责循环采集数据放到一个带锁的“最新快照”里UI线程用DispatcherTimer每隔100到200毫秒从快照取一次值并刷新界面。这样即使后台采集卡顿界面也不会跟着卡界面再慢也不会拖累采集频率。代码框架大概是这个样子private CancellationTokenSource _cts; private UInt16[] _latestData; public void StartRead() { _cts new CancellationTokenSource(); var token _cts.Token; Task.Run(async () { var fins new OmronFinsClient(192.168.250.1); fins.Connect(); while (!token.IsCancellationRequested) { try { var data fins.ReadDMArea(100, 20); lock (_lock) { _latestData data; } } catch (Exception ex) { // 记录日志必要时重连 } await Task.Delay(100, token); } }, token); var timer new DispatcherTimer(); timer.Interval TimeSpan.FromMilliseconds(150); timer.Tick (s, e) { if (_latestData null) return; UInt16[] snapshot; lock (_lock) snapshot _latestData; for (int i 0; i snapshot.Length; i) { // 只更新变化的值 if (snapshot[i] ! _oldValues[i]) { txtValue[i].Text snapshot[i].ToString(); _oldValues[i] snapshot[i]; } } }; timer.Start(); }这里要注意UI刷新时只更新变化的数据能明显减少控件重绘开销。点位特别多的时候几百毫秒刷一次全界面反而顺畅因为每次重绘的次数少了。之前有个项目同时显示200个点位用这种方式之后CPU占用率从30%降到8%不到。4.3 扩展扫码枪触发一次读取项目里常有扫码枪扫到条码后读一次PLC数据的场景。扫码枪一般走串口或者网口触发方式是异步事件。这种情况下不要和轮询抢锁最好的做法是单独一个FINS连接扫码事件来了直接读一次。因为一次读取耗时很短新建连接成本比较高建议启动时就把连接建立好扫码事件里只用锁保护发送接收。如果一定要在同一个连接里和轮询共用那就要处理好锁的粒度和超时防止扫码读取等锁时界面感觉卡顿。实际项目里我更推荐两条连接分摊逻辑隔离调试起来也简单。5. 现场问题排查与调试技巧5.1 错误码速查表FINS响应里最让人头疼的就是错误码。我整理一张现场用得最多的速查表错误码含义常见原因0x0000正常无0x0101头部错误消息头字节填错ICF、GCT等0x0102长度错误FINS/TCP头的长度字段算错0x0103命令错误命令码写错比如0101写成01000x1101区域分类错误内存区代码不存在或PLC不支持0x1102地址越界起始地址加字数超出范围0x1103数据设置错误读取字数超上限或数据格式不对0x2001命令异常该命令在当前模式下不可用出现错误码时优先检查是不是地址超范围。比如DM区总共8000个字你要从DM7000开始读2000个字虽然都在范围内但70002000已经越界了。字数不是“地址到末尾还有多少”而是“你想读多少”这两者要区分清楚。5.2 抓包定位组包错误代码写完了连不上或者数据不对我强烈建议用Wireshark抓包。这个方法十次能解决九次问题。电脑和PLC之间如果经过交换机把电脑网卡设置为混杂模式过滤tcp.port 9600然后发起一次读取看发出的报文是不是30字节再对比响应帧。重点看这几处FINS/TCP头里的长度字段是否正确、DA1是否等于PLC的IP末位、命令码是不是0101、区域代码是不是82。我自己调试时习惯在发送前把组好的十六进制报文打一条日志比如发送: 46494E53 0000001A 0001 0000 8000020000 000001010082 0064 0001扫码对照抓包结果一眼就能看出哪一段错了。这个方法比盯着代码猜快太多强烈建议养成日志习惯。5.3 几个容易踩的坑第一个大坑是SA1节点号。本机IP如果是192.168.1.100SA1就是0x64。如果电脑有多个网卡要用连PLC的那张网卡的IP别拿无线网卡的IP去填。这个字段错误时FINS层不会马上报错但PLC可能一直不回复或者返回异常帧。第二个坑是NJ/NX系列不能直接用D100这种地址。NJ/NX的程序是基于变量的如果变量没有分配具体地址FINS根本读不到。要让FINS能访问要么在Sysmac Studio里给变量设置AT指定地址要么通过IO映射表关联。CP/CJ/CS系列就没这个问题DM区地址都是原生存在的。第三个坑是IP不知道怎么办。NX1P2这类PLC如果IP记不清可以用USB线和电脑连接打开Sysmac Studio在控制器设置里查看或修改内置端口IP。CP系列就用CX-Programmer的IO表。实在不行把PLC断电用编程口连接重新上传配置。千万不要上来就乱猜IP容易白折腾半天。最后再分享一个我在现场一直保持的习惯任何设备上线前先抓包、看日志、验报文确认链路是通的再去调业务逻辑。FINS协议本身并不复杂绝大多数问题都出在跟随手代码相关的细节上地址多一位、字节序反了、长度字段写错。把报文结构当成强制规范来要求自己你会发现欧姆龙PLC的通信开发稳定得很。本文还有配套的精品资源点击获取
返回列表