【原创】IgH EtherCAT主站详解(二十三)--DC 同步实战

DC 同步实战

8.3 — 分布式时钟配置与测量


概览

DC 同步要达到什么效果?

EtherCAT 分布式时钟 (DC) 的核心目标是让总线上所有从站的内部时钟保持纳秒级同步。在配置正确的系统中,从站间同步抖动可控制在亚微秒级(通常小于 100 ns),从而实现精确的同步采样与输出更新。

本教程基于 IgH EtherCAT Master 1.5.2 提供的 dc_user 示例程序,演示如何在实际应用中配置和使用 DC 同步功能。

DC 同步的 4 个关键 API 调用

1. ecrt_slave_config_dc() — 配置从站 DC 参数(周期、偏移、激活字)
2. ecrt_master_application_time() — 每周期写入主站应用时间
3. ecrt_master_sync_reference_clock_to() — 同步参考时钟到指定时间
4. ecrt_master_sync_slave_clocks() — 同步所有从站时钟(漂移补偿)

示例程序运行效果

dc_user 示例以 1000 Hz(1 ms 周期)运行,启用 MEASURE_TIMING 宏后,每秒输出一次时序统计:

period     ...    1000147
exec       ...     987234
latency    ...      12453

时序测量指标

period (周期): 两次循环启动的实际间隔,理想值为 1000000 ns (1 ms)
exec (执行时间): 单次循环的有效执行耗时
latency (延迟): 唤醒时刻到实际启动的时刻差,反映调度抖动

两种 DC 参考时钟模式

EtherCAT DC 同步有两种参考时钟模式,核心区别在于谁的时间基准作为全网同步的参考。工业实践中从站参考时钟模式更为常用,因为从站 ESC 硬件时钟抖动仅约 12ns,远优于操作系统时钟稳定性。

DC 参考时钟模式对比

如何选择?

模式 A (主站参考): 适合简单 IO 同步场景,主站 CLOCK_MONOTONIC 为基准,使用 ecrt_master_sync_reference_clock_to() 单向写入参考从站。
模式 B (从站参考): 适合伺服控制、多轴同步等精确场景,参考从站 DC 时钟为基准,主站通过闭环算法调整自身周期追随从站时钟。使用 ecrt_master_reference_clock_time() 读取参考时钟,通过调整 system_time_base 实现同步。

本章下面分为两个大节:模式 A(L2/L3)和模式 B(L2/L3)。


技术详情

模式 A: 以主站为参考时钟 (dc_user 示例)

DC 配置完整流程

以下流程图展示了从创建主站到进入 DC 同步循环的完整步骤:

flowchart TD A["步骤 1<br/>ecrt_request_master(0)<br/>请求主站"] --> B["步骤 2<br/>ecrt_master_create_domain()<br/>创建过程数据域"] B --> C["步骤 3<br/>ecrt_master_slave_config()<br/>获取从站配置句柄"] C --> D["步骤 4<br/>ecrt_slave_config_dc()<br/>配置 DC 参数"] D --> E["步骤 5<br/>ecrt_slave_config_reg_pdo_entry()<br/>注册 PDO 入口"] E --> F["步骤 6<br/>ecrt_master_activate()<br/>激活主站"] F --> G["步骤 7<br/>ecrt_domain_data()<br/>获取过程数据指针"] G --> H["步骤 8<br/>进入 cyclic_task()<br/>DC 同步循环"] H --> I["每周期循环"] I --> I1["clock_nanosleep()<br/>绝对时间休眠"] I1 --> I2["ecrt_master_application_time()<br/>写入应用时间"] I2 --> I3["ecrt_master_receive()"] I3 --> I4["ecrt_domain_process()"] I4 --> I5["处理过程数据"] I5 --> I6["ecrt_master_sync_reference_clock_to()<br/>同步参考时钟"] I6 --> I7["ecrt_master_sync_slave_clocks()<br/>同步从站时钟"] I7 --> I8["ecrt_domain_queue()"] I8 --> I9["ecrt_master_send()"] I9 --> I1 style A fill:#c6f6d5,stroke:#68d391 style D fill:#fefcbf,stroke:#ecc94b style H fill:#ebf8ff,stroke:#90cdf4 style I2 fill:#fefcbf,stroke:#ecc94b style I6 fill:#fefcbf,stroke:#ecc94b style I7 fill:#fefcbf,stroke:#ecc94b

ecrt_slave_config_dc() 参数详解

这是 DC 配置的核心函数,在激活主站之前调用:

// dc_user/main.c — DC 配置调用
ecrt_slave_config_dc(sc, 0x0700, PERIOD_NS, 4400000, 0, 0);
参数 示例值 说明
assign_activate 0x0700 DC 激活字,写入从站寄存器 0x0981。0x0700 = SYNC0 + SYNC1 均激活 (bit8=1, bit9=1);0x0300 = 仅 SYNC0 激活 (bit8=1, bit9=0)。具体值取决于从站 ESI 文件中的 DcSync 配置
sync0_cycle PERIOD_NS (1000000 ns) SYNC0 脉冲周期时间(纳秒)。本例为 1 ms,对应 1000 Hz 循环频率
sync0_shift 4400000 (4.4 ms) SYNC0 相对于周期起始的偏移时间(纳秒)。用于补偿传播延迟,确保从站在数据到达后才触发采样
sync1_cycle 0 SYNC1 脉冲周期时间(纳秒)。设为 0 表示不使用 SYNC1
sync1_shift 0 SYNC1 相对于 SYNC0 的偏移时间(纳秒)。设为 0 表示不使用 SYNC1

