C#与欧姆龙PLC通信:FINS/TCP与HostLink源代码解析

发布时间:2026/11/2 10:26:58
C#与欧姆龙PLC通信:FINS/TCP与HostLink源代码解析

简介:面向C#与欧姆龙PLC通信开发的完整源代码包,覆盖工业自动化中上位机与PLC通过以太网进行数据交换和控制的核心需求,适合刚接触PLC通信的C#开发者快速上手,也能让有经验者直接复用通信层逻辑。压缩包内共21个文件,体积仅26KB,以12个C#源码文件(.cs)为主体,辅以解决方案(.sln)、项目文件(.csproj)、资源文件(.resx)及说明文档(.md),结构清晰,便于导入Visual Studio后直接编译调试。代码基于欧姆龙FINS协议实现,涵盖通信库封装、FINS指令构造与解析、TCP连接管理、多线程收发与UI状态刷新等关键模块,并内置错误处理与重试机制,能够帮助读者理解上位机与PLC交互的完整流程。目前已有908人学习下载,对于希望掌握工业通信协议并落地实际项目的技术人员来说,是一份值得参考的实用资源,尤其适合自动化、电气相关专业的初学者作为入门参考。

1. C# 和欧姆龙 PLC 通信程序源代码:先想清楚这条链路要解决什么

拿到“C# 和欧姆龙 PLC 通信程序源代码”这个需求的人,多半不是没见过 PLC,而是没见过能稳定跑上一两个月的上位机。PLC 侧的数据早就放在 DM、CIO 区里等上位机来读,缺的只是那条用 C# 把数据搬到电脑屏幕上的信道。这篇笔记按欧姆龙应用里最容易撞上的两条路——FINS/TCP 与 HostLink 串口——把一套可复现的上位机通信代码完整拆开:帧结构、握手、读写封装、CRC 校验、断线重连都落到可以抄的步骤上。适合做设备集成、产线数据采集和维护型上位机的 C# 开发者。目标不是跑通一次握手,而是跑通之后还能扛住断电重启、连续几天轮询和半夜的串口丢帧。

2. 先选通信方式:FINS/TCP、FINS/UDP 与 HostLink 怎么取舍

2.1 三种通信方式的适用场景与性能边界

欧姆龙 PLC 对外通信不是只有一种做法。新机型带网口,老机型只有串口,选错通道意味着整段代码推倒重来。最常见的三种方式是 FINS/TCP、FINS/UDP 和 HostLink,它们在传输层、实时性和 C# 实现成本上差别很大。

维度FINS/TCPFINS/UDPHostLink(串口)
传输层TCP,端口 9600UDP,端口 9600RS232/RS422 串口
典型机型CJ2M、CP1H 配以太网模块、NJ/NX同左,或经以太网模块CP1E、CQM、老 C 系列
是否需要连接握手需要,先发连接帧不需要不需要,但串口参数要先对齐
可靠性高,TCP 负责重传中,丢包只能靠应用层重发中,长帧要处理读取超时
C# 实现成本中,要处理帧组装和粘包中,帧结构相同但少握手低,SerialPort 直接读写
适合场景数据量大、要求可靠的新项目简单采集、允许偶发丢包的场合老设备利旧、本地短距离改造

我的建议很直接:只要 PLC 带网口就用 FINS/TCP。UDP 虽然省了握手,但丢包后没有 TCP 那层保障,上位机得自己处理“发了没回”的分支,代码写出来比 TCP 还啰嗦。HostLink 留给没有网口的老机型,它沿用了上世纪串口指令的格式,好处是任何一本欧姆龙编程手册都能查到命令表,坏处是地址换算和帧校验最容易翻车。

2.2 欧姆龙的地址模型:网络号、节点号、单元号和内存区

FINS 协议里没有“IP 地址”这个概念,它自己的寻址方式是四层:网络号(Network)、节点号(Node)、单元号(Unit),再加上一个每次请求递增的 SID。IP 地址只负责把包送到 PLC 的网卡,进了 PLC 之后找哪个 CPU、哪个模块,全靠 FINS 头里那六个字节决定。

我一般在代码里把这些值做成配置项,而不是写死:

FINS 头部字段含义常见取值
DNA目的网络号0(单网络直连)
DA1目的节点号PLC 的 FINS 节点号,比如 1
DA2目的单元号0 表示 CPU 单元
SNA源网络号0
SA1源节点号上位机节点号,握手响应里能拿到
SA2源单元号0
SID服务标识每次请求自增,用来匹配响应

