☰
基于.Net的OPC UA通信Demo:连接、读写、订阅与心跳全解析
2026/10/23 15:17:04 网站建设 项目流程

简介:面向.NET开发者与工业自动化工程师的OPC UA通信实践示例,聚焦与PLC等设备的数据交互场景,覆盖从建立连接到高级订阅的完整链路。压缩包共约2000个文件、87.75MB,以xml配置与说明、dll依赖库、txt文档、nupkg包及少量cs源码为主,另含sln解决方案与csproj工程文件,便于直接打开调试。内容围绕连接与断开、节点读写、订阅监视项、心跳监听等关键操作展开,演示客户端实例创建、服务器URL与安全设置、证书验证与身份认证,以及ReadValue、WriteValue调用和订阅推送回调的处理方式。已有677人学习下载,适合希望快速理解OPC UA客户端编程模型、排查连接与订阅异常、并将其迁移到实际项目中的初中级开发者参考。

1. 一个 Demo 把 OPC UA 的五件事全串起来了

工业现场做上位机对接,绕不开 OPC UA。可多数人第一次接触它,卡的不是协议本身,而是“连上之后呢”。我见过不少同行,客户端能 ping 通、能建会话,一到读点位就报BadNodeIdUnknown,订阅写好了却收不到通知,心跳一断整个界面假死。这套 .Net OPC UA 通信 Demo 的价值就在这:它不铺开讲协议栈,而是把连接、断开、读写、订阅、监听心跳这五件现场最常干的事,压进一个能跑起来的工程里。适合谁?做设备数据采集、SCADA 上位机、产线监控的 .Net 开发者,尤其是刚接手 OPC UA 对接、需要一份能照着改的骨架的人。下面我按自己拆包的习惯,把这份资源从结构到参数到坑,一层层过一遍。

2. 拆开 Demo 看结构:会话、节点、订阅三件套怎么摆

拿到一个通信 Demo,我第一件事不是运行,而是看它怎么分层。OPC UA 的客户端代码如果全堆在一个按钮事件里,后面加个重连就能把人逼疯。这份 Demo 的骨架基本遵循“会话管理、节点操作、订阅监听”三段式,理解了这个划分,改起来才有方向。

2.1 会话(Session)是整条链路的根

OPC UA 里所有操作都挂在一个 Session 上。连接的本质是:用端点地址(EndpointUrl)建一条安全通道,再用用户身份建会话。Demo 里通常把这两步封装成一个Connect方法,内部先SelectEndpoint再CreateSession。这里有个容易被忽略的点——安全策略。现场服务器如果配的是None,客户端却默认要签名加密,握手阶段就会失败,报的错还特别含糊。我一般会先把安全策略显式设成跟服务器一致,再谈其他。

// 建立会话的核心三步,参数按现场服务器实际配置来 var endpointDescription = CoreClientUtils.SelectEndpoint(endpointUrl, useSecurity: false); var endpointConfiguration = EndpointConfiguration.Create(); endpointConfiguration.OperationTimeout = 10000; // 操作超时,单位毫秒 var session = Session.Create( endpointDescription, false, // updateBeforeConnect "DemoSession", // 会话名,服务器日志里能看到 60000, // sessionTimeout,会话空闲超时 new UserIdentity(), // 匿名;有账号密码就换 UserIdentity(username, password) null ).Result;

这段代码里useSecurity: false对应服务器关闭安全策略的场景,如果服务器开了签名,这里要改成true并配好证书。sessionTimeout别设太短,现场网络抖动一下会话就掉了,重连逻辑还没写好就是灾难。OperationTimeout是单次请求的超时,跟会话超时是两码事,很多人混为一谈,结果读一个慢点位把整个会话拖死。

2.2 节点读写:NodeId 的写法决定成败

读写操作的对象是节点,节点的唯一标识是 NodeId。Demo 里读写的示例一般用ns=2;s=xxx这种字符串形式。这里的水很深:命名空间索引ns不是固定的,服务器重启、重新组态后可能变。我踩过的坑是硬编码ns=2,换一台服务器就全错。稳妥做法是先读服务器的命名空间数组,按名字匹配出索引,再拼 NodeId。

