通信协议:欧姆龙
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
