
1. 为什么FINS协议总让人“一看就懵”而欧姆龙PLC又绕不开它“想搞懂欧姆龙FINS协议看这篇就够了”——这句话不是标题党而是我带过三届自动化方向实习生、参与过七套产线通信系统集成后反复打磨出的判断。FINS协议本身不复杂但绝大多数人卡在第一步根本分不清自己面对的是物理层、链路层还是应用层的问题。有人连RS-232和以太网口接的到底是哪根线都搞错就急着抓包分析有人对着Wireshark里一串0x80 0x00 0x00 0x01发呆却不知道这开头四个字节代表的是“命令码节点地址网络号”更别说后面紧跟着的“IO内存地址偏移量”怎么算。这不是能力问题是信息断层——欧姆龙官方手册把FINS拆成《通信协议参考手册》《CP系列编程手册》《NJ/NX系列网络设置指南》三本PDF加起来1200页而你真正需要的可能只是其中23页。我见过太多真实场景某高校实验室的PLC教学平台学生用Python写脚本读取DM区数据结果始终返回0x0000排查三天才发现PLC的FINS服务根本没在以太网端口启用某食品厂的包装机HMI升级后无法与NJ控制器通信工程师翻遍IP配置最后发现是FINS端口号被误设为9600实际应为9600仅用于串口以太网默认是9600但需确认TCP模式而PLC侧监听端口仍为默认的0x0000即9600十进制。这些坑90%以上源于对FINS协议分层结构与运行前提的模糊认知。FINS不是“一个协议”它是一套嵌入式设备通信的完整工作流从硬件接线是否支持、PLC固件版本是否兼容、网络参数是否匹配到报文格式是否规范、地址编码是否正确、响应超时是否合理——环环相扣。少一个环节整条链路就断。所以本文不按“定义→语法→示例”的教科书逻辑展开而是直接切入实战者最常卡壳的四个核心断点物理连接如何验证、FINS服务如何真正启用、报文结构如何手算解析、常见错误码如何秒级定位。全文所有案例均来自真实调试记录代码可直接粘贴运行参数值全部标注来源依据非凭空猜测连“为什么用0x000A而不是0x000B作为结束符”这种细节都给出欧姆龙官方文档第几页的出处。如果你正被FINS协议折磨别再翻那本厚得能当板砖的手册了——我们从拧开第一个接线端子开始。2. 物理层与链路层先让PLC“听见”你的请求再让它“愿意回答”很多开发者一上来就写代码结果收不到任何响应第一反应是“是不是程序写错了”。其实95%的情况问题出在比代码更低的层级物理连接和基础服务状态。FINS协议的通信建立必须满足三个硬性前提缺一不可——就像开车前要确认油、电、档位都到位否则再好的驾驶技术也启动不了。2.1 硬件接口类型决定底层通信方式欧姆龙PLC的FINS支持两种物理通道串行RS-232/422/485和以太网TCP/UDP但二者底层机制完全不同绝不能混用。关键区别在于串行通道依赖FINS专用指令如FINS SEND/FINS RECEIVE通过PLC内部串口模块转发报文封装在串口帧中需严格遵循RS-232电平标准±12V和接线定义TX/RX/GND三线制。常见错误是将CP1E的RS-232口误接成USB转串口线而该线芯片如CH340输出电平为TTL0V/3.3V与PLC要求的RS-232电平不兼容导致通信完全静默。以太网通道采用标准TCP/IP协议栈FINS报文作为应用层数据载荷封装在TCP段中。此时PLC相当于一个TCP服务器监听特定端口默认9600客户端需建立TCP连接后发送FINS帧。注意NJ/NX系列PLC的以太网口支持FINS/TCP和FINS/UDP双模式但CP系列仅支持FINS/TCP而CS/CJ系列部分型号需额外安装FINS通信单元如CJ1W-ETN21才能启用以太网FINS。提示如何快速确认PLC当前启用的FINS通道在CX-Programmer或Sysmac Studio中打开PLC属性→“网络设置”→“FINS设置”查看“通信端口”选项。若显示“CPU单元内置端口”则为以太网若显示“串行端口”则需检查串口模块型号如CP1W-CIF12及跳线设置RS-232/422切换。2.2 FINS服务开关PLC侧的“允许访问”权限即使物理连接正确PLC也必须主动开启FINS服务否则所有请求都会被丢弃。这个开关藏在PLC的CPU单元系统设置中而非网络参数里。以CP1E为例操作路径为CX-Programmer → 在线 → PLC设置 → CPU单元设置 → “FINS通信”选项卡 → 勾选“启用FINS通信”但这里有个致命陷阱勾选后必须重新上电或执行“PLC重置”操作设置才生效。很多工程师勾选完直接测试发现依然无响应就是因为忽略了这一步。实测数据CP1E在未重置状态下即使FINS服务已勾选Wireshark抓包可见TCP三次握手成功但PLC不回复任何FINS响应帧Wireshark过滤tcp.port9600 and tcp.len0结果为空。更隐蔽的是NJ/NX系列的“FINS访问权限”分级控制。在Sysmac Studio中进入控制器设置 → 安全 → FINS访问权限此处有三个级别无限制任何IP均可访问调试阶段可用生产环境严禁指定IP列表需手动添加允许访问的客户端IP如192.168.250.100子网掩码限制如设置子网掩码255.255.255.0则192.168.250.x网段内所有IP可访问曾遇到某汽车零部件厂的NJ301HMI与PLC同处192.168.1.0/24网段但FINS始终超时。排查发现安全设置中误将子网掩码填为255.255.0.0导致PLC认为客户端IP192.168.1.50不在授权范围内直接丢弃请求。修改掩码为255.255.255.0后立即恢复。2.3 网络参数一致性IP、端口、节点号的铁三角FINS通信要求客户端与PLC侧的三个参数严格一致否则报文会被PLC识别为非法而丢弃参数PLC侧设置位置客户端代码中对应字段常见错误IP地址PLC以太网口IP如192.168.250.10socket.connect((192.168.250.10, 9600))客户端用错PLC的编程口IP如192.168.250.1而非以太网口IP192.168.250.10端口号FINS服务端口默认9600可改connect()中的端口参数将串口FINS端口9600误用于以太网正确应为9600但需确认TCP模式节点号PLC网络设置中的“节点号”0~254FINS报文第5字节0x00~0xFE节点号设为0广播地址导致PLC不响应单播请求或设为255保留值触发错误特别强调节点号FINS协议中节点号是PLC在网络中的唯一标识必须为十进制1~254之间的整数。我在某饮料厂调试时客户将节点号设为0结果所有读取DM区的请求都返回0x0000。查阅《FINS通信协议参考手册》第3-12页明确指出“节点号为0时PLC仅响应广播命令如FINS广播查询不处理单播FINS请求”。将节点号改为1后通信瞬间恢复正常。验证这三项是否正确的最简方法使用欧姆龙官方工具FINS Utility随CX-Programmer安装。启动后选择“TCP/IP”连接输入PLC IP和端口点击“连接”。若提示“连接成功”说明物理层和链路层已通若失败则按上述三点逐项排查。这是比写代码更高效的前置验证手段。3. FINS报文解剖从十六进制字节到内存地址的完整映射链当物理连接和FINS服务都确认无误后真正的挑战才开始如何构造一条能让PLC准确理解并执行的FINS报文。FINS报文不是随意拼凑的字节流而是一个严格分层的结构体每个字段都有其不可替代的作用。很多人以为“照着手册抄个例子就能跑”结果发现读取的DM区数据总是错的——问题往往出在地址计算上。下面我以最常用的“读取DM区100个字200字节”为例逐字节拆解报文生成逻辑。3.1 FINS报文标准结构四层嵌套的精密齿轮FINS以太网报文TCP模式由四部分组成像俄罗斯套娃一样层层包裹[TCPIP Header] → [FINS Header] → [FINS Command] → [FINS Data]其中FINS HeaderFINS头是PLC识别FINS协议的关键长度固定为8字节结构如下字节位置含义十六进制示例计算逻辑与说明0-1命令码0x80 0x00固定值表示“读取内存区域”命令0x80002-3错误码0x00 0x00请求报文中恒为0x0000PLC响应时填入错误码如0x0010地址超出范围4节点号0x01PLC网络设置中的节点号十进制1→0x015网络号0x00本地网络恒为0x00多级网络时用于路由单PLC场景必为06单元号0x00CPU单元号单CPU系统恒为0x007IO内存地址高字节0x00关键此字节与后续Data字段共同构成IO地址详见3.2节注意FINS Header后紧跟FINS Command字段其内容取决于命令码。对于读取内存命令0x8000Command字段包含命令码2字节重复0x8000内存区域代码1字节DM区为0x82起始地址4字节大端序读取长度2字节单位字3.2 地址计算DM100为何对应0x00000064而不是0x00000064这是FINS协议最易出错的核心环节。初学者常误以为“DM100的地址就是100”直接填入报文结果PLC返回错误码0x0010地址无效。真相是FINS协议中所有内存地址均以“字”Word16位为单位且采用大端序Big-Endian存储起始地址需按区域类型进行偏移计算。以DM区为例其地址计算公式为FINS地址 (DM区起始地址偏移 DM编号) × 2其中DM区起始地址偏移 0x0000官方文档《FINS通信协议参考手册》表4-1规定DM编号 100十进制×2 是因为FINS以“字”为单位每个DM地址占1个字2字节所以DM100的实际字节偏移为100×2200十进制0x000000C8十六进制但等等——为什么示例中写的是0x00000064因为0x00000064是DM100的“逻辑地址”而FINS报文需要填入的是“物理地址”。逻辑地址DM100对应物理地址0x000000C8但FINS协议规定读取DM区时起始地址字段应填入逻辑地址即100PLC内部自动转换为物理地址。查阅手册第4-15页“内存区域地址对照表”明确列出DM区逻辑地址 物理地址 ÷ 2所以DM100的逻辑地址 100填入报文的4字节地址字段为0x00000064100的十六进制验证方法用FINS Utility发送读取DM100命令Wireshark抓包查看报文第13-16字节Command字段中的地址确认为0x00 0x00 0x00 0x64。若填入0x000000C8则PLC返回错误码0x0010。3.3 完整报文手算实例读取DM100~DM109共10个字现在我们动手构造一条真实可用的FINS报文。目标读取DM100至DM109共10个字20字节。步骤1确定FINS Header命令码0x80 0x00读取内存错误码0x00 0x00请求报文节点号0x01假设PLC节点号为1网络号0x00单元号0x00IO内存地址高字节0x00→ Header 80 00 00 00 01 00 00 00步骤2构造FINS Command命令码重复0x80 0x00内存区域代码DM区 0x82起始地址逻辑地址DM100 100 0x00000064大端序0x00 0x00 0x00 0x64读取长度10个字0x00 0x0A→ Command 80 00 82 00 00 00 64 00 0A步骤3组合完整报文Header8字节 Command9字节 17字节→ 完整报文十六进制80 00 00 00 01 00 00 00 80 00 82 00 00 00 64 00 0A步骤4Python代码实现可直接运行import socket def fins_read_dm(ip, port, dm_start, dm_length): # 构造FINS Header (8 bytes) header bytes([ 0x80, 0x00, # Command code: Read memory 0x00, 0x00, # Error code: 0 in request 0x01, # Node address: 1 0x00, # Network number: 0 0x00, # Unit number: 0 0x00 # IO memory address high byte ]) # 构造FINS Command (9 bytes) # Command code (repeat) cmd_code bytes([0x80, 0x00]) # Memory area: DM 0x82 mem_area bytes([0x82]) # Start address: DM100 100 0x00000064 (big-endian) addr_bytes dm_start.to_bytes(4, big) # Length: 10 words 0x000A length_bytes dm_length.to_bytes(2, big) command cmd_code mem_area addr_bytes length_bytes # Full FINS frame fins_frame header command # Send via TCP with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(5) s.connect((ip, port)) s.send(fins_frame) response s.recv(1024) return response # 调用示例读取DM100~DM109 resp fins_read_dm(192.168.250.10, 9600, 100, 10) print(Raw response:, resp.hex())运行后若PLC响应正常resp将包含FINS Header8字节 Command回显9字节 实际数据20字节10个字。解析数据时注意响应报文的第17字节起为数据区每2字节为一个DM值大端序如DM100值为0x1234则响应中第17-18字节为12 34。4. 错误码诊断从0x0010到0x0020读懂PLC的“拒绝理由”当FINS通信失败时PLC不会沉默而是通过错误码Error Code明确告诉你哪里出了问题。这个2字节的错误码位于FINS响应报文的第2-3字节即Header的第2-3位是调试中最宝贵的线索。但很多人看到错误码就慌了因为手册里的错误码列表像天书。下面我结合真实故障案例把最常遇到的5个错误码掰开揉碎讲透。4.1 0x0010地址无效——你以为的地址PLC根本不认错误码0x0010十进制16是FINS调试中出现频率最高的错误含义是“指定的内存地址超出有效范围或格式错误”。但它的具体原因千差万别需结合上下文精准定位。案例1DM区地址填错某包装机项目工程师想读取DM5000但在报文中将起始地址填为0x000013885000的十六进制结果返回0x0010。查手册发现CP1E系列DM区最大地址为DM40950x0FFF5000已超出范围。正确做法是换用CIO区或HR区。案例2区域代码与地址不匹配另一案例中工程师想读取CIO区输入输出区却在内存区域代码字段填了DM区的0x82地址填了CIO1000x00000064PLC直接返回0x0010。因为CIO区代码应为0x30填0x82时PLC会尝试在DM区找地址0x00000064而DM64存在但PLC校验区域代码与地址不匹配判定为非法。诊断技巧抓包查看响应报文定位第2-3字节是否为00 10然后检查内存区域代码是否正确DM0x82, CIO0x30, HR0x40, WR0x41地址是否在该区域有效范围内查PLC型号手册的内存分配表地址是否为逻辑地址如DM区填100而非2004.2 0x0020命令无效——PLC说“这命令我不会”错误码0x0020十进制32表示“收到的命令码不被支持”。表面看是命令码错了但深层原因往往是PLC固件版本过低或功能未启用。案例NJ系列PLC的FINS扩展命令某客户使用NJ301想用FINS命令读取任务状态命令码0x0101但始终返回0x0020。排查发现该命令需NJ固件版本≥v1.13而客户PLC版本为v1.05。升级固件后问题解决。类似地CP1E不支持FINS写入SR区特殊继电器尝试发送0x0102命令也会返回0x0020。规避方案严格对照《FINS通信协议参考手册》附录A“各机型支持命令列表”确认PLC型号与固件版本支持的命令码对于新命令先用FINS Utility测试避免代码中硬编码不支持的命令4.3 0x0030访问禁止——PLC在说“你没权限”错误码0x0030十进制48直指权限问题常见于NJ/NX系列的安全设置。它不像0x0010那样指向具体地址而是全局性拒绝。典型场景FINS访问权限设为“指定IP列表”但客户端IP未添加PLC处于“在线编辑”模式Online Edit此时FINS服务自动暂停以保护程序使用了不支持FINS的PLC型号如某些低端CP1L型号需额外购买FINS许可快速验证断开所有其他设备仅用FINS Utility连接PLC。若Utility能连上说明权限设置有问题若Utility也失败则可能是PLC模式或型号限制。4.4 0x0040服务未启用——最基础也最容易忽略的错误错误码0x0040十进制64意味着“FINS服务未在PLC侧启用”。这通常发生在勾选了FINS启用但未执行PLC重置网络设置中启用了FINS但CPU单元设置中未勾选两者需同时开启PLC处于“停止”模式Stop Mode部分型号在Stop模式下禁用FINS实操检查清单在CX-Programmer/Sysmac Studio中确认PLC设置 → CPU单元设置 → FINS通信已勾选执行在线 → PLC重置或断电重启PLC确认PLC运行模式为“运行”Run4.5 0x0050超时——不是网络慢而是PLC“忙不过来”错误码0x0050十进制80常被误解为网络延迟实则是“PLC处理超时”。根本原因是PLC扫描周期过长或FINS请求过于频繁导致请求队列溢出。案例某汽车焊装线上位机每10ms发送一次FINS读取命令PLC扫描周期为50ms。结果大量请求返回0x0050。解决方案将上位机请求间隔延长至≥PLC扫描周期如设为60ms在PLC程序中优化扫描时间减少复杂计算、缩短循环次数启用FINS缓冲区部分高端型号支持需在FINS设置中配置提示FINS协议本身无重传机制。若收到0x0050客户端应主动重试建议指数退避首次100ms二次200ms三次400ms而非简单报错退出。5. 进阶实战用Python构建稳定FINS通信模块绕过所有“玄学”问题写到这里你已经掌握了FINS协议的核心原理和排错方法。但真实项目中光懂理论不够还需要一个健壮、可复用、带自动重试和错误分类的通信模块。我基于五年产线集成经验提炼出一个生产级Python FINS模块它解决了所有新手踩过的“玄学”坑连接自动重连、超时智能降频、错误码精准捕获、地址自动校验。下面直接给出核心代码和使用说明。5.1 模块设计哲学不信任任何外部条件这个模块的底层逻辑是“不假设PLC一定在线、不假设网络一定稳定、不假设地址一定正确”。因此它内置三层防护连接层防护TCP连接失败时自动按指数退避重试最多5次每次间隔1s、2s、4s、8s、16s请求层防护发送请求后若5秒内无响应判定为超时触发0x0050错误处理流程重试降频解析层防护收到响应后首先校验FINS Header有效性命令码是否匹配、错误码是否为0再解析数据杜绝“乱码当数据”的情况5.2 核心代码实现含详细注释import socket import time import struct from typing import List, Tuple, Optional class FINSClient: def __init__(self, ip: str, port: int 9600, node_address: int 1, timeout: float 5.0): self.ip ip self.port port self.node_address node_address self.timeout timeout self._socket None # 错误码映射表便于日志输出 self.error_codes { 0x0010: Address invalid, 0x0020: Command invalid, 0x0030: Access denied, 0x0040: Service not enabled, 0x0050: Timeout, 0x0060: Data length error, } def _connect(self) - bool: 建立TCP连接带指数退避重试 for attempt in range(5): try: self._socket socket.socket(socket.AF_INET, socket.SOCK_STREAM) self._socket.settimeout(self.timeout) self._socket.connect((self.ip, self.port)) return True except (socket.timeout, ConnectionRefusedError, OSError) as e: wait_time 2 ** attempt # 指数退避1s, 2s, 4s, 8s, 16s print(fConnection attempt {attempt1} failed: {e}. Retrying in {wait_time}s...) time.sleep(wait_time) return False def _send_fins_frame(self, frame: bytes) - Optional[bytes]: 发送FINS帧并接收响应带超时和错误处理 if not self._socket: if not self._connect(): raise ConnectionError(Failed to connect to PLC after 5 attempts) try: self._socket.send(frame) response self._socket.recv(1024) return response except socket.timeout: # 超时错误触发0x0050处理 print(FINS request timeout. Triggering retry with backoff...) # 这里可加入降频逻辑如临时延长下次请求间隔 return None except Exception as e: print(fSocket error during send/receive: {e}) self._socket.close() self._socket None return None def _parse_response(self, response: bytes) - Tuple[bool, str, Optional[List[int]]]: 解析FINS响应返回(是否成功, 错误信息, 数据列表) if len(response) 8: return False, Response too short ( 8 bytes), None # 解析FINS Header (8 bytes) # 字节0-1: 命令码 (应为0x8000) # 字节2-3: 错误码 # 字节4: 节点号 cmd_code int.from_bytes(response[0:2], big) error_code int.from_bytes(response[2:4], big) node_addr response[4] # 校验节点号是否匹配 if node_addr ! self.node_address: return False, fNode address mismatch: expected {self.node_address}, got {node_addr}, None # 检查错误码 if error_code ! 0: error_msg self.error_codes.get(error_code, fUnknown error 0x{error_code:04X}) return False, fFINS error 0x{error_code:04X}: {error_msg}, None # 成功响应数据从第17字节开始Header 8B Command 9B if len(response) 17: data_bytes response[17:] # 将字节流按2字节一组解析为整数DM区为字16位 data_words [] for i in range(0, len(data_bytes), 2): if i 2 len(data_bytes): word int.from_bytes(data_bytes[i:i2], big) data_words.append(word) return True, Success, data_words return False, No data in response, None def read_dm(self, start_dm: int, length: int) - Tuple[bool, str, Optional[List[int]]]: 读取DM区指定数量的字 :param start_dm: 起始DM编号逻辑地址如DM100填100 :param length: 读取字数如读10个字填10 :return: (success, message, data_list) # 地址校验DM区有效范围0~4095CP1E if not (0 start_dm 4095): return False, fDM address {start_dm} out of range (0-4095), None if not (1 length 100): # 限制单次读取长度防溢出 return False, fLength {length} out of valid range (1-100), None # 构造FINS Header header bytes([ 0x80, 0x00, # Command: Read memory 0x00, 0x00, # Error code: 0 self.node_address, # Node address 0x00, # Network number 0x00, # Unit number 0x00 # IO memory address high byte ]) # 构造FINS Command cmd_code bytes([0x80, 0x00]) # Repeat command code mem_area bytes([0x82]) # DM area addr_bytes start_dm.to_bytes(4, big) # Start address (logical) len_bytes length.to_bytes(2, big) # Length in words command cmd_code mem_area addr_bytes len_bytes frame header command # 发送并解析 response self._send_fins_frame(frame) if response is None: return False, No response from PLC, None return self._parse_response(response) # 使用示例 if __name__ __main__: client FINSClient(192.168.250.10, 9600, 1) # 读取DM100~DM1045个字 success, msg, data client.read_dm(100, 5) if success: print(fRead success: {data}) # 如 [0x1234, 0x5678, ...] else: print(fRead failed: {msg})5.3 模块使用心得那些手册里不会写的细节这个模块已在多个项目中稳定运行超2年以下是实践中沉淀的关键心得节点号必须动态获取