.NET
PDComm.OpcUa어셈블리로 서버와 클라이언트를 한 콘솔 응용 프로그램에서 실행합니다.- 설치한 SDK의 어셈블리와 네이티브 라이브러리로 예제를 빌드합니다.
- 리소스 해제와 예외 처리 방식을 확인합니다.
준비물
섹션 제목: “준비물”- PDComm OPC UA SDK
dotnet패키지(pdcomm-opcua-dotnet-darwin-arm64.tar.gz) - .NET 8 런타임과 .NET SDK(.NET 8 이상). 이 페이지는 .NET SDK 10.0.401과 .NET 8 런타임으로 실행했습니다.
- PKI와 신뢰 설정에서 만든
$PKI의 인증서. 터미널 두 개.
dotnet 패키지에는 다음이 들어 있습니다.
| 경로 | 내용 |
|---|---|
lib/dotnet/PDComm.OpcUa.dll |
.NET 어셈블리 |
bindings/dotnet/PDComm.OpcUa/ |
어셈블리의 C# 소스 |
lib/ |
네이티브 라이브러리 libpdcomm_opcua_ffi.dylib와 OpenSSL 3 런타임 |
examples/opcua/dotnet/ |
예제 프로젝트 |
1. 예제 빌드
섹션 제목: “1. 예제 빌드”실습 폴더에서 패키지를 풀고 예제 프로젝트를 작업 폴더로 복사합니다. 패키지 안의 파일은 고치지 않습니다.
tar -xzf pdcomm-opcua-dotnet-darwin-arm64.tar.gzexport OPCUA_DOTNET_SDK="$PWD/pdcomm-opcua-dotnet-darwin-arm64"cp -R "$OPCUA_DOTNET_SDK/examples/opcua/dotnet" my-opcua-appcd my-opcua-appdotnet build OpcUaExample.csproj -c Release -p:PdcommOpcUaSdk="$OPCUA_DOTNET_SDK" -o outBuild succeeded. 0 Warning(s) 0 Error(s)빌드하면 어셈블리, 네이티브 라이브러리, OpenSSL 런타임이 out에 함께 복사됩니다.
ls outOpcUaExampleOpcUaExample.deps.jsonOpcUaExample.dllOpcUaExample.pdbOpcUaExample.runtimeconfig.jsonPDComm.OpcUa.dlllibcrypto.3.dyliblibpdcomm_opcua_ffi.dyliblibssl.3.dylib프로젝트 파일은 PdcommOpcUaSdk 속성으로 SDK 위치를 받습니다.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <!-- Default: the SDK root that contains examples/opcua/dotnet. --> <PdcommOpcUaSdk Condition="'$(PdcommOpcUaSdk)' == ''">$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)../../..'))</PdcommOpcUaSdk> </PropertyGroup> <ItemGroup> <Reference Include="PDComm.OpcUa"> <HintPath>$(PdcommOpcUaSdk)/lib/dotnet/PDComm.OpcUa.dll</HintPath> </Reference> <!-- The native SDK library and its OpenSSL runtime are loaded from the application directory. --> <None Include="$(PdcommOpcUaSdk)/lib/*.dylib;$(PdcommOpcUaSdk)/lib/*.so*;$(PdcommOpcUaSdk)/bin/*.dll" CopyToOutputDirectory="PreserveNewest" Visible="false" /> </ItemGroup> <Target Name="ValidatePdcommOpcUaSdk" BeforeTargets="BeforeBuild"> <Error Condition="!Exists('$(PdcommOpcUaSdk)/lib/dotnet/PDComm.OpcUa.dll')" Text="Set PdcommOpcUaSdk to the extracted PDComm OPC UA SDK root." /> </Target></Project>2. 코드 읽기
섹션 제목: “2. 코드 읽기”// PDComm OPC UA .NET example: a secure server and a client in one console app.// server BIND_ADDRESS ENDPOINT_URL SERVER_CERT_DER SERVER_KEY_PEM TRUSTED_CLIENT_DER [--seconds N]// client ENDPOINT CLIENT_CERT_DER CLIENT_KEY_PEM TRUSTED_SERVER_DER [--policy P] [--mode M] [--node ID] [--write VALUE]// Exit codes: 0 success, 1 a Bad OPC UA status, 2 invalid arguments, 3 failure.// Credentials are never printed.using System.Diagnostics;using System.Runtime.InteropServices;using System.Security.Cryptography;using System.Text;using PDComm.OpcUa;using PDComm.OpcUa.Example;
const string Usage = """Usage: OpcUaExample server BIND_ADDRESS ENDPOINT_URL SERVER_CERT_DER SERVER_KEY_PEM TRUSTED_CLIENT_DER [--seconds N] OpcUaExample client ENDPOINT CLIENT_CERT_DER CLIENT_KEY_PEM TRUSTED_SERVER_DER [--policy aes256sha256rsapss|aes128sha256rsaoaep|basic256sha256|none] [--mode signandencrypt|sign|none] [--node ID] [--write VALUE]""";
try{ if (args.Length >= 6 && args[0] == "server") return RunServer(args[1..]); if (args.Length >= 5 && args[0] == "client") return RunClient(args[1..]); Console.Error.WriteLine(Usage); return 2;}catch (ArgumentException e){ Console.Error.WriteLine($"Error: {e.Message}"); return 2;}catch (OpcUaException e){ Console.Error.WriteLine($"Error: SDK status {e.NativeStatus}: {e.Message}"); return 3;}catch (Exception e) when (e is IOException or InvalidDataException or TimeoutException or PlatformNotSupportedException){ Console.Error.WriteLine($"Error: {e.Message}"); return 3;}
static byte[] Load(string path){ var info = new FileInfo(path); if (!info.Exists || info.Length == 0 || info.Length > 64 * 1024) throw new IOException($"Cannot read {path}"); return File.ReadAllBytes(path);}
static Dictionary<string, string> Options(string[] values, params string[] allowed){ if (values.Length % 2 != 0) throw new ArgumentException("Options need a value"); var result = new Dictionary<string, string>(); for (int i = 0; i < values.Length; i += 2) { if (!allowed.Contains(values[i]) || !result.TryAdd(values[i], values[i + 1])) throw new ArgumentException($"Unknown or repeated option {values[i]}"); } return result;}
static int RunServer(string[] a){ var options = Options(a[5..], "--seconds"); uint seconds = 0; if (options.TryGetValue("--seconds", out var text) && (!uint.TryParse(text, out seconds) || seconds is 0 or > 86400)) throw new ArgumentException("--seconds must be between 1 and 86400"); byte[] key = Load(a[3]); Server server; try { server = Server.Create(a[0], new ServerOptions { Endpoint = a[1], CertificateDer = Load(a[2]), PrivateKeyPem = key, TrustedClientDer = Load(a[4]), }); } finally { CryptographicOperations.ZeroMemory(key); } // The SDK copied the identity. using (server) { server.AddVariable(UaBinary.NodeId(1001), "Value", UaBinary.Int32Variant(42), VariableFlags.Writable | VariableFlags.Public); server.AddVariable(UaBinary.NodeId(1002), "Limit", UaBinary.Int32Variant(100), VariableFlags.Public); server.AddVariable(UaBinary.NodeId(1003), "Protected", UaBinary.Int32Variant(7), VariableFlags.Writable); // Register the stop signals before announcing readiness. using var stop = new ManualResetEventSlim(); using var interrupt = PosixSignalRegistration.Create(PosixSignal.SIGINT, c => { c.Cancel = true; stop.Set(); }); using var terminate = PosixSignalRegistration.Create(PosixSignal.SIGTERM, c => { c.Cancel = true; stop.Set(); }); // Windows Ctrl+Break (SIGQUIT in .NET), e.g. from a service manager. using var breakSignal = OperatingSystem.IsWindows() ? PosixSignalRegistration.Create(PosixSignal.SIGQUIT, c => { c.Cancel = true; stop.Set(); }) : null; using (var address = server.LocalAddress()) Console.WriteLine($"Listening on {Encoding.UTF8.GetString(address.Copy())}"); var clock = Stopwatch.StartNew(); while (!stop.IsSet && (seconds == 0 || clock.Elapsed < TimeSpan.FromSeconds(seconds))) { server.Poll(); stop.Wait(1); } using var snapshot = server.ReadValue(UaBinary.NodeId(1001)); var value = UaBinary.DecodeDataValue(snapshot.Copy()); server.Close(); Console.WriteLine($"Final Value={value.Value}"); Console.WriteLine("Server stopped"); return 0; }}
static int RunClient(string[] a){ var options = Options(a[4..], "--policy", "--mode", "--node", "--write"); var policy = options.GetValueOrDefault("--policy", "aes256sha256rsapss") switch { "aes256sha256rsapss" => SecurityPolicy.Aes256Sha256RsaPss, "aes128sha256rsaoaep" => SecurityPolicy.Aes128Sha256RsaOaep, "basic256sha256" => SecurityPolicy.Basic256Sha256, "none" => SecurityPolicy.None, _ => throw new ArgumentException("Unknown --policy"), }; var mode = options.GetValueOrDefault("--mode", policy == SecurityPolicy.None ? "none" : "signandencrypt") switch { "signandencrypt" => SecurityMode.SignAndEncrypt, "sign" => SecurityMode.Sign, "none" => SecurityMode.None, _ => throw new ArgumentException("Unknown --mode"), }; if ((policy == SecurityPolicy.None) != (mode == SecurityMode.None)) throw new ArgumentException("--policy none and --mode none must be used together"); uint node = 1001; if (options.TryGetValue("--node", out var nodeText) && (!uint.TryParse(nodeText, out node) || node == 0)) throw new ArgumentException("Invalid --node"); int? write = null; if (options.TryGetValue("--write", out var writeText)) write = int.TryParse(writeText, out int parsed) ? parsed : throw new ArgumentException("Invalid --write");
Console.WriteLine($"PDComm OPC UA ABI 0x{Client.AbiVersion:x8}"); using (var endpoints = Client.Discover(a[0], 5000)) foreach (var (url, endpointMode, uri) in UaBinary.DecodeEndpoints(endpoints.Copy())) Console.WriteLine($"Endpoint {url} mode={(SecurityMode)endpointMode} policy={uri}"); byte[] key = Load(a[2]); Client client; try { client = Client.Connect(a[0], new ClientOptions { Policy = policy, Mode = mode, AllowNone = policy == SecurityPolicy.None, TimeoutMs = 5000, CertificateDer = Load(a[1]), PrivateKeyPem = key, TrustedServerDer = Load(a[3]), }); } finally { CryptographicOperations.ZeroMemory(key); } // The SDK copied the identity. using (client) { client.ActivateAnonymous(); Console.WriteLine("Session activated (anonymous)"); int result = 0; if (write is int value) { var (service, status) = UaBinary.DecodeWrite(Exchange(client, UaBinary.Write(node, value))); Console.WriteLine($"Write ns=2;i={node} value={value} service=0x{service:x8} status=0x{status:x8}"); if (((service | status) & 0x80000000u) != 0) result = 1; } var (readService, data) = UaBinary.DecodeRead(Exchange(client, UaBinary.Read(node))); string shown = data?.Value?.ToString() ?? "none"; uint itemStatus = data?.Status ?? 0; Console.WriteLine($"Read ns=2;i={node} value={shown} service=0x{readService:x8} status=0x{itemStatus:x8}"); if (((readService | itemStatus) & 0x80000000u) != 0) result = 1; client.Close(); Console.WriteLine("Session closed"); return result; }}
// Sends one request and polls the client until its response arrives.static byte[] Exchange(Client client, byte[] request){ uint id = client.Begin(request); var clock = Stopwatch.StartNew(); while (clock.Elapsed < TimeSpan.FromSeconds(5)) { client.Poll(); using var response = client.Take(id); if (response is not null) return response.Copy(); Thread.Sleep(1); } throw new TimeoutException("Response timed out");}- 서버.
Server.Create가 주소에 바인드하고,Server.AddVariable로 변수 세 개를 추가합니다.PosixSignalRegistration으로 Ctrl+C와 SIGTERM을 받아 루프를 끝냅니다. - 클라이언트.
Client.Connect가 보안 채널과 세션을 엽니다. 연결 직후CryptographicOperations.ZeroMemory로 개인 키 배열을 지웁니다. - 요청.
Exchange가Client.Begin,Poll,Client.Take를 반복합니다.Take는 응답이 없으면null입니다. - 해제.
Client,Server,Response는IDisposable입니다.using으로 네이티브 핸들을 해제합니다. - 오류. C ABI 호출이 실패하면
OpcUaException이 납니다.NativeStatus가 C ABI 상태입니다.
요청 메시지는 UaBinary.cs가 만듭니다. C 예제의 ua_binary.h처럼 Read와 Write만 인코딩하는 작은 도우미입니다.
using System.Buffers.Binary;using System.Text;
namespace PDComm.OpcUa.Example;
// Minimal OPC UA Binary (Part 6) helpers for this example: one Read or Write of// the Value attribute of a numeric NodeId in namespace 2, scalar Int32 only.// Use a complete encoder in production code for every service you call.internal static class UaBinary{ private const uint ReadRequest = 631, ReadResponse = 634, WriteRequest = 673, WriteResponse = 676; private const uint ServiceFault = 397, GetEndpointsResponse = 431, ValueAttribute = 13; private const byte Int32Type = 6;
public readonly record struct DataValue(uint Status, int? Value);
public static byte[] NodeId(uint id) => Encode(w => PutNodeId(w, 2, id)); public static byte[] Int32Variant(int value) => Encode(w => { w.Write(Int32Type); w.Write(value); });
public static byte[] Read(uint id) => Encode(w => { PutNodeId(w, 0, ReadRequest); PutRequestHeader(w); w.Write(0.0); w.Write(3); w.Write(1); // maxAge, timestampsToReturn=Neither, one node PutValueId(w, id); w.Write((ushort)0); w.Write(-1); // default dataEncoding });
public static byte[] Write(uint id, int value) => Encode(w => { PutNodeId(w, 0, WriteRequest); PutRequestHeader(w); w.Write(1); PutValueId(w, id); w.Write((byte)1); w.Write(Int32Type); w.Write(value); // DataValue with an Int32 value });
// Returns (ServiceResult, DataValue). A ServiceFault has no DataValue. public static (uint Service, DataValue? Value) DecodeRead(byte[] body) { var r = new Reader(body); (uint id, uint service) = r.ResponseHeader(); if (id == ServiceFault) { r.End(); return (service, null); } if (id != ReadResponse || r.Length() != 1) throw new InvalidDataException("Unexpected ReadResponse"); var value = r.DataValue(); r.SkipDiagnosticInfos(); r.End(); return (service, value); }
public static (uint Service, uint Status) DecodeWrite(byte[] body) { var r = new Reader(body); (uint id, uint service) = r.ResponseHeader(); if (id == ServiceFault) { r.End(); return (service, 0); } if (id != WriteResponse || r.Length() != 1) throw new InvalidDataException("Unexpected WriteResponse"); uint status = r.UInt32(); r.SkipDiagnosticInfos(); r.End(); return (service, status); }
public static DataValue DecodeDataValue(byte[] body) { var r = new Reader(body); var value = r.DataValue(); r.End(); return value; }
// Lists (endpoint URL, security mode, security policy URI) from GetEndpoints. public static List<(string Url, uint Mode, string Policy)> DecodeEndpoints(byte[] body) { var r = new Reader(body); (uint id, uint service) = r.ResponseHeader(); if (id != GetEndpointsResponse || (service & 0x80000000u) != 0) throw new InvalidDataException("Unexpected GetEndpointsResponse"); var result = new List<(string, uint, string)>(); for (int i = r.Length(); i > 0; --i) { string url = r.Text(); r.Text(); r.Text(); r.SkipLocalizedText(); r.UInt32(); r.Text(); r.Text(); for (int j = r.Length(); j > 0; --j) r.Text(); r.Text(); uint mode = r.UInt32(); string policy = r.Text(); for (int j = r.Length(); j > 0; --j) { r.Text(); r.UInt32(); r.Text(); r.Text(); r.Text(); } r.Text(); r.Take(1); result.Add((url, mode, policy)); } r.End(); return result; }
private static byte[] Encode(Action<BinaryWriter> body) { using var stream = new MemoryStream(); using (var writer = new BinaryWriter(stream)) body(writer); // little-endian return stream.ToArray(); } private static void PutNodeId(BinaryWriter w, ushort ns, uint id) { if (ns == 0 && id <= 0xff) { w.Write((byte)0); w.Write((byte)id); } else if (ns <= 0xff && id <= 0xffff) { w.Write((byte)1); w.Write((byte)ns); w.Write((ushort)id); } else { w.Write((byte)2); w.Write(ns); w.Write(id); } } // The client replaces authenticationToken, timestamp and requestHandle. private static void PutRequestHeader(BinaryWriter w) { PutNodeId(w, 0, 0); w.Write(0L); w.Write(0u); w.Write(0u); w.Write(-1); w.Write(5000u); PutNodeId(w, 0, 0); w.Write((byte)0); } private static void PutValueId(BinaryWriter w, uint id) { PutNodeId(w, 2, id); w.Write(ValueAttribute); w.Write(-1); }
private sealed class Reader(byte[] data) { private int offset; public ReadOnlySpan<byte> Take(int n) { if (n < 0 || n > data.Length - offset) throw new InvalidDataException("Truncated OPC UA message"); var span = data.AsSpan(offset, n); offset += n; return span; } public byte Byte() => Take(1)[0]; public ushort UInt16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2)); public uint UInt32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4)); public int Length() { int n = (int)UInt32(); if (n < -1 || n > data.Length - offset) throw new InvalidDataException("Invalid OPC UA length"); return n; } public string Text() { int n = Length(); return n <= 0 ? "" : Encoding.UTF8.GetString(Take(n)); } public void End() { if (offset != data.Length) throw new InvalidDataException("Trailing OPC UA bytes"); } public void SkipNodeId() { switch (Byte()) { case 0: Take(1); break; case 1: Take(3); break; case 2: Take(6); break; case 3: case 5: Take(2); Text(); break; case 4: Take(18); break; default: throw new InvalidDataException("Invalid NodeId"); } } public void SkipLocalizedText() { byte mask = Byte(); if ((mask & 1) != 0) Text(); if ((mask & 2) != 0) Text(); } public void SkipDiagnosticInfo(int depth = 0) { byte mask = Byte(); if (depth > 4 || (mask & 0x80) != 0) throw new InvalidDataException("Invalid DiagnosticInfo"); foreach (byte bit in new byte[] { 0x01, 0x02, 0x08, 0x04 }) if ((mask & bit) != 0) Take(4); if ((mask & 0x10) != 0) Text(); if ((mask & 0x20) != 0) Take(4); if ((mask & 0x40) != 0) SkipDiagnosticInfo(depth + 1); } public void SkipDiagnosticInfos() { for (int i = Length(); i > 0; --i) SkipDiagnosticInfo(); } public (uint Id, uint Service) ResponseHeader() { uint id = Byte() switch { 0 => Byte(), 1 => Byte() == 0 ? UInt16() : throw new InvalidDataException("Unexpected namespace"), 2 => UInt16() == 0 ? UInt32() : throw new InvalidDataException("Unexpected namespace"), _ => throw new InvalidDataException("Invalid encoding NodeId"), }; Take(12); uint service = UInt32(); SkipDiagnosticInfo(); for (int i = Length(); i > 0; --i) Text(); SkipNodeId(); byte encoding = Byte(); if (encoding is 1 or 2) Text(); else if (encoding != 0) throw new InvalidDataException("Invalid ExtensionObject"); return (id, service); } public DataValue DataValue() { byte mask = Byte(); int? value = null; uint status = 0; if ((mask & 0xc0) != 0) throw new InvalidDataException("Invalid DataValue"); if ((mask & 0x01) != 0) { if (Byte() != Int32Type) throw new InvalidDataException("Only scalar Int32 values are shown by this example"); value = (int)UInt32(); } if ((mask & 0x02) != 0) status = UInt32(); if ((mask & 0x04) != 0) Take(8); if ((mask & 0x10) != 0) Take(2); if ((mask & 0x08) != 0) Take(8); if ((mask & 0x20) != 0) Take(2); return new DataValue(status, value); } }}3. 서버 실행
섹션 제목: “3. 서버 실행”첫 번째 터미널의 my-opcua-app 폴더에서 서버를 실행합니다.
dotnet out/OpcUaExample.dll server 127.0.0.1:4840 opc.tcp://localhost:4840/pdcomm "$PKI/server.der" "$PKI/server.key.pem" "$PKI/client.der"Listening on 127.0.0.1:48404. 클라이언트 실행
섹션 제목: “4. 클라이언트 실행”두 번째 터미널의 my-opcua-app 폴더에서 값 -5를 쓰고 읽습니다.
dotnet out/OpcUaExample.dll client opc.tcp://localhost:4840/pdcomm "$PKI/client.der" "$PKI/client.key.pem" "$PKI/server.der" --write -5엔드포인트 목록 뒤의 출력은 다음과 같습니다.
Session activated (anonymous)Write ns=2;i=1001 value=-5 service=0x00000000 status=0x00000000Read ns=2;i=1001 value=-5 service=0x00000000 status=0x00000000Session closed옵션은 C 예제와 같습니다(--policy, --mode, --node, --write). Basic256Sha256과 Sign으로 읽습니다.
dotnet out/OpcUaExample.dll client opc.tcp://localhost:4840/pdcomm "$PKI/client.der" "$PKI/client.key.pem" "$PKI/server.der" --policy basic256sha256 --mode signSession activated (anonymous)Read ns=2;i=1001 value=-5 service=0x00000000 status=0x00000000Session closed5. 오류 확인
섹션 제목: “5. 오류 확인”서버가 광고하지 않는 None 정책을 요청합니다.
dotnet out/OpcUaExample.dll client opc.tcp://localhost:4840/pdcomm "$PKI/client.der" "$PKI/client.key.pem" "$PKI/server.der" --policy nonePDComm OPC UA ABI 0x00000002Error: SDK status 5: Requested OPC UA endpoint not advertised신뢰할 서버 인증서를 잘못 지정합니다.
dotnet out/OpcUaExample.dll client opc.tcp://localhost:4840/pdcomm "$PKI/client.der" "$PKI/client.key.pem" "$PKI/client.der"Error: SDK status 5: OPC UA security CertificateUntrusted두 경우 모두 종료 코드는 3입니다.
6. 정리
섹션 제목: “6. 정리”서버 터미널에서 Ctrl+C를 누릅니다.
Final Value=-5Server stopped자주 생기는 오류
섹션 제목: “자주 생기는 오류”| 증상 | 원인과 조치 |
|---|---|
빌드 오류 Set PdcommOpcUaSdk to the extracted PDComm OPC UA SDK root. |
-p:PdcommOpcUaSdk의 경로에 lib/dotnet/PDComm.OpcUa.dll이 없습니다. 패키지를 푼 폴더를 지정합니다. |
서버가 kill -INT에 멈추지 않음 |
스크립트가 서버를 백그라운드(&)로 실행하면 셸이 SIGINT를 무시하도록 설정하고, .NET 런타임은 그 설정을 따릅니다. kill -TERM으로 SIGTERM을 보냅니다. |