Spiga

通信协议:欧姆龙

2026-03-14 15:52:38

一、欧姆龙协议

1. 欧姆龙通信设备

  • PLC:系列 CS系列、CJ系列、CP系列、NJ/NX系列
  • 微型:CPM1A、CPM2A、CP1H、CP1L
  • 小型:CPM2C、CQM1H、CJ1M
  • 中型:C200H、CJ1、CS1
  • 大型:CV、CS1D
  • 运动控制器:NJ、NX等

2. 欧姆龙PLC存储区

存储区分类

  • CIO:I/O继电器区、DM:数据区
  • WR:工作区,内部继电器
  • HR:保持继电器
  • AR:是模拟通道
  • C:计数器 、 T:定时器

访问规则

  • 最小存储单位是Word 2字节

  • bit、Word、Dword

  • 访问地址: 字

    0.0 CIO0.1 CIO0.15 D100 W100 W100.2

支持Modbus协议/OPC

3. FINS串口通信

  • 特点

    • 文档支持
    • Commands(5-1-1)
    • Memory Area(5-2-2)
    • End Codes(5-1-3)
    • PLC 默认存储 大端
  • 请求

  • 响应

  • 报文格式

static void FINS_Serial_Test()
{
    SerialPort serialPort = new SerialPort("COM1");
    serialPort.Open();

    string unit_num = "23";
    ushort byte_addr = 100;
    byte bit_addr = 0;
    ushort count = 5;

    string cmd = $"@{unit_num}FA000000000010182" +
        $"{byte_addr.ToString("X4")}{bit_addr.ToString("X2")}{count.ToString("X4")}";
    // 计算 FCS 校验
    var fcs = FCS(cmd);
    cmd += fcs;
    // * \r
    cmd += "*\r";

    byte[] req = Encoding.ASCII.GetBytes(cmd);

    serialPort.Write(req, 0, req.Length);

    byte[] resp = new byte[serialPort.BytesToRead];
    serialPort.Read(resp, 0, resp.Length);
    string str_resp = Encoding.ASCII.GetString(resp);
}

static string FCS(string cmd)
{
    byte[] b = Encoding.ASCII.GetBytes(cmd);
    byte xorResult = b[0];
    for (int i = 1; i < b.Length; i++)
    {
        xorResult ^= b[i];
    }

    return xorResult.ToString("X2");
}

4. FINSTCP以太网通信

  • 准备工具:

    • CX-Simulator 串口

    • Wireshark监控通信过程,OmronFinsTCP.Net

  • 通信过程

    • 建立TCP连接 Socket.Connect

    • 发送访问请求

    • 执行相关操作 ….

  • 通信请求报文(TCP三次握手后的第一次握手)

Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
socket.Connect("127.0.0.1", 9600);

// 请求连接  参数的交换
byte[] bytes = new byte[] {
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x0C,
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x00,
    // 异常码--
    0x00,0x00,0x00,0x00,
    0x00,0x00,0x00,0x0A
};
socket.Send(bytes);
  • FINSTCP-Read报文

// 01 01
// 读   单个地址,连续地址
bytes = new byte[] {
    // ------    FINSTCP Header    --------
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x1A,
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x02,
    // 异常码--
    0x00,0x00,0x00,0x00,

    // ------    FINS Header    --------
    0x80,
    0x00,
    0x02,
    0x00,0x01,0x00,0x00,0x00,0x02,
    0xFF,

    0x01,0x01,// 读取存储区数据
    //0x82,// 以Word方式读取D区数据
    0x30,// 以Bit方式读取CIO区数据
    0x00,0x0A,// 起始地址,100
    0x0A,// 位地址,10
    0x00,0x02,// 读取数量
};
socket.Send(bytes);
  • FINSTCP-Write报文

// 01 02
// 写   单个地址,连续地址
bytes = new byte[] {
    // ------    FINSTCP Header    --------
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x20,
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x02,
    // 异常码--
    0x00,0x00,0x00,0x00,

    // ------    FINS Header    --------
    0x80,
    0x00,
    0x02,
    0x00,0x01,0x00,0x00,0x00,0x02,
    0xFF,

    0x01,0x02,// 写入存储区数据
    //0x82,// 以Word方式写入D区数据
    0x30,// 以Word方式写入D区数据
    0x00,0x0B,// 起始地址,11
    0x07,// 位地址,07
    0x00,0x06,// 写入数量
    0x01,0x01,0x01,0x01,0x01,0x01
};
socket.Send(bytes);

其他报文

  • 多存储区读取:01 04

    注意:多个存储区只能获取一个地址

// 01 04
// 注意:多个存储区读取时,每个存储区只能获取一个地址数据(也说是说读取的时候不带读取数量)
// D01 D02 D03 D10  CIO02   CIO05.6
bytes = new byte[] {
    // ------    FINSTCP Header    --------
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x2C,    // 44     2C
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x02,
    // 异常码--
    0x00,0x00,0x00,0x00,

    // ------    FINS Header    --------
    0x80,
    0x00,
    0x02,
    0x00,0x01,0x00,0x00,0x00,0x02,
    0xFF,

    0x01,0x04,// 多存储区读取

    0x82,// 以Word方式读取D区数据
    0x00,0x01,// 起始地址,1
    0x00,// 位地址,无效

    0x82,// 以Word方式读取D区数据
    0x00,0x02,// 起始地址,2
    0x00,// 位地址,无效

    0x82,// 以Word方式读取D区数据
    0x00,0x03,// 起始地址,3
    0x00,// 位地址,无效

    0x82,// 以Word方式读取D区数据
    0x00,0x0A,// 起始地址,10
    0x00,// 位地址,无效

    0xB0,// 以Word方式读取D区数据
    0x00,0x02,// 起始地址,2
    0x00,// 位地址,无效

    0x30,// 以Word方式读取D区数据
    0x00,0x05,// 起始地址,5
    0x06// 位地址,6
};
socket.Send(bytes);
  • 数据填充:01 03

    与写入有什么区别?区别在于填充是将连续的区域设置为特定值

// 01 03  向指定连续区进行相关数据的填充
// 从D20开始,连续填充5个0xEE
bytes = new byte[] {
    // ------    FINSTCP Header    --------
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x1C,
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x02,
    // 异常码--
    0x00,0x00,0x00,0x00,

    // ------    FINS Header    --------
    0x80,
    0x00,
    0x02,
    0x00,0x01,0x00,0x00,0x00,0x02,
    0xFF,

    0x01,0x03,// 填充命令
    0x82,// 以Word方式读取D区数据
    0x00,0x14,// 起始地址,20
    0x00,// 位地址,
    0x00,0x05,// 连续填充数量
    0x00,0xEE
};
socket.Send(bytes);
  • 数据转移:01 05
// 01 05  内存数据转移
// D20向D40连续转移5个数据
bytes = new byte[] {
    // ------    FINSTCP Header    --------
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x1E,
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x02,
    // 异常码--
    0x00,0x00,0x00,0x00,

    // ------    FINS Header    --------
    0x80,
    0x00,
    0x02,
    0x00,0x01,0x00,0x00,0x00,0x02,
    0xFF,

    0x01,0x05,// 传输命令

    0x82,// 以Word方式读取D区数据
    0x00,0x14,// 起始地址,20
    0x00,// 位地址,

    0x82,// 以Word方式读取D区数据
    0x00,0x28,// 起始地址,40
    0x00,// 位地址,

    0x00,0x05,// 连续转移数量
};
socket.Send(bytes);
  • 日期时间读写:07 01、07 02
// 07 01
// 读取PLC时间
bytes = new byte[] {
    // ------    FINSTCP Header    --------
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x14,
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x02,
    // 异常码--
    0x00,0x00,0x00,0x00,

    // ------    FINS Header    --------
    0x80,
    0x00,
    0x02,
    0x00,0x01,0x00,0x00,0x00,0x02,
    0xFF,

    0x07,0x01// 请求时间
};
socket.Send(bytes);

// 07 02
// 写PLC时间
bytes = new byte[] {
    // ------    FINSTCP Header    --------
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x19,
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x02,
    // 异常码--
    0x00,0x00,0x00,0x00,

    // ------    FINS Header    --------
    0x80,
    0x00,
    0x02,
    0x00,0x01,0x00,0x00,0x00,0x02,
    0xFF,

    0x07,0x02,// 请求时间

    // 25年10月01日  08点30分    秒和星期可以忽略
    0x25,0x10,0x01,0x08,0x30
};
socket.Send(bytes);
  • PLC启停:04 01 04 02 04 03 Restart
// 04 01 
// PLC的启动
bytes = new byte[] {
    // ------    FINSTCP Header    --------
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x17,
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x02,
    // 异常码--
    0x00,0x00,0x00,0x00,

    // ------    FINS Header    --------
    0x80,
    0x00,
    0x02,
    0x00,0x01,0x00,0x00,0x00,0x02,
    0xFF,

    0x04,0x01,// 启动命令

    0xFF,0xFF,0x04// 这个指的是启动模式   后面三个字节可传可不传     02:表示监视模式
};
socket.Send(bytes);

// 04 02
// PLC的停止
bytes = new byte[] {
    // ------    FINSTCP Header    --------
    // FINS
    0x46,0x49,0x4E,0x53,
    // 后续字节数
    0x00,0x00,0x00,0x14,
    // Command - 表示客户端向服务端发送的连接请求
    0x00,0x00,0x00,0x02,
    // 异常码--
    0x00,0x00,0x00,0x00,

    // ------    FINS Header    --------
    0x80,
    0x00,
    0x02,
    0x00,0x01,0x00,0x00,0x00,0x02,
    0xFF,

    0x04,0x02// 停止命令
};
socket.Send(bytes);
  • CPU状态:06 01

5. CIP通信协议

  • 通信环境

    • Ethernet/IP通信

      EtherNet/IP(Ethernet/Industrial Protocol),是一个工业级的通信网络,用于工业器件间高速的信息交换,这些器件包括简单的IO器件(传感器),还有复杂的控制器(机器人,PLC,焊机,过程控制器)

    • CIP通信协议

      • CIP通信是Common Industrial Protocl(CIP)的简称,它是一个点到点的面向对象协议,能够实现工业器件(传感器,执行器)之间的连接,和高等级的控制器之间的连接。目前,有3种网络DeviceNet,ControlNet,EtherNet/IP使用CIP通信协议作为其上层网络协议,由ODVA组织统一管理,以确保其一致性和精确性。

      • 使用EtherNet和TCP/IP技术传送CIP通信包,CIP作为开放的应用层,位于EtherNet和TCP/IP协议之上。

      • 服务代码:用于指定要执行的操作,状态查询通常对应的服务代码是读取操作。

      • 路径:用于描述要访问的对象。在本例中,路径包括类别码和实例号。

      • 类:可能是输入模块的类别码,例如 0x20。

      • 实例:可能是输入模块的具体编号,例如 0x01 表示第一个输入模块。

      • 属性:取决于具体的操作,可能是输入模块状态的查询指令或响应。

  • 基本通信流程

    • 建立TCP连接 默认端口44818(TCP) 2222(UDP)
    • 请求会话ID,注册会话
    • 执行读写标签操作

  • 注册会话

数据在报文中按小端进行存放

Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
socket.Connect("192.168.174.128", 44818);

// 创建CIP Session
byte[] bytes = new byte[] {

    // 封装报头
    0x65,0x00,// 命令码
    0x04,0x00,// 命令特定数据部分的字节数
    0x00,0x00,0x00,0x00,// Session ID
    0x00,0x00,0x00,0x00,// 状态码
    0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,// 命令发送者附加数据,任何值
    0x00,0x00,0x00,0x00,// 选项信息

    // 命令特定数据
    0x01,0x00, // 协议版本号
    0x00,0x00  // 选项配置
};
socket.Send(bytes);

// 接收Session
byte[] resp = new byte[28];
socket.Receive(resp, 0, 28, SocketFlags.None);

Console.WriteLine("Status Code:" + string.Join(" ", resp.Skip(8).Take(4).Select(x => x.ToString("X2"))));
byte[] session = resp.Skip(4).Take(4).ToArray();
Console.WriteLine("Session Code:" + string.Join(" ", session.Select(x => x.ToString("X2"))));
  • CIP-Read报文

