Testbench不只看波形:用scoreboard抓住丢包、重复和乱序

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让"每笔事务是否按约定交付"成为可执行判断,波形负责解释失败发生的过程。两者配合,排查才有明确起点。

相关推荐
狂奔蜗牛(bradley)2 小时前
EtherCAT 主站拓扑判定的三级防护:从去抖到振荡检测
人工智能·嵌入式硬件·fpga开发·架构
XMAIPC_Robot2 小时前
工商业储能EMS边缘计算:本地AI负荷预测,彻底摆脱云端依赖
人工智能·fpga开发·机器人·边缘计算·arm+fpga·rk3588+fpga
XMAIPC_Robot3 小时前
FPGA+ARM联合调试避坑:储能电力采样数据同步、丢包问题定位与根治方案,应用于储能EMS、微网控制器中
arm开发·人工智能·fpga开发·rk3588+fpga·rk3576+fpga
星夜离尘563 小时前
FIFO深度怎么定?用突发流量和背压算清缓存预算
缓存·fpga开发·verilog·fifo·背压
一口一口吃成大V21 小时前
Fpga中常用器件
fpga开发
Riwuarua1 天前
从近似0基础开始FPGA开发——Part.23:AXI/AXIS 数据搬运、DMA 与 DDR 缓存架构设计
缓存·fpga开发
ChipCamp1 天前
零门槛上手的RISC-V项目:picorv32
ubuntu·verilog·verilator·iverilog·gtkwave·riscv64-unknown
Jason_zhao_MR1 天前
电力数据采集核心板选型指南
linux·嵌入式硬件·fpga开发·嵌入式
长春的KAKA1 天前
Proxmark3 RDV4.01:Windows 11 下 Iceman 固件升级与完整使用指南
fpga开发