Testbench不只看波形:用scoreboard抓住丢包、重复和乱序
当一条流水线连续传了几百笔数据,波形里每个数都"像是对的",仍然可能少了一笔、多了一笔,或者交换了相邻两笔。更麻烦的是,最后一笔消失后,再也没有新输出触发比较,测试反而安静地结束。
这篇把验证目标落到一个可运行的小实验:用输入端确认接收的事务生成期望队列,逐笔核对输出,并用有限等待窗口检查是否全部交付。重点是检查器如何发现错误,而不是再次介绍时钟激励或FIFO结构。
一、先写清楚比较的约定
本文只讨论同一时钟域内、保持顺序、一进一出的事务流。功能约定是原样转发;"包"在演示里简化为一个二元组,分别表示序号和8位数据,没有多拍包的边界字段。模块可以改变延迟,但不能丢失、重复、改写或调换事务。
序号是实验中随数据一起传递的可观察字段,用来区分事务。若真实接口只有数据,相邻两个相同数值发生交换,单看数据就无法辨认;不能凭空给输出补一个序号,再声称查出了真实身份。
检查器维护expected队列。输入实际被接收时入队;输出实际被接收时,从队首取出期望值比较;没有期望却收到输出,也算错误。cocotb-bus的官方scoreboard实现同样支持期望队列,默认只考虑队首,并在测试收尾时检查剩余事务。官方源码
二、参考模型不能偷偷相信被测对象
最容易写错的是:driver准备发一笔,就先把它加入expected。遇到背压,这笔可能尚未被接收,甚至仍在等待。此时检查器记录的是"发送意图",不是实际输入历史。
本例只有pending存在且in_ready成立才入队。输出则只在out存在且ready成立时比较。pending未被接收就保留;有输出但接收端暂停,也不会重复计数。
参考模型这里就是恒等映射,所以直接保存输入事务。以后换成加法器、滤波器或格式转换模块,应依据功能规格生成期望结果,明确位宽、符号和截断规则,避免把DUT内部的计算过程复制一份。当两个实现共享同一个错误时,比较相等也没有说服力。
三、能直接运行的故障注入实验
保存下面完整代码为demo.py,运行python demo.py。只用Python标准库,不需要cocotb。本次实际运行环境为Python 3.12.14。
前64个逻辑周期制造随机发送间隙和接收暂停;后64周期强制继续发送、保持接收,用来排空。每个变体都重新使用相同seed,随机调用次数也固定,使外部随机选择一致。不同故障会改变占用,因此实际握手轨迹不保证完全相同。
模型每轮先处理旧输出,再接收新输入,所以没有零周期直通。缓冲容量策略是教学简化,不对应某个具体FIFO微架构。drop故意丢掉末笔;duplicate重复序号4;reorder暂存序号4,等5到来后先输出5;fixed移除这些故障分支,保持原样转发。
python
"""Transaction-level teaching model; not RTL simulation. Python 3, stdlib only."""
from collections import deque
from random import Random
SEED, N, LIMIT = 20261011, 16, 128
def simulate(mode="fixed", seed=SEED, n=N):
rng = Random(seed)
expected, pipe = deque(), deque()
held = None
pending = None
sent = received = stalls = 0
for cycle in range(LIMIT):
# First half: random source gaps and sink backpressure.
# Second half: forced source progress and ready sink, for bounded drain.
offer = rng.random() < 0.75
ready = rng.random() < 0.55
if cycle >= LIMIT // 2:
offer = ready = True
if pending is None and sent < n and offer:
pending = (sent, (sent * 37 + 11) & 255)
# Snapshot output BEFORE accepting a new input: no zero-cycle bypass.
out = pipe[0] if pipe else None
if out is not None and not ready:
stalls += 1
if out is not None and ready:
pipe.popleft()
received += 1
if not expected:
return "FAIL", f"cycle={cycle} unexpected={out}"
want = expected.popleft()
if out != want:
return "FAIL", f"cycle={cycle} expected={want} actual={out}"
# Conservative finite buffering, with room for duplicate fault injection.
in_ready = len(pipe) + int(held is not None) < 3
if pending is not None and in_ready:
tx = pending
expected.append(tx) # reference: identity, only accepted input
pending = None
sent += 1
if mode == "drop" and tx[0] == n - 1:
continue # lose final transaction: only a timeout can expose it
if mode == "reorder" and tx[0] == 4:
held = tx
continue
pipe.append(tx)
if mode == "duplicate" and tx[0] == 4:
pipe.append(tx)
if mode == "reorder" and tx[0] == 5:
pipe.append(held)
held = None
# Observe the entire bounded window, including after expected becomes empty.
if sent != n or expected:
return "FAIL", (f"timeout={LIMIT} sent={sent}/{n} received={received} "
f"pending={len(expected)}")
return "PASS", f"sent={sent} received={received} stalls={stalls} window={LIMIT}"
def verify_case(mode, seed):
status, detail = simulate(mode, seed)
required = {
"drop": ("FAIL", ("received=15 pending=1",)),
"duplicate": ("FAIL", ("expected=(5, 196) actual=(4, 159)",
"unexpected=(4, 159)")),
"reorder": ("FAIL", ("expected=(4, 159) actual=(5, 196)",)),
"fixed": ("PASS", ("sent=16 received=16",)),
}
want_status, fragments = required[mode]
if status != want_status or not any(x in detail for x in fragments):
raise RuntimeError(f"self-test failed: {mode=} {seed=} {status} {detail}")
return status, detail
def main():
print(f"seed={SEED} n={N} limit={LIMIT}")
for mode in ("drop", "duplicate", "reorder", "fixed"):
status, detail = verify_case(mode, SEED)
print(f"{mode}: {status} {detail}")
for seed in range(100):
for mode in ("drop", "duplicate", "reorder", "fixed"):
verify_case(mode, seed)
if simulate(n=0)[0] != "PASS":
raise RuntimeError("empty-stream test failed")
print("SELFTEST PASS: 400 seeded cases + 1 empty-stream case")
if __name__ == "__main__":
main()
四、读懂失败,而不是只看红绿灯
下面是上述代码实际输出,没有删改周期数:
text
seed=20261011 n=16 limit=128
drop: FAIL timeout=128 sent=16/16 received=15 pending=1
duplicate: FAIL cycle=15 expected=(5, 196) actual=(4, 159)
reorder: FAIL cycle=11 expected=(4, 159) actual=(5, 196)
fixed: PASS sent=16 received=16 stalls=24 window=128
SELFTEST PASS: 400 seeded cases + 1 empty-stream case
丢末笔时,前15次比较都能通过,最后expected还剩一项。这个例子说明:只有"收到就比较"的检查器不够,还要主动检查"该收到的是否收齐"。
重复案例中,第一次序号4合法,第二次4到来时队首已经是5,因此报错。乱序案例恰好相反:队首等4,却先收到5。日志提供首个不一致位置,方便回到波形寻找对应握手;但它不会自动证明根因。真实设计里同样的日志,也可能来自输入监视器漏采样。另一个合法诊断是unexpected:某些seed下,重复的4到达时,5还没被输入端接收,期望队列恰好为空;因此自测接受这两种重复错误报告。
脚本中的FAIL是预期捕获的故障。SELFTEST PASS表示故障变体被拒绝、正常变体通过,不能把它解读为所有变体都正确。主程序还检查seed为0至99的400个组合,以及一次空流。verify_case同时核对状态和关键错误内容,避免把"乱序变体意外卡死"当作正确捕获了乱序;发现预期之外的结果会抛出异常,以非零状态退出。
五、超时需要说明环境保证
纯随机ready理论上可能一直不出现。因此,不能随意设置一个短超时,就断言模块丢包。本实验后半程提供持续可接收的环境;对固定的16笔事务和这个简化模型,64周期排空段留有余量。128周期是该实验预算,并非任何真实IP的延迟规格。
即使expected提前清空,也继续观察到窗口末尾,避免立刻结束而漏掉稍晚出现的重复输出。有限窗口仍无法排除窗口之后的错误。工程测试应根据规格约定排空期限、最大响应延迟或结束协议,明确测试能覆盖到哪里。
固定seed便于复现,但还应保存代码版本、运行环境和失败日志。Python官方说明了随机序列复现的条件及跨版本注意事项;此处使用独立Random实例和单线程执行。Python随机数文档
六、迁移到RTL测试平台时补什么
把Python模型换成真实DUT时,保留事务比较逻辑,将输入和输出采集换成monitor。必须定义时钟边沿及采样阶段,避免读到更新前后的混合状态;本例没有模拟HDL调度区,不能直接证明采样正确。
接着补协议断言,例如暂停期间输出有效状态与数据是否保持;补复位中途打断、缓冲满空切换、连续吞吐等定向场景,并记录覆盖情况。若规格允许乱序,需依据真实ID和顺序规则重设计匹配逻辑,不能简单放宽队首比较来消除报错。
scoreboard让"每笔事务是否按约定交付"成为可执行判断,波形负责解释失败发生的过程。两者配合,排查才有明确起点。