
简介面向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 节点号比如 1DA2目的单元号0 表示 CPU 单元SNA源网络号0SA1源节点号上位机节点号握手响应里能拿到SA2源单元号0SID服务标识每次请求自增用来匹配响应内存区则是另一套编码DM 字区的区码是 0x82CIO 字区是 0xB0WR 区是 0xB1HR 区是 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 Taskbyte[] ReceiveFullFrameAsync(Socket socket) { // 前 10 字节8 字节 FINS 固定头 2 字节长度字段 var header new byte[10]; int got 0; while (got 10) { int n await socket.ReceiveAsync( new ArraySegmentbyte(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 ArraySegmentbyte(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 Listbyte(); 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 Listbyte(); 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 Listbyte(); 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 就写 0D100 写 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 Listbyte(); 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 Listbyte(); 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 Taskstring 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 Taskstring 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 和失败前的最后一条命令帧这是排查现场问题唯一的后悔药。这套代码我做过三次产线落地最深的教训是通信链路看起来只有几十行真正花时间的是串口缓冲、半开连接和地址模式这些边角。把这些边界条件按上面的清单一个个钉死上位机才能从“能跑”变成“能守”。希望帮到你。本文还有配套的精品资源点击获取