☰
C# 直连欧姆龙 PLC:FINS 协议通信实战与避坑指南
2026/11/2 13:21:46 网站建设 项目流程

简介:这份资源面向具备一定C#基础、希望打通上位机与工业现场设备的开发者,聚焦用FINS协议与欧姆龙PLC建立通信这一常见需求。压缩包共45个文件,约4.38MB,以9个cs源码文件为核心,配合sln与csproj工程文件可直接打开调试;另有png协议格式与网络模型示意图、pdf通讯手册、docx直播素材及txt代码说明,并附带NetAssist网络调试工具,便于对照抓包验证。已有1120人学习下载。读者可从中获得TCP连接建立、FINS帧结构拼装、请求发送与响应解析的完整示例,理解命令代码、PLC地址与数据长度的组织方式,并借助通讯手册核对不同机型差异;课件与图示有助于梳理协议分层,调试工具可用于排查收发异常,适合作为PLC数据读写功能开发的入门参照。

1. 从一条产线调试日志说起:C# 直连欧姆龙 PLC 的 FINS 方案

去年帮一个做包装设备的团队排查通讯问题,他们的上位机用 C# 写,要实时读欧姆龙 PLC 的 DM 区和 CIO 区,之前用的是一个第三方控件,授权到期后产线一停就是半天。后来换成自己实现 FINS 协议,代码量不到八百行,反而更稳。这件事让我意识到,很多做工业上位机的开发者对 FINS 有畏难情绪,觉得协议文档厚、字节序绕、地址映射复杂,实际上核心报文结构就那么几种。这份 C# 通过 FINS 读取欧姆龙 PLC 的示例代码,解决的就是「不依赖商业控件、用原生 Socket 直连 PLC」这个诉求,适合做设备数据采集、产线监控、MES 对接的 C# 开发者,也适合想搞懂 FINS 报文到底长什么样的新手拿来对照调试。

2. FINS 协议拆解:报文结构、地址编码与握手流程

2.1 为什么选 FINS 而不是 Host Link

欧姆龙 PLC 常见的上位机通讯方式有两类:Host Link(串口或转以太网的 ASCII 命令)和 FINS(Factory Interface Network Service)。Host Link 的好处是命令简单,发RD 000100这样的字符串就能读数据,但它的短板也明显——串口速率受限,转以太网模块后延迟不稳定,而且一条命令只能读一个地址,批量采集时轮询效率很低。FINS 走 UDP 或 TCP,报文是二进制的,单帧可以携带多个地址的读写请求,在需要每秒采集几十个点位的场景下优势明显。

这份示例代码选的是 FINS/TCP,端口默认 9600。选 TCP 而不是 UDP 的理由很实际:UDP 虽然快,但丢包后没有重传机制,产线上一个数据包丢了,上位机可能显示一个错误的温度值,这种玄学问题排查起来非常痛苦。TCP 有连接状态可查,断线能立刻感知,对于「数据正确性优先于极致延迟」的监控场景更合适。

FINS 报文分两层:FINS 帧头(10 字节)+ 命令数据。帧头结构如下:

字段长度说明
ICF1 字节信息控制字段,命令请求一般为 0x80
RSV1 字节保留,固定 0x00
GCT1 字节网关计数,固定 0x02
DNA1 字节目标网络号,本机直连一般 0x00
DA11 字节目标节点号(PLC 的 FINS 节点地址)
DA21 字节目标单元地址,CPU 单元为 0x00
SNA1 字节源网络号,0x00
SA11 字节源节点号(上位机节点,TCP 下可填 0x00)
SA21 字节源单元地址,0x00
SID1 字节服务 ID,用于匹配请求与响应,自增即可

帧头之后是命令码(2 字节,如 0x0101 表示内存区读取)和具体参数。地址编码是新手最容易翻车的地方:欧姆龙的内存区用「区代码 + 字地址 + 位地址」表示,DM 区代码 0x82,CIO 区 0x30,W 区 0x31,H 区 0x32。字地址是 16 位,低字节在前。比如读 DM100 开始的 10 个字,命令数据部分要写成82 00 64 00 00 0A——区代码 0x82、字地址 0x0064(即 100)、位地址 0x00、读取长度 0x000A。

2.2 TCP 连接建立与 FINS 节点地址协商