// 读取单个标签数据:
// MemB、ValueFloat
bytes = new byte[] {

    // 封装报头
    0x6F,0x00,// 命令码
    0x2E,0x00,// 命令特定数据部分的字节数*************
    session[0],session[1],session[2],session[3],// Session ID
    0x00,0x00,0x00,0x00,// 状态码
    0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,// 命令发送者附加数据,任何值
    0x00,0x00,0x00,0x00,// 选项信息

    // 命令特定数据
    0x00,0x00,0x00,0x00,
    0x01,0x00,  // 超时时间

    0x02,0x00,
    // Address Item
    0x00,0x00,
    0x00,0x00, // 当前Item后续无数据字节
    // Data item
    0xB2,0x00,  // Unconnected Message
    0x1E,0x00,  // 当前Item后续数据字节长度***********

    0x52,
    0x02,  // 请求路径的长度/2  Word 字数
    0x20,0x06,0x24,0x01,  // 请求路径 
    0x0A,0x00,

    0x10,0x00,// **************************

    0x4C,// 读指令
    0x06, // 地址的长度/2  ************
    //0x91,0x04,0x4D,0x65,0x6D,0x42,  // 标签名称: MemB
    //0x91,0x05,0x56,0x61,0x72,0x41,0x41,0x00,  // 标签名称: VarAA
    0x91,0x0A,0x56,0x61,0x6C,0x75,0x65,0x46,0x6C,0x6F,0x61,0x74,  // 标签名称: ValueFloat
    0x01,0x00,

    0x01,0x00,
    0x01,0x00   // 最后的0x00可能会填充PLC的插槽号
};
socket.Send(bytes);
  • CIP-Write报文

// 写入单个标签数据:
// ServerIn-》666-》0x029A     0x9A 0x02
bytes = new byte[] {
    // 封装报头
    0x6F,0x00,// 命令码
    0x34,0x00,// 命令特定数据部分的字节数*************
    session[0],session[1],session[2],session[3],// Session ID
    0x00,0x00,0x00,0x00,// 状态码
    0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,// 命令发送者附加数据,任何值
    0x00,0x00,0x00,0x00,// 选项信息

    // 命令特定数据
    0x00,0x00,0x00,0x00,
    0x01,0x00,  // 超时时间

    0x02,0x00,
    // Address Item
    0x00,0x00,
    0x00,0x00, // 当前Item后续无数据字节
    // Data item
    0xB2,0x00,  // Unconnected Message
    0x24,0x00,  // 当前Item后续数据字节长度***********

    0x52,
    0x02,  // 请求路径的长度/2  Word 字数
    0x20,0x06,0x24,0x01,  // 请求路径 
    0x0A,0x00,

    0x16,0x00,// **************************

    // 写入报文的核心结构
    0x4D,
    0x06,
    //0x91,0x08,0x53,0x65,0x72,0x76,0x65,0x72,0x49,0x6E,  // ServerIn
    //0x91,0x09,0x53,0x65,0x72,0x76,0x65,0x72,0x4F,0x75,0x74,0x00,  // ServerOut
    0x91,0x0A,0x56,0x61,0x6C,0x75,0x65,0x46,0x6C,0x6F,0x61,0x74,  // ValueFloat
    //0xC3,0x00,// 写入的数据类型  表示INT
    //0xD2,0x00,// 写入的数据类型  表示Word
    0xCA,0x00,// 写入的数据类型  表示REAL - Float
    0x01,0x00,
    0x00,0x00,0x90,0x40,  //  写入的值 4.5

    0x01,0x00,
    0x01,0x00
};
socket.Send(bytes);
//Encoding.ASCII.GetBytes("ServerIn");
  • CIP-Multipule Read报文

NJ/NX系列

// NJ/NX系列多标签请求
bytes = new byte[] {
    // 封装报头
    0x6F,0x00,  // 命令码,表示注册SendRRData(Request/Reply),详见第2卷  2-4.2   18页
    0x4E,0x00,  // 后面命令特定数据部分开始的后续所有字节数
    session[0],session[1],session[2],session[3],  // Session Handler 请求时为空,由目标设备响应时返回,返回结果在后续请求中使用
    0x00,0x00,0x00,0x00,  // 状态码  详见第2卷  2-4.5   19页
    0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,  // Sender Context field 命令的发送者可以在此字段中放置任何值。它可用于将请求与其关联的回复进行匹配。
    0x00,0x00,0x00,0x00,  // Option Field 选项  此字段的目的是提供修改各种封装命令含义的位。尚未指定此字段的特定用途。

    // 命令特定数据
    0x00,0x00, 0x00,0x00,  // Interface handle . shall be 0 for CIP
    0x01,0x00,    // Timeout     1-65535之间一般为秒数,0表示封装协议自己处理,
    //----Item Count  默认为2
    0x02,0x00,
    //----1-Address Item
    0x00,0x00,  // 封装的项类型,0000表示used for UCMM messages,
                // 表示不需要封装路由。目标是本地 (以太网) 或路由信息在数据项中。
                // 详见第2卷,2-7.1  表2-7-3   31页
    0x00,0x00,  // 当前Item后续字节数,无 
    //----2-DataItem
    0xB2,0x00,  // 封装的项类型,
                // B2=封装未连接的传输数据包的数据项;
                // B1=封装连接的传输数据名的数据项;
                // 80=源到目标位置的连接套接字地址信息  81=相反
    0x3E,0x00,  // 后续字节长度,一般为CIP指令部分

    // CIP指令
    0x52, // 服务默认0x52
          // 指示请求路径中引用的对象以执行任务。
          // CIP 或设备制造商定义这些任务。
          // 本手册中介绍的大多数服务由 Rockwell Automation 供应商特定的对象定义,
          // 在 CIP Networks Library 中找不到。
          // 0A:Multiple_Service_Packet  第1卷 Appendix A A-3  1168页
    0x02, // 请求路径大小 默认2   ???   Request Path 的字节数/2

    0x20,0x06,0x24,0x01, // Requst Path
                         // Message router   请求路径 默认0x01240622 4byte
                         // 节点中用于将消息收发请求分发到相应应用程序对象的对象。
                         // 多服务数据包请求路径包含消息路由器对象(类 2,实例 1)。
                         // 这是多服务数据包的请求路径的目的地。
                         // Request Data 字段包含 Number of Services,
                         // 后跟到每个服务开头的字节偏移量,然后是每个 CIP 请求,每个请求都遵循标准的 Message Router Request 格式。
    0x0A,0xF0,//
    0x30,0x00,//Cip指令长度  服务标识到服务命令指定数据的长度 

    0x0A,
    0x02,//
    0x20,0x02,0x24,0x01,

    0x03,0x00,// 请求的标签数   Request Data Start
    0x08,0x00,// Offsets for each Service;From the start of the Request Data
    0x14,0x00,
    0x1E,0x00,

    // ==========================
    0x4C,//服务标识固定为0x4C 1byte  
    0x04,// 节点长度 2byte  规律为 (标签名的长度+1/2)+1    16位字的数量

    0x91,//扩展符号 默认为 0x91    表示使用SYMBOL_ANSI扩展符号
    0x05,//标签名的长度       实际标签字符长度
    0x56,0x61,0x72,0x41,0x41,0x00,//标签名 :VarAA: 56 61 72 41 41
    // MemB : 4D 65 6D 42
    // ValueC : 56 61 6C 75 65 43
    0x01,0x00,//服务命令指定数据 默认为0x0001

    // ==========================
    0x4C,//服务标识固定为0x4C 1byte  
    0x03,// 节点长度 2byte  规律为 (标签名的长度+1/2)+1    16位字的数量

    0x91,//扩展符号 默认为 0x91    表示使用SYMBOL_ANSI扩展符号
    0x04,//标签名的长度       实际标签字符长度
    0x4D,0x65,0x6D,0x42,//标签名 :MemB : 4D 65 6D 42
    0x01,0x00,

    // ==========================
    0x4C,//服务标识固定为0x4C 1byte  
    0x04,// 节点长度 2byte  规律为 (标签名的长度+1/2)+1    16位字的数量

    0x91,//扩展符号 默认为 0x91    表示使用SYMBOL_ANSI扩展符号
    0x06,//标签名的长度       实际标签字符长度
    0x56,0x61,0x6C,0x75,0x65,0x43,//标签名 :ValueC : 56 61 6C 75 65 43
    0x01,0x00,

    0x01,0x00,
    0x01,0x00//最后一位是PLC的槽号
};
//socket.Send(bytes);
// 注意:这个请求在目前的模拟环境下是无法请求的,会报0x08的异常,但是并不影响NJ/NX PLC的请求

CJ系列

// CJ2系列多标签请求
bytes = new byte[] {
    // 封装报头
    0x6F,0x00,  // 命令码,表示注册SendRRData(Request/Reply),详见第2卷  2-4.2   18页
    0x4A,0x00,  // 后面命令特定数据部分开始的后续所有字节数
    session[0],session[1],session[2],session[3],  // Session Handler 请求时为空,由目标设备响应时返回,返回结果在后续请求中使用
    0x00,0x00,0x00,0x00,  // 状态码  详见第2卷  2-4.5   19页
    0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,  // Sender Context field 命令的发送者可以在此字段中放置任何值。它可用于将请求与其关联的回复进行匹配。
    0x00,0x00,0x00,0x00,  // Option Field 选项  此字段的目的是提供修改各种封装命令含义的位。尚未指定此字段的特定用途。

    // 命令特定数据
    0x00,0x00, 0x00,0x00,  // Interface handle . shall be 0 for CIP
    0x01,0x00,    // Timeout     1-65535之间一般为秒数,0表示封装协议自己处理,
    //----Item Count  默认为2
    0x02,0x00,
    //----1-Address Item
    0x00,0x00,  // 封装的项类型,0000表示used for UCMM messages,
                // 表示不需要封装路由。目标是本地 (以太网) 或路由信息在数据项中。
                // 详见第2卷,2-7.1  表2-7-3   31页
    0x00,0x00,  // 当前Item后续字节数,无 
    //----2-DataItem
    0xB2,0x00,  // 封装的项类型,
                // B2=封装未连接的传输数据包的数据项;
                // B1=封装连接的传输数据名的数据项;
                // 80=源到目标位置的连接套接字地址信息  81=相反
    0x3A,0x00,  // 后续字节长度,一般为CIP指令部分

    // CIP指令
    0x52, // 服务默认0x52
          // 指示请求路径中引用的对象以执行任务。
          // CIP 或设备制造商定义这些任务。
          // 本手册中介绍的大多数服务由 Rockwell Automation 供应商特定的对象定义,
          // 在 CIP Networks Library 中找不到。
          // 0A:Multiple_Service_Packet  第1卷 Appendix A A-3  1168页
    0x02, // 请求路径大小 默认2   ???   Request Path 的字节数/2

    0x20,0x06,0x24,0x01, // Requst Path
                         // Message router   请求路径 默认0x01240622 4byte
                         // 节点中用于将消息收发请求分发到相应应用程序对象的对象。
                         // 多服务数据包请求路径包含消息路由器对象(类 2,实例 1)。
                         // 这是多服务数据包的请求路径的目的地。
                         // Request Data 字段包含 Number of Services,
                         // 后跟到每个服务开头的字节偏移量,然后是每个 CIP 请求,每个请求都遵循标准的 Message Router Request 格式。
    0x0A,0xF0,//
    0x2C,0x00,//Cip指令长度  服务标识到服务命令指定数据的长度 

    0x50,
    0x02,//
    0x20,0x6D,0x24,0x00,

    0x01,0x00,
    0xFF,0x3F,
    0x00,0x00,
    0x9F,0x67,

    0x03,0x00,  // 标签个数

    // ==========================
    0x06,0x00,

    0x91,//扩展符号 默认为 0x91    表示使用SYMBOL_ANSI扩展符号
    0x04,//标签名的长度       实际标签字符长度
    0x4D,0x65,0x6D,0x42,

    // ==========================
    0x08,0x00,

    0x91,//扩展符号 默认为 0x91    表示使用SYMBOL_ANSI扩展符号
    0x06,//标签名的长度       实际标签字符长度
    0x56,0x61,0x6C,0x75,0x65,0x43,

    // ==========================
    0x08,0x00,// 节点长度 2byte  规律为 (标签名的长度+1/2)+1    16位字的数量

    0x91,//扩展符号 默认为 0x91    表示使用SYMBOL_ANSI扩展符号
    0x05,//标签名的长度       实际标签字符长度
    0x56,0x61,0x72,0x41,0x41,0x00,//标签名 :ValueC : 56 61 6C 75 65 43
               
    0x01,0x00,
    0x01,0x00//最后一位是PLC的槽号
};

