在 Windows 上用 C# 实现 EtherCAT 主站:多轴电子凸轮

如果你做过包装、印刷或者灌装设备,大概都和"凸轮"打过交道。封切刀要在输送链走到某个相位时落下,印刷的色组辊要跟着版辊精确套准,灌装头要随着转盘的转角升降开阀——这些动作有个共同点:从动件的位移,必须是主轴相位的一个确定函数 s = f(θ)

过去这件事是靠机械实现的:车一片实体凸轮盘,轮廓就是那条曲线,主轴转一圈,从动件就沿着轮廓走一段。问题也很明显——换个产品规格,行程变了、相位变了,你就得拆机、换凸轮盘、重新对零、重新调试,一折腾就是大半天。柔性化生产、小批量多品种,这种"靠铁疙瘩定形状"的方式越来越吃不消。

电子凸轮就是来解决这个痛点的:把那条机械轮廓变成一条数学曲线,控制器每个控制周期实时算出从轴此刻该走到哪里,直接下发给驱动器。换曲线只要改软件参数;行程、相位、主轴转速全都能在线调;再也不用拆机换凸轮盘。这篇文章,我们就用 Darra EtherCAT Master 在 Windows 上直接用 C# 把多轴电子凸轮跑通。

下面进入正题。我们用 5 台鸣志 STF-EC EtherCAT 步进驱动器,演示如何用一根虚拟主轴驱动多根从轴做电子凸轮。两个核心思路先记在脑子里:

  • 主轴是软件相位,不需要真实编码器:用 masterPhase ∈ [0,1) 当主轴,每个 PDO 周期(125µs)推进一次。
  • 从轴只用 CSP 模式(Mode=8):周期同步位置模式,跟随主站逐周期算出的凸轮目标位置。

完整源码
本案例完整源码(WinForms + CiA 402 状态机 + 虚拟主轴 + 凸轮曲线 + CSP 跟随线程 + 报警/诊断)见 GitHub:
https://github.com/DarraTechnology/Ethercat_Master/tree/main/Windows/CSharp/STF-EC_ECam

硬件配置

  • 主站: Windows 10 + Intel i5
  • 步进驱动器: STF-EC EtherCAT 步进驱动器 × 5(厂商鸣志 Shanghai AMP&MOONS’ Automation)
  • 协议: CoE(CiA 402, Profile 402),DC Sync0 125µs
厂商 Shanghai AMP&MOONS’ Automation(鸣志)
VendorId 0x00000168
型号 / ProductCode STF-EC / 0x02
轴数 config.deni 实际扫描决定(本例 5 轴)
控制模式 CSP 周期同步位置(Mode = 8)
同步 DC Sync0 125µs(CSP 必需)

这里有个小细节值得说一句:轴数不是写死的,而是由 config.deni(真实拓扑文件)实际扫描到的从站数量决定。界面按轴数自动生成总览表,你接 3 台还是 8 台,代码一行不用改。本例 5 台 STF-EC 全部当作凸轮从轴。

它能用在哪

判断一个应用要不要上电子凸轮,有个简单的标准:过去是不是用机械凸轮做的? 是,基本就能换:

  • 包装机: 走膜、封切、横封纵封等动作,按输送链相位做确定位移。膜走到哪一段封刀就落下,完全由曲线决定。
  • 印刷机: 各印刷单元色组随版辊相位精确套准——套不准就是重影废品,对相位精度要求很高。
  • 飞剪 / 追剪: 剪刀按料带速度同步加减速,剪切的那一瞬间刀和料带等速,这样切口才平齐、料才不被拉扯。
  • 灌装机: 灌装头随转盘相位升降、开合阀,转盘转到工位上方才出料。

相比机械凸轮,电子凸轮最大的好处就是"软"——换曲线只改参数,行程、相位、主轴转速能在线调。换型从"拆机半天"变成"界面上点几下"。

工作原理

