简介:本资源是一个基于C#实现欧姆龙PLC通信的完整工业自动化开发示例,面向工业控制领域初学者与.NET开发者,解决C#环境下通过FINS协议远程读写PLC数据的核心问题。项目支持TCP与UDP双模式通信,涵盖连接管理、会话建立、寄存器读写、错误响应解析及WinForm可视化交互等关键环节,适用于设备监控、产线数据采集等实际场景。压缩包共82个文件,含14个核心C#源码(如OmromPlc_UDP.cs、OmromPlcHelper.cs)、14个DLL依赖(含HslCommunication 11.5.2等工业通信库)、14个XML配置与文档、以及exe可执行程序、sln解决方案和配套资源文件,整体大小为13.65MB,结构清晰便于模块化学习与二次开发。目前已有504人学习下载,提供开箱即用的调试环境、完整的协议封装逻辑、GUI操作界面及典型FINS报文构造范例,是理解工业协议与.NET网络编程融合实践的优质入门参考。 做上位机、搞数据采集的,八成绕不过欧姆龙PLC。我在好几个项目里都需要用C#读写欧姆龙PLC的数据,通信协议选的就是Fins-TCP和UDP。接触多了以后,干脆把常用的通信功能抽成了一个独立的库,也就是压缩包里的这套源码。今天把整个东西从设计到实现、从源码到实战完整捋一遍,包括报文格式、连接流程、踩坑记录,希望能帮你少走弯路。
先说这库能干什么:通过以太网对欧姆龙PLC的DM区、CIO区、WR区、HR区做字读写、位读写,支持Fins-TCP和Fins-UDP两种通道,拿到手可以直接在WinForm、WPF、控制台里用。适合的场景包括产线数据采集、设备参数下发、MES对接、简单的SCADA点位控制。如果你正准备用C#和欧姆龙PLC做通信,或者已经写了但老是超时、丢包、数据错位,这篇文章可以直接拿来参考对照。
1. 项目概述与整体设计思路
1.1 解决什么问题、为什么要自己写
最开始接项目时,我用的还是网上流传的老代码,抄来抄去,问题一大堆:有的是只能读DM区、写CIO就报错;有的是TCP握手都过不去;还有的代码连粘包都不处理,通信一频繁就乱码。后来换过收费的通信库,功能确实全,但一个授权好几个项目费用,而且底层封装得太黑盒,出了问题不好排查。
自己写这套库的核心动机其实就两个:一是可控,每一个字节都清楚是什么,出问题能自己抓包定位;二是干净,不依赖第三方组件,纯Socket实现,拷几个文件就能用。对于搞上位机开发的来说,通信协议这种东西一旦吃透了,后面换PLC型号、加功能都是顺手的。
设计目标也很明确:
- 支持Fins-TCP和Fins-UDP两种方式,接口尽量统一,业务层不感知底层差异;
- 提供字读写和位读写,覆盖DM、CIO、WR、HR等常用区域;
- 处理粘包拆包、超时重发、断线检测,这东西看着基础,但大部分通信问题都出在这;
- 保持单文件类库风格,包含注释和Demo,方便二次开发。
1.2 TCP和UDP两个版本怎么选
很多刚开始接触的人会纠结这个问题。Fins-TCP和Fins-UDP差异不只在传输层,使用体验上也有明显区别。
Fins-TCP需要先建立TCP连接,然后做一次“节点地址发送”握手,告诉PLC上位机的FINS节点号。之后每次读写数据,都要包一层FINS/TCP帧。它的优势是TCP本身有确认、重传、排序机制,局域网环境下基本不会丢数据,适合轮询点位多、数据量大、要求完整的场景。
Fins-UDP就简单很多,不需要握手,直接把FINS命令帧扔给PLC的9600端口,PLC处理后原路返回结果。它的优势是单次交互延迟更低、代码更简单,没有连接维护。但UDP不可靠,极端情况下会丢包,所以需要自己做超时重发。
我的实际选型经验是:
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 点位多、轮询密度高、数据不能丢 | Fins-TCP | 可靠传输,不用自己处理重传 |
| 单点读取、指令控制、响应要求快 | Fins-UDP | 延迟低,简单直接 |
| 无线网络、跨交换机、链路不稳定 | Fins-TCP | TCP重传机制扛得住 |
| 嵌入式上位机、资源受限 | Fins-UDP | 无连接,状态少,内存占用小 |
有个项目我在一个车间里头同时用了两种:设备实时状态用UDP快速轮询,工艺参数下发走TCP确保不丢。这样做的好处是高频小数据量走UDP不占TCP连接资源,关键下发改走TCP可靠传输。这个思路你也可以参考。
2. Fins协议原理与报文结构解析
2.1 FINS帧的10个字节头
FINS协议是欧姆龙定义的应用层协议,报文结构不复杂,核心就是10个字节的帧头 + 命令码 + 数据区。这10个字节的作用,我一个个解释清楚:
| 偏移 | 字段 | 说明 |
|---|---|---|
| 0 | ICF | 信息控制字段,0x80表示需要响应 |
| 1 | RSV | 保留字段,固定0x00 |
| 2 | GCT | 网关计数,固定0x00 |
| 3 | DNA | 目标网络号,通常0x00 |
| 4 | DA1 | 目标节点号,即PLC的FINS节点号 |
| 5 | DA2 | 目标单元号,通常0x00 |
| 6 | SNA | 源网络号,通常0x00 |
| 7 | SA1 | 源节点号,即上位机的FINS节点号 |
| 8 | SA2 | 源单元号,通常0x00 |
| 9 | SID | 服务ID,每次请求递增,用来匹配请求和响应 |
刚开始搞这个协议的时候,我总觉得ICF像是固定写死的,后来才明白ICF = 0x80的意思是“这个帧需要对方响应”,如果你发0x00,PLC收到就不回你,等着干瞪眼。帧头里最容易写错的就是DA1和SA1,很多超时问题都是节点号填错导致的。
2.2 读命令、写命令的报文模板
FINS最常用的两个命令是0101读内存区域和0102写内存区域。
读命令的完整报文是这样的:
ICF RSV GCT DNA DA1 DA2 SNA SA1 SA2 SID 01 01 内存区代码 起始地址(2字节) 位号 读取次数(2字节)举例:用TCP方式读PLC的D100开始的10个字,PLC节点号是10,上位机节点号也是10。报文展开就是:
80 00 00 00 0A 00 00 0A 00 01 01 01 82 00 64 00 00 0A- 内存区代码0x82是DM区
- 起始地址0x0064就是100
- 位号0x00表示按字访问
- 读取次数0x000A表示10个字
写命令的报文要带上数据区:
... SID 01 02 内存区代码 起始地址(2字节) 位号 写入次数(2字节) 数据...写D100、D101两个字的值为0x1234和0x5678,报文是:
80 00 00 00 0A 00 00 0A 00 02 01 02 82 00 64 00 00 02 12 34 56 78注意写入次数是2,对应两个字的长度。有个容易踩的坑:把写入次数写成数据字节数4,PLC会直接报参数错误。写入次数和读取次数的单位是“点数”,不是“字节数”。字访问时一个点是2字节,位访问时一个点是1位。
2.3 Fins-TCP比UDP多出来的封装层
Fins-TCP不是简单地把FINS帧塞进TCP流里,而是包了一层自己的帧格式:
"FINS"(4字节ASCII) + 长度(4字节) + 命令码(2字节) + 错误码(2字节) + 数据区连接建立后第一步是握手。上位机发一个命令码为0x0000的帧,数据区带上自己的节点号,PLC回复命令码0x0001表示握手成功。之后每次读写,命令码都是0x0002,PLC的响应命令码是0x0003。
握手帧的实际发送内容大概是这样的(节点号0x0A):
46 49 4E 53 00 00 00 05 00 00 00 00 0A- 46 49 4E 53 是ASCII码的"FINS"
- 00 00 00 05 是后续字节长度
- 00 00 是命令码(握手请求)
- 00 00 是错误码
- 0A 是上位机节点号
如果你用的是老款PLC或者某些协议栈版本比较严格的设备,握手不通过时可以尝试在节点号后面补3个0x00,把数据区凑成4字节再发。这个我在CJ2M上遇到过,补了就能过,不补就一直超时。
UDP方式没这层封装,直接把FINS帧发给PLC的9600端口就行,简单粗暴。但正因为没有握手,PLC对你的节点号完全靠FINS帧头里的SA1来识别,所以UDP模式下SA1必须填对,否则PLC根本不知道是谁在跟它说话,自然也不会回。
3. C#核心代码实现
3.1 工程结构与公共约定
整个库我分成了两个核心类:FinsTcpClient和FinsUdpClient,另外加了一个FinsHelper静态类处理数据转换。工程结构如下:
FinsLibrary/ ├── FinsTcpClient.cs ├── FinsUdpClient.cs ├── FinsHelper.cs └── Demo/ ├── Program.cs └── FormMain.cs公共约定:
- 所有方法命名统一为
ReadDm、WriteDm、ReadCiO、WriteCiO、ReadWr、ReadHr、ReadBit、WriteBit; - 字读写返回
short数组或byte[]原始数据,位读写返回bool[]; - 所有公共方法内部都带了锁,多线程环境下可以直接调用,不用额外同步。
为什么要统一成这套命名?因为PLC工程师和上位机开发之间沟通时,说的就是“读D100”“写CIO0.01”,方法名和现场叫法一一对应,不容易出错。
3.2 FinsTcpClient完整实现
先看核心的连接和握手部分。
public class FinsTcpClient : IDisposable { private TcpClient _tcp; private NetworkStream _stream; private readonly object _lock = new object(); private byte _sid = 0; /// <summary>上位机FINS节点号,必须和PLC端配置一致</summary> public byte LocalNode { get; set; } = 0x0A; /// <summary>PLC的FINS节点号</summary> public byte PlcNode { get; set; } = 0x0A; public bool Connect(string ip, int port = 9600) { _tcp = new TcpClient(); _tcp.ReceiveTimeout = 3000; _tcp.SendTimeout = 3000; _tcp.Connect(ip, port); _stream = _tcp.GetStream(); return Handshake(); } private bool Handshake() { byte[] frame = new byte[13]; frame[0] = 0x46; frame[1] = 0x49; frame[2] = 0x4E; frame[3] = 0x53; frame[4] = 0x00; frame[5] = 0x00; frame[6] = 0x00; frame[7] = 0x05; frame[8] = 0x00; frame[9] = 0x00; frame[10] = 0x00; frame[11] = 0x00; frame[12] = LocalNode; _stream.Write(frame, 0, frame.Length); byte[] resp = ReadResponse(); // 响应帧:FINS头 + 长度 + 命令码0001 + 错误码0000 if (resp.Length < 14) return false; return resp[8] == 0x00 && resp[9] == 0x01 && resp[12] == 0x00 && resp[13] == 0x00; } private byte[] ReadResponse() { byte[] header = ReadExactly(8); int length = (header[4] << 24) | (header[5] << 16) | (header[6] << 8) | header[7]; byte[] body = ReadExactly(length); byte[] result = new byte[8 + length]; Buffer.BlockCopy(header, 0, result, 0, 8); Buffer.BlockCopy(body, 0, result, 8, length); return result; } private byte[] ReadExactly(int count) { byte[] buffer = new byte[count]; int offset = 0; while (offset < count) { int read = _stream.Read(buffer, offset, count - offset); if (read == 0) throw new IOException("连接已断开"); offset += read; } return buffer; } }这里有个很重要的细节:ReadResponse不能只读一次,因为TCP是流协议,一次Read返回的字节数不一定等于你想要的长度。我见过太多初版代码就是_stream.Read(buf, 0, buf.Length)然后直接解析,结果数据一多就出现半包、粘包,数据全是乱的。ReadExactly循环读满指定长度,是解决这个问题的标准姿势。
然后是构建FINS帧和发送:
private byte[] BuildFinsFrame(byte mrc, byte src, byte[] data) { using MemoryStream ms = new MemoryStream(); ms.WriteByte(0x80); // ICF 需要响应 ms.WriteByte(0x00); // RSV ms.WriteByte(0x00); // GCT ms.WriteByte(0x00); // DNA ms.WriteByte(PlcNode); // DA1 ms.WriteByte(0x00); // DA2 ms.WriteByte(0x00); // SNA ms.WriteByte(LocalNode); // SA1 ms.WriteByte(0x00); // SA2 ms.WriteByte(_sid++); // SID ms.WriteByte(mrc); // 主命令码 ms.WriteByte(src); // 子命令码 if (data != null) ms.Write(data, 0, data.Length); return ms.ToArray(); } private byte[] SendCommand(byte mrc, byte src, byte[] data) { lock (_lock) { byte[] fins = BuildFinsFrame(mrc, src, data); // 包一层FINS/TCP帧 using MemoryStream ms = new MemoryStream(); ms.WriteByte(0x46); ms.WriteByte(0x49); ms.WriteByte(0x4E); ms.WriteByte(0x53); byte[] len = new byte[4]; int bodyLen = 2 + 2 + fins.Length; // 命令码2 + 错误码2 + FINS帧 len[0] = (byte)(bodyLen >> 24); len[1] = (byte)(bodyLen >> 16); len[2] = (byte)(bodyLen >> 8); len[3] = (byte)bodyLen; ms.Write(len, 0, 4); ms.WriteByte(0x00); ms.WriteByte(0x02); // 命令发送 ms.WriteByte(0x00); ms.WriteByte(0x00); // 错误码 ms.Write(fins, 0, fins.Length); byte[] sendBuf = ms.ToArray(); _stream.Write(sendBuf, 0, sendBuf.Length); byte[] resp = ReadResponse(); // 校验命令码是0003 if (resp[8] != 0x00 || resp[9] != 0x03) throw new Exception("非预期的响应命令码"); return resp; } }读DM区的方法:
public short[] ReadDm(int startAddress, int count) { byte[] data = new byte[6]; data[0] = 0x82; // DM区 data[1] = (byte)(startAddress >> 8); data[2] = (byte)(startAddress & 0xFF); data[3] = 0x00; // 位号,0=按字 data[4] = (byte)(count >> 8); data[5] = (byte)(count & 0xFF); byte[] resp = SendCommand(0x01, 0x01, data); // 去掉FINS/TCP帧头后,FINS帧的响应结构: // 10字节FINS头 + 01 01 + 错误码2字节 + 数据 int offset = 8 + 2 + 10; if (resp[offset - 2] != 0x00 || resp[offset - 1] != 0x00) throw new Exception($"PLC返回错误码: {resp[offset - 2]:X2}{resp[offset - 1]:X2}"); short[] result = new short[count]; for (int i = 0; i < count; i++) { int p = offset + i * 2; result[i] = (short)((resp[p] << 8) | resp[p + 1]); } return result; }注意读响应里的偏移计算。FINS/TCP响应帧的结构是:8字节头 + 2字节命令码 + 2字节错误码 + 10字节FINS头 + 2字节命令码 + 2字节FINS错误码 + 数据。很多人直接在resp里找数据,偏移量算错,读出来的数全是错的。我建议你拿到响应后先把FINS命令码和错误码解析出来,再根据错误码判断要不要继续解析数据。
3.3 FinsUdpClient实现要点
UDP版本简单很多,不需要握手和帧封装,直接把FINS帧发给PLC:
public class FinsUdpClient { private UdpClient _udp; private readonly object _lock = new object(); private byte _sid = 0; private IPEndPoint _remoteEp; public byte LocalNode { get; set; } = 0x0A; public byte PlcNode { get; set; } = 0x0A; public int Timeout { get; set; } = 1000; public void Connect(string ip, int port = 9600) { _remoteEp = new IPEndPoint(IPAddress.Parse(ip), port); _udp = new UdpClient(); _udp.ReceiveTimeout = Timeout; } private byte[] SendCommand(byte mrc, byte src, byte[] data) { lock (_lock) { byte[] fins = BuildFinsFrame(mrc, src, data); byte[] resp = null; for (int retry = 0; retry < 3; retry++) { _udp.Send(fins, fins.Length, _remoteEp); try { IPEndPoint remote = _remoteEp; resp = _udp.Receive(ref remote); if (resp != null && resp.Length >= 10) break; } catch (SocketException ex) { if (ex.SocketErrorCode != SocketError.TimedOut) throw; // 超时重发 } } if (resp == null) throw new TimeoutException("UDP通信超时,PLC无响应"); return resp; } } }UDP有一个关键问题:如果没有收到响应,没有任何办法区分是请求丢了还是响应丢了,所以只能靠超时重发。重发次数一般2到3次就够了,再多会加重网络负担,而且可能造成PLC侧重复执行写命令。如果你做的是写操作,重发前一定要考虑幂等性——写同一个值没问题,但如果是类似“累计加1”的指令,重发会导致重复执行。
另外一个UDP特有的注意点:接收响应时,remote可能不是你发的PLC地址(比如有人往你端口发垃圾包),所以最好校验一下返回的源地址是不是PLC的IP。稳一点的写法是在Receive之后比较remote.Address和_remoteEp.Address,不一致就不处理继续等。
3.4 数据转换的坑:字节序、位、字符串
FINS协议用的是大端字节序,也就是高字节在前。C#的BitConverter默认用的是本机字节序,Intel平台都是小端,直接拿来用会得到错误结果。
正确做法是手动组装:
public static short ToShort(byte[] data, int offset) { return (short)((data[offset] << 8) | data[offset + 1]); } public static int ToInt32(byte[] data, int offset) { return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; } public static byte[] GetBytes(short value) { return new byte[] { (byte)(value >> 8), (byte)(value & 0xFF) }; }32位数据还有一个坑:欧姆龙PLC里DINT和REAL占两个字,低地址存的是低16位,高地址存的是高16位。也就是说D100和D101组合成一个32位整数时,D100是低字,D101是高字。如果你直接用byte[]转int,必须注意字顺序:
public static int ReadDInt(byte[] highWord, byte[] lowWord) { return (highWord[0] << 24) | (highWord[1] << 16) | (lowWord[0] << 8) | lowWord[1]; }字符串处理也经常踩坑。欧姆龙PLC里字符串一般有两种存法:一种是一个字放两个ASCII字符,先放高位再放低位;另一种是Unicode格式。实际项目里,我建议你先确认PLC程序里字符串是怎么组装的,再做对应转换,不要想当然。见过不少项目因为字符串编码格式没对齐,读出来全是乱码,后面排查半天才发现是PLC侧用的是Unicode,上位机按ASCII解析了。
位读写的实现,本质上是把FINS帧里的“位号”字段从0改成具体的位号。欧姆龙的位地址写法是“字.位”,比如CIO 0.01,对应字地址0、位号1。读取时返回值一个点占1字节,0x00是False,0x01是True:
public bool[] ReadBit(byte areaCode, int wordAddress, int bitNumber, int count) { byte[] data = new byte[6]; data[0] = areaCode; data[1] = (byte)(wordAddress >> 8); data[2] = (byte)(wordAddress & 0xFF); data[3] = (byte)bitNumber; // 位号,非0表示按位访问 data[4] = (byte)(count >> 8); data[5] = (byte)(count & 0xFF); byte[] resp = SendCommand(0x01, 0x01, data); // 解析... bool[] result = new bool[count]; for (int i = 0; i < count; i++) { int p = offset + i; result[i] = resp[p] != 0x00; } return result; }写位则是把每一位打包成1字节的0x00或0x01,放在写命令的数据区。
4. 实操演示与性能调优
4.1 连接PLC前的通信参数核对清单
我每次到现场调试,第一步从来不是写代码,而是先把通信参数核对一遍。顺序固定好了,不容易漏:
- PLC的IP地址和上位机是否在同一网段,用
ping验证物理链路; - PLC的FINS节点号是多少,是否和IP最后一段一致(强烈建议一致,省得后面绕);
- PLC的FINS服务是否启用,UDP和TCP端口是不是默认的9600;
- 上位机防火墙是否放行了9600端口,Windows的防火墙默认会拦UDP入站;
- 如果交换机有端口隔离、VLAN设置,确认上位机所在VLAN能访问PLC所在VLAN。
有一次在现场折腾了两小时连不上,最后发现是笔记本连了Wi-Fi,有线网口和PLC根本不在一个网段,ping都不通。这种低级错误占了调试时间的很大比例,一定先查网络再查协议。
PLC侧的节点号设置一般在CX-Programmer或Sysmac Studio的“单元设置”里,具体路径每个型号多少有点差异,以你手上的PLC型号手册为准。设完需要复位PLC或者重启以太网单元才能生效,这点容易被忽略。
4.2 WinForm演示:读D区、写CIO位
下面是一段可以直接放进WinForm按钮事件的代码,功能是读取D100开始的50个字,显示在DataGridView里:
private void btnReadDm_Click(object sender, EventArgs e) { try { using var plc = new FinsTcpClient { LocalNode = 0x0A, PlcNode = 0x0A }; plc.Connect("192.168.250.10"); short[] values = plc.ReadDm(100, 50); dataGridView1.Rows.Clear(); for (int i = 0; i < values.Length; i++) { dataGridView1.Rows.Add($"D{100 + i}", values[i].ToString()); } } catch (Exception ex) { MessageBox.Show($"读取失败: {ex.Message}"); } }写CIO位的例子:
private void btnWriteBit_Click(object sender, EventArgs e) { try { using var plc = new FinsUdpClient { LocalNode = 0x0A, PlcNode = 0x0A }; plc.Connect("192.168.250.10"); // 写CIO 0.01 = True,0.02 = False plc.WriteBit(0x80, 0, 1, true); plc.WriteBit(0x80, 0, 2, false); } catch (Exception ex) { MessageBox.Show($"写入失败: {ex.Message}"); } }注意例子里的CIO内存区代码是0x80。不同区域的代码我整理成了表,方便你直接查:
| 区域 | 二进制模式代码 | 说明 |
|---|---|---|
| CIO | 0x80 | 最常用,I/O和内部继电器 |
| WR | 0xB1 | 工作区 |
| HR | 0x84 | 保持区,断电保持 |
| DM | 0x82 | 数据内存,按字访问 |
| IR | 0x85 | 索引寄存器 |
每次连接用完记得Dispose释放资源。如果程序里需要长时间频繁通信,不需要每次读都重连,保持着连接就行,但要注意PLC复位后TCP连接会断,得捕获异常后重连。
4.3 批量读写与轮询策略
通信性能优化里,最大的坑就是一条一条读。比如要读D100到D200共101个字,新手经常写个for循环去读101次,每次只读1个字。这样单条指令的网络往返开销全被浪费了,101次请求至少几秒钟,PLC那边也会被频繁中断搞得不胜其烦。
正确做法是一次FINS请求把连续区域全读回来:
short[] values = plc.ReadDm(100, 101); // 一次请求读101个字FINS协议的单次读写上限和PLC型号有关,一般建议不要超过960字节的数据区,字访问也就是480个点。如果点位分散在不同区域,可以分条指令读,但每条指令尽量读连续地址。
轮询策略上,我一般遵循这几个原则:
- 轮询周期不要小于PLC扫描周期的2到3倍,否则你读到的数据可能跨越多个扫描周期,逻辑上不一致;
- 用
System.Timers.Timer或者BackgroundWorker,别用Thread.Sleep死循环,关窗都关不干净; - 失败重试策略做好退避:连续失败3次后再判定断线,断线后每5
本文还有配套的精品资源,点击获取