在使用SpinalHDL进行硬件设计和仿真时,我们有时会遇到一些令人困惑的“幽灵行为”:DUT 的内部逻辑看起来是正确的,日志也支持这一点,但测试断言却失败了,报告的值与预期大相径庭。这类问题往往不是DUT的逻辑错误,而是 Testbench 与DUT交互时,在时序和信号采样上出了偏差。
本文将通过一个FreeList(空闲寄存器列表)并发分配与释放的例子,探讨这类问题的根源,并提供一套调试和解决方案。
案例:FreeList的并发操作疑云
一个FreeList模块,它维护一个物理寄存器的空闲状态位图。它有两个主要操作:
- 分配(Allocate):找到第一个空闲的物理寄存器,标记为已用,并输出其索引。
- 释放(Free):将指定的物理寄存器标记为空闲。
我们设计了一个测试用例,在一个时钟周期内同时执行以下操作:
- 分配一个新的物理寄存器(假设当前p1已分配,我们期望分配p2)。
- 释放之前已分配的物理寄存器p1。
期望行为:
- 在并发操作的那个周期,由于FreeList的掩码仍然显示p1已分配,分配逻辑应该选择p2。
- 时钟边沿后,FreeList的掩码会更新:p2变为已分配,p1变为空闲。
- 测试断言应该确认确实是p2被选中进行分配。
实际问题:
测试断言失败,报告称分配的是p1,而不是期望的p2。
[Error] Expected allocation of p2, got p1
但DUT的内部日志却显示,在那个并发周期,分配逻辑确实选择了p2,释放逻辑也正确处理了p1,最终计算出的下一个周期的掩码也是正确的。
NOTE [FreeList] Allocate Condition Met: nextFreeRegsMask(2) will be False. // 选择p2分配
NOTE [FreeList] Free Condition Met: nextFreeRegsMask(1) will be True. // 释放p1
NOTE [FreeList PRE-UPDATE] freeRegsMask=fc, nextFreeRegsMask=fa // 当前掩码fc (p1占用), 下周期掩码fa (p2占用, p1空闲)
日志与断言结果的矛盾,正是典型的时序/采样问题。
问题根源:组合逻辑输出与采样时机
在 SpinalHDL 中,模块的输出可以是寄存器输出(时钟同步)或组合逻辑输出。
- 寄存器输出:其值在一个时钟周期的特定边沿(例如上升沿)之后才会更新和稳定。
- 组合逻辑输出:其值会随着其输入信号的变化而立即变化(经过一定的门延迟)。
在我们的FreeList例子中:
freeRegsMask
:是一个寄存器 (Reg),其值在时钟上升沿更新。
io.allocate.physReg
:是一个组合逻辑输出,它的值取决于当前的freeRegsMask
和io.allocate.enable
等输入。
val firstFreeIdx = OHToUInt(PriorityEncoderOH(freeRegsMask))
io.allocate.physReg := firstFreeIdx
测试代码的问题在于采样 io.allocate.physReg
的时机:
dut.io.allocate.enable #= true
dut.io.free.enable #= true
dut.io.free.physReg #= p1_previously_allocated
dut.clockDomain.waitSampling()
val allocatedPhysReg = dut.io.allocate.physReg.toInt
assert(allocatedPhysReg == 2)
如果 allocatedPhysReg
是在时钟边沿之后,并且在 freeRegsMask
更新后、io.allocate.enable
可能已被拉低或改变的情况下采样的,那么它读取到的值将是基于新状态计算出来的,而不是并发操作那个瞬间DUT的决策。
泛化解决方案:精确控制激励与采样
解决这类问题的核心原则是:在正确的时机驱动激励,并在正确的时机采样DUT的响应。
理解信号类型:
- 明确DUT的哪些输出是组合逻辑,哪些是寄存器输出。
- 组合逻辑输出对输入变化敏感,需要在其输入稳定且处于你关心的状态时采样。
- 寄存器输出则在时钟边沿后采样。
控制激励的生命周期:
- 对于一次性的操作(如分配请求),确保使能信号 (
enable
) 只在必要的周期内有效。
- 如果一个信号需要在时钟边沿前驱动,并在边沿后撤销,请精确控制。
精确采样:
- 对于组合逻辑输出:如果你想知道DUT在某个特定输入组合下的即时反应(例如,在时钟边沿到来之前,DUT根据当前状态选择哪个寄存器分配),那么必须在这些输入有效、且在时钟边沿导致状态改变之前进行采样。
- 对于寄存器输出:通常在驱动激励后的下一个
waitSampling()
之后采样,以获取更新后的状态。
修正后的测试代码策略:
dut.io.allocate.enable #= true
dut.io.free.enable #= true
dut.io.free.physReg #= p1_previously_allocated
val chosenForAllocation = dut.io.allocate.physReg.toInt
dut.clockDomain.waitSampling()
dut.io.allocate.enable #= false
dut.io.free.enable #= false
assert(chosenForAllocation == 2, "Should have chosen p2 for allocation")
val currentMask = dut.freeList.freeRegsMask.toBigInt
使用 forkStimulus
和 dut.clockDomain.onSamplings
(高级):
对于更复杂的时序控制,可以利用 forkStimulus
来创建与主线程并发的激励线程。使用 dut.clockDomain.onSamplings(N)
或 dut.clockDomain.waitRisingEdge(N)
可以更精细地控制等待特定数量的时钟周期或边沿。
sleep(0)
vs sleep(1)
(或 waitCycles(1)
):
sleep(0)
或 dut.clockDomain.waitSampling(0)
:处理当前仿真时间点的delta cycle。用于确保组合逻辑传播完成,但不推进仿真时间。
sleep(1)
(或 waitCycles(1)
/ waitNextTime(timeStep)
): 推进仿真时间。这对于确保信号在模块间有足够的时间传播,或解决仿真器调度问题(尤其是在复杂赋值如Bundle或Stream的字段赋值后)可能非常关键。如果你发现一个 sleep(0)
无法解决的问题,而 sleep(1)
可以,这通常暗示着问题与仿真时间的推进有关,而不仅仅是delta cycle内的传播。
DUT内部日志的重要性:
在DUT中添加详细的 report(L"...")
语句,打印关键信号的当前值和计算出的下一状态值,这对于判断问题出在DUT内部还是Testbench的交互至关重要。例如,在我们的案例中,PRE-UPDATE
日志显示了DUT在时钟边沿前的状态和它基于此计算出的下一状态,这帮助我们确认了DUT逻辑的正确性。
小结
编写测试时,根据信号的类型不同,选择正确的采样时机。
本文基于真实案例,内容经过人工验证可靠。文案由 Gemini 2.5 Pro 与本人共同编写。
在使用SpinalHDL进行硬件设计和仿真时,我们有时会遇到一些令人困惑的“幽灵行为”:DUT 的内部逻辑看起来是正确的,日志也支持这一点,但测试断言却失败了,报告的值与预期大相径庭。这类问题往往不是DUT的逻辑错误,而是 Testbench 与DUT交互时,在时序和信号采样上出了偏差。
本文将通过一个FreeList(空闲寄存器列表)并发分配与释放的例子,探讨这类问题的根源,并提供一套调试和解决方案。
案例:FreeList的并发操作疑云
一个FreeList模块,它维护一个物理寄存器的空闲状态位图。它有两个主要操作:
- 分配(Allocate):找到第一个空闲的物理寄存器,标记为已用,并输出其索引。
- 释放(Free):将指定的物理寄存器标记为空闲。
我们设计了一个测试用例,在一个时钟周期内同时执行以下操作:
- 分配一个新的物理寄存器(假设当前p1已分配,我们期望分配p2)。
- 释放之前已分配的物理寄存器p1。
期望行为:
- 在并发操作的那个周期,由于FreeList的掩码仍然显示p1已分配,分配逻辑应该选择p2。
- 时钟边沿后,FreeList的掩码会更新:p2变为已分配,p1变为空闲。
- 测试断言应该确认确实是p2被选中进行分配。
实际问题:
测试断言失败,报告称分配的是p1,而不是期望的p2。
[Error] Expected allocation of p2, got p1
但DUT的内部日志却显示,在那个并发周期,分配逻辑确实选择了p2,释放逻辑也正确处理了p1,最终计算出的下一个周期的掩码也是正确的。
NOTE [FreeList] Allocate Condition Met: nextFreeRegsMask(2) will be False. // 选择p2分配
NOTE [FreeList] Free Condition Met: nextFreeRegsMask(1) will be True. // 释放p1
NOTE [FreeList PRE-UPDATE] freeRegsMask=fc, nextFreeRegsMask=fa // 当前掩码fc (p1占用), 下周期掩码fa (p2占用, p1空闲)
日志与断言结果的矛盾,正是典型的时序/采样问题。
问题根源:组合逻辑输出与采样时机
在 SpinalHDL 中,模块的输出可以是寄存器输出(时钟同步)或组合逻辑输出。
- 寄存器输出:其值在一个时钟周期的特定边沿(例如上升沿)之后才会更新和稳定。
- 组合逻辑输出:其值会随着其输入信号的变化而立即变化(经过一定的门延迟)。
在我们的FreeList例子中:
freeRegsMask
:是一个寄存器 (Reg),其值在时钟上升沿更新。
io.allocate.physReg
:是一个组合逻辑输出,它的值取决于当前的freeRegsMask
和io.allocate.enable
等输入。
val firstFreeIdx = OHToUInt(PriorityEncoderOH(freeRegsMask))
io.allocate.physReg := firstFreeIdx
测试代码的问题在于采样 io.allocate.physReg
的时机:
dut.io.allocate.enable #= true
dut.io.free.enable #= true
dut.io.free.physReg #= p1_previously_allocated
dut.clockDomain.waitSampling()
val allocatedPhysReg = dut.io.allocate.physReg.toInt
assert(allocatedPhysReg == 2)
如果 allocatedPhysReg
是在时钟边沿之后,并且在 freeRegsMask
更新后、io.allocate.enable
可能已被拉低或改变的情况下采样的,那么它读取到的值将是基于新状态计算出来的,而不是并发操作那个瞬间DUT的决策。
泛化解决方案:精确控制激励与采样
解决这类问题的核心原则是:在正确的时机驱动激励,并在正确的时机采样DUT的响应。
理解信号类型:
- 明确DUT的哪些输出是组合逻辑,哪些是寄存器输出。
- 组合逻辑输出对输入变化敏感,需要在其输入稳定且处于你关心的状态时采样。
- 寄存器输出则在时钟边沿后采样。
控制激励的生命周期:
- 对于一次性的操作(如分配请求),确保使能信号 (
enable
) 只在必要的周期内有效。
- 如果一个信号需要在时钟边沿前驱动,并在边沿后撤销,请精确控制。
精确采样:
- 对于组合逻辑输出:如果你想知道DUT在某个特定输入组合下的即时反应(例如,在时钟边沿到来之前,DUT根据当前状态选择哪个寄存器分配),那么必须在这些输入有效、且在时钟边沿导致状态改变之前进行采样。
- 对于寄存器输出:通常在驱动激励后的下一个
waitSampling()
之后采样,以获取更新后的状态。
修正后的测试代码策略:
dut.io.allocate.enable #= true
dut.io.free.enable #= true
dut.io.free.physReg #= p1_previously_allocated
val chosenForAllocation = dut.io.allocate.physReg.toInt
dut.clockDomain.waitSampling()
dut.io.allocate.enable #= false
dut.io.free.enable #= false
assert(chosenForAllocation == 2, "Should have chosen p2 for allocation")
val currentMask = dut.freeList.freeRegsMask.toBigInt
使用 forkStimulus
和 dut.clockDomain.onSamplings
(高级):
对于更复杂的时序控制,可以利用 forkStimulus
来创建与主线程并发的激励线程。使用 dut.clockDomain.onSamplings(N)
或 dut.clockDomain.waitRisingEdge(N)
可以更精细地控制等待特定数量的时钟周期或边沿。
sleep(0)
vs sleep(1)
(或 waitCycles(1)
):
sleep(0)
或 dut.clockDomain.waitSampling(0)
:处理当前仿真时间点的delta cycle。用于确保组合逻辑传播完成,但不推进仿真时间。
sleep(1)
(或 waitCycles(1)
/ waitNextTime(timeStep)
): 推进仿真时间。这对于确保信号在模块间有足够的时间传播,或解决仿真器调度问题(尤其是在复杂赋值如Bundle或Stream的字段赋值后)可能非常关键。如果你发现一个 sleep(0)
无法解决的问题,而 sleep(1)
可以,这通常暗示着问题与仿真时间的推进有关,而不仅仅是delta cycle内的传播。
DUT内部日志的重要性:
在DUT中添加详细的 report(L"...")
语句,打印关键信号的当前值和计算出的下一状态值,这对于判断问题出在DUT内部还是Testbench的交互至关重要。例如,在我们的案例中,PRE-UPDATE
日志显示了DUT在时钟边沿前的状态和它基于此计算出的下一状态,这帮助我们确认了DUT逻辑的正确性。
小结
编写测试时,根据信号的类型不同,选择正确的采样时机。
本文基于真实案例,内容经过人工验证可靠。文案由 Gemini 2.5 Pro 与本人共同编写。