在对接欧姆龙 PLC 时,真正难住不少 C# 开发者的,往往不是 Socket 或 TcpClient 的基本用法,而是 FinsTCP 报文该怎么组织、握手后哪些节点信息必须带上、读取流程又该如何拆分。下面按“协议认知、收发实现、连接初始化、数据读取”四个层次展开,尽量保留核心代码和关键字节位置,方便你对照现有项目快速落地。
FinsTCP 到底解决了什么问题
FinsTCP 是欧姆龙 PLC 提供的一种基于 TCP/IP 的工业通信协议,常用于上位机与 PLC 之间的数据读写、设备状态查询和基础控制交互。
从实现角度看,它的核心不是“连上 PLC”这么简单,而是要在连接建立后,按照协议要求完成节点获取、命令构造、报文发送、响应接收和结果解析。只要其中某一段字节组织不对,通信就可能失败。
建立连接后为什么要先拿节点地址
在正式发送读写命令前,需要先获取 PLC 节点地址 DA1 和 PC 节点地址 SA1。这两个值会写入后续 FINS 命令头,用来标识通信双方。
如果没有先完成这一步,后续命令即使通过 TCP 发到了设备侧,也可能因为节点字段不正确而无法被 PLC 正常处理。
FINS 命令结构关注哪些字段
FINS 命令主要用来定义本次通信的意图,包括:
- 读还是写
- 目标内存区域
- 起始通道号
- 偏移地址
- 读取或写入的数据长度
也就是说,真正决定“读哪一段 PLC 数据”的,不是单一地址,而是一组完整字段共同作用的结果。
PLC IO 存储器地址是怎么定位的
欧姆龙 PLC 的内存地址通常由内存区域、通道号(CH)和偏移量(Offset)组成,例如常见的 DM 区、CIO 区等。
在代码里,这类地址不会直接表现为一个字符串,而是要转换成 FINS 命令中的对应字节字段。因此,地址映射是否正确,直接决定最终读写结果是否准确。
C# 通讯层怎么完成发送与接收
基于 FinsTCP 的上位机通讯,本质上就是通过 Socket/TCP/IP 发送和接收字节数组。上位机发出请求报文后,再按协议长度读取 PLC 返回的数据,并继续做结果解析。
基于 TcpClient 的发送与接收实现
如果项目通讯层封装较轻,直接使用 TcpClient 就能完成基础收发。下面这组方法的特点是写法直接、便于理解,也适合先把协议链路跑通。
发送 BYTE 数据
public static bool SendData(out string msg, TcpClient tcpClient, byte[] sd)
{
msg = string.Empty;
try
{
tcpClient.GetStream().Write(sd, 0, sd.Length);
return true;
}
catch(Exception ex)
{
msg = ex.Message;
return false;
}
}
接收 BYTE 数据
public static bool ReceiveData(out string msg, TcpClient tcpClient, byte[] rd)
{
msg = string.Empty;
try
{
int index = 0;
do
{
int len = tcpClient.GetStream().Read(rd, index, rd.Length - index);
if (len == 0)
return false;
else
index += len;
} while (index < rd.Length);
return true;
}
catch(Exception ex)
{
msg = ex.Message;
return false;
}
}
这里有两个细节值得注意:
Write(sd, 0, sd.Length)直接把完整报文写入网络流。- 接收端通过循环
Read,直到读满目标长度rd.Length,避免只收到半包就提前结束。
基于 Socket 的发送与接收实现
如果项目需要更明确地控制连接状态,也可以封装成基于 Socket 的方式。下面这组实现加入了连接检查、失败重连和异常断开处理,更适合长连接场景。
发送 BYTE 数据
public bool SendData(out string msg, byte[] sd)
{
msg = string.Empty;
try
{
if(!(IsConnected && _Socket != null && _Socket.Connected))
{
if(!Connect(out msg))
{
Thread.Sleep(40);
if (!Connect(out msg)) return false;
}
}
_Socket.Send(sd, sd.Length, 0);
return true;
}
catch (Exception ex)
{
msg = ex.Message;
Disconnect(out string _msg);
return false;
}
}
接收 BYTE 数据
public bool ReceiveData(out string msg, byte[] rd)
{
msg = string.Empty;
try
{
if (!(IsConnected && _Socket != null && _Socket.Connected))
{
if (!Connect(out msg))
{
Thread.Sleep(40);
if (!Connect(out msg)) return false;
}
}
_Socket.Receive(rd, rd.Length, 0);
return true;
}
catch (Exception ex)
{
msg = ex.Message;
Disconnect(out string _msg);
return false;
}
}
这类写法的重点不在 API 本身,而在容错策略:
- 发送或接收前先检查
IsConnected、_Socket != null和_Socket.Connected - 首次连接失败后等待
40毫秒再重试一次 - 一旦异常,立即
Disconnect,避免后续继续复用失效连接
网络状态检测为什么要前置
在正式连接前做一次连通性检测,可以更快区分“PLC 无响应”和“代码逻辑错误”两类问题。示例方法如下:
public static bool PingCheck(string ip, int connectTimeout = 10000)
{
Ping ping = new Ping();
PingReply pr = ping.Send(ip, connectTimeout);
return pr.Status == IPStatus.Success;
}
这里默认超时时间是 10000,足够用于多数局域网场景下的基础检测。
欧姆龙 PLC 连接初始化要做哪些事
连接初始化可以理解为把“TCP 连通”升级成“协议可用”。除了建立 TcpClient 连接,还要发送握手报文、读取 PLC 返回结果,并从响应中提取节点信息。

