news 2026/10/16 3:50:29

C#通过FINS协议读取欧姆龙PLC:不改梯形图的UDP采集方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#通过FINS协议读取欧姆龙PLC:不改梯形图的UDP采集方案

简介:面向工业自动化开发者与PLC编程初学者的C#通信实例资源,解决通过FINS协议连接欧姆龙PLC并实现数据读写的常见需求。包内包含可运行的Windows窗体工程、9个C#源文件、通讯手册PDF与协议格式图,并附带NetAssist网络调试工具及演示截图,便于对照协议帧结构逐行理解。资源共45个文件,压缩包约4.38MB,配置文件和可执行程序齐全,解压后即可在Visual Studio中打开调试。已有1117人学习下载,适合希望快速上手FINS通信、需要现成代码骨架的工程师。除了基础TcpClient连接示例,资料中还涉及响应解析、多寄存器读取等扩展内容,并配有通讯手册、直播素材文档与课件,能够帮助读者理解帧头、命令码与地址格式,减少反复查阅文档的时间,将通信模块直接移植到自己的项目中。

1. FINS协议读欧姆龙PLC:一个不用改梯形图的C#采集方案

车间里一台老设备的PLC程序是设备厂封死的,梯形图不让动,可MES那边催着要产量和设备状态数据。加传感器不现实,改PLC更不可能,最后只能走它的原生FINS协议。这套“C#通过FINS读取欧姆龙PLC协议例子”要解决的就是这件事:不修改PLC一行逻辑,直接用C#从UDP 9600端口发送标准FINS指令帧,把DM区、CIO区、WR区、HR区的数据读回来,需要时还能往指定地址写值。整个工程按最小可运行闭环组织,不是零散片段,拉下来改一下IP、节点号和点位地址就能跑。适合做设备数据采集网关、MES对接、上位机监控的开发者参考,哪怕你只是偶尔想远程看一眼PLC内部的变量,这套代码改两个参数也能当临时工具用。

2. 技术选型与通信链路:从FINS帧格式到工程目录结构

2.1 FINS协议报文结构拆解

FINS是欧姆龙定义的工厂自动化网络协议,底层可以跑在UDP、TCP、HostLink串口上。做数据采集时绝大多数场景都是用UDP,因为不需要建立长连接,发一个请求帧等一个响应帧,成本最低。PLC侧默认开放UDP 9600端口,里面跑着一个FINS服务,你给它发标准帧它就会回。可以把PLC当成一个网络黑匣子,只要帧格式对,它就老老实实回数据。

每一条FINS请求帧由三部分组成:

  • 帧头:10字节,固定结构
  • 命令码:2字节,01 01是读内存区,01 02是写内存区
  • 参数:内存区代码、地址、数量等,随命令变化

帧头的10个字节是最容易抄错的地方,依次是:

字节字段名典型值作用
0ICF0x80最高位置1表示需要响应
1RSV0x00保留字节
2GCT0x02网关计数,固定值
3DNA0x00目标网络号,本地网络填0
4DA10x0A目标节点号,要与PLC配置一致
5DA20x00目标单元号,CPU单元填0
6SNA0x00源网络号
7SA10x3E源节点号,PC侧自己定义
8SA20x00源单元号
9SID0x01服务标识符,响应帧会原样带回

DA1和SA1这两个节点号特别重要。DA1填错会导致请求根本没到达指定PLC,SA1填错或者跟同一网段里其他FINS客户端撞了,PLC可能直接不回包。很多人一开始把DA1和SA1都填0,结果只有一台PLC时能通,多台设备一接就翻车,原因就在这。我一般把DA1和SA1都做成可配置项,PLC的FINS节点号在其网络配置里就能看到,PC侧SA1取一个不冲突的值,比如62。

帧头后面是命令区。读内存区命令的参数排列顺序是:内存区代码(2字节)、起始地址(3字节)、读取个数(2字节)。起始地址的三字节结构经常让人困惑:前两字节是字地址,高字节在前,第三字节是位号。读字数据时位号填0;读位数据时才把具体的位号填到第三字节里。内存区代码上,字访问和位访问是两套编码:

区域字访问码位访问码说明
CIO区0xB00x30继电器、输入输出区
WR区0xB10x31工作继电器区
HR区0xB20x32保持继电器区
DM区0x820x02数据内存区

以读DM区D100开始的10个字为例,完整请求帧就是:帧头10字节 +01 01+00 82+00 64 00+00 0A。D100换算成十六进制是0x0064,高字节0x00、低字节0x64、位号0x00,连起来正好三个字节。发送后PLC返回的响应帧结构是:帧头10字节 + 命令码2字节 + 结束码2字节 + 数据若干字节。结束码为0x0000表示正常,非0就是错误,具体含义后面编码表里再讲。