先回忆一下机械凸轮:主轴每转一圈,从动件就按凸轮轮廓做一段确定位移。换句话说,从动件位移是主轴转角的函数 s = f(θ)。这条 f 就是凸轮盘的轮廓形状。

电子凸轮做的事,本质就是把这条轮廓搬到软件里变成数学曲线,由控制器每周期实时计算并下发。整套逻辑围绕四个概念,我们一个个拆开讲为什么这么设计。

1. 虚拟主轴相位推进。 机械凸轮的主轴是根真实的轴,得装编码器读它的角度。但很多场合根本没有"真实主轴"可读,或者你就是想让设备自己当节拍源。所以这里用一个软件相位 masterPhase ∈ [0,1) 来当主轴——它不依赖任何编码器,纯软件维护,每个 PDO 周期(125µs)推进一点点:

Δphase = 主轴RPM / 60 / 8000   // 8000 Hz = 125µs 周期
masterPhase = frac(masterPhase + Δphase)   // 到 1 自动回 0, 一圈 = 0→360°

8000 这个数不是随便来的:1 / 125µs = 8000 Hz,也就是每秒推进 8000 次。frac() 取小数部分,让相位走到 1 就自动回 0,天然形成"一圈"的循环。你想让设备跑多快,改 主轴RPM 就行,相位推进的步长随之变化。

2. 挂载(Engage)无跳变。 这是最容易踩坑、也最体现工程细节的地方。从轴勾选"挂载"才开始跟随主轴。如果挂载的瞬间直接让目标位置 = Cam(...) × 行程,而曲线此刻的值不等于电机当前所在位置,那目标位置就会突变——伺服收到一个阶跃指令,电机猛地一蹿,轻则报跟随误差,重则撞坏机构。

解法是:挂载那一刻,快照当前实际位置作基准 Base,从轴目标 = Base + Cam(...) × 行程。这样挂载瞬间从轴目标恰好等于它当前所在位置(相对偏移为 0),不会产生任何位置阶跃。后续电机就从当前位置开始,平滑地沿曲线走起来。一句话:所有运动都是相对 Base 的增量,绝不给伺服一个绝对的"跳到那儿去"。

3. 相位偏移(Phase Offset)。 多轴凸轮里,经常需要让各轴在曲线上彼此错开——比如一台机器有 5 个工位,它们不能同时动,得在一个周期里均匀分布。每根从轴可设各自的相位偏移(度),例如 5 轴各偏 0/72/144/216/288°,正好在一圈里五等分。实现上就是取曲线值之前,先给主轴相位加上这个偏移。

4. 从轴目标位置公式(每周期、每挂载轴,把上面三点合起来):

output.TargetPosition = Base + (int)Round( Cam(曲线, frac(masterPhase + 相位偏移/360)) × 行程 )

读一遍这个公式,你就掌握了整个电子凸轮的数学:Base 保证零跳变,Cam(曲线, ...) 是归一化的轮廓形状,× 行程 把它缩放到真实脉冲,+ 相位偏移/360 让这根轴在曲线上错开。控制器每周期对每根挂载轴算一次这个公式,写进 PDO,就完事了。

为什么只用 CSP(Mode = 8)?

一句话:电子凸轮要求每个控制周期都精确下发一个新的目标位置,并与总线周期硬同步——这正好是 CSP(Cyclic Synchronous Position,周期同步位置)的语义,简直是为凸轮量身定做的。

  • 主站每周期把算好的凸轮目标位置写入对象字典 0x607A,轨迹由主站算,驱动器只做位置跟随,不自作主张。
  • 配合 DC Sync0(125µs)把各从站时钟对齐到同一节拍,各轴在同一拍上接收并执行各自的目标位置,多轴凸轮才不会彼此漂移。