握手报文如何构造
握手报文是建立 FinsTCP 会话的第一步。下面的实现构造了一个长度为 20 的字节数组,其中前四个字节是固定头 FINS:
private byte[] HandShake()
{
byte[] array = new byte[20];
array[0] = 0x46;
array[1] = 0x49;
array[2] = 0x4E;
array[3] = 0x53;
array[4] = 0;
array[5] = 0;
array[6] = 0;
array[7] = 0x0C;
array[8] = 0;
array[9] = 0;
array[10] = 0;
array[11] = 0;
array[12] = 0;
array[13] = 0;
array[14] = 0;
array[15] = 0;
array[16] = 0;
array[17] = 0;
array[18] = 0;
array[19] = 0;
return array;
}
从这个实现可以直接看出,握手请求并不复杂,但字节位置必须严格对应协议要求,尤其是报文头和长度字段。
FINS 读写命令如何拼装
相比握手,真正影响业务读写的是命令报文构造。下面的方法把读写类型、内存区域、数据类型、通道号、偏移量和数量统一拼进一个 34 字节数组中:
private byte[] FinsCommand(RorW rw, PlcMemory mr, MemoryType mt, short ch, short offset, short cnt)
{
byte[] array = new byte[34];
array[0] = 0x46;
array[1] = 0x49;
array[2] = 0x4E;
array[3] = 0x53;
array[4] = 0;
array[5] = 0;
if (rw == RorW.Read)
{
array[6] = 0;
array[7] = 0x1A;
}
else
{
if (mt == MemoryType.Word)
{
array[6] = (byte)((cnt * 2 + 26) / 256);
array[7] = (byte)((cnt * 2 + 26) % 256);
}
else
{
array[6] = 0;
array[7] = 0x1B;
}
}
array[8] = 0;
array[9] = 0;
array[10] = 0;
array[11] = 0x02;
array[12] = 0;
array[13] = 0;
array[14] = 0;
array[15] = 0;
array[16] = 0x80;
array[17] = 0x00;
array[18] = 0x02;
array[19] = 0x00;
array[20] = PLCNode;
array[21] = 0x00;
array[22] = 0x00;
array[23] = PCNode;
array[24] = 0x00;
array[25] = 0xFF;
if (rw == RorW.Read)
{
array[26] = 0x01;
array[27] = 0x01;
}
else
{
array[26] = 0x01;
array[27] = 0x02;
}
array[28] = GetMemoryCode(mr, mt);
array[29] = (byte)(ch / 256);
array[30] = (byte)(ch % 256);
array[31] = (byte)offset;
array[32] = (byte)(cnt / 256);
array[33] = (byte)(cnt % 256);
return array;
}
这里最值得对照检查的点有四个:
- 读命令和写命令在长度字段上的处理不同
PLCNode与PCNode来自握手响应,不能写死0x01 0x01表示读,0x01 0x02表示写- 内存区、通道号、偏移量、数量共同决定访问目标
打开连接时完整流程是什么
下面这个 Open 方法把连通性检测、TCP 连接、握手发送、响应接收和节点提取串了起来,基本就是一个可直接落地的初始化模板:
public bool Open(out string msg)
{
msg = string.Empty;
try
{
if (!SocketHelper.PingCheck(Ip, ConnectTimeout))
{
msg = "网络故障!";
return false;
}
tcpClient = new TcpClient();
tcpClient.ReceiveTimeout = ReceiveTimeout;
tcpClient.SendTimeout = SendTimeout;
tcpClient.Connect(Ip, Port);
if (!tcpClient.Connected)
{
throw new ApplicationException($"未连接到{Ip}");
}
if (!SocketHelper.SendData(out msg, tcpClient, HandShake()))
{
msg = $"连接,数据写入失败:{msg}!";
return false;
}
byte[] buffer = new byte[24];
if (!SocketHelper.ReceiveData(out msg, tcpClient, buffer))
{
msg = $"连接握手信号接收失败:{msg}!";
return false;
}
if (buffer[15] != 0)
{
msg = $"超过最大连接数或内部连接错误";
return false;
}
PCNode = buffer[19];
PLCNode = buffer[23];
msg = $"连接[{Ip}]成功";
return true;
}
catch (Exception ex)
{
Close(out string _msg);
msg = $"连接失败:{ex.Message}";
return false;
}
}
这段代码给出了几个很实用的判断依据:
- 先用
PingCheck(Ip, ConnectTimeout)过滤明显的网络不可达问题 - 握手响应长度按
24字节接收 - 通过
buffer[15]判断是否存在“超过最大连接数或内部连接错误” - 最后从
buffer[19]和buffer[23]分别取出PCNode与PLCNode
数据读取方法该怎么组织
当连接和节点初始化完成后,接下来就是业务最常见的数据读取。这里的关键不只是“发读命令”,而是如何按 PLC 允许的最大长度分段读取,并为每次响应准备正确大小的接收缓冲区。