AssignActivate 激活字说明

assign_activate 的值决定了 DC 同步模式。常见值:
0x0300: 仅 SYNC0 激活 — 适用于大多数简单同步场景
0x0700: SYNC0 + SYNC1 同时激活 — 适用于需要双同步信号的高级场景(如需要区分采样和输出时刻)

该值通常来自从站 ESI (EtherCAT Slave Information) XML 文件中的 <Dc> 配置节点。

完整代码走读: dc_user/main.c

1. 宏定义与常量
// dc_user/main.c — 核心宏定义
#define FREQUENCY 1000              // 循环频率 1000 Hz
#define CLOCK_TO_USE CLOCK_MONOTONIC // 使用单调时钟,不受系统时间跳变影响
#define MEASURE_TIMING               // 启用时序测量

#define NSEC_PER_SEC (1000000000L)   // 1 秒 = 10^9 纳秒
#define PERIOD_NS (NSEC_PER_SEC / FREQUENCY) // 周期 = 1000000 ns = 1 ms

// 时间差计算宏
#define DIFF_NS(A, B) (((B).tv_sec - (A).tv_sec) * NSEC_PER_SEC + \
        (B).tv_nsec - (A).tv_nsec)

// timespec 转 64 位纳秒
#define TIMESPEC2NS(T) ((uint64_t) (T).tv_sec * NSEC_PER_SEC + (T).tv_nsec)
2. 主站初始化与 DC 配置
// dc_user/main.c — main() 函数关键步骤
int main(int argc, char **argv)
{
    ec_slave_config_t *sc;

    // 锁定内存,防止页面换出导致延迟抖动
    if (mlockall(MCL_CURRENT | MCL_FUTURE) == -1) {
        perror("mlockall failed");
        return -1;
    }

    // 步骤 1: 请求主站
    master = ecrt_request_master(0);

    // 步骤 2: 创建过程数据域
    domain1 = ecrt_master_create_domain(master);

    // 步骤 3: 配置从站(总线耦合器 EK1100)
    sc = ecrt_master_slave_config(master, BusCouplerPos, Beckhoff_EK1100);

    // 步骤 3: 配置数字量输出从站 EL2008
    sc = ecrt_master_slave_config(master, DigOutSlavePos, Beckhoff_EL2008);
    // 注册 PDO 入口
    off_dig_out = ecrt_slave_config_reg_pdo_entry(sc, 0x7000, 1, domain1, NULL);

    // 步骤 3: 配置计数器从站
    sc = ecrt_master_slave_config(master, CounterSlavePos, IDS_Counter);
    off_counter_in = ecrt_slave_config_reg_pdo_entry(sc, 0x6020, 0x11, domain1, NULL);
    off_counter_out = ecrt_slave_config_reg_pdo_entry(sc, 0x7020, 1, domain1, NULL);

    // 步骤 4: 配置 DC 同步 —— 关键!
    // 激活 SYNC0+SYNC1, 周期 1ms, 偏移 4.4ms
    ecrt_slave_config_dc(sc, 0x0700, PERIOD_NS, 4400000, 0, 0);

    // 步骤 5: 激活主站
    ecrt_master_activate(master);

    // 步骤 6: 获取过程数据内存映射
    domain1_pd = ecrt_domain_data(domain1);

    // 设置实时调度优先级 (SCHED_FIFO)
    struct sched_param param = {};
    param.sched_priority = sched_get_priority_max(SCHED_FIFO);
    sched_setscheduler(0, SCHED_FIFO, &param);

    // 步骤 7: 进入 DC 同步循环
    cyclic_task();
    return 0;
}
3. 时间辅助函数
// dc_user/main.c — timespec_add() 纳秒级时间加法
// 精确的 timespec 加法,正确处理纳秒进位
struct timespec timespec_add(struct timespec time1, struct timespec time2)
{
    struct timespec result;

    if ((time1.tv_nsec + time2.tv_nsec) >= NSEC_PER_SEC) {
        result.tv_sec = time1.tv_sec + time2.tv_sec + 1;
        result.tv_nsec = time1.tv_nsec + time2.tv_nsec - NSEC_PER_SEC;
    } else {
        result.tv_sec = time1.tv_sec + time2.tv_sec;
        result.tv_nsec = time1.tv_nsec + time2.tv_nsec;
    }