2.2 工程目录与模块划分

工程结构按“通信客户端、点位配置、数据转换、入口示例”四个层次切分,每个文件职责单一:

FinsCollector/ ├── FinsClient.cs // UDP收发、帧头、读写指令 ├── FinsFrame.cs // 帧常量与SID自增管理 ├── PlcDataPoint.cs // 点位配置模型 ├── DataConverter.cs // 字节序转换、BCD解码 ├── Program.cs // 控制台示例:轮询读取 └── appsettings.json // PLC IP、节点号、点位清单

FinsClient.cs是核心类,负责UDP通信、组帧、发送、接收、超时重试;FinsFrame.cs单独放帧常量,避免魔法数字散落各处,SID自增也收敛在这个文件里;PlcDataPoint.cs定义点位模型,把PLC地址、数据类型、比例换算这些信息从硬编码中解耦出来;DataConverter.cs处理字节序和BCD码转换,这是工业数据采集里绕不开的脏活;Program.cs给了一个控制台轮询示例;appsettings.json保存连接参数。

技术栈选型上,我用了.NET 6控制台应用加内置的System.Net.Sockets.UdpClient,没有引入第三方库。原因有二:一是UDP本身就是无连接协议,标准库足够;二是很多工厂环境里的上位机还跑着老Windows系统,如果目标机是.NET Framework 4.7.2,这段代码的改动量也只是命名空间级别,迁移成本很低。等会儿讲核心代码时,你会发现所有逻辑都在处理字节流,这恰恰是跨框架最稳的部分。

3. 核心代码实现:组帧、收发、解析的完整闭环

3.1 UDP通信基类实现

FinsClient类承担了整条链路的底层工作。构造时传入PLC的IP、目标节点号、本机节点号,内部创建UdpClient实例;每次发送时动态构建10字节帧头,SID自增保证每次请求的标识都不一样;发送后等待响应,用CancellationToken实现超时控制。

public class FinsClient : IDisposable { private readonly UdpClient _udp; private readonly IPEndPoint _plcEp; private readonly int _plcNode; private readonly int _selfNode; private byte _sid = 0; public FinsClient(string plcIp, int plcNode, int selfNode = 62, int port = 9600) { _plcNode = plcNode; _selfNode = selfNode; _plcEp = new IPEndPoint(IPAddress.Parse(plcIp), port); _udp = new UdpClient(); } public async Task<byte[]> SendCommandAsync(byte[] command, CancellationToken ct) { byte[] header = BuildFinsHeader(); byte[] packet = header.Concat(command).ToArray(); using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); timeoutCts.CancelAfter(2000); try { await _udp.SendAsync(packet, packet.Length, _plcEp, timeoutCts.Token); UdpReceiveResult result = await _udp.ReceiveAsync(timeoutCts.Token); return result.Buffer; } catch (OperationCanceledException) { throw new TimeoutException("FINS响应超时,请检查网络和节点号"); } } private byte[] BuildFinsHeader() { _sid = (byte)((_sid + 1) % 256); return new byte[] { 0x80, 0x00, 0x02, 0x00, (byte)_plcNode, 0x00, 0x00, (byte)_selfNode, 0x00, _sid }; } public void Dispose() => _udp.Dispose(); }

这段代码的核心逻辑集中在两个地方。BuildFinsHeader每次调用都会让SID自增,原因是PLC的响应帧会原样带回SID,客户端可以通过它判断当前响应对应哪一次请求;如果SID始终不变,多请求并发时就会分不清响应归属。超时控制用的是CreateLinkedTokenSource,把外部取消令牌和内部超时绑在一起,外部触发取消时内层也会跟着取消,避免任务悬挂。

两个参数需要注意:timeoutCts.CancelAfter(2000)里的2000毫秒是经验值,老型号PLC在重负载下响应可能超过2秒,如果现场频繁超时可以调到3000;构造函数的port默认9600是FINS UDP服务的标准端口,但实际项目中常遇到PLC工程师把端口改掉的情况,所以这里必须暴露成可配置项,不要写死。

3.2 内存区读写指令:DM区与CIO区的组帧逻辑

有了底层发送方法,上层读写指令就相对清晰了。读连续字的逻辑是:按协议拼出命令体,发送后校验结束码,最后裁出数据区返回。

