diff --git a/.github/workflows/Build_VIPM_Library.yml b/.github/workflows/Build_VIPM_Library.yml index b20551e..2cd9da0 100644 --- a/.github/workflows/Build_VIPM_Library.yml +++ b/.github/workflows/Build_VIPM_Library.yml @@ -18,6 +18,7 @@ on: - '**.json' - '**.yml' - 'c/**' + - 'csharp/**' push: paths-ignore: @@ -29,6 +30,7 @@ on: - '**.json' - '**.yml' - 'c/**' + - 'csharp/**' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.github/workflows/Check_Broken_VIs.yml b/.github/workflows/Check_Broken_VIs.yml index d618cc5..0e1f463 100644 --- a/.github/workflows/Check_Broken_VIs.yml +++ b/.github/workflows/Check_Broken_VIs.yml @@ -16,6 +16,7 @@ on: - '**.json' - '**.yml' - 'c/**' + - 'csharp/**' pull_request: branches: @@ -31,6 +32,7 @@ on: - '**.json' - '**.yml' - 'c/**' + - 'csharp/**' # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: diff --git a/.gitignore b/.gitignore index cd46bc3..9ecfa3e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,19 @@ c/**/*.ilk c/**/*.idb c/**/*.tlog +# C# / .NET / Visual Studio build artefacts (under csharp/) +csharp/**/bin/ +csharp/**/obj/ +csharp/**/Debug/ +csharp/**/Release/ +csharp/**/x64/ +csharp/**/x86/ +csharp/**/.vs/ +csharp/**/*.user +csharp/**/*.suo + # Python build artefacts (under python/) python/**/__pycache__/ python/**/*.pyc python/**/*.pyo -python/**/.pytest_cache/ \ No newline at end of file +python/**/.pytest_cache/ diff --git a/csharp/README.md b/csharp/README.md new file mode 100644 index 0000000..4fcbbfd --- /dev/null +++ b/csharp/README.md @@ -0,0 +1,99 @@ +# CSM MassData Parameter Support —— C# 移植 + +本目录提供 CSM MassData Parameter Support 插件的 C# 实现, +接口与 [`addons/MassData-Parameter`](../addons/MassData-Parameter) +中的 LabVIEW VI 以及 [`c/`](../c) 目录下的 C 移植版本保持等价语义, +可与 LabVIEW / C 端无缝互通同一份 MassData 参数字符串。 + +## 目录结构 + +``` +csharp/ +├── src/CsmMassData/ # .NET 类库(API 实现) +│ ├── CsmMassData.cs # 公开 API(中文 XML 文档注释) +│ ├── CsmMassDataException.cs # 错误异常类 +│ ├── CsmMassDataOperation.cs # 读 / 写操作描述结构体 +│ ├── CsmMassDataStatus.cs # 与 C / LabVIEW 端对齐的状态码枚举 +│ └── CsmMassData.csproj # 类库工程 +├── _test/ +│ └── vs/ +│ ├── CsmMassData.Tests.sln # Visual Studio 2026 解决方案 +│ └── CsmMassData.Tests/ # MSTest 测试工程 +│ ├── CsmMassDataTests.cs # 端到端断言 +│ ├── MSTestSettings.cs # 全局禁用并行执行 +│ └── CsmMassData.Tests.csproj +└── README.md # 本文件 +``` + +## 接口对应关系 + +每个 C# 静态方法都是对应 LabVIEW VI 与 C 函数的逐字翻译: +名称相同、参数顺序一致、语义完全等价。 + +| LabVIEW VI | C# 方法 (`Csm.MassData.CsmMassData`) | +| --------------------------------------------------- | --------------------------------------------------------- | +| `CSM - Config MassData Parameter Cache Size.vi` | `ConfigMassDataParameterCacheSize` | +| `CSM - Convert MassData to Argument.vim` | `ConvertMassDataToArgument` | +| `CSM - Convert MassData to Argument With DataType.vim` | `ConvertMassDataToArgumentWithDataType` | +| `CSM - Convert Argument to MassData.vim` | `ConvertArgumentToMassData` | +| `CSM - MassData Data Type String.vi` | `MassDataDataTypeString` | +| `CSM - MassData Parameter Status.vi` | `MassDataParameterStatus` | + +参考字符串格式与 LabVIEW / C 端完全一致: + +``` +Start:;Size:[;DataType:] +``` + +## 错误处理 + +所有方法都遵循 .NET 的惯用做法:成功时返回结果值,失败时抛出 +`CsmMassDataException`。该异常通过 `Status` 属性暴露与 C 端 +`csm_massdata_status_t` 数值一致的 `CsmMassDataStatus` 枚举, +方便在 C# / C / LabVIEW 三种语言之间共享错误码语义。 + +## 线程安全 + +所有公开 API 都是线程安全的: + +- 静态字段的初始化由 CLR 保证“仅一次且对其它线程可见”。 +- 后续访问通过 `lock` 串行化。 +- 建议在进入多线程阶段前主动调用 + `CsmMassData.ConfigMassDataParameterCacheSize(...)` 完成缓冲区配置, + 以获得最佳性能。 + +## 构建与运行测试 + +### Visual Studio 2026(推荐) + +1. 用 Visual Studio 2026 打开 `csharp/_test/vs/CsmMassData.Tests.sln`。 +2. 在“测试资源管理器”中运行 `CsmMassData.Tests` 中的全部用例, + 或直接按 Ctrl+RA 运行所有测试。 + +### 命令行(任意 .NET 8 SDK) + +```bash +cd csharp/_test/vs +dotnet test +``` + +## 在自己的工程中链接 + +```bash +dotnet add reference path/to/csharp/src/CsmMassData/CsmMassData.csproj +``` + +典型的编码 / 解码往返如下: + +```csharp +using Csm.MassData; + +byte[] samples = new byte[8 * 1024]; + +CsmMassData.ConfigMassDataParameterCacheSize(64 * 1024 * 1024); // 64 MiB 缓冲区 +string arg = CsmMassData.ConvertMassDataToArgumentWithDataType(samples, "1D DBL"); + +// ... 通过 CSM 总线传递 arg ... + +byte[] restored = CsmMassData.ConvertArgumentToMassData(arg); +``` diff --git a/csharp/_test/vs/CsmMassData.Tests.sln b/csharp/_test/vs/CsmMassData.Tests.sln new file mode 100644 index 0000000..ec9b588 --- /dev/null +++ b/csharp/_test/vs/CsmMassData.Tests.sln @@ -0,0 +1,48 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.0.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsmMassData", "..\..\src\CsmMassData\CsmMassData.csproj", "{FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsmMassData.Tests", "CsmMassData.Tests\CsmMassData.Tests.csproj", "{D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Debug|x64.ActiveCfg = Debug|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Debug|x64.Build.0 = Debug|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Debug|x86.ActiveCfg = Debug|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Debug|x86.Build.0 = Debug|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Release|Any CPU.Build.0 = Release|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Release|x64.ActiveCfg = Release|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Release|x64.Build.0 = Release|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Release|x86.ActiveCfg = Release|Any CPU + {FF61A168-BF0A-47DF-9FAD-47A0BCA87DC3}.Release|x86.Build.0 = Release|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Debug|x64.ActiveCfg = Debug|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Debug|x64.Build.0 = Debug|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Debug|x86.ActiveCfg = Debug|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Debug|x86.Build.0 = Debug|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Release|Any CPU.Build.0 = Release|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Release|x64.ActiveCfg = Release|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Release|x64.Build.0 = Release|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Release|x86.ActiveCfg = Release|Any CPU + {D4B99627-DB4E-4C0F-B9F0-8EFD5B138468}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/csharp/_test/vs/CsmMassData.Tests/CsmMassData.Tests.csproj b/csharp/_test/vs/CsmMassData.Tests/CsmMassData.Tests.csproj new file mode 100644 index 0000000..5ede4ae --- /dev/null +++ b/csharp/_test/vs/CsmMassData.Tests/CsmMassData.Tests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + latest + enable + enable + + + + + + + + + + + + + + + diff --git a/csharp/_test/vs/CsmMassData.Tests/CsmMassDataTests.cs b/csharp/_test/vs/CsmMassData.Tests/CsmMassDataTests.cs new file mode 100644 index 0000000..0a7ab20 --- /dev/null +++ b/csharp/_test/vs/CsmMassData.Tests/CsmMassDataTests.cs @@ -0,0 +1,254 @@ +// +// MIT 许可证 —— 详见仓库根目录的 LICENSE 文件。 +// + +using Csm.MassData; + +namespace CsmMassData.Tests; + +/// +/// CSM MassData C# 接口的端到端测试,覆盖 +/// 中公开的所有 API: +/// +/// 缓冲区配置与状态查询 +/// 不带数据类型的编码 / 解码往返 +/// 带数据类型的编码 / 解码往返 +/// 数据类型解析(CSM - MassData Data Type String) +/// 解析错误的处理 +/// 环形缓冲区覆盖检测 +/// +/// +[TestClass] +[DoNotParallelize] +public class CsmMassDataTests +{ + /// 测试:缓冲区配置与状态查询。 + [TestMethod] + public void ConfigAndStatus_RoundTrip() + { + Assert.ThrowsExactly( + () => Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(0)); + + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(1024); + Csm.MassData.CsmMassData.MassDataParameterStatus( + out CsmMassDataOperation read, + out CsmMassDataOperation write, + out int cacheSize); + + Assert.AreEqual(1024, cacheSize); + Assert.AreEqual(0u, read.Size); + Assert.AreEqual(0u, write.Size); + } + + /// 测试:不带数据类型的编码 / 解码往返。 + [TestMethod] + public void Roundtrip_Plain() + { + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(4096); + + byte[] source = new byte[8 * sizeof(int)]; + for (int i = 0; i < 8; i++) + { + BitConverter.GetBytes((i + 1) * 10).CopyTo(source, i * sizeof(int)); + } + + string arg = Csm.MassData.CsmMassData.ConvertMassDataToArgument(source); + StringAssert.StartsWith(arg, "Start:"); + + byte[] restored = Csm.MassData.CsmMassData.ConvertArgumentToMassData(arg); + Assert.HasCount(source.Length, restored); + CollectionAssert.AreEqual(source, restored); + } + + /// 测试:带数据类型的编码 / 解码往返。 + [TestMethod] + public void Roundtrip_WithDataType() + { + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(4096); + + double[] values = { 1.5, -2.5, 3.5, -4.5 }; + byte[] source = new byte[values.Length * sizeof(double)]; + Buffer.BlockCopy(values, 0, source, 0, source.Length); + + string arg = Csm.MassData.CsmMassData.ConvertMassDataToArgumentWithDataType(source, "1D DBL"); + StringAssert.Contains(arg, ";DataType:1D DBL"); + + string type = Csm.MassData.CsmMassData.MassDataDataTypeString(arg, out string dup); + Assert.AreEqual("1D DBL", type); + Assert.AreEqual(arg, dup); + + byte[] restored = Csm.MassData.CsmMassData.ConvertArgumentToMassData(arg); + Assert.HasCount(source.Length, restored); + CollectionAssert.AreEqual(source, restored); + } + + /// 测试:参数中没有数据类型字段时,解析结果应为空字符串。 + [TestMethod] + public void DataType_Absent_ReturnsEmpty() + { + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(4096); + byte[] payload = { 0xAA, 0xBB, 0xCC }; + + string arg = Csm.MassData.CsmMassData.ConvertMassDataToArgument(payload); + string type = Csm.MassData.CsmMassData.MassDataDataTypeString(arg); + + Assert.AreEqual(string.Empty, type); + } + + /// 测试:解析非法字符串时应抛出 ParseError 异常。 + [TestMethod] + public void Parse_InvalidArgument_Throws() + { + AssertParseError("garbage"); + AssertParseError("Start:abc;Size:1"); + AssertParseError("Start:0;Size:1;wrong"); + AssertParseError("Start:0;Size:1;DataType:bad;value"); + AssertParseError("Start:99999999999999999999;Size:0"); // 溢出 ulong + } + + /// 测试:旧数据被环形缓冲区覆盖后,应抛出 Overwritten 异常。 + [TestMethod] + public void Overwrite_Detection() + { + // 缓冲区故意设得很小,后续写入会把第一份数据挤掉。 + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(32); + + byte[] small = { 1, 2, 3, 4, 5, 6, 7, 8 }; + string firstArg = Csm.MassData.CsmMassData.ConvertMassDataToArgument(small); + + byte[] filler = new byte[32]; + for (int i = 0; i < filler.Length; i++) + { + filler[i] = (byte)i; + } + + Csm.MassData.CsmMassData.ConvertMassDataToArgument(filler); + + var ex = Assert.ThrowsExactly( + () => Csm.MassData.CsmMassData.ConvertArgumentToMassData(firstArg)); + Assert.AreEqual(CsmMassDataStatus.Overwritten, ex.Status); + } + + /// 测试:写入数据大于缓冲区容量时应抛出 CacheTooSmall 异常。 + [TestMethod] + public void Encode_ExceedsCacheSize_Throws() + { + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(16); + byte[] big = new byte[64]; + + var ex = Assert.ThrowsExactly( + () => Csm.MassData.CsmMassData.ConvertMassDataToArgument(big)); + Assert.AreEqual(CsmMassDataStatus.CacheTooSmall, ex.Status); + } + + /// 测试:状态查询应当反映最近一次的读 / 写操作。 + [TestMethod] + public void Status_ReflectsLastOperations() + { + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(1024); + byte[] payload = { 9, 8, 7, 6, 5 }; + + string arg = Csm.MassData.CsmMassData.ConvertMassDataToArgument(payload); + byte[] restored = Csm.MassData.CsmMassData.ConvertArgumentToMassData(arg); + Assert.HasCount(payload.Length, restored); + + Csm.MassData.CsmMassData.MassDataParameterStatus( + out CsmMassDataOperation read, + out CsmMassDataOperation write, + out int cacheSize); + + Assert.AreEqual((ulong)payload.Length, write.Size); + Assert.AreEqual((ulong)payload.Length, read.Size); + Assert.AreEqual(1024, cacheSize); + } + + /// 测试:dataType 中包含非法字符时应抛出 InvalidArgument 异常。 + [TestMethod] + public void EncodeWithDataType_RejectsForbiddenCharacters() + { + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(4096); + byte[] data = { 1, 2, 3 }; + + foreach (string bad in new[] { "with;semicolon", "withangle" }) + { + var ex = Assert.ThrowsExactly( + () => Csm.MassData.CsmMassData.ConvertMassDataToArgumentWithDataType(data, bad)); + Assert.AreEqual(CsmMassDataStatus.InvalidArgument, ex.Status); + } + } + + /// 测试:空数据的编码 / 解码。 + [TestMethod] + public void EmptyData_RoundTrip() + { + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(1024); + + string arg = Csm.MassData.CsmMassData.ConvertMassDataToArgument(Array.Empty()); + byte[] restored = Csm.MassData.CsmMassData.ConvertArgumentToMassData(arg); + Assert.IsEmpty(restored); + } + + /// 测试:所有公开 API 在多线程并发调用下保持一致性。 + [TestMethod] + public void ThreadSafety_StressRoundTrip() + { + Csm.MassData.CsmMassData.ConfigMassDataParameterCacheSize(8 * 1024 * 1024); + + const int threadCount = 8; + const int iterations = 200; + Exception? firstFailure = null; + + var threads = new Thread[threadCount]; + for (int t = 0; t < threadCount; t++) + { + int seed = t; + threads[t] = new Thread(() => + { + try + { + var rng = new Random(seed); + byte[] payload = new byte[256]; + for (int i = 0; i < iterations; i++) + { + rng.NextBytes(payload); + string arg = Csm.MassData.CsmMassData.ConvertMassDataToArgument(payload); + byte[] restored = Csm.MassData.CsmMassData.ConvertArgumentToMassData(arg); + // 并发写入会导致部分引用被覆盖;只验证未被覆盖时 + // 取回的内容与编码时写入的一致。 + if (restored.Length == payload.Length) + { + CollectionAssert.AreEqual(payload, restored); + } + } + } + catch (CsmMassDataException ex) when (ex.Status == CsmMassDataStatus.Overwritten) + { + // 并发条件下属于预期分支,不视为失败。 + } + catch (Exception ex) + { + Interlocked.CompareExchange(ref firstFailure, ex, null); + } + }); + } + + foreach (Thread thread in threads) + { + thread.Start(); + } + + foreach (Thread thread in threads) + { + thread.Join(); + } + + Assert.IsNull(firstFailure, firstFailure?.ToString()); + } + + private static void AssertParseError(string argument) + { + var ex = Assert.ThrowsExactly( + () => Csm.MassData.CsmMassData.ConvertArgumentToMassData(argument)); + Assert.AreEqual(CsmMassDataStatus.ParseError, ex.Status, $"输入:{argument}"); + } +} diff --git a/csharp/_test/vs/CsmMassData.Tests/MSTestSettings.cs b/csharp/_test/vs/CsmMassData.Tests/MSTestSettings.cs new file mode 100644 index 0000000..152c852 --- /dev/null +++ b/csharp/_test/vs/CsmMassData.Tests/MSTestSettings.cs @@ -0,0 +1,4 @@ +// 由于 CsmMassData API 使用进程内共享的环形缓冲区, +// 各测试之间会通过 ConfigMassDataParameterCacheSize 互相影响, +// 因此整个程序集禁止并行执行。 +[assembly: DoNotParallelize] diff --git a/csharp/src/CsmMassData/CsmMassData.cs b/csharp/src/CsmMassData/CsmMassData.cs new file mode 100644 index 0000000..8f56b48 --- /dev/null +++ b/csharp/src/CsmMassData/CsmMassData.cs @@ -0,0 +1,571 @@ +// +// MIT 许可证 —— 详见仓库根目录的 LICENSE 文件。 +// + +using System.Globalization; + +namespace Csm.MassData; + +/// +/// CSM MassData Parameter Support 插件的 C# 移植版本。 +/// +/// +/// +/// 本类公开的方法与 LabVIEW 端 +/// addons/MassData-Parameter/CSM MassData Parameter Support.lvlib +/// 中的 VI 在功能与命名上完全一致;同时与 c/include/csm_massdata.h +/// 中暴露的 C API 保持等价语义,便于跨语言互通:使用 C# 编写的代码与 +/// 使用 LabVIEW / C 编写的代码可以无缝传递 MassData 参数字符串。 +/// +/// +/// +/// MassData 参数格式:MassData 参数是一段仅包含 ASCII 字符、 +/// 可读的引用字符串,指向进程内一个全局环形缓冲区中的实际数据。 +/// 支持以下两种形式: +/// +/// +/// 不带数据类型:<MassData>Start:<N>;Size:<N> +/// 带数据类型: <MassData>Start:<N>;Size:<N>;DataType:<T> +/// +/// +/// 其中 <N> 为非负十进制整数,<T> 为自由格式的 +/// 数据类型标签(例如 1D I32Waveform 等)。 +/// +/// +/// +/// 数据生命周期:MassData 内部使用环形缓冲区。当缓冲区写满后, +/// 新写入的数据将从缓冲区起始位置覆盖最早的数据。被覆盖的数据 +/// 无法恢复,后续对其引用进行解码时会抛出 +/// )。 +/// 同一 AppDomain 内的所有调用者共享同一份 MassData 缓冲区。 +/// +/// +/// +/// 线程安全:所有公开方法都是线程安全的。内部状态由静态构造函数 +/// 完成一次性初始化(CLR 保证其只执行一次且对其它线程可见),后续访问 +/// 通过同一个 lock 对象串行化。建议在进入多线程阶段前主动调用 +/// 完成初始化与 +/// 缓冲区配置,以获得最佳性能。 +/// +/// +public static class CsmMassData +{ + /// MassData 缓冲区的默认大小(字节),与 LabVIEW VI 一致:50 MiB。 + public const int DefaultCacheSize = 50 * 1024 * 1024; + + /// 编码方法返回的 MassData 参数字符串的最大长度(字符数)。 + public const int MaxArgumentLength = 256; + + /// 数据类型标签字符串的最大长度(字符数)。 + public const int MaxDataTypeLength = 128; + + private const string Prefix = ""; + + private static readonly object SyncRoot = new(); + + private static readonly char[] ForbiddenDataTypeChars = { ';', '<', '>' }; + + private static byte[] s_buffer = AllocateInitialBuffer(); + private static int s_capacity = s_buffer.Length; + private static ulong s_writeTotal; + private static CsmMassDataOperation s_lastRead; + private static CsmMassDataOperation s_lastWrite; + + /// + /// 配置 MassData 后台缓冲区大小。 + /// + /// + /// 对应 CSM - Config MassData Parameter Cache Size.vi 与 + /// CSM_ConfigMassDataParameterCacheSize。 + /// 与 LabVIEW VI 一致,调用本方法会丢弃当前缓存中的所有数据; + /// 通常应在任何编码 / 解码调用之前、应用启动阶段调用一次。 + /// + /// 新的缓冲区大小(字节),必须为正整数。 + /// + /// 当 不为正数() + /// 或内存分配失败()时抛出。 + /// + public static void ConfigMassDataParameterCacheSize(int size) + { + if (size <= 0) + { + throw new CsmMassDataException( + CsmMassDataStatus.InvalidArgument, + $"缓冲区大小必须为正数,但收到 {size}。"); + } + + byte[] newBuffer; + try + { + newBuffer = new byte[size]; + } + catch (OutOfMemoryException ex) + { + throw new CsmMassDataException( + CsmMassDataStatus.NoMemory, + $"无法分配 {size} 字节的 MassData 缓冲区。", + ex); + } + + lock (SyncRoot) + { + s_buffer = newBuffer; + s_capacity = size; + s_writeTotal = 0u; + s_lastRead = default; + s_lastWrite = default; + } + } + + /// + /// 将原始数据转换为 MassData 参数(不嵌入数据类型)。 + /// + /// + /// 对应 CSM - Convert MassData to Argument.vim 与 + /// CSM_ConvertMassDataToArgument。原始数据被复制到环形缓冲区, + /// 并返回形如 <MassData>Start:<N>;Size:<N> + /// 的引用字符串。 + /// + /// 待保存的原始字节,空 Span 等价于不写入任何数据。 + /// MassData 参数字符串。 + /// 编码过程中检测到错误时抛出。 + public static string ConvertMassDataToArgument(ReadOnlySpan data) + => Encode(data, dataType: null); + + /// + /// 将原始数据转换为 MassData 参数(不嵌入数据类型)。 + /// + /// 待保存的原始字节,null 视为空数据。 + /// MassData 参数字符串。 + /// + public static string ConvertMassDataToArgument(byte[]? data) + => Encode(data is null ? ReadOnlySpan.Empty : data.AsSpan(), dataType: null); + + /// + /// 将原始数据转换为带数据类型标签的 MassData 参数。 + /// + /// + /// 对应 CSM - Convert MassData to Argument With DataType.vim 与 + /// CSM_ConvertMassDataToArgumentWithDataType,生成的参数形如 + /// <MassData>Start:<N>;Size:<N>;DataType:<dataType>。 + /// + /// 待保存的原始字节。 + /// + /// 可为空字符串但不能为 null 的数据类型字符串(例如 "1D I32"); + /// 不允许包含 ';''<''>'。 + /// + /// MassData 参数字符串。 + /// 参数非法或编码失败时抛出。 + public static string ConvertMassDataToArgumentWithDataType(ReadOnlySpan data, string dataType) + { + if (dataType is null) + { + throw new CsmMassDataException( + CsmMassDataStatus.InvalidArgument, + "dataType 不能为 null。"); + } + + return Encode(data, dataType); + } + + /// + /// 将原始数据转换为带数据类型标签的 MassData 参数。 + /// + /// 待保存的原始字节。null 视为空数据。 + /// 可为空字符串但不能为 null 的数据类型字符串。 + /// MassData 参数字符串。 + /// + public static string ConvertMassDataToArgumentWithDataType(byte[]? data, string dataType) + => ConvertMassDataToArgumentWithDataType( + data is null ? ReadOnlySpan.Empty : data.AsSpan(), + dataType); + + /// + /// 将 MassData 参数还原为原始数据。 + /// + /// + /// 对应 CSM - Convert Argument to MassData.vim 与 + /// CSM_ConvertArgumentToMassData。返回的就是此前写入的原始字节, + /// 不受嵌入的数据类型标签影响(如需读取标签请使用 + /// )。 + /// + /// 以前由 ConvertMassDataToArgument* 返回的引用字符串。 + /// 解析得到的原始字节,长度恰好为参数中 Size 字段的值。 + /// + /// 参数非法( / + /// )或数据已被覆盖 + /// ()时抛出。 + /// + public static byte[] ConvertArgumentToMassData(string argument) + { + Parse(argument, out ulong start, out ulong size, dataType: out _, parseDataType: false); + + // 在 64 位 CLR 上,数组最大长度仍受 int.MaxValue 约束。 + if (size > (ulong)Array.MaxLength) + { + throw new CsmMassDataException( + CsmMassDataStatus.ParseError, + $"参数 Size 字段为 {size},超出当前运行时支持的最大数组长度。"); + } + + byte[] result = new byte[(int)size]; + + lock (SyncRoot) + { + CheckResidencyAndRead(start, size, result); + s_lastRead = new CsmMassDataOperation(start, size); + } + + return result; + } + + /// + /// 从 MassData 参数中解析出数据类型字符串。 + /// + /// + /// 对应 CSM - MassData Data Type String.vi 与 + /// CSM_MassDataDataTypeString。本方法不会消费输入,也不会 + /// 触碰环形缓冲区中的数据。 + /// + /// MassData 参数字符串。 + /// 数据类型标签;若参数中未携带类型字段则返回空字符串。 + /// 参数非法时抛出。 + public static string MassDataDataTypeString(string argument) + { + Parse(argument, out _, out _, out string? dataType, parseDataType: true); + return dataType ?? string.Empty; + } + + /// + /// 从 MassData 参数中解析出数据类型字符串,并返回输入字符串的副本。 + /// + /// + /// 对应 LabVIEW VI 中将 argument 同时透传到下游连线的数据流行为。 + /// + /// MassData 参数字符串。 + /// 输出的输入副本,与 内容相同。 + /// 数据类型标签;若参数中未携带类型字段则返回空字符串。 + /// 参数非法时抛出。 + public static string MassDataDataTypeString(string argument, out string argumentDup) + { + string result = MassDataDataTypeString(argument); + argumentDup = argument; + return result; + } + + /// + /// 读取 MassData 后台缓冲区的状态信息。 + /// + /// 对应 CSM - MassData Parameter Status.vi + /// 输出最近一次的读操作信息。 + /// 输出最近一次的写操作信息。 + /// 输出当前配置的缓冲区大小(字节)。 + public static void MassDataParameterStatus( + out CsmMassDataOperation activeRead, + out CsmMassDataOperation activeWrite, + out int cacheSize) + { + lock (SyncRoot) + { + activeRead = s_lastRead; + activeWrite = s_lastWrite; + cacheSize = s_capacity; + } + } + + private static byte[] AllocateInitialBuffer() + { + // 静态字段初始化由 CLR 保证仅执行一次且线程安全;首个失败的尝试 + // 不应阻止后续 ConfigMassDataParameterCacheSize 重新分配。 + try + { + return new byte[DefaultCacheSize]; + } + catch (OutOfMemoryException) + { + return Array.Empty(); + } + } + + private static string Encode(ReadOnlySpan data, string? dataType) + { + if (dataType is not null) + { + // 数据类型不能包含会破坏引用字符串语法的字符。 + if (dataType.IndexOfAny(ForbiddenDataTypeChars) >= 0) + { + throw new CsmMassDataException( + CsmMassDataStatus.InvalidArgument, + "dataType 中不允许出现 ';'、'<' 或 '>' 字符。"); + } + + // 与 C 端及 LabVIEW 端保持一致的最大长度(含末尾 NUL)。 + if (dataType.Length + 1 > MaxDataTypeLength) + { + throw new CsmMassDataException( + CsmMassDataStatus.BufferTooSmall, + $"dataType 长度 {dataType.Length} 超过 {MaxDataTypeLength - 1}。"); + } + } + + ulong startCursor; + int dataSize = data.Length; + + lock (SyncRoot) + { + if (s_buffer.Length == 0 || s_capacity == 0) + { + throw new CsmMassDataException( + CsmMassDataStatus.NoMemory, + "MassData 缓冲区尚未分配,请先调用 ConfigMassDataParameterCacheSize。"); + } + + if (dataSize > s_capacity) + { + throw new CsmMassDataException( + CsmMassDataStatus.CacheTooSmall, + $"待写入的数据 {dataSize} 字节超过缓冲区容量 {s_capacity}。"); + } + + startCursor = s_writeTotal; + if (dataSize > 0) + { + RingWrite(data); + s_writeTotal += (ulong)dataSize; + } + + s_lastWrite = new CsmMassDataOperation(startCursor, (ulong)dataSize); + } + + string argument = dataType is null + ? string.Format( + CultureInfo.InvariantCulture, + "{0}Start:{1};Size:{2}", + Prefix, + startCursor, + (ulong)dataSize) + : string.Format( + CultureInfo.InvariantCulture, + "{0}Start:{1};Size:{2};DataType:{3}", + Prefix, + startCursor, + (ulong)dataSize, + dataType); + + if (argument.Length + 1 > MaxArgumentLength) + { + throw new CsmMassDataException( + CsmMassDataStatus.BufferTooSmall, + $"生成的参数长度 {argument.Length} 超过 {MaxArgumentLength - 1}。"); + } + + return argument; + } + + private static void Parse( + string argument, + out ulong start, + out ulong size, + out string? dataType, + bool parseDataType) + { + start = 0u; + size = 0u; + dataType = parseDataType ? string.Empty : null; + + if (argument is null) + { + throw new CsmMassDataException( + CsmMassDataStatus.InvalidArgument, + "argument 不能为 null。"); + } + + if (!argument.StartsWith(Prefix, StringComparison.Ordinal)) + { + ThrowParse(argument); + } + + int pos = Prefix.Length; + + if (!TryConsume(argument, ref pos, "Start:") + || !TryParseUInt64(argument, ref pos, out start) + || pos >= argument.Length || argument[pos] != ';') + { + ThrowParse(argument); + } + + pos++; // 跳过 ';' + + if (!TryConsume(argument, ref pos, "Size:") + || !TryParseUInt64(argument, ref pos, out size)) + { + ThrowParse(argument); + } + + // 可选的 ;DataType: 后缀。 + if (pos == argument.Length) + { + return; + } + + if (argument[pos] != ';') + { + ThrowParse(argument); + } + + pos++; + if (!TryConsume(argument, ref pos, "DataType:")) + { + ThrowParse(argument); + } + + // 验证 DataType 值仅包含合法字符(不能含 ';' / '<' / '>')。 + for (int i = pos; i < argument.Length; i++) + { + char c = argument[i]; + if (c == ';' || c == '<' || c == '>') + { + ThrowParse(argument); + } + } + + if (parseDataType) + { + dataType = argument.Substring(pos); + } + } + + private static bool TryConsume(string s, ref int pos, string token) + { + if (pos + token.Length > s.Length) + { + return false; + } + + for (int i = 0; i < token.Length; i++) + { + if (s[pos + i] != token[i]) + { + return false; + } + } + + pos += token.Length; + return true; + } + + private static bool TryParseUInt64(string s, ref int pos, out ulong value) + { + value = 0u; + int start = pos; + while (pos < s.Length && s[pos] >= '0' && s[pos] <= '9') + { + pos++; + } + + if (pos == start) + { + return false; + } + + // 使用 InvariantCulture,且检查溢出,与 C 端 strtoull + errno 检查等价。 + return ulong.TryParse( + s.AsSpan(start, pos - start), + NumberStyles.None, + CultureInfo.InvariantCulture, + out value); + } + + private static void RingWrite(ReadOnlySpan src) + { + int offset = (int)(s_writeTotal % (ulong)s_capacity); + int firstRun = s_capacity - offset; + if (src.Length <= firstRun) + { + src.CopyTo(s_buffer.AsSpan(offset)); + } + else + { + src.Slice(0, firstRun).CopyTo(s_buffer.AsSpan(offset)); + src.Slice(firstRun).CopyTo(s_buffer.AsSpan(0)); + } + } + + private static void RingRead(ulong start, Span dst) + { + int offset = (int)(start % (ulong)s_capacity); + int firstRun = s_capacity - offset; + if (dst.Length <= firstRun) + { + s_buffer.AsSpan(offset, dst.Length).CopyTo(dst); + } + else + { + s_buffer.AsSpan(offset, firstRun).CopyTo(dst); + s_buffer.AsSpan(0, dst.Length - firstRun).CopyTo(dst.Slice(firstRun)); + } + } + + private static void CheckResidencyAndRead(ulong start, ulong size, byte[] dst) + { + if (s_buffer.Length == 0 || s_capacity == 0) + { + throw new CsmMassDataException( + CsmMassDataStatus.NoMemory, + "MassData 缓冲区尚未分配,请先调用 ConfigMassDataParameterCacheSize。"); + } + + if (size > (ulong)s_capacity) + { + throw new CsmMassDataException( + CsmMassDataStatus.Overwritten, + $"请求的 {size} 字节超过当前缓冲区容量 {s_capacity},对应数据已不可恢复。"); + } + + // 当前驻留窗口为 [writeTotal - capacity, writeTotal);任何末端落在 + // 该窗口之外的请求都视为已被覆盖。 + ulong oldest = s_writeTotal > (ulong)s_capacity + ? s_writeTotal - (ulong)s_capacity + : 0u; + + // 在加法前先检测 ulong 溢出。 + if (size > 0u && start > ulong.MaxValue - size) + { + throw new CsmMassDataException( + CsmMassDataStatus.Overwritten, + "Start + Size 在 64 位空间内溢出,引用已无效。"); + } + + if (start < oldest || start > s_writeTotal) + { + throw new CsmMassDataException( + CsmMassDataStatus.Overwritten, + "引用的数据已被环形缓冲区后续写入覆盖。"); + } + + ulong end = start + size; + if (end > s_writeTotal) + { + throw new CsmMassDataException( + CsmMassDataStatus.Overwritten, + "引用的数据末端尚未写入或已被覆盖。"); + } + + if (size > 0u) + { + RingRead(start, dst.AsSpan(0, (int)size)); + } + } + + private static void ThrowParse(string argument) + { + // 截断过长的原始输入,避免异常消息无限制膨胀。 + const int maxEcho = 64; + string echo = argument.Length > maxEcho + ? argument.Substring(0, maxEcho) + "..." + : argument; + throw new CsmMassDataException( + CsmMassDataStatus.ParseError, + $"无法将字符串解析为 MassData 参数:\"{echo}\"。"); + } +} diff --git a/csharp/src/CsmMassData/CsmMassData.csproj b/csharp/src/CsmMassData/CsmMassData.csproj new file mode 100644 index 0000000..26a6e57 --- /dev/null +++ b/csharp/src/CsmMassData/CsmMassData.csproj @@ -0,0 +1,10 @@ + + + + net8.0 + latest + enable + enable + + + diff --git a/csharp/src/CsmMassData/CsmMassDataException.cs b/csharp/src/CsmMassData/CsmMassDataException.cs new file mode 100644 index 0000000..730ba07 --- /dev/null +++ b/csharp/src/CsmMassData/CsmMassDataException.cs @@ -0,0 +1,43 @@ +// +// MIT 许可证 —— 详见仓库根目录的 LICENSE 文件。 +// + +namespace Csm.MassData; + +/// +/// 当 中的任意 API 检测到错误时抛出。 +/// +/// +/// 属性给出了与 C 语言移植版本对齐的错误码, +/// 便于在异常处理逻辑中以与 LabVIEW / C 一致的语义进行分支。 +/// +public sealed class CsmMassDataException : Exception +{ + /// + /// 使用指定的错误状态与人类可读的描述构造异常。 + /// + /// 触发异常的错误状态。 + /// 面向开发者的错误描述。 + public CsmMassDataException(CsmMassDataStatus status, string message) + : base(message) + { + this.Status = status; + } + + /// + /// 使用指定的错误状态、描述与内层异常构造异常。 + /// + /// 触发异常的错误状态。 + /// 面向开发者的错误描述。 + /// 引发本异常的底层异常。 + public CsmMassDataException(CsmMassDataStatus status, string message, Exception innerException) + : base(message, innerException) + { + this.Status = status; + } + + /// + /// 获取与 C / LabVIEW 端语义一致的错误状态码。 + /// + public CsmMassDataStatus Status { get; } +} diff --git a/csharp/src/CsmMassData/CsmMassDataOperation.cs b/csharp/src/CsmMassData/CsmMassDataOperation.cs new file mode 100644 index 0000000..21e2628 --- /dev/null +++ b/csharp/src/CsmMassData/CsmMassDataOperation.cs @@ -0,0 +1,52 @@ +// +// MIT 许可证 —— 详见仓库根目录的 LICENSE 文件。 +// + +namespace Csm.MassData; + +/// +/// 描述一次对 MassData 环形缓冲区的读或写操作。 +/// +/// +/// 与 LabVIEW 端 CSM - MassData Parameter Status.vi 输出的 +/// Active Read Operation / Active Write Operation 簇 +/// 在字段命名与语义上完全一致。 +/// +public readonly struct CsmMassDataOperation : IEquatable +{ + /// + /// 使用指定的起始游标与字节数构造一次操作描述。 + /// + /// 在缓冲区中的起始偏移量(字节)。 + /// 该次操作涉及的字节数。 + public CsmMassDataOperation(ulong start, ulong size) + { + this.Start = start; + this.Size = size; + } + + /// 获取该次操作在环形缓冲区中的起始偏移量(字节)。 + public ulong Start { get; } + + /// 获取该次操作涉及的字节数。 + public ulong Size { get; } + + /// + public bool Equals(CsmMassDataOperation other) + => this.Start == other.Start && this.Size == other.Size; + + /// + public override bool Equals(object? obj) + => obj is CsmMassDataOperation other && this.Equals(other); + + /// + public override int GetHashCode() => HashCode.Combine(this.Start, this.Size); + + /// 判断两次操作描述是否完全相同。 + public static bool operator ==(CsmMassDataOperation left, CsmMassDataOperation right) + => left.Equals(right); + + /// 判断两次操作描述是否不同。 + public static bool operator !=(CsmMassDataOperation left, CsmMassDataOperation right) + => !left.Equals(right); +} diff --git a/csharp/src/CsmMassData/CsmMassDataStatus.cs b/csharp/src/CsmMassData/CsmMassDataStatus.cs new file mode 100644 index 0000000..2d86c7b --- /dev/null +++ b/csharp/src/CsmMassData/CsmMassDataStatus.cs @@ -0,0 +1,36 @@ +// +// MIT 许可证 —— 详见仓库根目录的 LICENSE 文件。 +// + +namespace Csm.MassData; + +/// +/// CSM MassData 接口在出错时返回的状态码。 +/// +/// +/// 数值与 C 语言移植版本(csm_massdata_status_t)以及 +/// LabVIEW 端的错误约定保持一致,方便跨语言调试。 +/// +public enum CsmMassDataStatus +{ + /// 操作成功完成。 + Ok = 0, + + /// 参数为 null 或语义上无效。 + InvalidArgument = -1, + + /// 调用方提供的输出缓冲区不足以容纳结果。 + BufferTooSmall = -2, + + /// MassData 参数字符串无法解析。 + ParseError = -3, + + /// 引用的数据已被环形缓冲区后续写入覆盖,无法恢复。 + Overwritten = -4, + + /// 待写入的数据大于当前配置的缓冲区容量。 + CacheTooSmall = -5, + + /// 内存分配失败。 + NoMemory = -6, +}