// 先拿命名空间表,按 URI 或名字定位索引,避免硬编码 ns var namespaceArray = session.ReadValue(Variables.Server_NamespaceArray).Value as string[]; // 假设目标命名空间 URI 含 "Machine",找到它的索引 int nsIndex = Array.FindIndex(namespaceArray, s => s.Contains("Machine")); // 用定位到的索引拼 NodeId,再读值 var nodeId = new NodeId("Temperature", (ushort)nsIndex); DataValue value = session.ReadValue(nodeId); Console.WriteLine($"值={value.Value} 状态={value.StatusCode}");

ReadValue返回的是DataValue,里面除了值还有状态码和时间戳。新手常直接取.Value,状态码是Bad的时候拿到 null 还以为点位没数据。写操作对应session.Write,传WriteValue集合,注意写入前确认节点的数据类型,字符串往数值节点写会直接报类型不匹配。

2.3 订阅与心跳:两个独立的监听通道

订阅(Subscription)是 OPC UA 相对传统轮询的最大优势——服务器端变化才推送。Demo 里订阅的建立分三层:Subscription 管发布周期,MonitoredItem 管具体节点,Notification 事件管回调。发布间隔(PublishingInterval)设多少很讲究,设 100ms 对高频点位够用,但点位多了网络压力大;设 1000ms 又可能漏掉快速变化。我一般按点位变化频率的 1/2 到 1/3 来定。

心跳监听是另一条线。OPC UA 本身有KeepAlive机制,订阅在发布周期内没数据也会发空通知维持连接。Demo 里监听心跳,本质是监听这个保活信号或者定期读一个固定节点,用来判断链路是否还活着。这两件事别混在一起写,订阅回调里做重连判断,很容易在回调线程里把会话搞崩。

3. 从零跑通:连接、读写、订阅的完整操作链

结构看明白了,接下来是照着做的部分。这一章按“先连上、再读写、后订阅”的顺序走,每一步给出可抄的代码和参数说明。我建议第一次跑的时候用本机或测试服务器,别直接怼生产环境,出问题排查成本差很多。

3.1 连接与断开的正确姿势

连接前面说过核心三步,这里补全断开。断开不是简单调个Close就完事,顺序错了会留僵尸会话。正确顺序是:先删订阅、再关会话、最后关通道。Demo 里如果只写了session.Close(),服务器端可能还挂着你的会话直到超时,会话数一多就把服务器连接池占满。

// 断开的标准顺序:订阅 -> 会话 -> 通道 foreach (var sub in subscriptions) { sub.Delete(true); // 第二个参数 true 表示删除监控项 sub.Dispose(); } subscriptions.Clear(); if (session != null) { session.Close(); // 正常关闭会话,通知服务器释放 session.Dispose(); session = null; }

sub.Delete(true)里的true是删除监控项,别漏。session.Close()会向服务器发关闭请求,比直接 Dispose 干净。实际工程里我还会加一个try-catch包住整个断开流程,因为网络已断的情况下 Close 会抛异常,这时候要吞掉异常继续 Dispose,否则资源泄漏。

3.2 批量读写与数据类型转换

单点读写跑通后,现场往往是几十上百个点位。逐个ReadValue效率低,Demo 里如果有ReadNodes批量接口就用它,一次请求拿多个节点。批量读返回的是DataValueCollection,顺序跟传入的 NodeId 集合一致,按下标取就行。

// 批量读:一次请求拿多个点位,减少往返 var nodesToRead = new ReadValueIdCollection { new ReadValueId { NodeId = new NodeId("Temp1", ns), AttributeId = Attributes.Value }, new ReadValueId { NodeId = new NodeId("Temp2", ns), AttributeId = Attributes.Value }, new ReadValueId { NodeId = new NodeId("Pressure", ns), AttributeId = Attributes.Value } }; DataValueCollection results; DiagnosticInfoCollection diagnostics; session.Read(null, 0, TimestampsToReturn.Both, nodesToRead, out results, out diagnostics); for (int i = 0; i < results.Count; i++) { // 状态码先判断,再取值,避免 Bad 状态下拿到 null if (StatusCode.IsGood(results[i].StatusCode)) Console.WriteLine($"{nodesToRead[i].NodeId}: {results[i].Value}"); else Console.WriteLine($"{nodesToRead[i].NodeId}: 读取失败 {results[i].StatusCode}"); }

