EtherCAT DC 分布式时钟同步的 ModelSim 仿真:从帧模板到从站行为建模

EtherCAT DC 分布式时钟同步的 ModelSim 仿真:从帧模板到从站行为建模

关键词:EtherCAT、DC 分布式时钟、ESC、0x0918、0x0110 DL Status、ModelSim、Verilog testbench、行为级建模、线型拓扑

适用人群:正在做 EtherCAT 主站 FPGA 实现、需要验证 DC 同步流程的工程师


开篇:为什么 DC 同步必须靠仿真验证

EtherCAT 的 DC(Distributed Clock,分布式时钟)是精密运动控制的地基。多轴协同能做到亚微秒级同步,靠的就是每个从站 ESC 里的本地时钟被主站反复校准。

但 DC 同步有个特点:它是一个长流程状态机。一次完整的 DC 初始化要经历:

  1. 广播写 0x0900锁存时间戳
  2. 读 0x0918 拿各从站接收时间戳,算 offset
  3. 广播写 offset
  4. 读端口时间戳算传播延迟(pdelay / 系统延迟)
  5. 写系统延迟 + 时钟漂移补偿
  6. 周期性发 ARMW/FRMW 做运行期补偿

每一步都要发不同的帧模板、等不同的应答、解析不同的寄存器。32 个从站的话,这个流程要跑几千个时钟周期。

上板调试的问题:抓波形只能看到"最后成没成",中间哪一步卡住、是帧没发出去还是应答没回来、DL status 解析对不对------全都看不见。

所以正确的做法是:先搭一套能跑的仿真环境,把状态机每一步都跑通,再上板。

本文以一个真实的 DC 同步模块(ecat_sync_dc)为例,讲解配套 testbench 的搭建方法。核心是三件事:

  • 怎么模拟发送 (crc32_tx_cnt 字节计数器)
  • 怎么模拟应答 (resp_ack_sync + 200µs 超时)
  • 怎么模拟从站(时间戳 + DL status,含线型拓扑的末从站识别)

一、整体架构

复制代码
tb_ecat_sync_dc
├── sub1(行为级模型)      ← 64 位减法器,替代 Altera LPM_ADD_SUB IP
├──ecat_frame_module ← 46 字节 EtherCAT 帧模板
├── ecat_sync_dc(DUT)        ← 被测模块:DC 同步状态机
└── TB 模拟部分
    ├── ecat_tx 模型     ← crc32_tx_cnt 发送字节计数(74 拍/帧)
    ├── ecat_rx 模型          ← resp_ack_sync 应答 + overtime 超时
    └── 从站模型             ← esc_rec_time / esc_cap_time / DL status

1.1 sub1:四两拨千斤的 IP 替代

verilog 复制代码
//--------------------------------------------------------------------------------
// sub1 行为级模型 (64 位减法: result = dataa - datab)
// 替代 Altera LPM_ADD_SUB IP 核, 用于 offset / pdelay 计算
//--------------------------------------------------------------------------------
module sub1 (
    input  [63:0] dataa,
    input  [63:0] datab,
    output [63:0] result
);
    assign result = dataa - datab;
endmodule

这一句是整个 tb 能脱离 Quartus 独立运行的关键。

DC 同步要算 64 位的时间差(offset = 参考时钟 - 本地时钟、pdelay 等),RTL 里通常例化了 Altera 的 LPM_ADD_SUB。仿真时如果直接拉这个 IP,会遇到:

  • 需要 220model.v / altera_mf.v 仿真库
  • ModelSim 要配 +incdir+ 和库映射
  • 第三方(Icarus / Verilator)根本跑不了

解法 :同名模块 sub1,行为级一行实现。仿真时 tb 里的 sub1 覆盖了工程里的 IP 例化(或者在仿真文件列表里排在前面)。