socket.Send(bytes);

6. 附录

  • 附录一
类型 功能码 名称 功能 功能
I/O区读写 01 01 内存读取 读取连续I/O内存区域数据
01 02 内存写入 向连续I/O内存区
01 03 内存填充 向特定范围I/O内存区填充相同的数据
01 04 非连续内存读取 读取指定的非连续I/O内存区域数据
01 05 内存转移 将非连续内存区的
参数区读写 02 01 参数区读取 读取连续参数区内容
02 02 参数区写入 写入连续参数区内容
02 03 参数区填充 向特定范围参数区填充相同的内容
程序区读写 03 06 程序读取 读取UM(用户内存)区
03 07 程序写入 写入UM(用户内存)区
03 08 程序清除 清除UM(用户内存)区
操作模式切换 04 01 运行 将CPU单元操作模式切换为运行或监视
04 02 停止 将CPU单元操作模式切换为编程
设备配置读取 05 01 CPU单元数据读取 读取CPU单元数据
05 02 连接状态读取 读取对应地址的模块数量
状态读取 06 01 CPU单元状态读取 读取CPU单元状态
06 20 循环时间读取 读取最大、最小和平均循环时间
时间数据读写 07 01 时钟读取 读取当前年、月、日、分、秒和星期几
07 02 时钟写入 改变当前年、月、日、分、秒和星期几
故障信息显示 09 20 信息读取/清除 读取和清除信息,读取故障和严重故障信息
访问控制权限 0C 01 获取访问权限 只要没有其它设备持有访问权限,则获得访问权限
0C 02 强制获取访问权限 即使有其它设备持有访问权限,仍获得访问权限
0C 03 释放访问权限 即使已经持有访问权限,仍释放访问权限
错误日志 21 01 清除错误 清除错误或报警
21 02 读取错误日志 读取错误日志
21 03 清除若无日志 清除错误日志指针
FINS登入日志 21 40 FINS登入日志读取 CPU单元自动保存有执行过FINS登入命令的日志。这条命令读取此日志。
21 41 FINS登入日志清除 清除FINS登入列表
文件内存 22 01 文件名读取 读取文件内存区数据
22 02 单个文件读取 从某个文件中的指定位置读取特定长度的文件数据
22 03 单个文件写入 从某个文件中的指定位置写入特定长度的文件数据
22 04 文件内存格式化 格式化文件内存
22 05 文件删除 从文件内存中删除指定文件
22 07 文件复制 在系统中将某些文件复制到其他位置
22 08 重命名文件 改变一个文件的名字
22 0A 内存区间数据转移1 在I/O内存和文件内存间转移或比较数据
22 0B 内存区间数据转移2 在参数区和文件内存间转移或比较数据
22 0C 内存区间数据转移3 在用户内存和文件内存间转移或比较数据
22 15 创建/删除文件夹 创建或删除一个文件夹
22 20 存储盒转移(只针对CP1H,CP1L CPU单元) 在存储盒与CPU单元间转移和修改数据
调试 23 01 强制设置/重置 强制设置或重置位,或推出强制设置状态
23 02 强制设置/重置取消 取消所有强制设置或重置过的位
  • 附录二

  • 附录三,FinsTCP Header功能码
00 00 00 00 客户端到服务端通信
00 00 00 01 服务端到客户端通信
00 00 00 02 FINS帧发送命令
00 00 00 03 FINS帧发送错误通知命令
00 00 00 06 确立通信连接
  • 附录四,FinsTCP Header错误码
00 00 00 00 正常
00 00 00 01 数据头不是FINS或ASCII格式
00 00 00 02 数据长度过长
00 00 00 03 命令(Header功能码)错误
00 00 00 20 连接/通信被占用
00 00 00 21 指定的节点已经被连接
00 00 00 22 尝试从未描写的IP地址访问受保护节点
00 00 00 23 客户端FINS节点地址超出范围
00 00 00 24 客户端和服务器正在使用相同的FINS节点地址
00 00 00 25 所有可供分配的节点地址都已被使用
  • 附录五,CIP 命令码
Command Code 名称 描述
0x0000 NOP 只能使用TCP发送
0x0004 ListServices TCP和UDP都可以发送
0x0063 ListIdentity TCP和UDP都可以发送
0x0064 ListInterfaces 可选(可以使用 UDP 或 TCP 发送)
0x0065 Register Session 只能使用TCP发送
0x0066 UnRegister Session 只能使用TCP发送
0x006F SendRRData 只能使用TCP发送
0x0070 SendUnitData 只能使用TCP发送
0x0072 IndicateStatus 可选(可以使用 UDP 或 TCP 发送)
0x0073 Cancel 可选(可以使用 UDP 或 TCP 发送)
  • 附录六,CIP Header状态码
Status Code 描述
0x0000 Success
0x0001 发件人发出了无效或不受支持的封装命令
0x0002 接收方中的内存资源不足,无法处理该命令。这不是应用程序错误。相反,仅当封装层无法获取所需的内存资源时,才会出现这种情况。
0x0003 封装消息的数据部分中的数据格式不正确或不正确。
0x0004-0x0063 保留
0x0064 发起方在向目标发送封装消息时使用无效的会话句柄。
0x0065 目标收到长度无效的消息
0x0066-0x0068 保留
0x0069 不支持的封装协议修订版。
0x006A-0xFFFF 保留未来扩展
  • 附录七

  • 附录八,命令描述信息中Item ID Number
Item ID Number Item Type 描述
0x0000 Address Null (used for UCMM messages)
0x000C ListIdentity response
0x00A1 Address Connection-based (used for connected messages)
0x00B1 Data Connected Transport packet
0x00B2 Data Unconnected message
0x0100 ListServices response
0x8000 Data Sockaddr Info, originator-to-target
0x8001 Data Sockaddr Info, target-to-originator
0x8002 Sequenced Address iteme
  • 附录九,服务标识
服务标识 描述
0x4C Read Tag Service
0x52 Read Tag Fragmented Service
0x4D Write Tag Service
0x53 Write Tag Fragmented Service
0x4E Read Modify Write Tag Service
  • 附录十,响应错误码
Error code Error code Meaning Error code Error code Meaning
(Decimal) (Hex) (Decimal) (Hex)
0 0x0000 Success 33 0x0021 Write-once value or medium already written
1 0x0001 Connection failure 34 0x0022 Invalid Reply Received
2 0x0002 Resource unavailable 37 0x0025 Key Failure in path
3 0x0003 Invalid parameter value 38 0x0026 Path Size Invalid
4 0x0004 Path segment error 39 0x0027 Unexpected attribute in list
5 0x0005 Path destination unknown 40 0x0028 Invalid Member ID
6 0x0006 Partial transfer 41 0x0029 Member not settable
7 0x0007 Connection ID is not valid 42 0x002A The Transaction has Timed Out
8 0x0008 Service not supported 43 0x002B The Current Operation has Timed Out
9 0x0009 Invalid attribute value 44 0x002C Session Registration Timed Out
10 0x000A Attribute list error 45 0x002D Forward Open Command Failed
11 0x000B Already in requested mode/state 100 0x0064 Invalid Session
12 0x000C Object state conflict 101 0x0065 Invalid Length
13 0x000D Object already exists 105 0x0069 Unsupported Protocol Version
14 0x000E Attribute not settable 106 0x006A Stale Connection
15 0x000F Privilege violation 255 0x00FF Unknown Error Response
16 0x0010 Device state conflict 256 0x0100 Bad Address Structure
17 0x0011 Reply data too large 257 0x0101 Invalid Data Type
18 0x0012 Fragmentation of a primitive value 258 0x0102 Get Attribute Failed
19 0x0013 Not enough data 259 0x0103 PLC Config Changed: Tags Uploading
20 0x0014 Attribute not supported 260 0x0104 PLC Tag Upload Not Complete
21 0x0015 Too Much Data 261 0x0105 Transaction Timeout Count Exceeded
22 0x0016 Object does not exist 262 0x0106 Reading PLC Tag Information
23 0x0017 Service fragmentation sequence not in progress 263 0x0107 Reading PLC Structure Information
24 0x0018 No stored attribute data 264 0x0108 Requested Port Semaphore Timed Out
25 0x0019 Store operation failure 265 0x0109 Command Response Mismatch
26 0x001A Routing failure: request packet too large 266 0x010A No Port Tag defined
27 0x001B Routing failure: response packet too large 267 0x010B Port Disconnect delay < Session Timeout
28 0x001C Missing attribute list entry data 521 0x0209 Attempting To Send Invalid Read Msg To PLC
29 0x001D Invalid attribute value list 522 0x020A Attempting To Send Invalid Write Msg To PLC
30 0x001E Embedded service error 769 0x0301 No Connection Buffer Memory Available
31 0x001F Vendor specific error 796 0x031C Miscellaneous connection error
32 0x0020 Invalid parameter
  • 附录十一,CIP Service Codes and Names

二、欧姆龙PLC通信库封装

1. 封装功能

  • 三种类型协议封装 通信库结构 FINS串口 FINSTCP CIP
  • 连接与握手
  • 读取封装
  • 地址解析
  • 地址分组
  • 写入封装
  • 效率测试
  • 自动重连
  • 异步读写(优化)

2. 抽象封装

我们需要支持FINS串口、FINSTCP、CIP,三种协议,在具体实现前我们先封装一些抽象。

关键点

  • 定义 Open 和 Close 方法,以及相应检查 CheckResponse
  • GetDatas 和 GetBytes 三种协议的实现都是一样的,可以直接在抽象类中实现
  • string 类型不需要判断大小端
public abstract class OmronBase
{
    public virtual void Open(int timeout = 3000) { }

    public virtual void Close() { }


    protected virtual void CheckResponse(byte[] bytes) { }

    // 从字节到数据的转换
    public List<T> GetDatas<T>(byte[] bytes)
    {
        List<T> datas = new List<T>();
        if (typeof(T) == typeof(bool))
        {
            foreach (byte b in bytes)
            {
                dynamic d = (b == 0x01);
                datas.Add(d);
            }
        }
        else if (typeof(T) == typeof(string))
        {
            dynamic d = Encoding.UTF8.GetString(bytes);
            datas.Add(d);
        }
        else
        {
            int size = Marshal.SizeOf<T>();

            Type tBitConverter = typeof(BitConverter);
            MethodInfo[] mis = tBitConverter.GetMethods(BindingFlags.Public | BindingFlags.Static);
            if (mis.Count() <= 0) return datas;
            MethodInfo mi = mis.FirstOrDefault(m => m.ReturnType == typeof(T) && m.GetParameters().Count() == 2)!;

            for (int i = 0; i < bytes.Length; i += size)
            {
                byte[] data_bytes = bytes.ToList().GetRange(i, size).ToArray();

                if (BitConverter.IsLittleEndian)
                {
                    Array.Reverse(data_bytes);
                }
                dynamic v = mi.Invoke(tBitConverter, new object[] { data_bytes, 0 })!;
                datas.Add(v);
            }
        }

        return datas;
    }
    
    // 从数据到字节的转换
    public byte[] GetBytes<T>(params T[] values)
    {
        List<byte> bytes = new List<byte>();
        if (typeof(T) == typeof(bool))
        {
            foreach (var v in values)
            {
                bytes.Add((byte)(bool.Parse(v!.ToString()!) ? 0x01 : 0x00));
            }
        }
        else if (typeof(T) == typeof(string))
        {
            byte[] str_bytes = Encoding.UTF8.GetBytes(values[0]!.ToString()!);
            bytes.AddRange(str_bytes);
        }
        else
        {
            foreach (var v in values)
            {
                dynamic d = v!;
                byte[] v_bytes = BitConverter.GetBytes(d);
                if (BitConverter.IsLittleEndian)
                {
                    Array.Reverse(v_bytes);
                }
                bytes.AddRange(v_bytes);
            }
        }

        return bytes.ToArray();
    }
}