为什么不用 PP? 这个对比很重要,因为新手很容易想当然地用 PP。PP(Profile Position,轮廓位置)是点到点定位:你给驱动器一个目标点和速度/加速度参数,驱动器内部自己生成一条轨迹走过去。问题在于——凸轮的形状是任意曲线,逐周期变化,主站根本没法用"一个目标点 + 几个参数"把这种连续变化的形状交代给驱动器。轨迹规划权在驱动器手里,你就拿不到逐周期的凸轮形状。所以 PP 天生不适合凸轮,本例也不提供 PP 分支。

记住这个分工:CSP = 主站逐周期喂位置,驱动器纯跟随;PP = 主站给目标点,驱动器自己规划。 凸轮要的是前者。

系统架构

相位 + 0°

相位 + 72°

相位 + 144°

相位 + 216°

相位 + 288°

CSP 0x607A 目标位置

虚拟主轴 Master
masterPhase ∈ [0,1)
每 125µs 推进 Δ=rpm/60/8000

从轴 1 (STF-EC)

从轴 2 (STF-EC)

从轴 3 (STF-EC)

从轴 4 (STF-EC)

从轴 5 (STF-EC)

EtherCAT 总线
DC Sync0 125µs

看这张图,你会发现主站职责其实很轻,每周期只做三件事:

  1. 推进虚拟主轴相位;
  2. 对每根挂载从轴,用它的相位偏移取曲线值,算出目标位置;
  3. 维持 CiA 402 使能握手,把目标位置写进 RxPDO。

它不做内部插补、也不做轨迹规划——凸轮形状完全由曲线函数决定。这种"轻主站 + 纯跟随从站"的分工,正是 CSP 模式的精髓:复杂度在主站的曲线算法里,通信和执行交给硬件保证确定性。

代码示例

PDO 结构体定义

PDO(Process Data Object,过程数据对象)是 EtherCAT 周期交换的那块数据。我们得在 C# 里定义两个结构体,精确对应驱动器的输入输出布局。

STF-EC 默认 RxPDO 输出 29 字节、TxPDO 输入 35 字节。电子凸轮实际只用到输出的三项(ControlWord / ModesOfOperation / TargetPosition)和输入的三项(ErrorCode / StatusWord / PositionActualValue)。

这里有个新手必踩的坑:既然只用这几项,其余字段能不能删了图清爽?绝对不行。 PDO 是按字节布局的,结构体字段的偏移必须和驱动器的 PDO 条目逐字节对齐。你删掉中间任何一个字段,后面字段的偏移全错位,TargetPosition 写到的就不是 0x607A 那个位置了,映射直接失败,电机要么不动要么乱动。所以那些"用不到"的字段不是冗余,它们是占位,必须存在才能让结构体字节数和驱动的 PDO 分配严格对齐。

下面只把用到的字段贴出来,其余省略——但请记住,在真实代码里它们要按完整布局占位:

using System.Runtime.InteropServices;

// STF-EC 步进驱动器输出 PDO (RxPDO, 共 29 字节)
// 电子凸轮只用到 ControlWord / ModesOfOperation / TargetPosition 三项。
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct STF_Output  // 29 字节
{
    public ushort ControlWord;          // 0x6040  控制字 (CiA402 状态机)
    public sbyte  ModesOfOperation;     // 0x6060  操作模式 (CSP = 8)
    public int    TargetPosition;       // 0x607A  目标位置 (脉冲, 每周期由凸轮算出)
    // …其余字段省略, 仅为对齐 PDO 字节数, 详见完整源码
}

// STF-EC 步进驱动器输入 PDO (TxPDO, 共 35 字节)
// 注意: 此 PDO 错误码在前、状态字在后。
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct STF_Input  // 35 字节
{
    public ushort ErrorCode;            // 0x603F  错误码 (≠0 表示驱动故障)
    public ushort StatusWord;           // 0x6041  状态字 (判 CiA402 状态机)
    public int    PositionActualValue;  // 0x6064  实际位置 (脉冲, 挂载时快照作 Base)
    // …其余字段(操作模式显示/实际速度/数字输入/探针等)省略, 仅为对齐 PDO 字节数, 详见完整源码
}