FINS/TCP 不是连上就能直接发命令的,握手阶段有一个「客户端节点地址协商」过程。客户端连上 9600 端口后,先发一条 20 字节的握手帧,告诉 PLC「我想用哪个节点号」;PLC 回一条确认帧,里面包含它分配的节点号。这个节点号后续要填进 FINS 帧头的 SA1 字段,填错了 PLC 会直接丢弃报文,而且不返回任何错误——这就是很多人「连上了但读不到数据」的根因。

握手帧的结构是:46 49 4E 53("FINS" 的 ASCII)+ 4 字节长度 + 4 字节命令(0x00000000 表示节点地址请求)+ 4 字节错误码 + 4 字节客户端节点。PLC 的响应里,最后 4 字节是它分配的节点地址。示例代码里把这一步封装成了FinsTcpClient.Connect()方法,内部先发握手帧、解析响应,再进入正常命令循环。

// FINS/TCP 握手:请求节点地址 byte[] handshake = new byte[20]; Encoding.ASCII.GetBytes("FINS").CopyTo(handshake, 0); // 长度字段:从命令码开始算,固定 12 BitConverter.GetBytes(12).Reverse().ToArray().CopyTo(handshake, 4); // 命令 0x00000000 = 节点地址请求 handshake[8] = 0x00; handshake[9] = 0x00; handshake[10] = 0x00; handshake[11] = 0x00; // 客户端期望节点号,填 0 表示由 PLC 分配 handshake[16] = 0x00; handshake[17] = 0x00; handshake[18] = 0x00; handshake[19] = 0x00; tcpStream.Write(handshake, 0, 20); byte[] resp = new byte[24]; int n = tcpStream.Read(resp, 0, 24); // resp[20..23] 为 PLC 分配的节点号,大端序 int nodeId = (resp[20] << 24) | (resp[21] << 16) | (resp[22] << 8) | resp[23];

这段代码里有两个细节值得说。第一,长度字段和节点号都是大端序(网络字节序),而 FINS 命令数据里的字地址是小端序,同一个协议里混用两种字节序,这是欧姆龙的历史遗留,写代码时必须在每个字段上单独确认,不能一刀切用BitConverter。第二,Read方法不保证一次读满 24 字节,生产环境要循环读到够数为止,示例里为了可读性简化了,实际使用时建议包一层ReadExact。

2.3 内存区读取命令的组装与响应解析

握手完成后,读 DM 区的完整报文是:10 字节 FINS 帧头 + 2 字节命令码 0x0101 + 2 字节固定 0x0000 + 6 字节地址参数。响应报文是 10 字节帧头 + 2 字节命令码 + 2 字节结束码 + 数据区。结束码 0x0000 表示成功,非零值对应具体错误,比如 0x1103 表示地址越界,0x0401 表示不支持的命令。

