
简介这份资源是面向.NET开发者与工业自动化工程师的OPC UA通信入门实战Demo针对与PLC等设备进行安全数据交互的场景覆盖连接、断开、读写、订阅及心跳监听等核心操作适合已具备C#基础、希望快速上手OPC UA协议的中级开发者参考。压缩包共约2000个文件以1218个xml配置与说明、252个无扩展名文件、112个txt文档、28个nupkg依赖包及10个cs源码为主另含dll、exe、pdb等运行与调试组件整体约87.75MB结构完整、依赖齐全。目前已有677人学习下载说明其在同类示例中具备一定参考价值。读者可从中获得客户端连接与安全配置、节点读写调用、订阅监视项创建及心跳回调处理的完整代码脉络并借助项目文件与依赖包直接还原运行环境减少自行摸索库配置与证书验证的排错成本为后续在物联网或产线数据采集项目中落地OPC UA通信提供可复用的实践基础。1. 从一台连不上的设备说起这个 .Net OPC UA Demo 到底能解决什么产线上新到一台支持 OPC UA 的控制器IP 通了、端口 4840 也开着可上位机就是拿不到数据。现场最常见的排查路径是先怀疑网络再怀疑证书最后才怀疑自己代码里Session建了没关、订阅没释放。我见过太多团队卡在这一步——协议栈本身没问题问题出在“连接生命周期”和“订阅回调”这两块没人写对过。这份 .Net OPC UA 通信 Demo 就是冲着这个场景来的它把连接、断开、读写、订阅、监听心跳五件事拆成可运行的最小单元用 .Net 生态里成熟的 OPC UA 客户端库把每条链路跑通。适合谁正在做上位机、数据采集网关、设备联网改造的 .Net 开发者尤其是第一次接触 OPC UA、需要一份能对照着改的骨架代码的人。它不教你协议规范但能让你在半天内把“连上、读到值、收到变化通知”这条主线走完。2. 连接与断开Session 生命周期里的三个关键参数OPC UA 的“连接”不是一条 TCP 长连接那么简单。客户端要先建立安全通道SecureChannel再在其上创建会话Session会话里再挂订阅和读写请求。Demo 里连接部分通常封装成一个客户端类核心是Session.Create这一句。很多人翻车不是因为不会调 API而是没搞清Session和SecureChannel是两层东西Session关闭了底层通道可能还挂着通道断了Session对象却还在内存里装活着。2.1 先选对客户端库再谈连接.Net 下做 OPC UA常见选择是官方参考栈移植版如 OPCFoundation.NetStandard.Opc.Ua和第三方封装库。前者贴近规范、可控性强后者上手快但抽象层多。Demo 用的是前者思路因为它对Session、Subscription、MonitoredItem的暴露更直接排错时能看到底层状态码。选型时我一般看两点是否支持 .Net Standard 2.0 以上决定能不能跑在 .Net Framework 和 .Net Core 双栈以及是否自带证书自动生成。下面这段是连接的核心骨架参数含义逐行说明。// 创建应用配置应用名、证书存放目录、安全策略 var config new ApplicationConfiguration { ApplicationName OpcUaDemoClient, ApplicationUri $urn:{Dns.GetHostName()}:OpcUaDemoClient, SecurityConfiguration new SecurityConfiguration { // 客户端证书首次运行自动生成后续复用 ApplicationCertificate new CertificateIdentifier { StoreType Directory, StorePath OPC Foundation/CertificateStores/MachineDefault, SubjectName CNOpcUaDemoClient }, // 信任列表服务端证书要进这里否则握手被拒 TrustedIssuerCertificates new CertificateTrustList { StoreType Directory, StorePath ... }, TrustedPeerCertificates new CertificateTrustList { StoreType Directory, StorePath ... }, // 自动接受不受信证书仅调试期开生产必须关 AutoAcceptUntrustedCertificates true }, TransportQuotas new TransportQuotas { OperationTimeout 15000 }, ClientConfiguration new ClientConfiguration { DefaultSessionTimeout 60000 } }; await config.Validate(ApplicationType.Client); // 选端点SecurityPolicy 和 UserTokenType 必须与服务端匹配 var endpointDescription CoreClientUtils.SelectEndpoint(config, opc.tcp://192.168.1.10:4840, useSecurity: true); var endpointConfiguration EndpointConfiguration.Create(config); var endpoint new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration); // 建会话sessionTimeout 单位毫秒identity 为空表示匿名 var session await Session.Create( config, endpoint, updateBeforeConnect: false, sessionName: DemoSession, sessionTimeout: 60000, identity: new UserIdentity(), preferredLocales: null);这段代码里最容易被忽略的是updateBeforeConnect和sessionTimeout。前者设为false表示直接用本地缓存的端点信息避免每次连接都去服务端拉一遍端点列表——在设备端响应慢的时候这一步能省好几秒。后者是会话保活窗口服务端会在这个时间内等不到活动就回收会话设太短会导致空闲时被踢设太长则断线后服务端资源迟迟不释放。我一般按采集周期来定采集间隔 1 秒的会话超时给 60 秒足够采集间隔 30 秒的至少给到 180 秒。2.2 断开不是一句 Close 就完事断开环节的坑比连接还多。Session.Close()是异步的如果直接session.Dispose()而不等关闭完成服务端可能残留一个“僵尸会话”下次连接时设备端会话数打满直接拒绝新连接。Demo 里断开的标准动作是三步先删订阅、再关会话、最后释放通道。// 正确断开顺序订阅 - 会话 - 通道 if (subscription ! null) { await subscription.DeleteAsync(true); // true 表示删除监控项 subscription.Dispose(); } if (session ! null) { await session.CloseAsync(); // 等关闭完成别用 Close() session.Dispose(); } // 通道由 Session 内部管理Dispose 后自动释放DeleteAsync(true)的参数是silent传true表示服务端不返回每个监控项的删除结果减少一次往返。调试阶段可以传false能看到哪些监控项删除失败。CloseAsync之后必须Dispose否则Session对象持有的通道资源不会释放。这里有个血泪经验如果服务端是嵌入式设备会话数上限往往只有 5 到 10 个调试时反复启停客户端而不正确关闭几分钟就能把会话槽位占满现象是“昨天还能连今天连不上”重启设备才好——其实重启的是服务端会话表。3. 读写与订阅从单点读取到变化通知的完整链路读写是 OPC UA 最基础的操作但“读”和“订阅”解决的是两类问题读是主动拉订阅是服务端推。Demo 把两者分开实现正好能看清各自的适用边界。读适合低频、按需取值的场景比如点一下“刷新”按钮订阅适合持续监控比如温度越限报警。很多新手用轮询读去模拟订阅结果采集周期一密服务端 CPU 就上去了这是典型的用错机制。3.1 读写操作的节点 ID 与数据类型映射OPC UA 的读写都围绕 NodeId 展开。NodeId 有三种常见写法数字型ns2;i1001、字符串型ns2;sTemperature、GUID 型。Demo 里用字符串型居多因为可读性好但要注意命名空间索引ns不是固定的不同服务端可能把自定义节点放在ns2或ns3硬编码索引是常见翻车点。稳妥做法是先读Server_NamespaceArray拿到命名空间 URI 列表再按 URI 反查索引。// 读单个节点指定 NodeId 和期望的数据类型 var nodeId new NodeId(ns2;sMachine1.Temperature, 2); var readValue new ReadValueId { NodeId nodeId, AttributeId Attributes.Value // 读值属性也可读 DisplayName 等 }; var response await session.ReadAsync( null, 0, TimestampsToReturn.Both, new ReadValueIdCollection { readValue }); // 取结果StatusCode 为 Good 才取值类型转换要防异常 DataValue dv response.Results[0]; if (StatusCode.IsGood(dv.StatusCode)) { double temp Convert.ToDouble(dv.Value); // 服务端可能返回 float/int }TimestampsToReturn.Both表示同时要源时间戳和服务端时间戳做数据追溯时两个都要留。Convert.ToDouble这步别省OPC UA 的数值类型可能是float、int、short直接强转会抛InvalidCastException。写操作同理但多一个DataValue包装且要检查写结果的StatusCode——写失败不抛异常只回状态码不检查就等于没写。// 写单个节点值、类型、时间戳都要给 var writeValue new WriteValue { NodeId nodeId, AttributeId Attributes.Value, Value new DataValue(new Variant(25.5), StatusCodes.Good, DateTime.UtcNow) }; var writeResponse await session.WriteAsync(null, new WriteValueCollection { writeValue }, CancellationToken.None); if (StatusCode.IsBad(writeResponse.Results[0])) { // 常见 Bad 码BadUserAccessDenied权限、BadTypeMismatch类型不符 Console.WriteLine($写入失败: {writeResponse.Results[0]}); }Variant包装值的时候类型必须和服务端节点定义一致。服务端节点是Float你塞Double回的就是BadTypeMismatch。这个错误码在调试时很常见但日志里往往只打一个数字不查状态码表根本看不懂。3.2 订阅与监听MonitoredItem 的采样与队列参数订阅是这份 Demo 里最有价值的部分也是最容易配错的部分。一个订阅Subscription下挂多个监控项MonitoredItem每个监控项有自己的采样间隔和队列长度。服务端按采样间隔去读节点值值变了就进队列客户端按发布间隔批量取走。参数配不好要么丢变化要么把服务端压垮。// 建订阅发布间隔 1000ms保持存活计数 10 var subscription new Subscription(session.DefaultSubscription) { PublishingInterval 1000, // 客户端取数据的周期 KeepAliveCount 10, // 连续 10 个周期无数据则发心跳 LifetimeCount 30, // 连续 30 个周期无响应则订阅失效 MaxNotificationsPerPublish 100 // 单次发布最多带多少条通知 }; session.AddSubscription(subscription); await subscription.CreateAsync(); // 挂监控项采样间隔 500ms队列长度 5 var monitoredItem new MonitoredItem(subscription.DefaultItem) { StartNodeId new NodeId(ns2;sMachine1.Temperature, 2), AttributeId Attributes.Value, SamplingInterval 500, // 服务端采样周期-1 表示跟随发布间隔 QueueSize 5, // 缓冲最近 5 个变化值 DiscardOldest true // 队列满时丢最旧的 }; monitoredItem.Notification OnDataChange; // 变化回调 subscription.AddItem(monitoredItem); await subscription.ApplyChangesAsync();PublishingInterval和SamplingInterval的关系要理清采样是服务端的事发布是客户端取走的事。采样 500ms、发布 1000ms意味着服务端每 500ms 看一次值攒到 1000ms 一起发给客户端。QueueSize设小了快速变化的值会被覆盖设大了内存和网络开销上去。我一般按“变化频率 × 可接受延迟”来估温度这种秒级变化的队列 5 够用开关量抖动频繁的队列给到 10 到 20配合DiscardOldest保证拿到最新值。回调里别做耗时操作Notification是在库的线程上触发的阻塞久了会影响后续通知的接收。3.3 心跳监听KeepAlive 与连接状态的区分“监听心跳”这个说法在 OPC UA 里对应的是 KeepAlive 机制但它和 TCP 心跳不是一回事。订阅的KeepAliveCount控制的是连续多少个发布周期没有数据变化服务端就发一个空的 KeepAlive 通知告诉客户端“我还活着”。客户端收到 KeepAlive 说明订阅正常收不到且超过LifetimeCount订阅就失效了。Demo 里通常会在KeepAlive事件或PublishStatusChanged里更新一个“最后活跃时间”外部用定时器判断这个时间是否超阈值。// 订阅状态变化区分 KeepAlive 和真正的断线 subscription.PublishStatusChanged (s, e) { if (e.Status.Error) { // 发布失败可能是会话断了也可能是服务端忙 lastError e.Status.ToString(); } }; subscription.KeepAlive (s, e) { lastKeepAlive DateTime.UtcNow; // 收到心跳刷新活跃时间 }; // 外部健康检查超过 3 倍发布间隔没心跳就判定异常 var isAlive (DateTime.UtcNow - lastKeepAlive).TotalMilliseconds 3000;这里的关键认知是KeepAlive 正常不代表会话一定健康会话断了订阅自然收不到心跳但订阅失效也可能是服务端主动删了监控项。所以健康检查要分两层——先看Session.Connected再看lastKeepAlive。只看其中一个都会出现“以为连着其实早断了”的假象。4. 避坑与排查五个让 Demo 跑不起来的真实原因4.1 现象连接报 BadSecurityChecksFailed换匿名也不行原因多半是证书信任没配好。OPC UA 客户端和服务端握手时要互验证书客户端证书没进服务端的信任列表或者服务端证书没进客户端的信任列表都会回这个码。AutoAcceptUntrustedCertificates true只在客户端侧自动接受服务端证书服务端那边不认你的客户端证书照样拒。解决调试期先把两端证书目录互相拷贝进trusted目录生产环境走正式的证书签发流程。别长期开着自动接受那等于把安全层关了。4.2 现象读节点返回 BadNodeIdUnknown但用 UaExpert 能读到原因通常是命名空间索引写错了。UaExpert 里看到的ns2是它自己解析后的展示你的代码里硬编码2而服务端实际把该节点放在ns3就对不上。解决启动时先读Server_NamespaceArray按 URI 匹配出真实索引再拼 NodeId。这一步花不了几行代码但能省掉大量“明明节点存在却读不到”的困惑。4.3 现象订阅建成功但回调一次都不触发原因有两个常见方向一是SamplingInterval设成了-1且发布间隔很长服务端按发布周期采样变化被合并二是监控项加进订阅后没调ApplyChangesAsync配置没真正下发。解决先确认ApplyChangesAsync被 await 了再把采样间隔设成明确值比如 500ms测试。如果还不触发去服务端侧看监控项是否真的创建成功有些设备端对监控项数量有限制超了会静默丢弃。4.4 现象跑一段时间后服务端拒绝新连接提示会话数超限原因就是前面说的会话没正确关闭。调试时反复 F5 启动每次Session.Create都新建会话旧的没CloseAsync就随进程结束服务端要等sessionTimeout到期才回收。解决把断开逻辑放进finally块确保异常路径也执行调试期把sessionTimeout调短到 30 秒加快回收。更彻底的做法是客户端启动时先枚举并清理自己名下的残留会话但这需要服务端支持。4.5 现象写入返回 Good但设备值没变原因可能是写到了错误的属性或者服务端节点是只读的但状态码没如实返回。有些设备端实现不规范只读节点写入也回 Good实际不生效。解决写完立刻回读一次比对别信写操作的返回码。回读不一致就查节点AccessLevel属性确认是否可写。这个习惯我在对接非标设备时强制保留能挡掉一批“假成功”。5. 把 Demo 改成能上生产的骨架三个必须补的工程化动作Demo 跑通只是起点直接拿去接产线会出问题。我在实际项目里会补三件事这里说清楚怎么改。第一件是重连策略。Session断开后不会自动重连得自己监听Session.KeepAlive事件里的SessionDisconnected状态触发一个带退避的重连循环。退避别用固定间隔第一次 1 秒、第二次 2 秒、封顶 30 秒避免服务端刚恢复就被一堆客户端同时打爆。// 带指数退避的重连骨架 async Task ReconnectWithBackoff(Session session, int maxRetry 10) { int delay 1000; for (int i 0; i maxRetry; i) { try { await session.ReconnectAsync(); if (session.Connected) return; // 重连成功 } catch { /* 记录日志继续退避 */ } await Task.Delay(delay); delay Math.Min(delay * 2, 30000); // 翻倍封顶 30 秒 } }第二件是订阅的自动重建。会话重连后原来的订阅对象已经失效必须重新CreateAsync并重新挂监控项。把订阅配置抽成一个列表重连成功后遍历重建比在每个回调里写恢复逻辑干净得多。第三件是时间戳统一。读写和订阅拿到的DataValue带源时间戳和服务端时间戳入库前统一转 UTC 并记录来源否则跨时区部署时数据对不上。我一般会在DataValue落库前加一层转换把SourceTimestamp和ServerTimestamp都保留排查时序问题时这两个字段能救命。验证方法很简单把服务端网线拔掉 30 秒再插回观察客户端是否在退避窗口内自动恢复、订阅是否重建、期间的数据是否有断档标记。这个动作我每次交付前都强制走一遍比看日志猜状态靠谱。从那以后我每次接新的 OPC UA 设备都先把“拔线重连”当验收项跑一遍跑不过就不往下做业务逻辑。希望帮到你。本文还有配套的精品资源点击获取