[StructLayout(LayoutKind.Sequential, Pack = 1)] 这行特性很关键:Sequential 保证字段按声明顺序排列,Pack = 1 取消 .NET 默认的内存对齐填充。少了它,编译器会自作主张在字段间插入填充字节让结构体"对齐",那 PDO 布局立马就错了。还有一处容易看走眼:STF-EC 的输入 PDO 是错误码在前、状态字在后,顺序别想当然地反过来。

再强调一遍:结构体字段的偏移必须和 PDO 条目逐字节一致,所以省略的字段在真实代码里要按完整布局占位,绝不能删。文章为了清楚只展示用到的那几个。

连接与 DC 同步

连接流程是 Build → PreOp → (逐轴 PDO 重映 + DC) → SafeOp → (尺寸自检) → OP。这是 EtherCAT 主站标准的状态机启动序列,但其中两步是 CSP 凸轮的必备前提,我们重点讲:

  • PDO 重映(0x1C12 / 0x1C13):驱动器默认的 RxPDO 比较短,字节数和我们上面定义的完整结构体对不上。不重映,任何模式都会映射失败。重映就是经 CoE 把 PDO 分配改成 config.deni 描述的完整 4+4(输出 29 / 输入 35)。
  • DC Sync0:不开 DC,各从站各跑各的本地时钟,多轴之间的执行时刻会慢慢漂移,凸轮跑着跑着各轴就错位了。开了 DC,所有从站对齐到同一硬件节拍。

还有个时序约束要记牢:PDO 重映只能在 PreOp 态做。因为这时候负责过程数据的同步管理器(SM2)还没激活,改 PDO 分配才安全;进了 OP 再改就晚了。下面只摘关键步骤(省略了 try/catch 与取消检查这类样板):

// 构建主站并扫描从站
var buildResult = new DarraEtherCAT()
    .SetENI(deniPath)            // 加载 config.deni (真实拓扑, 带 SHA256 校验)
    .EnableAutoStartup()
    .Build();
if (!buildResult.Success) return;

var master = buildResult.Master;
int slaveCount = master.SlaveCount;   // 轴数由实际扫描决定

// 进 PreOp 后才能写 SDO 重映 PDO 分配
master.SetState(EcState.PreOp);

for (int i = 0; i < slaveCount; i++)
{
    var slave = master.Slaves[i];

    // ① 经 CoE 把 PDO 分配重映成完整 4+4 (输出 29 / 输入 35)。
    //    一次性初始化, 不进控制循环。
    slave.CoE.SDOWrite(0x1C12, 0, new byte[] { 0 });                       // 先清空 RxPDO 分配数
    slave.CoE.SDOWrite(0x1C12, 1, BitConverter.GetBytes((ushort)0x1600));
    // …装入其余 RxPDO 对象 (0x1601~0x1603)
    slave.CoE.SDOWrite(0x1C12, 0, new byte[] { 4 });                       // 回写分配 4 个 RxPDO

    slave.CoE.SDOWrite(0x1C13, 0, new byte[] { 0 });                       // 清空 TxPDO 分配
    slave.CoE.SDOWrite(0x1C13, 1, BitConverter.GetBytes((ushort)0x1A00));
    // …装入其余 TxPDO 对象 (0x1A01~0x1A03)
    slave.CoE.SDOWrite(0x1C13, 0, new byte[] { 4 });                       // 回写分配 4 个 TxPDO

    // ② 启用 DC Sync0 = 125µs, 把各从站时钟对齐到同一节拍 —— CSP 凸轮必需。
    //    SYNC0 必须与 LoopCycle 一致, 否则 PDO 帧与从站节拍失同步。
    master.Config.LoopCycle = 125000;             // PDO 交换周期 = 125µs
    if (slave.HasDC) slave.ConfigureDC(125000);   // Sync0 = 125µs
}

master.SetState(EcState.SafeOp);