3. FINS封装

  • 接收串口数据,关键点
    • 构造串口通信对象
    • 连接与关闭
    • 接收数据,协议规定是以 @ 开头,*\r 结尾
public class FINS : OmronBase
{
    SerialPort SerialPort = null!;

    public FINS(string portName, int baudRate, int dataBits, Parity parity, StopBits stopBits)
    {
        SerialPort = new SerialPort(portName, baudRate, parity, dataBits, stopBits);

        SerialPort.DataReceived += SerialPort_DataReceived;
    }

    public override void Open(int timeout = 3000)
    {
        if (SerialPort != null && !SerialPort.IsOpen)
        {
            SerialPort.ReadTimeout = timeout;
            SerialPort.Open();
        }
    }
    
    public override void Close()
    {
        if (SerialPort != null && SerialPort.IsOpen)
        {
            SerialPort.Close();
        }
    }

    private void SerialPort_DataReceived(object sender, SerialDataReceivedEventArgs e)
    {
        List<byte> resp = new List<byte>();
        bool is_fill = false;
        while (true)
        {
            byte rb = (byte)SerialPort.ReadByte();
            if (!is_fill && (char)rb == '@') // 更完善是判断前5个字节是不是@{unit}FA
                is_fill = true;

            if (!is_fill) continue;

            resp.Add(rb);

            // 报文结束的条件满足
            if (rb == 0x0D && (char)resp[resp.Count - 2] == '*') // \r     // *\r结尾
            {
                break;
            }
        }
    }
}
  • 定义枚举与数据字典
public enum Area
{
    CIO = 0x30,
    WR = 0x31,
    HR = 0x32,
    AR = 0x33,
    DM = 0x02,
    // 判断需要获取的数据类型不是Bool的时候 + 0x80

    TK = 0x06,
    IR = 0xDC,
    DR = 0xBC
}

public enum DataType
{
    BIT, WORD
}

internal class FINSResponseErrors
{
    public static Dictionary<string, string> Errors { get; set; } = new Dictionary<string, string>()
        {
            {"0000", "正常完成"},
            {"0001", "服务已取消"},

            {"0101", "本地节点不在网络中"},
            {"0102", "Token 超时"},
            {"0103", "重试失败"},
            {"0104", "发送帧过多"},
            {"0105", "节点地址范围错误"},
            {"0106", "节点地址重复"},

            {"0201", "目标节点不在网络中"},
            {"0202", "没有具有指定单元地址的单元"},
            {"0203", "第三个节点不存在"},
            {"0204", "目标节点繁忙"},
            {"0205", "响应超时"},

            {"0301", "通信控制器出错"},
            {"0302", "目标 CPU 单元中发生 CPU 错误"},
            {"0303", "由于 Board 中发生错误,因此未返回响应"},
            {"0304", "单元号设置错误"},

            {"0401", "单元/主板不支持指定的命令代码"},
            {"0402", "由于模型或版本不正确,无法执行该命令"},

            {"0501", "未在路由表中设置目标网络或节点地址"},
            {"0502", "由于没有路由表,因此无法进行中继"},
            {"0503", "路由表中有错误"},
            {"0504", "尝试发送到超过 3 个网络的网络"},

            {"1001", "该命令的长度超过了允许的最大长度"},
            {"1002", "命令短于允许的最小长度"},
            {"1003", "指定的元素数与写入数据项数不同"},
            {"1004", "命令格式错误,使用了不正确的格式"},
            {"1005", "标头错误,本地节点中的中继表或中继节点中的本地网络工作表不正确"},

            {"1101", "缺少区域分类,指定的字在内存区域中不存在或没有 EM 区域"},
            {"1102", "访问大小错误,访问大小规范不正确或指定了奇数字地址"},
            {"1103", "地址范围错误,命令进程中的起始地址超出可访问区域"},
            {"1104", "超出地址范围,命令进程中的结束地址超出可访问区域"},
            {"1106", "程序缺失,未指定 FFFF 十六进制"},
            {"1109", "关系错误,命令 data 中元素的 large-small 关系不正确"},
            {"110A", "重复数据访问,在数据跟踪期间指定差异监控,或在差分监控期间指定数据跟踪"},
            {"110B", "响应时间过长,响应格式长于允许的最大长度"},
            {"110C", "参数错误,其中一个参数设置出错"},

            {"2002", "程序区域受到保护"},
            {"2003", "尚未注册表"},
            {"2004", "搜索数据不存在"},
            {"2005", "已指定不存在的程序编号"},
            {"2006", "该文件在指定的文件设备上不存在"},
            {"2007", "被比较的数据是不一样的"},

            {"2101", "指定的区域是只读的"},
            {"2102", "程序区域受到保护,或者由于已指定自动生成数据链接表,因此无法写入"},
            {"2103", "无法创建文件,因为已超出限制,或者已为系统限制打开的最大文件数"},
            {"2105", "已指定不存在的程序编号"},
            {"2106", "该文件在指定的文件设备上不存在"},
            {"2107", "指定的文件设备中已存在同名的文件"},
            {"2108", "无法进行更改,因为这样做会产生问题"},

            {"2201", "模式不正确,或者数据链路正在运行"},
            {"2202", "模式不正确,或者数据链接处于活动状态"},
            {"2203", "PLC 处于 PROGRAM 模式"},
            {"2204", "PLC 处于 DEBUG 模式"},
            {"2205", "PLC 处于 MONITOR 模式"},
            {"2206", "PLC 处于 RUN 模式"},
            {"2207", "指定的节点不是轮询节点"},
            {"2208", "模式不正确"},

            {"2301", "文件设备缺失,指定的内存不作为文件设备存在。"},
            {"2302", "内存缺失,没有文件内存"},
            {"2303", "缺少时钟,没有时钟"},

            {"2401", "表格缺失,数据链接表尚未注册或包含错误"},

            {"2502", "内存错误,内存内容包含错误"},
            {"2503", "I/O 设置错误,已注册的 I/O 表与实际的 I/O 配置不一致"},
            {"2504", "I/O 点过多,注册的 I/O 点和远程 I/O 点过多"},
            {"2505", "CPU 总线错误,CPU 和 CPU 总线单元之间的数据传输发生错误"},
            {"2506", "I/O 设置复制,多次设置相同的号码/地址"},
            {"2507", "I/O 总线错误,CPU 和 I/O 单元之间的数据传输发生错误"},
            {"2509", "SYSMAC BUS/2 错误,SYSMAC BUS/2 线路上的数据传输出错"},
            {"250A", "CPU 总线错误,CPU 总线单元的数据传输出错"},
            {"250D", "SYSMAC 总线号码重复,同一号码被分配多次"},
            {"250F", "内存错误,内存、内存卡或 EM 文件内存中发生内存错误"},
            {"2510", "SYSMAC 总线终结点缺失,尚未设置终结点"},

            {"2601", "无保护,指定的区域不受保护"},
            {"2602", "密码不正确,指定的密码不正确"},
            {"2604", "保护,指定区域受到保护,或者接收命令的节点已经在处理 5 个命令"},
            {"2605", "服务已执行,服务正在执行中"},
            {"2606", "服务已停止,服务未执行"},
            {"2607", "无执行权,尚未获得执行服务的权限,或者由于发生了缓冲区错误,因此未返回响应"},
            {"2608", "设置未完成,尚未进行执行服务前所需的设置"},
            {"2609", "未设置必要的项目,尚未在命令 data 中设置所需的元素"},
            {"260A", "Number 已定义,指定的操作/转换编号已在以前的程序中注册"},
            {"260B", "错误不会清除,错误的原因尚未消除"},

            {"3001", "无访问权限,访问权限由 anotherdevice 持有。(正在从另一个节点执行联机编辑,或者 ACCESS RIGHT ACQUIRE 或 ACCESS RIGHT FORCE ACQUIRE 已由另一个节点执行。)"},
            {"4001", "服务已中止,使用 ABORT 命令中止了服务"}
        };
}
  • 读取数据,关键点
    • 0101表示内存读取
    • 获取的数据类型不是Bool的时候 + 0x80
    • 封装 SendAndReceive 和 CheckResponse 方法
    • 读取数据长度 len 的计算
    • 接收的数据固定从 23 的位置开始读取
public byte[] Read(int unit, Area area, int wordAddr, 
    byte bitAddr = 0, ushort count = 1,
    DataType dataType = DataType.WORD)
{
    if (SerialPort == null || !SerialPort.IsOpen) throw new Exception("串口未初始化");

    byte a = (byte)area;
    if (dataType == DataType.WORD)
    {
        a += 0x80;
    }
    string cmd = $"@{unit.ToString("00")}FA0000000000101{a.ToString("X2")}" +
        $"{wordAddr.ToString("X4")}{bitAddr.ToString("X2")}{count.ToString("X4")}";
    // 计算 FCS 校验
    var fcs = FCS(cmd);
    cmd += fcs;
    cmd += "*\r";

    byte[] req = Encoding.ASCII.GetBytes(cmd);
    byte[] resp = this.SendAndReceive(req);

    this.CheckResponse(resp);

    string resp_str = Encoding.Default.GetString(resp);
    int len = count * 2;
    if (dataType == DataType.WORD)
        len = count * 4;
    string data_str = resp_str.Substring(23, len);

    //  这个方法在.NET环境下有效,Framework环境下需要循环,每两个字节转一个字节
    byte[] data_bytes = Convert.FromHexString(data_str);

    return data_bytes;
}

private string FCS(string cmd)
{
    byte[] b = Encoding.ASCII.GetBytes(cmd);
    byte xorResult = b[0];
    for (int i = 1; i < b.Length; i++)
    {
        xorResult ^= b[i];
    }

    return xorResult.ToString("X2");
}

private byte[] SendAndReceive(byte[] reqBytes)
{
    SerialPort.Write(reqBytes, 0, reqBytes.Length);

    // @   \r
    List<byte> resp = new List<byte>();
    bool is_fill = false;
    while (true)
    {
        byte rb = (byte)SerialPort.ReadByte();
        if (!is_fill && (char)rb == '@')
            is_fill = true;

        if (!is_fill) continue;

        resp.Add(rb);

        // 报文结束的条件满足
        if (rb == 0x0D && (char)resp[resp.Count - 2] == '*') 
        {
            break;
        }
    }

    return resp.ToArray();
}

protected override void CheckResponse(byte[] resp)
{
    string resp_str = Encoding.Default.GetString(resp);
    // 检查FCS校验码
    string check_str = resp_str.Substring(0, resp_str.Length - 4);
    string fcs = this.FCS(check_str);
    if (fcs != resp_str.Substring(check_str.Length, 2))
    {
        throw new Exception("FCS校验异常,无法确认响应数据");
    }

    // 检查状态码
    string status = resp_str.Substring(19, 4);
    if (status != "0000")
    {
        if (FINSResponseErrors.Errors.ContainsKey(status))
            throw new Exception(FINSResponseErrors.Errors[status]);
        else
            throw new Exception("未知错误!");
    }
}
  • 多读,关键点
    • 封装一个用于多读的类型 FinsParameter
    • 0104表示非连续内存读取
    • 多读的请求和相应逻辑跟单个读取一样,报文内容只需要遍历 parameters 即可
public class FinsParameter
{
    public Area Area { get; set; }
    public int WordAddr { get; set; }
    public byte BitAddr { get; set; } = 0;
    public DataType DataType { get; set; } = DataType.WORD;
    public ushort Count { get; set; } = 1;

    /// <summary>
    /// 读取的数据
    /// </summary>
    public byte[]? Data { get; set; }
}

public class FINS
{
    public byte[] Read(int unit, FinsParameter parameter)
    {
        return this.Read(unit, parameter.Area, parameter.WordAddr,
            parameter.BitAddr, parameter.Count, parameter.DataType);
    }

