
做上位机这几年跟欧姆龙PLC打交道最多的就是FINS协议。无论是用C#写上位机读取DM区数据还是用Python脚本做产线数据采集只要连的是欧姆龙CP1H、CJ2M或者NJ系列最后基本都会落到FINS这个协议上。FINS全称是Factory Interface Network Service是欧姆龙自己定义的工厂接口网络服务协议在以太网、Controller Link、SYSMAC LINK这些链路上都能跑。它最方便的一点是不管底层走哪种物理链路命令格式是统一的也就是说你学会了以太网上的FINS/UDP报文格式换到串行链路或者Controller Link上报文结构照样通用只是传输方式不一样而已。这篇内容主要面向两类人一是刚接触欧姆龙PLC通信、需要快速把上位机和PLC连起来的新手二是已经会调用现成库、但遇到问题不知道怎么排查的开发者。我会从帧结构、节点规划、命令报文、实际部署这几个角度把FINS协议从底层到应用拆开讲清楚并且给出可以直接拿走的代码示例和排查思路。1. 先搞懂FINS协议的定位和通信模型1.1 为什么是FINS而不是别的协议欧姆龙PLC除了FINS其实还支持Modbus-TCP、以太网/IPEtherNet/IP这些通用协议。那为什么很多老项目仍然用FINS原因大致有三个方面第一FINS是欧姆龙的原生协议对PLC内部资源的访问最完整。比如通过Modbus-TCP也能读写DM区但像CPU总线单元的状态读取、远程程序传输、文件内存操作这类功能严格来说还是走FINS更直接因为FINS的命令码设计就是覆盖整个FA工厂自动化网络的。第二FINS本身就建立在TCP/UDP之上实现成本低。它不需要像EtherNet/IP那样做复杂的CIP对象建模抓包看报文非常直观一对请求和响应搞明白基本就能应对大部分数据采集和控制需求。第三欧姆龙的编程软件CX-Programmer、Sysmac Studio内置的网路配置工具默认通信方式也支持FINS。很多现场已经在用这些工具做在线监控你上位机沿用FINS跟在PLC侧看到的数据是完全一致的排查问题时候不用在多个协议之间来回转换。1.2 FINS节点和网络结构FINS协议里最基本的概念就是节点Node。它相当于给每台设备分配一个地址在同一网络里必须唯一。FINS的地址由三个部分组成网络号Network Address标识通信链路所在的网络范围0到127。现场一般都用0除非你做了多网段隔离。节点号Node Address网络内的设备编号范围1到254。PLC侧的节点号在单元设置里指定比如设为1上位机侧需要给它分配一个和PLC不冲突的编号。单元号Unit Address标识CPU单元或者特定通信单元。CPU单元通常是0如果是CJ1W-EIP21这类通信单元则按单元号来写。在实际报文里这三个参数分为目标地址和源地址两组。你在发命令时填的是目标设备的地址和你自己的源地址。PLC返回响应帧时会把这两个地址互换这样你就能通过响应帧的源地址判断响应来自哪台设备。1.3 FINS/UDP和FINS/TCP怎么选FINS在以太网上有两种承载方式UDP和TCP。这两者的区别不只是协议层次上的实际使用上有几个非常关键的点。FINS/UDP端口固定为9600无连接发请求后等响应。适用于快速读取和周期性数据采集开销最小。FINS/TCP端口也是9600但需要先建立TCP连接。连接建立后发送的FINS帧前面会增加4个字节的长度字段Length用于TCP流式传输的断包重组。另外TCP模式下还有个Keep-Alive机制通信双方可以互发保活帧检测链路状态。从实操角度来说如果你只是做简单的读写字数据UDP模式够用且容易调试如果你要长时间保持连接、大量连续读写或者需要可靠传输建议用TCP。我自己的经验是新项目优先用TCP因为它能复用连接不会因为频繁建立UDP会话而出现端口层面的问题老项目如果已经在用UDP跑得很稳定也不急着迁移。2. FINS报文结构逐字节拆解2.1 命令帧格式总览FINS UDP命令帧的整体结构由FINS Header10字节和FINS Command命令码参数数据组成。前面10个字节就是三组地址加一个服务标识符后面跟着具体要执行的操作。这里以读取PLC的DM0000开始的10个字为例完整命令帧长这样80 00 02 00 00 01 00 00 01 00 01 01 82 00 00 00 00 0A别急着背我把每一段拆开讲。2.2 FINS Header各字段含义FINS Header总共10个字节依次是字节偏移字段名示例值说明0ICF0x80通信控制字段0x80表示需要响应1RSV0x00保留字段固定为02GCT0x02允许网关转发3DNA0x00目标网络号4DA10x01目标节点号5DA20x00目标单元号6SNA0x00源网络号7SA10x01源节点号8SA20x00源单元号9SID0x00服务标识符用于请求响应匹配这里最需要注意的是ICF字段。ICF的低三位表示数据类型高位置1表示需要响应。如果ICF是0x80说明这是一个需要应答的命令PLC处理完后会回一个响应帧。如果你把ICF设为0x00表示不需要响应那就是发了不管模式一般不建议在数据采集里用因为没法确认PLC是否真的执行了。GCT字段通常填0x02意思是允许网关中继。如果你只有一台PLC直连这个字段其实不参与路由但按照FINS规范固定填0x02最稳妥。DNA、DA1、DA2是目标设备的网络号、节点号和单元号SNA、SA1、SA2则是源设备的对应信息。SID服务标识符你可以理解成请求的序列号PLC返回响应时会原样带上这个SID你的上位机靠它来区分当前响应到底对应哪一条请求。多线程并发发送请求时SID非常重要——每个请求用不同的SID响应回来就能一一对上。2.3 命令码和参数区在FINS Header之后紧接着是2字节的命令码。常用命令码有0101内存区读出Read0102内存区写入Write0103内存区填充Fill0104多内存区读出Multiple Read0105内存区数据比较2001运行/停止控制2301读时钟数据2302写时钟数据以0201之外的用途如果暂时用不上先记住0101和0102就够了它们覆盖了你90%以上的数据读写需求。对于0101命令命令码后面的参数是内存区代码1字节 起始地址2字节 数据个数2字节起始地址的2字节表示的是这个内存区内部的字地址。比如DM区在FINS报文里就是DM区代码0x82加上一个字地址例如DM0000对应的地址编码是0000。IK、CIO区的地址则要看PLC型号和操作数编号规则后面会单独讲。2.4 响应帧怎么解析PLC处理完请求后会返回一个响应帧。响应帧的ICF、SA1、DA1这些字段和命令帧是反过来的——源和目标的地址互相交换SID保持一致。响应帧的格式是FINS Header10字节 命令码2字节 响应码2字节 数据其中响应码是关键。0000表示正常结束非0000则表示出了异常。常见异常响应码包括响应码含义0x0101本地节点错误源节点号未被正确设置0x0102目标节点错误节点号不存在或未连接0x0201内存区指定错误地址越界或不符合规范0x0202访问被保护PLC启用了写保护0x0203存储器为只读比如读文件存储器但不能写0x1101参数异常数据个数或地址范围不对看到非0000响应码先别慌绝大多数情况下就是报文里的目的地址或者内存区代码写错了。对照响应码去查比盲猜快得多。3. 实操用Python实现FINS/UDP报文读写3.1 准备工作写代码之前先把PLC侧的通信设置检查一遍。这里以某型号欧姆龙PLC为例USB连接编程软件后依次确认PLC的IP地址比如192.168.1.10子网掩码255.255.255.0。PLC的节点号默认是1在单元设置里能看到。端口号默认9600FINS UDP服务默认是开启的。如果PLC开启了防火墙过滤规则需要放行UDP 9600。另外给上位机分配一个和PLC不冲突的节点号。我习惯用127这个中间值因为在上位机端手动指定的节点号不宜和PLC重复也不宜用0和255用127这个中间节点号能避开大多数默认配置。3.2 直接构造UDP报文的Python脚本以下代码不需要任何第三方库直接基于Python内置的socket和struct发送读取DM0000开始10个字的FINS命令并解析响应import socket import struct PLC_IP 192.168.1.10 PLC_PORT 9600 PLC_NODE 0x01 LOCAL_NODE 0x7F # 上位机分配的节点号127 def fins_header(dst_node, src_node, sid0x00, dst_net0x00, src_net0x00, dst_unit0x00, src_unit0x00): # ICF0x80 需要响应, GCT0x02 允许网关转发 header bytes([0x80, 0x00, 0x02]) header bytes([dst_net, dst_node, dst_unit]) header bytes([src_net, src_node, src_unit]) header bytes([sid]) return header def build_read_dm(start_addr, count): # 0101 内存区读出命令 cmd bytes([0x01, 0x01]) # 内存区代码0x82表示DM区 mem bytes([0x82]) # 起始地址2字节这里用struct转为大端字节序 addr struct.pack(H, start_addr) cnt struct.pack(H, count) return fins_header(PLC_NODE, LOCAL_NODE) cmd mem addr cnt def parse_response(resp): # 前10字节是FINS Header第11-12字节是命令码第13-14字节是响应码 if len(resp) 14: raise ValueError(响应帧长度不足) cmd resp[10:12] end_code resp[12:14] if end_code ! b\x00\x00: raise RuntimeError(fPLC返回错误码: {end_code.hex()}) data resp[14:] return data def read_dm(start_addr, count): req build_read_dm(start_addr, count) sock socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(3) try: sock.sendto(req, (PLC_IP, PLC_PORT)) resp, _ sock.recvfrom(2048) data parse_response(resp) # 每个字2字节大端序格式 values [] for i in range(count): value struct.unpack(H, data[i*2 : i*22])[0] values.append(value) return values finally: sock.close() if __name__ __main__: vals read_dm(0, 10) print(DM0000-DM0009:, vals)这段代码跑通后你就能拿到PLC里从DM0000开始的10个字数据返回值是无符号整数。如果DM区存的是带符号整数、浮点数或者ASCII字符串需要再把原始字节按目标格式重新解释。比如带符号整数用struct.unpack(h, ...)32位浮点要按两个字拼接后解包。3.3 写操作报文怎么构造写命令的报文结构和读命令非常相似只不过是在命令参数后面多加了一截要写入的数据。把DM0000地址的1个字写成1234报文的构造方式是def build_write_dm(start_addr, values): # 0102 内存区写入 cmd bytes([0x01, 0x02]) mem bytes([0x82]) addr struct.pack(H, start_addr) cnt struct.pack(H, len(values)) payload b for v in values: payload struct.pack(H, v) return fins_header(PLC_NODE, LOCAL_NODE) cmd mem addr cnt payload写操作的响应帧比读操作更短正常响应时只有命令码和响应码0000后面不跟数据。如果你的程序收到响应帧长度恰好是14字节Header 10字节 命令码2字节 响应码2字节说明写入成功。注意欧姆龙PLC的DM区是掉电保持区写入后数据会一直保留。如果你只是在测试写完记得把PLC设置里的DM区初始化选项确认一下避免把现场数据弄乱。3.4 用现成库还是手搓报文网上有不少现成的FINS通信库比如某Python库和其他开源实现封装了节点连接和内存区读写接口能省去很多构造报文的麻烦。我的态度是生产环境用现成库没问题但开发阶段一定要手搓一遍报文至少要用抓包工具看到自己发出去的帧结构。原因很简单——一旦通信出了问题你必须能看懂报文才能定位是哪一段出了问题。库帮你隐藏了细节排查时就少了一个抓手。如果你打算基于现成库做二次开发至少确认它支持以下能力自定义源节点号避免和PLC冲突。自定义响应超时时间和重试次数。支持UDP和TCP两种承载方式切换。能把原始响应帧暴露出来方便抓包对比。4. 节点规划、地址编码和常见坑4.1 节点号规划上位机和PLC怎么分配FINS UDP模式下PLC收到报文后是靠“目标节点号”判断要不要响应的。所以你要保证上位机发出去的DA1和PLC设置的节点号一致同时PLC在响应帧里会根据SNA、SA1回发也就是把你源端的网络号和节点号作为响应目标。这里有个很经典的坑在FINS UDP通信里两边节点号的和不能等于0。如果你把PLC节点号设成了254上位机又发一个254的源节点号两者相加会发生地址冲突响应帧可能发不回来。实际表现就是请求超时抓包却能看到PLC已经把响应发出去了。为了避免这类问题我一般建议设备节点号建议说明PLC1出厂默认值便于记忆上位机A127中间值避免加和冲突上位机B126多台上位机全靠不同节点号区分触摸屏2如果触摸屏走FINS也要占一个节点多台上位机同时连一台PLC是可行的因为它们源节点号不同PLC能够区分响应目标。但这也意味着所有上位机的FINS节点号必须规划好谁先占用了就不能再复用否则会出现响应被别的上位机“截胡”的情况。4.2 内存区代码和地址映射FINS协议用1字节的内存区代码来标识不同类型的存储区。常用代码对照如下内存区代码存储区说明0x82DM区数据内存字单位0x83AR区特殊辅助继电器区0x84HR区保持继电器区0xB0CIO区CIO区包括输入输出0xB1WR区工作继电器区0xB2HR区某些型号写法略有差异保持继电器0xB3定时器标志使用TC编号访问0xB4计数器标志使用TC编号访问0x98定时器PV当前值0x99计数器PV当前值在FINS报文里DM区的字地址直接用16位整数表示DM0000就是0000DM65535就是FFFF。但CIO区、WR区、HR区的地址编码跟梯形图里的通道号不一定直接一致。最典型的是CIO区的输入输出比如CIO 0.00这种按位寻址的点位在FINS里要用字地址0再加位偏移来换算。查内存区代码表的时候一定先确认PLC型号对应的存储区映射范围。4.3 位读写怎么实现有时候你不需要读整个字只需要读某一位。比如要监控CIO区某个输出点的通断。FINS也支持按位读写方法是把内存区代码换成位指定方式。以前面说的0101命令为例内存区代码仍然是0x30表示CIO区按位访问这种扩展代码起始地址的两字节则是“字地址和位号”打包的形式低4位表示位号高12位表示字地址。另一种更通用的做法是读取整个字然后在上位机里做位运算。比如要读CIO 10.02你就读10号字然后取第3位bit2的值。这种方式虽然多传了几个字节但程序逻辑简单也不会因为协议细节不同而出错。对于我的项目除非监控点特别多且要求实时性极高否则我都是用整字读取再按位解析。4.4 常见坑地址连续和数据类型上位机读取DM区时IO数据如果跨越了连续地址一切正常。但如果你读的是模拟量输入通道分配给CIO区从CH1到CH3是连续的CH4可能因为通道分配方式变成跳过的地址。编程时务必画出“PLC内部地址映射表”把模拟量、通信数据、工装参数这些区域划分清楚再按区域批量读取比一条条读有效得多。数据类型这块欧姆龙的DM区可以存放各种类型的变量。如果PLC里写的是浮点数32位浮点那一个字放不下需要连续两个字来存储。上位机读取时要按浮点格式解包这时候要注意字节序。FINS报文里多字节数据默认是大端序高字节在前而PLC内部的浮点存储方式也遵从大端序布局所以直接按f格式解包一般没问题。如果出现解析结果很奇怪比如数值差了好几个数量级先检查是不是字节序弄反了。5. 常见问题与排查技巧实录5.1 请求超时但PLC明明在线这是我被问得最多的问题。你ping PLC能通但FINS请求就是没响应。排查顺序建议如下检查端口是否通用工具向PLC的9600端口发UDP包确认有响应。UDP没有真正的“端口连通性测试”但你可以抓包看有没有回包。检查节点号加和是否为0。如果你的PLC节点号是254上位机FINS源节点号也写了254就会出现“请求发出去了也到了但响应回不来”的情况。把源节点号改成127通常立竿见影。查看PLC单元设置里的FINS UDP端口是不是被改过。某些型号默认不是9600或者被防火墙规则拦截了。检查SID是否重复。如果你连续发请求都用了同一个SID在快速连续发送的场景里响应匹配不上也可能导致程序判定超时。5.2 响应码0201内存区指定错误0201是地址越界或内存区不存在。排查重点是地址换算是否正确。举个例子你要读CIO区结果内存区代码填了0x30但实际在报文里CIO区字访问的代码是0xB0这就会直接报0201。另外起始地址加上要读的数据个数超出了存储区大小范围也会报这个错误。遇到0201时先手算一遍地址范围起始地址 数据个数 存储区上限。如果上限没错再检查存储区代码。5.3 抓包工具怎么配合调试调试FINS通信强烈建议装一个抓包工具。Wireshark对FINS协议有专门的解析器能直接识别出FINS header里的各个字段并对UDP 9600端口的报文标成FINS。抓包调试的具体步骤在上位机侧抓包过滤条件用udp.port 9600。发一条读DM0000的请求看请求帧的DA1、SA1是否符合预期。看PLC回包的目标IP和端口是否指向上位机本地。对比请求帧和响应帧的SID是否一致。如果请求帧发对了但响应帧没出现问题大概率在网络层。如果响应帧出现了但上位机程序超时问题大概率在节点号加和或者Winsock绑定上。5.4 TCP模式下的断包和长度字段切到FINS/TCP之后报文里会多出4字节的Length字段表示后面FINS帧的总长度。TCP是流协议一次接收可能包含多条请求也可能一条请求被拆成两个包。这时候接收缓冲处理就要正确先读4字节算出长度再按长度收取完整报文。这里的典型错误是直接用recv(1024)去收然后按固定长度解析。如果网络环境不好或者负载高很容易出现粘包/拆包问题导致解析出来的是错位的垃圾数据。我建议写一个简单的拆包函数def recv_fins_tcp(sock): header b while len(header) 4: chunk sock.recv(4 - len(header)) if not chunk: raise ConnectionError(连接断开) header chunk length struct.unpack(I, header)[0] payload b while len(payload) length: chunk sock.recv(length - len(payload)) if not chunk: raise ConnectionError(连接断开) payload chunk return payload5.5 多线程并发和SID分配FINS UDP本身没有严格的多路复用机制。你要在同一个socket上并发发送请求就必须保证每条请求的SID不重复否则响应回来时程序无法判断是哪个请求的回应。两个线程同时发同一条命令、用同一个SID一个线程收到响应后把数据取走另一个线程就会一直等到超时。几个建议用原子整数递增的方式生成SID。维护一个“待响应队列”发送请求时把SID和业务回调注册到队列。收到响应后根据SID从队列里找到对应回调做匹配处理。如果业务场景比较简单也可以退一步直接用互斥锁把发送动作串行化一次只发一条请求、等一条响应。虽然牺牲了一点吞吐但实现可靠很多。5.6 PLC侧需要做的配置最后强调一遍PLC侧的两个关键配置PLC内网IP、子网掩码必须正确。很多现场是把PLC直连工控机用了默认IP工控机换了IP段之后就把PLC“丢”了。节点号必须和上位机报文里的DA1一致。有些工程师在PLC里改了节点号但没有重新下载设置或者下载后没重启都会导致地址不生效。在线修改节点号后至少给PLC重新上电一次确保网络单元按新设置初始化。这类改完没生效的问题在欧姆龙PLC上还算常见别当玄学处理。6. 调试现场的一些感受FINS协议真正跑起来之后你会发现它其实非常规矩。相比很多工业协议喜欢搞复杂的状态机和对象模型FINS的核心就是发一条命令等一条响应帧结构固定字段含义清晰出错也有明确的错误码反馈。这也是为什么很多老工程师哪怕手里有现成的库遇到现场问题第一反应还是抓包看报文——因为协议本身足够简单报文一展开问题基本就暴露了。我个人在实际项目里的习惯是第一次联调一定先用最原始的UDP报文把读写通道打通再往工程代码里集成。别一上来就套框架或者库因为一旦通信链路有基础层面的问题比如节点号冲突、端口被挡在框架的层层封装下去排查反而更痛苦。基础通道确认没问题后再根据项目需要选择是继续裸报文还是封装成函数复用。如果后续要把这套通信做成通用服务建议把通信层拆成独立的模块内部封装好节点管理、SID分配、收发队列、超时重试这些机制对外只暴露ReadDM(addr, count)、WriteDM(addr, values)这类业务接口这样不管是做MES数据采集还是做设备远程控制都能快速复用。FINS协议本身不难难的其实是把通信能力和业务边界规划清楚这个思路比死记几个报文字段更重要。