// ③ PDO 尺寸自检: 用驱动实际进程映像字节数核对结构体, 不符立即报错
int expOut = Marshal.SizeOf<STF_Output>();   // 29
int expIn  = Marshal.SizeOf<STF_Input>();    // 35
for (int i = 0; i < slaveCount; i++)
    if (master.Slaves[i].OutputsByteCount != expOut ||
        master.Slaves[i].InputsByteCount  != expIn) return;   // 映射与 config.deni 不符

// ④ 进 OP 前: 目标位置先对齐当前实际位置, 上电不跳变
for (int i = 0; i < slaveCount; i++)
{
    ref var input  = ref master.Slaves[i].PDO.InputsMapping<STF_Input>();
    ref var output = ref master.Slaves[i].PDO.OutputsMapping<STF_Output>();
    output.ModesOfOperation = 8;                          // CSP
    output.TargetPosition   = input.PositionActualValue;  // 目标 = 当前实际, 无运动
}

master.SetState(EcState.OP);   // 进 OP, PDO 控制线程接管

几个值得圈出来的点:

  • LoopCycle 必须和 Sync0 一致,都是 125000ns(125µs)。一个是 PDO 帧的发送周期,一个是从站执行的触发节拍,两者不一致,帧就会和从站的节拍失同步,数据要么没准备好要么过期。
  • 第 ③ 步的尺寸自检是个好习惯:进 OP 之前,用驱动器实际报告的进程映像字节数核对我们的结构体大小,对不上立即报错返回。这能在第一时间抓出"PDO 重映没生效 / config.deni 和实际硬件不符"这类问题,省得到了运行时电机乱动才发现。
  • 第 ④ 步是零跳变的第一道保险:进 OP 之前先把目标位置设成当前实际位置。这样一上 OP,伺服收到的指令就是"待在原地",不会因为目标位置是个默认的 0 而猛地往回蹿。

凸轮曲线 Cam()

凸轮曲线是电子凸轮的灵魂——前面讲的所有框架,最后都是为了喂给这个函数算出一个值。Cam(type, u) 输入主轴相位 u ∈ [0,1),返回归一化位移 [-1,1],乘以行程(脉冲)即得实际位置偏移。我们提供三种曲线,对应三类典型机械凸轮轮廓:

// 取小数部分: 把任意相位规整到 [0,1) 一圈内
static double Frac(double x) => x - Math.Floor(x);

// 凸轮曲线: u ∈ [0,1) 为主轴相位, 返回 [-1,1] 归一化位移。
public static double Cam(int type, double u)
{
    u = Frac(u);
    switch (type)
    {
        case 0: // 正弦: 一圈内一个完整正弦往复, 加减速平滑, 最常用
            return Math.Sin(2.0 * Math.PI * u);

        case 1: // 摆线: 起停加速度连续(无冲击), 高速凸轮首选
        {
            double rise;
            if (u < 0.5)
            {
                double s = u * 2.0;                                        // s ∈ [0,1)
                rise = s - Math.Sin(2.0 * Math.PI * s) / (2.0 * Math.PI);  // 平滑上升 0→1
            }
            else
            {
                double s = (u - 0.5) * 2.0;
                double up = s - Math.Sin(2.0 * Math.PI * s) / (2.0 * Math.PI);
                rise = 1.0 - up;                                          // 镜像回落 1→0
            }
            return rise * 2.0 - 1.0;                                      // 0..1 映射到 -1..1
        }

        case 2: // 直线 / 电子齿轮: 线性斜坡, 位移与主轴相位严格成正比
            return u * 2.0 - 1.0;

        default:
            return 0.0;
    }
}

三种曲线各有脾气,选哪个看工况:

  • 正弦(case 0):一圈内一个完整正弦往复,加减速平滑,是最常用的通用曲线。位置、速度都连续,大多数往复机构用它就够了。
  • 摆线(case 1):在起停点连加速度都是连续的——也就是没有突变的冲击力。这对高速凸轮特别重要,因为速度突变意味着加速度无穷大,机构会"咣"地一下震动甚至损坏。摆线把这个冲击抹平了,所以是高速场合的首选。
  • 直线(case 2):线性斜坡,从轴位移与主轴相位严格成正比。

