# 在 Windows 上用 C# 实现 EtherCAT 主站:同步轴与电子齿轮
在 Windows 上用 C# 实现 EtherCAT 主站:同步轴与电子齿轮
你有一台龙门设备,横梁两端各一个电机,要它俩走得分毫不差。结果机器一动,两侧只要差几个脉冲,横梁就开始扭——轻则定位精度报废,重则机架直接卡死、电机过载报警。再比如收放卷:放卷轴和收卷轴得按线速比例联动,快一点慢一点,料带不是松垮就是绷断。分切机的主辊和刀辊、印刷机各个色组的辊筒,也都是同一个套路——多个轴必须按严格的比例一起转。
过去解决这类问题,靠的是机械:齿轮、链条、同步带,把几个轴硬连在一根传动轴上,比例由齿数决定,改一次比例就得换一套齿轮。机械同步可靠,但笨重、难改、有背隙,而且只要有一根轴单独动一下,整条传动链都得跟着拆。
现代做法是把这套"硬连接"搬到软件里:用一根虚拟主轴当基准,每个真实轴按自己的齿比(Ratio)跟随它运动。这就是电子齿轮 / 同步轴。改比例只要改一个数,加减轴只要改一行配置,而且没有背隙——这正是这篇要带你跑通的东西。
一句话概括:多个轴按各自的齿比,跟随同一个虚拟主轴运动。
- 齿比 1:1:所有轴「同一数据点同时映射」,运动完全一致(龙门双驱就靠这个)。
- 齿比 ≠ 1:即电子齿轮,各轴按固定比例联动。
- 运行模式恒为 CSP(周期同步位置,Mode=8)。
如果你看过这个系列里的电子凸轮那篇,会发现同步轴其实是凸轮的"简化版"——文章后面会专门讲到,电子齿轮就是电子凸轮取直线时的特例。
完整源码
本案例完整源码(WinForms 工程):
https://github.com/DarraTechnology/Ethercat_Master/tree/main/Windows/CSharp/STF-EC_SyncAxis
硬件配置
本例用的是一套很常见、很便宜的步进驱动方案,五个轴一字排开,方便肉眼观察比例联动:
- 主站: Windows 10 + Intel i5(一台普通工控机/笔记本足矣)
- 步进驱动器: 鸣志 STF-EC EtherCAT 步进驱动器 × 5 + 步进电机 × 5
| 项 | 值 |
|---|---|
| 厂商 | 鸣志 Shanghai AMP&MOONS’ Automation |
| VendorId | 0x00000168 |
| ProductCode | 0x02 |
| 协议 | CoE(CiA 402 Profile) |
| 同步方式 | DC Sync0 = 125µs |
| 轴数 | 由 config.deni 扫描决定(本例 5 轴,PhysAddr 0x1001~0x1005) |
这里有个省事的地方:它和电子凸轮案例用的是同款硬件、同一份 config.deni,所以如果你已经跑过凸轮那篇,这一份网络配置可以直接拿来复用,不用重新扫总线。
运动参数
- 控制模式: CSP(周期同步位置,Mode=8)——同步联动只用 CSP,后面会解释为什么不用别的模式。
- 每转脉冲数:
PULSES_PER_REV = 10000(STF-EC 默认细分,位置/角度换算用)。 - 虚拟主轴速度: 由用户设定(脉冲/秒),每周期推进步长 = 速度 ÷ 8000。
- 齿比范围: 任意实数(
1.0完全同步 /2.0主轴 2 倍 /0.5一半 /-1.0反向等速)。 - 位置精度: ±1 脉冲;启动瞬间快照实际位置作基准,零跳变。
为什么同步联动只用 CSP? CSP(Cyclic Synchronous Position,周期同步位置)的特点是:主站每个周期直接告诉驱动"这一拍你应该在哪个绝对位置",驱动内部只做位置环跟随。换句话说,轨迹规划这件事完全在主站手里——是我们这边算好每个轴每一拍的目标位置,而不是把"走到某个点"这种粗活丢给驱动自己去插补。多轴要严格锁相,就必须由主站统一节拍、统一算位置,所以非 CSP 不可。PP(轮廓位置)那种"下个目标点、驱动自己规划加减速"的模式,各轴各算各的,根本锁不住相位。
它能用在哪
凡是需要多轴严格比例联动的工业场合都适用,开头提到的几个就是最典型的:
- 龙门双驱同步: 龙门两侧驱动轴 1:1 严格同步,防止机架扭斜卡死。这是齿比
1.0的经典应用——两个轴用同一个虚拟主轴、同一个齿比,目标位置增量逐拍完全相等。 - 收放卷同步: 放卷轴与收卷轴按线速比例同步,维持张力恒定。卷径变化时改齿比就能跟上。
- 分切机: 主辊与多组分切刀辊按齿比联动,刀辊跟着主辊按固定比例转。
- 印刷套色: 多个色组辊筒按固定比例同步走纸,保证套印精度——套印差一点点,印出来就是重影。
把每个轴齿比设成不同值,机器一跑,肉眼就能看到比例联动的效果——这也是为什么 demo 里特意用了 5 个轴、给了 1.0 / 2.0 / 0.5 / -1.0 这么几个直观的齿比。
工作原理
虚拟主轴:一个软件计数器
整套方案最妙的一点是:根本不需要一根真实的"主轴"硬件。我们用一个软件里的 long 计数器当虚拟主轴,所有真实轴都跟着这个计数器走。
为什么这么设计?因为"主轴"在这里只是一个节拍的来源,它代表"运动进行到了哪一相位"。既然只是个相位量,何必非得有个物理电机?用软件计数器,反而想多快多慢、想正想反、想点动想清零都随你,比真硬件主轴灵活得多。
这个虚拟主轴有三种行为:
- 自动推进: 点「启动同步」后,每个 PDO 周期(约 125µs)主轴位置
+= 速度 ÷ 8000。这个 8000 哪来的?125µs 周期意味着一秒钟有 8000 拍,所以"每周期步长 = 每秒脉冲数 ÷ 8000"。例如速度 = 80000 脉冲/秒,则每周期 +10 脉冲。 - 手动点动: 按住「主轴-」/「主轴+」可直接推/退主轴位置,方便手动对位、慢慢观察比例运动到底对不对。调试期间这个特别好用——你按一下主轴 +20,立刻能看到齿比 2.0 的轴动了 40,齿比 0.5 的轴动了 10,一眼验证。
- 启动瞬间快照: 点「启动同步」时,PDO 线程会重新快照各轴当前实际位置作为基准
Base,同时把主轴清零。这一步是防跳变的关键——稍后细讲。
各轴按齿比跟随
每个真实轴的目标位置,都由同一个虚拟主轴位置 masterPos 算出来。区别只在各轴的齿比 Ratio 不同:
目标位置 = Base + round(masterPos × Ratio)
齿比的含义,看这张表最直观:
| 齿比 Ratio | 含义 |
|---|---|
1.000 |
各轴目标增量完全相同 → 「同一数据点同时映射」,多轴齐步走(龙门双驱) |
2.000 |
该轴走主轴的 2 倍(主轴转 1 圈,从轴转 2 圈) |
0.500 |
该轴走主轴的一半 |
-1.000 |
该轴反方向等速跟随(对向运动) |
注意公式里那个 round——masterPos × Ratio 算出来是个浮点数,而下发给驱动的目标位置必须是整数脉冲。这里用四舍五入而不是直接截断,是为了让累计误差不偏向某一侧,长时间跑下来位置不漂。配合公式本身是绝对位置(每一拍都从 Base 重新算,而不是在上一拍基础上累加),即便单拍有四舍五入的半个脉冲误差,也不会累积成越跑越偏——这一点后面讲核心算法时还会强调。
电子齿轮 = 电子凸轮的线性特例。 电子凸轮用一张主从位置曲线让从轴跟随主轴;当这条曲线退化成一条直线(固定斜率 = 齿比)时,就是电子齿轮。所以下面你会看到:凸轮里那个"查曲线"的步骤,在这里直接退化成了一次乘法。换句话说,你现在写的同步轴代码,本质上就是凸轮代码删掉查表那一行、换成
× Ratio。理解了这层关系,两篇就串成一个完整的体系了。
系统架构
整个数据流其实很清晰:界面写控制变量 → 125µs 周期回调推进虚拟主轴 → 各轴按齿比算目标位置 → 经 EtherCAT 总线下发。
这里要特别留意一点:虚拟主轴的推进、各轴目标位置的计算、下发,全部发生在同一个 125µs 周期回调里。这不是为了图省事,而是为了保证原子性——同一拍算出来的各轴目标,在同一帧 PDO 里一起提交,各轴的相位关系才不会因为"先算的轴和后算的轴隔了一拍"而出现错位。
代码示例
下面按"定义数据布局 → 建立连接和同步 → 写核心算法 → 周期下发"的顺序来。每段代码前面我都会多讲几句"为什么这么写、不这么写会怎样"。
PDO 结构体定义
PDO(Process Data Object)就是主从之间每个周期交换的那块固定字节。要在 C# 里读写它,得先用一个 struct 精确描述它的字节布局。
这里有个最容易栽跟头的地方:结构体的字段顺序、类型、偏移,必须和 config.deni 里 PDO 条目的实际布局逐字节一致,一个字节都不能错。为什么?因为 SDK 是直接把那块内存"覆盖"到你的结构体上(零拷贝映射),它并不知道你想读的是哪个字段——你结构体写歪一个字节,后面所有字段全部错位,读出来的状态字、实际位置全是垃圾值,而且不报错,排查起来极其折磨。
所以下面这两个结构体,字节数(输出 29 / 输入 35)和字段顺序都是从 config.deni 实测来的。同步轴实际只用到输出的三项(ControlWord / ModesOfOperation / TargetPosition)和输入的三项(ErrorCode / StatusWord / PositionActualValue),其余字段虽然用不上,也必须原样占位,因为它们决定了后面字段的偏移:
using System.Runtime.InteropServices;
// STF-EC 步进驱动器输出 PDO (RxPDO) = 29 字节
// 同步轴恒用 CSP, ModesOfOperation 永远 = 8。
[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 错误码
public ushort StatusWord; // 0x6041 状态字 (CiA402 状态机)
public int PositionActualValue; // 0x6064 实际位置 (脉冲) ← Base / 跟随误差用
// …其余字段(操作模式显示/实际速度/数字输入/探针等)省略, 仅为对齐 PDO 字节数, 详见完整源码
}
注意 [StructLayout(LayoutKind.Sequential, Pack = 1)] 这一行不能漏——Pack = 1 表示按 1 字节对齐,关掉 C# 默认的内存对齐填充,这样结构体的内存布局才和总线上的紧凑字节流一一对应。还有那个小坑:这个驱动的输入 PDO 是错误码在前、状态字在后,不同驱动顺序可能反过来,照抄别家的结构体就会错位。
再提醒一遍:省略的字段在真实代码里要按完整布局占位,字段偏移必须和 PDO 条目逐字节对齐,绝不能删。完整布局见源码。
连接与 DC 同步
结构体定义好之后,就要建立连接、把总线带到 OP 状态。CSP 同步联动要求每个 PDO 周期都精确下发新目标位置,所以连接阶段必须先做好三件事,缺一不可:
- 把 PDO 完整重映 —— 驱动出厂默认的 PDO 往往是个精简版(本例默认 RxPDO 只有 11 字节),装不下我们要用的字段,必须经 CoE 把它重新映射成完整的 4+4 布局,跟上面的结构体对齐。
- 启用 DC —— 没有分布式时钟,各轴就没有共同的时间基准,"同时锁存"就无从谈起。
- 把目标位置初始化为实际位置 —— 这是防上电跳变的关键,下面单独讲。
这里有个新手最容易忽略的致命坑:第 7 步,进 OP 之前一定要把每个轴的"目标位置"先设成它"当前的实际位置"。如果不这么做,目标位置默认是 0,而驱动当前实际位置可能在几万脉冲处——一进 OP,CSP 模式立刻命令电机从几万脉冲狂奔回 0,这就是所谓的"上电飞车",轻则撞限位,重则撞坏机械。所以"目标 = 实际"这一笔,本质是让运动从"原地不动"开始。
以下为关键步骤(省略了 try/catch、CancellationToken 取消检查等样板):
// 1) 建主站: 载入 config.deni, 开启自动启动流程
var buildResult = new DarraEtherCAT()
.SetENI(deniPath)
.EnableAutoStartup()
.Build();
DarraEtherCAT master = buildResult.Master;
int slaveCount = master.SlaveCount; // 轴数 = 实际扫描到的从站数
// 2) 进 PreOp: 此时 SM2 未激活, 可经 CoE 改 PDO 映射
master.SetState(EcState.PreOp);
for (int i = 0; i < slaveCount; i++)
{
var slave = master.Slaves[i];
// 3) 经 CoE 把 PDO 分配重映成完整 4+4 (输出29/输入35)。
// 驱动默认 RxPDO 只有 11 字节, 不重映就和结构体对不上, 任何模式都会映射失败。
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
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
// 4) 启用分布式时钟 Sync0 = 125µs (CSP 周期同步的时间基准, 与 LoopCycle 一致)
if (slave.HasDC) slave.ConfigureDC(125000);
}
// 设定 PDO 交换周期 = 125µs; SYNC0 与 LoopCycle 必须保持一致
master.Config.LoopCycle = 125000;
// 5) 进 SafeOp
master.SetState(EcState.SafeOp);
// 6) 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)
throw new Exception($"轴{i + 1} PDO 尺寸不符, 映射与 config.deni 不一致");
// 7) 进 OP 前逐轴初始化: CSP 模式 + 目标位置 = 当前实际位置 (避免上电跳变)
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; // 目标 = 实际, 零跳变
}
// 8) 进 OP, 开始周期通讯
master.SetState(EcState.OP);
几个值得停下来看的细节:
ConfigureDC(125000)和LoopCycle = 125000必须一致。Sync0(从站锁存目标的硬件时刻)和 LoopCycle(主站组帧发帧的节奏)是一对孪生节拍,对不齐就会出现"主站这一帧的数据,从站还没来得及锁存就被下一帧覆盖",或者反过来"从站锁了个旧值",同步质量直接崩。125µs 也是 Windows 上推荐的最小稳定周期。- 第 6 步的 PDO 尺寸自检不是可有可无的洁癖。它用驱动实测的字节数去核对你的结构体大小,一旦
config.deni和结构体对不上(比如你改了配置忘了改结构体),这里立刻抛异常,而不是等进了 OP 才发现位置全是乱码。这一笔自检能帮你省下几小时的排查。 - 进状态的顺序 PreOp → SafeOp → OP 是 EtherCAT 状态机的硬规定:只有在 PreOp 才能改 PDO 映射(SM2 还没激活),所以重映必须在这一步做。
电子齿轮齿比计算
铺垫了这么多,现在到了整篇文章最核心、也最让人意外的地方:同步轴的全部数学,只有一行。
每个从轴的目标位置由三部分组合而来:
Base—— 启动同步瞬间快照的该轴实际位置,让运动从「当前点」平滑开始,不跳变。masterPos—— 虚拟主轴位置,所有轴共用同一个值,各轴因此严格锁定同一相位。Ratio—— 该轴齿比。1.0时各轴目标增量一致(同一数据点同时映射);≠1.0即电子齿轮,按比例拉开。
// 核心公式 (单轴, 单周期):
// 目标位置 = Base + round(masterPos × Ratio)
a.CurrentTarget = a.Base + (int)Math.Round(masterPos * a.Ratio);
这行代码值得多盯一会儿。它有一个非常重要的性质:目标位置是个绝对公式,不是累加。
很多人初学时会下意识写成"这一拍的目标 = 上一拍的目标 + 增量",这就埋了一颗雷——每一拍的四舍五入误差、偶尔丢一拍,都会永久累积进位置里,跑久了各轴就越偏越多,锁相彻底失效。而这里我们每一拍都从固定的 Base 加上 masterPos × Ratio 重新算出绝对位置,masterPos 是各轴共用的同一个值,所以无论跑多久、中间丢没丢拍,各轴的相位关系永远由 Ratio 唯一确定,不会累积漂移。这就是为什么 1:1 齿比的龙门双驱能始终严丝合缝。
对照电子凸轮:那边目标 =
Base + Cam(曲线, 相位) × 行程,要查一张曲线;这边把曲线退化成斜率 =Ratio的直线,查曲线就变成了一次乘法。这就是"电子齿轮是电子凸轮线性特例"的最直观体现——同一套框架,凸轮查表、齿轮乘法,仅此而已。
CSP 周期跟随控制
最后一块拼图,是把"算位置"这件事挂到正确的执行节拍上。
前面在背景里就埋了伏笔:实时控制必须挂在 SDK 的 PDO 周期回调(ProcessDataCyclicSync)上,由总线周期(DC Sync0 125µs)硬同步驱动。这里再把原因说透:
实时控制要挂在
ProcessDataCyclicSync周期回调上(由 DC 总线周期驱动),别用Thread.Sleep在用户态自旋——那样节拍不稳,算不上实时控制。
SDK 的周期回调每个总线周期(125µs)跑一次,每次做两步:先推进虚拟主轴一次,再逐轴算出目标位置并下发。每个轴还要走 CiA 402 使能握手(0x06 → 0x07 → 0x0F);握手未完成或出故障时,目标位置恒等于实际位置,保证无跳变。
const int PULSES_PER_REV = 10000; // STF-EC 默认细分
const int JOG_MASTER_STEP = 20; // 主轴手动点动每周期步长 (脉冲)
long _masterPos = 0; // 虚拟主轴位置 (PDO 线程读写)
volatile int _masterStep = 10; // 每周期推进步长 = 速度脉冲/秒 ÷ 8000
volatile bool _syncRunning = false; // 是否自动推进
volatile bool _resyncRequested = false; // 启动同步 → 下周期重快照 Base + 主轴清零
volatile int _jogMasterDir = 0; // 主轴手动点动方向: +1 / -1 / 0
// 实时控制挂到 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, 主轴清零 (在 PDO 回调内完成, 保证原子)
if (_resyncRequested)
{
_resyncRequested = false;
_masterPos = 0;
for (int i = 0; i < arr.Length; i++)
{
ref var input = ref m.Slaves[arr[i].SlaveIndex].PDO.InputsMapping<STF_Input>();
arr[i].Base = input.PositionActualValue; // 以当前实际位置为基准
arr[i].CurrentTarget = input.PositionActualValue;
}
}
// ② 推进虚拟主轴 (自动同步 + 手动点动)
if (_syncRunning) _masterPos += _masterStep;
if (_jogMasterDir != 0) _masterPos += _jogMasterDir * JOG_MASTER_STEP;
long masterPos = _masterPos;
// ③ 各轴按齿比跟随同一虚拟主轴 —— 同一回调内连续写各轴目标位置, 同一 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>();
StepSync(a, masterPos, ref input, ref output);
}
// …另有跟随误差/掉OP/驱动故障等报警与组停逻辑, 详见源码
}
// 单轴 CSP 步进: CiA402 使能握手 (0x06→0x07→0x0F), 使能后按齿比跟随虚拟主轴。
// 未使能 / 握手中 / 故障时, 目标位置恒等于实际位置 (无跳变)。
void StepSync(AxisController a, long masterPos, ref STF_Input input, ref STF_Output output)
{
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) { a.CurrentTarget = input.PositionActualValue; a.Base = input.PositionActualValue; }
else if (IsSwitchOnDisabled(sw)) { cw = 0x06; a.CurrentTarget = input.PositionActualValue; a.Base = input.PositionActualValue; } // Shutdown
else if (IsReadyToSwitchOn(sw)) { cw = 0x07; output.TargetPosition = a.CurrentTarget = input.PositionActualValue; } // Switch On
else if (IsSwitchedOn(sw)) { cw = 0x0F; output.TargetPosition = a.CurrentTarget = input.PositionActualValue; } // Enable Operation
else if (IsOperationEnabled(sw))
{
// ★ 同步轴核心: 目标 = 基准 + 主轴位置 × 齿比
// 齿比 1:1 → 各轴目标增量一致; 齿比 ≠ 1 → 电子齿轮按比例拉开。
a.CurrentTarget = a.Base + (int)Math.Round(masterPos * a.Ratio);
output.TargetPosition = a.CurrentTarget;
cw = 0x0F;
}
output.ControlWord = cw;
}
这段代码里有几处设计值得说道:
_resyncRequested为什么要放进回调里处理? 因为"快照各轴 Base + 主轴清零"这两件事必须原子地在同一拍完成。如果在界面线程里直接改Base,可能改到一半被回调插队,各轴的基准就对不齐了。所以界面只置一个标志位,真正的快照在回调内完成,保证整组轴是同一时刻、同一份实际位置作基准。这也是"启动平滑、零跳变"的实现细节——以当前实际位置为Base,运动自然从原地开始。- 控制字和状态字之间的那串
if-else if,就是 CiA 402 状态机握手。 驱动上电后不是马上就能动的,得一步步爬:0x06(Shutdown)→0x07(Switch On)→0x0F(Enable Operation),每一步都在等驱动的状态字反馈到位才能往下走。在这个爬升过程中,目标位置始终钉在实际位置上,所以即便握手要花好几拍,电机也是稳稳不动的。只有真正进了OperationEnabled,那行核心公式才会生效、轴才真正跟随主轴。 volatile关键字用在那几个控制变量上,是因为它们被界面线程写、被回调线程读,volatile保证读到的是最新值而不是寄存器里的缓存。回调内部连续写各轴目标,因为都在同一拍、同一线程上下文,最终同一帧 PDO 一并提交,各轴相位天然对齐。
位置显示按
PULSES_PER_REV = 10000换算成角度:角度(°) = 脉冲 × 360 / 10000。报警 / 组停 / 诊断、UI 状态刷新(≈50ms)等逻辑不属于核心控制,详见源码。
操作步骤
代码讲完,实际跑起来就八步:
- 准备
config.deni: 用 Darra EtherCAT 主站 GUI 扫描总线上的 STF-EC 从站,导出网络配置为config.deni,放到可执行文件目录(可复用电子凸轮案例的同一份)。 - 连接: 点「连接」,等待状态变绿「已连接(CSP 同步轴)」,总览表列出全部轴。
- 全部使能: 点「全部使能」,各轴驱动进入
OperationEnabled(也就是上面那串握手走完)。 - 设齿比(可选): 在总览表「齿比」列双击直接输入,例如轴 2 填
2.000、轴 3 填0.500。默认全为1.000。 - 启动同步: 设好「速度(脉冲/秒)」后点「启动同步」。主轴位置开始推进,各轴按齿比一起动起来。
- 手动对位(可选): 用「主轴-」/「主轴+」按住点动主轴,观察各轴比例跟随——这是验证齿比对不对最直观的方式。
- 停止 / 急停: 「停止」停止主轴推进(各轴保持当前位置);「全部去使能(急停)」立刻松轴。
- 断开: 点「断开」安全停止并释放主站。
实测:DC 通讯抖动到底有多小
C# 带 GC、有线程调度,到底能不能做确定性实时运动?光嘴上说不算数,得拿数据砸。下面是这套方案在内核线程模式下实测的 DC 周期通讯抖动——也就是每一拍的实际时刻相对理想 125µs 节拍偏了多少:
| 模式 | p99 | p99.99 | max |
|---|---|---|---|
| 内核线程 | ~0 | 19µs | 62.4µs |
这三个数怎么读:
- p99 ≈ 0:99% 的周期几乎零抖动,节拍稳得像硬件时钟。这意味着绝大多数时候,每一拍都精确落在 125µs 上,基本看不出偏差。
- max = 62.4µs:跑下来最坏的那一拍,偏差 62.4µs。拿它和 125µs 周期一比——还不到半个周期。也就是说,哪怕是史上最糟的一拍,数据也稳稳赶在下一个 Sync0 锁存之前到位,没有任何一帧"错过班车"。
这就是多轴严格锁相的底气所在:每一拍都准时到位,各轴按齿比算出的目标位置在同一帧一起下发、被 DC 在同一时刻一起应用,1:1 同步下位置增量才能完全一致,不会越跑越偏。
这张表也正面回答了那个常见疑问——托管语言 + GC 能做硬实时吗? 能,但前提是别把节拍交给软件。因为 DC 把"何时执行"钉死在硬件 Sync0 上、关键链路走内核 RT 路径、控制代码挂周期回调而不是自旋,GC 那点偶发停顿根本影响不到相位锁定。反过来,如果你真用了用户态 Thread.Sleep 自旋,抖动立刻变成毫秒级、不可控(GC、线程调度随时插队),上面这张表会变得惨不忍睹——这正是它"不算实时控制"的根本原因。
小结 / 要点回顾
一篇看下来,同步轴 / 电子齿轮其实没那么玄,核心就这么几条:
- 虚拟主轴 = 软件计数器:
masterPos每周期+= 速度 ÷ 8000,所有轴共用同一个值,因此严格锁定同一相位。不需要真主轴硬件。 - 同步轴的全部数学只有一行:
目标 = Base + round(masterPos × Ratio),改齿比就改了联动比例。而且它是绝对公式不是累加,所以不累积漂移。 - CSP(Mode=8)+ DC Sync0 125µs:每周期由主站精确算出并下发目标位置、硬件统一锁存,1:1 齿比下各轴位置增量完全一致。
- 启动时快照
Base+ 主轴清零:运动从当前位置平滑开始,零跳变;进 OP 前"目标 = 实际"防上电飞车。 - 实时控制挂
ProcessDataCyclicSync回调,不要Thread.Sleep自旋——这是确定性实时控制的底线,也是 C# 能做硬实时运动的前提。 - 实测确定性:内核线程模式下 DC 通讯抖动 p99.99 ≤ 19µs、最坏 62.4µs(不到半个周期),节拍稳,多轴锁相才有保证。
最后回到那个贯穿全系列的关系:电子齿轮就是电子凸轮的线性特例。 把凸轮的主从曲线退化成斜率 = Ratio 的直线,“查曲线"就退化成"一次乘法”——你这篇写的代码,几乎就是凸轮代码去掉查表的那一版。
所以下一步很自然:如果你的应用需要的不是固定比例,而是非线性的相位跟随(比如飞剪要在切割段同步、空程段加速回程,或者凸轮机构那种走走停停的运动曲线),那就把这行乘法换成查一张凸轮表,升级到电子凸轮——那篇博客会带你把同一套框架扩展成任意主从曲线。同一套 DC + CSP + 周期回调的底座,从齿轮到凸轮,一通百通。
更多推荐
所有评论(0)