☰
C#通过FINS协议读取欧姆龙PLC:UDP通信从报文到联调避坑完整案例
2026/10/21 13:29:31 网站建设 项目流程

简介:在工业自动化与上位机开发中,PLC通信始终是设备数据采集的核心环节。欧姆龙PLC凭借其稳定性被广泛采用,而FINS协议则是与欧姆龙PLC交互的标准语言。本文从FINS报文结构出发,解析UDP/TCP选型原理,说明如何用C#构造读写命令、处理大小端与超时重试,并结合联调中的常见问题提供排查思路,最终实现一个可运行的上位机读取D区数据的工程案例。无论是新手还是资深工程师,都能从中获得快速上手的实用技巧。

1. C#通过FINS读取欧姆龙PLC:从需求到可跑通的协议例子

做上位机开发的,总有一天会撞上PLC。某天产线设备要求上位机实时读取一组温控数据,我翻遍手册发现这台欧姆龙PLC用的是FINS协议,而网上C#例子要么是半截代码、要么绕得看不懂。这篇文章就是一个能直接跑通的C#通过FINS读取欧姆龙PLC的例子,从报文格式、UDP收发、响应解析到联调避坑全都有。如果你面临同样的问题——手里有PLC、需要上位机通信但不想看几百页协议手册,这份资源就是为你准备的。读完你不仅能读D区数据,还能按自己的需求改成异步、批量读取,甚至做成一个多PLC网关。

2. 架构与技术选型:FINS报文结构、UDP/TCP取舍与工程分层

FINS是欧姆龙PLC的通信协议,支持UDP和TCP两种承载方式。这个示例选择UDP实现,因为绝大多数PLC型号默认开放UDP 9600端口,响应速度比TCP略好,而TCP需要额外维护连接状态,重连逻辑复杂。整个设计围绕着一个核心原则:把FINS协议细节封装在独立的客户端类里,上层调用者不需要知道任何报文结构,拿到方法就调用。

2.1 FINS协议不是黑匣子:一帧读写命令的完整解剖

FINS报文分两部分:10字节的固定帧头加命令区。帧头字段包括ICF、RSV、GCT、DNA、DA1、DA2、SNA、SA1、SA2、SID。不要被这些缩写吓到,真正需要关心的是DA1(目标节点号,即PLC节点)和SA1(源节点号,即上位机节点),其他字段在同一个网段内使用固定值。

字段字节数值含义
ICF10x80表示命令帧,需要响应
RSV10x00保留
GCT10x02网关计数,通常固定
DNA10x00目标网络号,本网络为0
DA11目标节点PLC的FINS节点号
DA210x00目标单元号
SNA10x00源网络号
SA11源节点上位机节点号
SA210x00源单元号
SID10x00消息序列号,每次通信递增

后面跟命令码和参数。读取内存区的命令码是0x0101,数据区固定5字节:内存区代码(1字节)、起始地址(2字节,高位在前)、读取字数(2字节,高位在前)。比如读D区100号地址的1个字,内存区代码0x82,起始地址0x0064,字数0x0001。

这里有个常见误区:初学者以为D100的地址就是100,但FINS协议里D区地址是按字编址的,直接传100没问题;可如果读CIO区位地址,地址就要按位来算,比如CIO 3.01,它的位地址是3*16+1=49,而不是3。所以协议手册里的内存区代码表和地址计算规则,最好在写代码前先打印一份贴在工位上。

2.2 UDP还是TCP:PLC通信场景下的取舍与实测差异

很多工程人员一上来就找TCP的例子,却发现有些老型号PLC默认不监听TCP端口,也没法改。我一般建议首选UDP,原因有三:第一,UDP 9600端口几乎是欧姆龙PLC的出厂默认,省去改PLC设置的麻烦;第二,FINS本身是无状态请求-响应,TCP的连接保持优势不明显,反而多了连接建立和断开的管理逻辑;第三,UDP丢包通过超时重试很容易补偿,只要重试次数合理,实际通信可靠性并不差。

实测对比中,UDP单次请求耗时通常在1-3毫秒,TCP因为要维护会话,首次连接要额外握手。但要注意,UDP没有滑动窗口,如果上位机连续快速发送多条命令,PLC的处理队列可能溢出,表现为偶发超时。所以我在代码里加了一个简单锁,保证同一时间只有一个请求在途,后面第4章会展开说。