按字节读取 Word 数据的实现思路
public bool ReadWordsByte_B(out string msg, PlcMemory mr, int startIndex, int len, out byte[] reData)
{
msg = string.Empty; reData = new byte[0];
try
{
int i = 0;
for (int index = startIndex; index < startIndex + len; index += OmronConsts.MAXREADDATE)
{
int _newLen = len + startIndex - index;
if (_newLen > OmronConsts.MAXREADDATE) _newLen = OmronConsts.MAXREADDATE;
byte[] array = FinsCmd(RorW.Read, mr, MemoryType.Word, (short)(index/2), 00, (short)(_newLen/2));
if (!SocketHelper.SendData(out msg, tcpClient, array))
{
msg = $"读取,数据写入失败[{i}次]:{msg}!";
return false;
}
byte[] buffer = new byte[30 + _newLen];
// 后续解析逻辑...
}
}
catch (Exception ex)
{
msg = ex.Message;
reData = new byte[0];
return false;
}
}
这段逻辑体现了几个典型处理方式:
- 通过
for循环按OmronConsts.MAXREADDATE分批读取,避免单次请求过长 - 每一轮根据剩余长度计算
_newLen - 读取 Word 数据时,通道号使用
(short)(index/2),读取数量使用(short)(_newLen/2) - 接收缓冲区按
30 + _newLen分配,说明协议响应头之外还要容纳实际数据区
如果你的项目里经常出现“部分地址能读、部分地址失败”的情况,优先检查的通常就是这几个点:分段长度、字节数与字数换算、缓冲区大小,以及后续响应解析是否与请求长度一致。
落地 FinsTCP 通讯模块时该抓住哪些关键点
把这套流程串起来看,基于欧姆龙 PLC 的 FinsTCP 通讯模块,重点其实非常集中:先通过握手拿到 PCNode 和 PLCNode,再按内存区域、通道号、偏移量和数量构造读写命令,最后用稳定的收发逻辑完成响应读取和解析。
对于 C# 上位机项目来说,真正影响稳定性的通常不是某一行 API 调用,而是三件事是否处理到位:连接前网络检测、异常时重连或断开策略,以及报文字段是否严格符合协议定义。把这些基础打稳,后续扩展写入、批量读取和更多 PLC 区域访问都会顺畅得多。