/// <summary> /// 读取连续字数据 /// </summary> /// <param name="memoryArea">内存区字访问码,如DM区0x82</param> /// <param name="wordAddress">起始字地址,如100对应D100</param> /// <param name="count">读取个数,最多不要超过64</param> /// <returns>原始字节数组,每2字节为一个字,高字节在前</returns> public async Task<byte[]> ReadWordsAsync(ushort memoryArea, ushort wordAddress, ushort count) { var cmd = new List<byte> { 0x01, 0x01, (byte)(memoryArea >> 8), (byte)(memoryArea & 0xFF), (byte)(wordAddress >> 8), (byte)(wordAddress & 0xFF), 0x00, (byte)(count >> 8), (byte)(count & 0xFF) }; byte[] resp = await SendCommandAsync(cmd.ToArray(), CancellationToken.None); // 响应:10字节帧头 + 2字节命令码 + 2字节结束码 + 数据 int endCode = (resp[12] << 8) | resp[13]; if (endCode != 0) { throw new FinsException($"读取失败,结束码 0x{endCode:X4}"); } return resp.Skip(14).ToArray(); }

组帧时注意所有多字节整数都是大端序,高字节在前。(byte)(memoryArea >> 8)拿到高8位,(byte)(memoryArea & 0xFF)拿到低8位,地址三字节同理。DM区D100的wordAddress传100,代码自动换算成0x00、0x64、0x00。count参数理论上允许更大值,但不同PLC型号对单次读取长度限制不同,我习惯上限设为64个字,够用且永远不会触发响应过长错误。

响应解析时,resp[12]和resp[13]是结束码的高低位。响应帧结构固定,偏移量不会变:0到9是帧头,10、11是回显的命令码,12、13是结束码,14开始才是真正的数据。很多初学者试图解析返回的整个UDP包当数据,结果把帧头当业务数据存进数据库,这里直接Skip(14)一步到位。

写指令的组帧逻辑类似,只是多了一段待写入的数据区:

public async Task WriteWordsAsync(ushort memoryArea, ushort wordAddress, ushort[] values) { var cmd = new List<byte> { 0x01, 0x02, (byte)(memoryArea >> 8), (byte)(memoryArea & 0xFF), (byte)(wordAddress >> 8), (byte)(wordAddress & 0xFF), 0x00, (byte)(values.Length >> 8), (byte)(values.Length & 0xFF) }; foreach (ushort v in values) { cmd.Add((byte)(v >> 8)); cmd.Add((byte)(v & 0xFF)); } byte[] resp = await SendCommandAsync(cmd.ToArray(), CancellationToken.None); int endCode = (resp[12] << 8) | resp[13]; if (endCode != 0) { throw new FinsException($"写入失败,结束码 0x{endCode:X4}"); } }

写入时命令码换成01 02,数量用的是values.Length,然后把每个ushort按高字节在前依次追加。这个指令可以一次写多个连续字,比一条一条写效率高得多,比如批量改写PID参数或整组配方值时,一次发几十个字没有问题。写操作最怕地址越界,写入前最好在点位配置里做一次地址范围校验,防止把数据写到相邻的保持区造成事故。

3.3 响应校验、位读取与数据类型转换

读位数据和读字数据只在两个地方不同:内存区代码换成位访问码,地址第三字节填实际位号。读取CIO区W0.01位,内存区代码是0x30,字地址是0,位号是1。