    return result;
}
4. DC 同步循环 (cyclic_task)
// dc_user/main.c — cyclic_task() DC 同步循环核心
void cyclic_task()
{
    struct timespec wakeupTime, time;
#ifdef MEASURE_TIMING
    struct timespec startTime, endTime, lastStartTime = {};
    uint32_t period_ns = 0, exec_ns = 0, latency_ns = 0,
             latency_min_ns = 0, latency_max_ns = 0,
             period_min_ns = 0, period_max_ns = 0,
             exec_min_ns = 0, exec_max_ns = 0;
#endif

    // 获取当前时间作为首次唤醒基准
    clock_gettime(CLOCK_TO_USE, &wakeupTime);

    while(1) {
        // 计算下一个绝对唤醒时间 = 当前唤醒时间 + 周期
        wakeupTime = timespec_add(wakeupTime, cycletime);

        // 使用绝对时间休眠 —— 确定性的周期唤醒
        // TIMER_ABSTIME: 休眠到绝对时刻,避免累积误差
        clock_nanosleep(CLOCK_TO_USE, TIMER_ABSTIME, &wakeupTime, NULL);

        // ====== DC 关键调用 1: 写入应用时间 ======
        // 使用目标时间(非测量时间),因为更稳定
        ecrt_master_application_time(master, TIMESPEC2NS(wakeupTime));

#ifdef MEASURE_TIMING
        clock_gettime(CLOCK_TO_USE, &startTime);
        latency_ns = DIFF_NS(wakeupTime, startTime);    // 唤醒延迟
        period_ns = DIFF_NS(lastStartTime, startTime);   // 实际周期
        exec_ns = DIFF_NS(lastStartTime, endTime);       // 执行时间
        lastStartTime = startTime;

        // 更新 min/max 统计
        if (latency_ns > latency_max_ns) latency_max_ns = latency_ns;
        if (latency_ns < latency_min_ns) latency_min_ns = latency_ns;
        if (period_ns > period_max_ns)   period_max_ns = period_ns;
        if (period_ns < period_min_ns)   period_min_ns = period_ns;
        if (exec_ns > exec_max_ns)       exec_max_ns = exec_ns;
        if (exec_ns < exec_min_ns)       exec_min_ns = exec_ns;
#endif

        // 接收过程数据
        ecrt_master_receive(master);
        ecrt_domain_process(domain1);

        // 可选: 检查域状态
        check_domain1_state();

        // 1 Hz 统计输出
        if (counter) {
            counter--;
        } else {
            counter = FREQUENCY;
            check_master_state();
#ifdef MEASURE_TIMING
            printf("period     %10u ... %10u\n", period_min_ns, period_max_ns);
            printf("exec       %10u ... %10u\n", exec_min_ns, exec_max_ns);
            printf("latency    %10u ... %10u\n", latency_min_ns, latency_max_ns);
            // 重置统计
            period_max_ns = 0;    period_min_ns = 0xffffffff;
            exec_max_ns = 0;      exec_min_ns = 0xffffffff;
            latency_max_ns = 0;   latency_min_ns = 0xffffffff;
#endif
            blink = !blink;
        }

        // 写入过程数据
        EC_WRITE_U8(domain1_pd + off_dig_out, blink ? 0x66 : 0x99);
        EC_WRITE_U8(domain1_pd + off_counter_out, blink ? 0x00 : 0x02);

        // ====== DC 关键调用 2: 同步参考时钟 ======
        if (sync_ref_counter) {
            sync_ref_counter--;
        } else {
            sync_ref_counter = 1; // 每周期同步一次
            clock_gettime(CLOCK_TO_USE, &time);
            ecrt_master_sync_reference_clock_to(master, TIMESPEC2NS(time));
        }

        // ====== DC 关键调用 3: 同步所有从站时钟 ======
        ecrt_master_sync_slave_clocks(master);

        // 发送过程数据
        ecrt_domain_queue(domain1);
        ecrt_master_send(master);

#ifdef MEASURE_TIMING
        clock_gettime(CLOCK_TO_USE, &endTime);
#endif
    }
}

时序测量输出分析

启用 MEASURE_TIMING 后,程序每秒输出三项时序统计的最小/最大值:

指标 理想值 可接受范围 异常阈值 可能原因
period (周期) 1000000 ns 999000 ~ 1001000 ns 偏差 > 10 us 调度优先级不足、非 RT 内核、其他高优先级中断
exec (执行时间) 取决于负载 < 周期的 80% 接近或超过周期 PDO 过多、printf 输出阻塞、内存未锁定
latency (延迟) 0 ns < 50 us > 100 us 内核未开启 PREEMPT_RT、未使用 SCHED_FIFO、GC 或电源管理干扰

模式 B: 以从站为参考时钟 (rtai_rtdm_dc 示例)

以下内容基于 examples/rtai_rtdm_dc/main.c,演示如何将从站 DC 时钟作为全网时间基准,并在主站应用中通过同步算法调整运行周期。

⚠️ 模式切换

代码中通过宏 SYNC_MASTER_TO_REF 切换模式:
SYNC_MASTER_TO_REF = 0: 模式 A (主站 → 参考从站) — 使用 ecrt_master_sync_reference_clock_to()
SYNC_MASTER_TO_REF = 1: 模式 B (参考从站 → 主站) — 使用 ecrt_master_reference_clock_time() + 同步算法
rtai_rtdm_dc 示例默认 SYNC_MASTER_TO_REF = 1

核心架构

在从站参考时钟模式下,主站不再是时间基准的提供者,而是主动追随参考从站的时间。其工作原理可以用一个闭环控制系统来描述:

flowchart LR subgraph "被控对象" A["主站周期任务<br/>system_time_ns()"] end subgraph "反馈通道" B["ecrt_master_<br/>reference_clock_time()"] --> C["dc_diff_ns =<br/>app_time - ref_time"] end subgraph "控制器 (PI)" D["update_master_<br/>clock()"] D -->|"system_time_base<br/>+= adjustment"| A end C --> D A -->|"prev_app_time"| C subgraph "参考从站" E["ESC 64位时钟<br/>0x0910"] end E -->|"FPRD 0x0910"| B style A fill:#c6f6d5,stroke:#38a169 style D fill:#fefcbf,stroke:#ecc94b style E fill:#ebf8ff,stroke:#90cdf4

关键 API 差异对比

维度 模式 A (主站参考) 模式 B (从站参考)
参考时钟写入 ecrt_master_sync_reference_clock_to(master, time) 不调用 (参考从站时钟自主运行)
参考时钟读取 不需要 ecrt_master_reference_clock_time(master, &ref_time)
从站时钟漂移补偿 ecrt_master_sync_slave_clocks(master) ecrt_master_sync_slave_clocks(master) (相同)
主站周期调整 固定周期 clock_nanosleep() 动态调整 system_time_base,通过 rt_sleep_until() 实现可变唤醒
参考时钟选择 自动 (第一个有 DC 能力的从站) ecrt_master_select_reference_clock(master, sc) 建议显式指定
时间基准 CLOCK_MONOTONIC rt_get_time_ns() - system_time_base (可调整)

