流水线 valid 对齐自检:输入气泡和中途复位怎么测
连续输入时,每拍都有结果,valid 即使提前一拍也可能不容易从几行打印中看出来。把输入改成孤立脉冲和气泡,再在事务还没走完时复位,问题就清楚得多。
本文沿着已有的 3 级乘法器文章继续做验证,但换成一个便于手算的四级算术流水线:8 位无符号输入,计算 y=(3x+5) mod 256。重点是逐拍对应关系、自检参考模型和 reset 取消在途事务的边界,不比较 Fmax,也不声称使用了 DSP 或完成上板。
1 运行环境与应该看到的结果
本次实际运行于 Windows、Vivado Simulator 2022.1,使用 SystemVerilog。时钟周期 10 ns,复位同步高有效;模块没有 ready、背压或全局 clock enable,每个非复位上升沿都会推进。
把第 4、5 节的代码保存为 02_pipeline/rtl.sv 与 02_pipeline/tb.sv,新建与 02_pipeline 同级的空目录 run_pipeline。工具加入 PATH 后,在这个目录中执行:
text
xvlog -sv ../02_pipeline/rtl.sv ../02_pipeline/tb.sv
xelab work.tb -s sim -debug typical
xsim sim -tclbatch simulate.tcl
同目录的 simulate.tcl:
tcl
open_vcd trace.vcd
log_vcd /tb/*
run all
close_vcd
quit
上述执行链与实测一致,源码路径改成了便于复制的相对路径。三个工具实际报告的版本都是 Vivado Simulator v2022.1;本次没有验证 Linux 环境。
预期有三条:有效输入在约定边沿输出;输入气泡也按同样的距离传到输出;reset 取消全部未完成事务,之后不泄漏旧 valid。原版实际日志为:
text
TEST_PASS PIPE cycles=599 assertions=1025 accepted=423 delivered=419 flushed=4 resets=6 random_valid=132 latency=3
这里 latency=3 统计的是"接受输入的边沿"到"输出更新的边沿"之间的三个完整周期。RTL 有四级寄存器,下面把计数过程展开,避免把日志数字误读成三级结构。
2 四级寄存器,为何日志里写 latency=3
结构很简单:d0 接收输入,d1 加 1,d2 乘 3,out_data 加 2。每一级都是 8 位,赋值时截断;最终与 (3x+5) mod 256 等价,不是饱和运算。
设某笔输入在第 n 个上升沿被接受:
| 上升沿之后,非阻塞赋值已完成 | 这笔输入所在位置 |
|---|---|
| n | d0=x,valid_pipe0=1 |
| n+1 | d1=(x+1) mod 256,valid_pipe1=1 |
| n+2 | d2=3(x+1) mod 256,valid_pipe2=1 |
| n+3 | out_data=(3x+5) mod 256,out_valid=1 |
因此,接受边沿与输出更新边沿之间隔了三个完整周期,经过四个寄存级。TB 在每个上升沿后 1 ns 读取结果,检查的就是表里的"沿后状态"。
如果下游也是 always_ff @(posedge clk),它在 n+3 这个沿采到的仍是更新前的输出,要到 n+4 沿才能采到这笔结果。讨论延迟时,应先说清观察位置,不能把"沿后可见"和"另一个寄存器沿上采样"混用。

图 1:从本次 pipeline_trace.csv 重绘的逐拍快照,每列取上升沿后 1 ns。灰色数据表示没有有效事务;这不是 Vivado GUI 截图。
实际记录中,cycle 5 接受 00,cycle 8 输出 05;cycle 10 接受 FF,cycle 13 输出 02。后一个结果也验证了模 256 的溢出约定:3×255+5=770,低 8 位为 02。
3 气泡是空槽,流水线仍然前进
这个接口只在 rst=0 && in_valid=1 的上升沿接受一笔输入。in_valid=0 表示当前没有新事务,但前面已经接受的事务仍要继续前移。
所以 valid 链每个非复位周期都更新:
systemverilog
valid_pipe <= {valid_pipe[2:0], in_valid};
如果把整条链放进 if(in_valid) 里面,孤立输入后面一旦都是气泡,那笔输入的 valid 就会卡在中途。本次"单笔输入后连续四个空拍"的用例正是为了让这种问题显现。
数据寄存器也会在气泡期间计算。图中某些 out_valid=0 的周期,out_data 仍在变化,这是当前实现允许的行为。下游只能在 out_valid=1 时把它当作结果;TB 不要求无效输出必须是 0 或保持不变。
但 valid 本身不能跳过检查。如果只在 out_valid=1 时比较数据,多出来的输出、丢失的输出或提前一拍的有效脉冲,都可能漏测。因此下面的 TB 每一拍都先检查 out_valid,再决定是否检查数据。
4 完整 RTL
保存为 02_pipeline/rtl.sv。两个 MUT_ 宏是故意保留的错误注入开关;正常编译不定义它们。
systemverilog
`timescale 1ns/1ps
// Four register banks; accept at edge n, output at n+3 (3 full periods).
// No backpressure: in_valid=0 inserts a bubble, not a clock stall.
module fixed_pipeline(input logic clk,rst,in_valid,
input logic [7:0] in_data,output logic out_valid,
output logic [7:0] out_data);
logic [7:0] d0,d1,d2;
logic [3:0] valid_pipe;
`ifdef MUT_VALID_EARLY
assign out_valid=valid_pipe[2]; // DELIBERATE one-edge misalignment.
`else
assign out_valid=valid_pipe[3];
`endif
always_ff @(posedge clk) begin
if(rst) begin
d0<=0; d1<=0; d2<=0; out_data<=0;
`ifdef MUT_RESET_LEAK
// DELIBERATE bug: reset with in_valid=1 fails to flush pending valids.
if(in_valid) valid_pipe<={valid_pipe[2:0],1'b0};
else valid_pipe<=0;
`else
valid_pipe<=0;
`endif
end else begin
d0<=in_data;
d1<=d0+8'd1;
d2<=d1*8'd3;
out_data<=d2+8'd2;
valid_pipe<={valid_pipe[2:0],in_valid};
end
end
endmodule
复位优先于输入。复位沿会清空数据和全部 valid,不接收同沿输入,也不兑现本来在该沿到期的结果。这里定义的是"取消在途事务",没有保存、重放或补发机制。
5 用接受日志建立参考模型
参考模型不再复制一条与 DUT 一模一样的 valid 移位链,而是记账:每接受一笔数据,就直接计算数学结果,并记录它的绝对到期周期 cycle+3。到期时应当有输出,其他周期不应冒出有效事务。
复位到来时,尚未到期或尚未交付的队列项全部取消。最终应满足:
text
accepted = delivered + flushed
pending = 0
保存为 02_pipeline/tb.sv:
systemverilog
`timescale 1ns/1ps
module tb;
logic clk=0,rst=1,in_valid=0;
logic [7:0] in_data=0;
wire out_valid;
wire [7:0] out_data;
// Independent acceptance log: absolute deadlines, no DUT-like shift model.
integer due[0:1023];
logic [7:0] expected[0:1023];
integer head=0,tail=0,cycle=0,assertions=0,accepted=0,delivered=0,flushed=0;
integer resets=0,trace_fd,random_valid=0;
logic [31:0] rng=32'h13579bdf;
always #5 clk=~clk;
fixed_pipeline dut(.*);
task automatic step(input bit reset_i,valid_i,input logic [7:0] value_i,input string label_i);
bit expected_valid;
logic [7:0] expected_data;
@(negedge clk); rst=reset_i; in_valid=valid_i; in_data=value_i;
cycle++; expected_valid=0; expected_data=0;
if(reset_i) begin
flushed+=tail-head; head=tail; resets++;
end else begin
if(valid_i) begin
due[tail]=cycle+3;
expected[tail]=(3*int'(value_i)+5)%256;
tail++; accepted++;
end
if(head<tail && due[head]==cycle) begin
expected_valid=1; expected_data=expected[head]; head++; delivered++;
end
end
@(posedge clk); #1; assertions++;
if(out_valid!==expected_valid)
$fatal(1,"CHECK_FAIL PIPE valid cycle=%0d %s got=%b expected=%b pending=%0d",
cycle,label_i,out_valid,expected_valid,tail-head);
if(expected_valid || reset_i) begin
assertions++;
if(out_data!==expected_data)
$fatal(1,"CHECK_FAIL PIPE data cycle=%0d %s got=%02h expected=%02h",
cycle,label_i,out_data,expected_data);
end
// Data is intentionally don't-care when invalid and not in reset.
$fdisplay(trace_fd,"%0d,%0d,%0d,%0d,%0d,%0d,%0d,%0d,%0d,%s",
cycle,rst,in_valid,in_data,out_valid,out_data,expected_valid,expected_data,tail-head,label_i);
endtask
task automatic drain;
repeat(4) step(0,0,8'he7,"bubble drains pipeline");
endtask
initial begin
trace_fd=$fopen("pipeline_trace.csv","w");
if(!trace_fd) $fatal(1,"CHECK_FAIL PIPE trace open");
$fdisplay(trace_fd,"cycle,rst,in_valid,in_data,out_valid,out_data,expected_valid,expected_data,pending,scenario");
repeat(4) step(1,0,0,"startup synchronous reset");
step(0,1,0,"isolated zero"); drain();
step(0,1,255,"isolated overflow FF"); drain();
for(int i=0;i<16;i++) step(0,1,i*17,"continuous values");
drain();
for(int i=0;i<20;i++) step(0,(i%2)==0,8'h80+i,"alternating valid and bubbles");
drain();
// One pending transaction: leak appears AFTER reset, not necessarily on it.
step(0,1,8'h42,"pending transaction to cancel");
step(1,1,8'hff,"midstream reset ignores valid input");
drain();
for(int i=0;i<5;i++) step(0,1,8'h20+i,"fill before reset");
step(1,0,0,"reset on a due output cancels all pending");
drain();
step(0,1,8'h7f,"resume immediately after reset drain");
drain();
for(int i=0;i<256;i++) begin
// Deterministic xorshift32, independent of simulator random seed.
rng=rng^(rng<<13); rng=rng^(rng>>17); rng=rng^(rng<<5);
random_valid+=rng[0];
step(0,rng[0],rng[15:8],"fixed-seed random valid");
end
drain();
// All arithmetic inputs at full throughput, covers modular overflow.
for(int i=0;i<256;i++) step(0,1,i,"all 256 arithmetic inputs");
drain(); assertions++;
if(head!=tail || accepted!=delivered+flushed)
$fatal(1,"CHECK_FAIL PIPE accounting accepted=%0d delivered=%0d flushed=%0d pending=%0d",
accepted,delivered,flushed,tail-head);
$fclose(trace_fd);
$display("TEST_PASS PIPE cycles=%0d assertions=%0d accepted=%0d delivered=%0d flushed=%0d resets=%0d random_valid=%0d latency=3",
cycle,assertions,accepted,delivered,flushed,resets,random_valid); $finish;
end
initial begin #20000; $fatal(1,"WATCHDOG PIPE"); end
endmodule
这里的检查是过程式比较与 $fatal,没有依赖 SVA 覆盖数据库。!== 会把意外 X/Z 也判为不一致;#1 用于在当前上升沿的非阻塞赋值更新后观察 DUT,不能搬进可综合逻辑当作延迟实现。
测试安排包含孤立输入、连续输入、交替 valid、连续四个空拍、固定种子的 256 周期 xorshift 输入,以及全部 256 个算术输入值。固定种子随机段实际有 132 个有效输入,所以再次运行能对照相同的计数和轨迹。
原版 1025 次检查由 599 次 valid 检查、419 次有效数据检查、6 次复位数据检查和 1 次最终记账检查组成。全部 256 个输入值都测过,不代表所有可能的控制时序都穷举过;这是有限的定向与确定性伪随机测试。
6 两次中途复位分别取消了什么

图 2:本次 CSV 的两段记录。红列为复位沿,蓝格为接受输入,绿格为有效输出;pending 是 TB 完成当前周期处理后的待交付数量。
第一次复位专门覆盖"rst 与 in_valid 同时为 1":
- cycle 59 接受
42,按原计划应在 cycle 62 更新输出 - cycle 60 复位,同时输入端给出
in_valid=1、in_data=FF 42被取消,FF不被接受;cycle 62 的out_valid必须为 0
只检查复位当拍还不够。错误 valid 可能先残留在中间级,复位释放两拍后才走到输出。因此 TB 在复位之后继续送空拍,检查旧事务有没有"回来"。
第二次复位发生在连续输入期间:cycle 65~69 接受 20、21、22、23、24,其中前两笔已在 cycle 68、69 输出。cycle 70 复位时,队列还有三笔,原本分别在 cycle 70、71、72 到期;本次约定把这三笔全部取消,包括刚好在复位沿到期的那一笔。
复位结束后,cycle 75 接受 7F,cycle 78 输出 82,固定延迟没有改变。两次中途复位合计取消四笔,最终 423=419+4。日志中的 resets=6 是六个被检查的复位周期,其中四个在启动阶段,两个在中途,不是六次中途故障。
这里的 delivered 统计的是 TB 在沿后观察到的有效输出。它没有模拟下游寄存器的握手或采样反馈,也没有 ready;不能把这个计数扩大成带背压系统中的交付证明。
7 两个错误注入,验证自检是否敏感
两个故障变体都使用同一份 TB,不修改期望。各自在新的空目录内编译、展开和运行,避免宏定义或旧快照混用。
第一种故意把 out_valid 接到 valid_pipe[2],比正确输出提前一级:
text
xvlog -sv -d MUT_VALID_EARLY ../02_pipeline/rtl.sv ../02_pipeline/tb.sv
xelab work.tb -s sim -debug typical
xsim sim -tclbatch simulate.tcl
实际捕获结果:
text
Fatal: CHECK_FAIL PIPE valid cycle=7 bubble drains pipeline got=1 expected=0 pending=1
cycle 5 的输入应在 cycle 8 才有效,cycle 7 提前冒出的 valid 被抓住。这个用例直接检查时间关系,不是仅比较算术结果。
第二种故意在 rst && in_valid 时继续移位 valid,而没有完整清空:
text
xvlog -sv -d MUT_RESET_LEAK ../02_pipeline/rtl.sv ../02_pipeline/tb.sv
xelab work.tb -s sim -debug typical
xsim sim -tclbatch simulate.tcl
实际捕获结果:
text
Fatal: CHECK_FAIL PIPE valid cycle=62 bubble drains pipeline got=1 expected=0 pending=0
这正好对应 cycle 59 接受、cycle 60 本应取消的事务。到 cycle 62,参考队列已空,故障实现却重新输出有效脉冲。
两个变体都成功编译、展开,然后由功能断言抓到指定错误;它们不是原版运行失败。需要注意,本机 xsim 在这两个 $fatal 之后都返回了退出码 0,所以运行脚本还检查 CHECK_FAIL 与 TEST_PASS。不能仅用进程是否返回非零来判断这种自检仿真的成败,也不能把 watchdog 当作成功抓到设计错误。
8 这组检查可以复用到哪里
这套"接受时记到期周期"的方法适合无背压、无停顿、固定边沿延迟的模块。替换算式时,应同步调整参考计算的位宽、有符号性、溢出约定和到期周期,再保留每拍 valid 检查与复位记账。
如果以后加入全局 CE、ready/valid 背压或弹性缓冲,事务可能停在某一级,按绝对时钟周期 cycle+3 判定就不再成立,需要按推进条件或握手重新建模。不能只给现有代码加一个 if(ready),就继续沿用本次固定延迟结论。
本次没有综合、DSP 资源报告、时序分析或板级测试;10 ns 是仿真激励的时钟周期,不是对器件 100 MHz 时序收敛的证明。仿真已经回答的是:在这 599 个检查周期里,气泡按约定传播,中途 reset 取消了四笔在途事务,两个故意错位的实现也确实被同一份 TB 捕获。
相关阅读
- 流水线设计 Pipeline:提升时序性能的核心技巧(附3级流水线乘法器实操):前置的流水线与数据对齐内容;本文新增控制时序自检
- AMD UG900 2022.1:Running SystemVerilog in Standalone or prj Mode:
xvlog -sv与独立运行方式 - AMD UG900 2022.1:Parsing Design Files, xvhdl and xvlog:编译工具与命令说明
- AMD UG901 2022.1:综合与 HDL 编码参考;功能仿真与实际资源映射应分别验证