EtherCAT C# SDK 开发实战:在 Windows 上用 C# 跑通 EtherCAT 主站

关键词:EtherCAT C# SDK, C# EtherCAT 主站, Windows 实时 EtherCAT, Darra EtherCAT Master

写在前面

最近接了个多轴运动控制的活,几个伺服轴加一堆 I/O。总线选型没什么悬念,EtherCAT 是默认答案。真正卡住我的是主站这一层。

我们整套上位逻辑是 C# 写的,团队也都吃 .NET 这一套。但说实话,C# 在 EtherCAT 主站上一直是个有点尴尬的位置。能选的路大概几条,我都掂量了一遍:

走 IgH 或 SOEM 这类开源 C/C++ 栈,成熟、免费,拿来理解 EtherCAT 的报文流程很合适。代价是它们是给 C 程序员准备的,我要在上面再糊一层 P/Invoke,字节序、位操作、PDO 内存映射全得自己手抠,而且 IgH 基本是 Linux 的世界,搬到 Windows 上做硬实时不是它的主场。

往商业方案看,TwinCAT 是绕不开的标杆,生态、稳定性、配套工具都没话说,做 PLC + 运动控制的项目它几乎是行业默认。它把上位逻辑放进 Beckhoff 的开发环境和那套 IDE 里,对深扎 Beckhoff 生态的团队很顺手;我们这种纯 .NET 应用层、不想引入一整套工程体系的团队,方向上对不太上。CODESYS、KPA 各有侧重,路子跟 TwinCAT 类似,都是"买进一整套生态"。

选型时我列了几个硬指标:C# 原生、Windows 上能跑硬实时、不强绑某家 IDE、协议特性够用。逐个对照下来,我把 Darra EtherCAT Master 的 C# SDK 拉进来试了一轮——它是个商业方案,和 TwinCAT / CODESYS / EC-Master / KPA 同一档,纯软件、标准网卡就能跑。试完之后这个项目就定它了。这篇把从环境搭建、扫从站、PDO 收发到驱伺服的整个过程记一下,连带配置怎么做、坑怎么排、实测数据是什么样。机器人那边的业务我不展开,只聊 EtherCAT 这条线。官网在这儿:https://ethercat.darra.xyz/

环境搭建

NuGet 直接装:

Install-Package Darra-EtherCAT-Master

或者 .NET CLI:

dotnet add package Darra-EtherCAT-Master

除了包本身,还要装一个叫 DarraRT 的内核实时驱动(随安装包带,免费)。我一开始对"还得装个内核驱动"是有点犹豫的——多一个组件就多一处可能出问题的地方。装完之后想法变了:它在内核态接管帧的实时收发、顺手抑制 SMI(系统管理中断),用户态代码只管读写 PDO 映像。换句话说,Windows 上做实时最烦的那层脏活,它在内核里替你干了。安装包会自动注册驱动,GUI 工具也会弹引导,没让我手动折腾 inf。网卡用的就是机器自带的 Intel 千兆口,没买任何实时扩展卡——这一点后面测抖动的时候才体会到分量。

整个搭环境的流程我复盘一下,给后来人对个步骤:

  1. NuGet 装包,DarraRT 驱动跟着装包自动注册,重启一次让驱动加载。
  2. 打开 GUI 配置工具,它会列出本机网卡,认准那块直连从站的 Intel 千兆口(别选错了 WLAN 或虚拟网卡,这是最常见的低级错)。
  3. 工具里点"扫描网络",把在线从站和拓扑先认出来,确认每个从站都能进到 PREOP,再往下走代码。

这套顺序我建议照搬:先用工具确认物理链路和拓扑没问题,再写代码。我头一回图省事直接上代码扫站,结果是网卡选错,排查半天才发现根本不在协议层。

第一个程序:扫描从站

照例从最小例子起步,先把网络上的从站扫出来确认拓扑:

using Darra.EtherCAT;

class Program
{
    static void Main()
    {
        var master = new EtherCATMaster();
        master.Init("Intel Ethernet"); // 网卡名称

        var slaves = master.ScanNetwork();
        Console.WriteLine($"发现 {slaves.Length} 个从站:");

        foreach (var slave in slaves)
        {
            Console.WriteLine($"  [{slave.Position}] {slave.Name} " +
                            $"Vendor:0x{slave.VendorId:X8} " +
                            $"Product:0x{slave.ProductId:X8} " +
                            $"State:{slave.State}");
        }

        master.Dispose();
    }
}

输出大致这样:

发现 3 个从站:
  [0] EL2008  Vendor:0x00000002 Product:0x000007D8 State:OP
  [1] AX5206  Vendor:0x00000002 Product:0x00001456 State:OP
  [2] EL1008  Vendor:0x00000002 Product:0x000003F0 State:OP

这一步主要是核对:从站个数对不对、位置(Position)排列跟我物理接线顺序一致不一致、State 能不能一路推到 OP。第一次扫的时候我有个从站卡在 PREOP 上不去 OP,回去看是 ENI 里那颗从站的 PDO 映射没配全,工具里重新生成一遍配置就过了——这类问题在扫描阶段就能暴露,比到了周期里数据不对再回头查省事得多。

