Python实现FINS/TCP服务端:欧姆龙PLC通信协议解析与模拟 1. 从零理解FINS协议为什么工控上位机绕不开它搞工业自动化的朋友尤其是做上位机、SCADA、MES对接的大概率都听过FINS这个词。它是欧姆龙OMRON系列PLC最核心的通信协议之一全称是Factory Interface Network Service。你可以把它理解成欧姆龙设备之间、以及上位机与欧姆龙设备之间的一套“普通话”——只要双方都说FINS不管底层走的是串口、以太网还是其他链路数据格式和命令语义都是统一的。我第一次接触FINS是在一个产线数据采集项目里当时需要从一台欧姆龙CP系列PLC里按周期读取几百个DM区寄存器同时还要往CIO区写控制字。一开始想用现成的OPC服务器但授权费用高、部署链路长而且中间多一层就多一个故障点。后来决定直接用Python自己实现FINS的TCP客户端和服务端客户端负责主动去读写PLC服务端则用来模拟PLC方便在没有真实硬件的情况下做联调和压力测试。这个“服务端”就是本篇要重点聊的东西。为什么服务端这么重要因为在真实项目里你不可能每次都抱着PLC去调试上位机逻辑。你需要一个能“假装自己是PLC”的服务端它监听TCP端口接收FINS命令帧解析出读写请求然后返回符合协议规范的响应帧。有了它上位机开发、协议解析验证、异常场景模拟都能在本地完成。而且当你把服务端写通之后客户端逻辑基本就是镜像操作理解成本会大幅下降。这篇文章适合谁看如果你有Python基础知道socket怎么用但对FINS协议还比较陌生或者你已经在做欧姆龙设备对接但一直用现成库、想搞清楚底层到底发生了什么那这篇内容就是为你准备的。我会从协议帧结构讲起把TCP服务端的骨架一步步搭出来重点解释每个字段为什么这么设计、字节序怎么处理、命令码怎么映射以及我在实际调试中踩过的那些坑。篇一先聚焦“能收能回”的最小可用服务端把地基打牢。2. FINS/TCP帧结构拆解每个字节都有它的脾气2.1 FINS/TCP的头部与FINS帧的分层关系很多人第一次看FINS/TCP的报文会懵因为它其实是两层结构叠在一起外层是FINS/TCP头内层才是真正的FINS命令帧。外层头固定16个字节负责在TCP链路上标识“这是一个FINS/TCP报文”并携带一些链路管理信息内层则是变长的FINS帧包含命令码、参数和实际数据。外层这16个字节的布局是这样的前4个字节是固定魔数0x46 0x49 0x4E 0x53也就是ASCII的“FINS”接着4个字节是长度字段表示后面FINS帧的字节数再4个字节是命令码常见的有0x00000000表示FINS帧收发0x00000001表示节点地址请求0x00000002表示节点地址响应最后4个字节是错误码正常情况为0。这里有个容易搞混的点长度字段只算内层FINS帧的长度不包括这16字节头本身。我当初就是多算了16字节导致服务端一直认为报文不完整卡了很久。内层FINS帧的结构更复杂一些它由信息控制字段ICF、保留字段RSV、网关允许数GCT、目标网络地址DNA、目标节点地址DA1、目标单元地址DA2、源网络地址SNA、源节点地址SA1、源单元地址SA2、服务IDSID、命令码MRC/SRC以及后面的数据区组成。听起来很多但实际写服务端时你只需要关心其中几个关键字段命令码决定你要执行什么操作目标节点地址和单元地址决定操作哪个设备数据区里才是真正的读写地址和值。2.2 命令码与读写操作的对应关系FINS的命令码是两段式的MRC是主命令码SRC是子命令码。对于内存区读写最常用的是操作类型MRCSRC说明内存区读取0x010x01从指定内存区读连续数据内存区写入0x010x02向指定内存区写连续数据内存区填充0x010x03用同一个值填充一段区域强制置位/复位0x230x01/0x02对位地址进行强制操作读取命令的数据区结构是起始地址2字节 区域代码1字节 位偏移1字节 读取长度2字节。写入命令则是起始地址2字节 区域代码1字节 位偏移1字节 写入长度2字节 实际数据。区域代码用来区分DM区、CIO区、WR区、HR区等比如DM区通常是0x82CIO区是0xB0。这些代码在欧姆龙的手册里有完整列表服务端需要根据区域代码去对应的“内存模型”里取数据。我在实现时给服务端建了一个字典来模拟内存区键是区域代码值是一个字节数组。读取时按地址和长度切片返回写入时按地址覆盖对应位置。这样虽然简单但足够支撑大部分联调场景。如果你要模拟更真实的PLC行为还可以加上地址越界检查、区域大小限制、写入保护等逻辑。2.3 字节序与地址计算的坑FINS协议在TCP上传输时多字节字段统一采用大端序Big-Endian。Python的struct模块用H表示大端无符号短整型I表示大端无符号整型。这个点看起来简单但实际写的时候特别容易在地址计算上翻车。举个例子DM区的地址在FINS帧里是2字节但PLC内部实际是按字16位寻址的。假设你要读DM100开始的10个字帧里的起始地址就是100长度是10。但如果你要读的是位地址比如CIO区的第100.03位那地址字段和位偏移字段就要配合使用。位偏移是0到15之间的值表示在这个字里的第几位。服务端解析时需要先定位到字再根据位偏移取出对应的bit。我踩过的一个坑是有些客户端在写位地址时会把位偏移直接加到地址上比如把100.03写成103然后位偏移填0。这种“扁平化”的地址表示在某些库里有约定但标准FINS帧并不是这样。服务端如果只按标准解析就会读错位置。所以我在服务端里加了一个兼容逻辑如果位偏移为0且区域代码是位寻址区域就按字地址处理否则按标准位地址处理。这个判断虽然不完美但在实际对接中减少了很多扯皮。3. 用Python搭出TCP服务端骨架从监听端口到收包3.1 为什么选socketserver而不是裸socketPython写TCP服务端有两条路一条是用标准库的socket模块自己管理连接、收发和超时另一条是用socketserver框架它帮你封装了连接监听、多线程/多进程分发的基础逻辑。对于FINS服务端这种“一连接一会话”的场景我强烈建议用socketserver.ThreadingTCPServer。原因很简单FINS/TCP通常是长连接一个客户端连上来之后会持续发命令用多线程可以同时服务多个客户端而且每个连接的状态比如节点地址协商结果可以独立保存不会互相干扰。裸socket当然也能做但你需要自己处理accept循环、连接超时、线程池、异常断开后的资源回收。这些逻辑写起来不难但容易漏。用socketserver的话你只需要继承BaseRequestHandler重写handle方法在里面写收包和回包的逻辑就行。框架会为每个连接自动创建一个线程连接断开后线程自然结束。不过有一点要注意ThreadingTCPServer默认的allow_reuse_address是False这意味着服务端重启时如果端口还没完全释放会报“Address already in use”。我习惯在启动前把它设为True这样调试时反复重启不会卡端口。另外daemon_threads也建议设为True否则主线程退出时子线程可能还在跑导致进程挂不掉。3.2 收包策略定长头变长体的粘包处理TCP是流式协议没有消息边界。FINS/TCP的报文是“16字节固定头变长FINS帧”所以收包时必须先收满16字节解析出长度字段再根据长度收后面的FINS帧。这就是典型的“定长头变长体”模式。我在handle方法里是这样做的先用一个循环反复调用self.request.recv(16)直到收满16字节或者连接断开。收满头之后解析出fins_len然后再循环收fins_len个字节。这里有个细节recv返回的字节数可能小于请求的字节数所以必须用循环累加不能假设一次就能收全。我见过有人直接recv(16)然后就用结果在网络抖动时解析出乱七八糟的长度服务端直接崩掉。收满一个完整报文后就可以交给解析函数处理了。处理完生成响应帧再用self.request.sendall()发回去。sendall会保证所有字节都发出去比send省心。如果你要支持高并发还可以在handle里加一个接收缓冲区把多次recv的数据拼起来再切分但对我们这个场景来说按“头体”两步收已经够用了。3.3 连接生命周期与节点地址协商FINS/TCP在正式收发FINS帧之前通常有一个节点地址协商的过程。客户端会先发一个外层命令码为0x00000001的报文请求分配节点地址服务端返回0x00000002的响应里面包含分配给客户端的节点地址和自身的节点地址。这个过程不是必须的有些客户端会跳过直接发FINS帧但为了兼容性服务端最好支持。我在服务端里维护了一个连接级别的状态字典记录每个连接协商后的客户端节点地址和服务端节点地址。如果客户端没协商就直接发FINS帧我就用默认值比如客户端节点1、服务端节点0来填充响应帧里的源和目的地址。这样无论客户端是否协商服务端都能正常回应。还有一个实际经验节点地址协商的响应帧里外层长度字段是8因为后面的数据区固定是8字节客户端节点地址4字节服务端节点地址4字节。这个长度别算错否则客户端会认为响应不完整。我当初就是把这个长度写成了12导致客户端一直重发协商请求日志里全是重复的节点地址请求排查了半天才发现是长度字段多写了4。4. 解析FINS命令并构造响应让服务端“活”起来4.1 从字节流到命令对象的解析流程收到完整的FINS帧后第一步是把它从字节流解析成一个结构化的命令对象。我习惯定义一个FinsCommand类包含mrc、src、sid、data等字段再加一个parse类方法负责从bytes构造实例。解析时按固定偏移量逐段读取ICF在偏移0RSV在偏移1GCT在偏移2DNA在偏移3DA1在偏移4DA2在偏移5SNA在偏移6SA1在偏移7SA2在偏移8SID在偏移9MRC在偏移10SRC在偏移11数据区从偏移12开始。这里有个容易忽略的点FINS帧里的地址字段是“网络地址节点地址单元地址”的三段式。网络地址通常为0表示本地网络节点地址是PLC在网络中的节点号单元地址用于区分CPU单元和特殊单元比如0x00是CPU单元0xE1是特殊单元。服务端在构造响应时需要把源和目的地址对调请求里的目标地址变成响应里的源地址请求里的源地址变成响应里的目标地址。这个对调逻辑如果写错客户端可能会认为响应不是发给自己的直接丢弃。解析完命令后根据mrc和src分发到不同的处理函数。我一般用一个字典做映射键是(mrc, src)元组值是处理函数。这样新增命令支持时只需要加一个条目不用改主流程。对于篇一来说先实现内存区读取和写入这两个最常用的命令就够了。4.2 内存区读取的响应数据组装读取命令的处理逻辑是从数据区解析出起始地址、区域代码、位偏移和读取长度然后从模拟内存里取出对应数据组装成响应帧。响应帧的FINS部分结构是命令码MRC/SRC 结束码2字节 读取到的数据。结束码正常为0x0000如果地址越界或区域不存在就返回对应的错误码比如0x0001表示“命令长度错误”0x0002表示“命令不支持”0x0003表示“地址越界”。组装响应时外层FINS/TCP头也要重新生成魔数不变长度字段等于内层FINS帧的字节数命令码填0x00000000表示FINS帧收发错误码填0。内层FINS帧的ICF、RSV、GCT等字段可以沿用请求里的值但源和目的地址要对调。SID字段必须原样返回因为客户端靠SID来匹配请求和响应。如果SID对不上客户端会认为响应超时。我实测下来读取响应最容易出错的地方是数据长度。比如客户端请求读10个字响应里就必须正好有20字节的数据每个字2字节。如果多一个字节或少一个字节客户端解析时就会错位。所以我在组装数据区时会严格按读取长度 * 2来切片并在返回前用len()校验一遍。4.3 写入命令的校验与内存更新写入命令比读取多了一步数据校验。请求的数据区里除了起始地址、区域代码、位偏移和写入长度后面还跟着实际要写入的数据。服务端需要先检查数据长度是否和写入长度匹配如果不匹配就返回长度错误。然后检查地址范围是否在模拟内存的合法区间内越界就返回地址错误。只有都通过后才把数据写入模拟内存。写入操作有一个“原子性”的考虑如果客户端一次写100个字服务端应该要么全写成功要么全不写。我在实现时先把数据暂存到一个临时列表校验全部通过后再一次性更新内存。这样即使中间发现某个地址越界也不会出现“前50个字写了、后50个字没写”的尴尬情况。还有一个实际场景有些客户端会连续发送多个写入命令中间不加延迟。服务端如果处理太慢TCP缓冲区可能会堆积。我在handle里加了一个简单的流控每处理完一个命令就立即回响应不攒批。这样客户端收到响应后再发下一个天然形成了请求-响应式的节流。如果你要支持客户端“管道化”发送那就需要在服务端加一个命令队列按顺序处理并回包但篇一先不展开。5. 实测中遇到的典型问题与排查思路5.1 服务端收到报文但客户端一直超时这是我最开始遇到的现象服务端日志显示已经收到完整报文并回了响应但客户端那边一直报超时。排查时我先用抓包工具看了下TCP流发现服务端确实发出去了响应但客户端没有确认。后来对比请求和响应的字节序列发现响应里的SID字段和请求不一致。原因是我的解析函数在构造响应时重新生成了一个SID而不是沿用请求里的。客户端靠SID匹配请求和响应SID对不上自然就超时了。这个问题的教训是FINS协议里SID是请求-响应的关联标识服务端必须原样返回不能自己生成。类似地ICF字段里的“响应要求”位也要正确处理如果客户端要求响应服务端就必须回如果客户端不要求响应比如某些广播场景服务端就可以不回。我在服务端里加了一个判断如果ICF的最低位是0表示不要求响应那就只处理不回包。5.2 地址越界导致的结束码错误另一个常见问题是客户端读了一个超出模拟内存范围的地址服务端返回了结束码0x0003但客户端不认这个错误码直接抛异常。后来查手册发现不同系列的PLC对错误码的定义略有差异有些客户端只认0x0000和0x0001其他都当成未知错误。为了让联调更顺畅我在服务端里做了一个“宽松模式”对于越界读取不返回错误码而是返回全0数据并记录一条警告日志。这样客户端至少能拿到响应不会因为异常中断流程。当然正式环境里还是应该返回标准错误码宽松模式只适合调试阶段。还有一个细节结束码在响应帧里的位置是命令码之后、数据之前占2字节大端序。我见过有人把它放在数据之后结果客户端解析出的数据全部错位。这个位置是固定的不能随意调整。5.3 多客户端并发时的状态隔离用ThreadingTCPServer时每个连接一个线程但如果你把模拟内存定义成全局变量多个客户端同时读写就会互相干扰。我在测试时开了两个客户端一个读DM100一个写DM100结果读到的值忽大忽小。后来把模拟内存改成每个连接独立一份问题就消失了。但这样又带来一个新问题如果两个客户端需要共享同一份PLC内存独立内存就不对了。我的解决方案是把模拟内存做成一个可配置的共享对象默认每个连接独立但可以通过参数指定共享。对于大多数联调场景独立内存更安全因为一个客户端的误操作不会影响另一个。如果你要模拟真实PLC的多客户端访问那就用共享内存并加线程锁来保护读写操作。Python的threading.Lock在这种场景下足够用锁的粒度可以粗一点直接锁整个内存区避免死锁。5.4 字节序和长度字段的反复确认字节序问题我前面提过但实际调试时还是反复踩。最典型的是长度字段外层FINS/TCP头的长度字段是4字节大端内层FINS帧里的读取长度是2字节大端。我一开始用struct.pack(I, len)和struct.pack(H, len)时把两个长度搞混了导致外层长度写成了内层数据长度加16客户端解析时多读了16字节把后面的TCP流也吞了。后来我养成了一个习惯每次打包前先打印出各个字段的十六进制值和手册上的示例报文逐字节对比。这个方法虽然笨但非常有效尤其是对协议不熟的时候。还有一个隐藏坑Python的bytes是不可变的拼接时用会不断创建新对象。对于小报文无所谓但如果你的服务端要处理大量并发建议用bytearray或者struct.pack一次性打包。我在服务端里用了一个build_response函数把所有字段按顺序pack到一个bytearray里最后一次性sendall性能比反复拼接好很多。6. 从服务端到客户端篇一之后还能怎么扩展服务端能收能回之后其实你已经掌握了FINS协议最核心的交互模式。接下来最自然的扩展就是写一个对应的客户端用它去连接这个服务端做读写测试。客户端和服务端的逻辑是镜像的服务端解析请求、构造响应客户端构造请求、解析响应。你把服务端的解析函数反过来用基本就能拼出客户端。我在实际项目里就是先写服务端再用服务端来验证客户端两边对着调比直接连PLC效率高得多。再往后可以扩展的方向有几个一是支持更多命令码比如内存区填充、强制置位复位、运行状态读取等二是把模拟内存做成可持久化的比如从JSON文件加载初始值这样每次重启服务端都能恢复到已知状态三是加一个简单的Web界面或者命令行工具能实时查看和修改模拟内存方便演示和调试。这些扩展都不难但能让你的FINS工具链更完整。最后分享一个我在调试时的小技巧在服务端的handle方法里加一个可开关的十六进制日志把收到的每个报文和发出的每个响应都打印出来格式化成每行16字节的十六进制加ASCII。这个日志在排查协议问题时比任何断点都管用因为你可以直接和手册上的示例报文做逐字节对比。我通常只在调试时打开正式跑的时候关掉避免日志刷屏。如果你也在做FINS相关的东西建议从第一天就把这个日志机制加上后面会省很多时间。