简介这是一份面向工业自动化、设备联网与远程监控场景的C# OPC UA服务器源码包主要帮助.NET开发者解决如何基于C#实现跨平台OPC UA服务器的问题。压缩包共2000个文件体积约101.81MB以cs源代码、dll类库、xml配置为主体并辅以txt说明、png图示、nupkg依赖包等结构清晰覆盖从服务器构建、配置、调试到扩展的完整环节。其中示例项目直观演示了订阅、发布、数据读写等核心流程封装好的辅助类库可加速二次开发配套的.NET Core演示程序则展示跨平台部署方式此外还提供命令行配置工具与标准库版本帮助读者深入掌握证书管理、节点管理、订阅模型、安全策略等实现细节。资源已有402人学习对于初次接触OPC UA的开发者和希望借鉴成熟C#实现的技术人员都是一份可对照研究的实战代码库。1. C# OPC UA 服务器源码不是启动器而是一棵地址空间树搜“c# opc ua 服务器源码”的人多数不是要从零写协议栈而是要一套能改的服务器骨架把 PLC、仪表的数据暴露给 MES、SCADA 或自己的上位机。OPC UA 服务器在 C# 里的实现核心不在 socket 收发而在两件事启动一个可被客户端发现的端点以及维护一棵由节点和引用组成的地址空间树。源码包拆开后真正值得改的地方集中在 Server 装配、NodeManager 建模、订阅发布三块。下面按从骨架到落地的顺序过一遍先看懂分层再跑通最小服务器然后建模和调订阅最后落在证书和并发这些现场必查的细节上。适合正在写 C# 上位机、想摆脱厂商私有协议的开发者。2. 源码骨架OPC UA 服务端的地址空间、三层结构与安全策略如果你把这份 C# 源码当成普通 socket 服务去读会绕很多弯路。OPC UA 服务端的核心抽象是地址空间Address Space而不是收发线程。地址空间是一张由节点Node和引用Reference组成的图客户端看到的设备、温度、开关量、方法全在这张图里。2.1 先把地址空间看对Node、Reference 与 NodeClass在服务器端每个节点都有 NodeClass决定了它的语义和行为。源码里对应的是标准库里的状态类而不是你自己定义的 DTO。下表是常见的几种节点类和它们在 OPC UA .NET Standard 里的落地类NodeClass含义OPC UA .NET Standard 里的类Object设备、机台、任务实例BaseObjectStateVariable数值、状态、属性BaseDataVariableStateTMethod可远程调用的操作MethodStateView面向特定角色的数据子集ViewStateDataType自定义数据类型定义DataTypeState为什么要严格按 NodeClass 建模因为客户端浏览时依赖这些语义。你把温度做成 Object 而不是 Variable客户端就不知道该读取哪个值你把方法建模成 Variable客户端就无法调用。常见做法是把物理设备映射成 Object把测点映射成它的子 Variable把复位、启动类动作用 Method 挂上去再把父子关系通过 HasComponent 引用连起来。引用在服务器端不是外键而是节点之间的有向边客户端可以从任意节点沿引用遍历整张图。地址空间设计得好不好直接决定客户端拿到源码后要不要为你的数据结构写一堆特判。2.2 源码包的常规分层Server、NodeManager、Configuration以 OPC Foundation 的 .NET Standard 库为基础写出来的服务器即使项目名五花八门目录结构也趋同。第一层是继承 StandardServer 的入口类负责装配 NodeManager 并返回服务器属性第二层是自定义 NodeManager通常继承 CustomNodeManager2在 CreateAddressSpace 里添加节点第三层是 ApplicationConfiguration负责读取证书、端点、安全策略。这三层如果被压缩进一个文件短时间内跑通没问题一旦要接真实设备和多客户端改动就会互相牵连。我的习惯是保持三条边界Server 不知道设备细节NodeManager 不处理网络配置Configuration 不包含业务逻辑。你在源码包里改设备协议时应该只动 NodeManager 那一层改端口和证书时只动配置文件。这样即使设备从 Modbus 换成 EtherNet/IP服务器装配链路一行不用动。2.3 端点是门面安全策略在它后面开关端点Endpoint是客户端能连接的地址组合格式是 opc.tcp://主机名:端口。C# 服务器启动时会把配置文件里的 BaseAddresses 挨个发布出去。安全策略决定消息是否签名、是否加密常用的三档如下安全策略消息签名加密使用场景None无无内网调试、原型验证Basic256Sha256是是默认生产选型Aes128_Sha256_RsaOaep是是需要更高安全强度的新部署在配置文件里同时写明 None 和 Basic256Sha256 是常见折中先保证演示环境能连上生产再关掉 None。配置文件里 BaseAddresses 和 SecurityPolicies 的对应关系要留意只写端点不写策略服务器启动时不会报错但客户端会找不到可用的安全模式。另一个容易踩的坑是改 BaseAddresses 之后客户端仍然按证书里的 ApplicationUri 匹配端点。ApplicationUri 一旦变化已信任的客户端会认为服务器身份改变直接拒绝连接。ServerConfiguration BaseAddresses String xmlnshttp://opcfoundation.org/UA/2008/02/Types.xsdopc.tcp://0.0.0.0:62541/String /BaseAddresses SecurityPolicies ServerSecurityPolicy SecurityPolicyBasic256Sha256/SecurityPolicy /ServerSecurityPolicy ServerSecurityPolicy SecurityPolicyNone/SecurityPolicy /ServerSecurityPolicy /SecurityPolicies /ServerConfiguration这段配置里把 Basic256Sha256 放在 None 前面客户端协商时会先拿到强安全策略。0.0.0.0 表示监听本机所有网卡部署到服务器虚拟化环境时省去改 IP 的麻烦但对外网暴露时千万别这么写应该换成具体的内网地址。3. 从 zip 到能连通的 C# 最小 OPC UA 服务器拿到源码包先别急着拖进 IDE 编译。先确认包里有没有 csproj、Program.cs 和 appsettings 配置文件。缺了这三样说明源码包不完整要么是官方模板剪出来的骨架要么缺了启动入口。3.1 最小文件清单四个文件把服务器立起来一个能跑通的最小 C# OPC UA 服务器文件可以精简到四个文件职责Program.cs加载配置、检查证书、调用 StartServer.cs继承 StandardServer装配 NodeManagerDataNodeManager.cs继承 CustomNodeManager2创建地址空间节点appsettings.xml端点、安全策略、证书目录、日志路径这里没有包含 .csproj 是因为任何支持 .NET 6/8 的 SDK 项目模板都能直接引用 NuGet 包 OPCFoundation.NetStandard.Opc.Ua 编译。源码包里如果还有什么 DbLogger、UserManager 之类的目录先判断是不是周边功能不要在第一次编译时被它们卡住。3.2 启动链路ApplicationInstance、证书检查与 StandardServerProgram.cs 里最关键的调用链是四步少一步服务器都可能起不来或者客户端连不上using Opc.Ua; using Opc.Ua.Configuration; class Program { static async Task Main(string[] args) { ApplicationInstance application new ApplicationInstance { ApplicationName CSharpOpcUaPill, ApplicationType ApplicationType.Server }; // 第 1 步加载端点、证书路径、安全策略 ApplicationConfiguration config await application.LoadApplicationConfiguration( appsettings.xml, silent: false); // 第 2 步检查应用证书没有就自动生成 2048 位密钥 bool certReady await application.CheckApplicationInstanceCertificate( silent: true, minimumKeySize: 2048); // 第 3 步启动服务器发布端点 await application.Start(new PillServer()); Console.WriteLine( OPC UA server listening on opc.tcp://localhost:62541); await Task.Delay(Timeout.InfiniteTimeSpan); } }第 1 步的silent: false会在配置出错时把验证异常打出来没有这个参数很多时候服务器静默启动失败客户端只看到连接被拒绝。第 2 步的CheckApplicationInstanceCertificate会自动在pki/own/certs下生成开发者证书如果源码包已经在别的机器上生成过证书这一步会直接复用把pki目录整个拷贝到另一台机器并保持 ApplicationUri 一致也是可行的部署方式但要保证证书没有过期。第 3 步的Start是异步的阻塞式等待Timeout.InfiniteTimeSpan是为了避免 Main 方法直接退出服务器进程被瞬间回收。Server.cs 里不需要写任何与 PLC 或传感器相关的代码只做两件事装配 NodeManager向库返回服务器属性public class PillServer : StandardServer { private DataNodeManager _nodeManager; protected override MasterNodeManager CreateMasterNodeManager( IServerInternal server, ApplicationConfiguration configuration) { _nodeManager new DataNodeManager(server, configuration); return new MasterNodeManager(server, configuration, _nodeManager); } protected override ServerProperties LoadServerProperties() { return new ServerProperties { ProductName CSharpOpcUaPill, ProductUri urn:demo:csharp-opcua-pill, ManufacturerName PillDemo }; } }ProductUri应该和 appsettings.xml 里的ApplicationUri保持一致。很多客户端把 ApplicationUri 当作服务器身份标识一旦不一致即使证书信任了也会出现奇怪的握手失败。把设备逻辑写进 Server 类是新手最常见的误用后面每次换协议都要重新动启动链路不划算。3.3 启动后的三个自检动作服务器启动后先做三项检查再考虑接设备端口是否在监听。Windows 用netstat -ano | findstr 62541Linux 用ss -lntp | grep 62541。没有监听就回头查配置里的 BaseAddresses 是否被防火墙拦了。证书目录是否生成。pki/own/certs下应出现一个以应用名命名的 der 文件如果没有证书配置路径写错了。用客户端工具连接浏览。打开 UaExpert添加服务器opc.tcp://localhost:62541连接后展开 Objects能看到服务器启动时创建的节点树。如果报 BadCertificateUntrusted说明证书还没被客户端信任不是服务器代码的问题。这三项都通过说明最小链路是通的接下来才轮到 NodeManager 建模。4. 语义分配NodeManager 建模、数据更新与订阅发布地址空间建模是源码包里你改动量最大的部分。设备表怎么映射成节点、更新频率怎么设定、订阅参数怎么配这些问题没有唯一答案但有稳定可循的步骤。4.1 自定义命名空间与变量节点在 NodeManager 里复制一个设备对象要把它的所有数据点写进地址空间。每个服务器的节点至少属于一个命名空间0 和 1 是 OPC UA 标准保留的自定义命名空间从 2 开始。自定义命名空间在 CustomNodeManager2 构造函数里通过 URI 注册初始化后会返回 NamespaceIndex。同一份源码包如果部署在两套系统上只要配置文件里的命名空间 URI 不变客户端就不需要改动浏览路径。public class DataNodeManager : CustomNodeManager2 { public const string NamespaceUri urn:demo:csharp-opcua-pill; private readonly BaseDataVariableStatedouble _temperature; public DataNodeManager( IServerInternal server, ApplicationConfiguration configuration) : base(server, configuration, NamespaceUri) { } public override void CreateAddressSpace( IDictionaryNodeId, IListIReference externalReferences) { base.CreateAddressSpace(externalReferences); BaseObjectState machine new BaseObjectState(null) { NodeId new NodeId(PillMachine, NamespaceIndex), BrowseName new QualifiedName(PillMachine, NamespaceIndex), DisplayName new LocalizedText(PillMachine) }; _temperature new BaseDataVariableStatedouble(machine) { NodeId new NodeId(Temperature, NamespaceIndex), BrowseName new QualifiedName(Temperature, NamespaceIndex), DisplayName new LocalizedText(Temperature), DataType DataTypes.Double, AccessLevel AccessLevels.CurrentRead, UserAccessLevel AccessLevels.CurrentRead, Value new Variant(25.0), StatusCode StatusCodes.Good, Timestamp DateTime.UtcNow }; machine.AddChild(_temperature); machine.AddReference( ReferenceTypes.HasComponent, false, _temperature.NodeId); AddPredefinedNode(SystemContext, machine); } }这里NodeId(PillMachine, NamespaceIndex)的字符串部分会在客户端显示为ns2;sPillMachine。浏览路径一旦发布出去就不要轻易改 NodeId。AccessLevel用的是位组合只读就是CurrentRead可写再叠加CurrentWrite忘了设置UserAccessLevel的话某些客户端会显示节点存在但不可读。Data 节点的Timestamp要每次更新时同步刷新否则客户端看到的时间戳永远停留在服务器启动那一刻排查时容易被误判为数据假死。4.2 数值更新采集线程与 UI 刷新分离你在上位机里做循环数据采集和 UI 刷新时最常遇到的现象是界面卡顿。采集线程直接更新 WinForms/WPF 控件或者让 OPC UA 服务器的 Timer 回调里同时做 UI 操作都会互相抢占。正确的做法是把采集从 UI 里彻底剥离设备数据由独立后台线程写入服务器地址空间UI 从自己的数据源订阅或定时拉取。这样即使 UI 卡住服务器依然在正常更新数据。Task.Run(async () { while (!cts.IsCancellationRequested) { double value await _plcGateway.ReadTemperatureAsync(0); _temperature.Value new Variant(value); _temperature.Timestamp DateTime.UtcNow; _temperature.ClearChangeMasks(SystemContext, false); await Task.Delay(200, cts.Token); } });后台采集循环里只做两件事读取设备、写入节点状态。ClearChangeMasks的作用是通知内部变更检测机制让订阅客户端感知到值变化。注意采集循环里Task.Delay的取值不能小于设备的真实响应周期你 200ms 读一次设备但 PLC 最快 500ms 才刷新寄存器读到的永远是重复值属于无意义更新。更合理的做法是采集周期对齐设备扫描周期或对齐客户端订阅的发布周期不要各跑各的。4.3 订阅参数拆解发布周期、采样周期与排列组合OPC UA 的实时推送由 Subscription 与 MonitoredItem 两层组成。客户端创建订阅后服务器按PublishingInterval把数据包发给客户端每个监控项按自己的SamplingInterval去检查节点值是否变化。这两个参数经常被混为一谈实际作用对象完全不同。参数作用对象含义建议值PublishingIntervalSubscription数据包发往客户端的周期500~1000ms 普通数据50~200ms 快速采集LifetimeCountSubscription未收到发布请求多久解除订阅计数×发布周期1000 以上MaxKeepAliveCountSubscription无数据变化时允许的空包周期数10~20SamplingIntervalMonitoredItem服务端检测变量变化的时间间隔发布周期的 1/2 以内QueueSizeMonitoredItem缓存未上报数据的容量低速链路需要调大10~50慢速 PLC 点位把 PublishingInterval 设 1000ms 就够运动控制类点位才需要 50ms 以下。注意MaxKeepAliveCount是以发布周期为单位的发布周期 1000ms、MaxKeepAliveCount 10意味着 10 秒才发一次心跳。如果客户端在 15 秒内没有任何数据变化某些连接池会先断开这时调 MaxKeepAliveCount 比调线程数更有效。监控项数量多时要控制单个订阅里的监控项数量。一个客户端开 200 个发布周期为 50ms 的订阅比开一个订阅挂 200 个监控项的做法差很多前者产生 200 个发布循环后者在服务器里只占一个定时发布循环复用同一条链路负载小一个数量级。4.4 Method 建模让客户端能远程复位设备源码包里通常只包含数据节点但现场经常要远程复位、切换配方或者触发某个动作。OPC UA 里这类操作应该建模成 Method而不是用 Variable 字段做标志位。方法节点的好处是客户端调用时会带参数服务器端可以做参数校验失败会返回标准状态码避免上位机自己维护一套状态机。MethodState resetMethod new MethodState(machine) { NodeId new NodeId(Reset, NamespaceIndex), BrowseName new QualifiedName(Reset, NamespaceIndex), DisplayName new LocalizedText(Reset), Executable true, UserExecutable true }; resetMethod.OnCallMethod async (ISystemContext context, MethodState method, IListobject inputArguments, IListobject outputArguments) { // 校验参数后执行设备复位 await _plcGateway.ResetDeviceAsync(); return new Listobject(); };这里 OnCallMethod 委托的签名在 .NET Standard 库里是GenericMethodCalledEventHandler返回类型是 Task 而不是 void。签名写不对的话方法节点虽然能被客户端浏览到但调用时会报内部错误。UserExecutable和Executable要都设为 true否则客户端看到的是一堆灰色不可点的方法。方法参数用InputArguments属性声明备注里写明参数类型客户端才能正确显示输入框。5. 证书、并发与脚本验证源码落地前的临门一脚服务器代码写完之后现场出问题最多的地方不在业务逻辑而在证书信任、并发限制和验证方式上。这三件事处理完这套 C# OPC UA 服务器源码才算真正能交出去。5.1 三个常见的证书信任坑第一把pki/own目录在开发机之间复制共享。多台服务器共用同一个 ApplicationCertificate证书吊销列表无法区分设备现场排查时会把问题指向错误方向。第二开发证书的有效期只有一年发布到生产环境前要看下到期时间否则半夜三点报 BadCertificateTimeInvalid。第三客户端第一次连接时服务器会把未知客户端证书丢进pki/rejected目录但很多人不知道看这个目录。客户端连接失败时先去看 rejected 里有没有新文件比翻日志更快。5.2 客户端变多时先调订阅别加线程会话数上来之后服务器慢的第一反应不要是加线程池而是看订阅参数。每个会话都有独立订阅但服务器内部会把所有订阅放到一个发布循环里统一调度。限制单订阅的MaxNotificationsPerPublish防止一次发布包太大导致 TCP 分片限制单个绘画的MaxMonitoredItems防止客户端一次创建上万个监控项把服务器内存打爆。这些配置都在ServerConfiguration的MaxXxx参数里把默认值调小一半并发稳定性通常反而更好。5.3 用脚本验证订阅是真推还是空包UaExpert 能浏览节点不代表订阅推送就正常最可靠的方式是写一个短脚本直接订阅变量观察回调是否持续触发。下面用 python-opcua 库做端到端验证import time from opcua import Client, ua # 连接服务器 client Client(opc.tcp://127.0.0.1:62541) client.connect() # 按 String NodeId 直接访问地址空间节点 temp_node client.get_node(ns2;sTemperature) print(Current value:, temp_node.get_value()) # 创建订阅回调里打印变化 def change_callback(node, val, data): print(f{node} - {data.value} {data.timestamp}) sub client.create_subscription(500, change_callback) handle sub.subscribe_data_change(temp_node) # 观察 3 秒确认回调持续触发 time.sleep(3) sub.unsubscribe(handle) client.disconnect()create_subscription(500)的 500 是发布周期单位毫秒必须和服务端的 PublishingInterval 大致匹配。如果脚本里 3 秒内没有任何回调触发而 UaExpert 却能读到值优先怀疑服务端的数据变更掩码没有正确清除或者采样间隔远大于发布周期。把脚本里的subscribe_data_change换成subscribe_events可以用来验证事件通知。这类验证脚本建议直接留在源码包里配合 git hook 在每次改完 NodeManager 后跑一遍比测试人员手动连接快得多。本文还有配套的精品资源点击获取