命令行扫从站我只在第一天玩了玩,后面基本都开它自带的图形化配置工具:扫一遍网络、自动认拓扑,把 PDO/SDO 结构和启动参数生成出来,ENI 也能一键导入导出。下面要用到的那个 PDO 结构体,其实就是工具生成的,省了我对着对象字典手写映射。顺手发现的一个好处是,它能按同一份配置吐出好几种语言的控制代码,所以后来我拿 Python 临时搭了个验证小工具时,PDO 这层几乎没重写。

PDO 读写实战

EtherCAT 的过程数据走 PDO 周期通信。Darra 的 C# SDK 把 PDO 映像直接映射成结构体,用 ref 拿内存映像的引用来读写,省掉中间那次拷贝。这点我当时特意确认过——做闭环最怕的就是数据在用户态被反复 memcpy,每周期多一次拷贝,几十微秒的周期下就是实打实的开销。

周期节拍不是我自己拿循环去凑的。SDK 让你注册一个回调,由 DarraRT 驱动按设定周期(比如 62.5µs / 125µs)实时触发,回调里就做"读 Tx、算、写 Rx"这点活,发送交给实时层。SDO 邮箱我只在初始化阶段用(设模式、配参数),周期里一律走 PDO,这两条路径不混。

// PDO 结构(由图形化配置工具自动生成)
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct RxPDO
{
    public ushort ControlWord;      // 0x6040
    public int    TargetPosition;   // 0x607A
}

