跳到主要内容

C# 特有语法糖

本页只介绍 DarraEtherCAT C# SDK 独有的、利用 .NET 语言特性叠加的便捷写法。它们 不是新功能,只是把已有 API 包装成更符合 C# 习惯的形式(元组解构、record struct、Range 索引、LINQ、pattern matching、async/await、IObservable<T>Span<byte> 等)。其他语言(C/C++/Java/Python/Rust)的 SDK 没有这些写法 —— 因此本节单独成页,不与通用 API 混在一起。

共存而非替代 —— 旧 API 全部保留。语法糖只是另一种写法。性能敏感场景(PDO 周期内)建议优先用语法糖中的 Span 版本,其他场景按可读性选择。

命名空间 —— 全部位于 DarraEtherCAT_Master,主类型 DarraEtherCATDarraEtherCAT.Slave 已带语法糖扩展,引入命名空间即可使用。


一、元组解构 (Deconstruct)

C# 7 起支持的元组解构。DarraEtherCAT.Slave 提供三个 Deconstruct 重载,让用户一行拿全身份/运行态字段。

取 EEPROM 身份 3 元组

写法调用
标准 APIuint v = slave.VendorId; uint p = slave.ProductId; uint r = slave.RevId;
语法糖var (vendor, product, revision) = slave;
foreach (var slave in master.Slaves)
{
var (vendor, product, revision) = slave;
Console.WriteLine($"VID=0x{vendor:X8} PID=0x{product:X8} REV=0x{revision:X8}");
}

取完整 4 元组(含 Serial)

var (vendor, product, revision, serial) = slave;

取运行态视图(索引/名字/状态)

var (idx, name, state) = slave;
if (state != EcState.OP) Console.WriteLine($"#{idx} {name} 不在 OP");

何时用

  • 诊断/日志循环:避免重复写 slave.VendorIdslave.ProductId 多个属性访问。
  • switch / pattern matching:与 is var (v, p, _) 配合天然契合。

二、不可变身份记录 (record struct + with)

SlaveIdentityKey 是 readonly record struct,面向应用层,不与 DLL 内存对齐结构 SlaveIdentity 冲突。

特性record struct
结构体相等性字段相等 ⇒ Equals=true(编译器生成)
with 表达式派生新值仅改一个字段
可作为 Dictionary Key自动 GetHashCode
pattern matching字段属性可匹配
// 取键并入字典
var key = slave.GetIdentityKey();
if (_configCache.TryGetValue(key, out var cfg)) { ApplyCfg(cfg); }

// with 派生 fuzzy 查表 key (相同 vendor/product, 不同 revision)
var fuzzyKey = key with { Revision = 0 };

// pattern matching
string family = key switch
{
{ VendorId: 0x00000002 } => "Beckhoff",
{ VendorId: 0x000000ABu } => "Copley",
_ => "Unknown"
};

// 仅按 Vendor+Product 比较 (忽略 Revision/Serial)
if (key.MatchesType(otherKey)) { ... }

何时用

  • 缓存查表:把"上次扫描到的从站"和"配置"按身份键挂在 Dictionary<SlaveIdentityKey, ...> 里。
  • 配置 fallbackwith { Revision = 0 } 一行写出"忽略 revision"的查表 key。
  • 不要替代 SlaveIdentity —— 那个仍用于 P/Invoke 内存对齐。

三、Range 索引和 LINQ 风格筛选

C# 8 起支持 RangeIndex。我们把"挑出符合条件的从站"全做成 LINQ 扩展。

切片

写法调用
标准 APIfor (int i = 1; i < 4; i++) Process(master.Slaves[i]);
语法糖 (ns2.0)foreach (var s in master.Slaves.Slice(startIndex: 1, count: 3)) Process(s);
语法糖 (.NET Core 3+)foreach (var s in master.Slaves.Slice(1..4)) Process(s);

netstandard 2.0 不带 System.Range,所以 Slice(start, count) 是默认重载;.NET Core 3+ 通过 #if 提供额外的 Slice(Range) 重载。

LINQ 链式过滤

// 所有 CoE 从站
foreach (var s in master.Slaves.WithProtocol(MailboxType.CANopenOverEtherCAT))
Console.WriteLine(s.Name);

// OP 状态 + 组 1 + 在线
var online = master.Slaves
.OnlineOnly()
.WithGroup(1)
.InState(EcState.OP)
.ToList();

// 配 PdoSpan 一起用 (清零所有 OP 从站的输出)
foreach (var s in master.Slaves.InState(EcState.OP))
s.OutputSpan().Clear();

// 调试输出全部身份
foreach (string line in master.Slaves.ToIdentityStrings())
log.Info(line);

