简介:一份面向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消息体。
帧头结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| FINS | 4字节 | 固定ASCII字符0x46 0x49 0x4E 0x53 |
| 长度 | 4字节 | 后面所有数据的字节数,大端 |
| 命令字 | 2字节 | 0x0001表示命令,0x0002表示响应 |
| 错误码 | 2字节 | 通信层错误码,0x0000正常 |
也就是说,发一条FINS命令时,前8个字节是固定的框架,真正干活的是后面的消息体。响应帧也是同样结构,接收时先从流里读8字节,解析出长度字段,再按长度把剩余部分读完,这样能有效避免粘包问题。
2.2 FINS消息头与内存读取命令
FINS消息体本身也分两部分:10字节的FINS消息头,加具体的命令数据。消息头各字节含义如下:
| 字节 | 名称 | 常见值 |
|---|---|---|
| ICF | 信息控制 | 0x80(需要响应) |
| RSV | 保留 | 0x00 |
| GCT | 网关计数 | 0x02 |
| DNA | 目标网络号 | 0x00(本地网络) |
| DA1 | 目标节点号 | PLC的IP地址最后一段 |
| DA2 | 目标单元号 | 0x00(CPU单元) |
| SNA | 源网络号 | 0x00 |
| SA1 | 源节点号 | 电脑的IP地址最后一段 |
| SA2 | 源单元号 | 0x00 |
| SID | 服务标识号 | 每次发送递增,用于匹配响应 |
我刚开始用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写成0100 |
| 0x1101 | 区域分类错误 | 内存区代码不存在,或PLC不支持 |
| 0x1102 | 地址越界 | 起始地址加字数超出范围 |
| 0x1103 | 数据设置错误 | 读取字数超上限,或数据格式不对 |
| 0x2001 | 命令异常 | 该命令在当前模式下不可用 |
出现错误码时,优先检查是不是地址超范围。比如DM区总共8000个字,你要从DM7000开始读2000个字,虽然都在范围内,但7000+2000已经越界了。字数不是“地址到末尾还有多少”,而是“你想读多少”,这两者要区分清楚。
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.100,SA1就是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的通信开发,稳定得很。
本文还有配套的精品资源,点击获取