内存区则是另一套编码:DM 字区的区码是 0x82,CIO 字区是 0xB0,WR 区是 0xB1,HR 区是 0xB2。欧姆龙 CPU 的 DM 区地址从 0 开始,按“字”访问;CIO 区像 CIO 0.01 这种写法,字地址是 0,位号是 1。把这些概念在动手写代码之前理清,否则后面调一个地址错位能磨掉一晚上。

3. 用 C# 实现 FINS/TCP 读写:握手、读 DM 区、写 CIO 区

3.1 建立 FINS/TCP 会话:先收满帧头再按长度收数据

FINS/TCP 的会话建立分两步:先 TCP 连上 9600 端口,再发一个命令码为 0x0000 的连接帧。连接帧通过后,这条 TCP 连接上跑的都是 FINS 命令。先写收帧底层,因为 TCP 是流,不是包,一次 Receive 拿到的可能只是半帧,也可能粘了两帧。

static async Task<byte[]> ReceiveFullFrameAsync(Socket socket) { // 前 10 字节:8 字节 FINS 固定头 + 2 字节长度字段 var header = new byte[10]; int got = 0; while (got < 10) { int n = await socket.ReceiveAsync( new ArraySegment<byte>(header, got, 10 - got), SocketFlags.None); if (n == 0) throw new IOException("连接被对方关闭"); got += n; } int len = (header[8] << 8) | header[9]; var body = new byte[len]; int bodyGot = 0; while (bodyGot < len) { int n = await socket.ReceiveAsync( new ArraySegment<byte>(body, bodyGot, len - bodyGot), SocketFlags.None); if (n == 0) throw new IOException("连接被对方关闭"); bodyGot += n; } var full = new byte[10 + body.Length]; Buffer.BlockCopy(header, 0, full, 0, 10); Buffer.BlockCopy(body, 0, full, 10, body.Length); return full; }

这段代码解决的是 TCP 粘包和拆包问题。FINS/TCP 每帧都以 8 字节固定头开始,第 8、9 字节是后面命令段的长度。先循环把 10 字节头部收满,再从长度字段得知命令段多大,继续循环收 body。两次 ReceiveAsync 都可能是部分到达,所以必须用 while 把缺口补满,不能用一次 ReceiveAsync 的返回值当完整帧。后面的读写命令全部复用这一个收帧函数,业务代码里不用再关心粘包。

握手请求是标准的 FINS/TCP 连接帧:

var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(ipAddress, 9600); var handshake = new List<byte>(); handshake.AddRange(Encoding.ASCII.GetBytes("FINS")); // 协议标识 handshake.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); // 保留 handshake.AddRange(new byte[] { 0x00, 0x06 }); // 长度:命令码 2 字节 + 数据 4 字节 handshake.AddRange(new byte[] { 0x00, 0x00 }); // 命令码 0x0000 = 连接 handshake.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); // 节点号 0 表示请求分配 await socket.SendAsync(handshake.ToArray()); var resp = await ReceiveFullFrameAsync(socket); if (resp.Length < 16) throw new Exception("握手响应帧长度不对");

握手响应同样以 10 字节头开始,命令段长度为 6。正常情况下响应帧里会带回 PLC 分配的节点号,有的型号放在数据段前两个字节里。我习惯把这段响应在调试期用日志打出来,因为后续请求里的 SA1(源节点号)直接填这个值,比手写节点号更稳。注意不同 CPU 型号对“节点号 0”的容忍度不一样,建议把 PLC 侧设置的 FINS 节点号作为 DA1,上位机侧节点号从握手响应里解析。

3.2 封装读命令:从 DM 区读 10 个字的完整方法

FINS 读内存的命令码是 0x0101,命令段由三部分组成:读命令码、10 字节 FINS 报文头、以及内存区参数。参数里依次是区码、起始字地址、位号、读取数量。下面这个函数把读 DM 区拼帧、发送、收帧、解析绑在一起。

static byte[] BuildReadCommand(byte sid, byte da1, byte sa1, ushort startAddr, ushort count, byte areaCode) { var payload = new List<byte>(); payload.AddRange(new byte[] { 0x01, 0x01 }); // 读命令 payload.AddRange(new byte[] // FINS 报文头 { 0x80, 0x00, 0x02, 0x00, da1, 0x00, 0x00, sa1, 0x00, sid }); payload.AddRange(new byte[] { areaCode, 0x00 }); // 内存区码 payload.AddRange(BigEndian(startAddr)); // 字地址 payload.AddRange(new byte[] { 0x00, 0x00 }); // 位号固定 0 payload.AddRange(BigEndian(count)); // 字数 var frame = new List<byte>(); frame.AddRange(Encoding.ASCII.GetBytes("FINS")); frame.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); frame.AddRange(BigEndian((ushort)payload.Count)); // 长度字段 frame.AddRange(payload); return frame.ToArray(); } static ushort[] BigEndian(ushort v) => new byte[] { (byte)(v >> 8), (byte)(v & 0xFF) };

读 DM 区时 areaCode 传 0x82,读 CIO 区传 0xB0。起始地址是 D0 就写 0,D100 写 100,单位是“字”。发送后收到响应,结束码在响应帧的第 14、15 字节,0x0000 表示成功,数据从第 16 字节开始,每两个字节一个字,高字节在前。

var cmd = BuildReadCommand(sid, da1, sa1, 0x0000, 10, 0x82); await socket.SendAsync(cmd); var resp = await ReceiveFullFrameAsync(socket); int endCode = (resp[14] << 8) | resp[15]; if (endCode != 0x0000) throw new Exception($"FINS 结束码异常: {endCode:X4}"); var values = new ushort[(resp.Length - 16) / 2]; for (int i = 0; i < values.Length; i++) values[i] = (ushort)((resp[16 + i * 2] << 8) | resp[17 + i * 2]);

这里有个坑:响应里解析出来的数是直接按大端读的,不要拿 BitConverter.ToUInt16 去读,因为 .NET 本机是小端。结束码不是 0x0000 时,上位机最常见的是 0x1101(区域规格错误)一类,这时候别猜,把十六进制打日志,再对照欧姆龙对应型号的 FINS 错误码表查。

3.3 改一个 bit:先读后改写,别直接发位区命令

上位机要操作 PLC 里的开关量时,我一般不建议一上来就去用 FINS 的位区命令。位区命令的区码和地址换算在不同 CPU 型号里并不一致,调试成本高。更稳妥的做法是读修改写:把一个字读回来,在 C# 里把这一位改掉,再把整个字写回去。

static byte[] BuildWriteCommand(byte sid, byte da1, byte sa1, ushort startAddr, ushort[] data, byte areaCode) { var payload = new List<byte>(); payload.AddRange(new byte[] { 0x01, 0x02 }); // 写命令 payload.AddRange(new byte[] { 0x80, 0x00, 0x02, 0x00, da1, 0x00, 0x00, sa1, 0x00, sid }); payload.AddRange(new byte[] { areaCode, 0x00 }); payload.AddRange(BigEndian(startAddr)); payload.AddRange(new byte[] { 0x00, 0x00 }); // 位号 payload.AddRange(BigEndian((ushort)data.Length)); // 写入字数 foreach (var v in data) payload.AddRange(BigEndian(v)); var frame = new List<byte>(); frame.AddRange(Encoding.ASCII.GetBytes("FINS")); frame.AddRange(new byte[] { 0x00, 0x00, 0x00, 0x00 }); frame.AddRange(BigEndian((ushort)payload.Count)); frame.AddRange(payload); return frame.ToArray(); }

写单 bit 的调用序列是:先 BuildReadCommand 读回 CIO 0.00 这个字,在本地用位运算把第 0 位置 1 或清 0,再 BuildWriteCommand 写回同一个字地址。这一招在跨型号复用代码时特别省心,因为字区命令的区码比位区命令稳定。写命令的响应帧没有数据段,结束码同样是第 14、15 字节,判断成功之后即可返回。

4. 用 C# 实现 HostLink 串口通信:帧格式、CRC 校验与超时重发

4.1 HostLink 请求帧与响应帧:一个字符都不能错

HostLink 是欧姆龙老串口协议,帧格式比 FINS 直观,但全是 ASCII 字符,一个字符大小写错了,PLC 就回错误码。读 DM 区的请求帧长这样:

@ 设备号 命令 起始地址 字数 FCS 终止符 @ 00 RR 0000 0010 xx *CR

设备号是两位十六进制,默认 00,对应 PLC 串口设置里的单元号。RR 是读命令,后跟 4 位起始字地址和 4 位读取字数,地址和字数都是十六进制。校验 FCS 是异或校验,从 @ 之后第一个字符开始,到 FCS 前一个字符结束,逐字符异或,结果转成两位大写十六进制。终止符是星号加回车。

响应帧结构更简单:@00RR00后面跟数据,前两位结束码,00 表示成功。读取 10 个字,响应数据就是 20 个 ASCII 字符,每 4 个字符代表一个字。

static string BuildHostLinkRead(string deviceNo, string address, int count) { string body = $"{deviceNo}RR{address}{count:X4}"; string fcs = CalcFcs(body); return "@" + body + fcs + "*\r"; }

写命令把 RR 换成 WR,数据部分拼在字数后面,格式仍然是每个字 4 位十六进制。HostLink 的地址在不同系列里有暗坑:CS/CJ 系列用 CV 地址模式,DM 区从 0000 开始;老的 C 系列用固定地址模式,DM 区地址要加上偏移。代码里写死地址之前,先确认 PLC 串口设置里的协议模式。

4.2 CRC 异或校验与 SerialPort 参数:对不上就连错误码都收不到

static string CalcFcs(string body) { int xor = 0; foreach (char c in body) xor ^= (byte)c; return xor.ToString("X2"); }

这个函数是 HostLink 唯一需要的校验。实现思路是把 body 里每个字符的 ASCII 码逐字节异或,结果用大写十六进制输出。注意异或范围绝对不能包含开头的 @,也不能包含结尾的 * 和 \r,否则一切发送都会被 PLC 判成校验失败,回一个结束码 01。

SerialPort 的参数要和 PLC 串口设置完全一致。欧姆龙默认常见配置是 9600、8、无校验、1 停止位,但这只是出厂值,很多产线改过,所以接 PLC 之前先在它的系统设置里确认。打开串口后别急着发命令,先等 500ms,让 PLC 的串口驱动就绪,否则第一帧经常丢。

serialPort = new SerialPort("COM3", 9600, Parity.None, 8, StopBits.One); serialPort.ReadTimeout = 800; serialPort.WriteTimeout = 800; serialPort.Open(); await Task.Delay(500);

DtrEnable 和 RtsEnable 是另一个玄学点。有的欧姆龙串口模块靠 DTR 信号完成握手,有的不需要,行为不一致。我一般先全部设为 false,如果 PLC 不回帧,再改成 true 试,不要两种状态来回跳。

4.3 超时重发机制:串口读不完一帧怎么办

SerialPort 的 ReadTimeout 到了会抛 TimeoutException,但更麻烦的是半截帧已经进了接收缓冲区。如果不清掉残留数据就重发,下一帧会被残留字节污染,永远对不上。所以重发前必须先清缓冲区。

async Task<string> SendHostLinkAndReadAsync(string frame) { for (int attempt = 0; attempt <= 2; attempt++) { serialPort.DiscardInBuffer(); serialPort.Write(frame); try { return await ReadHostLinkResponseAsync(); } catch (TimeoutException) { // 连续超时由上层统计,超过 3 次直接关端口重开 } } throw new TimeoutException("HostLink 连续 3 次无响应"); }

ReadHostLinkResponseAsync 不要按固定长度去读,因为响应数据长度取决于读了多少字。更稳的做法是逐字节找到 @ 开始,再一直读到星号结束,把 \r 吞掉。这样即使前面串口里有少量残留,只要第一条 @ 定位准确,后面都能解析对。

async Task<string> ReadHostLinkResponseAsync() { var builder = new StringBuilder(); while (true) { int b = serialPort.ReadByte(); if (b == -1) throw new TimeoutException(); if ((char)b == '@') { builder.Clear(); continue; } if ((char)b == '*') { int cr = serialPort.ReadByte(); if (cr == '\r' || cr == -1) break; continue; } builder.Append((char)b); } return builder.ToString(); }

读到 @ 时清空 StringBuilder,是为了防止缓冲区里出现碎字符。星号代表帧结束,把后面的 \r 也读掉。如果 PLC 回了一个非 00 的结束码,这个函数一样能正常收完,解析结束码的逻辑交给上层,不要在收帧函数里抛异常,否则真正的错误码会被截断。

5. 欧姆龙 PLC 通信的常见问题与排查清单:五个翻车点都有后悔药

5.1 FINS/TCP 连上就断或请求超时:节点号与单元号没配对

现象:TCP 握手能通,Socket 也连上了,但第一次读写命令发出去就超时,或者 PLC 那边根本没收到请求。原因:FINS 头里的 DA1、DA2 和 PLC 实际配置对不上,报文被 PLC 的网络层直接丢弃,连错误码都不回。解决:先确认 PLC 的 FINS 节点号,把 DA1 改成这个值;DA2 填 0 是 CPU 单元的万能选项。再检查上位机防火墙,Windows 默认会拦来自 PLC 方向的回包,端口 9600 的入站规则要放行。测试时用一个最简单的读命令、固定 SID,把请求帧用日志完整打出来逐字节核对。

5.2 HostLink 首帧乱码或校验失败:串口打开顺序和流控问题

现象:上位机先打开串口号 PLC 没反应,换 CX-Programmer 先连一下再关掉,上位机就能通了。原因:串口被别的软件占用,或者 RTS、DTR 信号状态不对,PLC 侧串口模块没进入通信就绪状态。解决:上位机独占串口,打开后延时 500ms;同时把 DtrEnable 和 RtsEnable 按 PLC 型号说明书设置,不确定就两个值都试一遍;开发期间不要把 CX-Programmer 的在线监视开着,它会在后台发自己的协议帧,跟你的上位机抢串口。

5.3 地址差一或数据错位:C 系列地址和 CV 地址模式

现象:读 CIO 区,想读 CIO 0.00,发过去的地址是 0000,读回来的却是别的数据。原因:老 C 系列 PLC 用固定地址模式,CIO 区在 HostLink 里的地址起点不是 0000,而是 8000 之类的段偏移。CV 模式(扩展模式)下才统一从 0 开始。解决:去 PLC 串口设置里确认协议模式,统一用 CV 模式,上位机侧不要自己加偏移。FINS 协议同样存在这个问题,新机型都是 CV 模式,但如果对接的是十年前的设备,最好先在 PLC 上用编程软件手动读一个地址,确认协议侧地址起始规则。

5.4 PLC 断电重启后上位机不知情:TCP 半开连接与心跳包重传

现象:PLC 断电重启,上位机 Socket.Connected 还是 true,发读写命令一直超时,程序也不报断线。原因:TCP 连接是半开的,PLC 重启后旧连接消失,但上位机侧没有感知,继续往一个已经失效的连接上写数据。解决:应用层做心跳包重传机制,每次读 PLC 数据时记录超时次数,连续 3 次无响应就关闭 Socket 重新走握手流程;不要把 Socket.Connected 当存活依据,它只反映本地套接字状态。重连成功后 SID 从 0 或随机值重新开始,否则 PLC 端会认为请求序号异常。

5.5 响应帧解析结果不对:FINS 高字节在前与 .NET 字节序

现象:读回来的 DM 值要么完全不对,要么高低位互换,比如 PLC 里是 0x1234,上位机读成 0x3412。原因:FINS 协议传输数据是标准的网络字节序,高字节在前,而 .NET 的 BitConverter 默认按本机小端解析。解决:全部用手动移位拼数字,(byte[0] << 8) | byte[1],写回去同理。HostLink 因为是 ASCII 帧,不存在这个问题,但它那边用 4 个字符一个字的格式,解析时容易把两个字节合并顺序搞反,处理方法一样,先按字符拼出字符串再转 int,不要直接 BitConverter。

6. 把这套通信代码变成可维护的上位机:轮询、事件与断线重连

别让读写函数裸奔在窗体按钮里。产线上位机的常态是每秒读几次数据、显示在界面、必要时写回 PLC,这套动作和 UI 事件完全没关系。我会把通信对象封装成一个独立的类,接口只暴露 ReadDmAsync、WriteDmAsync 和两个事件,UI 层根本不需要知道帧格式。

public async Task PollLoopAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { try { var data = await ReadDmWordsAsync(startAddr, count); DataUpdated?.Invoke(data); failCount = 0; } catch (Exception ex) { OnError?.Invoke(ex); if (++failCount >= 3) { await ReconnectAsync(); failCount = 0; } } await Task.Delay(pollMs, ct); } }

轮询间隔我一般设 200ms 到 500ms,太快会增加 PLC 的通信负载,太慢界面数据会显得迟钝。断线重连做成连续失败计数,而不是每次异常都重连,否则 PLC 重启的十几秒里上位机会反复建连,反而把 PLC 的通信接口拖住。把数据更新抛成事件,UI 在事件处理器里用 Dispatcher 回到主线程更新界面,通信线程保持干净。日志里记录每次请求的 SID 和失败前的最后一条命令帧,这是排查现场问题唯一的后悔药。

这套代码我做过三次产线落地,最深的教训是:通信链路看起来只有几十行,真正花时间的是串口缓冲、半开连接和地址模式这些边角。把这些边界条件按上面的清单一个个钉死,上位机才能从“能跑”变成“能守”。希望帮到你。

本文还有配套的精品资源,点击获取