提供的扩展

扩展作用
WithProtocol(MailboxType)邮箱协议位匹配(位 OR 逻辑)
InState(EcState)仅指定 EtherCAT 状态
WithVendor(uint) / WithProduct(uint)按 ID 过滤
WithGroup(byte)按 0-7 组号过滤
OnlineOnly()排除 IsLost
WithDC()仅有 DC 同步
Slice(int start, int count)索引切片(ns2.0 通用)
Slice(Range)Range 切片(.NET Core 3+)
ToIdentityStrings()调试用,逐行打印 ID

何时用

  • 手写 foreach + if 三层以上 → 改成一句 LINQ。
  • 不在 PDO 周期内调用 —— LINQ 内部 yield 不分配大对象,但还是会有迭代器开销,1ms 以下 RT 路径建议用普通 for 循环。

四、越界安全索引

Slaves[n] 越界抛 IndexOutOfRangeException。启动早期/热插拔时配置可能不一致,建议改用 nullable 风格。

// 标准 API (越界抛异常)
var s = master.Slaves[2];

// 语法糖 (越界返回 null)
var s = master.Slaves.GetOrNull(2);
if (s is { } slave) slave.SetState(EcState.OP);

// TryGet 风格
if (master.Slaves.TryGet(idx, out var slave)) { ... }

// 1-based EtherCAT SlaveNum 友好访问
var s5 = master.GetSlaveBySlaveNum(5); // 找 SlaveNum=5

// 按 ConfigAddr 站地址查
var slv = master.GetSlaveByConfigAddr(0x1003);

何时用

  • 索引来源是用户配置 / 网络扫描 / 远端命令 —— 拿不准长度时永远用 GetOrNull
  • 把 EtherCAT 1-based SlaveNum 直接传过来 —— 用 GetSlaveBySlaveNum

五、Pattern Matching: AL Status 分类

ALErrorClassifier.Classify(...) 是静态方法。我们把它包装成 EcALState 的扩展方法 + 谓词,配合 switch 表达式直接用。

// 标准 API
if (ALErrorClassifier.Classify(slave.ErrorCode) == ALErrorCategory.Transient)
Retry();

// 语法糖
if (slave.ErrorCode.IsTransient()) Retry();

// switch 表达式
string action = slave.ErrorCode.Category() switch
{
ALErrorCategory.None => "正常",
ALErrorCategory.Transient => "重试",
ALErrorCategory.Configuration => "重配置",
ALErrorCategory.Hardware => "联系用户",
_ => "未知"
};

// 邮箱协议错误识别
if (slave.ErrorCode.IsMailboxError()) HandleMailboxFailure();

// DC 错误识别
if (slave.ErrorCode.IsDcError()) ResetDcSync();

// 是否需要降级到 PreOp
if (slave.ErrorCode.NeedsDowngradeTo(EcState.PreOp))
slave.SetState(EcState.PreOp);

// UI 直接显示中文标签
label.Text = slave.ErrorCode.DescribeCategory();

提供的扩展

扩展作用
Category()等价 ALErrorClassifier.Classify
IsNone() / IsTransient() / IsConfiguration() / IsHardware()谓词
IsMailboxError()0x0041-0x004F 区间
IsDcError()0x0030-0x0037 区间
NeedsDowngradeTo(EcState)0x0021/0x0022/0x0023
DescribeCategory()中文短描述(UI 用)

何时用

  • 状态机/异常处理if 链式判断三个以上 EcALState 值 → switch 表达式。
  • UI 标签 / 日志显示 → 直接 DescribeCategory()

六、async/await 状态切换

SetState / WaitForState 是同步阻塞 API,在 GUI/Service 中容易卡线程。语法糖用 Task.Run 推到线程池,支持 CancellationToken

// 标准 API (同步阻塞 UI)
master.SetState(EcState.OP);
master.WaitForState(EcState.OP, 10000);

// 语法糖 (UI 不卡)
await master.SetStateAsync(EcState.OP, ct);
await master.WaitForStateAsync(EcState.OP, TimeSpan.FromSeconds(10), ct);

// 一行启动 + 等待 OP, 失败抛异常含错误信息
try
{
await master.GoToOpAsync(TimeSpan.FromSeconds(10), ct);
log.Info("已进入 OP");
}
catch (TimeoutException ex) { log.Error(ex.Message); }
catch (InvalidOperationException ex) { log.Error(ex.Message); }

何时用

  • WinForms / WPF / Avalonia / MAUI 等 UI 程序
  • ASP.NET Core / 其它服务,希望取消令牌驱动

七、异步资源管理 (CloseAsync / DisposeAsync)

