简介:欧姆龙FINS协议动态库是面向PLC及自动化设备通信开发的软件组件,适合使用Delphi、VC++、VB等环境的自动化工程师与上位机开发者。FINS协议是欧姆龙设备间数据交换的通用网络协议,此动态库封装了寄存器读写、远程I/O、设备诊断等核心功能,并借助多线程串口机制支持多设备并发通信,有效提升系统响应性。压缩包共42个文件,体积1.32MB,涵盖DLL库、CHM官方手册以及Delphi、Visual C++、Visual Basic三套演示工程,包含头文件、源码、编译好的可执行程序及工程配置等,便于在对应环境中直接查看调用逻辑与参数设置。已有874人学习使用。对需要快速集成欧姆龙通信能力的开发者而言,这套资源提供了完整的手册、可运行的范例与可直接引用的DLL,能显著降低协议对接门槛。无论是新建项目还是老设备维护,都能借助示例快速定位通信参数,减少调试时间。
1. 欧姆龙FINS协议动态库:一条命令读写PLC点位,为什么还要自己封装
做设备上位机的人,十有八九都跟欧姆龙PLC打过交道。CP1H、CJ2M、NJ/NX系列,串口走的是Host Link,以太网走的就是FINS/UDP或FINS/TCP。很多项目赶进度,直接拿厂商自带库或者网上找的Demo去连,结果设备联调的时候才发现问题:要么报文长度算不对,要么读连续点位超过上限直接被PLC拒绝,要么断线之后不会自动重连,只能重启上位机。这套欧姆龙FINS协议动态库,本质上是把FINS指令封装成一组可直接调用的函数,上层不用关心字节序、帧格式和重传逻辑。它能解决的就是一件事:让你用几行代码就能完成PLC的IO读写、DM区读写、时钟读取和远程启停,而不是每次都从套接字开始自己拼报文。适合谁?适合做设备集成、MES对接、以及要维护多条产线的上位机开发者。这文章我会按实际拆库的思路来讲,先把协议底子过一遍,再讲动态库的设计和封装,然后给出具体调用实例和参数说明,最后把调试里最容易踩的几个坑列出来。
2. FINS报文拆解与动态库分层:先搞清楚帧格式再写代码
2.1 FINS命令帧的固定结构,和你必须知道的两个字节序问题
FINS协议本身并不复杂,一条完整的以太网FINS帧由两部分组成:FINS/以太网头部(也叫FINS Header)和FINS命令区。头部固定10个字节,依次是ICF、RSV、GCT、DNA、DA1、DA2、SNA、SA1、SA2、SID。命令区才是真正干活的,包含MRC(主命令码)、SRC(子命令码)以及参数。
先说最关键的字节序问题。FINS协议规定多字节整数按高字节在前(Big-Endian)传输,而我们的PC一般是x86架构,内存里是低字节在前。举个例子,要写的DM区地址是D100,十六进制就是0x0064,在报文里必须以00 64顺序发送,而不是64 00。这个坑特别隐蔽,因为有时候你写的是100以内的地址,高低字节颠倒后对不上,但PLC那边可能返回一个错误码。
第二个字节序问题是寄存器值本身。比如读回来的通道值0x1234,如果直接把两个字节拼成short,高字节在前才能得到正确的数值。动态库在做数据转换时,必须提供一个从字节数组到数值类型的显式接口,否则在C#里直接用BitConverter会按本机字节序解释,结果就是读回来的温度值忽大忽小。
FINS报文的响应格式也值得一提。正常响应中,MRC和SRC会和请求一致,然后是结束码(两个字节)。如果结束码是0x0000,表示正常;其他值如0x0101表示头码异常,0x1101表示地址越界,0x2002表示数据长度错误。动态库最好把结束码翻译成可读字符串,否则现场调试时看到一行十六进制错误码,还得翻手册。
2.2 UDP还是TCP:选型要从PLC型号和网络结构出发
FINS支持UDP和TCP两种承载方式,但两者的行为差异很大。UDP方式下,PLC的实际端口号是9600,上位机随便用一个本地端口发送命令帧,PLC处理完直接回给这个端口。TCP方式下,PLC作为服务端在9600端口监听,上位机主动连接,建立的是长连接。
我一般建议优先用UDP,原因有三个。第一,UDP的FINS协议是无连接的,不需要维护会话状态,PLC那边资源占用小,长时间运行的稳定性反而好。第二,UDP的帧里带了DNA/DA1/DA2这些节点地址信息,理论上可以通过一个端口同时访问多个PLC。第三,TCP方式在PLC侧有四五个上位机同时连接的限制,产线上如果有两台工控机同时监控,很容易就把连接数占满了。
但有一种情况必须用TCP:PLC型号较老且固件版本较低时,UDP响应可能不稳定。这个问题在CJ1M上出现过,后来查手册发现是UDP广播响应被交换机丢弃。如果你在现场遇到UDP发出去没有响应,而TCP一切正常的情况,优先怀疑交换机的IGMP Snooping设置和端口隔离策略。
2.3 动态库的分层设计:传输层、命令层和数据转换层各干什么
拿到这套动态库,不要急着写业务代码,先把它的分层结构摸清楚。设计合理的库会分成三层。传输层负责套接字收发、超时控制和重发逻辑;命令层负责拼接FINS命令区、解析响应结束码;数据转换层负责字节序转换、位打包和数值类型换算。
传输层的核心状态机有三个状态:空闲、等待响应、重发等待。发送命令后,如果超过设定超时时间没收到响应,库会自动重发。这里参数很关键:重发次数一般设2到3次,超时时间设800到1500毫秒比较合适。短了容易误判超时,长了会让上层业务卡顿。
命令层是整个动态库的精华所在。一条典型的FINS读命令,命令区格式是:0101(读DM区是0101,读CIO区是0101+地址类型)、起始地址、位数、读取数量。拼接时要注意,起始地址分成三个字节:字地址高字节、字地址低字节、位地址字节。如果读CIO区的W0.05,那么字地址是0x0000,位地址是0x05。这个位地址和字地址的组合方式,最容易写错。
数据转换层做的事情更琐碎:读回来的数据如果是字符串,每个字符占两个字节,高字节在前;如果是32位整数,由两个16位寄存器组成,K3M+1存放高16位还是低16位取决于PLC的设定。动态库如果能提供按数据类型自动解析的接口,就能省掉大量手工移位的工作。
3. 动态库接口设计与开发环境搭建:从项目结构到第一个能跑的DLL
3.1 先理清项目结构:四个源码文件就够了
这套动态库的源码结构,按功能拆成四个文件就够了,没必要搞得太复杂。第一个是FinsFrame.cs,定义报文帧结构和序列化逻辑;第二个是FinsNode.cs,管理PLC连接信息和通信参数;第三个是FinsCommand.cs,封装各类读写指令的构造方法;第四个是FinsClient.cs,对外暴露全部API入口,内部协调前面三个模块。
开发环境方面,我用的是Visual Studio 2022,目标框架选.NET Framework 4.7.2。为什么不选.NET 6或更高版本?因为很多设备上位机跑在Windows 7的工控机上,.NET Framework兼容性最稳。当然,如果你确定现场系统都是Windows 10以上,选.NET 8也没问题,而且性能更好。
实际项目里,我一般把动态库输出成两个版本:Debug版留着详细的内部日志,Release版关闭日志以提升性能。库内通过条件编译指令控制,不需要维护两份代码。
3.2 连接对象的初始化参数,每一项都有讲究
初始化一个Fins连接,参数大概有以下几个:PLC节点地址(DA1)、上位机节点地址(SA1)、PLC型号、IP地址、端口号、协议类型(UDP/TCP)、超时时间、重发次数。这里要特别说下节点地址。FINS协议里,节点地址就是FINS帧里的DA1和SA1字段。欧姆龙PLC一般默认节点号是1,上位机节点号可以随便设一个,比如63(0x3F),只要不超过0xFE就行。
如果你的网络里有多台PLC,每台PLC的FINS节点号必须不一样。这个节点号在PLC的CX-Programmer或Sysmac Studio里的“CPU单元设定”中配置,而不是在Windows网络设置里配的。很多人第一次接触时,会以为节点地址就是IP地址的最后一位,实际上两者没有直接对应关系,需要手动建立映射规则。
连接初始化代码示例:
# Python调用示例,假设动态库导出了FinsClient类 from ctypes import * # 加载动态库 fins_dll = CDLL("FinsClient.dll") # 初始化连接参数结构体 class FinsConnection(Structure): _fields_ = [ ("ip", c_char_p), ("port", c_uint16), ("da1", c_ubyte), # PLC节点地址 ("sa1", c_ubyte), # 上位机节点地址 ("protocol", c_ubyte), # 0=UDP, 1=TCP ("timeout_ms", c_uint16), ("retry_count", c_ubyte) ] conn = FinsConnection(ip=b"192.168.1.10", port=9600, da1=1, sa1=63, protocol=0, timeout_ms=1000, retry_count=2) fins_dll.Fins_Connect(byref(conn)) print("PLC连接成功,内部句柄: ", conn)这段示例的动态库封装思路是:以C接口形式导出函数,Python通过ctypes直接调用。注意protocol字段是1个字节,0表示UDP、1表示TCP;timeout_ms是超时时间,单位毫秒;retry_count是失败后的重发次数。连接对象初始化后,由动态库内部维护套接字和状态,不需要上层再管。
3.3 把DLL编译输出:导出函数表怎么设计才不被业务方吐槽
编译动态库之前,先设计好导出函数表。接口设计的原则是少而精,越底层越稳定。我见过一些网上的开源库,把上百个函数一股脑导出来,结果用的人根本分不清该调哪个。正常场景下,一个FINS动态库需要导出的函数只有八九个:
// FinsClient.h 导出函数声明 extern "C" { __declspec(dllexport) int Fins_Connect(FinsConnection* conn); __declspec(dllexport) int Fins_Disconnect(int handle); __declspec(dllexport) int Fins_ReadWord(int handle, int area, int addr, int count, unsigned short* values); __declspec(dllexport) int Fins_WriteWord(int handle, int area, int addr, int count, unsigned short* values); __declspec(dllexport) int Fins_ReadBit(int handle, int area, int addr, int bit, int count, unsigned char* values); __declspec(dllexport) int Fins_WriteBit(int handle, int area, int addr, int bit, int count, unsigned char* values); __declspec(dllexport) int Fins_ReadClock(int handle, char* datetime); __declspec(dllexport) const char* Fins_GetErrorDesc(int error_code); }这里的area参数是存储区枚举值:DM区对应0x02,CIO区对应0x30,WR区对应0x31,HR区对应0x32。addr是字地址,bit是位地址(0到15)。handle是连接成功后的句柄,多路连接时用来区分不同的PLC实例。返回值为0表示成功,负数表示通信层错误,正数表示PLC返回的业务错误码,用Fins_GetErrorDesc去查描述。这样的设计把错误处理统一收口,业务方拿到返回值后只需要分两类处理:重试或报警。
4. 用动态库完成点位读写:三个核心函数的参数与调用实例
4.1 读DM区域数值:从发送到解析的完整链路
DM区在欧姆龙PLC里是最常用的数据存储区,断电保持是它与CIO区最大的区别。读DM区的调用很简单,但理解内部的参数传递很关键。看下面这段代码,使用C#调用动态库:
// 引用动态库 [DllImport("FinsClient.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int Fins_ReadWord(int handle, int area, int addr, int count, [Out] ushort[] values); // 业务调用示例 ushort[] data = new ushort[10]; int ret = Fins_ReadWord(handle, 0x02, 100, 10, data); if (ret == 0) { // 读取成功,data[0]对应D100,data[9]对应D109 Console.WriteLine($"D100的值 = {data[0]}"); Console.WriteLine($"D105的值 = {data[5]}"); } else { string err = Fins_GetErrorDesc(ret); Console.WriteLine($"读取失败: {err}"); }这里的关键参数是addr=100,对应PLC的D100,count=10表示连续读10个字。内部实现的逻辑是:先构造0101命令帧,起始地址部分拆成三个字节——字地址高字节0x00、低字节0x64、位地址0x00,然后发送并等待响应。收到响应后检查结束码,为0则把后面的数据字节按Big-Endian解析成ushort数组。数据量有一个边界必须记住:FINS协议规定单次读取DM区的最大字数是960个字(0x03C0),超过会被PLC拒绝并返回错误码0x1101。
4.2 写单个保持继电器位:位地址的换算逻辑
写位比写字要多一层换算,因为FINS协议对位地址的编码是第一字节高6位固定为0,低2位和地址相关。我这里直接给一个换算规则。比如要写CIO区的W0.05这个位,字地址是0,位号是5,那么命令参数里addr填0,bit填5。动态库内部构造地址字节序列时,会做如下计算:
// 位地址编码逻辑 int wordAddr = 0; // W0 int bitIndex = 5; // .05 // 三个字节分别对应:字地址高字节、字地址低字节、位地址字节 byte addrHi = (byte)(wordAddr >> 8); byte addrLo = (byte)(wordAddr & 0xFF); byte bitAddr = (byte)bitIndex; // 注意bitIndex范围是0~15,不会溢出位地址字节的取值范围是0x00到0x0F,对应16个位。这里有个常见误解:有人以为bit这个参数可以写到0x80开头表示强制置位,实际上FINS协议里强制的标志位不在这个字段里。 强制置位要走专门的命令码,普通位读写改变的是保持继电器的当前状态,断电后保持上一次的值。 如果业务上需要强制输出,得单独调用强制置位/复位的命令接口。
写位操作的时序要特别留意。如果上位机发完写位命令后立刻断电,可能存在PLC缓存区没来得及刷入的问题。虽然概率很小,但这种场景下我一般会在写完位后延时50毫秒再发一次读回校验,确保值确实写进去了。
4.3 读时钟:不占PLC扫描时间的小技巧
读时钟这个功能很多人忽略,其实它特别实用。比如产线换班时要记录生产时间戳、MES要求上传任务起止时间,如果上位机用本机时间,一旦工控机时间漂移,记录下来的时间就失真了。FINS读时钟命令码是0701,不需要附加参数,响应会返回7个字节:时分秒、年月日。动态库封装时要注意,返回的时间是BCD编码,比如0x23表示23点,0x07表示7号。有些人直接把BCD码当十进制用,就会读出一堆异常数据。
// 读取PLC时钟 [DllImport("FinsClient.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int Fins_ReadClock(int handle, StringBuilder datetime); StringBuilder dt = new StringBuilder(32); int ret = Fins_ReadClock(handle, dt); if (ret == 0) { // 返回格式:YY-MM-DD HH:MM:SS Console.WriteLine($"PLC当前时间: {dt}"); }如果发现读出来的年份不对,比如显示110年,说明PLC里的年份设置是两位数映射到2000年以后,需要自己加2000。不同PLC型号的处理不同,NJ系列读出来直接就是四位年份,CJ系列则是两位。这个差异动态库没法完全屏蔽,只能让调用方知道有这回事。
5. FINS通信常见问题排查:超时、重连与响应异常的血泪记录
5.1 命令发出后长时间没有响应,但PLC面板RUN灯正常
现象:上位机发送读命令后,一直等到超时也没有收到任何响应,但PLC本身运行正常,用编程软件在线监视也没问题。
原因:排查后发现是交换机的IGMP Snooping把UDP单播流量隔离开了。FINS UDP虽然目的IP是PLC的地址,属于单播,但有些二层交换机开启组播过滤后,会对目的端口9600的报文进行特殊处理。还有一种可能是PLC配置了IP地址过滤,只允许特定上位机IP访问PLC的FINS服务,但工控机换过IP没有同步修改。
解决:先在PLC上用编程软件确认FINS节点号和IP地址的映射关系,然后在交换机上关闭IGMP Snooping或者把9600端口加入白名单。如果还不能解决,改用TCP方式连接验证是不是UDP报文被网络中间层丢弃。网络里如果有防火墙软件,记得检查工控机的出站规则,Windows自带防火墙默认会拦截未经允许的入站响应。
5.2 读回来的数据全部是0或者0xFF,不是预期的实际值
现象:FINS_ReadWord返回成功,但data数组的内容要么全是0,要么全是0xFFFF。写进去的值再读出来对不上。
原因:地址区域搞错了。比如你写的地址是D100,但动态库的area参数传成了0x30(CIO区),这时如果CIO区的W100恰好是空的,读出来全是0。如果传成0x31(WR区),可能读出来是保持继电器的实际值,和你预期的D区数据完全不是一回事。
解决:先确认PLC组态里数据区分布,然后逐个area参数试读。我一般会让库增加一个调试函数,读回时把原始报文打印出来,看PLC返回的结束码和第一个数据字节。如果返回码是0x1101(地址越界),大概率是addr+count超出了DM区的实际范围,比如DM区只有2000个字,你却从D1800开始读300个字。
5.3 长时间运行后偶尔出现一次读失败,重启程序后恢复
现象:程序运行两三个小时后,某一条读指令偶发超时,之后又自动恢复正常。有时候一天出现一两次,不好复现也抓不到规律。
原因:FINS UDP的响应端口是随机的本地端口,操作系统回收TIME_WAIT状态连接时,如果该端口被占用,PLC的响应报文会送达但是Socket接收队列处理不过来。更常见的原因是动态库内部的重传机制没有生效:第一次发送后超时,重发报文被PLC当作重复请求,PLC直接丢弃了。
解决:把重发次数调到3次,并且重发间隔要递增,比如第一次800ms、第二次1200ms、第三次1800ms。另外,在动态库内部最好增加一个序列号机制,每发一条新命令时SID(服务标识符)要递增,PLC通过SID来识别新命令。如果SID不变,PLC会认为是重复帧而不响应。这个SID递增逻辑是判断一个FINS动态库是否成熟的重要指标。
5.4 写位操作后,PLC的对应输出点没动作
现象:WriteBit返回成功,但PLC指示灯没亮,用编程软件在线看那个点位状态也没变化。
原因:写的是CIO区的软继电器,不是物理输出点。比如你写的是W0.00,这个地址在PLC程序里如果被编程序覆盖了,外部写入的临时值会被下一个扫描周期刷掉。另一种情况是PLC运行模式下,程序在上位机写入之后又执行了OUT指令,把值覆盖了。
解决:先明确写的地址是什么类型的存储区。如果是物理输出点,要看PLC配置里对应的输出通道是CIO 100.00还是其他地址。更可靠的做法是让PLC程序里预留一个上位机写锁存区,上位机把目标值写到这个区,PLC程序再负责把锁存区的值搬运到实际输出点。这个方法能避免程序扫描覆盖的问题,是设备联调中最稳妥的方案。
6. 进阶:多PLC并发轮询与超时重试的工程化处理
动态库单条命令跑通之后,接下来就是工程化了。产线通常有十几台PLC,不能每台PLC都单独用一个线程阻塞读,需要用异步轮询。我的做法是开一个后台轮询线程,每隔100毫秒遍历一次所有PLC的读命令队列,每台PLC的命令之间留20毫秒的间隔,避免同一时间多台PLC同时响应导致交换机端口拥塞。这个间隔是根据现场24台PLC的轮询测试摸出来的,太小了丢包率明显上升,太大了数据实时性跟不上。
超时重试的逻辑也要分场景。读实时数据时,超时200毫秒就该放弃这条命令,直接标记该PLC通信故障,等下一个轮询周期再尝试;写关键参数时,超时时间拉长到1000毫秒,并且最多重试2次,因为写操作重复执行可能产生重复计数的问题。这里要特别小心:FINS写命令本身不具备幂等性,比如写一个累加器,重复发送两次同一个写值命令,结果就是累加了两倍。解决方法是业务上做好写前读校验,先读当前值再写目标值,如果读到的是目标值就跳过。
代码里还有一个容易被忽视的细节:多线程环境下句柄的互斥。如果上位机UI线程和轮询线程同时调用Fins_ReadWord,动态库内部如果没有加锁,套接字缓冲区会被写乱,导致报文错位。动态库对外提供的每个命令函数内部有临界区保护,但上层还是尽量避免对同一个handle做并行调用。必要时为每个PLC分配独立的handle,这样可以在线程之间隔离通信通道。
从那以后我每次封装这类通信动态库,都会强制走一遍全链路测试:先单命令验证数值对不对,再并发压测看丢包率,最后挂机48小时看稳定性。这套欧姆龙FINS协议动态库帮我省掉了大量重复造轮子的时间,有些坑比如SID不递增、位地址编码错误、BSD接收缓存溢出,都是后来才逐个填平的。如果你正准备接欧姆龙PLC的上位机,把这篇里的细节对照着手里的库过一遍,能少走不少弯路,希望帮到你。
本文还有配套的精品资源,点击获取