byte[] BuildReadCommand(byte areaCode, ushort wordAddr, byte bitAddr, ushort count, byte sid) { var cmd = new byte[20]; cmd[0] = 0x80; // ICF:命令请求 cmd[1] = 0x00; // RSV cmd[2] = 0x02; // GCT cmd[3] = 0x00; // DNA cmd[4] = (byte)plcNode; // DA1:PLC 节点号 cmd[5] = 0x00; // DA2:CPU 单元 cmd[6] = 0x00; // SNA cmd[7] = (byte)localNode; // SA1:握手拿到的本机节点 cmd[8] = 0x00; // SA2 cmd[9] = sid; // SID cmd[10] = 0x01; cmd[11] = 0x01; // 命令码:内存区读取 cmd[12] = 0x00; cmd[13] = 0x00; // 固定 cmd[14] = areaCode; // 区代码,DM=0x82 cmd[15] = (byte)(wordAddr & 0xFF); // 字地址低字节 cmd[16] = (byte)((wordAddr >> 8) & 0xFF); // 字地址高字节 cmd[17] = bitAddr; // 位地址 cmd[18] = (byte)(count & 0xFF); // 读取字数低字节 cmd[19] = (byte)((count >> 8) & 0xFF); // 读取字数高字节 return cmd; }

参数说明:areaCode决定读哪个区,DM 区 0x82、CIO 区 0x30、W 区 0x31;wordAddr是起始字地址,注意欧姆龙地址从 0 开始,界面上显示的 DM100 对应wordAddr = 100;count是读取字数,单帧上限取决于 PLC 型号,一般 CP 系列建议不超过 30 个字,超了会返回结束码 0x1103。响应解析时,先校验resp[12..13]的结束码,再取resp[14..]的数据,每两个字节按小端序拼成一个ushort。

3. 把示例跑起来:环境配置、连接参数与读写封装

3.1 开发环境与 PLC 侧准备

示例代码基于 .NET Framework 4.7.2 和System.Net.Sockets,没有第三方依赖,Visual Studio 2019 及以上直接打开.sln就能编译。如果目标机器是 .NET 6/8,把项目文件里的TargetFramework改掉即可,代码本身不用动,因为用到的 API 都是跨版本兼容的。

PLC 侧需要确认三件事。第一,PLC 的以太网单元或内置 EtherNet/IP 口要配好 IP,并且和上位机在同一网段;第二,FINS/TCP 端口 9600 要在 PLC 的「通信设置」里启用,部分型号默认只开 UDP;第三,PLC 的节点号(FINS 节点地址)要记下来,通常在以太网设置页面能看到,这个值要填进plcNode字段。我一般会先用欧姆龙的配置工具 ping 一下,确认网络通、端口开,再跑 C# 代码,避免把网络问题误判成协议问题。

// 连接参数配置示例 var client = new FinsTcpClient( ip: "192.168.1.10", // PLC 的 IP port: 9600, // FINS/TCP 默认端口 plcNode: 1, // PLC 的 FINS 节点号,从 PLC 设置里查 timeoutMs: 2000 // 读写超时,产线环境建议 1000~3000 ); client.Connect(); // 内部完成 TCP 连接 + 节点地址握手

timeoutMs这个参数值得单独说。设太短,网络稍有抖动就抛超时异常,上位机频繁重连反而更不稳定;设太长,PLC 掉线后界面会卡住好几秒。我的经验是产线有线网络设 1500ms,跨网段或无线桥接设 3000ms,同时在调用层做重试,而不是靠加大超时硬扛。

3.2 批量读取与数据类型转换

实际项目里很少只读一个字,常见的是读一段连续的 DM 区做趋势记录。示例里的ReadWords方法返回ushort[],再根据业务语义转成short、int或float。这里有个坑:欧姆龙的 32 位整数和浮点数在内存里是「低字在前、高字在后」,和 x86 的小端序一致,但和很多 PLC 品牌相反。如果对接的是第三方系统,数据对不上,先查这个。

ushort[] raw = client.ReadWords(areaCode: 0x82, startAddr: 100, count: 10); // 16 位有符号整数 short temp = (short)raw[0]; // 32 位整数:低字在前 int counter = raw[1] | (raw[2] << 16); // 32 位浮点:同样低字在前,用 BitConverter 重组 byte[] floatBytes = new byte[4]; BitConverter.GetBytes(raw[3]).CopyTo(floatBytes, 0); BitConverter.GetBytes(raw[4]).CopyTo(floatBytes, 2); float pressure = BitConverter.ToSingle(floatBytes, 0);

逻辑说明:raw[1] | (raw[2] << 16)把两个 16 位字拼成 32 位,raw[1]是低 16 位。浮点数不能直接强转,必须按字节重组,因为ushort到float没有隐式转换,而且字节顺序要和 PLC 内存布局对齐。参数上,count要按实际需要的字数传,读 10 个字就传 10,不要图省事一次读 100 个再截取,多余的数据会增加网络负载,在多点位轮询时累积起来很可观。

3.3 写入命令与位操作

写 DM 区的命令码是 0x0102,报文结构和读类似,只是地址参数后面跟的是要写入的数据。写单个位(比如 CIO 区的 0.01)时,区代码用 0x30,位地址填 0x01,数据部分用 0x0001 表示置 ON、0x0000 表示置 OFF。示例代码把写操作封装成WriteWords和WriteBit两个方法,内部都做了 SID 自增和响应校验。

// 写 DM200 = 1234 client.WriteWords(areaCode: 0x82, startAddr: 200, new ushort[] { 1234 }); // 置位 CIO 0.01 client.WriteBit(areaCode: 0x30, wordAddr: 0, bitAddr: 1, value: true);

写操作比读操作更需要谨慎。产线上一个误写可能直接让设备动作,所以示例里在WriteWords内部加了长度校验和结束码检查,写入失败会抛异常而不是静默返回。实际使用时,我建议在业务层再加一道「写前读回确认」——写完立刻读同一地址,比对值是否一致,虽然多一次往返,但能挡住大部分因网络半包导致的写入异常。

4. 避坑与排查:五个让通讯「时好时坏」的真实原因

4.1 现象:连接成功但读数据超时,PLC 无响应

原因:FINS 帧头的 SA1 字段填了 0 或者填了错误的节点号。TCP 握手阶段 PLC 分配的节点号必须原样填回,填 0 在部分型号上会被当作非法源地址丢弃。解决:把握手响应里解析出的nodeId存成成员变量,每次组帧都用它,不要硬编码。

4.2 现象:读回来的数值明显偏大或偏小,像是放大了 256 倍

原因:字地址的字节序搞反了。FINS 命令数据里的字地址是小端序,但帧头里的节点号是大端序,有人图省事全用BitConverter.GetBytes再Reverse,结果地址字段被反转。解决:帧头字段手动按大端拼,命令数据里的地址和长度手动按小端拼,两套逻辑分开写,别共用工具方法。

4.3 现象:单次读 50 个字正常,读 100 个字返回结束码 0x1103

原因:单帧读取长度超过 PLC 型号限制。CP1 系列一般上限 30 字左右,CJ 系列可以到 100 字以上,但还要看以太网单元的缓冲区配置。解决:在ReadWords里按 30 字分片,循环读取后拼接,分片大小做成可配置参数,换 PLC 型号时只改一个值。

4.4 现象:运行几小时后连接断开,重连后前几条命令失败

原因:TCP 连接被中间交换机或防火墙按空闲超时回收,而代码没有心跳机制。FINS 本身没有规定心跳,但可以周期性读一个固定地址(比如 DM0)当保活。解决:加一个 30 秒的定时器做保活读,同时监听Socket的断开事件,断线后延迟 1 秒重连,重连后重新走一遍握手拿节点号。

4.5 现象:多线程同时调用读写,偶发响应错位

原因:FINS 的请求和响应靠 SID 匹配,但示例的tcpStream是共享的,两个线程同时写会导致报文交错。解决:给通讯层加锁,或者用SemaphoreSlim限制同时只有一个请求在途,产线采集频率不高时串行化完全够用,别为了吞吐量引入复杂的异步队列。

5. 进阶技巧:用抓包对照和分片策略把 FINS 调稳

调试 FINS 最有效的手段不是读文档,是抓包对照。我习惯在 PLC 和上位机之间接一个镜像口,用 Wireshark 过滤tcp.port == 9600,先看握手帧的 20 字节是否和代码里拼的一致,再看读命令的 20 字节里地址字段有没有错位。有一次现场读 CIO 区一直返回 0x0401,抓包发现区代码填成了 0x82(DM 区),改回 0x30 立刻正常——这种问题看代码看不出来,看字节流一眼就定位。

分片策略上,我一般把单次读取控制在 20 字以内,即使 PLC 支持更长。原因不是协议限制,而是产线网络里常有不稳定的交换机,帧越长,半包和重传的概率越高,20 字一帧的往返时间在千兆网络下也就几毫秒,对 100ms 级的采集周期完全够用。分片读取的代码结构是外层循环算偏移、内层调ReadWords,每片之间不加延时,靠 TCP 自身的流控保证顺序。

还有一个容易被忽略的点:SID 的回绕。SID 是 1 字节,0 到 255 循环,如果代码里用sid++且不处理溢出,跑到 256 时会抛异常或者回绕到 0,而 0 在某些 PLC 上保留不用。稳妥的写法是sid = (byte)((sid + 1) & 0xFF),并且在 0 时跳过。这个细节在短时间测试里根本暴露不出来,但产线连续跑一周就会撞上,属于典型的「后悔药」型问题。

从那以后我每次接新的欧姆龙型号,都强制先抓一轮包、跑一遍 24 小时连续读写,确认 SID 回绕、分片边界和断线重连都正常,再上产线。这套示例代码的价值不在于它多完整,而在于它把 FINS 的报文结构摊开给你看,你可以照着改、照着抓包对照,比对着几百页手册猜要快得多。希望帮到你。

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

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

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

立即咨询