    public void MultiRead(int unit, params FinsParameter[] parameters)
    {
        if (parameters.Length == 1)
            parameters[0].Data = this.Read(unit, parameters[0]);

        if (SerialPort == null || !SerialPort.IsOpen) throw new Exception("串口未初始化");

        string cmd = $"@{unit.ToString("00")}FA0000000000104";
        foreach (FinsParameter param in parameters)
        {
            byte a = (byte)param.Area;
            if (param.DataType == DataType.WORD)
            {
                a += 0x80;
            }
            cmd += a.ToString("X2") +
                $"{param.WordAddr.ToString("X4")}{param.BitAddr.ToString("X2")}";
        }
        // 计算 FCS 校验
        var fcs = FCS(cmd);
        cmd += fcs;
        cmd += "*\r";

        byte[] req = Encoding.ASCII.GetBytes(cmd);
        byte[] resp = this.SendAndReceive(req);

        this.CheckResponse(resp);

        // 数据的解析
        // 000082 xx xx  B0  xx xx 
        string resp_str = Encoding.Default.GetString(resp);
        int index = 25;
        foreach (FinsParameter param in parameters)
        {
            int len = 2;
            if (param.DataType == DataType.WORD)
                len = 4;
            string data_str = resp_str.Substring(index, len);

            param.Data = Convert.FromHexString(data_str);

            index += len + 2;
        }
    }    
}
  • 写入数据,关键点
    • 第一个参数是要写入的数据
    • 写入不需要 count 参数,可以根据 dataBytes 计算
    • 0102表示内存写入
    • 可以使用多读时相同的参数来封装,用 Data 传入要写入的数据
public void Write(byte[] dataBytes, int unit, Area area, int wordAddr, 
    byte bitAddr = 0, DataType dataType = DataType.WORD)
{
    if (SerialPort == null || !SerialPort.IsOpen) throw new Exception("串口未初始化");

    byte a = (byte)area;
    int count = dataBytes.Length;
    if (dataType == DataType.WORD)
    {
        a += 0x80;

        if (dataBytes.Length % 2 > 0)
            throw new Exception("需要写入的数据字节长度不正确");

        count = dataBytes.Length / 2;
    }
    string cmd = $"@{unit.ToString("00")}FA0000000000102{a.ToString("X2")}" +
        $"{wordAddr.ToString("X4")}{bitAddr.ToString("X2")}{count.ToString("X4")}";
    // 加入数据字节
    cmd += string.Join("", dataBytes.Select(b => b.ToString("X2")));

    // 计算 FCS 校验
    var fcs = FCS(cmd);
    cmd += fcs;
    cmd += "*\r";

    byte[] req = Encoding.ASCII.GetBytes(cmd);
    byte[] resp = this.SendAndReceive(req);

    this.CheckResponse(resp);
}

public void Write(int unit, FinsParameter finsParameter)
{
    if(finsParameter.Data == null)
        throw new Exception("需要写入的数据内容为空");

    this.Write(finsParameter.Data, unit, finsParameter.Area,
        finsParameter.WordAddr, finsParameter.BitAddr, finsParameter.DataType);
}
  • PLC 启动与停止
public enum RunMode
{
    MONITOR = 0x02,
    RUN = 0x04
}

public void Run(int unit, RunMode mode = RunMode.RUN)
{
    if (SerialPort == null || !SerialPort.IsOpen) throw new Exception("串口未初始化");

    string cmd = $"@{unit.ToString("00")}FA0000000000401FFFF";
    cmd += ((byte)mode).ToString("X2");

    // 计算 FCS 校验
    var fcs = FCS(cmd);
    cmd += fcs;
    cmd += "*\r";

    byte[] req = Encoding.ASCII.GetBytes(cmd);
    byte[] resp = this.SendAndReceive(req);

    this.CheckResponse(resp);
}

public void Stop(int unit)
{
    if (SerialPort == null || !SerialPort.IsOpen) throw new Exception("串口未初始化");

    string cmd = $"@{unit.ToString("00")}FA0000000000402FFFF";

    // 计算 FCS 校验
    var fcs = FCS(cmd);
    cmd += fcs;
    cmd += "*\r";

    byte[] req = Encoding.ASCII.GetBytes(cmd);
    byte[] resp = this.SendAndReceive(req);

    this.CheckResponse(resp);
}
  • 日期时间读写
public DateTime GetClock(int unit)
{
    if (SerialPort == null || !SerialPort.IsOpen) throw new Exception("串口未初始化");

    string cmd = $"@{unit.ToString("00")}FA0000000000701";

    // 计算 FCS 校验
    var fcs = FCS(cmd);
    cmd += fcs;
    cmd += "*\r";

    byte[] req = Encoding.ASCII.GetBytes(cmd);
    byte[] resp = this.SendAndReceive(req);

    this.CheckResponse(resp);

    // 解析时间数据
    string resp_str = Encoding.Default.GetString(resp);
    string date_str = resp_str.Substring(23, 14);
    byte[] date_bytes = Convert.FromHexString(date_str);

    // 年
    byte b_year = date_bytes[0];
    if (!int.TryParse("20" + b_year.ToString("X2"), out int year))
    {
        throw new Exception("时间转换失败");
    }
    // 月
    byte m_byte = date_bytes[1];
    if (!int.TryParse(m_byte.ToString("X2"), out int month))
    {
        throw new Exception("时间转换失败");
    }
    // 日
    byte dayByte = date_bytes[2];
    if (!int.TryParse(dayByte.ToString("X2"), out int day))
    {
        throw new Exception("时间转换失败");
    }
    // 时
    byte hourByte = date_bytes[3];
    if (!int.TryParse(hourByte.ToString("X2"), out int hour))
    {
        throw new Exception("时间转换失败");
    }
    // 分
    byte minuteByte = date_bytes[4];
    if (!int.TryParse(minuteByte.ToString("X2"), out int minute))
    {
        throw new Exception("时间转换失败");
    }
    // 秒
    byte secondByte = date_bytes[5];
    if (!int.TryParse(secondByte.ToString("X2"), out int second))
    {
        throw new Exception("时间转换失败");
    }

    DateTime dt = new DateTime(year, month, day, hour, minute, second);
    return dt;
}

public void SetClock(int unit, DateTime time)
{
    if (SerialPort == null || !SerialPort.IsOpen) throw new Exception("串口未初始化");

    string cmd = $"@{unit.ToString("00")}FA0000000000702";

    cmd += time.ToString("yyMMddHHmm");

    // 计算 FCS 校验
    var fcs = FCS(cmd);
    cmd += fcs;
    cmd += "*\r";

    byte[] req = Encoding.ASCII.GetBytes(cmd);
    byte[] resp = this.SendAndReceive(req);

    this.CheckResponse(resp);
}
  • 扩展——按字符串读写
public byte[] Read(int unit, string variable, ushort count)
{
    FinsParameter finsParameter = this.GetAddress(variable);
    finsParameter.Count = count;
    return this.Read(unit, finsParameter);
}

public void Write(int unit, string variable, byte[] dataBytes)
{
    FinsParameter finsParameter = this.GetAddress(variable);
    finsParameter.Data = dataBytes;
    this.Write(unit, finsParameter);
}

// CIO0.15    CIO10
// IR10
// DR10
// TK10
// D100       D10.15
// H0.5       H10
// W0.5       W10
// A0.5       A10
public FinsParameter GetAddress(string variable)
{
    Area area = Area.DM;
    ushort word_addr = 0;
    byte bit_addr = 0;
    DataType dataType = DataType.BIT;

    string area_str = variable.Substring(0, 3).ToUpper();
    if (area_str == "CIO")
    {
        dataType = DataType.WORD;
        area = Area.CIO;// 相关的存储区按照Word进行处理
        string addr_str = variable.Substring(3).ToUpper();
        string[] addrs = addr_str.Split(".");

        ushort.TryParse(addrs[0], out word_addr);

        if (addrs.Length == 2)
        {
            byte.TryParse(addrs[1], out bit_addr);
            dataType = DataType.BIT;
        }
    }
    else if ("IRDRTK".Contains(variable.Substring(0, 2).ToUpper()))
    {
        var a_str = variable.Substring(0, 2).ToUpper();
        area = (Area)Enum.Parse(typeof(Area), a_str);
        ushort.TryParse(variable.Substring(2), out word_addr);
    }
    else if ("ADHW".Contains(variable.Substring(0, 1).ToUpper()))
    {
        var a_str = variable.Substring(0, 1).ToUpper();
        if (a_str == "D")
            a_str = a_str + "M";
        else
            a_str = a_str + "R";

        // 默认情况下使用字请求
        area = (Area)Enum.Parse(typeof(Area), a_str);
        dataType = DataType.WORD;

        string[] addrs = variable.Substring(1).Split(".");
        ushort.TryParse(addrs[0], out word_addr);

        if (addrs.Length == 2)
        {
            byte.TryParse(addrs[1], out bit_addr);
            // 这里判断是否使用位请求
            dataType = DataType.BIT;
        }
    }
    else
        throw new Exception("地址格式不正确");

    return new FinsParameter
    {
        Area = area,
        WordAddr = word_addr,
        BitAddr = bit_addr,
        DataType = dataType
    };
}
  • 测试
public void FinsTest()
{
    FINS fins = new FINS("COM1", 9600, 8, Parity.None, StopBits.One);
    fins.Open();

    byte[] bytes = fins.GetBytes<ushort>(123, 456);
    fins.Write(bytes, 0, Area.DM, 50);
    byte[] valueBytes = fins.Read(0, Area.DM, 50, 0, 3);
    var values = fins.GetDatas<ushort>(valueBytes);

    fins.MultiRead(0,
                   new FinsParameter { Area = Area.DM },
                   new FinsParameter { Area = Area.CIO, BitAddr = 5, DataType = DataType.BIT },
                   new FinsParameter { Area = Area.DM, WordAddr = 50 });

    fins.Run(0);
    fins.Stop(0);

    DateTime plc_time = fins.GetClock(0);
    fins.SetClock(0, new DateTime(2026, 2, 1, 8, 20, 0));
}
  • 扩展——异步思想
// 关于委托参数:这里可以用一个类进行封装,将时间戳、执行结果状态以及数据字节封装返回
// 这是异步处理的基本思路,未做测试 
public void ReadAsync(int unit, FinsParameter param, Action<byte[]> callback)
{
    if (SerialPort == null || !SerialPort.IsOpen) throw new Exception("串口未初始化");

    byte a = (byte)param.Area;
    if (param.DataType == DataType.WORD)
    {
        a += 0x80;
    }
    string cmd = $"@{unit.ToString("00")}FA0000000000101{a.ToString("X2")}" +
        $"{param.WordAddr.ToString("X4")}{param.BitAddr.ToString("X2")}{param.Count.ToString("X4")}";
    // 计算 FCS 校验
    var fcs = FCS(cmd);
    cmd += fcs;
    cmd += "*\r";

    byte[] req = Encoding.ASCII.GetBytes(cmd);

    SerialPort.DataReceived += (se, ev) =>
    {
        List<byte> resp = new List<byte>();
        bool is_fill = false;
        while (true)
        {
            byte rb = (byte)SerialPort.ReadByte();
            if (!is_fill && (char)rb == '@') // 判断前5个字节是不是@号FA
                is_fill = true;

            if (!is_fill) continue;

            resp.Add(rb);

            // 报文结束的条件满足
            if (rb == 0x0D && (char)resp[resp.Count - 2] == '*') // \r     // *\r结尾
            {
                break;
            }
        }
        this.CheckResponse(resp.ToArray());

        string resp_str = Encoding.Default.GetString(resp.ToArray());
        int len = param.Count * 2;
        if (param.DataType == DataType.WORD)
            len = param.Count * 4;
        string data_str = resp_str.Substring(23, len);

        //  这个方法在.NET环境下有效,Framework环境下需要循环,每两个字节转一个字节
        byte[] data_bytes = Convert.FromHexString(data_str);

        callback?.Invoke(data_bytes);
    };
    SerialPort.Write(req, 0, req.Length);
}

4. FINSTCP封装

  • 连接与关闭,关键点
    • 异常码最后一位,本机 IP 地址末位
    • 0x46,0x49,0x4E,0x53 是 FINS 的 ASKII 码,固定传输
public class FINSTCP : OmronBase
{
    int pc_node, plc_node;
    SocketSender socketSender = null!;

    public FINSTCP(string host, int port, int timeout = 3000)
    {
        socketSender = new SocketSender(host, port);
        socketSender.ResponseTimeOut = timeout;
    }