⚠️ 注意 :这只是仿真替身 。综合上板时必须换回真正的 LPM IP,否则 64 位减法直接用 assign 会综合出一根巨大的减法器链,时序大概率不过。建议在文件头注释里写清楚,或者用 ```ifdef SIMULATION`` 隔离。

1.2 DUT 例化

复制代码
端口分组看得很清楚:

| 方向 | 信号 | 含义 |
|---|---|---|
| 输入 | `wkc_max` | 从站个数(本例 32) |
| 输入 | `crc32_tx_cnt` | 发送字节计数(帧发送进度) |
| 输入 | `resp_ack_sync` | 帧应答(从站回来了) |
| 输入 | `overtime_plus_sync` | 200µs 超时脉冲 |
| 输入 | `esc_rec_time` | 从站 `0x0918` 接收时间戳 |
| 输入 | `esc_cap_time` | 端口时间戳 / DL status |
| 输入 | `ecat_module_data` | 帧模板
| 输出 | `sync_sm` | 同步状态机当前状态 |
| 输出 | `ecat_module_adr` | 帧模板地址 |
| 输出 | `sync_done` | 同步完成标志 |

**`ecat_module_adr` 是理解整个流程的钥匙**------DUT 通过它选不同的帧模板,tb 只要监视这个地址,就知道 DUT 当前在发什么帧、走到哪一步了。

## 二、关键设计 1:发送字节计数器(74 拍一帧)

这是 tb 里**最容易写错**的部分。必须严格对照真实以太网帧结构:

```verilog
//================================================================================
// 模拟 ETH_DAT_TX + ETH_TX: 发送字节计数器 crc32_tx_cnt
//   完整以太网帧结构 (对照 ETH_TX.v / ETH_DAT_TX.v):
//     crc32_tx_cnt 1~7   : 前导码 0x55 (7 字节)
//     crc32_tx_cnt 8     : SFD 0xD5
//     crc32_tx_cnt 9~14  : 目的 MAC (6 字节)
//     crc32_tx_cnt 15~20 : 源 MAC (6 字节)
//     crc32_tx_cnt 21~22 : EtherType (2 字节)  ← ecat_sync 在 cnt==22 加载 send_buf
//     crc32_tx_cnt 23~68 : EtherCAT 报文 (46 字节, 每拍左移 send_buf 输出 1 字节)
//     crc32_tx_cnt 69~72 : CRC32 (4 字节)
//     crc32_tx_cnt 73    : ETH_TX 停止写 RAM (tx_wea=0)
//   => 一帧共需 crc32_tx_cnt 从 0 递增到 73 (74 拍)
//================================================================================
reg [8:0] tx_cnt;
reg       tx_active;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        tx_cnt    <= 9'd0;
        tx_active <= 1'b0;
    end else begin
        if (sync_respone_valid && !tx_active) begin
            tx_active <= 1'b1;     // DUT 请求发帧 → 启动
            tx_cnt    <= 9'd0;
        end else if (tx_active) begin
            if (tx_cnt < 9'd73)
                tx_cnt <= tx_cnt + 1'b1;
            else
                tx_active <= 1'b0; // 一帧发完
        end
    end
end

always @(*) crc32_tx_cnt = tx_active ? tx_cnt : 9'd0;

2.1 为什么是 74 拍

复制代码
7(前导) + 1(SFD) + 6(目的MAC) + 6(源MAC) + 2(EtherType)
+ 46(EtherCAT报文) + 4(CRC) + 1(停止) = 73 拍增量

2.2 关键节拍点

crc32_tx_cnt 事件
21~22 EtherType,DUT 在此加载 frame_tx_buf
23~68 逐字节输出 46 字节 EtherCAT 报文
73 发送结束

⚠️ 坑 :crc32_tx_cnt 必须在非发送期间归零 。原 tb 用 always @(*) crc32_tx_cnt = tx_active ? tx_cnt : 9'd0; 处理了这点。如果忘了归零,DUT 会误判一直在发帧,状态机卡死。

三、关键设计 2:应答与超时模型

verilog 复制代码
reg [15:0] overtime_cnt;
reg        res_ack_pending;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        res_ack_sync       <= 1'b0;
        overtime_plus_sync <= 1'b0;
        overtime_cnt       <= 16'd0;
        res_ack_pending    <= 1'b0;
    end else begin
        res_ack_sync       <= 1'b0;   // 默认清零(单拍脉冲)
        overtime_plus_sync <= 1'b0;

        if (tx_active && (tx_cnt == 9'd73)) begin
            // 帧发送完成, 开始等待应答
            res_ack_pending <= 1'b1;
            overtime_cnt    <= 16'd0;
        end else if (res_ack_pending) begin
            overtime_cnt <= overtime_cnt + 1'b1;
            // 模拟从站应答延迟 (约 2 拍)
            if (overtime_cnt == 16'd2) begin
                res_ack_sync    <= 1'b1;
                res_ack_pending <= 1'b0;
            end else if (overtime_cnt >= 16'd8000) begin
                // 200us 超时 (40MHz × 200µs = 8000 拍)
                overtime_plus_sync <= 1'b1;
                res_ack_pending    <= 1'b0;
            end
        end
    end
end

3.1 两个数字的含义

数值 换算 说明
2 拍 50ns 模拟从站应答延迟(实验室短距离,几乎无延迟)
8000 拍 200µs 200µs 超时门限

200µs 这个数字不是随便定的------它是 EtherCAT 主站判断"帧丢了"的常用门限,对应 40MHz 下 8000 个周期。

3.2 怎么测超时场景

把应答关掉即可:

verilog 复制代码
// 测试超时:注释掉应答分支,只留超时分支
if (overtime_cnt >= 16'd8000) begin
    overtime_plus_sync <= 1'b1;
    resp_ack_pending    <= 1'b0;
end

或者做成可控的:

verilog 复制代码
reg ack_enable;    // 1=正常应答, 0=模拟丢帧
...
if (ack_enable && (overtime_cnt == 16'd2)) begin
    resp_ack_sync <= 1'b1;
    ...
end

四、关键设计 3:从站行为建模(本文核心)

这是整个 tb 最有技术含量的部分------怎么让 tb 扮演"32 个从站"。

4.1 时间戳基础

verilog 复制代码
reg [63:0] slave_time_base;
reg [31:0] port_time_base;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        slave_time_base <= 64'd0;
        port_time_base  <= 32'd0;
    end else begin
        slave_time_base <= slave_time_base + 64'd25;  // 从站时钟运行 (40MHz, 25ns/拍)
        port_time_base  <= port_time_base + 32'd25;
    end
end

每拍 +25ns,模拟从站本地时钟自由运行。

4.2 影子计数器:tb 怎么知道"我是第几个从站"

这是行为级建模的核心妥协。

真实硬件里,从站是根据自身物理位置 返回 DL status 的------它知道自己上游有没有连接、下游有没有连接,完全不依赖主站内部计数器。

4.3 DL status 线型拓扑策略

EtherCAT 常见的是线型拓扑(daisy chain):主站 → 从站1 → 从站2 → ... → 从站N(末端)。

复制代码
主站 ──Port0──[从站1]──Port3──┬──Port0──[从站2]──Port3──┬─...─┬──Port0──[从站N]
                              链路                      链路           (末从站)
                                                                     Port3 悬空

DL status 寄存器 0x0110 的位定义(16-bit):

位 含义
bit 0 PDI Operational
bit 1 DL User Watchdog
bit 2 Extended Link Detection
bit 7:4 物理链路 LINK:Port0~Port3(1 = link up)
bit 15:12 通信环路 LOOP:Port0~Port3(1 = 在环路内)

活跃 = LINK && LOOP(两个位都为 1 才算这个口真正通着)。

对应到 tb:

verilog 复制代码
always @(*) begin
    if (reg918_rec_valid)
        esc_rec_time = slave_time_base;       // 从站 0x0918 时间戳
    else
        esc_rec_time = 64'd0;

    if (rec_port_time_valid)
        esc_cap_time = port_time_base;        // 端口时间戳
    else if (sync_aux_cnt == 3'd1) begin
        // DL status 0x0110: 末从站 Port3 不活跃
        if (tb_slave_idx == (wkc_nu - 1'b1)) begin
            // 末从站: 仅 Port0 活跃 (bit[4]=1, bit[9]=1)
            //   byte0=0x10, byte1=0x02 → port3_active=0, is_port3_inactive=1
            esc_cap_time = 32'h0000_0210;
        end else begin
            // 非末从站: Port0 + Port3 均活跃
            //   byte0=0x90(bit[4,7]=1), byte1=0x82(bit[9,15]=1)
            esc_cap_time = 32'h0000_8290;
        end
    end else
        esc_cap_time = 32'd0;
end

把这两个常量拆开看(关键):

复制代码
非末从站  0x0000_8290
          = 0000_0000_0000_0000_1000_0010_1001_0000 (bin)
                                │        │  │   │
                          bit15 ┘   bit9 ┘  │   └─ bit4 (Port0 LINK)
                          (Port3 LOOP)      └───── bit7 (Port3 LINK)
          → Port0: LINK=1(bit4) LOOP=1(bit9)  ✓ 活跃
          → Port3: LINK=1(bit7) LOOP=1(bit15) ✓ 活跃

末从站    0x0000_0210
          = 0000_0000_0000_0000_0000_0010_0001_0000 (bin)
                                        │       └─ bit4 (Port0 LINK)
                                  bit9 ┘
          → Port0: LINK=1(bit4) LOOP=1(bit9)  ✓ 活跃
          → Port3: LINK=0(bit7) LOOP=0(bit15) ✗ 不活跃

末从站的 Port3 没有下游连接,所以 LINK 和 LOOP 都是 0------这正是主站判断"拓扑到头了"的依据。

复制代码
tb_slave_idx    0    1    2   ...  30   31(末)
esc_cap_time   8290 8290 8290 ... 8290  0210
                                        ↑ 最后一拍突变
port3_active    1    1    1   ...  1    0

截图要点:跑完 32 个从站,看 esc_cap_time 在前 31 个是 0x8290、最后一个变成 0x0210。这一拍的突变就是末从站识别逻辑被验证成功的标志。

4.4 为什么末从站识别这么重要

主站靠 DL status 判断拓扑结构,用于:

  • 确定环网是否闭合(末从站 Port3 不活跃 = 线型;Port3 活跃且连回主站 = 环型)
  • 计算传播延迟时排除未连接的端口
  • 电缆冗余场景判断断线位置

如果这段建模错了(比如末从站也返回 0x8290),主站会认为拓扑没到头,继续等待,导致 sync_done 永远不出来------这是 DC 仿真最常见的"跑不完"原因之一。


五、测试流程

verilog 复制代码
initial begin
    $display("========================================");
    $display("TEST: my_ecat_sync (DC Synchronization)");
    $display("========================================");
    test_cnt = 0;
    pass_cnt = 0;
    fail_cnt = 0;

    // 初始化
    rst_n             = 1'b0;
    wkc_nu            = 7'd32;         // 32 个从站
    crc32_tx_cnt      = 9'd0;
    res_ack_sync      = 1'b0;
    overtime_plus_sync = 1'b0;
    esc_rec_time      = 64'd0;
    esc_cap_time      = 32'd0;

    #100;
    rst_n = 1'b1;
    $display("[Reset released]");

    // 等待 DC 同步完成或超时
    sync_done_flag = 1'b0;
    timeout_flag   = 1'b0;

    fork
        begin
            @(posedge sync_done);
            sync_done_flag = 1'b1;
            $display("[sync_done asserted at time=%0t]", $time);
        end
        begin
            #50_000_000;  // 50ms 超时
            timeout_flag = 1'b1;
            $display("[WARNING: sync_done timeout]");
        end
    join

    // 检查结果
    check(sync_done_flag, "DC sync completed successfully");
    check(sync_error === 1'b0, "no sync error");
    check(error_code === 8'd0, "error code is 0");
    check(sync_rst_n === 1'b1, "sync_rst_n released");

    $display("========================================");
    $display("SUMMARY: %0d total, %0d passed, %0d failed",
             test_cnt, pass_cnt, fail_cnt);
    if (fail_cnt == 0)
        $display(">>> ALL TESTS PASSED <<<");
    else
        $display(">>> %0d TESTS FAILED <<<", fail_cnt);
    $display("========================================");

    $finish;
end

5.1 这个 fork/join 有个隐患

verilog 复制代码
fork
    begin @(posedge sync_done); sync_done_flag = 1'b1; end
    begin #50_000_000; timeout_flag = 1'b1; end
join

fork...join 会等两条分支都结束 才继续。所以即使 sync_done 在第 5ms 就来了,tb 也要白等到 50ms 超时分支走完才继续。

这不是功能 bug,但很浪费仿真时间------32 从站的 DC 流程本来就长,每次跑都白等 45ms。

改进 (ModelSim 支持 join_any 的话):

verilog 复制代码
fork
    begin @(posedge sync_done); sync_done_flag = 1'b1; end
    begin #50_000_000; timeout_flag = 1'b1; end
join_any          // ← 任一分支结束就继续
disable fork;     // 可选:杀掉其余分支

如果用的是 ModelSim 10.1b 等不支持 join_any 的老版本,用 flag 轮询:

verilog 复制代码
// 兼容写法:超时计数器 + 标志轮询
timeout_cnt = 0;
while (!sync_done_flag && (timeout_cnt < 50_000)) begin
    @(posedge clk);
    timeout_cnt = timeout_cnt + 1;
end
if (!sync_done_flag) $display("[WARNING: sync_done timeout]");

5.2 check 任务

verilog 复制代码
task check;
    input        cond;
    input [255:0] msg;
    begin
        test_cnt = test_cnt + 1;
        if (cond) begin
            pass_cnt = pass_cnt + 1;
            $display("  PASS: %0s", msg);
        end else begin
            fail_cnt = fail_cnt + 1;
            $display("  FAIL: %0s (time=%0t)", msg, $time);
        end
    end
endtask

简单够用。建议在 FAIL 分支里多打几个关键信号的值,方便定位:

verilog 复制代码
$display("  FAIL: %0s (time=%0t) sm=%0d idx=%0d err=%0d code=%0d",
         msg, $time, sync_sm, ecat_index_sync, sync_error, error_code);

六、仿真结果分析与截图指南

6.1 波形 do 文件

tcl 复制代码
# wave_dc.do
vlog +define+SIMULATION ../rtl/*.v tb_my_ecat_sync.v
vsim -voptargs="+acc" work.tb_my_ecat_sync

set TOP sim:/tb_my_ecat_sync
set DUT ${TOP}/u_ecat_sync

# --- 全局控制 ---
add wave -divider {--- Control ---}
add wave -radix binary  ${TOP}/clk
add wave -radix binary  ${TOP}/rst_n
add wave -radix unsigned ${TOP}/wkc_nu

# --- 状态机(最重要)---
add wave -divider {--- FSM ---}
add wave -radix unsigned -color Chartreuse ${TOP}/sync_sm
add wave -radix unsigned ${TOP}/ecat_const_adr
add wave -radix unsigned ${TOP}/ecat_index_sync
add wave -radix unsigned ${TOP}/sync_aux_cnt
add wave -radix binary  -color Green ${TOP}/sync_done
add wave -radix binary  -color Red   ${TOP}/sync_error
add wave -radix unsigned ${TOP}/error_code

# --- 帧收发 ---
add wave -divider {--- Frame TX/RX ---}
add wave -radix binary  -color Yellow ${TOP}/sync_respone_valid
add wave -radix unsigned ${TOP}/crc32_tx_cnt
add wave -radix hex     ${TOP}/tx_data_sync
add wave -radix binary  ${TOP}/res_ack_sync
add wave -radix binary  -color Orange ${TOP}/overtime_plus_sync

# --- 从站数据 ---
add wave -divider {--- Slave Data ---}
add wave -radix decimal ${TOP}/esc_rec_time
add wave -radix hex     -color Magenta ${TOP}/esc_cap_time
add wave -radix binary  ${TOP}/reg918_rec_valid
add wave -radix binary  ${TOP}/rec_port_time_valid
add wave -radix unsigned ${TOP}/tb_slave_idx

configure wave -timelineunits ns
wave zoom full
run -all

七、这个 tb 的三个隐患与改进

隐患 1:fork...join 白等 50ms

问题 :前面说过,join 等两条分支都结束,即使 sync_done 早就来了也要等满 50ms。

改进 :用 join_any(新版本)或 flag 轮询(老版本),见 5.1。

隐患 2:tb_slave_idx 是白盒建模

问题 :触发条件 sync_sm == 7'd3 && sync_aux_cnt == 3'd7 && res_ack_sync 硬编码对齐 DUT 内部时序。DUT 一改就失效,且失效方式隐蔽(返回错误的 DL status,看起来像"拓扑错了")。

改进 :改用 DUT 输出信号 ecat_index_sync 作为从站索引来源(见 4.2)。如果 DUT 没有这个输出,建议 给 DUT 加一个------这是个很有价值的调试输出。

隐患 3:esc_recv_time 用组合逻辑赋 64 位

问题:

verilog 复制代码
always @(*) begin
    if (reg918_rec_valid)
        esc_recv_time = slave_time_base;
    else
        esc_recv_time = 64'd0;
end

reg918_recv_valid 是 DUT 输出,slave_time_base 是 tb 寄存器。组合逻辑赋值可能与 DUT 的采样沿竞争------DUT 在这个时钟沿采样 esc_rec_time 时,tb 的 slave_time_base 也刚更新,读到的是新值还是旧值取决于仿真器调度顺序。


八、完整 checklist

搭 DC 同步仿真环境时逐条确认:

  • 时钟:```timescale 1ns/1ps`` + #12.5(40MHz),自检 100 拍 = 2500ns
  • IP 替代:sub1 等行为级模型,仿真脱离 Quartus 库
  • 帧结构:74 拍一帧,关键节拍 22(加载)/ 73(结束)
  • 非发送期 crc32_tx_cnt 归零
  • 应答延迟 2 拍,超时门限 8000 拍(200µs)
  • 从站时钟每拍 +25ns
  • 影子计数器触发条件与 DUT 对齐(或改用 DUT 输出)
  • DL status:非末从站 0x8290、末从站 0x0210
  • esc_recv_time / esc_cap_time 用时序赋值,避免竞争
  • fork 用 join_any 或 flag 轮询,不白等
  • check 失败时打印 sync_sm / ecat_index_sync / error_code
  • vlog +define+SIMULATION(断言生效)
  • vsim -voptargs="+acc"(内部信号可见)
  • 改了源码重新 vlog

九:仿真图

十、结语

DC 同步仿真的难点不在 RTL,而在怎么让 tb 扮演好"32 个从站"。

三件事做对了,环境就通了:

  1. 发送节拍严格对照真实以太网帧结构(74 拍)
  2. 应答/超时用明确的拍数建模(2 拍 / 8000 拍)
  3. 从站行为用 DL status 反映真实拓扑(末从站 Port3 不活跃)

其中最容易被忽视、也最容易出错的是第三点 ------末从站的 DL status 如果建模错了,主站会一直等拓扑到头,sync_done 永远出不来,你会以为是状态机有 bug,其实是 tb 的锅。

另外提醒一句:tb 里凡是依赖 DUT 内部信号的地方,都是白盒建模,属于技术债。能改成用 DUT 输出信号的尽量改------DUT 一改 tb 就废的教训,踩过一次就记住了。


如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区交流------尤其是你也在做 EtherCAT 主站 DC 同步的话,欢迎一起探讨拓扑扫描、电缆冗余那些坑。

相关推荐
FYKJ_20101 小时前
SSM校园互助与闲置交易平台62145-计算机课程设计、毕业设计
java·spring boot·python·mysql·架构·spark·课程设计
Dawson Zhu1 小时前
Agent上下文压缩的“状态保真“取舍框架解读
人工智能·语言模型·架构·aigc·agi
Shulex1 小时前
跨境电商智能售后中台架构演进:从浅层文本生成到业务流闭环驱动
人工智能
“AI国潮设计-小江”1 小时前
Python实战 | SDXL批量生成“带炸英歌”国潮IP:从海报到门店落地(附核心Prompt与授权思路)
开发语言·人工智能·python·prompt·aigc
归秋1421 小时前
2026企业AI办公工具选型指南:如何评估具备端到端任务交付能力的平台
大数据·人工智能
IT_陈寒1 小时前
Redis集群切换主节点时,服务竟然全员掉线
前端·人工智能·后端
铅笔小新z1 小时前
【stm32】I2C 中断模式数据收发
stm32·单片机·嵌入式硬件
打工仔折腾 AI1 小时前
多台服务器日志分散难查?用Promtail+Loki+Grafana搭建集中检索平台
服务器·人工智能·后端·python·性能优化·django·grafana
2601_962203511 小时前
需要本地或私有化部署,又担心硬件和配置成本?快鹭KuWork、OpenOcta、安捷AI、AnythingLLM四款企业级AI智能体办公平台技术对比
人工智能