最后这条直线,藏着本文(和系列)最重要的一个洞见:

同步轴(电子齿轮)就是凸轮曲线取直线时的线性特例。

type = 2(直线)时,从轴位移 2u-1 与主轴相位严格成比例,这正是电子齿轮 / 同步轴的比例跟随——主轴走多少,从轴按固定比例走多少。换句话说,"电子齿轮"并不是一套独立的技术,它只是凸轮曲线里最简单的一根直线

二者本质同源:凸轮是一般的 s = f(θ),电子齿轮则是其中 f 取线性的那个特例。理解了这一点,你就理解了运动控制里这两个概念的关系——不是并列的两样东西,而是一般与特殊。电子齿轮的完整玩法(变比、相位补偿、动态换比等)可以另开一篇细讲,这里点到为止。

CSP 周期跟随控制

到这一步,所有零件都齐了,该把它们装进那个每 125µs 跑一次的回调里了。

凸轮控制挂在 SDK 的 PDO 周期回调上,每个总线周期(125µs)执行一次,做三件事:凸轮启动时锁存各挂载轴的 Base、推进虚拟主轴相位、逐轴算凸轮目标位置。这里有个工程上的讲究:热路径(每 125µs 跑一次的代码)要尽可能轻。所以报警检测、跟随误差判断这类枝节,在热路径上只置一个 volatile 闩(打个标记),真正的决策和界面呈现放到 50ms 的 UI 侧去做——别在那一拍里干重活,否则就可能拖累节拍。这部分此处略去,详见源码。

实时控制要挂在 ProcessDataCyclicSync 周期回调上(由 DC 总线周期驱动),别用 Thread.Sleep 在用户态自旋——那样节拍不稳,算不上实时控制。

// 实时控制挂到 SDK 的 PDO 周期回调上 —— 由总线周期 (125µs) 硬同步驱动。
master.Events.ProcessDataCyclicSync += OnPdoCycle;

// SDK 每个总线周期 (125µs) 自动回调一次, 推进主轴相位 + 逐轴凸轮跟随。
void OnPdoCycle(ushort mi)
{
    var m = master;
    if (m == null) return;
    var arr = axes;

    // ① 凸轮启动锁存: 快照各挂载轴当前实际位置作为 Base, 跟随时无跳变
    if (_camStartLatch)
    {
        for (int i = 0; i < arr.Length; i++)
            arr[i].Base = m.Slaves[arr[i].SlaveIndex]
                           .PDO.InputsMapping<STF_Input>().PositionActualValue;
        _camStartLatch = false;
    }

    // ② 主轴相位推进: Δphase = rpm/60/8000 (8000 Hz = 125µs 周期), 到 1 自动回 0
    if (_camRunning)
        _masterPhase = Frac(_masterPhase + _masterRpm / 60.0 / 8000.0);

    int curve = _curveType, amp = _amplitude;   // amp = 行程 (脉冲)
    double phase = _masterPhase;

    // ③ 逐轴 CSP 跟随 —— 同一回调内连续写各轴目标位置, 同一 PDO 帧一并提交
    for (int i = 0; i < arr.Length; i++)
    {
        var a = arr[i];
        ref var input  = ref m.Slaves[a.SlaveIndex].PDO.InputsMapping<STF_Input>();
        ref var output = ref m.Slaves[a.SlaveIndex].PDO.OutputsMapping<STF_Output>();
        StepCam(a, ref input, ref output, curve, amp, phase);
        // …另有报警/跟随误差检测, 详见源码
    }
}

