.Net OPC UA 通信 Demo:连接、读写与订阅的完整实现

发布时间:2026/10/23 15:08:10
.Net OPC UA 通信 Demo:连接、读写与订阅的完整实现

简介:这份资源是面向.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 = "CN=OpcUaDemoClient" }, // 信任列表:服务端证书要进这里,否则握手被拒 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 有三种常见写法:数字型ns=2;i=1001、字符串型ns=2;s=Temperature、GUID 型。Demo 里用字符串型居多,因为可读性好,但要注意命名空间索引ns不是固定的,不同服务端可能把自定义节点放在ns=2或ns=3,硬编码索引是常见翻车点。稳妥做法是先读Server_NamespaceArray拿到命名空间 URI 列表,再按 URI 反查索引。

// 读单个节点:指定 NodeId 和期望的数据类型 var nodeId = new NodeId("ns=2;s=Machine1.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("ns=2;s=Machine1.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 里看到的ns=2是它自己解析后的展示,你的代码里硬编码2,而服务端实际把该节点放在ns=3,就对不上。

解决:启动时先读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 设备,都先把“拔线重连”当验收项跑一遍,跑不过就不往下做业务逻辑。希望帮到你。

本文还有配套的精品资源,点击获取