FEATURED · 精选文章

.NET中使用OPC UA实现工业数据采集与通信的完整实践

发布时间 / 2026/9/9 15:29:12
来源 / 创域科博编辑部
栏目 / 资讯中心
.NET中使用OPC UA实现工业数据采集与通信的完整实践 简介面向.NET开发者的OPC UA通信示例包演示连接、断开、读写、订阅与心跳监听等关键操作适合需要与PLC及自动化设备进行数据交互的工业物联网、上位机开发人员参考。压缩包共2000个文件约87.75MB主体为1218个XML工程文件与252个DLL另有112个TXT说明、28个NuGet包及少量C#源码和工程配置可支撑编译、调试和二次修改。已有670人学习下载。示例覆盖OPC UA客户端的完整生命周期从会话建立、节点变量读写到订阅实时推送与心跳检测均有相应代码片段结构便于按模块拆解学习结合工程文件可快速搭建通信Demo并理解证书验证、订阅条件、心跳回调等关键处理逻辑是一份实用的入门与联调参考。 前阵子接了一个产线数据采集的活现场设备有PLC、温控仪表、还有几台老式智能电表协议完全不统一。跟客户聊需求的时候对方只问了一句能不能用一个统一的方案把这些数据全部采上来我最后给出的答案是OPC UA然后在.Net里用官方库做了个通信Demo把连接、断开、读写、订阅、心跳监听这几个日常最常用的操作全串了一遍。这篇就把这套东西拆开来讲适合刚接触工业通信的.Net开发者也适合准备做设备数据采集上位机的朋友参考。1. 先把OPC UA为什么值得做说透1.1 协议碎片化时代OPC UA解决了什么做工业上位机的朋友应该都有同感现场设备协议五花八门Modbus TCP、S7、MC协议、BACnet、CANopen一个车间能有四五种通信方式。每接一种新设备就要写一套驱动调试时候还得抱着协议手册翻半天项目周期大量耗在这事上。OPC UAOpen Platform Communications Unified Architecture不一样它把“怎么描述数据”和“怎么传数据”都标准化了。服务器端定义地址空间客户端通过统一的节点ID读写和订阅不管底层连的是PLC还是仪表上层拿到手的都是同一个模型。跨平台、默认端口4840、支持加密签名这些特性让它在工业4.0和智能制造的项目里几乎是标配。再对比一下老的OPC DAClassic很多老项目还在用但它的硬伤很明显对比项OPC DAClassicOPC UA底层依赖Windows COM/DCOMTCP/HTTPS跨平台端口分配动态端口防火墙极难配默认固定端口4840安全机制基本没有证书认证、签名、加密数据表达能力简单标签完整信息模型订阅推送能力有限完善的订阅机制所以你看新项目再往OPC DA上走就没有必要了UA已经是事实上的标准。我在这个Demo里就是用.Net 8的控制台程序跑OPC UA客户端目标服务器我建议先用Prosys的OPC UA Simulation Server免费版就够用地址是opc.tcp://localhost:53530/opcua/它自带一堆模拟温度、压力、随机数的节点拿来测试刚刚好。1.2 为什么选UA-.NETStandard而不是自研或第三方SDK网上也有不少第三方的OPC UA库但我最终还是选了OPC Foundation官方出的OPCFoundation.NetStandard.Opc.Ua。理由很简单官方库更新稳定、社区案例多、踩坑资料能找到。虽然它的API风格偏底层封装程度没有商业库那么高但正因为如此你对通信过程的控制更细出了问题也更容易定位。有人可能会问能不能自己用Socket写一个OPC UA二进制协议我劝你打消这个念头。OPC UA的二进制协议栈非常庞大握手协商、安全通道、会话管理、订阅消息都要自己实现工程量远超想象。官方库把这些全封装好了你只需要面向它的Session、Subscription、MonitoredItem这几个核心对象编程就行。这个Demo的定位不是生产级框架而是把最小可用链路跑通你后续做封装、做扩展也好至少有个正确的地基。2. 环境搭建与连接断开的正确姿势2.1 项目初始化与NuGet包的版本陷阱先建一个普通的控制台项目.NET 6以上都行我这会儿用的是.Net 8dotnet new console -n OpcUaDemo cd OpcUaDemo dotnet add package OPCFoundation.NetStandard.Opc.Ua这里必须提醒你一个坑NuGet上有些类似的包名比如OPC.Ua、OPCFoundation.NetStandard.Opc.Ua.Core这些别装错了。我们要用的是带Client能力的全家桶包核心命名空间是Opc.Ua和Opc.Ua.Client。装包的时候注意看版本1.5.x系列和4.x系列API差别不小我下面的代码以1.5.3为准。装完包以后第一件事是配置ApplicationConfiguration。这个配置对象包含了应用名、证书验证规则、超时时间等信息OPC UA的安全机制决定了客户端必须有合法的标识。虽说本地测试可以跳过证书校验但我想让你先理解它的存在别一上来就关安全。using Opc.Ua; using Opc.Ua.Client; var application new ApplicationInstance { ApplicationName OpcUaDemoClient, ApplicationType ApplicationType.Client }; var config await application.LoadApplicationConfiguration( Opc.Ua.Client.Config.xml, false);还有一种方式是直接代码构建ApplicationConfiguration但在有配置文件的情况下走LoadApplicationConfiguration更规范也让证书路径等设置更清晰。2.2 Session建立的三个前置步骤连接OPC UA服务器的逻辑分三步选择Endpoint、创建ConfiguredEndpoint、创建Session。// 第一步拿到服务器的Endpoint描述列表Pick一个可用的 var endpointUrl opc.tcp://localhost:53530/opcua/; var endpointDescription CoreClientUtils.SelectEndpoint( config, endpointUrl, useSecurity: false); // 第二步用EndpointDescription创建带配置的Endpoint对象 var endpointConfig EndpointConfiguration.Create(config); var endpoint new ConfiguredEndpoint( null, endpointDescription, endpointConfig); // 第三步创建并激活Session var session await Session.Create( config, endpoint, updateBeforeConnect: true, OpcUaDemoSession, 60000, new UserIdentity(new AnonymousIdentityToken()), null); Console.WriteLine($Session已连接{session.ValidFrom});SelectEndpoint为什么是必要的因为服务器可能暴露多个Endpoint安全策略、证书要求都不同。这个方法会帮你拿到服务器支持的Endpoint列表再根据参数挑一个合适的。useSecurity: false代表本地测试时用无加密None模式生产环境我不建议这么干至少要用SignAndEncrypt配合用户名密码或证书身份认证。身份令牌这块Demo为了简单用了匿名AnonymousIdentityToken。真实项目里大部分设备服务器都允许配置用户名密码或者要求客户端证书。三种方式做个小对比身份方式安全级别适用场景匿名低开发调试、内网开放环境用户名密码中大多数产线设备X509证书高对安全有审计要求的系统Session创建的时候有个updateBeforeConnect参数设为true表示连接前先读取服务器端的命名空间和服务器状态方便你后续定位节点也顺带验证了连接有效性。2.3 断开连接很多人第一步就错了断开连接的代码看起来只有两三行但顺序错了会留下隐患// 先删除订阅再关Session最后Dispose foreach (var subscription in session.Subscriptions) { await subscription.DeleteAsync(false); } session.Close(); session.Dispose();为什么先删订阅因为订阅是服务器端的资源如果你直接Close Session服务端可能过一段时间才回收这些Subscription短期内大量重连的话服务端资源会被旧订阅占满。我先显式删掉保证不留垃圾。还有一个细节Session.Close()是发送CloseSession请求通知服务端正常关闭会话而Dispose()是释放本地资源。有些Demo只调了Dispose结果服务端那边会话还挂着连接数一多就报错。两条都要执行顺序别反。2.4 连接状态跟踪Session有几个事件值得在Demo里就挂上后面做心跳监听也要用到session.KeepAlive Session_KeepAlive; session.SessionClosing Session_SessionClosing;SessionClosing在服务端主动关闭会话或者客户端断开时触发可以用来做资源清理和状态通知。这些事件在5.x高版本里都保留了放心用。3. 读写操作从定位节点到写成功一个值3.1 NodeId的三种写法与获取手段OPC UA里定位一个数据节点靠的是NodeId。它有三种典型形态数字型、字符串型、GUID型。最直观的写法是带命名空间索引的文本形式比如ns2;sChannel1.Device1.Tag1命名空间索引2字符串标识ns3;i1001命名空间索引3数字标识1001ns4;gxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxGUID标识拿到这些值最靠谱的方式是用UAExpert或Prosys OPC UA Browser连接服务器在地址空间树里找到目标节点右键看属性里的NodeId。我之前见过有同事不看工具自己凭感觉猜字符串ID结果连不上就怀疑代码有bug查了半天发现是ID写错了。代码里构造NodeId也简单var nodeId NodeId.Parse(ns2;sChannel1.Device1.Tag1); // 或者直接指定命名空间和标识 var nodeId2 new NodeId(1001, 2);建议你把节点ID配置放到一个单独的地方比如类常量或配置文件里后续维护方便很多。3.2 Read与Write的代码骨架读取单个节点用ReadValueAsync读取多个节点用ReadValuesAsync。我一般优先用批量读取因为OPC UA服务端处理一次批量读取的代价远低于多次单点读取尤其是当你有成百上千个点位的时候。var nodeToRead new ReadValueId { NodeId nodeId, AttributeId Attributes.Value, DataEncoding null }; var readResult await session.ReadValueAsync( null, nodeToRead, CancellationToken.None); if (StatusCode.IsGood(readResult.StatusCode)) { Console.WriteLine($值{readResult.Value}, 类型{readResult.Value.GetType()}); } else { Console.WriteLine($读取失败{readResult.StatusCode}); }把ReadValueId单独建出来是为了后面扩展比如监听某个节点还可以换个AttributeId去读它的描述、工程单位等。写操作类似var writeValue new WriteValue { NodeId nodeId, AttributeId Attributes.Value, Value new DataValue(new Variant(21.5)) }; var writeResult await session.WriteValueAsync( null, writeValue, CancellationToken.None); if (StatusCode.IsGood(writeResult.StatusCode)) { Console.WriteLine(写入成功); } else { Console.WriteLine($写入失败{writeResult.StatusCode}); }写入的值一定要用Variant包一层这个类型是OPC UA统一的类型容器能自动适配服务器的数据类型。3.3 读写中常见的类型与状态码坑读写踩坑主要踩在类型匹配上。举个例子服务器某个节点定义是Int16你用new Variant(21.5)去写返回的很可能就是BadTypeMismatch。所以写操作前最好先用UAExpert看一眼节点的数据类型再决定用哪个C#类型去构造Variant。另一个经典错误是BadNodeIdUnknown。这个没啥好说的十有八九是NodeId拼错了或者是服务器重启后命名空间索引变了。OPC UA允许服务器动态调整命名空间索引所以生产环境我建议用字符串ID而不是数字ID或者至少在启动的时候重新解析一下命名空间表。读取超时也要注意。Session.Create里设的sessionTimeout是会话超时不是每次读写请求的超时单个请求超时由OperationTimeout控制。如果你的点位特别多批量读取的上限要控制一下一次性读几千个点会导致请求太长服务端可能直接拒绝。4. 订阅推送实时监控多个节点的正确打开方式4.1 轮询和订阅不是简单的快慢问题很多人一开始做数据采集第一反应就是开个Timer循环读间隔500ms或1s。这个方案在点数少、实时性要求不高的场景下确实能用但代价是每次轮询都有一堆没变化的数据在网络里跑客户端CPU和网络带宽都在浪费轮询间隔越短浪费越明显。订阅推送的思路不一样客户端告诉服务器“我关注哪些节点变化多大的时候通知我”然后服务端在值变化或者达到采样周期时主动把数据推过来。对于变化频繁、点数多的监控场景订阅的实时性和效率都远超轮询。对比项轮询订阅实时性取决于轮询周期服务端即时推送网络开销一直有请求响应有变化才推实现复杂度简单中等适合场景点数少、变化慢点数多、变化频繁订阅也不是银弹它适合值变化频率较高、你需要一直挂着的点位但对于低频累积量这种比如电表电量轮询每秒读一次反而简单可靠。真实项目里两种是混着用的别死认一种。4.2 创建Subscription和MonitoredItem的完整流程OPC UA订阅机制分两个层次一个是Subscription订阅对象一个是MonitoredItem监控项。Subscription相当于一条“通道”MonitoredItem是通道上具体监控的节点。一个Subscription可以挂很多MonitoredItem服务端按发布间隔把这一批节点的通知打成一个包发给客户端。完整创建代码如下var subscription new Subscription(session.DefaultSubscription) { PublishingInterval 1000, KeepAliveCount 10, LifetimeCount 100, MaxNotificationsPerPublish 1000 }; // 把Subscription挂到Session上并创建 await session.AddSubscriptionAsync(subscription); await subscription.CreateAsync(); // 创建监控项 var monitoredItem new MonitoredItem(subscription.DefaultItem) { StartNodeId NodeId.Parse(ns2;sChannel1.Device1.Tag1), AttributeId Attributes.Value, SamplingInterval 500, QueueSize 10, DiscardOldest true }; // 注册数据变化通知 monitoredItem.Notification (MonitoredItem item, MonitoredItemNotificationEventArgs e) { var notification e.NotificationValue as MonitoredItemNotification; if (notification ! null) { Console.WriteLine(${item.StartNodeId} {notification.Value.Value} $(状态: {notification.Value.StatusCode})); } }; // 把监控项加到Subscription await subscription.AddItemAsync(monitoredItem); await subscription.ApplyChangesAsync();这里有个很多教程没讲透的点subscription.CreateAsync()和AddItemAsync之后的ApplyChangesAsync()是必须调用的否则Subscription创建了但监控项没生效数据照样不推。我在第一次写Demo的时候就是漏了ApplyChangesAsync订阅静悄悄没反应排查了半天。4.3 三个订阅参数必须调明白发布间隔、采样间隔、生存计数订阅相关的参数容易让人懵尤其是刚接触的人会把PublishingInterval和SamplingInterval搞混。我帮你理清参数含义我的建议PublishingIntervalSubscription级服务端多久发一次通知包给客户端单位ms1000SamplingIntervalMonitoredItem级服务端多久采样一次节点值单位ms500KeepAliveCountSubscription级无数据变化时服务端隔几个PublishingInterval发一次保活消息10LifetimeCountSubscription级服务端隔几个KeepAlive周期没收到Publish请求就删订阅100SamplingInterval可以小于PublishingInterval这样服务端在同一个发布周期内能检测到多次变化并合并成一次发送。但如果SamplingInterval设得比PublishingInterval还大效果会变成“晚一步”看到值更新实时性反而差。LifetimeCount这个参数最容易被忽略它其实是个保护机制如果订阅长时间没有消息往来服务端会判定客户端“不活跃”然后强制删除订阅。LifetimeCount必须大于KeepAliveCount一般取10倍以上。因为我KeepAliveCount设了10所以LifetimeCount设100这样服务端发送10次保活消息都没收到反馈时才删除容错空间足够。还有一个经验如果你订阅的节点值本身变化很频繁而你又把QueueSize设得很小、DiscardOldest设为false那队列满了之后新通知会被丢弃表象就是你偶尔会发现值跳变。做数据采集要尽可能保留连续变化所以我把QueueSize设10DiscardOldest设true宁可丢旧值也不丢新值。5. 心跳监听与断线重连5.1 为什么主动断开之外还需要心跳Session创建之后TCP连接必然会存在但TCP连接能断不代表业务能第一时间知道。断电、网线松动、交换机重启、对端设备死机这些情况下TCP连接可能处于半开状态客户端发消息发不出去但也没有立刻收到错误。如果没有心跳你的界面可能一直显示“已连接”实际上数据早就断了。OPC UA官方库内置了KeepAlive机制它会周期性发送保活请求给服务器。我们要做的就是监听这个机制的反馈结果。5.2 KeepAlive事件的正确解读方式session.KeepAlive事件的触发逻辑是库内部定期与服务端交互如果服务端正常响应事件里e.CurrentState就是SessionState.Connectede.Status是Good。一旦服务端失联CurrentState会变成Reconnecting或者Closed。private static DateTime _lastHeartbeatTime DateTime.UtcNow; private static void Session_KeepAlive(Session session, KeepAliveEventArgs e) { _lastHeartbeatTime DateTime.UtcNow; if (e.CurrentState SessionState.Connected StatusCode.IsGood(e.Status)) { Console.WriteLine($[心跳] 连接正常状态码 {e.Status}); } else { Console.WriteLine($[心跳] 连接异常SessionState{e.CurrentState}, Status{e.Status}); // 这里触发重连逻辑 } }注意一个细节e.Status是服务端返回的状态码即使是“连接异常”这个分支事件本身还是本地正常触发的不要看到异常事件就以为系统崩溃。真正的断线判断标准是CurrentState从Connected变到其他状态并且Status不再是Good。另外心跳事件回调是在库内部线程触发的别在里面直接操作UI控件否则跨线程异常会把你搞懵。我习惯的做法是在回调里只更新状态变量或抛出事件UI层再转发。5.3 重连策略别把Session玩坏了断线重连最忌讳的事情是试图复用一个已经失效的Session。OPC UA的Session有状态服务端可能已经把它标记为关闭继续用只会不断报错。正确姿势是先Dispose旧的再重新走一遍连接流程。private static async Task ReconnectAsync() { try { if (_session ! null) { _session.Dispose(); _session null; } // 指数退避重试避免把服务器冲垮 for (var attempt 1; attempt 5; attempt) { try { _session await ConnectAsync(); Console.WriteLine(重连成功); break; } catch (Exception ex) { Console.WriteLine($第{attempt}次重连失败{ex.Message}); await Task.Delay(TimeSpan.FromSeconds(attempt * 2)); } } } catch (Exception ex) { Console.WriteLine($重连最终失败{ex.Message}); } }指数退避的意思是第一次等2秒、第二次4秒、第三次6秒依此类推给服务器恢复的时间也避免短时间内疯狂重试。我在现场调试时见过有人写了重连逻辑结果服务器还在重启过程中客户端每秒钟发起一次连接把服务器活活“重试”到崩溃这就是没有退避策略的后果。还要注意线程安全问题。KeepAlive事件可能在任何时候触发如果你的重连逻辑和业务线程同时在操作Session会产生并发冲突。建议把重连放到单独的Task里跑并加一个Interlocked标志位防止重复重连。6. 调试过程中的坑连不上、没数据、写不进去6.1 连不上先查Endpoint而不是查网线连不上服务器很多人第一反应是ping IP、查网线、看防火墙这些当然要查但我建议你先看程序抛出的异常状态码是什么再决定排查方向。常见几种情况现象可能原因排查动作BadConnectionRejected服务器拒绝匿名或未信任客户端证书换用户名密码或把客户端证书导入服务器信任列表BadSecurityChecksFailed安全策略或证书校验失败检查Endpoint是否允许None模式确认证书是否信任Timeout服务器未启动、端口不通、防火墙拦截确认服务器进程telnet 目标IP 4840BadNodeIdUnknown节点ID不存在或命名空间索引变了用UAExpert确认节点真实NodeId我之前遇到最多的是证书信任问题。OPC UA开发环境经常被警告“证书未信任”解决办法是让服务器信任客户端的自签名证书。不同服务器配置方式不一样Prosys Simulation Server的界面里可以直接把客户端证书加入信任列表操作起来不复杂。6.2 订阅没响应先检查两个时间参数订阅创建完成后一点反应都没有多半出在参数配置上。优先检查PublishingInterval和SamplingInterval如果SamplingInterval设得过大比如5000ms而你的传感器2秒变一次那就永远等不到值更新事件。如果PublishingInterval过大通知会攒很久才发一次看起来像是“卡了”。检查KeepAliveCount和LifetimeCount的数值关系LifetimeCount比KeepAliveCount还小的话订阅会在某次短暂延时后直接消失。还有一点容易被忽略订阅创建后一定要保证会话是活的。如果Session因为心跳超时被服务端关闭了Subscription跟着就没了。你光看客户端代码看不出问题到服务器上看会话和订阅列表才能发现。6.3 UAExpert——调试OPC UA的必备利器UAExpert是OPC Foundation官方出的客户端调试工具免费你值得装一个。它能直接浏览服务器地址空间、读写节点、创建订阅、查看历史数据是排查问题是“服务器问题”还是“客户端代码问题”的黄金标准。举个例子你发现订阅收不到数据先用UAExpert建一个同样NodeId的订阅看它能不能收到。如果UAExpert都收不到那是服务器节点本身不发布问题在服务器配置如果UAExpert能收到而你的程序收不到那就是代码的问题。这种二分定位法能省你一下午的时间。还有一个使用习惯用UAExpert把目标节点浏览一遍把它的NodeId、数据类型、访问级别都记下来然后再回代码里对接。别跳过我这一步现场调试效率差的基本都是没在工具里先摸清节点信息。写这个Demo的时候我最大的感受是OPC UA的功能一点都不难写难的是理解它的几个核心对象的关系以及参数背后的语义。Session是连接Subscription是通道MonitoredItem是通道上监控的节点心跳机制是保活的哨兵把这些理清了后面的封装和扩展都顺理成章。最后给你一个实际项目里的建议别把OPC UA逻辑层层塞进窗体代码里最好封装成一个独立的OpcUaClientService类对外暴露数据变更事件、连接状态事件和几个读写方法上层界面只管订阅事件不碰协议细节。这样不管后台是WinForms、WPF还是别的框架替换起来都轻松。这套Demo代码改改地址和节点ID就能跑通跑通之后再往细处做你会比踩过这些坑的人顺利很多。本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