EtherCAT DC 分布式时钟同步的 ModelSim 仿真:从帧模板到从站行为建模
关键词:EtherCAT、DC 分布式时钟、ESC、0x0918、0x0110 DL Status、ModelSim、Verilog testbench、行为级建模、线型拓扑
适用人群:正在做 EtherCAT 主站 FPGA 实现、需要验证 DC 同步流程的工程师
开篇:为什么 DC 同步必须靠仿真验证
EtherCAT 的 DC(Distributed Clock,分布式时钟)是精密运动控制的地基。多轴协同能做到亚微秒级同步,靠的就是每个从站 ESC 里的本地时钟被主站反复校准。
但 DC 同步有个特点:它是一个长流程状态机。一次完整的 DC 初始化要经历:
- 广播写
0x0900锁存时间戳 - 读
0x0918拿各从站接收时间戳,算 offset - 广播写 offset
- 读端口时间戳算传播延迟(pdelay / 系统延迟)
- 写系统延迟 + 时钟漂移补偿
- 周期性发 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 个从站"。
三件事做对了,环境就通了:
- 发送节拍严格对照真实以太网帧结构(74 拍)
- 应答/超时用明确的拍数建模(2 拍 / 8000 拍)
- 从站行为用 DL status 反映真实拓扑(末从站 Port3 不活跃)
其中最容易被忽视、也最容易出错的是第三点 ------末从站的 DL status 如果建模错了,主站会一直等拓扑到头,sync_done 永远出不来,你会以为是状态机有 bug,其实是 tb 的锅。
另外提醒一句:tb 里凡是依赖 DUT 内部信号的地方,都是白盒建模,属于技术债。能改成用 DUT 输出信号的尽量改------DUT 一改 tb 就废的教训,踩过一次就记住了。
如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区交流------尤其是你也在做 EtherCAT 主站 DC 同步的话,欢迎一起探讨拓扑扫描、电缆冗余那些坑。