
做上位机开发这些年我遇到“设备上有台欧姆龙PLC上位机需要远程读写数据”这类需求特别多。早期我习惯走HostLink串口简单直接但线缆长度、串口资源、抗干扰能力都受限。后来只要PLC带以太网口我几乎统一改用FinsTCP。这个协议是欧姆龙自家FINS通信协议在TCP/IP之上的实现稳定性、兼容性、跨平台能力都很能打而且报文格式公开透明完全可以在没有官方DLL的情况下自己写驱动。这篇文章我会从协议思路、报文结构、代码实现到坑点排查完整过一遍适合刚接触欧姆龙PLC上位机通信的电气工程师和软件工程师也适合准备自己封装通信库而不是直接套现成组件的朋友。1. 为什么上位机与欧姆龙PLC通信绕不开FinsTCP1.1 FinsTCP到底解决什么问题先理一下背景。欧姆龙PLC的通信方式有串口HostLink、Controller Link、FINS/UDP、FINS/TCP等其中FINS是欧姆龙制定的应用层协议全称Factory Interface Network Service它定义了一套统一的内存读写命令不管是走串口还是走以太网命令格式都差不多。FinsTCP就是这套FINS命令跑在TCP协议上端口固定9600专门给上位机、触摸屏、SCADA系统通过以太网访问PLC数据区用。它解决的核心问题有三个一是把物理链路从串口换成了以太网传输距离和速度都有明显提升二是通过TCP的可靠传输机制避免UDP丢包导致的数据错乱三是报文基于明确的字节码定义不同型号的欧姆龙PLC包括CJ系列、CP系列、NJ/NX系列只要支持以太网口基本都能用同一套FINS命令访问。这意味着你今天写的上位机通信代码换一台支持以太网的欧姆龙PLC改一下IP和节点号就能复用不用推倒重来。1.2 用TCP承载FINS比传统串口通信强在哪先说结论在需要高频率、大数据量、多设备集中管理的场景里FinsTCP几乎是无脑首选。HostLink串口通信一帧数据也就几十个字节波特率通常115200实际有效吞吐还受半双工和应答机制限制。而FinsTCP走以太网理论带宽远高于串口实测在百兆局域网里单次读写几百个字的DM区数据往返时间能控制在几毫秒级别。FinsTCP还天然适合长连接。TCP本身就是面向连接的协议握手完成后一直保持链路上位机可以随时发起读写请求PLC也能主动上报状态事件通过FINS命令或辅助手段这种双向能力在设备状态监控、产线数据采集里非常实用。相比之下如果用HTTP轮询或Modbus TCP可能要频繁建立连接或者协议封装过重实时性和灵活性都不如FinsTCP直接。当然FinsTCP也不是没有门槛。它和Modbus TCP最大的不同在于Modbus TCP的报文结构简单、网上资料多而FinsTCP的命令帧里包含了FINS节点地址、网络号、单元号等一堆概念初学者第一次看到报文会有点懵。而且欧姆龙官方文档虽然齐全但组织方式偏手册化很多细节散落在不同PDF里网上能直接抄的完整代码也不多。这篇文章就是帮你把这层窗户纸捅破把报文拆开揉碎讲清楚。2. FinsTCP报文结构拆解读不懂帧格式就无法真正上手2.1 FINS命令层的10字节头拆解FinsTCP报文由两层构成外层是FINS/TCP帧封装包含握手和长度信息内层是FINS命令帧。真正描述“读哪个地址、读多少、数据是什么”的核心信息全在内层所以先从FINS命令帧说起。一个标准的FINS命令帧由10字节命令头和后续数据区构成。10字节头里每个字段都有明确含义我列个表字节偏移字段含义典型值0ICF信息控制字段表示请求/响应方向请求0x80响应0x001RSV系统保留固定0x000x002GCT网关允许通过数直连PLC设20x023DNA目标网络号0x00表示本网络4DA1目标节点号即PLC的FINS节点号常见0x015DA2目标单元号CPU单元为00x006SNA源网络号0x007SA1源节点号即上位机的FINS节点号自己指定如0x648SA2源单元号上位机通常为00x009SID服务标识符每次请求自增0x00~0xFF循环很多初学者会卡在SA1这里网上搜“欧姆龙fins中的sa1是什么意思”也就是这个问题。SA1就是源节点号代表“我是谁”。PLC内部维护着一张节点表收到请求后它会看SA1把响应数据回给这个节点。上位机软件里这个值可以随便设置吗不是随便有两个原则一是不能和LAN内已有PLC的节点号冲突否则PLC分不清请求到底发给谁二是建议固定一个值不要每次请求随机变否则PLC侧的FINS节点表会不停刷新严重时影响响应稳定性。DA1是目标节点号也就是你要访问那台PLC的节点号。如果一台电脑要访问两台PLC两台PLC的DA1不能一样比如1号PLC设12号PLC设2上位机发请求时DA1分别填1或2即可。这个机制有点类似IP地址里的主机号只不过作用在FINS层面。2.2 FINS/TCP的握手与帧封装FinsTCP不是TCP一连上就能直接发FINS命令的它先要完成一次应用层握手。握手目的是让PLC知道上位机的FINS节点号并确认通信参数。握手请求帧固定如下46 49 4E 53 00 00 00 0C 00 00 00 00 00 00 00 01一字节一字节拆46 49 4E 53ASCII码字符“FINS”这是帧类型标识所有FinsTCP帧都有这个头。00 00 00 0C4字节长度字段表示从“命令码”开始到帧尾的数据长度。这个值等于12十六进制0x0C因为握手请求除了这12字节外没有附加数据。00 00 00 004字节命令码0表示握手请求。00 00 00 014字节数据这里填的是上位机的FINS节点号我举例用1。PLC收到握手请求后如果一切正常会回复46 49 4E 53 00 00 00 14 00 00 00 00 00 00 00 03 00 00 00 01长度字段变成0x14即20命令码后面多了8字节前4字节00 00 00 03是握手响应码3表示正常后4字节00 00 00 01是PLC自己的FINS节点号。握手成功后TCP连接不会断开之后所有数据读写都在这条连接上完成不需要重复握手。握手之后正式发读写命令时外层帧格式是46 49 4E 53 4字节长度 4字节命令码(0x00000002) FINS命令帧例如读D100这个请求整体报文会是46 49 4E 53 -- 帧头 00 00 00 16 -- 长度FINS命令帧22字节 00 00 00 02 -- 命令码2表示数据发送 80 00 02 00 01 00 00 64 00 00 01 00 82 00 64 00 00 00 01最后18字节就是FINS命令帧。注意外层命令码和FINS命令帧里有两个不同的“命令”外层0x00000002表示“这是数据帧”内层0x0101才表示“读内存区”。2.3 内存区读写地址和数据的编码规则FINS命令帧在10字节头之后紧跟着2字节命令码。常用命令码就两个01 01读内存区01 02写内存区读内存区命令命令码后面跟5个参数内存区代码(1字节) 起始地址(2字节) 位地址(2字节) 读取数量(2字节)写内存区命令命令码后面跟5个参数再加数据内存区代码(1字节) 起始地址(2字节) 位地址(2字节) 写入数量(2字节) 数据(N字节)内存区代码是重中之重我把常用的整理成表内存区字单位代码说明CIO区0x30欧姆龙传统I/O区对应实际输入输出继电器WR区0x31工作区相当于中间继电器HR区0x32保持区断电保持DM区0x82数据寄存器区最常见存数值和参数EM区0xA0起扩展数据区不同PLC型号块数不同注意DM区这里用了0x82而不是0x02这是因为0x82对应“二进制模式下的DM区”0x02对应“BCD模式下的DM区”。绝大多数上位机处理数值都习惯用二进制整数所以协议里把二进制模式和BCD模式分成了不同的内存区代码用0x82就是告诉PLC我读写的数据按二进制解释。起始地址是2字节以字为单位从0开始编号。比如DM区第一个字是D0十六进制就是0x0000D100写成0x0064D200写成0x00C8。位地址在字单位读写时固定填00 00只有在按位读写时才用到。这里有个特别容易搞混的地方读一个字还是读一个位FINS命令里“读取数量”前没有单位标志它由内存区代码和位地址字段共同决定。当位地址为0且按字单位访问时数量就是字数当位地址非0且内存区支持位访问时数量就是位数。我平时90%的场景都是按字读DM区所以位地址固定填0不会出问题。3. 用C#实现一个最小可用的FinsTCP客户端3.1 通信前的准备PLC侧与上位机侧要改哪些设置代码之前先做环境准备。PLC侧无论你用Sysmac Studio还是CX-Programmer都要确认三件事IP地址、FINS节点号、FINS服务使能。以NJ/NX系列用Sysmac Studio为例在“控制器设置”的“节点设置”里能看到IP地址和FINS节点号节点号范围1~254默认通常是1。CP1H、CJ2M用CX-Programmer的话在“IO表和单元设置”里找到内置以太网口同样能看到IP地址和节点号设置。FINS节点号和IP地址是两套体系一台PLC的IP是192.168.1.10FINS节点号可以是1两者通过PLC内部的地址映射关联。上位机侧需要做两件事一是把电脑IP和PLC设在同一个网段比如PLC是192.168.1.10电脑就设192.168.1.100子网掩码默认255.255.255.0二是放行Windows防火墙的TCP 9600端口有些开发环境能弹窗让你允许如果没有弹窗就得手动在防火墙入站规则里加一条TCP 9600的放行策略。有些朋友问用虚拟机里的上位机软件连PLCVMware该选哪种网络模式实测用桥接模式最省心让虚拟机和PLC处于同一局域网络的二层链路FinsTCP广播和直连都不受影响。NAT模式通常也能连通但有些跨网段场景下PLC的FINS路由配置会比较麻烦。另外建议把PLC的IP改到比较靠后的地址段比如192.168.1.10以上避免和家用路由器默认网段冲突。3.2 完整代码实现连接、握手、读D区、写D区下面直接给出一个可用的C# FinsTCP客户端类我只保留核心方法方便你理解报文构造和解析逻辑。using System; using System.Net.Sockets; using System.Threading; public class FinsTcpClient { private TcpClient _tcp; private NetworkStream _stream; private byte _sid; // 服务标识每次请求自增 private byte _sa1; // 上位机FINS节点号 private byte _da1; // PLC的FINS节点号 public FinsTcpClient(byte localNode, byte remoteNode) { _sa1 localNode; _da1 remoteNode; } public bool Connect(string ip, int port 9600) { _tcp new TcpClient(); _tcp.ReceiveTimeout 3000; _tcp.SendTimeout 3000; _tcp.Connect(ip, port); _stream _tcp.GetStream(); return Handshake(); } private bool Handshake() { // 握手请求FINS 长度12 命令0 本地节点号 byte[] req new byte[16]; Buffer.BlockCopy(new byte[] { 0x46, 0x49, 0x4E, 0x53 }, 0, req, 0, 4); Buffer.BlockCopy(new byte[] { 0x00, 0x00, 0x00, 0x0C }, 0, req, 4, 4); Buffer.BlockCopy(new byte[] { 0x00, 0x00, 0x00, 0x00 }, 0, req, 8, 4); req[15] _sa1; _stream.Write(req, 0, req.Length); byte[] resp ReceiveFrame(); // 响应长度0x14响应码后4字节为0x00000003表示成功 return resp.Length 20 resp[15] 0x03; } public bool ReadDm(int startWord, ushort[] buffer) { int count buffer.Length; // 构造FINS命令帧命令0101DM区0x82起始地址位地址0数量 byte[] cmd new byte[18]; cmd[0] 0x01; cmd[1] 0x01; cmd[2] 0x82; cmd[3] (byte)(startWord 8); cmd[4] (byte)(startWord 0xFF); cmd[5] 0x00; cmd[6] 0x00; cmd[7] (byte)(count 8); cmd[8] (byte)(count 0xFF); byte[] frame BuildDataFrame(cmd); _stream.Write(frame, 0, frame.Length); byte[] resp ReceiveFrame(); // 跳过10字节FINS头 2字节命令码 2字节结束码 int dataOffset 14; if (resp[12] ! 0x00 || resp[13] ! 0x00) return false; for (int i 0; i count; i) { buffer[i] (ushort)((resp[dataOffset i * 2] 8) | resp[dataOffset i * 2 1]); } return true; } public bool WriteDm(int startWord, ushort[] values) { int count values.Length; // 构造写命令命令0102DM区0x82起始地址位地址0数量数据 byte[] cmd new byte[11 count * 2]; cmd[0] 0x01; cmd[1] 0x02; cmd[2] 0x82; cmd[3] (byte)(startWord 8); cmd[4] (byte)(startWord 0xFF); cmd[5] 0x00; cmd[6] 0x00; cmd[7] (byte)(count 8); cmd[8] (byte)(count 0xFF); for (int i 0; i count; i) { cmd[9 i * 2] (byte)(values[i] 8); cmd[10 i * 2] (byte)(values[i] 0xFF); } byte[] frame BuildDataFrame(cmd); _stream.Write(frame, 0, frame.Length); byte[] resp ReceiveFrame(); return resp.Length 14 resp[12] 0x00 resp[13] 0x00; } private byte[] BuildDataFrame(byte[] finsCmd) { // 外层FINS 长度 命令2 FINS命令 int len 8 finsCmd.Length; byte[] frame new byte[len]; Buffer.BlockCopy(new byte[] { 0x46, 0x49, 0x4E, 0x53 }, 0, frame, 0, 4); frame[4] (byte)(finsCmd.Length 8 0xFF); frame[5] (byte)(finsCmd.Length 0xFF); frame[6] 0x00; frame[7] 0x00; frame[8] 0x00; frame[9] 0x00; frame[10] 0x00; frame[11] 0x02; Buffer.BlockCopy(finsCmd, 0, frame, 12, finsCmd.Length); return frame; } private byte[] ReceiveFrame() { // 读取FINS帧头前8字节含长度字段 byte[] header ReadExactly(8); if (header[0] ! 0x46 || header[1] ! 0x49 || header[2] ! 0x4E || header[3] ! 0x53) throw new Exception(invalid frame header); int bodyLen (header[4] 8) | header[5]; byte[] body ReadExactly(bodyLen); byte[] full new byte[8 bodyLen]; Buffer.BlockCopy(header, 0, full, 0, 8); Buffer.BlockCopy(body, 0, full, 8, bodyLen); return full; } private byte[] ReadExactly(int count) { byte[] buf new byte[count]; int offset 0; while (offset count) { int n _stream.Read(buf, offset, count - offset); if (n 0) throw new Exception(connection closed); offset n; } return buf; } public void Close() { _stream?.Close(); _tcp?.Close(); } }调用示例var fins new FinsTcpClient(localNode: 90, remoteNode: 1); if (fins.Connect(192.168.1.10)) { ushort[] data new ushort[10]; if (fins.ReadDm(100, data)) { Console.WriteLine($D100{data[0]}, D101{data[1]}); } ushort[] writeData new ushort[] { 1234, 5678 }; fins.WriteDm(200, writeData); fins.Close(); }这段代码的核心思路是先TCP连接再握手然后每次请求先构造18字节的FINS命令帧再套上FINS/TCP外层帧发送接收时按长度字段精确拆包。特别注意ReceiveFrame方法用了“先读8字节定长头部再根据长度读取剩余字节”的策略这是处理TCP流式传输粘包问题的标准做法。3.3 代码里的关键细节与参数计算第一个关键点是节点号的选择。我的示例里上位机SA1设为90是因为PLC节点号通常从1开始分配给上位机留一个比较大的独立号更安全。如果你有两台PLC节点号分别设1和2上位机仍然是90访问不同PLC时只需要把_remoteNode换成对应对手即可SA1保持不变。第二个关键点是起始地址的计算。D100不是直接填100而是转成十六进制0x0064后按高字节在前填入两个字节。很多第一次写的人会直接把100填成0x64 0x00字节序反了PLC返回“地址错误”或者读到完全错误的数据。处理多字节数值时欧姆龙统一用大端序也就是高位在前这和x86电脑的内存小端序正好相反代码里我用(byte)(startWord 8)和(byte)(startWord 0xFF)就是为了完成这个转换。第三个关键点是读响应的数据解析。FINS响应帧的结构是10字节头 2字节命令码 2字节结束码 数据。结束码00 00表示成功非零则对应具体错误。读写DM区时每个数据点是2字节高字节在前。比如返回06 02 00 00 00 00 12 34最后两个字就是0x0000和0x1234对应D00D10x1234。第四个关键点是SID服务标识。我的示例代码里没有递增SID但实际生产环境建议在BuildDataFrame时把SID加进去并自增这样当上位机并发发送多个请求时可以通过SID匹配响应和请求避免乱序导致的数据错乱。简单场景下固定0也能用多线程场景就不够了。4. 常见问题与排查实录4.1 连不上、握手失败多半是这三类原因我在调试现场见过最多的就是“TCP能通但握手失败”和“完全连不上”原因基本集中在三块。第一块是IP和节点号配置不对。用工程软件连接PLC看当前IP和FINS节点号确保上位机IP和PLC在同一网段同时PLC节点号确实是你代码里DA1填的那个值。有个容易忽略的坑有些PLC型号的FINS节点号默认是0而0在某些固件版本里不被认为是合法目标节点直接改成1最稳妥。第二块是Windows防火墙拦截了9600端口。TCP连接超时、握手下发后无响应多半是防火墙在中间作妖。你可以暂时关闭防火墙测试如果能通就说明是拦截问题再精准添加放行规则。第三块是PLC的FINS服务未开启或芯片固件限制。部分欧姆龙内置以太网口型号默认只做了FINS/UDP服务FINS/TCP需要在单元设置里显式选择“使用FINS/TCP服务”。另外个别老型号PLC固件版本太低FINS/TCP支持有bug直接升级固件能解决。我整理了一张速查表现象可能原因处理方式TCP连接直接被拒IP不通、端口未开放ping测IPtelnet 192.168.1.10 9600测端口连接建立但握手无响应防火墙拦截、PLC未开FINS/TCP服务放行TCP 9600检查PLC单元设置握手返回码非0x03FINS节点号冲突、节点表满换一个SA1值检查网络内节点号分布读写请求响应超时地址越界、数据区不存在确认内存区代码和地址范围是否匹配PLC型号响应结束码非0000地址、数量、权限等错误对照错误码表逐项排查4.2 能读但写不了或者响应错误码乱飞读写都走通之后下一步最容易踩的坑是“能读不能写”和“错误码看不懂”。FINS响应里的结束码是2字节下面列几个高频值结束码含义常见触发场景0x0101本地节点错误同时发太多请求FINS队列溢出0x0201目标节点错误PLC节点号对不上或PLC忙不过来0x1101内存区代码错误写错了内存区代码比如DM区写成了0x020x1102指定地址不存在地址超过该型号PLC实际范围0x1103读/写数据长度错误读的数量超过了单帧上限一般限制在500字左右0x2002数据受保护DM区被设置了写保护0x2003对象不可写尝试写只读区域比如实际输入继电器CIO区某些位能读但不能写优先查0x2002和0x2003。有些欧姆龙PLC的DM区可以在CX-Programmer或Sysmac Studio里设置写保护范围一旦设置了保护上位机写操作就会被拒绝但读操作不受影响。另外如果你写的地址是CIO区的实际输入地址比如CIO 0.00对应物理输入端子很多PLC型号不允许上位机直接写这种输入映射区要写也只能写输出区或工作区。还有一个比较隐蔽的坑是单帧数据量限制。FINS协议对单次读写的最大字数有限制不同型号不一样常见上限是读取约500字、写入约500字。如果你一次要读写几千个字必须自己拆分成多次请求。拆的时候注意地址连续性问题比如读D0到D999建议按每批256字拆成4批避免因为协议限制导致响应错误。4.3 与三菱、西门子等PLC通信方式的横向对比有朋友会拿FinsTCP和三菱的MC协议、西门子的S7协议做对比这里简单说说差异。三菱MC协议走TCP时端口通常自己定义比如默认端口不固定报文里也有帧头、网络号、PC号等概念和FINS相当类似但内存区地址编码方式不同三菱的软元件编号和地址偏移计算需要额外查表。西门子S7协议基于ISO-on-TCP固定端口102里面有一套复杂的TPKT/COTP/ S7 PDU嵌套结构还要配置TSAP参数对接机架号和槽号上手门槛明显比FinsTCP高。相比之下FinsTCP的报文结构是最“直白”的没有太多嵌套封装一个内存区代码加上一个地址偏移就能定位数据位置。而且欧姆龙的协议文档里对地址换算给了大量例子照着套基本不会错。如果团队里有人问“上来学哪个PLC以太网协议练手”我一般推荐先拿欧姆龙FinsTCP开刀搞懂它之后再看三菱MC协议会轻松很多因为它们底层思路共通差别主要在字段定义上。5. 最后一点个人体会我在实际项目里用FinsTCP写过设备数据采集、配方下发、远程监控也帮客户排查过无数奇奇怪怪的通信问题。有一个经验想特别分享不管代码写得多顺手调试时一定先用Wireshark抓一下包把握手报文和读写报文对照着看一遍。很多时候你以为代码没问题但抓包一看才发现长度字段算错了、节点号填反了、响应数据拿错了偏移这些坑靠眼睛盯代码很难发现抓包一眼就能定位。另一个心得是超时和重试机制一定要做扎实。FinsTCP虽然是可靠连接但PLC扫描周期、总线负载都可能导致响应延迟上位机如果一超时就报错产线现场很容易频繁告警。我的习惯是把接收超时设在500毫秒超时后重试3次重试前重新发一次握手这样可以避开PLC瞬时繁忙的影响。如果重试还失败再抛异常通知上位机界面这时候基本可以确定是网络或者PLC硬件问题了。最后提醒一句FINS节点号在整个局域网内必须全局唯一包括PLC之间、PLC和上位机之间这是排查一切FinsTCP连接异常的基石。