一份 1500 行 Verilog 的六轮复查实录,以及那些读代码读不出来、必须跑仿真才现形的东西。
模块:ARM+FPGA 架构 EtherCAT 主站的命令帧执行引擎。
摘要:本文复盘一个 EtherCAT 主站命令执行引擎的设计与调试。它把 ARM 下发的非周期命令(初始化配置、CoE SDO)打包成 EtherCAT datagram 发上总线,再把 WKC 和数据捞回来。全部复杂度来自一句话:如何在不打扰周期 PDO 帧的前提下把命令帧送出去。初版用常数估算命令帧耗时,实测漂移 42~74µs 必错;新版改为实测环回耗时自适应跟踪,并直接读节拍发生器的时基做时间感知调度。经六轮复查修复 20+ 处缺陷,其中"静默数据损坏"“锁存路径漏网”"防抖处理不了振荡"三类最具代表性。文末提炼五条工程口诀。
零、先纠正一个会被所有人误解的定位
如果只读一句话就走,请读这句:
这个模块 95% 的生命周期在做初始化(Pre-Op / Safe-Op 的成百上千帧配置下发);OP 运行期在 PDO 间隙里插入偶发 SDO,只是它 5% 的工作量。
但这 5% 贡献了 90% 的设计复杂度。
这个错位是理解整个模块的钥匙,也是最容易踩的坑——很多人照着"如何在 PDO 间隙插 SDO"去理解它,结果在初始化阶段做了大量无用约束。
两种用途的约束完全不同:
| ① 初始化(主) | ② OP 内偶发 SDO(辅) | |
|---|---|---|
| 流量 | 成百上千帧(扫描、SII、FMMU/SM、PDO 映射、mailbox 轮询) | 偶尔几条 |
| 实时约束 | 无 | 不能推迟 PDO |
| PDO 竞争 | Pre-Op 规范规定不传 PDO | 严格按周期 |
| 失败后果 | 系统上不了线 | 该条失败,回退 Safe-Op 重做 |
| 时隙机制 | 形同虚设 | 核心机制 |
关键推论:Pre-Op 下 PDO 不跑 → 节拍器未排程 → 时间预算根本不参与判据 → 命令帧可以跑满总线。
于是初始化阶段得到三个"白捡"的好处:
- 不需要专门发标定帧—— 扫描的前几帧天然就是耗时测量的样本,标定在系统不知情时就完成了
- 时隙门控自动放行—— 不会有任何一帧被"时间不够"挡住
- 实测环回耗时变成可诊断量—— 可以直接读出来判断链路质量
启动时间是可估的:单帧耗时 ≈ 传输时间 + 传播延迟 + 握手开销,按千帧量级的初始化流量算,典型规模(数十从站)在几十毫秒量级。这完全可接受。
若实测远大于此(比如到了几百毫秒),瓶颈通常不在 FPGA,而在ARM 与 FPGA 之间的总线握手——每帧都要"写参 → 触发 → 轮询读结果 → 撤触发"。那时要优化的是 ARM 侧的批量准备与中断替代轮询。
一、初版为什么必错:一个不能被"算"出来的量
初版用两个常数估算命令帧耗时:
CMD_RESERVE_SDO_NS = 120000 // 120µs CMD_RESERVE_SHORT_NS = 60000 // 60µs63 从站实测42 ~ 74 µs,而且 ESC 型号、温度、链路状态都会让它漂。常数必错——这不是"精度不够",是方法论错了。
为什么不能算
一帧 EtherCAT 的环回耗时可以分解:
T_sdo = T_tx(帧长) + N × d_esc + T_proc(从站) + T_rx其中d_esc(从站转发延迟)是 ESC 芯片的硬件参数,典型 0.3 ~ 1.0 µs,且随温度漂移。63 个从站串联,d_esc变化 0.1µs 就是 6.3µs 的总偏差。你不可能在编译期知道它。
所以正确做法是测,不是算:每帧记录从"字节开始上线"到"收到匹配应答"的实际耗时。
跟踪算法用快升慢降 + max-hold:
- 标定阶段(前若干帧):纯 max-hold,只抬不降,快速收敛到真实上限
- 运行阶段:实测超过估计 →立即抬到实测+余量(一帧生效,安全优先);连续多帧富余 → 才降一步(防震荡)
为什么快升慢降?因为低估会撞 PDO(硬故障),高估只是少发一帧(软损失)。不对称的代价必须用不对称的收敛策略。
有了耗时估计之后,还差"还剩多少时间"
估计值只告诉你"发这帧要多久",还缺"现在到下一拍 PDO 还有多久"。
新版不去猜,而是直接读节拍发生器的时基。节拍发生器里本来就有两个现成的量:一个是每拍递增的当前预测时间,一个是下一拍 PDO 帧的目标时刻。两者相减就是精确的剩余时间——不依赖任何参数、不依赖从站数、不依赖周期配置。
判据只有一条新门控(文字描述,非实际代码):
当前没有 PDO 帧在线上 && 距下一拍 PDO 的时间 > (估计耗时 + 保护带) → 发 否则 → 推迟到下一周期间隙不满足就推迟;等太久就报错让 ARM 回退 Safe-Op——而不是死等。
这里有个集成陷阱值得一提:那两个时间量的性质完全不同——一个是每时钟递增的当前时间,一个是每 PDO 周期才更新一次的目标时刻。接反或接成同一个,差值恒为 0,判据恒假。而症状极具迷惑性:初始化一切正常,进 OP 后 SDO 全部失败,且没有任何报错。如果你遇到这个现象,第一嫌疑就是它。
二、静默数据损坏:NBA 与组合的一拍之差
整个调试里最怕的不是报错,是报成功但数据是错的。
初版的应答判据:
wire resp_done = res_ack_init || resp_seen;第二个操作数带 Idx 匹配门控,第一个是裸信号。看起来"至少有一半是安全的"——但这行是死代码。
为什么是死代码——精确到拍的分析
关键在于两种赋值的生效时机:
res_ack_init是组合信号,当拍有效resp_seen是非阻塞赋值(NBA),下一拍才生效
所以判据必定在res_ack_init到达的那一拍就为真,而此刻resp_seen还是 0。Idx 匹配门控根本没有机会拦截任何东西。
后果:收到别人的帧也报成功,但 WKC=0、数据域全 0。ARM 以为配置写进去了,实际没有。63 从站系统里极难复现。
修法:两层全中,且不提供降级开关
新判据改为两层强制门控(此处只描述结构,不给表达式):
- 第一层:本帧必须已真正往线上推过字节 —— 杜绝尚未发送时收到杂散应答就上报"成功"
- 第二层:Idx 必须等于本模块分配的值 —— 杜绝把别人的帧当自己的,同时把第一层也纳入 Idx 匹配
刻意不做成参数开关。因为一旦留下"设 0 退回旧行为"的口子,迟早有人在集成时被"命令批量超时"误导而关掉它,把假成功放回来。
教训:安全判据应该是强制的,不是可选的。如果有人在集成时报"命令批量超时",正确做法是去查接收侧在数据位置的采样是否可靠,而不是放宽判据。放宽只会把静默损坏放回来。
三、最阴的 BUG:门控只堵了一边
修完组合路径,我以为安全了。第三轮复查发现更狠的:
"帧已上线"这个门控只管了组合路径,没管锁存路径。
而空闲状态只存在一拍(触发信号一到就跳走),所以在"等时隙""等仲裁"这两个阶段(本帧还没发出去,门控=0):
上一帧迟到的应答脉冲 → 成功标志被锁存 上一帧迟到的超时脉冲 → 超时标志被锁存一旦进入发送态(授权上升沿使门控=1):
判据 = 门控 && (... || 成功标志=1) = 1 → 假成功! 失败判据 = 超时标志=1 → 假失败!假成功尤其危险:数据域还没收到就上报完成,ARM 读到全 0——正是费劲消灭的"静默数据损坏",从锁存路径原样复活。
修法是置位端也加门控(不只是读取端):只有本帧真正上线之后才允许锁存。
教训:门控要覆盖所有路径——组合路径和锁存路径都要管。只堵一边,BUG 会从另一边原样复活。这类 BUG 仿真里很难碰巧触发(需要精确构造"上一帧迟到"的时序),但真实总线上必然发生。
四、豁免条件的陷阱:一个"防御性设计"里藏着的真 BUG
4.1 先说清楚:这是预防性设计,不是我踩过的坑
这点必须诚实交代,否则会误导读者。
新版新增时间感知调度后,未标定时"足够发完一帧"取一个保守值(约 200µs 量级)。于是存在一个理论上的死锁:如果节拍发生器在 Pre-Op 就开始发 PDO 帧,且周期小于这个保守值——
节拍器在排程 → 距下一拍的时间恒小于保守值 → 判据恒假 → 初始化帧一条都发不出去 → 标定计数永不递增 → 标定永远无效 → 永久死锁后果不是"SDO 慢一点",而是系统上不了线——所以它值得防。
但我实测确认过:我们的节拍发生器在 Pre-Op 不发 PDO 帧(规范也这样要求)。所以这个死锁不会真实触发。
那为什么还保留这个豁免?因为它的成本是零(一个 sticky 寄存器 + 一个或项),而它挡的是"系统上不了线"这种最坏后果。这是预防性加固,不是故障复盘。
我特意说明这一点,是因为复盘文章很容易把"防御性设计"写成"踩过的坑"——读者会以为你真遇到过,实际上是你推演出来提前堵掉的。两者价值不同,应该分开讲。
4.2 但豁免条件本身有一个真 BUG
最初的豁免写法是"标定无效就放行"。而**"标定无效"这个 0 值有两个来源**:
| 来源 | PDO 帧 | 放行是否正确 |
|---|---|---|
| ① 从未标定(初始化) | 不跑 | ✅ 对 |
| ②OP 中掉站触发重标定 | 正在跑 | ❌变成盲插 |
推演一遍:OP 掉站 → 标定标记清零 → 判据退化成"只要线上没帧就发" → 命令帧跳过时间预算 → 本帧要 42µs 而 PDO 只剩 10µs →PDO 被推迟 32µs。
这与"OP 内拓扑变化后应阻塞、回退 Safe-Op 重做"的设计意图正好相反——我要的是阻塞,实际给的是放行。
修法:用一个sticky标志(复位外永不清除)区分"从未"和"曾经但失效":
- 从未标定(初始化)→ 0 → 豁免生效 → 帧可通过 ✓
- 标定过后 OP 中掉站 → 1 → 豁免失效 → 预算生效 → 阻塞 ✓
4.3 更隐蔽的一层:豁免会被"振荡"永久劫持
上面那个 sticky 标志看似完备,其实还有个后门——接触不良会让它永远停在 0。
接触不良 → 从站数反复跳变 → 重标定反复触发 → 标定永远完不成 → sticky 标志永为 0 → 首次标定豁免【永久生效】 → 判据退化成"只要线上没帧就发" → OP 内盲插 → PDO 被反复推迟等于把 4.2 刚修好的盲插 BUG 从后门放回来,而触发它的恰恰是工业现场最常见的故障。
修法:豁免条件再加一项——“链路未振荡”。两个条件同时满足才豁免。
教训:同一个标志的 0 值可能来自不同的原因,不能一概而论地豁免。要么用两个独立标志区分,要么用 sticky 标志区分"从未"和"曾经但失效"。
而豁免条件本身就是特权通道——所有特权通道都要问一句:它会不会被某个异常状态永久激活?一个"永远为真"的豁免,比没有豁免更危险。
五、拓扑指纹:防抖处理不了"振荡"
从站数被用作拓扑指纹——它一变,传播延迟项(N×d_esc)必然变,耗时估计失效,需要重新标定。
但原实现的防抖逻辑有三个根本错误:
- "上次值"只在确认变化那一拍才更新→ “是否变化"一旦为真就永久为真。计数器实际在数"变化持续了多久”,是一条纯延迟线,防抖完全失效:一个单周期毛刺也会照样触发
- 计数方向反了:原意"连续 N 拍相等才算稳定",实现却是"不相等时 +1"
- 用时钟周期计时长(若干拍 = 几微秒),与拓扑/标定的时间尺度无关
更深层:接触不良不是"拓扑变化"
| 真实拓扑变化(热插拔) | 持续振荡(接触不良) | |
|---|---|---|
| 变化后 | 会稳定在新值 | 永不稳定 |
| 正确响应 | 防抖确认 → 重标定 | 上报故障,停止重标定 |
防抖天生处理不了"永不稳定的信号"——这是设计层面的错配,不是参数没调好。
振荡的失效链尤其危险(已在 4.3 讲过):它会劫持"首次标定豁免",把盲插放回来。
三级判定
| 级别 | 机制 | 作用 |
|---|---|---|
| L1 毛刺滤波 | 新值须连续稳定若干个PDO 周期(不是时钟周期) | 滤掉单周期毛刺 |
| L2 确认变化 | 稳定达标 → 发出单周期脉冲 | 触发重标定 |
| L3 振荡检测 | 滑动窗口内变化次数超阈值 → 锁存告警 | 停止自动重标定,上报 ARM |
L3 在整窗口无变化时自愈清零,无需 ARM 干预。
注意 L1 用PDO 周期而不是时钟周期计时——因为拓扑指纹是靠总线测量得到的,只在扫描时更新,用时钟周期计会在它更新完成前就误判"已稳定"。
教训:防抖不是"延迟一下就确认",而是要识别三种不同的信号——毛刺(滤掉)、真实变化(确认)、持续振荡(锁存告警)。特别是振荡,防抖处理不了它,必须有独立的检测+锁存机制。否则振荡会触发"标定风暴",进而劫持其他豁免条件。
六、Verilog 编码里那些不报错的坑
这 20+ 个 BUG 里,有一类特别值得拿出来说:综合器不报错、仿真不报错、但功能是错的。
6.1 表达式位选不合法
wire [31:0] t_meas_cur = (dc_time_ns - t_start_ns)[31:0]; // Verilog-2001 不合法对表达式直接做位选只有 Verilog-2005 及部分工具接受。Xilinx/Intel 综合工具通常放过,但 Verilator / 严格 lint 会直接报语法错误。修法:显式引入中间量再截断。
6.2 无符号下溢成"碰巧匹配"——比永不匹配更危险
wire [9:0] wkc_auto_off = cmd_dg_len - 10'd2; // cmd_dg_len<2 时下溢10 位无符号:0-2 = 1022、1-2 = 1023→ WKC 位置 = 26+1022 = 1048。而字节计数器真能数到 1048(最长帧 1518),于是会**"碰巧匹配"某个无关字节,把它当成 WKC 上报**。
这不是"永不匹配"那种安全失败,而是静默错误。修法:钳到 0(偏移 0 是命令字节,绝不可能是 WKC,安全且可诊断)。
6.3 多驱动源——综合必炸
数据缓冲原先被两个 always 块写——ARM 经总线写入的那个块,和状态机里写 Idx 的那一行。寄存器堆型 RAM 不允许多个驱动源,综合会报 multiple driver。
修法:把"写 Idx"也变成一条组合写使能,并入唯一的那一个 always 块,用 if/else 给状态机侧更高优先级。
顺便:结果寄存器也犯过同样的错(状态机块与捕获块同时驱动),是静态检查扫赋值左侧才发现的。
6.4 参数截断:一个跑仿真才抓出来的 BUG
assign rxbuf_rd_data = (rxbuf_rd_addr < RX_BUF_BYTES[7:0]) ? rxbuf[...] : 8'h00; // ↑ 10 位参数截断成低 8 位当RX_BUF_BYTES=256时,RX_BUF_BYTES[7:0] = 8'd0→ 比较恒假 →读回永远输出 0,ARM 收不到任何接收数据。
默认参数 256 正好踩中截断边界,改小改大反而"正常"。这是"参数一改就炸 + 不报错"的典型。
这个 BUG 读了几轮代码都没发现,是跑仿真才抓出来的。
6.5 异步复位遗漏导致的 X 传播
reg [63:0] next_beat_prev; // 原先没有复位仿真中初值为 X → 拍沿检测 = X → X 传播进计数器,使超时判据彻底失效且不会报错。FPGA 上电默认 0,首拍误计一拍。
教训:Verilog 里"不报错"不等于"没问题"。下溢、多驱动、X 传播、表达式位选、参数截断……最有效的防线是严格 lint + 真的跑仿真,而不是反复读代码。
七、断言不是装饰品——别让它成为空断言
模块里有十余条 SVA 断言。其中两条让我印象深刻。
7.1 前提恒假 = 空断言(vacuous pass)
// 原写法:请求信号拉高那拍状态已变,前提恒假 (state == ST_WAIT_SLOT) && $rose(execute_req) |-> $past(slot_ok);改成状态迁移形式:
(state == ST_WAIT_SLOT) ##1 (state == ST_ARB_REQ) |-> $past(slot_ok);7.2 前提恒真但语义错 = 误报
原断言写"resp_done |-> idx_match",看似合理。但合法应答到达那一拍,Idx 模块会用同一个应答脉冲把匹配标志清零(为下一条命令做准备),而成功标志已锁存 → 完成态期间"判据为真而匹配标志为 0"。这是正常的,断言会误报。
改为约束置位时刻:成功标志上升沿时,上一拍必须"已上线且 Idx 匹配"。
教训:断言的前提条件必须真的能被满足,否则就是空断言,给了你虚假的安全感。写完断言后一定要在仿真里确认它真的触发过——没触发过的断言等于没写。
八、授权即承诺——一个看似激进实则必要的设计
有个 BUG 是这样的:在等仲裁的状态里,“已获授权但复查发现时隙不够 → 退回等时隙”。
这有两个问题:
- 残帧:授权信号为 1 时,字节就已经在线上推了;退回使请求信号掉 0 → 仲裁撤销授权 →帧被拦腰截断
- 假失败:授权那一拍已把"已上线"标志置 1,人退回而门控已开 → 迟到的应答/超时被锁存 → 假成功 / 假失败
修法:授权即承诺。
拿到授权 → 进发送态,发完 未授权(零字节上线)且时隙不够 → 才可安全退回复核只准在"零字节上线"时进行。
那"等待授权期间 PDO 抢先开始发"怎么办?——这正是保留"未授权才可复核"这条规则的原因。进入时判据成立,但等仲裁会停留一段时间,期间总线状态可能突变。这道复核是在唯一安全的时间窗内做动态检查,不是冗余。
配套的还有两条断言守护:发送态下一拍不得退回等时隙;已获授权必须提交发送。
教训:有些"安全检查"在错误的时间点执行,本身就是危险的。一旦字节上线就没有回头路——退回会产生残帧。安全检查应该在不可逆操作之前做,而不是之后。
九、写在最后:验证方法论
这 20+ 处里,有几类读代码永远读不出来:
| 类型 | 为什么读不出来 |
|---|---|
| 参数截断 | 默认参数正好踩边界,看代码觉得"没问题" |
| 振荡级联失效 | 需要跑几十个 PDO 周期才能看到状态演化 |
| 死锁 | 需要对拍推演 + 参数组合 |
| 假成功 | 需要精确构造"上一帧迟到"的注入时序 |
我写测试平台时最有价值的两个用例:
- 在"等时隙"阶段注入一个匹配的应答→ 断言不应报成功(守假成功)
- 模拟掉站后在 OP 内发命令→ 断言应被阻塞(守盲插)
最容易踩的坑:把测试平台的模型 BUG 当成设计 BUG。我遇到过仲裁器模型重复授权导致标志恒为 0,症状完全像设计有问题,查了半天是测试模型的锅。判断方法:先看模型是否满足接口契约(比如"每帧发完必须撤销授权")。
十、五条工程口诀
- 安全判据应该是强制的,不是可选的—— 留了开关,迟早有人在集成时被"批量超时"误导而关掉它
- 门控要覆盖所有路径—— 组合路径 + 锁存路径,只堵一边 BUG 会从另一边原样复活
- 防抖要识别三种信号—— 毛刺(滤掉)、真实变化(确认)、持续振荡(锁存告警)。振荡必须有独立机制,否则会劫持其他豁免条件
- 授权即承诺—— 安全检查必须在不可逆操作之前;字节上线后没有回头路
- 豁免是特权通道,要问它会不会被永久激活—— 同一个标志的 0 值可能有不同来源;一个"永远为真"的豁免比没有豁免更危险
最后补一句关于验证的:“不报错"不等于"没问题”。下溢、多驱动、X 传播、参数截断、表达式位选……每一个都能让代码"看起来对、实际错"。最有效的防线是严格 lint 加真的跑仿真,而不是反复读代码。
如果你也在做 EtherCAT 主站、或者任何硬实时的 FPGA 设计,希望这些教训能帮到你。
交流方式
如果在阅读过程中遇到问题,可以在评论区留言。共性问题会优先整理到后续文章中。
也欢迎关注(狂奔蜗牛的代码仓),我会同步更新嵌入式、FPGA 和工业以太网相关学习笔记。
本文基于一个约 1500 行 Verilog 模块的六轮复查整理而成,累计修复 20+ 处缺陷。