rtai_rtdm_dc 代码走读

1. DC 同步变量定义
// rtai_rtdm_dc/main.c — DC 变量
#define DC_FILTER_CNT          1024   // 漂移率采样窗口
#define SYNC_MASTER_TO_REF        1   // 1=从站参考, 0=主站参考

static uint64_t dc_start_time_ns = 0LL; // 同步启动时刻
static uint64_t dc_time_ns = 0;          // 当前应用时间
static uint8_t  dc_started = 0;          // 是否已检测到首次偏差
static int32_t  dc_diff_ns = 0;          // 当前归一化偏差: app_time - ref_time
static int32_t  prev_dc_diff_ns = 0;     // 上一周期偏差 (用于计算 delta)
static int64_t  dc_diff_total_ns = 0LL;  // 偏差累积量
static int64_t  dc_delta_total_ns = 0LL; // 偏差变化率累积量
static int      dc_filter_idx = 0;       // 采样计数器
static int64_t  dc_adjust_ns;            // 累积调整量 (单位: ns)
static int64_t  system_time_base = 0LL;  // 时间基准偏移 (核心变量!)

system_time_base 的作用

system_time_base 是主站时间基准的偏移量。主站每次获取时间时:
app_time = rt_get_time_ns() - system_time_base

同步算法通过调整 system_time_base 来改变主站感知的时间,从而改变 rt_sleep_until() 的唤醒时刻,使主站周期逐渐与参考从站对齐。

2. sync_distributed_clocks() — 每周期同步函数
// rtai_rtdm_dc/main.c — sync_distributed_clocks()
void sync_distributed_clocks(void)
{
    uint32_t ref_time = 0;
    uint64_t prev_app_time = dc_time_ns;

    // 获取当前调整后的系统时间
    dc_time_ns = system_time_ns();

    // ★ 关键: 读取参考从站的当前时钟时间
    ecrt_master_reference_clock_time(master, &ref_time);

    // ★ 计算偏差: 上一周期的 app_time - 参考从站当前时间
    // 注意: ref_time 是 32 位截断值
    dc_diff_ns = (uint32_t) prev_app_time - ref_time;

    // 同步所有从站时钟到参考从站 (漂移补偿)
    ecrt_master_sync_slave_clocks(master);
}
sequenceDiagram participant RT as RT Task<br/>(周期任务) participant Master as 主站内核 participant Ref as 参考从站<br/>(DC 时钟) participant Slaves as 其他从站 Note over RT,Slaves: sync_distributed_clocks() 调用时序 RT->>RT: dc_time_ns = system_time_ns()<br/>获取调整后的当前时间 RT->>Master: ecrt_master_reference_clock_time() Master->>Ref: FPRD 0x0910 (读 64 位系统时间) Ref-->>Master: ref_time (32 位截断) Master-->>RT: 返回 ref_time RT->>RT: dc_diff_ns = prev_app_time - ref_time<br/>计算偏差 RT->>Master: ecrt_master_sync_slave_clocks() Master->>Ref: FRMW 0x0910 (读参考时钟) Ref->>Slaves: 帧沿总线返回时<br/>各从站自动锁存并补偿
3. update_master_clock() — 核心同步算法
// rtai_rtdm_dc/main.c — update_master_clock()
void update_master_clock(void)
{
    // 步骤 1: 计算偏差变化率 (delta)
    int32_t delta = dc_diff_ns - prev_dc_diff_ns;
    prev_dc_diff_ns = dc_diff_ns;

    // 步骤 2: 归一化偏差到 [-T/2, +T/2]
    dc_diff_ns =
        ((dc_diff_ns + (cycle_ns / 2)) % cycle_ns) - (cycle_ns / 2);

    // 步骤 3: 首次启动检测
    if (!dc_started) {
        dc_started = (dc_diff_ns != 0);
        if (dc_started) {
            dc_start_time_ns = dc_time_ns;
        }
        return;
    }

    // 步骤 4: 累积偏差和变化率
    dc_diff_total_ns += dc_diff_ns;
    dc_delta_total_ns += delta;
    dc_filter_idx++;

    // 步骤 5: 每 1024 周期更新一次漂移补偿
    if (dc_filter_idx >= DC_FILTER_CNT) {
        // 平均漂移率 (D 项)
        dc_adjust_ns +=
            ((dc_delta_total_ns + (DC_FILTER_CNT / 2)) / DC_FILTER_CNT);

        // 漂移方向修正 (极弱 P 项: 仅 ±1 ns)
        dc_adjust_ns += sign(dc_diff_total_ns / DC_FILTER_CNT);

        // 限幅 ±1000 ns
        if (dc_adjust_ns < -1000) dc_adjust_ns = -1000;
        if (dc_adjust_ns >  1000) dc_adjust_ns =  1000;

        // 重置累积器
        dc_diff_total_ns = 0LL;
        dc_delta_total_ns = 0LL;
        dc_filter_idx = 0;
    }

    // 步骤 6: 每周期应用调整
    //   dc_adjust_ns: 长期漂移补偿 (每 1024 周期更新)
    //   sign(dc_diff_ns): 即时微调 (仅 ±1 ns)
    system_time_base += dc_adjust_ns + sign(dc_diff_ns);
}
4. 循环主体
// rtai_rtdm_dc/main.c — my_cyclic() 循环
void my_cyclic(void)
{
    // 设置首次唤醒时间
    wakeup_time = system_time_ns() + 10 * cycle_ns;
    start_rt_timer(nano2count(cycle_ns));
    rt_make_hard_real_time();

    while (run) {
        // 使用可调整的系统时间等待 (关键!)
        wait_period();  // 内部: rt_sleep_until(system2count(wakeup_time))

        // 接收过程数据
        ecrt_master_receive(master);
        ecrt_domain_process(domain1);

        // ... 处理 PDO ...

        // 队列过程数据
        ecrt_domain_queue(domain1);

        // ★ DC 同步: 在 send 前调用,设置最精确的时间
        sync_distributed_clocks();

        // 发送 EtherCAT 帧
        ecrt_master_send(master);

        // ★ 更新主站时钟: 在 send 后调用,减少 sync_distributed_clocks 的抖动
        update_master_clock();
    }
}