    public void Open(int timeout = 3000)
    {
        socketSender.ResponseTimeOut = timeout;
        socketSender.Open();

        string ip = socketSender.IP.ToString();
        pc_node = byte.Parse(ip.Split('.')[3]);

        // 请求连接  参数的交换
        byte[] bytes = new byte[] {
                0x46,0x49,0x4E,0x53,
                // 后续字节数
                0x00,0x00,0x00,0x0C,
                // Command - 表示客户端向服务端发送的连接请求
                0x00,0x00,0x00,0x00,
                // 异常码--
                0x00,0x00,0x00,0x00,
                0x00,0x00,0x00,(byte)pc_node
            };

        byte[] resp = socketSender.SendAndReceive(bytes);

        if (!resp.Take(4).SequenceEqual(new byte[] { 0x46, 0x49, 0x4E, 0x53 }))
            throw new Exception("返回字节数据异常");

        byte[] status = resp.Skip(12).Take(4).ToArray();
        if (status[3] > 0)
            throw new Exception(FinsTcpHeaderErrors.Errors[status[3]]);

        plc_node = resp[23];
    }
    
    public void Close()
    {
        socketSender.Close();
    }
}

internal class SocketSender
{
    public int ResponseTimeOut
    {
        get => socket.ReceiveTimeout;
        set => socket.ReceiveTimeout = value;
    }

    Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);

    string _host;
    int _port;

    public SocketSender(string host, int port)
    {
        _host = host;
        _port = port;
    }

    public void Open()
    {
        if (socket == null)
            socket = new Socket(SocketType.Stream, ProtocolType.Tcp);

        socket.Connect(_host, _port);
    }

    public void Close() { }

    public IPAddress IP { get => (socket.LocalEndPoint as IPEndPoint)!.Address; }


    public byte[] SendAndReceive(byte[] data)
    {
        socket.Send(data);
        // 获取
        byte[] resp = new byte[1024];
        socket.Receive(resp, 0, 1024, SocketFlags.None);

        return resp;
    }
}

internal class FinsTcpHeaderErrors
{
    public static Dictionary<byte, string> Errors = new Dictionary<byte, string>
        {
            { 0x00,"正常"},
            { 0x01,"数据头不是FINS或ASCII格式"},
            { 0x02,"数据长度过长" }
        };
}
  • 读取数据,关键点
    • pc_node:目标的IP地址末尾(PLC)
    • plc_node:源的IP地址末尾(PC)
    • sid:服务ID,00-FF之间任意值
    • 数据部分跟串口一样
int sid = 0;
public byte[] Read(Area area, int wordAddr, 
    byte bitAddr = 0, ushort count = 1,
    DataType dataType = DataType.WORD)
{
    byte a = (byte)area;
    if (dataType == DataType.WORD)
    {
        a += 0x80;
    }

    sid = (sid + 1) % 0xFF;
    byte[] bytes = new byte[] {
        // ------    FINSTCP Header    --------
        // FINS
        0x46,0x49,0x4E,0x53,
        // 后续字节数
        0x00,0x00,0x00,0x1A,
        // Command - 表示客户端向服务端发送的连接请求
        0x00,0x00,0x00,0x02,
        // 异常码--
        0x00,0x00,0x00,0x00,

        // ------    FINS Header    --------
        0x80,
        0x00,
        0x02,
        0x00,(byte)pc_node,0x00,
        0x00,(byte)plc_node,0x00,
        (byte)sid ,// SID

        0x01,0x01,// 读取存储区数据
        (byte)a,
        (byte)(wordAddr/256%256),(byte)(wordAddr%256),// 起始地址,100
        bitAddr,// 位地址,10
        (byte)(count/256%256),(byte)(count%256)// 读取数量
    };

    byte[] resp = socketSender.SendAndReceive(bytes);
    this.CheckResponse(resp);

    int len = count;// 位数据返回
    if (dataType == DataType.WORD)
        len = count * 2;
    byte[] data_bytes = resp.Skip(30).Take(len).ToArray();
    return data_bytes;
}

public byte[] Read(FinsParameter finsParameter)
{
    return this.Read(finsParameter.Area, finsParameter.WordAddr,
        finsParameter.BitAddr,
        finsParameter.Count,
        finsParameter.DataType);
}

protected override void CheckResponse(byte[] resp)
{
    if (!resp.Take(4).SequenceEqual(new byte[] { 0x46, 0x49, 0x4E, 0x53 }))
        throw new Exception("数据头不是FINS或ASCII格式");

    byte[] h_status = resp.Skip(12).Take(4).ToArray();
    if (h_status[3] > 0)
        throw new Exception(FinsTcpHeaderErrors.Errors[h_status[3]]);

    if (resp[25] != sid)
        throw new Exception("Seesion ID 不一致");

    string end_status = resp[28].ToString("X2") + resp[29].ToString("X2");
    if (end_status != "0000")
    {
        if (FINSResponseErrors.Errors.ContainsKey(end_status))
            throw new Exception(FINSResponseErrors.Errors[end_status]);
        else
            throw new Exception("未知错误!");
    }
}
  • 多读,关键点
    • 0104无法获取到数据的问题:长度需要减去头部的8个字节
    • 第30个字节是区域代码 后面跟着对应的数据
public void MultiRead(params FinsParameter[] parameters)
{
    List<byte> bytes = new List<byte>()
        {
            // FINS
            0x46,0x49,0x4E,0x53,
            // 后续字节数
            0x00,0x00,0x00,0x00,
            // Command - 表示客户端向服务端发送的连接请求
            0x00,0x00,0x00,0x02,
            // 异常码--
            0x00,0x00,0x00,0x00,

            // ------    FINS Header    --------
            0x80,
            0x00,
            0x02,
            0x00,(byte)pc_node,0x00,
            0x00,(byte)plc_node,0x00,
            (byte)sid ,// SID

            0x01,0x04,
        };

    // 0104无法获取到数据的问题:这里的长度需要减去头部的8个字节
    int len = bytes.Count() - 8;
    foreach (var item in parameters)
    {
        byte a = (byte)item.Area;
        if (item.DataType == DataType.WORD)
        {
            a += 0x80;
        }
        bytes.Add(a);

        bytes.Add((byte)(item.WordAddr / 256 % 256));
        bytes.Add((byte)(item.WordAddr % 256));

        bytes.Add(item.BitAddr);

        len += 4;
    }
    bytes[4] = (byte)(len / 256 / 256 / 256 % 256);
    bytes[5] = (byte)(len / 256 / 256 % 256);
    bytes[6] = (byte)(len / 256 % 256);
    bytes[7] = (byte)(len % 256);

    byte[] resp = socketSender.SendAndReceive(bytes.ToArray());
    this.CheckResponse(resp);

    // 第30个字节是区域代码 后面跟着对应的数据
    int index = 31;
    foreach (var item in parameters)
    {
        int data_len = 1;
        if (item.DataType == DataType.WORD)
            data_len = 2;

        item.Data = resp.Skip(index).Take(data_len).ToArray();
        index += data_len + 1;
    }
}
  • 写入数据
public void Write(byte[] dataBytes, Area area, int wordAddr,
    byte bitAddr = 0, DataType dataType = DataType.WORD)
{
    int count = dataBytes.Length;
    byte a = (byte)area;
    if (dataType == DataType.WORD)
    {
        a += 0x80;
        count = dataBytes.Length / 2;
    }

    sid = (sid + 1) % 0xFF;
    int len = dataBytes.Length + 0x1A;
    List<byte> bytes = new List<byte> {
            // ------    FINSTCP Header    --------
            // FINS
            0x46,0x49,0x4E,0x53,
            // 后续字节数
            (byte)(len/256/256/256%256),
            (byte)(len/256/256%256),
            (byte)(len/256%256),
            (byte)(len%256),
            // Command - 表示客户端向服务端发送的连接请求
            0x00,0x00,0x00,0x02,
            // 异常码--
            0x00,0x00,0x00,0x00,

            // ------    FINS Header    --------
            0x80,
            0x00,
            0x02,
            0x00,(byte)pc_node,0x00,
            0x00,(byte)plc_node,0x00,
            (byte)sid ,// SID

            0x01,0x02,// 
            (byte)a,
            (byte)(wordAddr/256%256),(byte)(wordAddr%256),// 起始地址,100
            bitAddr,// 位地址,10
            (byte)(count/256%256),(byte)(count%256)// 读取数量
        };
    bytes.AddRange(dataBytes);

    byte[] resp = socketSender.SendAndReceive(bytes.ToArray());
    this.CheckResponse(resp);
}

public void Write(FinsParameter finsParameter)
{
    if (finsParameter.Data == null)
        throw new Exception("需要写入的数据内容为空");

    this.Write(finsParameter.Data, finsParameter.Area, finsParameter.WordAddr,
        finsParameter.BitAddr, finsParameter.DataType);
}
  • PLC 启动与停止
public void Run(RunMode mode = RunMode.RUN)
{
    sid = (sid + 1) % 0xFF;
    byte[] bytes = new byte[] {
        0x46,0x49,0x4E,0x53,
        0x00,0x00,0x00,0x17,
        0x00,0x00,0x00,0x02,
        0x00,0x00,0x00,0x00,

        // ------    FINS Header    --------
        0x80,
        0x00,
        0x02,
        0x00,(byte)pc_node,0x00,
        0x00,(byte)plc_node,0x00,
        (byte)sid ,// SID

        0x04,0x01,//
        0xFF,0xFF,
        (byte)mode
    };

    byte[] resp = socketSender.SendAndReceive(bytes);
    this.CheckResponse(resp);
}

public void Stop()
{
    sid = (sid + 1) % 0xFF;
    byte[] bytes = new byte[] {
        0x46,0x49,0x4E,0x53,
        0x00,0x00,0x00,0x14,
        0x00,0x00,0x00,0x02,
        0x00,0x00,0x00,0x00,

        // ------    FINS Header    --------
        0x80,
        0x00,
        0x02,
        0x00,(byte)pc_node,0x00,
        0x00,(byte)plc_node,0x00,
        (byte)sid ,// SID

        0x04,0x02,
    };

    byte[] resp = socketSender.SendAndReceive(bytes);
    this.CheckResponse(resp);
}
  • 日期时间读写
public DateTime GetClock()
{
    sid = (sid + 1) % 0xFF;
    byte[] bytes = new byte[] {
            0x46,0x49,0x4E,0x53,
            0x00,0x00,0x00,0x14,
            0x00,0x00,0x00,0x02,
            0x00,0x00,0x00,0x00,

            // ------    FINS Header    --------
            0x80,
            0x00,
            0x02,
            0x00,(byte)pc_node,0x00,
            0x00,(byte)plc_node,0x00,
            (byte)sid ,// SID

            0x07,0x01,
        };

    byte[] resp = socketSender.SendAndReceive(bytes);
    this.CheckResponse(resp);

    // 解析时间数据
    byte[] date_bytes = resp.Skip(30).Take(6).ToArray();
    // 年
    byte b_year = date_bytes[0];
    if (!int.TryParse("20" + b_year.ToString("X2"), out int year))
    {
        throw new Exception("时间转换失败");
    }
    // 月
    byte m_byte = date_bytes[1];
    if (!int.TryParse(m_byte.ToString("X2"), out int month))
    {
        throw new Exception("时间转换失败");
    }
    // 日
    byte dayByte = date_bytes[2];
    if (!int.TryParse(dayByte.ToString("X2"), out int day))
    {
        throw new Exception("时间转换失败");
    }
    // 时
    byte hourByte = date_bytes[3];
    if (!int.TryParse(hourByte.ToString("X2"), out int hour))
    {
        throw new Exception("时间转换失败");
    }
    // 分
    byte minuteByte = date_bytes[4];
    if (!int.TryParse(minuteByte.ToString("X2"), out int minute))
    {
        throw new Exception("时间转换失败");
    }
    // 秒
    byte secondByte = date_bytes[5];
    if (!int.TryParse(secondByte.ToString("X2"), out int second))
    {
        throw new Exception("时间转换失败");
    }

    DateTime dt = new DateTime(year, month, day, hour, minute, second);
    return dt;
}

