
简介面向工业自动化与物联网开发者的 .NET OPC UA 通信 Demo围绕客户端与 PLC、OPC UA 服务器的实际交互展开覆盖建立连接、安全断开、节点读写、数据订阅与心跳监听等核心环节适合具备 C# 基础、希望快速理解 OPC UA 客户端开发流程的开发者作为练习样例和联调参考。压缩包共 2000 个文件整体约 87.75MB以 xml 配置与说明、dll 依赖库、txt 文档、nupkg 包及少量 cs 源码、config 配置和 exe 运行文件为主较完整地保留了典型 .NET OPC UA 工程的依赖与目录结构。已有 678 人学习表明该主题在工业通信学习中具有一定关注度。读者可从中梳理客户端初始化、证书与身份验证设置、订阅对象创建、监视项添加、读写调用及心跳回调的实现思路并借助配置文件和依赖清单理解工程组织方式为后续对接 PLC、排查连接与订阅异常提供可复用参考。1. 从一条模拟产线说起.NET OPC UA Demo 到底覆盖哪五个动作设备侧已经开了 OPC UA 服务上位机却还在用定时轮询数据库这种架构在模拟产线里很常见。这个 .NET OPC UA Demo 把客户端最常遇到的五个动作拆开了连接、断开、读写、订阅、监听心跳。它适合刚接触 .netOPCUA 的开发者也适合手里已有服务端、需要快速验证点位和断线行为的熟手。代码不追求大而全重点是把 Session、Subscription、MonitoredItem 和 KeepAlive 这几条线跑通让人能对照自己的现场参数改。拿到 Demo 后别急着接真实设备先把本机端点、匿名/用户名身份、安全策略跑一遍再换成自己的 NodeId坑会少很多。2. 连接与断开Session 超时、安全策略和重连骨架怎么落地连接是整套 Demo 的第一道门槛。很多人把 Endpoint URL 填对就以为成功了结果卡在证书、用户身份或 Session 超时上。OPC UA 客户端连接不是简单开一个 Socket它要发现端点、协商安全策略、创建会话再维持 KeepAlive。常见做法是先把连接参数集中到一个配置对象里别散落在按钮事件中。下面按端点发现、Session 创建、关闭顺序、断线重连四块拆开。2.1 先定连接参数Endpoint、SecurityPolicy、UserIdentityEndpoint 一般写成opc.tcp://127.0.0.1:4840生产环境换成服务端实际地址。SecurityPolicy 在调试阶段常用 None但只要跨机器或上生产Basic256Sha256 这类策略就要把客户端证书加入服务端信任列表。UserIdentity 分匿名和用户名两种匿名适合内网调试用户名要确认服务端是否允许该账户读节点。SessionTimeout 我一般先给 60 秒OperationTimeout 给 10 秒KeepAliveInterval 给 5 秒再根据网络抖动调整。// 加载客户端配置并检查应用实例证书 var app new ApplicationInstance { ApplicationName OpcUaDemoClient, ApplicationType ApplicationType.Client, ConfigSectionName OpcUaDemoClient }; var config await app.LoadApplicationConfiguration(OpcUaDemoClient.Config.xml, false); await app.CheckApplicationInstanceCertificate(false, 2048); var endpointUrl opc.tcp://127.0.0.1:4840; var useSecurity true; // 发现服务端端点15000 是发现超时不是会话超时 var endpointDescription CoreClientUtils.SelectEndpoint(config, endpointUrl, useSecurity, 15000); var endpointConfiguration EndpointConfiguration.Create(config); var endpoint new ConfiguredEndpoint(null, endpointDescription, endpointConfiguration);这段代码先加载OpcUaDemoClient.Config.xml再检查客户端证书。CheckApplicationInstanceCertificate(false, 2048)里的2048是证书密钥长度不同维护分支的重载可能略有差异按 IntelliSense 调整即可。CoreClientUtils.SelectEndpoint的第三个参数决定是否走安全通道调试时设false能快速排除证书问题但不要把这个开关带到生产。ConfiguredEndpoint把发现结果包装成后续Session.Create可用的对象。参数上最容易翻车的是把发现超时当成 Session 超时结果服务端稍慢就误判连接失败。2.2 建立 Session 与关闭 Session 的顺序Session 创建时要把身份、会话名、超时一起传进去。会话名不要每次随机生成否则服务端会话列表会很难排查。创建成功后立刻设置OperationTimeout和KeepAliveInterval再挂 KeepAlive 事件。关闭时先移除订阅再关会话最后 Dispose顺序反了容易出现回调还在跑、通道已经断的情况。// 匿名身份用户名身份改成 new UserIdentity(user, password) IUserIdentity identity new UserIdentity(); var session await Session.Create( config, endpoint, false, OpcUaDemoSession, 60_000, identity, null); session.OperationTimeout 10_000; session.KeepAliveInterval 5_000; session.KeepAlive OnSessionKeepAlive; // 关闭顺序先摘订阅再关会话最后释放 try { if (subscription ! null) session.RemoveSubscription(subscription); if (session.Connected) session.Close(); } finally { session.Dispose(); }Session.Create的参数里60_000是请求的会话超时单位毫秒false表示连接前不再做一次端点更新按库版本重载可能不同。OperationTimeout控制单次读写等待时间KeepAliveInterval控制客户端向服务端发 KeepAlive 的间隔。关闭阶段先RemoveSubscription是为了停止通知回调再Close让服务端释放会话最后Dispose释放通道。只把变量置 null 不会立刻释放会话服务端会话数被占满时下一次连接就会报错。2.3 断线重连不是循环 new Session断线后最忌讳在 KeepAlive 事件里直接new Session。旧订阅、旧 MonitoredItem、旧回调都还在循环几次就会出现重复通知和句柄泄漏。正确做法是维护一个重连标志失败后做退避再调用会话自带的重连能力。重连期间界面状态应该从 Connected 切到 Reconnecting而不是假装还连着。private int _reconnecting; private DateTime _lastKeepAliveUtc DateTime.UtcNow; private async Task OnKeepAliveAsync(Session session, KeepAliveEventArgs e) { if (!ServiceResult.IsBad(e.Status)) { _lastKeepAliveUtc DateTime.UtcNow; return; } // 同一时间只允许一个重连流程 if (Interlocked.Exchange(ref _reconnecting, 1) 1) return; try { await Task.Delay(TimeSpan.FromSeconds(2)); await session.ReconnectAsync(); _lastKeepAliveUtc DateTime.UtcNow; } catch (Exception ex) { // 记录异常下一轮定时器再试不要在这里死循环 Console.WriteLine(ex.Message); } finally { Interlocked.Exchange(ref _reconnecting, 0); } }ServiceResult.IsBad(e.Status)表示协议层 KeepAlive 已经异常此时先等 2 秒再重连避免服务端刚重启就被客户端打满。Interlocked.Exchange是防止 KeepAlive 回调重入的简单办法。session.ReconnectAsync()在部分库里是Reconnect()按实际引用调整。重连成功之后还要检查订阅是否恢复不能只看session.Connected。我一般会在重连后主动读一次心跳节点确认应用层也活了再恢复界面状态。3. 读写节点NodeId、DataValue 与批量读写的类型边界读写看起来只是两个方法真正花时间的是 NodeId 和数据类型。服务端的 namespace index 可能随工程版本变化写死ns2很容易在某次升级后全部读空。另一个高频问题是写入类型不匹配客户端写float服务端节点是double返回 BadTypeMismatch但界面上只看到一个失败没有细节。下面从 NodeId 确认、单点读写、批量读写三个层面落地。3.1 NodeId 的三种写法与 Browse 确认NodeId 常见有数字、字符串、GUID 三类。数字写法如i2258字符串写法如ns2;sMachine/Line1/SpeedGUID 多用于复杂设备。数字节点稳定但可读性差字符串节点可读性好但 namespace index 要确认。动手前先用 Browse 或服务端工具看一眼节点树确认ns和s部分不要凭记忆写。// 字符串节点ns2 要和服务端命名空间表一致 NodeId speedNode NodeId.Parse(ns2;sMachine/Line1/Speed); // 标准节点示例ServerStatus 常见映射为 i2256 NodeId serverStatusNode new NodeId(2256); // 浏览 Objects 下的组织节点确认实际 NodeId var browseDescription new BrowseDescription { NodeId ObjectIds.ObjectsFolder, BrowseDirection BrowseDirection.Forward, ReferenceTypeId ReferenceTypeIds.Organizes, IncludeSubtypes true, NodeClassMask (uint)NodeClass.Variable | (uint)NodeClass.Object, ResultMask (uint)BrowseResultMask.All }; var browseResponse session.Browse( null, null, 0, new[] { browseDescription });NodeId.Parse适合字符串节点new NodeId(2256)适合标准数字节点。Browse 里的BrowseDirection.Forward表示往下层找ReferenceTypeIds.Organizes是组织关系IncludeSubtypes允许子类型。NodeClassMask只保留变量和对象避免把方法节点也刷出来。浏览结果里的DisplayName和NodeId要一起看别只抄显示名。服务端命名空间表变化时最稳的办法是启动时 Browse 一次把业务节点映射到配置里而不是每次读写都重新猜。3.2 ReadValue 与 WriteValue 的同步写法单点读写的关键是看StatusCode不要只看返回值是不是 null。DataValue里还有 SourceTimestamp 和 ServerTimestamp做趋势和心跳时要用到。写值之前先读一次节点的 DataType 属性确认 Variant 类型尤其是浮点、整型和枚举。var readIds new ReadValueIdCollection { new ReadValueId { NodeId speedNode, AttributeId Attributes.Value } }; session.Read( null, 0, TimestampsToReturn.Both, readIds, out DataValueCollection readResults, out DiagnosticInfoCollection readDiagnostics); foreach (var dv in readResults) { if (StatusCode.IsBad(dv.StatusCode)) Console.WriteLine($读失败: {dv.StatusCode}); Console.WriteLine(${dv.Value} {dv.SourceTimestamp}); } var writeValues new WriteValueCollection { new WriteValue { NodeId speedNode, AttributeId Attributes.Value, Value new DataValue(new Variant(12.5)) } }; session.Write( null, writeValues, out StatusCodeCollection writeStatuses, out DiagnosticInfoCollection writeDiagnostics); foreach (var status in writeStatuses) Console.WriteLine(status);读方法里的TimestampsToReturn.Both表示同时要源时间戳和服务端时间戳做数据追溯时有用。Attributes.Value只读值属性如果要读数据类型改成Attributes.DataType。写方法里的new Variant(12.5)生成 Double 类型如果服务端节点是 Float就会类型不匹配。writeStatuses要逐个检查不要只判断集合数量。诊断信息writeDiagnostics在批量写失败时能定位到具体节点调试阶段不要省略。3.3 批量读写与类型不匹配的翻车点批量读写能减少往返但一次塞几千个节点会撞上服务端消息大小限制。我一般按 200 个一批先小批量验证再按现场调整。类型不匹配的排查顺序是读Attributes.DataType、读Attributes.ValueRank、对照写入的 Variant 类型。数组节点还要注意维度单值写数组节点会直接失败。const int batchSize 200; for (int offset 0; offset allReadIds.Count; offset batchSize) { var batch new ReadValueIdCollection( allReadIds.Skip(offset).Take(batchSize)); var response session.Read( null, 0, TimestampsToReturn.Both, batch); foreach (var dv in response.Results) { if (StatusCode.IsBad(dv.StatusCode)) { // 记录 NodeId 和状态码别只记“读失败” Console.WriteLine(dv.StatusCode); } } }batchSize设 200 是经验值不是协议硬限制服务端消息上限小的时候要往下调。Skip/Take只适合内存集合节点特别多时改成游标分批。类型不匹配不要靠猜读Attributes.DataType拿到的是类型 NodeId再对照服务端文档或 Browse 结果。写入数组时new Variant(new double[] { ... })和单值new Variant(12.5)是两种类型别混用。批量写失败时先看writeDiagnostics再看单个节点的 StatusCode能省很多时间。4. 订阅与心跳MonitoredItem 参数和看门狗的两条实现路径订阅是 OPC UA 客户端最值得用的能力。轮询读在低频点位上看不出问题一到几百个点位、几百毫秒刷新网络请求就会堆起来。订阅由服务端按 PublishingInterval 推送客户端只负责处理通知。心跳则要分两层看协议层 KeepAlive 说明会话还活着应用层 ServerStatus 或自定义心跳变量说明服务端业务线程还在刷新。两层都看才不会把“会话没断但数据停了”误判成正常。4.1 为什么轮询读不如订阅轮询读的节奏由客户端控制读 500 个点位就要发 500 个读请求或者拼一个大请求等返回。订阅把采样和队列放在服务端客户端只接收变化通知PublishingInterval 控制推送节奏SamplingInterval 控制采样节奏。对于温度、速度、状态字这种连续变化的点位订阅的实时性更好网络开销也更稳定。常见做法是把高频点位放进一个 Subscription低频诊断点位放另一个别把所有点位塞进同一个发布周期。4.2 创建 Subscription 和 MonitoredItem 的参数Subscription 的参数里PublishingInterval 是发布间隔KeepAliveCount 是连续多少个发布周期没有通知时服务端发一个空 KeepAliveLifetimeCount 是订阅生命周期。MonitoredItem 的 SamplingInterval 是采样间隔QueueSize 是队列长度DiscardOldest 决定队列满时丢旧值还是丢新值。创建顺序是先把 Subscription 挂到 Session再 Create再 AddItem最后 ApplyChanges。var subscription new Subscription(session.DefaultSubscription) { DisplayName Line1Subscription, PublishingInterval 500, KeepAliveCount 10, LifetimeCount 30, MaxNotificationsPerPublish 1000, PublishingEnabled true, Priority 0 }; session.AddSubscription(subscription); subscription.Create(); var item new MonitoredItem(subscription.DefaultItem) { DisplayName Line1Speed, StartNodeId speedNode, AttributeId Attributes.Value, MonitoringMode MonitoringMode.Reporting, SamplingInterval 200, QueueSize 10, DiscardOldest true }; item.Notification OnMonitoredItemNotification; subscription.AddItem(item); subscription.ApplyChanges();PublishingInterval 500表示每 500 毫秒发布一次SamplingInterval 200表示服务端按 200 毫秒采样。KeepAliveCount 10和LifetimeCount 30的关系要满足服务端约束LifetimeCount 通常要大于 KeepAliveCount 的若干倍。QueueSize 10配合DiscardOldest true适合只关心最新值的场景如果要做趋势队列要加大并保留旧值。MonitoringMode.Reporting才会产生通知设成 Disabled 或 Sampling 时回调不会按预期来。ApplyChanges别漏漏了会出现对象创建了但服务端没生效。4.3 心跳监听ServerStatus 与 KeepAlive 两条路心跳监听我一般做两条路。第一条挂session.KeepAlive事件判断协议层状态第二条订阅 ServerStatus 节点或服务端自定义心跳变量判断应用层是否还在刷新。只有 KeepAlive 正常、ServerStatus 长时间不更新时才说明服务端可能卡在业务线程而不是网络断了。var heartbeatItem new MonitoredItem(subscription.DefaultItem) { DisplayName ServerStatusHeartbeat, StartNodeId new NodeId(2256), // ServerStatus 标准节点常见映射 i2256 AttributeId Attributes.Value, SamplingInterval 1000, QueueSize 1, DiscardOldest true }; heartbeatItem.Notification (item, e) { foreach (var dv in item.DequeueValues()) { if (StatusCode.IsGood(dv.StatusCode)) _lastHeartbeatUtc DateTime.UtcNow; } }; subscription.AddItem(heartbeatItem); subscription.ApplyChanges(); // 看门狗定时器超过阈值没刷新就标记可疑 var timer new System.Threading.Timer(_ { var silent DateTime.UtcNow - _lastHeartbeatUtc; if (silent TimeSpan.FromSeconds(6)) { // 触发告警或交给重连状态机不要直接关闭会话 } }, null, 0, 1000);ServerStatus 节点i2256是常见映射不同服务端可能把自定义心跳放在ns2;sHeartbeat这类节点上移植时以 Browse 结果为准。SamplingInterval 1000表示每秒采一次看门狗阈值 6 秒是示例实际按3 × max(SamplingInterval, PublishingInterval)再加网络余量。回调里用DequeueValues把队列取空避免积压。定时器只做判断不要在定时器里直接new Session重连交给第 2 章的状态机。订阅回调运行在库线程更新界面时要切回 UI 线程否则会撞跨线程异常。5. 避坑与排查五个现场高频现象从现象到解决5.1 连接成功但读不到值现象是 Session 显示 Connected读点位返回 null 或 BadNodeIdUnknown。原因多半是 NodeId 的 namespace index 不对或者读的属性不是 Value也有服务端账户没有读权限的情况。解决时先 Browse 父节点确认实际 NodeId再读Attributes.DisplayName和Attributes.DataType对照。如果状态码是 BadNotReadable查账户权限如果是 BadNodeIdUnknown别改代码先改 NodeId。把读失败的 NodeId 和 StatusCode 一起写日志比只打“读取失败”有用得多。5.2 订阅收不到通知现象是subscription.Create()没报错但回调一直不触发。常见原因是漏了ApplyChanges或者MonitoringMode不是 Reporting或者PublishingEnabled被设成 false。另一个原因是服务端队列满后丢弃了通知客户端队列又没及时取。排查时先看subscription.Created和item.Status.Error再用同步 Read 读同一个节点确认值本身在变。解决顺序是补 ApplyChanges、改 Reporting、把 QueueSize 调大并在回调里 DequeueValues 取空。不要用 Thread.Sleep 等通知等不到还堵住线程。5.3 断开后会话泄漏现象是客户端界面已经断开服务端会话数还在涨重连几次后报会话数超限。原因通常是只把 session 置 null没有 Close 和 Dispose或者异常路径跳过了释放。解决时把关闭逻辑放进 try/finally先 RemoveSubscription再 Close再 Dispose重连前也检查旧会话是否已经释放。调试阶段可以在服务端看会话列表确认每次断开后数量回落。别在 KeepAlive 回调里直接重建会话旧回调没摘干净会重复触发。5.4 心跳误判现象是网络正常客户端却频繁判定心跳丢失或者服务端业务线程卡住客户端还显示在线。原因是只看了 KeepAlive 事件没有看应用层心跳或者看门狗阈值小于 PublishingInterval正常发布间隔被当成超时。解决时做双通道KeepAlive 事件看协议层ServerStatus 或自定义心跳变量看应用层阈值按采样周期和发布周期取大者再乘 3再加网络余量。日志里同时记录最后心跳时间、最后通知时间和重连次数误判时能回溯。5.5 安全证书不受信现象是连接返回 BadSecurityChecksFailed 或 BadCertificateUntrusted换成 None 就正常。原因是客户端证书没有被服务端信任或者应用实例 URI 不一致证书里绑定的地址对不上。解决时把客户端证书导出到服务端信任列表服务端证书也要导入客户端信任列表应用名和配置里的 ApplicationUri 保持一致。调试可以临时用 None 定位问题但不要把 None 当成最终方案。证书换过之后旧会话要清掉否则还会拿旧通道重试。6. 把 Demo 抽成组件接口封装、状态机与上线前验收清单Demo 跑通之后下一步是把连接、读写、订阅收进一个可复用组件别让界面按钮直接调 Session。我一般先定一个窄接口把状态、连接、断开、读、写、订阅暴露出来内部再维护重连状态机。状态至少分 Disconnected、Connecting、Connected、Reconnecting、Faulted 五个界面只读状态不直接碰 Session。订阅返回 IDisposable调用方释放时自动 RemoveItem避免订阅越加越多。public interface IOpcUaClient : IAsyncDisposable { ConnectionState State { get; } Task ConnectAsync(CancellationToken ct default); Task DisconnectAsync(); TaskDataValue ReadAsync(NodeId nodeId, CancellationToken ct default); Task WriteAsync(NodeId nodeId, Variant value, CancellationToken ct default); IDisposable Subscribe(NodeId nodeId, ActionDataValue onChanged); }验收时别只看“能连上”。下面这几项我每次上线前都会跑一遍尤其是断网恢复和订阅恢复翻车大多出在这里。验收项操作通过标准连接超时填一个不可达地址在设定超时内返回失败界面回到 Disconnected断网重连拔掉网络 10 秒再恢复状态从 Reconnecting 回到 Connected不重复建会话订阅恢复重连后观察通知原 MonitoredItem 继续回调没有重复通知心跳阈值暂停服务端业务线程应用层心跳超时告警协议层状态可区分资源释放反复连接断开 20 次服务端会话数回落客户端句柄不持续增长接口里的ConnectionState用枚举实现即可Subscribe内部把 MonitoredItem 和回调包成一个可释放对象释放时先RemoveItem再ApplyChanges。重连状态机不要放在 UI 线程里跑放到后台任务界面只订阅状态变化。血泪经验是Demo 阶段能读能写不代表能上线拔网线跑三遍、反复开关会话跑二十遍比加十个日志面板都管用。从那以后我每次交付 OPC UA 客户端都强制走一遍这张验收表再谈现场联调。希望帮到你。本文还有配套的精品资源点击获取