Close() / Dispose() 是同步阻塞(停 PDO + 切 PreOp + Join 线程,秒级)。语法糖提供异步版本:

// 标准 API
master.Close();
master.Dispose();

// 语法糖 (UI 不卡)
await master.CloseAsync(ct);
await master.DisposeAsync(ct);

// 多主站并发释放
await new[] { masterA, masterB, masterC }.DisposeAllAsync();

关于 await using / IAsyncDisposable

netstandard 2.0 不自带 IAsyncDisposable,要用 await using,需要在你自己的项目引入 NuGet 包:

<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />

然后写包装类:

public sealed class AsyncMaster : IAsyncDisposable
{
public DarraEtherCAT M { get; }
public AsyncMaster(DarraEtherCAT m) { M = m; }
public ValueTask DisposeAsync() => new ValueTask(M.DisposeAsync());
}

await using var m = new AsyncMaster(master);

SDK 主类不直接实现 IAsyncDisposable,避免给所有用户强加 NuGet 依赖。


八、IObservable<T> 事件流

event 包装成 IObservable<T>,让用户用 using 自动反订阅,并可链式接 System.Reactive 操作符。

// 标准 API (容易忘记反订阅)
EventHandler<StateChangedEventArgs> h = (s, e) => Log(e.NewState);
master.Events.StateChanged += h;
// ... 别忘了:
master.Events.StateChanged -= h;

// 语法糖 (using 自动反订阅)
using var sub = master.StateChangeStream().Subscribe(
e => Log($"{e.OldState}->{e.NewState}"));

// 从站状态流 (元组事件)
using var sub2 = slave.StateChangeStream().Subscribe(
t => Log($"{t.OldState}->{t.NewState}"));

// CoE 0x10F3 诊断流 (订阅时挂 DiagnosticMessageReceived 事件)
using var sub3 = slave.CoE.DiagnosticStream().Subscribe(
msg => Log($"诊断 0x{msg.DiagCode:X8}"));

// CoE 紧急事件流
using var sub4 = slave.EmergencyStream().Subscribe(
e => Log($"EMCY 0x{e.Code:X4}"));

配合 System.Reactive (可选 NuGet)

引入 System.Reactive 后可以链式过滤/节流/合并:

master.StateChangeStream()
.Where(e => e.NewState == EcState.OP)
.Throttle(TimeSpan.FromSeconds(1))
.Subscribe(_ => Beep());

不引 Rx 也能用 —— SDK 自带 Subscribe(Action<T>) 扩展 + 极简 IObservable<T> 实现,零额外依赖。

何时用

  • 多个订阅者需要统一管理生命周期 → 把所有 IDisposable 放进 CompositeDisposable (Rx) 或 List<IDisposable> 一起 dispose。
  • 想用 LINQ-to-Events 操作符(Where/Throttle/Buffer/Merge)→ 引入 System.Reactive NuGet。

九、Span<byte> 零拷贝 PDO 数据视图

slave.PDO.Inputs / Outputsbyte[] 属性,每次访问都分配新数组 + memcpy。在 PDO 周期内(125μs - 1ms)频繁访问会增加 GC 压力。

语法糖直接返回 Span<byte>,零分配、直读 IOmap。

// 标准 API (每次 get 都 new byte[] + memcpy)
byte[] inputs = slave.PDO.Inputs;
int sum = 0;
for (int i = 0; i < inputs.Length; i++) sum += inputs[i];

// 语法糖 (零分配, 直读 IOmap)
var span = slave.InputSpan();
int sum = 0;
for (int i = 0; i < span.Length; i++) sum += span[i];

// 写输出, 直接对 IOmap 写, 不要中间 buffer
var outSpan = slave.OutputSpan();
outSpan[0] = 0xFF;
outSpan[1] = 0x55;

// MDP 模块化设备分模块切片 (零拷贝)
var module1Inputs = slave.InputSlice(0, 8); // 模块 1 输入 8 字节
var module2Outputs = slave.OutputSlice(8, 4); // 模块 2 输出 4 字节

// 拷到/从 stackalloc 不分配
Span<byte> stack = stackalloc byte[16];
slave.CopyInputsTo(stack);
slave.CopyToOutputs(stack);

提供的扩展

扩展返回作用
InputSpan()ReadOnlySpan<byte>输入 PDO 只读视图
OutputSpan()Span<byte>输出 PDO 可写视图
InputSlice(offset, length)ReadOnlySpan<byte>输入切片 (MDP)
OutputSlice(offset, length)Span<byte>输出切片 (MDP)
CopyInputsTo(Span<byte>)int拷到指定 Span (零分配)
CopyToOutputs(ReadOnlySpan<byte>)int从 Span 拷到 IOmap