TimestampsToReturn.Both表示同时要源时间戳和服务器时间戳,做数据记录时用得上。maxAge传 0 表示要最新值,传大于 0 的值允许服务器返回缓存。批量写用Write,构造WriteValueCollection,每个WriteValue里AttributeId固定是Attributes.Value,Value用new DataValue(你的值)包一层。

3.3 订阅参数怎么设才不漏数据

订阅这块参数最多,也最容易设错。核心三个:PublishingInterval(发布周期)、SamplingInterval(采样周期)、QueueSize(队列长度)。采样周期不能小于发布周期,否则没意义;队列长度决定两次发布之间攒多少个变化值,设 1 只保留最新值,设大了能拿到中间过程但占内存。

// 建订阅并挂监控项 var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 500, // 每 500ms 发布一次 KeepAliveCount = 10, // 10 个周期无数据则发保活 LifetimeCount = 30, // 30 个周期无保活则订阅失效 MaxNotificationsPerPublish = 1000 }; session.AddSubscription(subscription); subscription.Create(); var item = new MonitoredItem(subscription.DefaultItem) { StartNodeId = new NodeId("Temperature", ns), AttributeId = Attributes.Value, SamplingInterval = 250, // 采样比发布快,保证不漏变化 QueueSize = 5, // 攒 5 个变化值 DiscardOldest = true // 队列满丢最旧的 }; item.Notification += OnDataChange; // 绑定回调 subscription.AddItem(item); subscription.ApplyChanges();

KeepAliveCount和LifetimeCount的关系要理清:前者是多久没数据发一次保活,后者是多久收不到保活就认为订阅死了。LifetimeCount必须大于等于KeepAliveCount的 3 倍,这是规范建议值,设反了订阅会频繁失效。回调OnDataChange里只做数据入队或赋值,别在里面做耗时操作,回调线程被堵住后续通知全积压。

3.4 心跳监听的两种实现

心跳监听 Demo 里一般给两种思路。第一种是监听订阅的保活通知,订阅对象有PublishStatusChanged之类的事件,状态变成Error就说明链路有问题。第二种是起一个定时器,每隔几秒读一个固定节点,读成功刷新时间戳,连续失败就触发重连。第二种更直观,也更好控制重连节奏。

// 定时读固定节点做心跳,连续失败触发重连 private DateTime _lastHeartbeat = DateTime.Now; private int _failCount = 0; private async Task HeartbeatLoop(CancellationToken token) { while (!token.IsCancellationRequested) { try { var v = session.ReadValue(new NodeId("Heartbeat", ns)); if (StatusCode.IsGood(v.StatusCode)) { _lastHeartbeat = DateTime.Now; _failCount = 0; } else _failCount++; } catch { _failCount++; // 异常也算失败,别吞了不计数 } if (_failCount >= 3) // 连续 3 次失败才重连,避免抖动误判 { await ReconnectAsync(); _failCount = 0; } await Task.Delay(3000, token); // 3 秒一次 } }

连续失败阈值设 3 是我自己的习惯,设 1 网络抖一下就重连,反而更不稳定。重连方法里要先清理旧会话再建新的,顺序跟断开一致。心跳线程用CancellationToken控制退出,别用Thread.Abort,那玩意儿在 .Net 里是黑匣子级别的坑。

4. 避坑排查:这五个地方我全翻过车

Demo 能跑不代表能用,现场环境和测试环境差得远。下面五条是我在真实对接里反复遇到的,每条按现象、原因、解决写,照着对号入座能省不少时间。

4.1 连接报 BadSecurityChecksFailed

