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 status1.1 sub1:四两拨千斤的 IP 替代
//-------------------------------------------------------------------------------- // 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:应答与超时模型
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 end3.1 两个数字的含义
| 数值 | 换算 | 说明 |
|---|---|---|
2拍 | 50ns | 模拟从站应答延迟(实验室短距离,几乎无延迟) |
8000拍 | 200µs | 200µs 超时门限 |
200µs 这个数字不是随便定的——它是 EtherCAT 主站判断"帧丢了"的常用门限,对应 40MHz 下 8000 个周期。
3.2 怎么测超时场景
把应答关掉即可:
// 测试超时:注释掉应答分支,只留超时分支 if (overtime_cnt >= 16'd8000) begin overtime_plus_sync <= 1'b1; resp_ack_pending <= 1'b0; end或者做成可控的:
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 时间戳基础
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:
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 仿真最常见的"跑不完"原因之一。
五、测试流程
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; end5.1 这个 fork/join 有个隐患
fork begin @(posedge sync_done); sync_done_flag = 1'b1; end begin #50_000_000; timeout_flag = 1'b1; end joinfork...join会等两条分支都结束才继续。所以即使sync_done在第 5ms 就来了,tb 也要白等到 50ms超时分支走完才继续。
这不是功能 bug,但很浪费仿真时间——32 从站的 DC 流程本来就长,每次跑都白等 45ms。
改进(ModelSim 支持join_any的话):
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 轮询:
// 兼容写法:超时计数器 + 标志轮询 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 任务
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分支里多打几个关键信号的值,方便定位:
$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 文件
# 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 位
问题:
always @(*) begin if (reg918_rec_valid) esc_recv_time = slave_time_base; else esc_recv_time = 64'd0; endreg918_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_codevlog +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 同步的话,欢迎一起探讨拓扑扫描、电缆冗余那些坑。