简介:这是一份面向工业自动化、设备联网与远程监控场景的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 | 设备、机台、任务实例 | BaseObjectState |
| Variable | 数值、状态、属性 | BaseDataVariableState<T> |
| Method | 可远程调用的操作 | MethodState |
| View | 面向特定角色的数据子集 | ViewState |
| DataType | 自定义数据类型定义 | 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 xmlns="http://opcfoundation.org/UA/2008/02/Types.xsd">opc.tcp://0.0.0.0:62541</String> </BaseAddresses> <SecurityPolicies> <ServerSecurityPolicy> <SecurityPolicy>Basic256Sha256</SecurityPolicy> </ServerSecurityPolicy> <ServerSecurityPolicy> <SecurityPolicy>None</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 | 加载配置、检查证书、调用 Start |
| Server.cs | 继承 StandardServer,装配 NodeManager |
| DataNodeManager.cs | 继承 CustomNodeManager2,创建地址空间节点 |
| appsettings.xml | 端点、安全策略、证书目录、日志路径 |
这里没有包含 .csproj 是因为任何支持 .NET 6/8 的 SDK 项目模板都能直接引用 NuGet 包 OPCFoundation.NetStandard.Opc.Ua 编译。源码包里如果还有什么 DbLogger、UserManager 之类的目录,先判断是不是周边功能,不要在第一次编译时被它们卡住。
3.2 启动链路:ApplicationInstance、证书检查与 StandardServer
Program.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 62541,Linux 用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 BaseDataVariableState<double> _temperature; public DataNodeManager( IServerInternal server, ApplicationConfiguration configuration) : base(server, configuration, NamespaceUri) { } public override void CreateAddressSpace( IDictionary<NodeId, IList<IReference>> 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 BaseDataVariableState<double>(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)的字符串部分会在客户端显示为ns=2;s=PillMachine。浏览路径一旦发布出去,就不要轻易改 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去检查节点值是否变化。这两个参数经常被混为一谈,实际作用对象完全不同。
| 参数 | 作用对象 | 含义 | 建议值 |
|---|---|---|---|
| PublishingInterval | Subscription | 数据包发往客户端的周期 | 500~1000ms 普通数据,50~200ms 快速采集 |
| LifetimeCount | Subscription | 未收到发布请求多久解除订阅,计数×发布周期 | 1000 以上 |
| MaxKeepAliveCount | Subscription | 无数据变化时允许的空包周期数 | 10~20 |
| SamplingInterval | MonitoredItem | 服务端检测变量变化的时间间隔 | 发布周期的 1/2 以内 |
| QueueSize | MonitoredItem | 缓存未上报数据的容量,低速链路需要调大 | 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, IList<object> inputArguments, IList<object> outputArguments) => { // 校验参数后执行设备复位 await _plcGateway.ResetDeviceAsync(); return new List<object>(); };这里 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("ns=2;s=Temperature") 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 后跑一遍,比测试人员手动连接快得多。
本文还有配套的精品资源,点击获取