调用顺序关键点

flowchart TD A["wait_period()<br/>rt_sleep_until(wakeup_time)"] --> B["ecrt_master_receive()"] B --> C["ecrt_domain_process()"] C --> D["处理 PDO 数据"] D --> E["ecrt_domain_queue()"] E --> F["sync_distributed_clocks()<br/>★ 读取 ref_time<br/>★ 计算 dc_diff_ns"] F --> G["ecrt_master_send()"] G --> H["update_master_clock()<br/>★ 调整 system_time_base"] H --> A style F fill:#fefcbf,stroke:#ecc94b style H fill:#fefcbf,stroke:#ecc94b Note1["在 send 前调用 sync: 确保 ref_time<br/>反映最新帧到达后的从站状态"] -.-> F Note2["在 send 后调用 update: 避免增加<br/>sync_distributed_clocks() 的时间抖动"] -.-> H style Note1 fill:#faf5ff,stroke:#b794f4 style Note2 fill:#faf5ff,stroke:#b794f4

参考时钟选择

在从站参考模式下,建议显式指定参考时钟从站,而非依赖自动选择:

// rtai_rtdm_dc/main.c — 参考时钟选择
// 显式选择总线耦合器 EK1100 作为参考时钟
// NULL = 自动选择第一个有 DC 能力的从站
ret = ecrt_master_select_reference_clock(master, sc_ek1100);
if (ret < 0) {
    fprintf(stderr, "Failed to select reference clock: %s\n",
            strerror(-ret));
    return ret;
}

参考时钟选择建议

  1. 通常选择总线耦合器 (如 EK1100) 作为参考时钟,因为它位于总线最前端
  2. 也可选择具有高精度 DC 的伺服驱动器
  3. 传入 NULL 则自动选择第一个有 DC 能力的从站
  4. 参考时钟从站必须支持 DC (寄存器 0x0981 可写)

常见问题与解决方案

现象 可能原因 解决方案
周期不稳定,period 波动大 未使用实时调度策略 调用 sched_setscheduler() 设置 SCHED_FIFO;使用 PREEMPT_RT 或 Xenomai 内核
latency 偶尔出现尖峰 内存页面换出、中断干扰 调用 mlockall(MCL_CURRENT | MCL_FUTURE) 锁定内存;关闭 CPU 频率调节
从站 AL 状态无法进入 OP DC 参数不匹配(周期或激活字错误) 对照从站 ESI 文件检查 ecrt_slave_config_dc() 参数;确认 assign_activate
WC (Working Counter) 不等于 3 从站未响应或帧丢失 检查物理连接;确认从站地址和位置正确;检查网线质量
DC 同步报错:时钟偏差过大 参考时钟未正确同步 确认参考时钟从站存在且有 DC 能力;检查 ecrt_master_sync_reference_clock_to() 是否每周期调用
printf 输出影响实时性能 标准 I/O 引起阻塞 在生产代码中移除或异步化 printf 调用;使用无锁环形缓冲记录日志

深入源码

模式 A 源码机制

DC 同步时序: 循环内部序列

下图展示了 cyclic_task() 单个循环周期中各步骤的时间关系:

sequenceDiagram participant SLEEP as clock_nanosleep participant APP as 应用程序 participant MASTER as ec_master (内核) participant REF as 参考时钟从站 participant SLAVES as 其他从站 Note over SLEEP,SLAVES: 周期 T = 1ms rect rgb(235, 248, 255) Note over SLEEP,APP: T₀ 唤醒时刻 SLEEP->>APP: 绝对时间唤醒 (TIMER_ABSTIME) APP->>MASTER: ecrt_master_application_time(T₀) Note right of MASTER: ioctl → 写入 master->app_time end rect rgb(255, 245, 238) Note over APP,SLAVES: 接收上一周期数据 APP->>MASTER: ecrt_master_receive() MASTER->>APP: 返回过程数据 APP->>APP: ecrt_domain_process() end rect rgb(240, 255, 244) Note over APP,SLAVES: 处理数据 + DC 同步 APP->>APP: 处理 PDO / 写入输出 APP->>MASTER: ecrt_master_sync_reference_clock_to(T_now) Note right of MASTER: ARMW 0x0910 → 参考从站 MASTER->>REF: ARMW: 读参考时钟 + 写入新时间 APP->>MASTER: ecrt_master_sync_slave_clocks() Note right of MASTER: FRMW 0x0910 → 广播 MASTER->>REF: FRMW: 读取参考时钟系统时间 REF->>SLAVES: 系统时间沿总线传播 Note right of SLAVES: 各从站根据传播延迟<br/>自动补偿偏移 end rect rgb(250, 245, 255) Note over APP,SLAVES: 发送本周期数据 APP->>MASTER: ecrt_domain_queue() APP->>MASTER: ecrt_master_send() MASTER->>REF: 发送 EtherCAT 帧 REF->>SLAVES: 转发帧 end Note over SLEEP,SLAVES: 下一次 T₀+T 唤醒

