
1. 为什么用C#直连FINS/TCP来读写欧姆龙NJ系列PLC1.1 NJ系列和传统欧姆龙PLC到底差在哪手上有NJ系列机器自动化控制器的朋友应该都有这种感受这套东西跟以前用CX-Programmer那批CJ、CS系列完全是两个路子。NJ101、NJ301、NJ501这些型号跑的是Sysmac自动化平台编程环境换成Sysmac Studio编程规范照着IEC 61131-3来写的是结构化文本、梯形图、功能块最核心的变化是——它是以变量标签为中心的不再像老PLC那样满脑子都是D0、W1.00这种绝对地址。我一开始接触NJ501-1300的时候最大的不适应就是以前做上位机直接在C#里拼个FINS帧读DM区就完事了现在变量名变了、数据结构变了就下意识觉得是不是得走别的协议了。实际折腾下来才发现NJ系列为了保证向下兼容CJ/CS那一套内存区CIO、W、H、DM在NJ上依然存在而且可以用同一套FINS协议访问。这句话是整个方案的基石后面所有代码都建立在这个前提上。所以这篇上篇要干的事很明确用C#从零手写一个FINS/TCP客户端把NJ系列PLC的DM区、CIO区数据稳定读上来能把byte[]还原成short、int、float这些真实工程值。适合已经会点C#、手上有NJ实机或者仿真器、想搞清楚底层到底发了什么字节的工控同行。下篇再讲写入、批量优化和CIP标签访问。1.2 通信方式选型FINS、EtherNet/IP、OPC UA怎么挑在动手写代码之前选型这一步必须想清楚因为这直接决定后面几周的活好不好干。NJ系列本身支持好几种上位机通信手段我把实际用过的几种摆出来对比一下都是我用真实项目踩出来的认知通信方式底层协议寻址方式实现难度典型场景FINS/TCP欧姆龙私有协议内存区地址DM/CIO/W中需自己组帧上位机批量读写、老系统迁移FINS/UDP欧姆龙私有协议内存区地址中低延迟、可容忍丢包EtherNet/IP CIPCIP over TCP/UDP变量标签名高需CIP路径需要按变量名访问Modbus TCP标准Modbus寄存器映射低需PLC侧写功能块做映射OPC UA标准OPC UA变量节点中需服务端授权跨平台、MES对接为什么上篇选FINS/TCP而不是CIP三个理由。第一CIP访问标签需要构造符号段Symbol Segment既要拿到变量名对应的实例ID又要在PLC侧开启标签的网络公开属性变量一多维护成本飙升。第二FINS协议是欧姆龙自家的帧结构公开、规整组帧逻辑写一遍就通吃DM/CIO/W/定时器各区代码复用率高。第三FINS/TCP是长连接握手一次之后所有读写都复用这条TCP链路单次读取延迟实测能压到几毫秒级别做上位机轮询完全够用。FINS的短板也直说它按绝对地址访问读不到没有AT地址的纯局部变量。解决办法是在NJ侧把需要给上位机的变量用AT指定到DM区或者CIO区比如把g_stMachineStatus这个结构体整体AT到D1000开始的若干字。这也是很多现场工程师实际采用的做法逻辑层用变量名编程通信层留一块DM区当数据交换区两边解耦维护起来反而清爽。提示选型阶段先把要给上位机的数据列一张清单标明类型和长度再决定AT到哪块区。别等代码写完再回头改PLC程序那样两边都要返工。1.3 这套方案的边界和适用范围得把话说在前面免得有人拿这个方案去干它干不了的事。FINS/TCP这套读写方案本质上是在内存区层面做数据搬运它不关心PLC里跑的是什么逻辑。所以它能干的读DM区、CIO区、W区、H区、定时器计数器当前值能做的写操作也是往这些区里写值。它不能干的不能直接调用NJ里的功能块、不能读写没有映射到内存区的纯变量、不能做程序下载上载。还有一点关于节点地址的认知必须提前建立。FINS网络里每个节点都有一个节点号0到254通常1到126可用TCP/IP场景下这个节点号跟IP地址是两码事后者管链路前者管FINS网络寻址。上位机PC自己也要有个节点号这个号可以手动指定也可以让PLC在握手时自动分配一个。我实测下来让服务器自动分配最省心因为手动指定容易跟PLC上其它已占用的节点号撞车。后面第4章会讲这个握手过程怎么在代码里落地。2. FINS/TCP协议拆解把帧结构吃透再动手2.1 FINS/TCP头部和FINS帧是两层皮很多人第一次看FINS资料会懵因为这里其实叠了两层结构外层是FINS/TCP头内层才是真正的FINS帧。理解这个分层是写出正确代码的第一步。外层的FINS/TCP头负责怎么在以太网上把这段数据送到对端它固定有一串标志和长度字段内层的FINS帧负责到了PLC之后要读哪个区、从哪开始、读多少个字。外层是信封内层是信纸。信封上写的是收件人信息信纸上才是真正要传达的内容。先看外层。FINS/TCP头结构如下所有多字节字段都是小端序这一点极其关键我第一次写的时候按大端拼结果PLC一直返回错误码0x00000001头部不是FINS偏移长度含义说明04魔术字固定ASCII FINS44数据长度从命令字段起算的字节数84命令码0000握手 / 0002发FINS帧124错误码请求时为0响应时看这里这里要特别注意长度字段的算法它指的是从命令字段开始到整段报文结尾的字节数也就是命令4字节 错误码4字节 后面跟的内容。如果后面的内容是FINS帧那么长度就等于8 FINS帧长度。手动握手命令0x00000000稍微特殊它后面还要再跟4字节的客户端节点地址长度就是12。再看内层FINS帧。它的固定头是10个字节加上2字节命令码再跟参数区。这10个字节里最有讲究的是那几个控制字段。热词里有人问FINS里的SA1是什么意思这里正好展开说清楚——SA1就是源节点地址Source Address 1也就是发出这条FINS命令的那个节点号。与它对应的是DA1目标节点地址即PLC的节点号。搞混这两个帧就路由不到目标。2.2 十个控制字段逐个说明FINS帧的头部我按顺序拆开讲每个字段都配上实际填什么值字段长度含义常见取值ICF1字节信息控制字段0x80需要响应RSV1字节保留固定0x00GCT1字节网关计数固定0x02DNA1字节目标网络地址0x00本网络DA11字节目标节点地址PLC节点号DA21字节目标单元地址0x00CPU单元SNA1字节源网络地址0x00SA11字节源节点地址PC节点号SA21字节源单元地址0x00SID1字节服务ID任意用于配对请求响应ICF填0x80表示我这条命令需要你回复如果填0x00就是不需要响应的广播式。实际项目里一律填0x80不然读不到数据。GCT填0x02是告诉网关最多经过2级路由因为我们只是同一网段直连其实填0x02是官方推荐的默认值传0或者1在某些路由结构下会被丢弃。SID这个字段容易被忽视。它是个流水号请求和响应里的SID必须一致。在单连接串行读写的场景下随便填一个固定值比如0x01都能工作但如果你的上位机要并发发多条命令就必须靠SID来匹配这条响应到底回的是哪条请求否则数据会串。我这里建议从一开始就把SID做成自增计数器虽然上篇串行读取用不上但为后面的扩展留好接口。2.3 内存区代码和地址映射表FINS帧头部10字节之后紧跟2字节命令码再跟参数。读内存区用命令码0x0101写内存区用0x0102。读命令的参数结构依次是内存区代码2字节 起始地址2字节 位位置1字节仅位操作时有效 读取点数2字节。内存区代码是一张固定表你要读哪块区就查哪个码。位地址和字地址是两套码读一个字用字码读一个位用位码。下表是我整理的高频区代码实际项目里DM区和CIO区用得最多内存区字地址码位地址码说明CIO0xB00x30输入输出继电器NJ上映射IO变量工作区W0xB10x31内部中间继电器保持继电器H0xB20x32断电保持辅助继电器A0xB30x33系统辅助区DM区0x820x02数据存储区交换区首选定时器当前值0x810x01读TC当前值举个具体例子读D1000开始的10个字那么参数就是内存区代码0x82、起始地址0x03E81000的十六进制、位位置0x00、点数0x000A。把这几个字段跟FINS头拼在一起再套上FINS/TCP头一条完整的读取命令就成型了。注意起始地址字段本身在协议里是按小端序排的但数值的语义还是十进制地址转十六进制。也就是说D1000应该是0x03E8写到报文里是E8 03。这两个概念别搞混一个是地址值的十六进制表示一个是这个数值在内存里的排列顺序。3. 上位机环境和C#工程准备3.1 PLC侧Sysmac Studio里几个不能漏的设置先说PLC侧。我用的是Sysmac Studio版本1.60这套比较稳。打开工程后重点看控制器设置里的内置EtherNet/IP端口设置注意NJ的内置以太网口同时承载EtherNet/IP和FINS两种服务互不冲突但如果你的项目里还挂了EtherCAT从站网段规划要提前想好。第一步是把IP设置好进入TCP/IP设置页给PLC指定固定的IP、子网掩码、默认网关。做上位机通信强烈建议PLC用静态IPDHCP在工业现场就是个定时炸弹。第二步是关键的FINS设置。在FINS设置页里确认FINS/TCP的端口号默认就是9600没有特殊需求不要改。再往下看FINS节点地址这里可以选自动或者手动指定。自动模式下节点地址由系统分配通常从节点号表里取一个可用的手动模式则填一个1到126之间的数。我建议先用自动等通信跑通、需要固定拓扑时再改手动。设置完点击同步把配置下装到控制器。这一步很多人会忘改完设置不下载PLC还是按旧配置跑然后对着代码debug半天。下装完成后PLC通常会自动重启网络服务稍等几秒。3.2 PC侧网段、防火墙和抓包工具PC侧没那么复杂但要细心。第一把PC的网卡IP设成和PLC同网段比如PLC是192.168.250.1PC就设192.168.250.100掩码255.255.255.0。第二关掉Windows防火墙对该网段的拦截或者干脆给通信程序加个入站规则因为FINS/TCP是主动连接一般不被拦但有些安全软件会抽查。第三用ping确认连通性这一步别跳过。工具方面我强烈推荐装一个抓包工具比如Wireshark配合抓包过滤器tcp.port 9600能把每一帧发出去、收回来的字节看得一清二楚。调试FINS的时候抓包看到自己拼的字节和实际发的字节是否一致比在代码里打log靠谱得多。我第一次组帧错误就是靠抓包发现长度字段算错了1个字节因为握手时多算了客户端节点地址。3.3 C#工程骨架一个通信类打天下工程用.NET Framework 4.7.2或者.NET 6以上都行看你们现场跑什么系统。我没有引任何第三方库纯用System.Net.Sockets这样部署时零依赖。如果你不想自己写也可以看看HslCommunication这类库它封装了欧姆龙FINS但自己写一遍的好处是出问题能定位到字节级别。类结构我这么设计的一个OmronFinsClient类内部持有TcpClient和NetworkStream。核心方法先开三个Connect()负责建TCP连接和FINS握手ReadWords()负责读字ReadBits()负责读位。数据打包和解包各抽一个私有方法。字段层面维护_plcNodePLC节点号、_pcNodePC节点号、_sid服务ID自增三个状态。using System; using System.Net.Sockets; public class OmronFinsClient : IDisposable { private TcpClient _tcp; private NetworkStream _stream; private byte _plcNode 0x01; private byte _pcNode 0x00; private byte _sid 0x01; public bool Connect(string ip, int port 9600) { _tcp new TcpClient(); _tcp.Connect(ip, port); _stream _tcp.GetStream(); return Handshake(); } // 握手与读写方法见后文 }构造函数里_plcNode先给个默认值等握手时如果PLC返回了实际节点号再修正。这套骨架足够上篇把读功能全部跑通。4. 读取功能落地从握手到解析4.1 握手让PLC给你分配一个节点号TCP连上之后不能马上发读命令得先握手。握手用的是FINS/TCP命令0x00000000官方叫节点地址数据发送命令。它的作用是让PLC知道我这边来了个客户端请分配一个FINS节点号给我之后所有FINS帧里的SA1就用这个分配到的号。握手请求的字节布局是外层头16字节FINS长度命令错误码后面再跟4字节客户端节点地址。长度字段因此等于12。客户端节点地址这里的填法有讲究填0x00000000表示请你自动分配填具体值表示我要求用这个号。除非你有严格的节点规划否则填0。响应回来之后同样解析16字节外层头然后第16到19字节就是PLC分配给你的节点号。这个号要保存到_pcNode后面每条FINS帧的SA1都用它。同时响应头里如果错误码非0说明握手失败常见的是节点号冲突或者没开FINS服务。private bool Handshake() { byte[] req new byte[20]; req[0] (byte)F; req[1] (byte)I; req[2] (byte)N; req[3] (byte)S; // 长度12小端 req[4] 12; req[5] 0; req[6] 0; req[7] 0; // 命令码 0x00000000 req[8] 0; req[9] 0; req[10] 0; req[11] 0; // 错误码 0 req[12] 0; req[13] 0; req[14] 0; req[15] 0; // 客户端节点地址0表示自动分配 req[16] 0; req[17] 0; req[18] 0; req[19] 0; _stream.Write(req, 0, req.Length); byte[] resp new byte[24]; int n _stream.Read(resp, 0, resp.Length); if (n 20) return false; uint err BitConverter.ToUInt32(resp, 12); if (err ! 0) return false; _pcNode resp[19]; // 服务器分配的节点号 return true; }注意读响应的长度不固定我固定读24个字节是因为握手响应足够短。真实项目里应该先读4字节魔术字再读长度字段然后按长度动态读完剩下的这样才稳。这里为了讲清楚逻辑先简化。4.2 读命令组帧从命令码到参数区握手成功就可以读数据了。完整的读请求分三段拼外层FINS/TCP头16字节 FINS帧头10字节 读参数7字节。其中FINS/TCP头的长度字段是8 FINS帧长度FINS帧长度这里是10 2 7 19所以长度字段等于27。FINS帧里ICF填0x80GCT填0x02DNA和SNA都填0DA1填PLC节点号SA1填上一步分配到的_pcNodeSID用自增。命令码0x0101。参数区按2.3节的规则拼内存区代码2字节、起始地址2字节、位位置1字节、点数2字节。private byte[] BuildReadRequest(ushort areaCode, ushort startAddr, ushort count) { // FINS帧 19 字节 外层头 16 字节 byte[] frame new byte[35]; frame[0] (byte)F; frame[1] (byte)I; frame[2] (byte)N; frame[3] (byte)S; int len 8 19; frame[4] (byte)len; frame[5] 0; frame[6] 0; frame[7] 0; frame[8] 2; frame[9] 0; frame[10] 0; frame[11] 0; // 命令0x00000002 frame[12] 0; frame[13] 0; frame[14] 0; frame[15] 0; // 错误码 // FINS帧头从偏移16开始 frame[16] 0x80; // ICF frame[17] 0x00; // RSV frame[18] 0x02; // GCT frame[19] 0x00; // DNA frame[20] _plcNode; // DA1 frame[21] 0x00; // DA2 frame[22] 0x00; // SNA frame[23] _pcNode; // SA1 frame[24] 0x00; // SA2 frame[25] _sid; // SID frame[26] 0x01; frame[27] 0x01; // 命令码 0x0101 读 // 参数区 frame[28] (byte)(areaCode 0xFF); frame[29] (byte)(areaCode 8); frame[30] (byte)(startAddr 0xFF); frame[31] (byte)(startAddr 8); frame[32] 0x00; // 位位置字读写固定0 frame[33] (byte)(count 0xFF); frame[34] (byte)(count 8); return frame; }这里每一处移位和小端处理都不能马虎。areaCode传0x82就是读DM区传0xB0就是读CIO区。起始地址传1000就是读D1000。4.3 响应解析和字节序转换响应回来的结构是外层头16字节 FINS响应帧。FINS响应帧的前10字节是控制字段可以简单跳过然后是2字节命令码再2字节主响应码。主响应码是判断这次读取成功还是失败的关键。主响应码从偏移16开始数16到25是控制字段26到27是命令码28到29才是主响应码。响应码为0x0000表示成功之后从偏移30开始就是数据区。数据区的字节序是小端序一个16位的字低字节在前。读10个字就是20个字节每2个字节构成一个字。解析成short是BitConverter.ToInt16默认就是小端直接能用解析成int、float这种32位数据就要注意跨字排列了。欧姆龙的一个字是16位读一个32位浮点数通常占2个字这2个字在内存里的排列顺序跟IEEE 754的字节顺序需要对齐。我实测发现NJ上AT到DM区的REAL变量两个字在FINS返回里已经是低字在前、每个字内低字节在前的组合所以直接用BitConverter.ToSingle对20字节里的对应4字节解析就对得上。private short[] ParseReadResponse(byte[] resp, int n, int wordCount) { ushort mainCode BitConverter.ToUInt16(resp, 28); if (mainCode ! 0) throw new Exception($FINS读失败主响应码 0x{mainCode:X4}); short[] result new short[wordCount]; for (int i 0; i wordCount; i) { result[i] BitConverter.ToInt16(resp, 30 i * 2); } return result; }如果主响应码不是0就要查表定位问题。常见的主响应码有0x0000正常、0x000B命令不支持、0x000C地址范围错、0x0011不可访问、0x0020读写错误、0x0083超出程序区。这几个在实际排障里出现频率最高。4.4 批量读取和位读取的写法批量读取其实跟单点的代码完全一样区别只是count参数给大一点一条命令就能把D1000到D1100共100个字一次读回来。这一点是FINS相对Modbus的最大优势——Modbus受制于单次最大125个寄存器超了要拆分多条请求FINS单条命令一次能读几百个字轮询效率高很多。但也不能无限大单帧数据区上限跟PLC型号有关我实测NJ501单次读2000个字以内稳再大建议拆包。位读取用的是同一套读命令只是参数里的内存区代码换成位码CIO的0x30、W的0x31并且位位置字段要填具体是第几位点数填读多少位。响应数据区里每个位占1字节0x00为OFF0x01为ON。解析成bool数组时逐字节判断即可。private bool[] ParseBitResponse(byte[] resp, int bitCount) { bool[] bits new bool[bitCount]; for (int i 0; i bitCount; i) bits[i] resp[30 i] 0x01; return bits; }提示位读取一次不要跨字节边界太多。比如从CIO0.3读10个位会跨到CIO1.4虽然协议支持但解析时点的序号容易算错。稳妥做法是按字节对齐读取比如从CIO0.0读16位解析完再按位取。5. 常见问题与排查实记5.1 连不上、超时、错误码一览通信搭不起来的坑我按从网络层到协议层整理成一张速查表出问题按顺序往下排现象可能原因排查方法TCP连不上IP不通、端口没开ping通后确认9600端口监听握手无响应FINS/TCP未设置Sysmac Studio里确认FINS服务已开启握手错误码0x00000001报文头不是FINS检查魔术字和小端排列握手错误码0x00000020连接已被占用断开旧连接再试握手错误码0x00000025无可用节点号手动指定一个PC节点号读超时帧长字段算错抓包核对长度是否等于8FINS帧长主响应码0x000C起始地址超范围核对区大小和起始地址主响应码0x0020读写错误确认地址是否可访问最容易踩的坑就是长度字段。我遇到过发送时长度填成了FINS帧总长、忘了加那8字节PLC直接不响应抓包看到对面就是没有任何回包。还有一次是握手时长度按8算实际上应该是12因为多了4字节客户端节点地址。这类问题抓包一看就明白。5.2 数据读出来是乱的值怎么办成功读到字节但数值不对这是另一类问题。排在前面的原因一般是三种字序不对、字节序不对、地址偏了。如果是读float出现明显不合理的值比如读温度读出个1e-38八成是32位数据的字序问题。这个时候可以反着试一次把两半字对调再解析。我建议在代码里加一个SwapWord开关出问题时切换一下就能验证。如果是读short出现相邻数据互换D1000的值跑到D1001去了那就是字节序问题说明你把小端当大端读了或者反过来。FINS的数据区是标准小端用BitConverter在x86/x64平台上默认就对。如果读出来的值整体偏移了一个字那多半是起始地址算错。NJ的DM区从D0开始编号读到D0的时候起始地址填0别填1。这个差一问题老生常谈但每次都有人犯。5.3 我实测踩过的几个坑第一个坑PLC侧变量没AT到内存区。一开始我在NJ程序里定义了一堆全局变量满心欢喜用FINS去读对应地址读回来的全是0。后来才反应过来NJ里的变量如果不做AT分配它根本不在内存区里有固定位置Sysmac Studio编译时会自动排布地址不固定。解决办法是把要给上位机的变量显式AT到DM区比如g_stData AT %D1000这样地址才稳定可控。第二个坑读写之间没有隔开请求。上位机如果开了多个线程同时发FINS命令PC节点号还是同一个如果不靠SID区分响应就会串。我在串行读取阶段一切正常一旦加了个异步写入读到的数据就偶尔错位。后来老老实实加了个lock所有读写走同一条队列问题消失。所以**上篇里读功能先走单线程串行别急着上并发**。第三个坑网络抖动后的重连。现场网络偶尔闪断TcpClient底层断了但代码不知道下一次Write抛异常。我在ReadWords外层包了个可重试的封装捕获IOException后重建连接并重新握手最多重试三次。这个重连逻辑看似小但现场长期运行能不能稳全靠它。public short[] ReadDM(ushort start, ushort count) { for (int retry 0; retry 3; retry) { try { byte[] req BuildReadRequest(0x82, start, count); _stream.Write(req, 0, req.Length); byte[] resp new byte[30 count * 2]; int n _stream.Read(resp, 0, resp.Length); return ParseReadResponse(resp, n, count); } catch (System.IO.IOException) { Reconnect(); } } throw new Exception(读取失败重试三次仍不可用); }第四个坑抓包时发现响应分两次到达。TCP是流式协议Read一次不保证读完一整帧。我在代码里一开始假设一次Read就拿到全部结果偶尔读到一半的响应解析时数组越界。正确写法是先读固定头部解析出长度再按长度循环读到齐。这个坑不抓包很难发现因为大部分时候数据一次就到全了偶发才出问题。把读这一块做扎实之后写入的帧结构其实是同一套逻辑反过来——命令码从0x0101换成0x0102参数区后面追加数据主响应码同样的判读方式。区别在于写入对地址边界和写保护特别敏感稍有不慎会覆盖PLC里的运行数据所以我把它放到下篇里单独讲连带着把批量写入、数据类型打包、以及通过CIP按变量名访问标签这一套也一起说清楚。就我个人的经验读写这段代码写完真正花时间的不是协议本身而是把这些边角case一个个填平让它在现场连续跑几个月不掉链子。