C# 对接欧姆龙 PLC:FinsTCP 通讯模块实现拆解

今天早睡
今天早睡
发布时间:2026-08-23| 63人浏览过
原创

前言

在对接欧姆龙 PLC 时,真正难点通常不在于会不会用 C# 建立 TCP 连接,而在于 FinsTCP 握手、节点地址、命令字段和分段读取这些细节是否全部对上。本文按协议结构、收发实现、连接初始化和读取流程逐段拆开,保留关键代码与字节位置,帮助你判断一套通讯模块到底该怎样写,哪里又最容易出错。

在对接欧姆龙 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 握手初始化流程图,展示从网络检测到提取 PCNode 和 PLCNode 的关键步骤
FinsTCP 连接初始化流程把 TCP 连通变成协议可用,关键在于握手响应校验和节点提取。

握手报文如何构造

握手报文是建立 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 允许的最大长度分段读取,并为每次响应准备正确大小的接收缓冲区。

PLC 数据读取分段流程图,展示 MAXREADDATE 分批读取、长度换算和接收缓冲区分配逻辑
批量读取的分段与缓冲区计算批量读取稳定与否,通常取决于分段长度控制和字节数、字数之间的换算。

按字节读取 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 区域访问都会顺畅得多。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。