ecrt_master_application_time() 内部机制

该函数将应用程序的当前时间通知给主站内核模块,用于 DC 参考时钟同步。

flowchart LR A["用户空间<br/>ecrt_master_application_time()"] -->|"ioctl<br/>EC_IOCTL_APP_TIME"| B["内核空间<br/>ec_ioctl_app_time()"] B --> C["master->app_time = time64<br/>master->app_time_set = 1"] C --> D["下一次 ecrt_master_send() 时<br/>ref_sync_datagram 自动携带该时间"] D -->|"FPWR 0x0910"| E["参考时钟从站<br/>系统时间寄存器被更新"] style A fill:#ebf8ff,stroke:#90cdf4 style B fill:#fefcbf,stroke:#f6e05e style E fill:#f0fff4,stroke:#9ae6b4

为什么用目标时间而非测量时间?

dc_user 示例使用 wakeupTime(目标唤醒时间)而非 startTime(实际启动时间)来调用 ecrt_master_application_time()。这是因为:

  1. 稳定性: 目标时间是精确计算的,不受调度抖动影响
  2. 单调性: 目标时间严格递增(每次加固定周期),而实际时间可能因调度延迟出现波动
  3. 确定性: 参考时钟从站收到的应该是"应该到达的时间",而不是"实际到达的时间"

ecrt_master_sync_reference_clock_to() 内部机制

该函数将主站应用时间同步到 DC 参考时钟从站,使用 ARMW (Auto Increment Read Multiple Write) 命令:

// 内核实现逻辑 (master/ioctl.c)
// ecrt_master_sync_reference_clock_to() 对应的 ioctl 处理
static ATTRIBUTES int ec_ioctl_sync_ref(
        ec_master_t *master, void *arg, size_t size)
{
    uint64_t app_time = *((uint64_t *) arg);

    // 将应用时间写入 ref_sync_datagram 的数据区
    // ref_sync_datagram 类型: FPWR (Configured Address Physical Write)
    // 目标寄存器: 0x0910 (DC 系统时间, 64 位)
    EC_WRITE_U64(master->ref_sync_datagram.data, app_time);
    master->ref_sync_datagram.state = EC_DATAGRAM_INITIALIZED;

    return 0;
}
flowchart TD A["ecrt_master_sync_reference_clock_to(master, time)"] -->|"ioctl<br/>EC_IOCTL_SYNC_REF"| B["EC_WRITE_U64<br/>ref_sync_datagram.data<br/>写入 64 位时间"] B --> C["下次 send() 时<br/>ref_sync_datagram 发送"] C -->|"FPWR 0x0910"| D["参考时钟从站<br/>ESC_DCSYSTIM 被写入"] style A fill:#ebf8ff,stroke:#90cdf4 style D fill:#f0fff4,stroke:#9ae6b4

ecrt_master_sync_slave_clocks() 内部机制

该函数执行全网从站时钟的漂移补偿。它通过 FRMW (Configured Address Read, Multiple Write) 广播命令实现:

flowchart TD A["ecrt_master_sync_slave_clocks(master)"] -->|"ioctl<br/>EC_IOCTL_SYNC_SLAVES"| B["标记 sync64_datagram<br/>需要发送"] B --> C["下次 send() 时<br/>sync64_datagram 发送"] C -->|"ARMW/FRMW 0x0910"| D["读取参考时钟 64 位系统时间"] D --> E["帧沿总线返回时<br/>每个从站自动锁存<br/>并写入本地 0x0910"] E --> F["各从站 DC 硬件<br/>自动计算偏移并补偿"] subgraph "从站内部漂移补偿" F --> F1["读取 0x092C 系统时间差"] F1 --> F2["更新 0x0920 系统时间偏移"] F2 --> F3["调整本地时钟速度<br/>(0x0930 速度计数器)"] end style A fill:#ebf8ff,stroke:#90cdf4 style D fill:#fefcbf,stroke:#f6e05e style F fill:#f0fff4,stroke:#9ae6b4

ARMW vs FRMW 的区别

ARMW (Auto Increment Read Multiple Write): 使用自增地址,读取参考从站数据并广播写入后续从站。适用于首次同步。

FRMW (Configured Address Read Multiple Write): 使用配置地址,读取指定从站数据并广播写入其他从站。适用于运行时周期性漂移补偿。

IgH 主站在运行时通常使用 FRMW,地址指向参考时钟从站的配置地址。

clock_nanosleep 与确定性周期

dc_user 使用 clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, ...) 实现确定性周期唤醒,其关键在于 TIMER_ABSTIME 标志:

特性 相对时间 (默认) 绝对时间 (TIMER_ABSTIME)
唤醒基准 从调用时刻起延迟指定时间 休眠到指定的绝对时刻
累积误差 每次循环会累积处理时间的误差 不会累积,始终对齐到精确周期
唤醒公式 now + period start + N * period
适用场景 非实时应用 实时周期任务(推荐)
// 绝对时间唤醒模式 (推荐)
// 初始化: 获取当前时间
clock_gettime(CLOCK_TO_USE, &wakeupTime);

