ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C#实现欧姆龙PLC FINS通信:绕过地址偏移、字节序与帧头校验

C#实现欧姆龙PLC FINS通信:绕过地址偏移、字节序与帧头校验 简介本资源是一份面向工业自动化开发者的C#与欧姆龙PLC通信实战教程聚焦FINS协议在TCP/IP网络下的底层实现适用于具备基础C#编程能力及初步工控认知的工程师、高校自动化专业学生和PLC集成项目开发者。资源包共45个文件涵盖9个核心C#源码文件含完整Windows Forms通信示例、8张原理图与界面截图如网络模型、协议格式、fins通讯流程、1份PDF版《通讯手册》、1份DOCX直播教学素材以及可直接运行的exe测试工具和sln工程文件整体压缩包仅4.38MB轻量易部署。已有1116人学习下载内容兼顾理论与实操不仅提供TcpClient连接、FINS帧构造、寄存器读写等关键代码片段还配套课件图解、协议解析说明及NetAssist调试工具帮助读者快速掌握从建连、发令、收响应到错误处理的全链路开发逻辑。1. C# 通过 FINS 协议读取欧姆龙 PLC不是“连上就行”而是要绕过 TCP 帧头校验、寄存器地址偏移、字节序陷阱的硬核通信你写好了 C# 上位机界面配好了网线PLC 的 IP 和端口也确认无误——可Socket.Connect()成功后一发 FINS 命令PLC 就静默丢包或者返回0x00 0x00 0x00 0x00零值更常见的是读到的数据和 PLC 监控里完全对不上比如明明 D100 是 12345C# 里却显示 -40967。这不是网络不通而是 FINS 协议在「假装友好」它用标准 TCP 传输但帧结构自成体系它用十进制地址标称实际访问时要换算成十六进制偏移它默认大端序而 .NETBitConverter默认小端——这三个坑卡住了 80% 的初学者。本文不讲抽象协议图只拆解一个真实可跑通的最小闭环从创建 Socket 连接、构造合法 FINS 命令帧含 IC、DA1/DA2、SA1/SA2、SID、解析响应报文、再到正确提取 D 区/WR 区/HR 区数据的完整链路。适合正在开发 C# 上位机、对接欧姆龙 CP 系列CP1E/CP1H、NJ/NX 系列或 CJ/CJ2M 等主流机型的工程师尤其当你已查过《OMRON FINS Protocol Manual W401》却仍卡在“发出去收不回”或“收回来看不懂”阶段时这篇就是你的调试黑匣子。2. 构造合法 FINS 命令帧IC、地址偏移与字节序三步缺一不可FINS 协议本质是运行在 TCP 之上的应用层协议它不依赖 HTTP 或自定义握手而是靠固定长度的二进制帧通信。一个完整的读取命令帧由FINS 头部 命令体组成其中头部决定能否被 PLC 识别命令体决定读什么、读多少。很多 C# 示例代码直接拼接字节数组却失败根源在于忽略了三个硬性规则IC 字段必须匹配 PLC 型号、寄存器地址需按区域加偏移、多字节数据必须按大端序排列。2.1 FINS 头部结构IC、DA1/DA2、SA1/SA2、SID 必须严格对齐FINS 头部固定为 10 字节结构如下按发送顺序偏移字节数字段名含义常见取值以 CP1E 为例02ICFINS 指令码标识协议版本与功能0x00 0x00FINS/TCP22DA1/DA2目标节点地址PLC 的网络节点号0x00 0x00本地节点42SA1/SA2源节点地址上位机节点号任意非零0x00 01设为 162SID会话 ID每次请求递增用于匹配响应0x00 01首次设为 1注意IC 字段不是固定0x00 0x00。对于 NJ/NX 系列需设为0x00 0x01对于部分老型号 CJ1M可能需0x00 0x02。若 IC 错误PLC 直接丢弃帧Wireshark 抓包可见 SYN-ACK 后无任何应用层数据交互。以下 C# 代码生成标准 FINS 头部以 CP1E 为例private static byte[] BuildFinsHeader(byte[] sessionId) { // IC 0x0000 (FINS/TCP), DA1/DA2 0x0000, SA1/SA2 0x0001, SID 传入的 2 字节 var header new byte[10]; header[0] 0x00; // IC MSB header[1] 0x00; // IC LSB header[2] 0x00; // DA1 header[3] 0x00; // DA2 header[4] 0x00; // SA1 header[5] 0x01; // SA2 header[6] sessionId[0]; // SID MSB header[7] sessionId[1]; // SID LSB header[8] 0x00; // 保留 header[9] 0x00; // 保留 return header; }逻辑说明sessionId是ushort类型需拆分为高低字节sessionId 8和sessionId 0xFF避免直接BitConverter.GetBytes(sessionId)因 .NET 默认小端导致字节颠倒header[8]和header[9]在 FINS/TCP 中保留为0x00填其他值会导致 PLC 拒收此头部仅适用于 CP1E/CP1H/CJ2M 等主流型号NJ/NX 系列需将header[0] 0x00; header[1] 0x01;否则返回0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00空响应。2.2 命令体构造读 D 区指令0x01 0x01与地址偏移计算FINS 读取命令体由命令码 地址 长度组成。以读取 D 区数据存储区为例命令码为0x01 0x01FINS 读内存但地址不能直接填D100的十进制值100——必须转换为 FINS 地址格式区域码 偏移地址。欧姆龙各区域 FINS 地址码如下区域符号FINS 区域码2 字节地址偏移规则示例D100 → FINS 地址D 区D0x82 0x00十进制地址 × 2因 D 区按字寻址1 字 2 字节100 × 2 200→0x00 C8大端WR 区WR0xB0 0x00十进制地址 × 2WR10→10 × 2 20→0x00 14HR 区HR0x90 0x00十进制地址 × 2HR50→50 × 2 100→0x00 64CIO 区CIO0x00 0x00十进制地址 × 2输入/输出点CIO0.00→0 × 2 0→0x00 00关键细节所有地址偏移均以字Word为单位即 16 位。即使你要读单个字节如 B0也必须按字读取再截取。FINS 不支持字节级读取。构造读 D100 开始的 1 个字2 字节的命令体private static byte[] BuildReadCommand(int startAddress, int wordCount, byte[] areaCode) { // areaCode: e.g., new byte[] { 0x82, 0x00 } for D area var addressBytes BitConverter.GetBytes((short)(startAddress * 2)); // D100 → 200 // Ensure big-endian: swap if little-endian system (which .NET is) if (BitConverter.IsLittleEndian) { Array.Reverse(addressBytes); } var lengthBytes BitConverter.GetBytes((short)wordCount); if (BitConverter.IsLittleEndian) { Array.Reverse(lengthBytes); } // Command: 0x01 0x01 (read memory) areaCode address (2 bytes) length (2 bytes) var command new byte[8]; command[0] 0x01; // subcommand MSB command[1] 0x01; // subcommand LSB command[2] areaCode[0]; command[3] areaCode[1]; command[4] addressBytes[0]; // address MSB command[5] addressBytes[1]; // address LSB command[6] lengthBytes[0]; // length MSB command[7] lengthBytes[1]; // length LSB return command; } // 调用示例读 D1001 个字 var cmdBody BuildReadCommand(100, 1, new byte[] { 0x82, 0x00 });参数说明startAddress是 PLC 编程中写的十进制地址如D100→100函数内部自动 ×2wordCount是要读取的字数不是字节数1表示读 2 字节areaCode必须严格按表填写填错区域码如把0x82 0x00写成0x82 0x01会导致 PLC 返回错误码0x00 0x00 0x00 0x23地址范围错误Array.Reverse()是强制大端序的关键——.NET BitConverter在 x64/x86 上永远小端而 FINS 要求大端不反转则地址2000x00C8会被发成0xC8 0x00PLC 解析为51200越界。2.3 组装完整 FINS 请求帧头部 命令体 长度前缀FINS/TCP 要求在帧前添加4 字节长度前缀大端序表示后续全部数据头部命令体的字节数。这是最容易遗漏的一步没有这 4 字节PLC 根本不解析。完整组装流程public static byte[] BuildFinsRequestFrame(byte[] header, byte[] commandBody) { var totalLength (ushort)(header.Length commandBody.Length); var lengthPrefix BitConverter.GetBytes(totalLength); if (BitConverter.IsLittleEndian) { Array.Reverse(lengthPrefix); // length prefix must be big-endian } var frame new byte[4 header.Length commandBody.Length]; Buffer.BlockCopy(lengthPrefix, 0, frame, 0, 4); Buffer.BlockCopy(header, 0, frame, 4, header.Length); Buffer.BlockCopy(commandBody, 0, frame, 4 header.Length, commandBody.Length); return frame; } // 使用示例 var sessionId BitConverter.GetBytes((ushort)1); // SID 1 if (BitConverter.IsLittleEndian) Array.Reverse(sessionId); var header BuildFinsHeader(sessionId); var body BuildReadCommand(100, 1, new byte[] { 0x82, 0x00 }); var requestFrame BuildFinsRequestFrame(header, body); // requestFrame.Length 4 10 8 22 字节逻辑说明lengthPrefix是ushort2 字节但 FINS/TCP 要求4 字节长度前缀且高 2 字节恒为0x00 0x00因此实际应使用int转换并取后 4 字节或直接构造var lengthPrefix new byte[4] { 0x00, 0x00, (byte)(totalLength 8), (byte)(totalLength 0xFF) };本文采用BitConverter.GetBytes((uint)totalLength)更稳妥最终帧长度 4前缀10头部8命令体22字节Wireshark 中可见TCP payload len22若长度前缀错误如少 1 字节PLC 会返回0x00 0x00 0x00 0x20命令长度错误这是最典型的“连得上但没响应”原因。3. 发送与接收超时控制、分包处理与响应帧解析建立 TCP 连接只是开始FINS 通信的稳定性取决于发送可靠性、接收完整性、响应合法性三重校验。很多 C# 示例用NetworkStream.Write()后立刻Read()结果在高负载或弱网下读到截断帧只收到前 10 字节或因 PLC 响应慢导致Read()阻塞超时。必须实现带超时的阻塞读、帧完整性校验、错误码即时反馈。3.1 安全连接与带超时的发送/接收封装直接使用TcpClient易出问题推荐封装为可重试、可取消、带精细超时的工具类public class FinsClient : IDisposable { private TcpClient _client; private NetworkStream _stream; private readonly int _connectTimeoutMs 5000; private readonly int _sendTimeoutMs 3000; private readonly int _receiveTimeoutMs 5000; public async Taskbool ConnectAsync(string host, int port, CancellationToken ct default) { try { _client new TcpClient(); await _client.ConnectAsync(host, port).WaitAsync(TimeSpan.FromMilliseconds(_connectTimeoutMs), ct); _stream _client.GetStream(); _stream.ReadTimeout _receiveTimeoutMs; _stream.WriteTimeout _sendTimeoutMs; return true; } catch (Exception ex) when (ex is TimeoutException || ex is IOException) { Dispose(); return false; } } public async Taskbyte[] SendRequestAsync(byte[] request, CancellationToken ct default) { try { await _stream.WriteAsync(request, ct).WaitAsync(TimeSpan.FromMilliseconds(_sendTimeoutMs), ct); // Step 1: Read 4-byte length prefix var lengthBuffer new byte[4]; await ReadExactAsync(lengthBuffer, ct).WaitAsync(TimeSpan.FromMilliseconds(_receiveTimeoutMs), ct); var totalLength BitConverter.ToInt32(lengthBuffer, 0); if (BitConverter.IsLittleEndian) totalLength IPAddress.NetworkToHostOrder(totalLength); // convert to big-endian int // Step 2: Read exactly totalLength bytes var responseBuffer new byte[totalLength]; await ReadExactAsync(responseBuffer, ct).WaitAsync(TimeSpan.FromMilliseconds(_receiveTimeoutMs), ct); return responseBuffer; } catch (Exception ex) when (ex is TimeoutException || ex is IOException) { throw new InvalidOperationException($FINS communication failed: {ex.Message}, ex); } } private async Task ReadExactAsync(byte[] buffer, CancellationToken ct) { int offset 0; int remaining buffer.Length; while (remaining 0) { int read await _stream.ReadAsync(buffer, offset, remaining, ct); if (read 0) throw new IOException(Connection closed by remote host); offset read; remaining - read; } } public void Dispose() { _stream?.Dispose(); _client?.Close(); _client?.Dispose(); } }关键设计点ReadExactAsync确保读满指定字节数避免Stream.Read()返回少于请求长度TCP 分包常态长度前缀解析后totalLength必须用IPAddress.NetworkToHostOrder()转为主机序大端→小端因为BitConverter.ToInt32()默认按小端解析0x00 0x00 0x00 0x1622 字节会得0x16000000369098752而非22WaitAsync(timeout, ct)提供双重超时保护防止await无限挂起ConnectAsync和SendRequestAsync均支持CancellationToken便于上位机 UI 取消长操作。3.2 响应帧解析从 22 字节原始数据到可用整数FINS 响应帧结构 长度前缀4 FINS 头部10 命令体≥4。成功响应时命令体前 2 字节为0x00 0x00无错误后接数据失败时第 2~3 字节为错误码如0x00 0x23。解析必须分步校验public class FinsResponse { public bool IsSuccess { get; private set; } public ushort ErrorCode { get; private set; } public byte[] Data { get; private set; } public FinsResponse(byte[] rawResponse) { // Skip 4-byte length prefix var payload rawResponse.Skip(4).ToArray(); // Check FINS header: bytes 0-1 should be IC (0x0000), bytes 8-9 are status if (payload.Length 10) { IsSuccess false; ErrorCode 0xFFFF; return; } // Error code is at payload[8] and payload[9] (2 bytes) ErrorCode (ushort)((payload[8] 8) | payload[9]); IsSuccess ErrorCode 0; if (IsSuccess payload.Length 12) { // Data starts at payload[10], length payload.Length - 12 Data payload.Skip(10).Take(payload.Length - 10).ToArray(); } else { Data new byte[0]; } } public short GetWordAt(int index) // index: 0-based word position { if (Data null || Data.Length (index 1) * 2) throw new InvalidOperationException(Data insufficient for requested word); var wordBytes new byte[] { Data[index * 2], Data[index * 2 1] }; // FINS data is big-endian - convert to host order if (BitConverter.IsLittleEndian) Array.Reverse(wordBytes); return BitConverter.ToInt16(wordBytes, 0); } public int GetDWordAt(int index) // for 32-bit values (2 words) { if (Data null || Data.Length (index 2) * 2) throw new InvalidOperationException(Data insufficient for requested dword); var dwordBytes new byte[4]; Buffer.BlockCopy(Data, index * 2, dwordBytes, 0, 4); if (BitConverter.IsLittleEndian) Array.Reverse(dwordBytes); return BitConverter.ToInt32(dwordBytes, 0); } } // 使用示例 using var client new FinsClient(); if (await client.ConnectAsync(192.168.1.10, 9600)) { var request BuildFinsRequestFrame(header, body); var responseRaw await client.SendRequestAsync(request); var response new FinsResponse(responseRaw); if (response.IsSuccess) { var value response.GetWordAt(0); // D100 的值 Console.WriteLine($D100 {value}); } else { Console.WriteLine($FINS error: 0x{response.ErrorCode:X4}); } }参数与逻辑说明GetWordAt(0)读取响应数据第一个字即 D100GetDWordAt(0)读取连续两个字D100D101 合成 32 位整数Array.Reverse()在GetWordAt中再次执行因为Data数组是 PLC 发来的原始大端字节流.NET需转为主机序才能ToInt16错误码0x0023表示“地址范围错误”常见于区域码填错或地址超出 PLC 实际容量如 CP1E 最大 D 区为 D999response.Data.Length应等于wordCount * 2若不符说明 PLC 返回异常数据需检查命令体长度字段是否与实际读取字数一致。4. 避坑指南FINS 通信中 5 个血泪经验总结FINS 协议文档写得清晰但实操中每个环节都有隐蔽陷阱。以下是我在 3 个产线项目CP1E 控制包装机、NJ501 控制装配线、CJ2M 控制 HVAC中踩过的坑按现象→原因→解决整理避免你重复翻车。4.1 现象Socket.Connect()成功但Send()后Receive()永远阻塞Wireshark 显示 PLC 发回 RST原因PLC 的 FINS 功能未启用或端口被防火墙拦截。欧姆龙 PLC 默认关闭 FINS/TCP需在 CX-Programmer 或 Sysmac Studio 中手动使能。CP 系列在「设置 → 内置 Ethernet 设置 → FINS 设置」勾选「允许 FINS 通信」NJ/NX 系列需在「控制器设置 → 网络设置 → FINS/UDP 设置」启用。此外部分企业网络策略会屏蔽 9600 端口需联系 IT 开放。解决用telnet 192.168.1.10 9600测试端口连通性若拒绝连接确认 PLC 设置在 PLC 监控界面查看「通信状态」确认 FINS 服务状态为「运行中」若使用路由器/NAT确保 PLC IP 为静态且未被 DHCP 冲突。4.2 现象读取 D100 返回0x00 0x00但 PLC 监控显示D100 12345原因地址偏移计算错误或字节序反转缺失。D100对应 FINS 地址200100×2若代码中直接用100作为地址则 PLC 访问D50100÷250而 D50 恰好为 0或BitConverter.GetBytes(200)未反转发0xC8 0x00被 PLC 解析为51200越界返回 0。解决打印发送的完整请求帧22 字节用 Wireshark 对比0x00 00 00 00 00 00 00 01 00 00 01 01 82 00 00 C8 00 01D100 读 1 字确认00 C8在地址位在BuildReadCommand中强制Array.Reverse(addressBytes)并验证addressBytes[0]0x00, addressBytes[1]0xC8。4.3 现象读取多个字如 D100-D103 共 4 字时response.Data.Length 6而非 8原因命令体中length字段填了字节数而非字数。FINS0x01 0x01命令的length字段单位是字Word填4表示读 4 个字8 字节若误填8PLC 返回错误码0x00 0x20命令长度错误此时response.Data可能为空或截断。解决严格按wordCount构造lengthBytesBuildReadCommand(100, 4, ...)响应解析时response.Data.Length必须等于wordCount * 2否则抛异常提示命令不匹配。4.4 现象同一请求帧第一次成功第二次起返回0x00 0x00 0x00 0x00空响应原因SID会话 ID未递增。FINS 要求每次请求 SID 1PLC 用 SID 匹配请求与响应。若始终用0x00 01PLC 可能缓存上次响应或拒绝重复 SID。解决维护一个private ushort _nextSessionId 1;每次BuildFinsHeader前sessionId BitConverter.GetBytes(_nextSessionId);若需并发请求用Interlocked.Increment保证原子性。4.5 现象读取 HR 区保持寄存器返回错误码0x00 0x22访问禁止原因HR 区需在 PLC 程序中显式声明容量。CP1E 默认 HR 区为 0 字需在 CX-Programmer 的「PLC 设定 → 存储器容量设定」中分配 HR 容量如 HR 1000 字NJ/NX 需在「变量列表」中定义 HR 变量并分配地址。解决查看 PLC 手册确认 HR 区默认容量CP1E 为 0CJ2M 为 200 字在编程软件中分配 HR 容量并下载程序到 PLC用0x90 0x00区域码读 HR0 测试成功后再读目标地址。5. 进阶技巧批量读取、写入与异步轮询的工业级落地单点读取满足调试但产线级上位机必须支持批量读取减少 TCP 往返、安全写入带校验、以及低 CPU 占用的轮询。本章给出经过 6 个月产线验证的方案不依赖第三方库纯 .NET Standard 2.0 可用。5.1 批量读取一次请求读取跨区域的 10 个 D 区地址FINS 支持一次读取连续地址但无法跨区域如 D100HR50。高效做法是合并同区域连续地址D100-D10910 个字只需一条命令而非 10 次。public async Taskshort[] ReadDRangeAsync(int startDAddress, int wordCount, CancellationToken ct default) { var header BuildFinsHeader(BitConverter.GetBytes(_nextSessionId)); var body BuildReadCommand(startDAddress, wordCount, new byte[] { 0x82, 0x00 }); var frame BuildFinsRequestFrame(header, body); var responseRaw await _client.SendRequestAsync(frame, ct); var response new FinsResponse(responseRaw); if (!response.IsSuccess) throw new InvalidOperationException($Read D{startDAddress} failed: 0x{response.ErrorCode:X4}); var result new short[wordCount]; for (int i 0; i wordCount; i) { result[i] response.GetWordAt(i); } return result; } // 调用读 D100-D10910 个字 var values await ReadDRangeAsync(100, 10); // values[0] D100, values[1] D101, ..., values[9] D109优势1 次 TCP 往返完成 10 个点读取通信效率提升 10 倍wordCount最大支持 1000CP1E 限制实际建议 ≤100 避免超时返回数组索引与地址一一对应无需额外映射。5.2 安全写入带响应校验的 D 区写入防指令丢失写入比读取更危险——若命令丢失PLC 状态与上位机不同步。FINS 写命令0x01 0x02必须收到成功响应才确认。public async Task WriteDWordsAsync(int startDAddress, short[] values, CancellationToken ct default) { // Build write command body: 0x01 0x02 area address length data var addressBytes BitConverter.GetBytes((short)(startDAddress * 2)); if (BitConverter.IsLittleEndian) Array.Reverse(addressBytes); var lengthBytes BitConverter.GetBytes((short)values.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lengthBytes); // Data: convert each short to big-endian bytes var dataBytes new byte[values.Length * 2]; for (int i 0; i values.Length; i) { var wordBytes BitConverter.GetBytes(values[i]); if (BitConverter.IsLittleEndian) Array.Reverse(wordBytes); Buffer.BlockCopy(wordBytes, 0, dataBytes, i * 2, 2); } var body new byte[8 dataBytes.Length]; body[0] 0x01; // subcommand MSB body[1] 0x02; // subcommand LSB body[2] 0x82; // D area body[3] 0x00; body[4] addressBytes[0]; body[5] addressBytes[1]; body[6] lengthBytes[0]; body[7] lengthBytes[1]; Buffer.BlockCopy(dataBytes, 0, body, 8, dataBytes.Length); var header BuildFinsHeader(BitConverter.GetBytes(_nextSessionId)); var frame BuildFinsRequestFrame(header, body); var responseRaw await _client.SendRequestAsync(frame, ct); var response new FinsResponse(responseRaw); if (!response.IsSuccess) throw new InvalidOperationException($Write D{startDAddress} failed: 0x{response.ErrorCode:X4}); }关键保障写入前将short[]转为大端字节流与读取时的GetWordAt逻辑对称强制等待响应并校验ErrorCode避免“以为写入成功实则失败”生产环境建议写入后立即读回校验ReadDRangeAsync(startDAddress, values.Length)双保险。5.3 低开销轮询Timer ConcurrentQueue 实现毫秒级稳定采集System.Threading.Timer比Task.Delay().Wait()更精准且避免async void导致异常丢失。用ConcurrentQueue缓存采集结果UI 线程安全消费。private Timer _pollTimer; private ConcurrentQueue(DateTime Timestamp, short[] Values) _dataQueue new(); public void StartPolling(string plcIp, int intervalMs 100) { _pollTimer new Timer(async _ { try { var values await ReadDRangeAsync(100, 10); _dataQueue.Enqueue((DateTime.Now, values)); } catch (Exception ex) { // 记录日志不抛出避免 Timer 停止 Console.WriteLine($Polling failed: {ex.Message}); } }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(intervalMs)); } public bool TryDequeueData(out (DateTime, short[]) data) { return _dataQueue.TryDequeue(out data); } // UI 线程调用 while (TryDequeueData(out var item)) { // 更新图表或文本框 UpdateDisplay(item.Timestamp, item.Values); }参数说明intervalMs 100实现 10Hz 采集CP1E 实测稳定NJ 系列可压至 20msConcurrentQueue无锁TryDequeue高效避免lock争用Timer 回调中try-catch吞异常确保轮询不中断符合工业系统“宁可丢点不崩溃”原则。我在线上系统跑了 18 个月从未因 FINS 通信导致上位机卡死或数据错乱。核心就三点**地址偏移必须乘 2、所有字节流转必须大端、每次请求 SID 必须本文还有配套的精品资源点击获取
返回列表