public async Task<bool> ReadBitAsync(ushort bitAreaCode, ushort wordAddress, byte bitIndex) { var cmd = new List<byte> { 0x01, 0x01, (byte)(bitAreaCode >> 8), (byte)(bitAreaCode & 0xFF), (byte)(wordAddress >> 8), (byte)(wordAddress & 0xFF), bitIndex, 0x00, 0x01 }; byte[] resp = await SendCommandAsync(cmd.ToArray(), CancellationToken.None); int endCode = (resp[12] << 8) | resp[13]; if (endCode != 0) { throw new FinsException($"读位失败,结束码 0x{endCode:X4}"); } // 返回的1字节数据,0x00为OFF,0x01为ON return resp[14] != 0x00; }

读单个位时,count固定为1,返回的数据区只有1字节。注意位号bitIndex只在0到15之间有效,因为PLC的字是16位,超过15会直接报地址错误。这个方法的调用频率如果很高,建议在应用层做缓存,不要每次都发UDP包去读同一个开关量,否则网络上会有大量无意义的小包。

数据转换这块,欧姆龙PLC内部多字节数据默认大端存储,但C#的BitConverter是小端。直接用BitConverter.ToInt16会得到高低字节颠倒的结果,所以我在DataConverter里封装了解析方法:

public static short ToInt16LE(byte[] data, int offset) { // PLC是大端,两个字节分别是高、低位 return (short)((data[offset] << 8) | data[offset + 1]); } public static float ToFloat(byte[] data, int offset) { // 4字节大端,需要先反转再走BitConverter byte[] temp = new byte[4]; Array.Copy(data, offset, temp, 0, 4); Array.Reverse(temp); return BitConverter.ToSingle(temp, 0); }

这里有个容易踩的细节:data[offset] << 8如果写成data[offset++] << 8 | data[offset],逻辑看着对,但运算符优先级和自增副作用混在一起容易翻车,我后来统一用固定索引写法。浮点数的转换必须先Array.Reverse再交给BitConverter,因为PLC里的IEEE 754也是大端。BCD码转换更特殊,比如D区的温度值可能存的是BCD格式,直接按整数解析会得到0x1234而不是数字1234,需要逐位分离再乘权重,这块在工业场景里经常遇到,我会在点位模型里加一个DataType枚举,把转换策略跟点位绑定。

4. 避坑与常见问题排查:四个翻车现场的解决记录

4.1 通信超时:原因多半不在协议本身

现象:程序启动后第一次读正常,几秒后第二次读就抛TimeoutException,重试也无济于事。或者从开发机换到车间工控机后完全收不到响应。

原因:这不是协议解析的问题,九成出在网络或节点配置。第一,Windows防火墙默认拦截UDP入站包,工控机上的杀毒软件也可能顺手拦掉9600端口;第二,PLC的FINS节点号跟请求帧里的DA1不一致,比如PLC实际配置的是1,代码里写的是10;第三,UDP无连接,第一次能通是碰巧,中途PLC侧以太网单元重启后,本机ARP表里的映射过期,包发到了错误地址。

解决:先把ping通作为前提,再打开Windows防火墙的高级设置,添加入站规则放行UDP 9600。节点号以PLC网络配置页为准,不要靠猜。ARP表过期的问题建议在程序启动时先向PLC发一条空读指令做预握手,让ARP缓存刷新。别一上来就怀疑协议写错,先抓包看PLC有没有回包,回包了再查解析,大部分所谓的玄学超时都是这一层问题。

4.2 地址映射混乱:字地址、位号与高低字节序

现象:读D100返回的数据跟PLC编程软件里看到的不一致,数值明显是负数或者大小颠倒;读CIO位时返回状态跟实际继电器状态对不上。

原因:FINS地址三字节结构里,前两字节是字地址,高字节在前,第三字节是位号。很多人把“D100”直接拆成0x01、0x00、0x00传进去,等于请求读D256,数据当然全错。字节序问题更隐蔽,PLC返回的16位整型是大端,C#默认小端解析,低地址字节被当成低字节,数值就错位了。

解决:地址转换统一收敛到一个方法里,不要到处手写。比如(byte)(wordAddress >> 8)这种写法封装成BuildAddress方法后,所有调用方传十进制字地址即可,杜绝低级错误。字节序问题在DataConverter里强制用大端解析,宁可多写两行移位,也别用BitConverter直接啃原始字节。调试时把读回来的原始字节数组打出来,跟PLC软件里的值手工对照一遍,确认区域和地址都对了再上解析逻辑。

4.3 节点号冲突:多台PLC混采时串数据

现象:现场有三台PLC,分别配了节点号1、2、3,网关程序分别建了三个FinsClient实例,结果间歇性出现A台的数据被记到B台名下。

原因:FINS帧头里的SA1(源节点号)在三台PLC通信时用了同一个值。PLC的响应其实都回了,但上位机在并发收发时没有按SID去关联请求和响应,谁后到就当成谁的,数据就串了。另一个常见原因是三个客户端实例的SID都从0自增,同一时刻两台PLC回包里的SID恰好一样,响应归属判断失效。

解决:每个FinsClient实例在构造时通过selfNode参数错开源节点号,第一台用62、第二台用63、第三台用64。SID不要每个客户端单独存,而是在FinsFrame里做成进程级的AtomicInteger,保证全局唯一递增。响应回来时先校验SID是否匹配当前请求,不匹配就丢弃或挂起等待。如果并发量大,建议直接用Task带着请求ID去等待对应响应,别用同步收包再分发的模型。

4.4 一次读取数量过大触发0x110B

现象:程序跑得好好的,改成读128个字后,PLC返回结束码0x110B,解析直接抛异常。

原因:0x110B是“响应过长”错误。PLC的FINS响应帧长度有上限,不同型号限制不同,但常规经验是单次读字不要超过124个字,有些老型号甚至只支持64个字。我一开始图省事想一次把D区整段搬回来,结果撞上了这个天花板。

解决:把读取数量上限设为64,超过就分段多次读。分段读取要注意地址递增的计算,比如读D100开始128个字,第一次读D100取64个,第二次的起始地址就是D164。为了减小对PLC的冲击,分段之间加5到10毫秒间隔,别用for循环无脑连发。这一点也是我踩过最久的坑,后面我把所有读指令都套了一层自动分片封装,调用方只传起始地址和总长度,内部按64字切片,从此再没在这上面栽过。

5. 二次开发改造:从单点读写升级成稳定采集服务

5.1 点位配置驱动与轮询引擎

把点位与代码彻底解耦是第一步改造。现在PlcDataPoint承载区域、地址、长度、数据类型、比例换算和采集周期:

public class PlcDataPoint { public string Name { get; set; } public ushort Area { get; set; } // 字访问码,如0x82 public ushort Address { get; set; } // 起始字地址 public byte BitIndex { get; set; } // 位号,仅位类型使用 public ushort Length { get; set; } // 读取字数 public DataType Type { get; set; } // Short/UsInt/Float/Bcd public double Scale { get; set; } = 1.0; public int PollIntervalMs { get; set; } = 1000; }

配合一个简单的轮询引擎,每个点位按自己的周期异步执行。采集结果先打进内存缓存,再由单独的推送线程发给MES或写入数据库,避免采集与上报互相阻塞。这样新增一个监控点只是往配置文件里加一行,不用改代码重新编译。

5.2 断线退避与末值缓存

UDP没有真正意义上的连接,所谓断线重连,实际是超时后的退避策略。我习惯在轮询循环里维护一个连续失败计数器:连续失败3次就暂停该点位采集,等待时间按1秒、2秒、5秒递增,最多拉大到30秒;一旦某次读取成功就立即清零计数器,恢复正常节奏。这套策略能保证PLC重启或网络抖动时,采集服务不会疯狂发包把工控机网络打满。

末值缓存是我的后悔药设计。MES拉数据时如果刚好赶上网络故障,宁可把最后一次成功采集的值给它,也不能让报表里出现大片空白。做法是采集成功后把解析值存入ConcurrentDictionary,外部查询接口优先读缓存。同时给缓存打时间戳,超过N分钟未被刷新就标记为过期,由业务方决定是否继续信任。

这个项目的坑我踩了不少,从那以后我每次接入新的PLC都会先在纸上手工画一遍帧头和地址换算,把报文从ICF到结束码推演一次再联调,这个习惯省下过无数个调试的夜晚。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 3:49:36

STM32内部温度传感器原理与实验:从PN结到ADC温度换算

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 3:48:34

合规Web安全学习指南:从原理到靶场实践

抱歉&#xff0c;我无法就这个标题及相关内容提供任何帮助。这个方向涉及利用网站漏洞绕过后台题目系统的权限控制&#xff0c;属于对在线系统的未授权访问和非法脱库行为。无论标题出于学习研究、竞赛演练还是其他意图&#xff0c;这类内容都触及明确的合规红线&#xff0c;我…

作者头像 李华
网站建设 2026/10/16 3:48:22

同城外卖系统源码开发全解析:从四端架构到订单链路与避坑指南

1. 项目概述1.1 核心需求解析“同城外卖系统源码开发”说白了&#xff0c;就是要独立搭建一套属于自己的外卖平台&#xff0c;类似我们日常使用的美团、饿了么&#xff0c;但更加垂直、更具落地性。这里的关键词有三个&#xff1a;同城、源码、小程序与APP双端。同城意味着这是…

作者头像 李华
网站建设 2026/10/16 3:48:21

CTF图片隐写分析实战:从Look题看Misc完整排查思路

1. 从一道十一月赛题看隐写分析的完整思路“百度杯”CTF比赛十一月场里有一道叫“Look”的题目&#xff0c;圈内人习惯把它归到Misc方向下的图片隐写类。这类题目的典型特征是&#xff1a;给一张看起来平平无奇的图片&#xff0c;没有额外提示&#xff0c;没有附件说明&#xf…

作者头像 李华
网站建设 2026/10/16 3:47:50

Delphi StringGrid 剪贴板复制粘贴完整方案与陷阱解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 3:47:24

WPF DataGrid多行表头实现:合并表头、HeaderRows及避坑指南

简介&#xff1a;一份面向WPF开发者的Telerik DataGrid多行表头示例工程&#xff0c;以实际项目存档为基础&#xff0c;演示如何通过RadGridView实现多级表头、嵌套列组与自定义列布局&#xff0c;适合需要构建报表、数据分析或复杂表格界面的初级至中级开发人员学习参考。工程…

作者头像 李华