while (1) {
    // 计算下一个绝对唤醒时刻
    wakeupTime = timespec_add(wakeupTime, cycletime);

    // 休眠到绝对时刻 —— 无累积误差
    clock_nanosleep(CLOCK_TO_USE, TIMER_ABSTIME, &wakeupTime, NULL);

    // ... 周期任务 ...
}

模式 B 同步算法深度分析

以下对 update_master_clock() 中的同步算法进行数学建模和问题分析,并给出改进方案。

同步算法控制系统框图

算法数学建模

原始算法本质上是一个类 PD 控制器(比例-微分),而非标准 PI 控制器:

控制项 原始算法实现 数学表达 问题
P (比例) sign(dc_diff_ns) u_P = sign(e(n)) = ±1 ns 极弱! 误差 100ns 仅修正 1ns,增益仅 0.01
D (微分/漂移率) (dc_delta_total_ns + N/2) / N u_D = avg(Δe) / 1024 1024 采样窗口造成 ~1s 延迟 (1ms 周期时)
积分累加 dc_adjust_ns Σ u_D + sign(avg(e)) 每 1024 周期才更新一次,响应极慢
输出 system_time_base += dc_adjust_ns + sign(e) base += Σu_D + u_P 每周期应用 dc_adjust_ns (含历史累积) + 即时修正

收敛性分析

flowchart TD subgraph "原始算法收敛过程" A1["初始偏差<br/>e₀ = 100000 ns (100us)"] --> A2["P 项修正<br/>sign(e) = +1 ns/周期"] A2 --> A3["仅靠 P 项收敛时间<br/>≈ 100000/1 = 100000 周期<br/>≈ 100 秒 (1ms周期)"] A3 --> A4["加上 D 项(漂移补偿)<br/>加速约 2-5 倍<br/>实际 ≈ 20-50 秒"] end subgraph "改进 PI 算法收敛过程" B1["初始偏差<br/>e₀ = 100000 ns (100us)"] --> B2["P 项修正<br/>Kp × e = 0.05 × 100000<br/>= 5000 ns/周期"] B2 --> B3["指数衰减收敛<br/>τ = 1/Kp = 20 周期<br/>≈ 20 ms (1ms周期)"] B3 --> B4["I 项消除残余偏差<br/>最终精度: ±10 ns 以内<br/>总收敛 ≈ 50-200 周期"] end style A3 fill:#fed7d7,stroke:#e53e3e style A4 fill:#fefcbf,stroke:#ecc94b style B3 fill:#c6f6d5,stroke:#38a169 style B4 fill:#c6f6d5,stroke:#38a169

原始算法的 4 个关键问题

# 问题 代码位置 影响
1 P 项增益过小: sign(dc_diff_ns) 仅输出 ±1 ns system_time_base += dc_adjust_ns + sign(dc_diff_ns) 100us 初始偏差需要 ~100s 收敛,远超实际可接受范围
2 1024 采样窗口过长: 漂移率更新延迟 ~1s dc_filter_idx >= DC_FILTER_CNT 温度变化引起的时钟漂移 (~50ppm) 响应太慢
3 无标准 I 项: 缺少偏差积分消除稳态误差 整个 update_master_clock() 当 P 项和 D 项平衡后可能存在残余稳态偏差
4 dc_adjust_ns 单向累积: 缺少抗饱和机制 dc_adjust_ns += ... (只加不减) 如果初始阶段累积过大,后续反向调整困难

改进方案: 标准 PI 控制器

将原始的 crude PD 结构替换为标准 PI 控制器,实现更快速、更稳定的收敛:

/* ========= PI 控制器参数 (可根据总线规模调整) ========= */
#define DC_KP              0.05    /* 比例增益: 100ns偏差 → 5ns修正 */
#define DC_KI              0.0005  /* 积分增益: 消除稳态偏差 */
#define DC_INTEGRAL_MAX    100000  /* 积分器抗饱和上限 (ns) */
#define DC_OUTPUT_MAX      1000    /* 单周期输出限幅 (ns) */
#define DC_FILTER_CNT      1024    /* 保持 1024 用于归一化 */

/* ========= PI 控制器状态 ========= */
static int64_t  dc_integral_ns = 0;    /* 积分项累积值 */
static int32_t  dc_diff_ns = 0;        /* 当前偏差 */
static int32_t  prev_dc_diff_ns = 0;   /* 上一周期偏差 */
static int64_t  system_time_base = 0;  /* 时间基准偏移 */
static uint8_t  dc_started = 0;

/* ========= 改进的 sync_distributed_clocks (不变) ========= */
void sync_distributed_clocks(void)
{
    uint32_t ref_time = 0;
    uint64_t prev_app_time = dc_time_ns;

    dc_time_ns = system_time_ns();
    ecrt_master_reference_clock_time(master, &ref_time);
    dc_diff_ns = (uint32_t) prev_app_time - ref_time;
    ecrt_master_sync_slave_clocks(master);
}