public void SetClock(DateTime time)
{
    sid = (sid + 1) % 0xFF;
    byte[] bytes = new byte[] {
            0x46,0x49,0x4E,0x53,
            0x00,0x00,0x00,0x19,
            0x00,0x00,0x00,0x02,
            0x00,0x00,0x00,0x00,

            // ------    FINS Header    --------
            0x80,
            0x00,
            0x02,
            0x00,(byte)pc_node,0x00,
            0x00,(byte)plc_node,0x00,
            (byte)sid ,// SID

            0x07,0x02,
            Convert.ToByte((time.Year-2000).ToString(), 16),
            Convert.ToByte(time.Month.ToString(), 16),
            Convert.ToByte(time.Day.ToString(), 16),
            Convert.ToByte(time.Hour.ToString(), 16),
            Convert.ToByte(time.Minute.ToString(), 16),
        };

    byte[] resp = socketSender.SendAndReceive(bytes);
    this.CheckResponse(resp);
}
  • 扩展——字符串读写

由于FINS串口和TCP的地址解析方法是一样的,我们可以将前面串口实现的地址解析方法再抽取一个基类出来,实现共用

public class FinsCommand : OmronBase
{
    // CIO0.15    CIO10
    // IR10
    // DR10
    // TK10
    // D100       D10.15
    // H0.5       H10
    // W0.5       W10
    // A0.5       A10
    public FinsParameter GetAddress(string variable)
    {
        Area area = Area.DM;
        ushort word_addr = 0;
        byte bit_addr = 0;
        DataType dataType = DataType.BIT;

        string area_str = variable.Substring(0, 3).ToUpper();
        if (area_str == "CIO")
        {
            dataType = DataType.WORD;
            area = Area.CIO;// 相关的存储区按照Word进行处理
            string addr_str = variable.Substring(3).ToUpper();
            string[] addrs = addr_str.Split(".");

            ushort.TryParse(addrs[0], out word_addr);

            if (addrs.Length == 2)
            {
                byte.TryParse(addrs[1], out bit_addr);
                dataType = DataType.BIT;
            }
        }
        else if ("IRDRTK".Contains(variable.Substring(0, 2).ToUpper()))
        {
            var a_str = variable.Substring(0, 2).ToUpper();
            area = (Area)Enum.Parse(typeof(Area), a_str);
            ushort.TryParse(variable.Substring(2), out word_addr);
        }
        else if ("ADHW".Contains(variable.Substring(0, 1).ToUpper()))
        {
            var a_str = variable.Substring(0, 1).ToUpper();
            if (a_str == "D")
                a_str = a_str + "M";
            else
                a_str = a_str + "R";

            // 默认情况下使用字请求
            area = (Area)Enum.Parse(typeof(Area), a_str);
            dataType = DataType.WORD;

            string[] addrs = variable.Substring(1).Split(".");
            ushort.TryParse(addrs[0], out word_addr);

            if (addrs.Length == 2)
            {
                byte.TryParse(addrs[1], out bit_addr);
                // 这里判断是否使用位请求
                dataType = DataType.BIT;
            }
        }
        else
            throw new Exception("地址格式不正确");

        return new FinsParameter
        {
            Area = area,
            WordAddr = word_addr,
            BitAddr = bit_addr,
            DataType = dataType
        };
    }
}

public class FINS : FinsCommand
{
    //其他部分
}

public class FINSTCP : FinsCommand
{
    //其他部分
    public byte[] Read(string variable, ushort count)
    {
        FinsParameter finsParameter = this.GetAddress(variable);
        finsParameter.Count = count;
        return this.Read(finsParameter);
    }

    public void Write(string variable, byte[] dataBytes)
    {
        FinsParameter finsParameter = this.GetAddress(variable);
        finsParameter.Data = dataBytes;
        this.Write(finsParameter);
    }
}
  • 测试
public void FinsTcpTest()
{
    FINSTCP finsTCP = new FINSTCP("127.0.0.1", 9600);
    finsTCP.Open();
    byte[] datas = finsTCP.Read(Area.DM, 0, count: 3);
    datas = finsTCP.Read(Area.DM, 0, count: 5);


    finsTCP.Write(new byte[] { 0x00, 0x64 }, Area.DM, 10);
    finsTCP.Write(new byte[] { 0x01, 0x01 }, Area.CIO, 20, 7, DataType.BIT);

    finsTCP.MultiRead(
        new FinsParameter { Area = Area.DM },
        new FinsParameter { Area = Area.CIO, BitAddr = 5, DataType = DataType.BIT },
        new FinsParameter { Area = Area.DM, WordAddr = 50 });


    finsTCP.Run();

    finsTCP.Stop();

    DateTime plc_tme = finsTCP.GetClock();

    finsTCP.SetClock(new DateTime(2026, 2, 1, 8, 20, 0));

    var values = finsTCP.GetDatas<ushort>(datas);

    //byte[] datas = finsTCP.Read("D100", 13);
    //byte[] datas = finsTCP.Read("CIO0.15", 13);
    //byte[] datas = finsTCP.Read("CIO10", 1);
    //byte[] datas = finsTCP.Read("D10.5", 1);
}

5. CIP封装

  • 枚举与数据字典
internal class CipTypeCodes
{
    public static Dictionary<string, int> TypeLength
        = new Dictionary<string, int>
        {
            { "C1",8},
            { "C2",8},
            { "C3",16},
            { "C4",32},
            { "C5",64},
            { "C6",8},
            { "C7",16},
            { "C8",32},
            { "C9",64},
            { "CA",32},
            { "CB",64},
            { "D0",0},
            { "D1",8},
            { "D2",16},
            { "D3",32},
            { "D4",64},
        };
}

public enum CipDataType
{
    BOOL = 0xC1,
    SINT = 0xC2,
    INT = 0xC3,
    DINT = 0xC4,
    LINT = 0xC5,
    USINT = 0xC6,
    UINT = 0xC7,
    UDINT = 0xC8,
    ULINT = 0xC9,
    REAL = 0xCA,
    LREAL = 0xCB,
    STRING = 0xD0,
    BYTE = 0xD1,
    WORD = 0xD2,
    DWORD = 0xD3,
    LWORD = 0xD4
}

internal class CIPErrors
{
    internal static Dictionary<byte, string> HeaderErrors = new Dictionary<byte, string>
    {
        {0x00,"Success" },
        {0x01,"发件人发出了无效或不受支持的封装命令" },
        {0x02,"接收方中的内存资源不足,无法处理该命令。这不是应用程序错误。相反,仅当封装层无法获取所需的内存资源时,才会出现这种情况" },
        {0x03,"封装消息的数据部分中的数据格式不正确或不正确" },
        {0x64,"发起方在向目标发送封装消息时使用无效的会话句柄" },
        {0x65,"目标收到长度无效的消息" },
        {0x69,"不支持的封装协议修订版" },
    };

    public static Dictionary<string, string> RespErrors = new Dictionary<string, string>()
    {
        {"0000","Success" },
        {"0001","Connection failure" },
        {"0002","Resource unavailable" },
        {"0003","Invalid parameter value" },
        {"0004","Path segment error" },
        {"0005","Path destination unknown" },
        {"0006","Partial transfer" },
        {"0007","Connection ID is not valid" },
        {"0008","Service not supported" },
        {"0009","Invalid attribute value" },
        {"000A","Attribute list error" },
        {"000B","Already in requested mode/state" },
        {"000C","Object state conflict" },
        {"000D","Object already exists" },
        {"000E","Attribute not settable" },
        {"000F","Privilege violation" },
        {"0010","Device state conflict" },
        {"0011","Reply data too large" },
        {"0012","Fragmentation of a primitive value" },
        {"0013","Not enough data" },
        {"0014","Attribute not supported" },
        {"0015","Too Much Data" },
        {"0016","Object does not exist" },
        {"0017","Service fragmentation sequence not in progress" },
        {"0018","No stored attribute data" },
        {"0019","Store operation failure" },
        {"001A","Routing failure: request packet too large" },
        {"001B","Routing failure: response packet too large" },
        {"001C","Missing attribute list entry data" },
        {"001D","Invalid attribute value list" },
        {"001E","Embedded service error" },
        {"001F","Vendor specific error" },
        {"0020","Invalid parameter" },
        {"0021","Write-once value or medium  already written" },
        {"0022","Invalid Reply Received" },
        {"0025","Key Failure in path" },
        {"0026","Path Size Invalid" },
        {"0027","Unexpected attribute in list" },
        {"0028","Invalid Member ID" },
        {"0029","Member not settable" },
        {"002A","The Transaction has Timed Out" },
        {"002B","The Current Operation has Timed  Out" },
        {"002C","Session Registration Timed Out" },
        {"002D","Forward Open Command Failed" },
        {"0064","Invalid Session" },
        {"0065","Invalid Length" },
        {"0069","Unsupported Protocol Version" },
        {"006A","Stale Connection" },
        {"00FF","Unknown Error Response" },
        {"0100","Bad Address Structure" },
        {"0101","Invalid Data Type" },
        {"0102","Get Attribute Failed" },
        {"0103","PLC Config Changed: Tags  Uploading" },
        {"0104","PLC Tag Upload Not Complete" },
        {"0105","Transaction Timeout Count  Exceeded" },
        {"0106","Reading PLC Tag Information" },
        {"0107","Reading PLC Structure Information" },
        {"0108","Requested Port Semaphore Timed Out" },
        {"0109","Command Response Mismatch" },
        {"010A","No Port Tag defined" },
        {"010B","Port Disconnect delay <  Session Timeout" },
        {"0209","Attempting To Send Invalid Read  Msg To PLC" },
        {"020A","Attempting To Send Invalid Write  Msg To PLC" },
        {"0301","No Connection Buffer Memory  Available" },
        {"031C","Miscellaneous connection error" }
    };
}
  • 连接与启动
public class CIP : OmronBase
{
    byte _slot = 0x00;
    byte[]? _session_Handle;
    SocketSender? sender = null;

    public CIP(string host, int port, byte slot = 0x00)
    {
        _slot = slot;
        sender = new SocketSender(host, port);
    }

    byte[] flag = Encoding.ASCII.GetBytes("xiaosuo\r");
    public override void Open(int timeout = 3000)
    {
        if (sender != null)
        {
            sender.ResponseTimeOut = timeout;
            sender.Open();

            // 请求SessionHandle
            byte[] bytes = new byte[] {
                0x65,0x00,// 命令码
                0x04,0x00,// 命令特定数据部分的字节数
                0x00,0x00,0x00,0x00,// Session ID
                0x00,0x00,0x00,0x00,// 状态码
                flag[0],flag[1],flag[2],flag[3],flag[4],flag[5],flag[6],flag[7],// 命令发送者附加数据,任何值
                0x00,0x00,0x00,0x00,// 选项信息

                // 命令特定数据
                0x01,0x00, // 协议版本号
                0x00,0x00  // 选项配置
            };
            byte[] resp = sender.SendAndReceive(bytes);
            this.CheckResponse(resp);

            _session_Handle = resp.Skip(4).Take(4).ToArray();
        }
    }

    public override void Close()
    {
        sender?.Close();
    }
    
    protected override void CheckResponse(byte[] bytes)
    {
        byte header_code = (byte)(bytes.Skip(8).Take(4).ToList().Sum(b => b));
        if (header_code != 0x00)
        {
            if (CIPErrors.HeaderErrors.ContainsKey(header_code))
                throw new Exception(CIPErrors.HeaderErrors[header_code]);
            else
                throw new Exception($"未知异常: {header_code}");
        }

        // 这个针对读写的时候进行结果状态判断
        string resp_status = bytes[43].ToString("X2") + bytes[42].ToString("X2");
        if (resp_status != "0000")
        {
            if (CIPErrors.RespErrors.ContainsKey(resp_status))
                throw new Exception(CIPErrors.RespErrors[resp_status]);
            else
                throw new Exception($"未知异常: {resp_status}");
        }
    }
}
  • 读取数据

关键点:相关长度计算