// 单轴 CSP 凸轮跟随: 先维持 CiA402 使能握手 (0x06→0x07→0x0F, 故障 0x80),
// 进入 OperationEnabled 后按凸轮算目标位置写入 RxPDO。
void StepCam(AxisController a, ref STF_Input input, ref STF_Output output,
             int curve, int amp, double phase)
{
    output.ModesOfOperation = 8;        // CSP
    ushort sw = input.StatusWord;
    ushort cw = 0;

    if (a.FaultReset)                { cw = 0x80; a.FaultReset = false; }                              // 故障复位
    else if (IsFault(sw))            { /* 等待故障复位 */ }
    else if (!a.ServoEnabled)        { output.TargetPosition = input.PositionActualValue; }
    else if (IsSwitchOnDisabled(sw)) { cw = 0x06; output.TargetPosition = input.PositionActualValue; } // Shutdown
    else if (IsReadyToSwitchOn(sw))  { cw = 0x07; output.TargetPosition = input.PositionActualValue; } // Switch On
    else if (IsSwitchedOn(sw))       { cw = 0x0F; output.TargetPosition = input.PositionActualValue; } // Enable Operation
    else if (IsOperationEnabled(sw))
    {
        cw = 0x0F;   // 保持使能
        if (a.Engaged && _camRunning)
        {
            // ★ 核心: 从轴目标 = Base + Cam(曲线, 主轴相位 + 该轴相位偏移) × 行程
            double u = Frac(phase + a.PhaseOffsetDeg / 360.0);   // 加相位偏移让多轴错开
            output.TargetPosition = a.Base + (int)Math.Round(Cam(curve, u) * amp);
        }
        else
        {
            output.TargetPosition = input.PositionActualValue;   // 未挂载: 保持当前位置
        }
    }
    output.ControlWord = cw;
}

回调里的三步,对应的就是前面"工作原理"讲的那套,代码和理论一一对上了:_camStartLatch 锁存 Base(零跳变),相位推进,然后逐轴在同一个回调里连续写各轴目标位置——注意这点很重要,同一回调内写完所有轴,它们就会在同一个 PDO 帧里一并提交,各轴的目标位置严格在同一拍生效,这正是多轴不漂移的基础。

StepCam 里那一长串 if-elseCiA 402 状态机的使能握手。伺服不是上电就能动的,它有一套标准状态机,得按顺序敲控制字把它一步步"唤醒"到 OperationEnabled 才能接受位置指令。这里每一步都遵守"还没进 OperationEnabled 时,目标位置 = 当前实际位置"的原则,继续保证零跳变。等真正进了 OperationEnabled 且这根轴已挂载、凸轮在跑,才执行那行带 ★ 的核心公式。

握手的判别全是对状态字 sw 做掩码比较,几个常量记一下:

  • IsSwitchOnDisabled = (sw & 0x6F) == 0x40
  • IsReadyToSwitchOn = 0x21
  • IsSwitchedOn = 0x23
  • IsOperationEnabled = (sw & 0x6F) == 0x27
  • IsFault = (sw & 0x4F) == 0x08

使能握手依次发控制字 0x06 → 0x07 → 0x0F(Shutdown → Switch On → Enable Operation,把伺服从"禁止"一步步推到"运行使能");遇到故障则发 0x80 复位。这套 0x06→0x07→0x0F 的握手序列是 CiA 402 标准动作,几乎所有 EtherCAT 伺服/步进都吃这一套,学会一次,换个驱动器也通用。

操作步骤

把上面的代码逻辑映射到界面操作,实际跑一遍是这样:

  1. 连接: 走 Init → PreOp → SafeOp → OP,逐轴启用 DC Sync0、PDO 重映与尺寸自检,进入 CSP。
  2. 全部使能: 各轴跑 CiA 402 握手(0x06 → 0x07 → 0x0F)进入 OperationEnabled。
  3. 选曲线 + 行程: 选凸轮曲线(正弦 / 摆线 / 直线)和行程脉冲;界面即时预览曲线形状,选之前就能看到轮廓长啥样。
  4. (可选)设相位偏移: 给各轴填不同角度,让多轴在凸轮上均匀错开(如 0/72/144/216/288°)。
  5. 全部挂载: 把从轴挂到凸轮——挂载一刻快照 Base,跟随无跳变。
  6. 启动凸轮: 设主轴 RPM 后启动,主轴相位开始推进,挂载轴按曲线跟随;停止让各轴停在当前位置。

