简介:本资源是一套面向工业自动化开发者的C#与欧姆龙PLC通信实战方案,聚焦FINS协议在TCP/IP网络下的底层实现,适用于具备基础C#编程与网络通信知识的工程师、自动化专业学生及PLC集成项目开发者。资源包共45个文件,含9个核心C#源码文件(含连接管理、FINS帧构造与解析逻辑)、8张原理图与界面截图(如网络模型、协议格式、通讯手册关键页)、4个可执行测试工具(含NetAssist.exe用于FINS通信调试)、1个PDF通讯手册及1个DOCX直播教学素材,辅以配置文件、资源文件与解决方案工程(.sln/.csproj),整体压缩后仅4.38MB,轻量易部署。已有1116人学习下载,内容覆盖从TCP连接建立、FINS请求/响应帧组装、寄存器读写到错误处理的完整链路,并提供课件讲解脉络与实操验证路径,特别适合需快速落地PLC数据采集或远程监控功能的中小型工业项目参考。
1. C# 通过 FINS 协议读取欧姆龙 PLC:不是调个库就完事,而是要亲手把 TCP 帧、节点号、地址偏移、响应校验全对齐
你写好了 C# 上位机界面,拖了几个按钮和文本框,NuGet 装了System.Net.Sockets,甚至搜到某篇博客说“用 Socket 发送 0x80 0x00 0x02… 就能读 D100”,结果一运行——超时、连接拒绝、返回乱码、或干脆收不到任何字节。这不是你代码写得差,而是 FINS 协议根本不是 HTTP 那种“发请求等 JSON”的黑匣子,它是一套带状态、带层级、带地址映射、带校验规则的工业级二进制协议。本资源是一份可直接编译、可断点调试、可对照欧姆龙 CP1E/CP2E/NJ 系列手册逐字验证的 C# FINS 实战工程,含完整 TCP 封包逻辑、地址解析器(支持 DM/EM/CIO/W/R 等全部区域)、响应解析器(含错误码 0x0020/0x0030/0x0040 的精准定位)、以及真实产线已验证的重连+心跳保活机制。适合正在开发 SCADA 上位机、设备数据采集系统、或需要对接欧姆龙 PLC 的 C# 工程师——尤其当你已经卡在“能连上但读不出值”“读出的值总是错两位”“偶尔成功偶尔失败”这类玄学问题上时,这份资源就是你的后悔药。
2. FINS 协议底层结构拆解:为什么必须手写封包,而不是依赖“万能 PLC 通讯库”
FINS(Factory Interface Network Service)是欧姆龙为其 PLC 定制的二进制应用层协议,运行在 TCP/IP 之上。它不像 Modbus TCP 那样有公开标准文档,其帧格式、地址编码、错误定义均深度绑定欧姆龙硬件手册(如 W536-E1-01《FINS Protocol Reference Manual》)。市面上所谓“通用 PLC 通讯库”往往只覆盖基础读写,一旦遇到 CP2E 的 EM 区分段访问、NJ 系列的变量别名映射、或带子网掩码的多节点路由,就会翻车。本项目坚持手写封包,原因有三:
- 地址编码不可简化:欧姆龙地址如
D100、W10.01、CIO200.03并非直接内存偏移,需按区域类型(DM=0x82, CIO=0x30, W=0x31…)+ 地址高位/低位拆解为 4 字节十六进制; - 命令码与子命令强耦合:读操作固定用
0x01 0x01,但写操作分0x01 0x02(单字)、0x01 0x03(多字)、0x01 0x04(位写),且每种对应不同响应结构; - 响应校验无容错余地:FINS 响应头含 4 字节 FINS 网关路径(Gateway Node)、目标节点(Destination Node)、源节点(Source Node),任意一字节错,整个帧即被 PLC 丢弃,表现为“无响应”。
因此,本项目不封装成黑盒 API,而是暴露每一层细节,让你在调试器里亲眼看到byte[0] = 0x80(FINS 帧头)、byte[4] = 0x01(网络号)、byte[6] = 0x02(节点号)如何与 PLC 设置一一对应。
2.1 FINS 请求帧构造:从 C# 字节数组到 TCP 流的精确映射
FINS 请求帧由 12 字节固定头 + 可变体组成。以下为读取D100(1 个字,16 位)的标准帧构造逻辑,已在FinsRequestBuilder.cs中实现:
public static byte[] BuildReadCommand(string plcAddress, int wordCount = 1) { // 步骤1:解析地址 -> 获取区域码 + 地址数值 var (areaCode, addressValue) = ParsePlcAddress(plcAddress); // 如 D100 → (0x82, 100) // 步骤2:地址转为大端 4 字节(注意:欧姆龙用 Big-Endian!) byte[] addressBytes = BitConverter.GetBytes((uint)addressValue); if (BitConverter.IsLittleEndian) Array.Reverse(addressBytes); // 强制大端 // 步骤3:构造 12 字节 FINS 头 byte[] header = new byte[12]; header[0] = 0x80; // FINS 帧头 header[1] = 0x00; // 保留 header[2] = 0x02; // 命令码:读 header[3] = 0x00; // 子命令码:读字 header[4] = 0x00; // 网络号(通常 0x00) header[5] = 0x00; // 节点号(PLC 的 Node Address,非 IP!) header[6] = 0x01; // 单元号(通常 0x01,CPU 单元) header[7] = 0x01; // 目标单元号(同上) header[8] = 0x00; // 服务 ID(0x00 = FINS) header[9] = 0x00; // 保留 header[10] = 0x00; // 保留 header[11] = 0x00; // 保留 // 步骤4:拼接地址区域码 + 地址字节 + 字数(大端 2 字节) byte[] body = new byte[8]; body[0] = areaCode; // 区域码,如 DM=0x82 Array.Copy(addressBytes, 0, body, 1, 4); // 地址 4 字节 byte[] wordCountBytes = BitConverter.GetBytes((ushort)wordCount); if (BitConverter.IsLittleEndian) Array.Reverse(wordCountBytes); Array.Copy(wordCountBytes, 0, body, 5, 2); return header.Concat(body).ToArray(); }关键参数说明:
header[5](节点号)必须与 PLC 硬件设置完全一致(CP 系列在 CX-Programmer 中设置,NJ 系列在 Sysmac Studio 中设置),不是 PLC 的 IP 地址;addressBytes必须为大端序,C# 默认小端,故需Array.Reverse();body[0]的区域码严格按手册查表:D区=0x82,CIO区=0x30,W区=0x31,HR区=0x32,AR区=0x33,LR区=0x34,TIM/CNT=0x35;wordCount是字数(16 位单位),若读D100-D101则传2,而非字节数。
2.2 FINS 响应帧解析:从原始字节流中精准提取数据并校验
PLC 返回的响应帧同样含 12 字节头 + 数据体。解析核心在于:跳过头、检查错误码、按区域提取有效载荷。以下为FinsResponseParser.cs中的主解析逻辑:
public static (bool success, ushort errorCode, byte[] data) ParseReadResponse(byte[] response) { if (response.Length < 12) return (false, 0x0020, new byte[0]); // 帧长不足,视为协议错误 // 检查 FINS 响应头:byte[0] 应为 0x80,byte[2] 应为 0x00(成功响应) if (response[0] != 0x80 || response[2] != 0x00) return (false, 0x0020, new byte[0]); // 提取错误码:位于 byte[10]-byte[11],大端 ushort errorCode = BitConverter.ToUInt16(new[] { response[11], response[10] }, 0); if (errorCode != 0x0000) return (false, errorCode, new byte[0]); // 数据体起始位置:头后第 12 字节开始 int dataStartIndex = 12; int dataLength = response.Length - dataStartIndex; // FINS 读响应体结构:1 字节区域码 + 4 字节地址 + 2 字节字数 + N 字节数据 // 我们只关心最后的数据部分(dataStartIndex + 7 开始) if (dataLength < 7) return (false, 0x0030, new byte[0]); // 数据体过短 byte[] data = new byte[dataLength - 7]; Array.Copy(response, dataStartIndex + 7, data, 0, data.Length); return (true, 0x0000, data); }逻辑说明:
- 错误码
0x0020表示“命令非法”,常见于节点号错或区域码错;0x0030表示“地址范围错误”,如读D10000(超出 CP1E DM 区上限);0x0040表示“未启用 FINS 服务”,需确认 PLC 的“FINS 通信设置”已开启;response[10]和response[11]是错误码,但顺序为低字节在前、高字节在后(小端),故需new[] { response[11], response[10] }构造大端 ushort;- 数据体偏移
+7是因为响应体前 7 字节为:1 字节区域码 + 4 字节地址 + 2 字节字数,之后才是纯数据;- 若读取
D100(1 字),data.Length == 2;若读D100-D101(2 字),data.Length == 4,且为大端序排列(D100高字节, D100低字节, D101高字节, D101低字节)。
2.3 地址解析器:支持 D/W/CIO/HR/AR/LR/TIM/CNT 全区域自动转换
欧姆龙地址语法多样(D100,W10.01,CIO200.03,HR00010),手动换算易错。本项目提供PlcAddressParser.cs,将字符串地址一键转为(区域码, 地址值)元组:
public static (byte areaCode, uint addressValue) ParsePlcAddress(string address) { address = address.Trim().ToUpper(); // Dxxx 格式:D100 → DM 区,地址 100 if (address.StartsWith("D") && address.Length > 1 && IsAllDigits(address.Substring(1))) return (0x82, uint.Parse(address.Substring(1))); // Wxx.x 或 Wxx 格式:W10.01 → W 区,地址 10*16 + 1 = 161(. 后为位号,W 区按字寻址,位号转字内偏移) if (address.StartsWith("W") && address.Length > 1) { var parts = address.Substring(1).Split('.'); uint wordAddr = uint.Parse(parts[0]); uint bitOffset = parts.Length > 1 ? uint.Parse(parts[1]) : 0; return (0x31, wordAddr * 16 + bitOffset); } // CIOxxx.y 格式:CIO200.03 → CIO 区,地址 200*16 + 3 = 3203 if (address.StartsWith("CIO") && address.Length > 3) { var parts = address.Substring(3).Split('.'); uint wordAddr = uint.Parse(parts[0]); uint bitOffset = parts.Length > 1 ? uint.Parse(parts[1]) : 0; return (0x30, wordAddr * 16 + bitOffset); } // HRxxx 格式:HR00010 → HR 区,地址 10(HR 区地址为 5 位十进制,实际值 = 后 5 位) if (address.StartsWith("HR") && address.Length >= 5) { string numPart = address.Substring(2).PadLeft(5, '0').Substring(0, 5); return (0x32, uint.Parse(numPart)); } throw new ArgumentException($"不支持的地址格式: {address}"); }使用场景说明:
W10.01解析为0x31, 161,因 W 区每个字占 16 位,.01表示该字的第 1 位(Bit 1),故地址 =10 * 16 + 1 = 161;CIO200.03同理,200 * 16 + 3 = 3203,对应 CIO 区第 200 字的第 3 位;HR00010中00010即十进制 10,HR 区地址直接取数值,无需乘法;- 所有解析结果均为
uint,确保 32 位地址空间兼容(如 NJ 系列 EM 区可达 0xFFFFF)。
3. C# Socket 通讯层实现:连接管理、超时控制与异常恢复的工业级写法
工业现场网络不稳定,PLC 可能重启、交换机掉电、网线松动。一个合格的上位机绝不能“连不上就报错退出”,而应具备自动重连、心跳保活、请求队列、失败隔离能力。本项目FinsTcpClient.cs不基于TcpClient封装,而是直接使用Socket+async/await+CancellationToken,确保毫秒级响应与可控超时。
3.1 连接建立与心跳保活:避免 TCP 连接空闲被中间设备断开
欧姆龙 PLC 默认 TCP KeepAlive 时间较长(常为 2 小时),而企业防火墙/路由器常设 30 分钟空闲断连。若上位机长时间无读写,连接会被静默切断,后续请求直接失败。解决方案:主动发送 FINS 无操作命令(0x00 0x00)作为心跳。
private async Task StartHeartbeatAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) { try { // 发送 FINS 心跳帧:12 字节头 + 0 字节体(命令码 0x00 0x00) byte[] heartbeat = new byte[12]; heartbeat[0] = 0x80; heartbeat[2] = 0x00; // 心跳命令码 heartbeat[3] = 0x00; await _socket.SendAsync(new ArraySegment<byte>(heartbeat), SocketFlags.None, cancellationToken).ConfigureAwait(false); // 心跳间隔设为 25 秒,留 5 秒缓冲给网络抖动 await Task.Delay(25000, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } catch (Exception ex) when (ex is SocketException or IOException) { // 心跳失败,触发重连 _logger.LogWarning(ex, "心跳发送失败,准备重连"); await ReconnectAsync(cancellationToken).ConfigureAwait(false); } } }参数设计依据:
- 心跳帧
0x00 0x00是 FINS 协议定义的“无操作”命令,PLC 收到后仅返回成功响应,不改变任何状态;- 25 秒间隔远小于常见防火墙 30 分钟阈值,且预留 5 秒冗余,避免临界超时;
SendAsync使用CancellationToken,确保关闭时能立即退出循环,不阻塞线程。
3.2 请求超时与重试机制:防止单次请求阻塞整个采集线程
TCPSend/Receive默认无超时,若 PLC 假死或网络中断,await socket.ReceiveAsync()将永久挂起。本项目采用CancellationTokenSource控制单次请求生命周期:
public async Task<byte[]> SendRequestAsync(byte[] request, int timeoutMs = 5000, CancellationToken cancellationToken = default) { var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); cts.CancelAfter(timeoutMs); // 关键:强制超时 try { // 发送请求 await _socket.SendAsync(new ArraySegment<byte>(request), SocketFlags.None, cts.Token).ConfigureAwait(false); // 接收响应:先读 12 字节头,再读剩余长度 byte[] header = new byte[12]; int received = await ReceiveExactlyAsync(header, cts.Token).ConfigureAwait(false); if (received != 12) throw new IOException("未能接收完整 FINS 头"); // 从头中解析数据体长度(实际需读取的字节数) int dataLength = GetResponseDataLength(header); byte[] dataBody = new byte[dataLength]; received = await ReceiveExactlyAsync(dataBody, cts.Token).ConfigureAwait(false); if (received != dataLength) throw new IOException("未能接收完整 FINS 数据体"); return header.Concat(dataBody).ToArray(); } catch (OperationCanceledException) when (cts.IsCancellationRequested) { throw new TimeoutException($"FINS 请求超时({timeoutMs}ms)"); } finally { cts.Dispose(); } }关键点说明:
CancelAfter(timeoutMs)是核心,它让ReceiveExactlyAsync在超时后抛出OperationCanceledException,而非无限等待;ReceiveExactlyAsync是自定义方法,循环调用socket.ReceiveAsync()直至收满指定字节数,避免 TCP 拆包导致只收到部分帧;GetResponseDataLength(header)从响应头中提取数据体长度(FINS 协议中,header[10]和header[11]存储响应体总长,需组合解析);- 超时异常明确抛出
TimeoutException,便于上层业务做重试决策(如重试 2 次,每次间隔 500ms)。
3.3 连接状态机与自动重连:从“连不上”到“自动恢复”的全流程闭环
连接失败不应由 UI 层处理,而应由通讯层内部闭环。本项目定义ConnectionState枚举,并在ReconnectAsync中实现指数退避重试:
private async Task ReconnectAsync(CancellationToken cancellationToken) { int retryCount = 0; TimeSpan nextDelay = TimeSpan.FromMilliseconds(100); while (retryCount < 5 && !cancellationToken.IsCancellationRequested) { try { _socket?.Dispose(); _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await _socket.ConnectAsync(_plcIp, _plcPort, cancellationToken).ConfigureAwait(false); // 连接成功后启动心跳 _heartbeatCts?.Cancel(); _heartbeatCts = new CancellationTokenSource(); _ = StartHeartbeatAsync(_heartbeatCts.Token); _logger.LogInformation("FINS 连接重建成功"); return; } catch (Exception ex) when (ex is SocketException or IOException) { retryCount++; _logger.LogWarning(ex, $"第 {retryCount} 次重连失败,{nextDelay.TotalMilliseconds:F0}ms 后重试"); await Task.Delay(nextDelay, cancellationToken).ConfigureAwait(false); nextDelay = nextDelay.Add(nextDelay); // 指数退避:100ms → 200ms → 400ms... } } _logger.LogError("连续 5 次重连失败,进入离线状态"); throw new InvalidOperationException("无法连接到 PLC"); }设计哲学:
- 重试上限设为 5 次,避免无限循环耗尽资源;
- 指数退避(100ms → 200ms → 400ms → 800ms → 1600ms)减少网络风暴,给 PLC/网络设备恢复时间;
- 成功后立即启动心跳,确保连接活性;
- 所有日志使用
ILogger,方便集成 Serilog/NLog,生产环境可输出到文件或 ELK。
4. 避坑指南:FINS 通讯中 5 个血泪经验总结(现象→原因→解决)
FINS 协议看似简单,实则暗坑密布。以下 5 条均来自真实产线踩坑记录,每一条都曾导致数小时调试无果。
4.1 现象:Socket.ConnectAsync() 报错 “连接被拒绝”(10061)
原因:PLC 的 FINS 服务未启用,或 IP 地址/端口配置错误。
解决:
- 在 CX-Programmer(CP 系列)或 Sysmac Studio(NJ 系列)中,打开 PLC 参数 → “网络设置” → “FINS 通信设置”,确认“启用 FINS 通信”已勾选;
- 检查 PLC 的 IP 地址是否与上位机在同一网段(如 PLC=192.168.1.10,上位机=192.168.1.100);
- 默认端口为
9600,确认未被防火墙拦截(Windows 防火墙需放行该端口); - 终极验证:用
telnet 192.168.1.10 9600测试端口连通性,能打开黑窗即端口通。
4.2 现象:能连接,但所有读请求返回0x0020错误码
原因:FINS 请求帧中的“节点号”(header[5])与 PLC 硬件设置不匹配。
解决:
- CP 系列:在 CX-Programmer 中,PLC 参数 → “基本设置” → “节点号”(Node Address),默认为
0x01,但若多台 PLC 级联,需设为0x02、0x03等; - NJ 系统:在 Sysmac Studio 中,“控制器设置” → “网络设置” → “FINS 设置” → “节点号”,必须与代码中
header[5]一致; - 验证方法:用欧姆龙官方软件 CX-Integrator 连接同一 PLC,查看其 FINS 设置中的“目标节点号”,代码中必须完全一致。
4.3 现象:读取D100返回值总是比预期小 1 或大 1
原因:地址解析时未考虑欧姆龙的“地址偏移惯例”。
解决:
- 欧姆龙所有地址(D/W/CIO)均以
0为起始(D0是第一个字),但部分旧版手册或第三方工具误将D1当作首地址; - 本项目
ParsePlcAddress严格按手册:D100→ 地址值100,非99; - 自查:用 CX-Programmer 在线监控
D100,同时用本程序读取,对比原始字节(如0x00 0x0A= 十进制 10),确认无偏移。
4.4 现象:读取W10.01返回值正确,但W10.00总是 0
原因:W 区地址计算错误,.00被误算为10*16+0=160,但实际W10.00对应字W10的 Bit 0,地址应为10*16+0=160,没错;真正问题是——W 区按字(Word)读取,无法单独读位。
解决:
- FINS 协议不支持直接读单个位(W10.00),必须读整个字
W10(地址 160),再用 C# 位运算提取:var bit0 = (data[0] & 0x01) == 0x01;; - 若需频繁读位,应在 PLC 程序中将常用位复制到
D区(如MOV W10.00 D100),再读D100; - 注意:
W10.01的01是位号(0~15),不是字内偏移字节,W10的字节布局为W10_High(0x??), W10_Low(0x??),Bit 0 在Low字节的最低位。
4.5 现象:程序运行数小时后,读取开始随机超时或返回乱码
原因:Socket 缓冲区溢出或未及时清理接收缓存。
解决:
- 每次
ReceiveAsync后,必须确保读取完整帧(12 字节头 + 数据体),否则残留字节会污染下一次接收; - 本项目
ReceiveExactlyAsync方法已强制读满,但若自定义逻辑中漏掉此步,就会出现“半帧粘包”; - 强制规范:所有接收操作必须封装为
ReceiveExactlyAsync(byte[], int length),禁止直接用socket.Receive(); - 生产环境建议开启
Socket.NoDelay = true(禁用 Nagle 算法),避免小包合并延迟。
5. 实战验证:用真实 CP2E PLC 搭建最小闭环测试(含接线图与参数表)
理论终需落地。本章带你用一台最入门的欧姆龙 CP2E PLC(型号 CP2E-30CDR-A),5 分钟搭出可验证的物理闭环。无需复杂模块,仅需 PLC 主机 + 网线 + 笔记本电脑。
5.1 硬件接线与网络配置(零门槛)
| 设备 | 配置项 | 值 | 说明 |
|---|---|---|---|
| CP2E PLC | IP 地址 | 192.168.1.10 | 通过拨码开关或 CX-Programmer 设置 |
| CP2E PLC | 子网掩码 | 255.255.255.0 | 同上 |
| CP2E PLC | FINS 节点号 | 0x01 | CX-Programmer → PLC 参数 → 基本设置 → 节点号 |
| CP2E PLC | FINS 端口 | 9600 | 默认,无需修改 |
| 笔记本电脑 | IP 地址 | 192.168.1.100 | 手动设置,与 PLC 同网段 |
| 笔记本电脑 | 防火墙 | 关闭或放行 9600 端口 | Windows 防火墙高级设置 |
接线图一句话:PLC 的 Ethernet 口(RJ45)直连笔记本网口,无需交换机。CP2E 自带 10/100M 自适应网口,支持交叉/直连自识别。
5.2 PLC 端最小化程序(CX-Programmer 编写)
只需 2 行梯形图,让D100每秒加 1,W10的 Bit 0 每 2 秒翻转,用于验证读取:
--| |-------------------( )--- D100 TMR000 (ON DELAY 1.0s) --| |-------------------( )--- W10.00 TMR001 (ON DELAY 2.0s)TMR000设定值K10(10×0.1s=1.0s),触点驱动D100自增;TMR001设定值K20(2.0s),触点驱动W10.00翻转;- 下载程序后,PLC 运行(RUN 指示灯亮),
D100从 0 开始每秒 +1,W10的 Bit 0 每 2 秒切换 0/1。
5.3 C# 端验证代码(Program.cs 片段)
将本项目FinsTcpClient实例化,执行三次读取并打印:
var client = new FinsTcpClient(IPAddress.Parse("192.168.1.10"), 9600); await client.ConnectAsync(); // 读 D100(1 字) var d100Bytes = await client.ReadWordsAsync("D100", 1); ushort d100Value = BitConverter.ToUInt16(d100Bytes, 0); Console.WriteLine($"D100 = {d100Value}"); // 读 W10(1 字,用于提取 Bit 0) var w10Bytes = await client.ReadWordsAsync("W10", 1); ushort w10Value = BitConverter.ToUInt16(w10Bytes, 0); bool w10Bit0 = (w10Value & 0x0001) == 0x0001; Console.WriteLine($"W10.00 = {w10Bit0}"); // 写 D200 = 999 await client.WriteWordsAsync("D200", new ushort[] { 999 }); client.Dispose();预期输出:
D100 = 5 // 运行 5 秒后 W10.00 = True若输出符合预期,证明 FINS 读写全链路打通;若
D100停滞,检查 PLC 是否 RUN;若W10.00始终 False,检查TMR001是否设定正确。
5.4 故障快速定位表(一线工程师随身查)
| 现象 | 优先检查项 | 工具/命令 | 预期结果 |
|---|---|---|---|
ConnectAsync失败 | PLC IP 与电脑是否同网段 | ping 192.168.1.10 | Reply from 192.168.1.10 |
| 连接成功但读超时 | FINS 服务是否启用 | CX-Programmer → PLC 参数 → FINS 设置 | “启用 FINS 通信” 勾选 |
读返回0x0020 | 节点号是否匹配 | CX-Programmer 查节点号 vs 代码header[5] | 数值完全一致(如都是0x01) |
| 读值恒为 0 | PLC 程序是否 RUN | CX-Programmer 在线监控D100 | D100数值随时间递增 |
| 读值跳变无规律 | 网线接触不良 | 更换网线,或用Wireshark抓包 | FINS 请求/响应帧成对出现,无丢包 |
6. 进阶技巧:批量读取优化与跨平台部署(Linux + .NET 6)
当你的上位机需同时监控 200 个地址(如D0-D199),逐个ReadWordsAsync会引发 200 次 TCP 往返,延迟飙升。本项目提供ReadBulkAsync方法,将多个地址合并为单次 FINS 请求,大幅提升吞吐量。
6.1 批量读取原理:FINS 多地址读命令(0x01 0x05)的构造
FINS 协议支持一次读多个不连续地址,命令码为0x01 0x05,请求体结构为:[区域码][地址1][地址2]...[地址N][字数]。本项目FinsRequestBuilder.BuildBulkReadCommand实现如下:
public static byte[] BuildBulkReadCommand(List<(string address, int wordCount)> requests) { // 计算总字数与地址数量 int totalWords = requests.Sum(x => x.wordCount); int addressCount = requests.Count; // 头部(12 字节)+ 地址列表(每个地址 5 字节:1 区域码 + 4 地址)+ 字数(2 字节) <p> <a href="https://download.csdn.net/download/h425262167/11156710" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>