现象:建会话阶段直接失败,错误码BadSecurityChecksFailed或BadCertificateUntrusted。原因:客户端和服务器安全策略不匹配,或者客户端证书没被服务器信任。解决:先确认服务器端点支持哪些安全策略,把客户端SelectEndpoint的useSecurity和策略 URI 对齐;如果服务器要求信任证书,把客户端证书导入服务器信任列表,测试环境可以先关安全策略验证链路是否通。

4.2 读点位返回 BadNodeIdUnknown

现象:连接正常,读某个点位状态码是BadNodeIdUnknown。原因:NodeId 拼错,最常见的是命名空间索引ns不对,或者标识符大小写、分隔符跟服务器不一致。解决:用 UA 客户端工具浏览服务器地址空间,确认目标节点的完整 NodeId;代码里改成动态解析命名空间索引,别硬编码。还有一种情况是节点存在但当前用户没读权限,状态码会是BadUserAccessDenied,别跟这个搞混。

4.3 订阅建了但收不到通知

现象:subscription.Create()没报错,回调就是不触发。原因:多半是PublishingInterval设得比服务器允许的最小值还小,服务器悄悄给你改了;或者监控项的SamplingInterval大于发布周期,采样根本没发生。解决:把发布周期设成服务器支持的合理值(一般 100ms 以上),采样周期小于等于发布周期;再检查ApplyChanges()有没有调,漏了这步监控项不会生效。

4.4 心跳误判导致频繁重连

现象:界面每隔几秒闪一次“重连中”,但数据其实没断。原因:心跳超时阈值设太短,或者把单次读失败当成断线。解决:连续失败计数再触发重连,阈值 3 起步;心跳读取的超时单独设,别用会话的全局超时;重连前先判断会话状态,Session.Connected为 true 时先尝试恢复订阅而不是重建会话。

4.5 断开后服务器会话数不释放

现象:反复连接断开,服务器端会话数只增不减,最后新连接被拒。原因:断开时只 Dispose 没 Close,或者异常路径下跳过了清理。解决:断开流程用try-finally保证Close和Dispose都执行;订阅先删再关会话;给会话设合理的sessionTimeout作为兜底,万一客户端崩了服务器也能回收。

5. 进阶:把 Demo 改成能上产线的采集骨架

Demo 跑通只是起点,真要上产线,还得补三样东西:重连的幂等性、订阅的自动恢复、以及数据落地的缓冲。我一般会在 Demo 基础上加一个连接状态机,把“未连接、连接中、已连接、重连中”四个状态显式管起来,所有操作先判断状态再执行,避免在重连过程中调读写把异常抛得到处都是。

订阅恢复这块有个细节:会话重建后旧订阅全失效,得重新建订阅并重新挂监控项。所以监控项配置要抽成一份列表,重连成功后遍历重建,而不是散落在各处。下面这个结构我用了很多次,稳。

// 监控项配置抽成列表,重连后统一重建 public class MonitoredItemConfig { public string NodeId { get; set; } public int SamplingInterval { get; set; } = 250; public int QueueSize { get; set; } = 5; } // 重连成功后调用,按配置重建订阅 private void RebuildSubscriptions() { var sub = new Subscription(session.DefaultSubscription) { PublishingInterval = 500, KeepAliveCount = 10, LifetimeCount = 30 }; session.AddSubscription(sub); sub.Create(); foreach (var cfg in _itemConfigs) { var item = new MonitoredItem(sub.DefaultItem) { StartNodeId = new NodeId(cfg.NodeId, _nsIndex), SamplingInterval = cfg.SamplingInterval, QueueSize = cfg.QueueSize }; item.Notification += OnDataChange; sub.AddItem(item); } sub.ApplyChanges(); }

数据缓冲用ConcurrentQueue接回调里的值,另起消费线程批量入库,别在回调里直接写数据库。回调线程是 OPC UA 栈内部的,堵住它整个订阅就瘫了。验证方法很简单:把服务器网线拔了再插上,看状态机是否走完“已连接→重连中→已连接”,订阅是否自动恢复,缓冲队列有没有丢数据。这套流程我每次改完通信层都强制走一遍,拔插网线这招看着土,但比任何单元测试都接近现场。从那以后我每次交付前都拿它当验收动作,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询