/* ========= 改进的 update_master_clock: PI 控制器 ========= */
void update_master_clock_pi(void)
{
    /* 步骤 1: 计算偏差变化率 (用于诊断, PI 不直接使用) */
    int32_t delta = dc_diff_ns - prev_dc_diff_ns;
    prev_dc_diff_ns = dc_diff_ns;

    /* 步骤 2: 归一化偏差到 [-T/2, +T/2] (处理 32 位回绕) */
    dc_diff_ns =
        ((dc_diff_ns + (cycle_ns / 2)) % cycle_ns) - (cycle_ns / 2);

    /* 步骤 3: 首次启动检测 */
    if (!dc_started) {
        dc_started = (dc_diff_ns != 0);
        return;
    }

    /* 步骤 4: P 项 — 按比例修正当前偏差 */
    int32_t p_term = (int32_t)(DC_KP * dc_diff_ns);

    /* 步骤 5: I 项 — 累积偏差消除稳态误差 */
    dc_integral_ns += dc_diff_ns;

    /* 抗饱和: 积分器钳位 */
    if (dc_integral_ns >  DC_INTEGRAL_MAX) dc_integral_ns =  DC_INTEGRAL_MAX;
    if (dc_integral_ns < -DC_INTEGRAL_MAX) dc_integral_ns = -DC_INTEGRAL_MAX;

    int32_t i_term = (int32_t)(DC_KI * dc_integral_ns);

    /* 步骤 6: 总输出 = P + I, 限幅 */
    int32_t output = p_term + i_term;
    if (output >  DC_OUTPUT_MAX) output =  DC_OUTPUT_MAX;
    if (output < -DC_OUTPUT_MAX) output = -DC_OUTPUT_MAX;

    /* 步骤 7: 应用到时间基准 */
    system_time_base += output;
}

新旧算法对比

flowchart LR subgraph "原始算法" direction TB O1["sign(e) → ±1 ns"] --> O2["漂移率 avg / 1024"] O2 --> O3["dc_adjust_ns 累积"] O3 --> O4["base += 累积 + sign(e)"] end subgraph "改进 PI 算法" direction TB N1["Kp × e → 比例修正"] --> N3["P + I 求和"] N2["Ki × Σe → 积分修正"] --> N3 N3 --> N4["限幅 ±1000 ns"] N4 --> N5["base += output"] end O4 -.->|"100us 偏差收敛<br/>~20-50 秒"| R1["收敛慢<br/>精度: ±50-100ns"] N5 -.->|"100us 偏差收敛<br/>~50-200 周期<br/>≈ 50-200ms"| R2["收敛快<br/>精度: ±10ns"] style O1 fill:#fed7d7,stroke:#e53e3e style O4 fill:#fefcbf,stroke:#ecc94b style N1 fill:#c6f6d5,stroke:#38a169 style N2 fill:#c6f6d5,stroke:#38a169 style R1 fill:#fed7d7,stroke:#e53e3e style R2 fill:#c6f6d5,stroke:#38a169
对比维度 原始算法 改进 PI 算法
控制器类型 弱 P + D (漂移率) 标准 P + I
P 项增益 sign(e) → 增益 1/|e| (非线性, 极弱) Kp = 0.05 (线性, 可调)
积分/消除偏差 无标准 I 项, 依赖 D 项累积 Ki × Σe, 带抗饱和
更新频率 D 项: 每 1024 周期; P 项: 每周期 每周期更新 P + I
100us 偏差收敛 ~20-50 秒 (20000-50000 周期) ~50-200 周期 (50-200 ms)
稳态精度 ±50-100 ns (可能存在残余偏差) ±10 ns (I 项消除稳态偏差)
参数调整 仅 DC_FILTER_CNT Kp, Ki, 积分上限, 输出限幅 (4 个参数)

PI 参数调节指南

参数 默认值 范围 调节建议
Kp 0.05 [0.01, 0.1] 从站少/周期短 → 增大; 从站多/周期长 → 减小。太大则振荡, 太小则收敛慢
Ki 0.0005 [0.0001, 0.001] 消除稳态偏差。通常比 Kp 小 100 倍。太大则低频振荡
INTEGRAL_MAX 100000 ns [10000, 1000000] 抗饱和上限。防止积分器累积过大导致过冲
OUTPUT_MAX 1000 ns [100, 5000] 单周期最大调整量。不超过周期的 0.1%

收敛曲线示意

flowchart TD subgraph "时间轴 →" direction LR T0["0"] --> T1["50ms"] --> T2["100ms"] --> T3["200ms"] --> T4["500ms"] --> T5["稳态"] end subgraph "偏差 (dc_diff_ns)" direction LR E0["100000 ns<br/>(初始偏差)"] --> E1["~6000 ns<br/>指数衰减"] --> E2["~400 ns<br/>继续衰减"] --> E3["~20 ns<br/>接近收敛"] --> E4["<10 ns<br/>I 项消除残余"] --> E5["±5-10 ns<br/>稳定跟踪"] end E0 -.->|"P 项主导<br/>快速修正"| E1 E1 -.->|"P+I 协同"| E2 E2 -.->|"I 项主导<br/>消除残余"| E3 style E0 fill:#fed7d7,stroke:#e53e3e style E2 fill:#fefcbf,stroke:#ecc94b style E5 fill:#c6f6d5,stroke:#38a169

⚠️ 实际部署注意

  1. 首次启动: 主站与参考从站初始偏差可能达数十微秒 (传播延迟 + 时钟偏移),PI 算法在 50-200 周期内即可收敛
  2. 实时要求: PI 计算必须在周期任务内完成 (通常 <1us),不可放入非实时上下文
  3. 温度漂移: ESC 晶振温漂约 ±50 ppm (即 1ms 周期偏差 ±50ns),I 项可自动跟踪补偿
  4. 与 SYNC0 对齐: 目标是使主站唤醒时刻与从站 SYNC0 脉冲对齐,dc_diff_ns ≈ 0 表示完美对齐
  5. RTAI/Xenomai 差异: 示例使用 RTAI API (rt_sleep_until, rt_get_time_ns),Xenomai 用户空间应替换为对应的 RTDM API

深入了解

posted @ 2026-04-15 08:12  沐多  阅读(1032)  评论(2)    收藏  举报