[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct TxPDO
{
    public ushort StatusWord;       // 0x6041
    public int    ActualPosition;   // 0x6064
}

var master = new EtherCATMaster();
master.LoadENI("config.xml");

// 初始化阶段:SDO 仅用于配置(设 CSP 模式 = 0x08)
master.WriteSDO(slaveIndex: 0, index: 0x6060, subIndex: 0x00, value: (sbyte)0x08);

// 拿到零拷贝的 PDO 映像引用(指向过程数据映像内存,读写即生效)
ref var rx = ref master.GetRxPDO<RxPDO>(0);
ref var tx = ref master.GetTxPDO<TxPDO>(0);

// 注册周期回调:由 DarraRT 实时层按设定周期触发,
// 用户代码只读写零拷贝 PDO 映像,发送由实时层完成
master.OnCycle += () =>
{
    // CiA 402 状态机:通过 PDO 里的控制字/状态字推进
    switch (tx.StatusWord & 0x006F)
    {
        case 0x0040: // Switch On Disabled
            rx.ControlWord = 0x0006; // Shutdown -> Ready to Switch On
            break;
        case 0x0021: // Ready to Switch On
            rx.ControlWord = 0x0007; // Switch On
            break;
        case 0x0023: // Switched On
            rx.ControlWord = 0x000F; // Enable Operation
            break;
        case 0x0027: // Operation Enabled —— 进入正常控制
            // CSP:每个周期下发新的目标位置
            rx.TargetPosition = tx.ActualPosition + 100;
            break;
    }
};

master.Start(); // INIT -> ... -> OP,并启动实时周期

这段的要点是:控制字 0x6040、状态字 0x6041、目标位置 0x607A、实际位置 0x6064 全映射进了 PDO,靠每周期收发的过程映像驱动状态机和闭环;SDO 只在 Start() 之前设一次模式。状态机靠读状态字(StatusWord & 0x006F)判断当前态再写控制字推进,不靠 Thread.Sleep 去"等一会儿"——确定性判定才是这层该有的写法。

调这段 CiA 402 状态机的时候,我的调试手法是在回调里把 StatusWord & 0x006F 打到一个环形缓冲里,回头看状态迁移的轨迹。第一回伺服死活进不了 Operation Enabled,一看轨迹卡在 0x0021(Ready to Switch On)来回跳,是我控制字写错了顺序,把 0x000F 直接怼上去而没走 0x0007 这一步。状态字一路 0x0040 → 0x0021 → 0x0023 → 0x0027 走顺,伺服上使能、跟着 CSP 目标动起来,这条就算跑通了。

这里用的是 CSP(周期同步位置)。换成 CSV、CST 或别的模式,本质就是改 0x6060 和对应的 PDO 映射,图形工具能把对应模式的结构直接生成出来。CiA 402 那几个模式它都覆盖,后来调速度环我换 CSV,PDO 映射让工具重生成一遍,对象字典几乎没自己抄。

关于"用 Sleep 当周期"这件事

这是我自己实打实栽过的坑。入门示例特别爱写 Thread.Sleep(1) 当 1ms 周期,我照着试了,在 Windows 上直接翻车:默认定时器分辨率是 15.6ms,Sleep(1) 实际可能睡 1~16ms,根本不是稳定周期,更别提微秒级。当时我把每周期的时间戳记下来画了个直方图,周期分布从 1ms 拖到 16ms 全有,乱成一团——那张图比任何文档都直观,一眼就知道这条路走不通。

正确做法就是上面那样,周期交给 DarraRT 回调,用户代码不碰定时。换成回调驱动以后再画同样的直方图,周期就死死贴在 62.5µs 上了。如果你只是想写个不接真硬件的 demo 看数据流转,Sleep 凑合也行,但心里得清楚那是演示、不是实时节拍——这两件事我一开始混为一谈,调了半天才反应过来问题出在定时器上。

实时性能实测

实时性是我选型时最纠结的一块,也是 Windows 做 EtherCAT 最容易被一句话带偏的地方——“Windows 抖动大、做不了硬实时,老老实实换 Linux”,这话我也信过一阵子。直到自己量了才发现没那么绝对。

测法说一下,免得这组数看着像凭空来的:我让 DarraRT 跑在 62.5µs 周期,把每帧实际发送时刻和理论时刻的差值采下来,连续跑几分钟统计典型值和最大值。下面是我机器上测出来的量级,跟官网"帧发送周期抖动测试"表对得上:

平台 建议周期 帧发送抖动(典型) 最大抖动
Windows 10 IoT Enterprise(普通) 125 µs 1.1 µs 210 µs
Windows 10 IoT Enterprise + DarraRT + SMI 抑制 62.5 µs 1.0 µs 4.2 µs
Linux PREEMPT_RT 62.5 µs 0.7 µs 3.2 µs
FreeRTOS 31.25 µs 0.3 µs 2 µs

第一行那台就是我这台机器没装 DarraRT、用默认配置时的样子,也是"Windows 抖动大"这个印象的来源:最大抖动能飙到 210µs,确实做不了硬实时。但问题不在 Windows 本身,在默认的 SMI 和调度。第二行是同一台机器装了 DarraRT、开了 SMI 抑制之后——62.5µs 周期下典型抖动掉到 1.0µs、最大 4.2µs,跟 Linux PREEMPT_RT 那行(0.7µs / 3.2µs)已经是一个量级。这个对比是我整个选型里最意外的一处:原本是抱着"先在 Windows 凑合开发、产线再换 Linux"的心态来的,量完发现没这个必要了——同一块标准 Intel 千兆口,装个免费驱动,Windows 就跑到了我以为只有 Linux 才有的确定性。

为了让这个数靠得住,我调试时做了几件事,列出来方便复现:BIOS 里关掉 C-state 和无关的电源管理、给 EtherCAT 那颗核做核隔离、关掉一批用不上的后台服务,再开 DarraRT 的 SMI 抑制。这套基本功做下来,4.2µs 那行就稳得住;少了哪一项,最大抖动就会往上抬一截。也就是说这数不是裸装白来的,是配出来的——但配的方法很标准,按上面这几步走就行。

对我这个项目的结论很具体:标准 Intel 千兆口 + 免费的 DarraRT,就在我现成的这台 Windows 10 IoT Enterprise 上,拿到了够用的确定性,不用为实时性单独搭一套 Linux。而且这套 SDK 在 Win/Linux/FreeRTOS/RT-Thread 上 API 一致,万一哪天真要迁,FreeRTOS 那行甚至能跑到 31.25µs 周期,代码改动也小——这层退路在,我反而更敢就在 Windows 上落地。

用下来的几点体会

把这一程跑下来,最值的几样:API 是纯 C# 的味道,PDO 直接是强类型结构体,不用我自己处理字节序和位操作,这对 .NET 团队是实打实的省心;零拷贝那条路径让闭环代码读着也干净,每周期省掉的那次拷贝在几十微秒的节拍下不是小事。配置工具确实省事,扫网络、生结构、导 ENI 一条龙,手写映射的活基本免了;换伺服模式时让它重生成 PDO 结构,对象字典几乎不用自己碰。文档中英文都有,例子覆盖了我用到的场景。

最让我安心的还是实时性这块:闭环跑起来之后,DC 同步、CiA 402 全模式、PDO 周期收发这几样我天天在用,量过的抖动也一直稳在那个量级,没出过意外。把"Windows 上拿到接近 Linux 的硬实时"从一句口号变成机器上能复现的数,这件事是 DarraRT 这个免费驱动替我办成的,也是整个项目里我最庆幸选对了的一处。

我的取舍

回头看,这次选型其实没有"最强"的答案,只有"最合手"的答案。TwinCAT 在它的生态里依然是标杆,SOEM/IgH 在 Linux 和开源场景里依然好用,这些都没变。Darra 之所以被我留下,是因为它把我这个项目最在意的三件事——C# 原生、Windows 上能压到微秒级抖动、不绑死某家 IDE——同时满足了,而我把每一件都自己搭起来、调过、量过,结论是它恰好长在我需求的形状上。

如果你的约束条件跟我相近——纯 .NET 团队、部署在 Windows、又要做多轴伺服——它值得拉进候选名单,按上面这套步骤自己跑一轮;想验证那张抖动表,标准网卡装个免费驱动就能复现,比我转述的可信。文档和六语言 SDK 在 https://ethercat.darra.xyz/

更多推荐