public byte[] Read(string tag)
{
    List<byte> bytes = new List<byte>{
        0x6F,0x00,// 命令码
        0x2E,0x00,// 命令特定数据部分的字节数*************
        _session_Handle[0],_session_Handle[1],_session_Handle[2],_session_Handle[3],// Session ID
        0x00,0x00,0x00,0x00,// 状态码
        flag[0],flag[1],flag[2],flag[3],flag[4],flag[5],flag[6],flag[7],// 命令发送者附加数据,任何值
        0x00,0x00,0x00,0x00,// 选项信息

        // 命令特定数据
        0x00,0x00,0x00,0x00,
        0x01,0x00,  // 超时时间

        0x02,0x00,
        // Address Item
        0x00,0x00,
        0x00,0x00, // 当前Item后续无数据字节
        // Data item
        0xB2,0x00,  // Unconnected Message
        0x1E,0x00,  // 当前Item后续数据字节长度***********38 39

        0x52,
        0x02,  // 请求路径的长度/2  Word 字数
        0x20,0x06,0x24,0x01,  // 请求路径 
        0x0A,0x00,

        0x10,0x00,// **************************  48 49

        0x4C,// 读指令
        0x06, // 地址的长度/2  ************
        //0x91,0x04,0x4D,0x65,0x6D,0x42,  // 标签名称: MemB
        //0x91,0x05,0x56,0x61,0x72,0x41,0x41,0x00,  // 标签名称: VarAA
        //0x91,0x0A,0x56,0x61,0x6C,0x75,0x65,0x46,0x6C,0x6F,0x61,0x74,  // 标签名称: ValueFloat
        //0x01,0x00,

        //0x01,0x00,
        //0x01,0x00   // 最后的0x00可能会填充PLC的插槽号
    };

    int tag_len = tag.Length;
    List<byte> tag_bytes = new List<byte>()
    {
        0x91,(byte)tag_len
    };
    tag_bytes.AddRange(Encoding.UTF8.GetBytes(tag));
    if (tag_len % 2 > 0)
        tag_bytes.Add(0x00);

    // 相关长度计算
    int addr_len = tag_bytes.Count / 2;
    int cmd_len = tag_bytes.Count + 4;
    int item_len = cmd_len + 14;
    int sp_len = item_len + 16;

    // 相关长度信息填充
    bytes[2] = (byte)(sp_len % 256);
    bytes[3] = (byte)(sp_len / 256 % 256);

    bytes[38] = (byte)(item_len % 256);
    bytes[39] = (byte)(item_len / 256 % 256);

    bytes[48] = (byte)(cmd_len % 256);
    bytes[49] = (byte)(cmd_len / 256 % 256);

    bytes[51] = (byte)addr_len;
    // 拼接Tag地址信息
    bytes.AddRange(tag_bytes);
    // 接尾
    bytes.Add(0x01);
    bytes.Add(0x00);
    bytes.Add(0x01);
    bytes.Add(0x00);
    bytes.Add(0x01);
    bytes.Add(_slot);

    byte[] resp = sender.SendAndReceive(bytes.ToArray());

    // 校验/检查
    // 状态
    this.CheckResponse(resp);

    // 数据解析
    // 44 取数据类型
    string type_str = resp[44].ToString("X2");
    int byte_len = -1;
    if (CipTypeCodes.TypeLength.ContainsKey(type_str))
        byte_len = CipTypeCodes.TypeLength[type_str];

    if (byte_len == -1) return null;

    if (byte_len == 0)
    {
        // 0xD0 0x00 0x03 0x00 0x31 0x32 0x33
        int str_len = resp[46] + resp[47] * 256;
        return resp.Skip(48).Take(str_len).ToArray();
    }

    byte_len /= 8;// 具体数据的字节数

    byte[] data_bytes = resp.Skip(46).Take(byte_len).ToArray();
    Array.Reverse(data_bytes);

    return data_bytes;
}
  • 写入数据,关键点
    • string 类型不用翻转
    • 单字节数据的情况下,需要填充一个0x00的字节
public void Write(string tag, CipDataType dataType, byte[] data)
{
    List<byte> bytes = new List<byte>{
        0x6F,0x00,// 命令码
        0x2E,0x00,// 命令特定数据部分的字节数*************
        _session_Handle[0],_session_Handle[1],_session_Handle[2],_session_Handle[3],// Session ID
        0x00,0x00,0x00,0x00,// 状态码
        flag[0],flag[1],flag[2],flag[3],flag[4],flag[5],flag[6],flag[7],// 命令发送者附加数据,任何值
        0x00,0x00,0x00,0x00,// 选项信息

        // 命令特定数据
        0x00,0x00,0x00,0x00,
        0x01,0x00,  // 超时时间

        0x02,0x00,
        // Address Item
        0x00,0x00,
        0x00,0x00, // 当前Item后续无数据字节
        // Data item
        0xB2,0x00,  // Unconnected Message
        0x1E,0x00,  // 当前Item后续数据字节长度***********38 39

        0x52,
        0x02,  // 请求路径的长度/2  Word 字数
        0x20,0x06,0x24,0x01,  // 请求路径 
        0x0A,0x00,

        0x10,0x00,// **************************  48 49

        0x4D,// 读指令
        0x06, // 地址的长度/2  ************
        //0x91,0x04,0x4D,0x65,0x6D,0x42,  // 标签名称: MemB
        //0x91,0x05,0x56,0x61,0x72,0x41,0x41,0x00,  // 标签名称: VarAA
        //0x91,0x0A,0x56,0x61,0x6C,0x75,0x65,0x46,0x6C,0x6F,0x61,0x74,  // 标签名称: ValueFloat
        //0x01,0x00,

        //0x01,0x00,
        //0x01,0x00   // 最后的0x00可能会填充PLC的插槽号
    };
    int tag_len = tag.Length;
    List<byte> tag_bytes = new List<byte>()
    {
        0x91,(byte)tag_len
    };
    tag_bytes.AddRange(Encoding.UTF8.GetBytes(tag));
    if (tag_len % 2 > 0)
        tag_bytes.Add(0x00);

    int addr_len = tag_bytes.Count / 2;

    tag_bytes.Add((byte)dataType);
    tag_bytes.Add(0x00);

    tag_bytes.Add(0x01);
    tag_bytes.Add(0x00);

    if (dataType != CipDataType.STRING)
    {
        Array.Reverse(data);
    }
    else
    {
        byte[] len_bytes = BitConverter.GetBytes((ushort)data.Length);
        if (!BitConverter.IsLittleEndian)
            Array.Reverse(len_bytes);
        tag_bytes.AddRange(len_bytes);
    }

    tag_bytes.AddRange(data);
    // 单字节数据的情况下,需要填充一个0x00的字节
    if (dataType == CipDataType.BOOL ||
        dataType == CipDataType.SINT ||
        dataType == CipDataType.USINT ||
        dataType == CipDataType.WORD)
        tag_bytes.Add(0x00);

    int cmd_len = tag_bytes.Count + 2;
    int item_len = cmd_len + 14;
    int sp_len = item_len + 16;

    // 相关长度信息填充
    bytes[2] = (byte)(sp_len % 256);
    bytes[3] = (byte)(sp_len / 256 % 256);

    bytes[38] = (byte)(item_len % 256);
    bytes[39] = (byte)(item_len / 256 % 256);

    bytes[48] = (byte)(cmd_len % 256);
    bytes[49] = (byte)(cmd_len / 256 % 256);

    bytes[51] = (byte)addr_len;

    // 拼接Tag地址信息
    bytes.AddRange(tag_bytes);
    // 接尾
    bytes.Add(0x01);
    bytes.Add(0x00);
    bytes.Add(0x01);
    bytes.Add(_slot);

    byte[] resp = sender.SendAndReceive(bytes.ToArray());

    // 校验/检查
    // 状态
    this.CheckResponse(resp);
}
  • 测试
public void FinsCIPTest()
{
    CIP cip = new CIP("192.168.1.128", 44818);
    cip.Open();

    // 读
    byte[] values = cip.Read("MemB");
    //byte[] values = cip.Read("VarAA");
    //byte[] values = cip.Read("StrTest");

    // 写
    cip.Write("MemB", CipDataType.WORD, new byte[] { 0x00, 0x64 });
    //cip.Write("VarAA", CipDataType.BOOL, new byte[] {  0x01 });
    //cip.Write("StrTest", CipDataType.STRING, Encoding.UTF8.GetBytes("xiaosuo"));
}
  • 多读
// 针对NJ/NX系列PLC的标签读取
public void MultiRead(params CipParameter[] tags)
{
    List<byte> bytes = new List<byte>{
        0x6F,0x00,// 命令码
        0x2E,0x00,// 命令特定数据部分的字节数*************
        _session_Handle[0],_session_Handle[1],_session_Handle[2],_session_Handle[3],// Session ID
        0x00,0x00,0x00,0x00,// 状态码
        flag[0],flag[1],flag[2],flag[3],flag[4],flag[5],flag[6],flag[7],// 命令发送者附加数据,任何值
        0x00,0x00,0x00,0x00,// 选项信息

        // 命令特定数据
        0x00,0x00,0x00,0x00,
        0x01,0x00,  // 超时时间

        0x02,0x00,
        // Address Item
        0x00,0x00,
        0x00,0x00, // 当前Item后续无数据字节
        // Data item
        0xB2,0x00,  // Unconnected Message
        0x1E,0x00,  // 当前Item后续数据字节长度***********38 39

        0x52,
        0x02,  // 请求路径的长度/2  Word 字数
        0x20,0x06,0x24,0x01,  // 请求路径 
        0x0A,0x00,

        0x10,0x00,// **************************  48 49

        0x0A,
        0x02,
        0x20,0x02,0x24,0x01,
                
        // 第一个标签的偏移量
        // 第 ... 个标签的偏移量
    };
    // 标签数
    bytes.Add((byte)(tags.Length % 256));
    bytes.Add((byte)(tags.Length / 256 % 256));

    int start_len = tags.Length * 2 + 2;

    List<byte> _tagBytes = new List<byte>();
    for (int i = 0; i < tags.Length; i++)
    {
        bytes.Add((byte)(start_len % 256));
        bytes.Add((byte)(start_len / 256 % 256));
        //
        _tagBytes.Add(0x4C);
        _tagBytes.Add((byte)Math.Ceiling((tags[i].Tag.Length + 2) * 1.0 / 2));
        _tagBytes.Add(0x91);
        _tagBytes.Add((byte)tags[i].Tag.Length);
        _tagBytes.AddRange(Encoding.UTF8.GetBytes(tags[i].Tag));
        if (tags[i].Tag.Length % 2 > 0)
            _tagBytes.Add(0x00);

        _tagBytes.Add(0x01);
        _tagBytes.Add(0x00);

        start_len = _tagBytes.Count;
    }
    bytes.AddRange(_tagBytes);

    bytes.Add(0x01);
    bytes.Add(0x00);
    bytes.Add(0x01);
    bytes.Add(_slot);

    byte[] resp = sender.SendAndReceive(bytes.ToArray());
    this.CheckResponse(resp);
    
    int start_index = 46;
    int temp = 0;
    foreach (var tag in tags)
    {
        byte[] offset = resp.Skip(start_index + temp).Take(2).ToArray();
        int start = offset[1] * 256 + offset[0] + start_index;

        string resp_status = bytes[start].ToString("X2") + bytes[start + 1].ToString("X2");
        if (resp_status != "0000")
        {
            if (CIPErrors.RespErrors.ContainsKey(resp_status))
                tag.Error = new Exception(CIPErrors.RespErrors[resp_status]);
            else
                tag.Error = new Exception($"未知异常: {resp_status}");
        }

        start += 2;

        string type_str = resp[start].ToString("X2");
        int byte_len = -1;
        if (CipTypeCodes.TypeLength.ContainsKey(type_str))
            byte_len = CipTypeCodes.TypeLength[type_str];

        if (byte_len == -1)
            tag.Error = new Exception("未知数据类型");

        start += 2;
        if (byte_len == 0)
        {
            // 0xD0 0x00 0x03 0x00 0x31 0x32 0x33
            int str_len = resp[start] + resp[start + 1] * 256;
            tag.Data = resp.Skip(start + 2).Take(str_len).ToArray();
        }

        byte_len /= 8;// 具体数据的字节数

        byte[] data_bytes = resp.Skip(start).Take(byte_len).ToArray();
        Array.Reverse(data_bytes);

        tag.Data = data_bytes;

        temp += 2; //每多一个数据,会多2位长度信息
    }
}

public class CipParameter
{
    public string? Tag { get; set; }
    public byte[]? Data { get; set; }
    public Exception? Error { get; set; }
}

没有环境,多读代码没有测试,可能存在bug