安全约定:连接/上电后不会自动运动——因为目标位置初始化成了当前实际位置。必须先「使能」→「挂载」→「启动凸轮」,电机才会动。这种"默认不动"的设计是有意为之:工业现场最怕的就是上电瞬间机构自己乱蹿,所以我们让运动必须由人显式触发,层层确认。此外还有报警分级、故障组停闭锁、跟随误差防误报(grace 宽限)等诊断逻辑,详见源码。

实测:DC 通讯抖动有多小

C# 带 GC、有线程调度,到底能不能做确定性实时运动?光讲道理不算数,直接看实测。下面是这套方案在内核线程模式下实测的 DC 周期通讯抖动:

模式 p99 p99.99 max
内核线程 ~0 19µs 62.4µs

怎么读这张表(抖动 = 实际发帧时刻相对理想 125µs 节拍的偏差):

  • p99 ≈ 0:99% 的周期几乎零抖动,节拍稳得像硬件时钟。绝大多数拍都是准点到。
  • p99.99 = 19µs:哪怕是万分之一的极端周期,偏差也只有 19µs。也就是说,你跑一万拍,最差的那一拍也才偏 19µs。
  • max = 62.4µs:整段跑下来最坏的一拍,抖动 62.4µs——相对 125µs 的周期还不到一半。最坏情况都没"丢拍",远在安全余量之内。

这就是"确定性"的量化证据:每一拍都准时到,凸轮目标位置不会因为节拍漂移而失真,多轴之间也不会越跑越偏。回扣开头的疑问——C# / .NET 确实能做确定性实时运动,前提是把"何时执行"交给 DC 硬件时钟和内核 RT 路径,而不是在托管线程里掐表。

反过来,要是你真用用户态 Thread.Sleep 自旋去定节拍,抖动会是毫秒级、还不可控(GC、线程调度随时插一脚),根本拿不到这种数据——这正是它"不算实时控制"的原因,也是为什么我们从头到尾死守 ProcessDataCyclicSync 回调这条路。

小结 / 要点回顾

我们用 Darra EtherCAT Master,在 Windows 上纯 C# 把多轴电子凸轮跑通了。回头看,几个核心点串起来就是整套方案:

  • 虚拟主轴 = 软件相位:masterPhase ∈ [0,1) 每周期推进,不需要真实编码器主轴。
  • CSP(Mode=8)是凸轮的天然选择:主站逐周期算目标位置写 0x607A,驱动器只做跟随;PP 让驱动器自己规划轨迹,给不了凸轮形状,不能用。
  • DC Sync0 125µs 把多轴对齐到同一节拍,凸轮才不会彼此漂移;LoopCycle 必须和 Sync0 一致。
  • 挂载时快照 Base,目标 = Base + Cam(...) × 行程,保证上电/挂载零跳变。
  • 实时控制挂 ProcessDataCyclicSync 回调,不要 Thread.Sleep 自旋——这是托管语言能做确定性实时控制的底线。
  • 实测确定性:内核线程模式下 DC 通讯抖动 p99.99 ≤ 19µs、最坏 62.4µs(不到半个 125µs 周期),节拍稳定,凸轮才不漂移。这就是对"C# 能不能做硬实时"最直接的回答。

最后,留一条线索给下一篇。前面我们发现:电子齿轮就是凸轮曲线取直线时的线性特例——把 Cam 换成线性斜坡,凸轮就退化成了同步轴的比例跟随。这意味着你已经掌握了电子齿轮的内核。下一篇我们专门讲多轴电子齿轮:变速比、相位补偿、动态换比这些电子齿轮特有的玩法,看看这根"最简单的直线"还能玩出多少花样。凸轮与齿轮本质同源,理解了一个,另一个就在手边。

更多推荐