TCP方式也并不是不能用,它的命令帧结构和UDP基本一致,只是在发送前要拼上一个4字节的帧长度,并且要处理连接断开后的重连。这个示例没有走TCP,但发送和接收方法被独立封装,将来需要支持TCP时,只需要替换内部收发逻辑,构造帧和解析响应的代码完全复用。

2.3 工程目录与分层:让FINS通信代码能塞进现有上位机

整个示例是一个Visual Studio控制台项目,但为了复用,我把通信逻辑独立成一个类库结构:

FinsDemo/ ├── FinsUdpClient.cs # UDP客户端,构造帧、收发、解析 ├── FinsMemoryCode.cs # 内存区代码常量 ├── FinsException.cs # 自定义异常,携带PLC错误码 └── Program.cs # 控制台入口,演示读取D区

FinsMemoryCode是一个静态类,把0x82、0xB0这些魔法数字变成有名字的常量:

public static class FinsMemoryCode { public const byte DM = 0x82; // D区,字单位 public const byte EM = 0x83; // E区,字单位 public const byte CIO = 0xB0; // CIO区,位单位(也可用0x30进行字读取) public const byte WR = 0xB1; // WR区,字单位 public const byte HR = 0xB2; // HR区,字单位 }

这样在调用时写FinsMemoryCode.DM,比直接写0x82清晰得多,也减少拼写错误。FinsException继承自Exception,增加一个StatusCode属性,当PLC返回非零错误码时抛出,上层捕获后可以精确知道是哪个错误码。

如果你要把这套代码嵌进WinForms或WPF项目,直接把FinsUdpClient.cs、FinsMemoryCode.cs、FinsException.cs三个文件复制过去就行,不用改任何命名空间。Program.cs只是演示,不需要带进正式项目。

3. 核心代码拆解:FINS帧构造、UDP收发与响应解析全链路

这一章直接上代码。我会按一次完整读取的顺序逐步展开:构造命令帧 → 发送UDP报文 → 接收响应 → 解析出寄存器值。每个关键点都有参数说明和容易踩的地方,你在抄代码时手边放一份FINS协议手册,核对一遍会更安心。

3.1 构造FINS读取命令:把D区地址和长度塞进17个字节

先看核心方法BuildReadCommand,它接受内存区代码、起始地址和读取字数,返回一个长度固定为17字节的数组:

private byte[] BuildReadCommand(byte memoryArea, int address, int wordCount) { byte[] frame = new byte[17]; // FINS头部固定字段 frame[0] = 0x80; // ICF:命令帧,需要响应 frame[1] = 0x00; // RSV:保留 frame[2] = 0x02; // GCT:网关计数 frame[3] = 0x00; // DNA:目标网络号 frame[4] = (byte)_destinationNode; // DA1:PLC节点号 frame[5] = 0x00; // DA2:目标单元号 frame[6] = 0x00; // SNA:源网络号 frame[7] = (byte)_sourceNode; // SA1:上位机节点号 frame[8] = 0x00; // SA2:源单元号 frame[9] = _sid; // SID:消息序列号 // 命令码:读取内存区 frame[10] = 0x01; // MRC frame[11] = 0x01; // SRC // 数据区:内存区代码 + 起始地址(高在前) + 读取字数(高在前) frame[12] = memoryArea; // 例如0x82 = D区 frame[13] = (byte)(address >> 8); // 起始地址高位 frame[14] = (byte)(address & 0xFF); // 起始地址低位 frame[15] = (byte)(wordCount >> 8); // 读取字数高位 frame[16] = (byte)(wordCount & 0xFF); // 读取字数低位 return frame; }

逻辑说明:前10字节是对齐FINS帧头,其中DA1和SA1是动态值,其他按手册推荐的固定值。第10、11字节是命令码0x0101,表示内存区读取。第12字节指定内存区,D区是0x82;第13、14字节是起始地址,注意对D区来说地址直接就是字地址,D100就是100,不需要乘以2。第15、16字节是读取字数,我们要读几个字就写几个。

参数说明:memoryArea对应内存区代码,常用0x82(D区)、0x83(E区)、0x84(C区扩展)、0xB0(CIO位区)等;address是字地址,范围取决于PLC型号,一般D区从0到32767;wordCount最小1,一次最多建议不要超过128个字,否则PLC可能直接返回错误。序列号_sid在每次发送后自增,避免连续请求被PLC当成重复帧。

3.2 UDP发送与超时重试:上位机通信稳定性的底线

构造好命令后,接下来是发送和接收。这里使用UdpClient,设置3000毫秒超时,超时自动重试3次,间隔200毫秒。代码:

private byte[] SendReceive(byte[] frame) { byte[] response = null; for (int attempt = 0; attempt < 3; attempt++) { try { _udpClient.Send(frame, frame.Length, _plcEndPoint); _udpClient.Client.ReceiveTimeout = 3000; response = _udpClient.Receive(ref _plcEndPoint); break; } catch (SocketException ex) { if (attempt == 2) throw new TimeoutException($"FINS通信超时:{_plcEndPoint}", ex); Thread.Sleep(200); } } return response; }

逻辑说明:Send后立即阻塞Receive,等待PLC响应。UdpClient的ReceiveTimeout在客户端Socket上设置,超时会抛出SocketException,我们捕获后重试。最后一次超时则抛TimeoutException,把错误抛给上层,避免上层拿到半截数据。注意Receive(ref _plcEndPoint)会修改这个变量,将它更新为实际发送方的地址,所以必须要用同一个引用传递给构造函数。

注意点:重试发送同一个frame时,_sid已经被外部递增,所以重试帧的SID可能不同,这在多数PLC上没问题;但严格来说,重试应该用相同SID,所以我把_sid的递增放在SendReceive之外,每次构建命令前加一。如果你在一个循环里不断调用ReadD,看到的SID会一直增长。

实际调试时,我一般把重试次数和超时做成可配置字段,避免硬编码。比如先在构造函数里给个默认值,然后提供属性让外部调整。现场PLC负载高的时候,3000毫秒不一定够,调成5000毫秒往往就稳定了。

3.3 响应解析:状态码、数据区与大小端转换一次做对

PLC响应帧收到后,先校验SID,再检查状态码,最后把数据字节变成short数组。解析方法:

private short[] ParseWordResponse(byte[] response, int expectedCount, byte expectedSid) { // 响应最小长度:帧头10 + 命令码2 + 状态码2 + 数据0 if (response.Length < 14) throw new FinsException("响应帧长度不足"); if (response[9] != expectedSid) throw new FinsException($"SID不匹配:期望0x{expectedSid:X2},实际0x{response[9]:X2}"); // 状态码 int status = (response[12] << 8) | response[13]; if (status != 0) throw new FinsException($"PLC返回错误码:0x{status:X4}"); // 数据区从第14字节开始,每2字节一个字,高位在前 short[] values = new short[expectedCount]; for (int i = 0; i < expectedCount; i++) { int offset = 14 + i * 2; values[i] = (short)((response[offset] << 8) | response[offset + 1]); } return values; }

逻辑说明:响应帧前10字节是同样的FINS头,第10、11字节是命令码,第12、13字节是PLC返回的状态码,0x0000表示成功。如果非0,说明请求参数有问题,必须抛出异常而不是继续解析数据。状态码之后才是真正读到的数据,一个字占两个字节,FINS规定高位字节在前,所以用(byte << 8) | byte组装。

易错点:C#的BitConverter.ToInt16默认按小端解析,直接BitConverter.ToInt16(response, 14)会得到高低字节颠倒的错误值。我就翻过这个车。另一个易错点是D区的数据可能有负数,PLC里D区可以用作有符号整数,所以用short接收而不是ushort。如果需要无符号,改成ushort即可。

如果读的是浮点数,情况更复杂。一个32位浮点占两个字,PLC内部存储顺序是高字在前还是低字在前,不同PLC不同,我见过同一系列不同型号都不一致。建议先写一个测试程序,给PLC写入已知浮点值,再抓包确认字节顺序,不要在解析代码里盲目猜测。

4. 联调避坑:与欧姆龙PLC通信最常见的5个翻车现场

上下位机联调,十次有八次不是代码逻辑问题,而是网络参数没对齐。这一章把高频坑列出来,每条按现象、原因、解决三步说清,都是我实际验过或者帮别人排查过的。

4.1 超时无响应:节点号与IP地址的"玄学"对应关系

现象:代码运行后一直报"FINS通信超时",抓包发现PLC没有回任何报文。

原因:FINS报文里的DA1目标节点号和PLC配置不一致。很多欧姆龙PLC的默认节点号等于IP地址最后一段的数字,比如IP是192.168.1.10,节点号默认就是10。但如果你在上位机里把DA1设成1,PLC收到后发现自己不是节点1,直接丢弃。

解决:先用PLC编程软件(比如CX-Programmer)查看PLC的FINS节点号,确保_destinationNode和它一致。同时检查上位机节点号_sourceNode不要与PLC相同,随便设一个1到126之间的非0数字即可。我习惯把上位机节点固定为1,PLC节点用实际值。

4.2 错误码0x1101:内存区代码和起始地址的错位

现象:收到了响应,但解析时抛出"PLC返回错误码:0x1101"。

原因:0x1101是"内存区代码错误"。常见是内存区代码写错,比如把0x82写成了0xB2;或者起始地址超过了内存区范围,比如D区只有2000个字却去读地址5000。

解决:核对PLC型号的内存区范围,D区通常从0开始,不同小型机上限不同。另外,如果读的是CIO区(内存区代码0xB0),地址是位地址,不是字地址,不能直接拿字序号当位地址用。可以先在PLC软件里监视一下要读的地址是否存在,排除硬件配置问题。

4.3 多线程串包:共享UdpClient引发的数据错乱

现象:上位机同时开两个线程分别读D区和E区,结果偶发出现甲线程读到的数据是乙线程返回的,甚至状态码对不上。

原因:UdpClient的Receive方法会把收到的任意UDP报文当成当前请求的响应,多个线程共享同一个UdpClient时,一个请求发出后还没收到响应,另一个请求也发出去了,PLC按顺序返回后,两个线程各自Receive,就乱了。

解决:在客户端类里加锁,让同一时间只有一个请求在途:

private readonly object _lock = new object(); public short[] ReadD(int startAddress, int count) { lock (_lock) { // 构造、发送、接收、解析 全都在锁内 } }

加锁后吞吐量会下降,但换来了不会串包。如果实在需要并发,可以对每个线程创建独立的UdpClient实例,各自绑定不同的本地端口。注意UdpClient默认绑定在0端口,多个实例使用不同端口后,PLC才能区分响应发给谁。

4.4 值不对但能收到响应:大小端字节序的坑

现象:读到D100的值是0x1234,PLC监视器里明明显示0x3412;或者相反。

原因:FINS协议使用大端序(高字节在前),而C#的BitConverter和大多数Windows API默认小端序。如果你偷懒直接用BitConverter,数字就会被反向组装。

解决:严格按照3.3节的(byte << 8) | byte方式组装,或者用BinaryPrimitives.ReadInt16BigEndian。另外,如果PLC里存的是浮点数(两个字),组装方式又有变化,需要单独处理。我在现场还遇到过,某些触摸屏通过FINS写入的值,本身字节序是反的,不是你代码的问题,而是数据源就反了。

4.5 重试后重复写:SID序列号不更新的问题

现象:连续写入操作偶尔报错,PLC侧写入值不稳定,或者明明发送一次,PLC却执行了两次。

原因:SID字段用于匹配请求和响应。如果多次请求使用相同SID,PLC可能认为这是重复命令直接忽略,或者上位机把上一次的响应当成了本次的。常见于重试逻辑里没有递增SID,导致同一条命令发送了多次但SID完全一样。

解决:保证每次新请求的SID都不同。我在客户端里维护一个自增计数器,发送前取_sid++并保存在临时变量,解析响应时校验这个临时值,重试时用同一个临时值。注意不要让SID在重试时变化,但每次新请求必须变化。这样既保证重试匹配,又避免PLC侧判定重复。

5. 把示例跑起来:从新建控制台到读通D100的全过程

前面代码零散,这里整合成一个完整可运行的最小工程。我用的环境是Windows 10 + Visual Studio 2022 + .NET 6,理论上.NET Framework 4.7.2也能跑,只要控制台模板别选得太老,用.NET 6是最省事的。

5.1 创建项目与引入FINS客户端类

打开Visual Studio,新建一个控制台应用,目标框架选.NET 6。然后添加两个文件:FinsUdpClient.cs和Program.cs。FinsUdpClient类的内容把第3章的几个方法拼在一起,构造函数如下:

public FinsUdpClient(string plcIp, int plcPort, int sourceNode, int destNode) { _udpClient = new UdpClient(); _plcEndPoint = new IPEndPoint(IPAddress.Parse(plcIp), plcPort); _sourceNode = sourceNode; _destinationNode = destNode; }

记得在文件顶部using System.Net; using System.Net.Sockets;。完整的ReadD方法还需要把BuildReadCommand、SendReceive、ParseWordResponse串起来:

public short[] ReadD(int startAddress, int wordCount) { lock (_lock) { byte expectedSid = _sid; _sid = (byte)((_sid + 1) & 0xFF); byte[] request = BuildReadCommand(FinsMemoryCode.DM, startAddress, wordCount); request[9] = expectedSid; byte[] response = SendReceive(request); return ParseWordResponse(response, wordCount, expectedSid); } }

这里把SID递增放到锁内,避免多线程拿到相同SID。因为整个方法都在锁里,所以单线程调用不会有问题。

5.2 配置PLC网络参数:IP、端口、节点号一次性理顺

这一步非常关键,几乎90%的联调失败都挂在参数错位。先给PLC和电脑配好静态IP,比如电脑192.168.1.5,PLC 192.168.1.10。然后用PLC编程软件在线查看PLC的FINS节点号,假设是10。上位机节点号建议设置成1到126之间的非0数字,不要和PLC相同。UDP端口默认9600,除非你改过PLC设置,否则不用动。

连接验证小技巧:在命令行ping 192.168.1.10通,说明网络层没问题。然后在C#里先用一个最简单的命令发出去,用抓包工具看PLC有没有回包。如果回了,再解析;没回,优先查节点号。注意有些PLC的FINS服务被禁用了,需要在PLC设置里打开。

5.3 运行验证:读到寄存器值并核对PLC监视值

编译运行,如果一切正常,控制台会输出类似"D100 = 1234"的值。此时打开PLC编程软件的数据监视,找到D100,看看里面的值和程序输出是否一致。一致说明通信链路没问题。如果不一致,按第4章排查:状态码非0查内存区代码和地址范围;值反了查大小端。

我给一个验证例子:在PLC里手动给D100写入十六进制0x1234,再看程序输出。如果输出为4660(十进制)且PLC监视显示十六进制0x1234,说明解析正确。如果你看到0x3412,赶紧检查你用的是不是BitConverter。

如果是写入操作,验证方式类似:程序写入0x1234到D100,然后PLC软件监视确认。注意写入命令的命令码是0x0102,数据区格式和读取不同,需要另外构造帧。我建议先把读取跑通,再扩展写入。

6. 二次开发:从示例子类化到通用FINS网关的改造思路

当你能跑通单个D区读取,接下来的需求往往是"读几十个地址""支持多个PLC""不能卡UI"。这一章给三条改造路径,都是我实际用过且好收尾的。

6.1 从同步到异步:UDP通信的Task化封装

把ReadD改成async不是简单加个关键字。UdpClient有ReceiveAsync方法,但配合超时和重试,推荐用Task.WhenAny:

private async Task<byte[]> SendReceiveAsync(byte[] frame, CancellationToken ct) { for (int attempt = 0; attempt < 3; attempt++) { var sendTask = _udpClient.SendAsync(frame, frame.Length, _plcEndPoint); var recvTask = _udpClient.ReceiveAsync(); var completed = await Task.WhenAny(recvTask, Task.Delay(3000, ct)); if (completed == recvTask) return recvTask.Result.Buffer; ct.ThrowIfCancellationRequested(); await Task.Delay(200, ct); } throw new TimeoutException("FINS通信超时"); }

注意这里不能直接用ReceiveAsync的超时参数,因为UdpClient没提供;用WhenAny能干净地实现超时。改造后,WinForms里直接await client.ReadDAsync(...),界面不卡。

6.2 多地址批量读取:设计一个地址描述符

实际情况中,D100、D200、CIO位3.01可能要一起读。我建议建一个FinsAddress结构:

public readonly struct FinsAddress { public byte MemoryArea { get; } public int WordAddress { get; } public int BitAddress { get; } public FinsAddress(byte area, int word, int bit = -1) { MemoryArea = area; WordAddress = word; BitAddress = bit; } }

读取时遍历地址列表,每个地址单独发请求。这样虽然慢一点,但边界清晰。如果追求性能,可以按内存区合并连续地址,一次读多个字,再按地址偏移拆分。合并逻辑要注意不要跨PLC的内存区边界。

真实项目里我还会在客户端外面套一个"重试策略"层:遇到超时重试两次,遇到PLC错误码则立刻中断而不是重试,因为地址错重试一百次也没用。每次请求前后把十六进制报文打到日志文件,方便现场定位。从那以后,我每次联调PLC都会先打印一条日志,比对了再往下走。很多所谓"玄学"问题,其实就是报文某一位不对或字节序翻车。希望这个例子的代码和避坑经验能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询