重要警告

  • Span 生命周期 = 当前 PDO 周期。状态切换(SafeOp ↔ OP)后 IOmap 重映射,旧 Span 全部失效。
  • 不可越过 await / 跨线程。Span 是栈类型,async 方法里跨 await 用 Span 会编译报错。
  • 超出 Ibytes / Obytes 索引未定义行为(与 IOmap 物理边界对齐)。

何时用

  • PDO 周期内热路径 —— GC 优化首选。
  • MDP 模块化设备 —— 切片读写避免重复 offset 计算。
  • 不要在配置/启动阶段用 —— 那种场景用 slave.PDO.Inputs 字节数组更直观。

十、诊断消息 yield 流 (CoE 0x10F3)

把"轮询 → 读取 → 确认"的 while 循环变成 foreach yield 流。

// 标准 API (手写循环)
while (running)
{
if (slave.CoE.PollHasNewDiagnostic())
{
var msgs = slave.CoE.ReadDiagnosticMessages();
foreach (var m in msgs) Process(m);
if (msgs.Count > 0)
slave.CoE.AcknowledgeDiagnostic(msgs[msgs.Count - 1].SubIndex);
}
Thread.Sleep(100);
}

// 语法糖 (yield)
foreach (var msg in slave.CoE.PollDiagnosticMessages(
pollIntervalMs: 100,
autoAcknowledge: true,
stopWhen: () => !running))
{
Process(msg);
}

// CancellationToken 驱动
foreach (var msg in slave.CoE.PollDiagnosticMessages(ct, pollIntervalMs: 100))
Process(msg);

关于 IAsyncEnumerable<T> / await foreach

netstandard 2.0 不自带 IAsyncEnumerable<T>,要用 await foreach 需要:

<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />

并自行包装:

public static async IAsyncEnumerable<DiagnosticMessage> PollAsync(
this DarraEtherCAT.Slave.CoEInstance coe,
[EnumeratorCancellation] CancellationToken ct, int interval = 100)
{
foreach (var m in coe.PollDiagnosticMessages(ct, interval))
{
yield return m;
await Task.Yield();
}
}

SDK 本体只提供同步 IEnumerable 版本以保持零额外依赖。

何时用

  • 后台诊断采集线程 —— 写 foreachwhile 直观。
  • 配 LINQ:PollDiagnosticMessages(ct).Where(m => m.DiagCode > 0xF000).Take(10)

文件清单

所有语法糖位于 CSharp/Master/Sugar/,独立 .cs 文件,不修改任何已有 SDK 代码:

文件内容
SlaveDeconstruct.csSlave.Deconstruct 三个重载(partial 追加)
SlaveIdentityKey.csreadonly record struct SlaveIdentityKey + slave.GetIdentityKey()
SlaveLinqExtensions.csWithProtocol/InState/WithVendor/WithGroup/OnlineOnly/Slice/...
SlaveSafeIndex.csGetOrNull/TryGet/GetSlaveBySlaveNum/GetSlaveByConfigAddr
MasterStateAsync.csSetStateAsync/WaitForStateAsync/GoToOpAsync
AlStatusPattern.csCategory/IsTransient/IsMailboxError/...
PdoSpan.csInputSpan/OutputSpan/InputSlice/OutputSlice/CopyInputsTo/CopyToOutputs
EventStreams.csStateChangeStream/DiagnosticStream/EmergencyStream + 极简 IObservable<T>
DiagnosticPolling.csPollDiagnosticMessages (yield)
MasterAsyncClose.csCloseAsync/DisposeAsync/DisposeAllAsync

依赖

全部为零额外 NuGet 依赖。可选启用:

NuGet启用什么
Microsoft.Bcl.AsyncInterfacesawait foreach (IAsyncEnumerable) / IAsyncDisposable 包装
System.ReactiveIObservable.Where/Throttle/Buffer/Merge 等 LINQ-to-Events

与其他语言 SDK 的关系

写法C#C/C++/Java/Python/Rust
元组解构用各自语言原生方式(C++17 structured binding / Python tuple unpacking)
record struct + withC++ 用 struct + designated initializer;其他语言无对应
Range / LINQ各语言用 stream API / list comprehension 自行实现
async/awaitJava CompletableFuture / Python asyncio / Rust async fn 风格不同
IObservable<T>RxJava / RxPy / 各自 SDK 有自己的事件抽象
Span<byte>C/C++ 直接用裸指针;Rust 用 &[u8];Java/Python 用 ByteBuffer
Pattern matching其他语言用 if/else 或 visitor pattern

核心结论:本页是 C# 用户的便利写法,跨语言一致性放在通用 API 章节。两边都保留,不矛盾。