简介:这是一份面向C#开发者与工业自动化工程师的PLC通讯参考源码包,基于OPC UA协议实现与PLC的数据读写,重点解决跨厂商设备通信与上位机集成问题。压缩包共1174个文件,包含589个C#源码、107个DLL库,以及配置文件、XML资源、工程文件和若干可执行程序,整体约12.18MB,目录结构清晰,便于按模块查阅。源码覆盖OPC UA客户端库调用、PLC连接参数配置、节点查找与管理、Read/Write读写操作、数据变化事件订阅及异常处理等关键环节,并附带测试工程、类型定义与构建脚本,可帮助新手理解C#下OPC UA的开发流程,也为有经验的开发者提供可复用的工程模板和排错思路。已有2270人学习下载,适合工控项目选型、协议学习或快速搭建PLC通讯原型时参考。 我最早开始做PLC上位机通讯的时候,最大的痛点不是写代码,而是品牌之间的协议壁垒。西门子走S7,三菱走MC,台达走Modbus,欧姆龙又自成一套,每个协议都要单独维护一套库,客户换一台PLC,整个通讯层就得推倒重来。后来我转向OPC UA,用C#做上层应用,一套封装代码就能同时对接不同品牌的PLC,这个问题才算真正解决。这篇文章基于我实际项目中整理的读写源码和踩坑过程,把C#访问OPC UA实现PLC数据读写这一整套东西,尽量讲透。
如果你正在做C#上位机开发,遇到"要对接西门子、汇川、台达等好几款PLC"或者"现场要求数据采集必须稳定可靠,还得支持加密通讯"这类需求,那这篇内容基本能帮你省下一两周的摸索时间。我会从技术选型、核心概念、完整源码、排错经验四个角度展开,中间穿插大量现场才能学到的细节。无论你是刚接触OPC UA的新手,还是已经写过几个通讯模块的工程师,都能在里面找到能直接抄作业的东西。
1. 项目整体设计与技术选型
1.1 为什么是OPC UA,而不是Modbus TCP或厂商私有协议
先回答一个大家都会问的问题:现场明明能用Modbus TCP,为什么非要绕一圈用OPC UA?
我的体会是这样:Modbus TCP简单直接,但前提是PLC那边有人帮你把每个寄存器地址整理得明明白白。实际项目里,PLC程序往往是电气工程师写的,地址表可能是一份手写的Excel,甚至可能不在最新版本。你用Modbus去读,一个地址对不上,排查起来就是灾难。OPC UA不一样,它自带一套地址空间模型,PLC里的变量、设备状态、报警信息都被组织成一棵"节点树",上位机可以直接按名字去浏览和读取,相当于PLC自己把地图给你了。
另外,OPC UA和OPC DA(经典COM/DCOM)完全是两个时代的东西。老OPC DA在Windows上配置DCOM权限能让人崩溃,OPC UA基于TCP,默认走4840端口,跨平台,内置证书加密机制,数据模型也丰富得多。现在西门子S7-1500/1200、汇川、信捷等主流PLC基本都原生支持或者通过网关支持OPC UA,用它在C#里做数据采集,属于既能解决眼前问题、又不给未来挖坑的方案。
1.2 C#访问OPC UA的库怎么选
工具选型这块,我直接给结论:用OPC Foundation官方开源的OPC UA .NET Standard库,NuGet包名是Opc.Ua.Client和Opc.Ua.Core。这是社区里最成熟、维护最活跃的选择,没有之一。
有些人会找第三方商业库,比如Opc.UaFx之类,封装度确实高,但后期遇到问题不好排查,而且商业授权是个隐形成本。官方库看起来代码多一点,但它的结构非常清晰,任何一次通讯异常你都能在堆栈里追到具体原因。实在需要更高层的封装,网上也有很多基于官方库二次封装的开源项目可以借鉴,但底层不要去动。
我的项目环境是.NET 8 + Visual Studio 2022,目标框架用的net8.0-windows,因为现场上位机通常跑在Windows工控机上。如果你还在用.NET Framework 4.6.1,官方库也支持,但建议尽早升级,毕竟性能和安全机制都有差距。
1.3 项目结构规划
我习惯把OPC UA的通讯逻辑独立成一个单独的类库项目,和WPF/WinForms界面项目分开。这样做的好处是,通讯层可以单独测试,以后如果从WinForms换到WPF,或者加上WebAPI对外提供数据,通讯层完全不用动。
整个项目的核心是一个OpcUaClient类,里面封装了连接、断开、读取、写入、订阅、重连这几个基础能力。界面层只需要调用这个类的公开方法,拿到值就直接绑定或者刷新UI,通讯细节全部隔离在底层。下面这个结构是我一直在用的:
OpcUaSolution ├── OpcUa.Core // 类库:封装OPC UA客户端 │ ├── OpcUaClient.cs │ ├── OpcUaConfig.cs │ └── LogHelper.cs └── OpcUa.App // WPF/WinForms界面 └── MainWindow.xaml这样的分层在项目后期会给你很大自由度。比如客户突然说"要把数据传到MES系统",你只需要在Core层加一个数据订阅回调,把采集结果转成MES需要的格式上报,界面层几乎不用改。
2. OPC UA核心概念:读懂地址与信息模型
2.1 节点、节点Id和命名空间
第一次接触OPC UA的人,最容易卡在"节点Id"这个概念上。我刚开始也懵,后来用一个生活化的类比就明白了。
把OPC UA服务器想象成一个图书馆,每个书架、每本书都有一个唯一的编号。OPC UA里的每个数据点(变量、对象、方法)都是一个"节点",节点的唯一标识就是NodeId。它通常长这样:
ns=2;s=Channel1.Device1.Tag1这里的ns是命名空间索引,相当于"这本书属于哪个分类体系";s=后面是字符串形式的节点标识,相当于书名。不同的PLC品牌,节点Id的生成规则不完全一样,但整体逻辑都是这个套路。
理解了NodeId,读写操作其实就一句话:告诉服务器你要找哪个节点,然后读它的值或者给它写新值。难点在于怎么知道节点的名字。现场一般有两种方式:一种是PLC厂家提供OPC UA地址表,直接把Channel1.Device1.Tag1这样的字符串给你;另一种是你自己用UaExpert(OPC Foundation官方的免费客户端工具)去浏览服务器的节点树,找到对应点位,右键复制NodeId。我每次调试新设备,第一步永远是打开UaExpert看节点树,这个习惯帮我避免了很多次"地址写错"的低级问题。
2.2 值、质量戳和时间戳
OPC UA读取到的数据,不只是"一个数值"那么简单。每个变量节点返回的DataValue里其实有三个关键字段:Value(实际数值)、StatusCode(质量戳,标识数据是否有效)、SourceTimestamp(源时间戳,标识PLC侧最后一次变更的时间)。
这三个字段,我强烈建议你在项目里全部保留下来。很多上位机界面只显示数值,等现场出现设备报警时才发现数据不对——这时候质量戳能帮你快速判断是通讯断了、PLC处于停止状态还是数据本身就是旧的。我遇到过几次诡异问题,都是靠质量戳揪出来的:现场某个传感器没接线,PLC里该变量一直是初始值,但上位机上一排全是0,看起来毫无异常。加上质量戳的判断逻辑后,这种假数据立刻就能暴露出来。
2.3 地址空间的浏览与自发现
OPC UA有一个很实用的特性:支持浏览服务器地址空间。意思是说,你不用提前知道任何NodeId,连上服务器之后,可以通过代码一层层展开节点树,找到目标变量。
这个特性用来做"自动发现设备变量"的工具非常方便。我在项目里写过一个简单工具:连上服务器,递归浏览所有节点,把路径和NodeId全部导出一个Excel清单,直接发给电气工程师确认。这一步大大减少了现场联调时"对照地址表一个点一个点核对"的工作量。下面是核心的浏览代码:
public async Task<List<(string Path, NodeId NodeId)>> BrowseNodesAsync(NodeId startNode) { var result = new List<(string, NodeId)>(); var browseDescription = new BrowseDescription { NodeId = startNode, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, NodeClassMask = (uint)NodeClass.Object | (uint)NodeClass.Variable, ResultMask = (uint)BrowseResultMask.All }; var request = new BrowseRequest { NodesToBrowse = new BrowseDescriptionCollection { browseDescription }, RequestedMaxReferencesPerNode = 1000 }; var response = await _session.BrowseAsync(request); foreach (var reference in response.Results[0].References) { result.Add((reference.DisplayName.Text, reference.NodeId)); } return result; }实际项目里我会加上递归调用和节点路径拼接,把整颗变量树完整导出来,排查问题时非常有用。
3. C#实现OPC UA读写的完整过程
3.1 环境准备:NuGet包和基本配置
新建一个类库项目后,在NuGet包管理器里安装三个包:
Opc.Ua.Core Opc.Ua.Client Opc.Ua.Configuration版本选最新的稳定版即可。接下来需要为客户端生成应用证书。OPC UA的安全机制要求客户端和服务器各自持有一份证书,第一次连接时双方需要互相信任。官方库提供了ApplicationConfiguration配置类,我封装了一个BuildConfigAsync方法,逻辑是:如果本地还没生成证书,就先创建一份,然后保存到本地目录。
private async Task<ApplicationConfiguration> BuildConfigAsync(string appName) { var config = new ApplicationConfiguration { ApplicationName = appName, ApplicationUri = $"urn:{Dns.GetHostName()}:{appName}", ApplicationType = ApplicationType.Client, SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.X509Store, StorePath = "CurrentUser\\UA_MachineKeys", SubjectName = $"CN={appName}, DC={Dns.GetHostName()}" }, TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = "OPCUA_Certificates\\TrustedPeer" }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = "OPCUA_Certificates\\TrustedIssuer" }, RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = "OPCUA_Certificates\\Rejected" } }, TransportConfigurations = new TransportConfigurationCollection(), TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, TraceConfiguration = new TraceConfiguration() }; await config.Validate(ApplicationType.Client); var cert = await config.SecurityConfiguration.ApplicationCertificate.Load(); if (cert == null) { await config.SecurityConfiguration.ApplicationCertificate.Create(); } return config; }第一次运行后,你的工程目录下会生成一个证书文件路径。到这一步,很多新手会踩坑,后面第4部分我会专门讲证书信任的排查方法。
3.2 建立连接:Endpoint选择和Session创建
OPC UA服务器的地址格式是opc.tcp://192.168.1.10:4840。连接前先获取服务器的Endpoint描述信息,再根据它创建Session。需要注意一点:如果服务器支持多种安全策略,代码里最好加上筛选逻辑,我通常优先选择SecurityPolicies.Basic256Sha256,也就是加密强度最高的那个。
public async Task<bool> ConnectAsync(string endpointUrl) { try { var config = await BuildConfigAsync("OpcUaClientApp"); var endpointDescriptions = await GetEndpointsAsync(config, endpointUrl); var selectedEndpoint = endpointDescriptions .Where(e => e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256) .OrderByDescending(e => e.SecurityLevel) .FirstOrDefault() ?? endpointDescriptions[0]; _session = await Session.Create( config, new ConfiguredEndpoint(null, selectedEndpoint), false, "C# OPC UA Client Session", 60000, new UserIdentity(new AnonymousIdentity()), null); _session.KeepAlive += Session_KeepAlive; _session.ReconnectComplete += Session_ReconnectComplete; Console.WriteLine($"连接成功: {endpointUrl}"); return true; } catch (Exception ex) { Console.WriteLine($"连接失败: {ex.Message}"); return false; } }如果你用的是西门子S7-1500这类内置OPC UA服务器的PLC,用户身份这块通常支持匿名登录,但需要注意PLC侧要在OPC UA设置里把"激活OPC UA"和"允许匿名访问"的开关打开。这个配置在TIA Portal的PLC属性里,不打开的话客户端永远连不上,而且报错还很不直观。
3.3 读取节点值:从单点读取到批量读取
读取操作最基础的是单点读取,代码如下:
public async Task<object> ReadNodeAsync(string nodeId) { var readValueId = new ReadValueId { NodeId = new NodeId(nodeId), AttributeId = Attributes.Value }; var request = new ReadRequest { NodesToRead = new ReadValueIdCollection { readValueId }, MaxAge = 0, TimestampsToReturn = TimestampsToReturn.Both }; var response = await _session.ReadAsync(request); var value = response.Results[0].Value; Console.WriteLine($"节点 {nodeId} 的值: {value}, 质量: {response.Results[0].StatusCode}"); return value; }这里有个性能优化点必须提一下:如果你在循环里一个点一个点地调用上面的方法,做几十个点位会非常慢。实测下来,单点读取100个变量,每次往返约2~5毫秒,加起来就要几百毫秒,根本扛不住高频采集。正确的做法是一次性把一个批次的所有节点打包成数组,发给服务器,再从响应里取结果:
public async Task<DataValueCollection> ReadNodesAsync(List<string> nodeIds) { var nodesToRead = new ReadValueIdCollection(); foreach (var id in nodeIds) { nodesToRead.Add(new ReadValueId { NodeId = new NodeId(id), AttributeId = Attributes.Value }); } var request = new ReadRequest { NodesToRead = nodesToRead, MaxAge = 0, TimestampsToReturn = TimestampsToReturn.Both }; var response = await _session.ReadAsync(request); return response.Results; }批量读取的优化效果非常明显。同样的100个变量,打包成一次请求,总耗时可能只有几十毫秒,采集效率提升了一个数量级。这也是现场做高频采集时最核心的优化手段。
3.4 写入节点值:类型匹配是最大的坑
写入操作同样要做成批量提交,除非你只做点动控制。单个节点写入的代码如下:
public async Task<bool> WriteNodeAsync(string nodeId, object value) { var writeValue = new WriteValue { NodeId = new NodeId(nodeId), AttributeId = Attributes.Value, Value = new DataValue(new Variant(value)) }; var request = new WriteRequest { NodesToWrite = new WriteValueCollection { writeValue } }; var response = await _session.WriteAsync(request); var result = response.Results[0]; if (StatusCode.IsBad(result.StatusCode)) { Console.WriteLine($"写入失败: {result.StatusCode}"); return false; } return true; }这里最容易踩的坑就是类型匹配。PLC侧的变量如果定义的是Int16,你C#这边传一个int过去,服务器大概率返回BadTypeMismatch。最常见的做法是在写入前先读取一次该节点的DataType,然后按照数据类型把值做一次转换。更稳妥的方案是让现场电气工程师在PLC里统一变量类型,比如所有整数都用Int32,所有浮点都用Float,可以省下大量联调时间。
3.5 订阅数据变化:事件驱动替代轮询
如果用OPC UA只做定时Read,其实还是老一套轮询思路,没有充分发挥协议的优势。OPC UA真正的强项是订阅(Subscription)机制:客户端告诉服务器"我对这几个节点感兴趣,有变化就通知我",然后等待事件推送即可。这样数据变化几乎是实时的,而且客户端负载非常低。
我常用的订阅代码如下:
public void SubscribeNode(string nodeId, Action<string, DataValue> onDataChanged) { var subscription = new Subscription(_session) { PublishingInterval = 100, Priority = 1, DisplayName = "Data Subscription" }; _session.AddSubscription(subscription); subscription.Create(); var item = new MonitoredItem { StartNodeId = new NodeId(nodeId), AttributeId = Attributes.Value, SamplingInterval = 100, QueueSize = 10, DiscardOldest = true }; item.Notification += (MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs e) => { var notification = (MonitoredItemNotification)e.NotificationValue; onDataChanged?.Invoke(nodeId, notification.Value); }; subscription.AddItem(item); subscription.ApplyChanges(); }这里SamplingInterval是服务器采集PLC变量的周期,PublishingInterval是服务器通知客户端的周期。实际项目中,如果只是做画面显示,500毫秒的间隔足够了;如果是做运动控制或者精确的数据记录,建议100毫秒以内,但也要评估现场PLC的负载能力。
订阅机制还有一个天然的好处,就是可以配合扫码枪触发事件做联动。我做过一个项目,扫码枪通过串口把条码发到上位机,C#收到字符串后立马触发一个写操作,把条码写入PLC的一个String变量,PLC侧检测到条码值变化后就执行下一步分拣动作。整个过程一气呵成,完全不需要轮询,响应快而且准。
4. 实战中躲不过的坑与排查技巧
4.1 证书不信任导致的连接失败
OPC UA第一次建立安全连接时,如果客户端和服务器的证书没有互相加入信任列表,服务器会拒绝连接,报错通常带有BadSecurityChecksFailed或者BadCertificateUntrusted字样。这类问题几乎每个OPC UA新手都会遇到。
排查路径我总结成三步:第一步,去服务器(或网关)的日志里看有没有一条"收到未知客户端证书"的记录,把它加入信任列表;第二步,打开客户端生成的证书目录,看看有没有证书文件(注意证书文件要放在TrustedPeer文件夹下,而不是只放在Own目录);第三步,如果双方在同一个局域网内部调试,懒得配证书信任,可以临时把安全策略改为None,也就是不加密不签名,先跑通流程再说。不过这只是测试手段,正式项目我建议一定要开加密,尤其是数据涉及生产工艺参数的时候,裸奔出事的教训我见过不止一次。
4.2 写入总是报类型错误
写值的时候报BadTypeMismatch,十有八九是类型转换问题。可以参考下表对照检查:
| PLC侧类型 | C#侧应该传的类型 |
|---|---|
| Bool | bool |
| Int8 / Int16 | sbyte/short |
| Int32 | int |
| Int64 | long |
| Float | float |
| Double | double |
| String | string |
这里有个细节很容易忽略:OPC UA服务器对无符号类型和有符号类型的区分很严格,PLC里如果是UInt16,你传一个short过去照样失败。所以建议你在写代码前,先通过ReadNodeAsync读取该节点的DataType并打印出来,确认后再写入。不要相信通讯地址表里写的"short"就能直接对应,一定要以服务器返回的数据类型为准。
4.3 断线重连机制怎么设计
现场环境不比办公室,工控机可能遭遇交换机重启、PLC断电、网线松动等各种状况。程序里如果做了连接但没做断线重连,一旦PLC重启,上位机就得人工重启才能恢复,这在无人值守的产线上是完全不能接受的。
我的做法是利用Session自带的KeepAlive事件做心跳检测。KeepAlive每隔一段时间会收到服务器的保活包,如果连续几次没有收到,就认为连接已断开,触发重连逻辑。重连的时候先销毁旧Session,再重新执行ConnectAsync。重连期间要有一个状态标志位,避免多个线程同时触发重连导致连接错乱。这类重连逻辑写好后,还需要在界面上用一个状态指示器显示当前通讯状态,现场人员看到绿色/红色指示灯就知道通讯是否正常。
4.4 UI刷新卡顿问题的解决思路
你在搜"C# 循环数据采集和UI刷新卡顿"的时候,应该已经遇到过这个问题了。数据采集跑得越来越快,UI线程也跟着疯狂刷新,整个界面卡得跟幻灯片一样。这个问题的根源在于:采集线程直接跨线程操作UI控件,UI线程被大量BeginInvoke调用淹没了。
我的经验是:绝不让UI线程直接接收每一个采集值,而是用"生产者-消费者"模式做一次缓冲。采集线程(或者订阅回调)把数据塞进一个ConcurrentQueue或者Channel,UI线程定时(比如200毫秒)从队列里批量取出最新值,一次性刷新所有绑定的控件。这样做的好处是,即使采集频率很快,UI刷新频率始终可控,界面不会卡顿,数据完整性还能通过队列长度来观察。
private readonly Channel<Dictionary<string, object>> _dataChannel = Channel.CreateUnbounded<Dictionary<string, object>>(); // 采集线程/回调中写入 await _dataChannel.Writer.WriteAsync(newData); // UI线程定时批量取出 var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(200) }; timer.Tick += async (s, e) => { while (_dataChannel.Reader.TryRead(out var batch)) { UpdateUI(batch); } }; timer.Start();这套方案我在多个项目里验证过,采集频率500Hz以下时,UI线程占比几乎可以忽略不计。
4.5 调试工具:UaExpert是最省力的助手
最后必须安利一个调试神器:UaExpert。这是OPC Foundation官方出品的免费客户端,支持Windows和Linux,图形化浏览节点树,查看实时数据,测试读写,导出地址表,简直全功能。我每次现场联调,先连UaExpert确认服务器正常、点位能读到值,再用自己的C#代码去连,这样能把"服务器的问题"和"代码的问题"彻底分开。
很多时候客户说"你们的软件连不上PLC",我第一反应不是改代码,而是先用UaExpert连一下服务器的地址,看能不能连上。如果UaExpert也连不上,那基本就是PLC侧OPC UA服务没配置好或者网络不通,跟我们写的程序关系不大。这个排查顺序帮我解决过大量无效沟通。
5. 后续可以扩展的方向
如果你已经跑通了基础的读写功能,我建议接下来往这几个方向深挖。
第一个是数据采集的统一封装。现在只对接了一台PLC,但工程上往往是几十台上位机、好几个品牌混用。可以基于OPC UA的订阅机制,把采集到的数据统一推送到MES、SCADA或者数据库。这样只要所有PLC都支持OPC UA,上层数据平台只需要面对统一的接口,后续扩容非常方便。
第二个是"边缘计算"的思路。OPC UA采集到的原始数据,其实可以在上位机侧做一轮处理,比如超标报警、变化率计算、趋势预测等。C#处理这些逻辑远比PLC梯形图灵活,而且改起来不用等电气工程师重新下载程序。
第三个是和安全、权限相关的配置。正式交付的项目建议开启OPC UA的用户认证,用用户名密码代替匿名访问,同时启用安全策略加密通讯。这样做一方面符合等保要求,另一方面也能防止厂家调试完忘记关闭测试账号导致的安全隐患。
最后分享一个我个人的体会:OPC UA这套标准最大的价值不是"技术先进",而是"统一"。它把PLC品牌之间的差异挡在外面,让C#开发人员把精力集中在数据怎么用、业务怎么做,而不是陷在协议细节里。如果你正在选型上位机的通讯方案,或者被多品牌PLC的通讯折磨得焦头烂额,我很建议认真试一下OPC UA这条路。上手确实有一点门槛,但熬过证书信任和数据类型这两关之后,后面的路会越走越顺。
本文还有配套的精品资源,点击获取