超时计数器差一拍:启动、完成与到期的周期边界
“启动后等待 4 个时钟,没有完成就报超时”,看起来只需要一个计数器。但同样从 0 开始计数,有的实现第 4 个边沿超时,有的第 5 个边沿才超时;如果完成信号恰好在最后一拍到达,还可能同时报成功和失败。
解决这种问题,先给启动边沿一个名字。本文把接收start的上升沿称为t0,它之后的第 N 个上升沿称为tN。从 t0 到 tN 正好经过 N 个完整周期;若 tN 同拍收到完成信号,按完成处理。
之前的 FSM 文章把timer_done当作交通灯状态机的外部输入。这篇把计时模块本身展开:给出精确接口、完整 RTL、自检和两个差错对照,不再比较状态机的几种写法。
一、先画出 N 等于 4 的边沿表
假设没有完成信号,启动前模块为空闲。
图 1:按契约绘制的时序示意,输入在采样边沿前建立,输出表示边沿后的寄存器值。
下面列的是各上升沿处理后的寄存器值:
| 边沿 | 本次动作 | 已经过的完整周期 | elapsed | busy | timed_out |
|---|---|---|---|---|---|
| t0 | 接受start | 0 | 0 | 1 | 0 |
| t1 | 继续等待 | 1 | 1 | 1 | 0 |
| t2 | 继续等待 | 2 | 2 | 1 | 0 |
| t3 | 继续等待 | 3 | 3 | 1 | 0 |
| t4 | 到期退出 | 4 | 3,保留旧值 | 0 | 1 |
| t5 | 保持空闲 | 5 | 3,保留旧值 | 0 | 0 |
启动边沿负责记录“从现在开始等待”,不消耗一个等待周期。内部elapsed在到期时不必继续加到 4,因为外部只需要知道操作已退出和本拍的退出原因。
测试使用 10 ns 时钟,N=4 就对应从 t0 到 t4 的 40 ns。这是仿真时间轴上的关系。工程中换成 50 MHz 时钟,同一个 N=4 对应 80 ns;模块本身只计时钟周期。
二、完成、启动和到期不能靠使用者猜顺序
端口为clk/rst/start/done,输出为busy/completed/timed_out。全部输入假定已经在同一时钟域。
| 边沿前状态 | 本拍条件 | 边沿后动作 |
|---|---|---|
| 任意状态 | rst=1 | 结束当前操作,清计数与所有输出 |
| 空闲 | start=0 | 保持空闲,忽略done |
| 空闲 | start=1 | 启动新操作;即使done=1也不立即完成 |
| 忙碌 | done=1 | completed=1,清busy;优先于到期 |
| 忙碌 | done=0且达到终点 | timed_out=1,清busy |
| 忙碌 | done=0且未到终点 | 继续等待 |
start在忙碌期间始终忽略,包括当前操作退出的那个边沿。它不会重置计数,也不会顺便启动下一项操作。如果需要重新开始,应等模块进入空闲后再给start。
这一点在边界上很重要:tN 同拍start=1、done=1,当前操作只报告完成,不接受新操作。若start一直保持高,下一次空闲上升沿会自动启动另一项操作。因此,上层最好把它当作启动脉冲使用,而不是一个长期保持高的请求电平。
这里没有独立的取消端口,也没有“忙时重启”功能。done表示外部确认完成,不能悄悄复用成另一种取消原因。同步复位可以中止当前操作,但会清除整个模块状态;若系统需要区分成功、取消和超时,应增加单独的事件定义,再补同时到达时的优先级测试。
三、为什么比较 N 减 1,却在 tN 到期
时序always_ff内的条件判断使用边沿前的寄存器值;非阻塞赋值安排的更新随后才生效。
对 N=4 而言,t3 处理完后elapsed=3。到 t4 上升沿,条件看到旧值 3,满足elapsed == N-1,因此在 t4 把timed_out置 1。这正好是四个完整周期。
若计数寄存器能够表示 N,把比较改成elapsed == N,才会出现 t4 从 3 加到 4、t5 报到期的晚一拍情况。但本文 N=4 时elapsed只有两位,根本不能表示 4:直接这样改,3 加 1 会回绕到 0,终点永远比较不到,结果是永不超时。若为了另一个计数契约改终点,也必须同步检查寄存器范围。反过来,若把启动边沿也算进已等待周期,却仍沿用原终点,同样会改变输出时刻。
所以,N与N-1没有脱离上下文的正确答案。必须连同启动装载值、第一次递增边沿和输出是否寄存一起判断。OpenTitan 的定时器说明也把 tick 产生、计数和比较器分开描述;这是阅读计时逻辑时很实用的分层方法。
另一个常见误会发生在接收端。本文timed_out在 tN 边沿处理后变高,并保持一个周期。另一个同钟always_ff若在边沿上读取它,会在 tN+1 边沿读到这个高电平。不能把模块的输出生效时刻与下一级寄存器响应时刻混为一谈。testbench 的边沿后#1只是避开仿真调度竞争,不是 RTL 里的一段硬件延迟。
四、完整 RTL
保存为rtl.sv。参数 N 必须大于等于 1;N=1 时仍给计数寄存器保留一位,避免零宽声明。正常仿真不定义MUT_宏。
`timescale 1ns/1ps // N>=1. Start at t0; timeout at tN, after N complete periods. module timeout_counter #(parameter integer N=4)( input logic clk,rst,start,done, output logic busy,completed,timed_out); localparam integer W=(N<=1)?1:$clog2(N); logic [W-1:0] elapsed; `ifdef MUT_EARLY localparam integer LIMIT=(N>1)?N-2:0; `else localparam integer LIMIT=N-1; `endif initial if(N<1) $fatal(1,"N must be >= 1"); always_ff @(posedge clk) begin if(rst) begin busy<=0; completed<=0; timed_out<=0; elapsed<=0; end else begin completed<=0; timed_out<=0; if(!busy) begin if(start) begin busy<=1; elapsed<=0; end end else begin `ifdef MUT_TIMEOUT_WINS if(elapsed==LIMIT) begin busy<=0; timed_out<=1; end else if(done) begin busy<=0; completed<=1; end `else if(done) begin busy<=0; completed<=1; end else if(elapsed==LIMIT) begin busy<=0; timed_out<=1; end `endif else elapsed<=elapsed+1'b1; end end end endmodulecompleted和timed_out每个非复位边沿先安排清零,命中事件时再安排置位。这样一次操作只在退出时发出一个周期的事件,之后不会留下旧超时。
done的优先级只在忙碌分支内成立。把所有输入机械地排成rst > done > start > timeout会丢掉这个状态条件:空闲时start与done同拍,本实现会启动,忽略那个没有对应进行中操作的done。
这一版 N 是编译/展开时参数,不支持运行中改阈值;计数也不会在空闲时继续自由运行。它与 CPU 的持续时间计数器加比较寄存器属于不同接口。例如 RISC-V Machine Timer 规范定义的是比较条件触发的中断 pending,不能直接拿它的保持行为套用到本文的单周期事件输出。
五、自检从边沿期望出发
下面的 testbench 并行实例化 N=1、2、4、5,覆盖最小值、2 的幂和非 2 的幂。每次step都明确给出{busy,completed,timed_out}的期望值,不复制 DUT 内部计数器。
检查序列包括:
- 复位与其他输入同时有效,复位必须优先
- 空闲
done忽略,空闲start+done只启动 - 忙碌时持续给
start,原计时仍在 tN 到期 - 到期同拍
done+start,只报告完成 - 第一个等待边沿给出完成,之后不能冒出陈旧超时;N>1 时属于提前完成,N=1 时正好位于终点
- 退出事件下一拍清零,复位能中止进行中的操作
保存为tb.sv:
`timescale 1ns/1ps module timeout_case #(parameter integer N=4)(output bit passed); logic clk=0,rst=1,start=0,done=0; wire busy,completed,timed_out; integer checks=0; always #5 clk=~clk; timeout_counter #(.N(N)) dut(.*); task automatic step(input bit reset_i,start_i,done_i, input logic [2:0] expected, input string label_i); @(negedge clk); rst=reset_i; start=start_i; done=done_i; @(posedge clk); #1; checks++; if({busy,completed,timed_out} !== expected) $fatal(1,"CHECK_FAIL TIMEOUT N=%0d %s got=%b expected=%b",N,label_i,{busy,completed,timed_out},expected); endtask initial begin passed=0; step(1,1,1,0,"reset priority"); step(0,0,1,0,"done while idle ignored"); step(0,1,1,4,"idle start wins over idle done; t0"); for(int age=1;age<N;age++) step(0,1,0,4,"busy start ignored; before tN"); step(0,1,0,1,"exactly tN timeout; busy start ignored"); step(0,0,0,0,"one-cycle timeout pulse"); step(0,1,0,4,"restart t0"); for(int age=1;age<N;age++) step(0,0,0,4,"before boundary done"); step(0,1,1,2,"done wins at tN; busy start ignored"); step(0,0,0,0,"one-cycle completed pulse"); step(0,1,0,4,"start for early done"); step(0,0,1,2,"done at first elapsed cycle"); for(int age=0;age<N+1;age++) step(0,0,0,0,"no stale timeout"); step(0,1,0,4,"start before reset"); step(1,0,0,0,"reset aborts operation"); step(0,0,0,0,"idle after reset"); $display("CASE_PASS TIMEOUT N=%0d checks=%0d",N,checks); passed=1; end endmodule module tb; wire p1,p2,p4,p5; timeout_case #(1) c1(p1); timeout_case #(2) c2(p2); timeout_case #(4) c4(p4); timeout_case #(5) c5(p5); initial begin wait(p1 && p2 && p4 && p5); $display("TEST_PASS TIMEOUT N=1,2,4,5"); $finish; end initial begin #10000; $fatal(1,"WATCHDOG TIMEOUT"); end endmoduleN=1 是特别有价值的边界:t0 启动,紧接的 t1 就到期;如果 t1 有done,则必须完成。它会把启动、到期和完成优先级压到最短序列里,减少靠波形猜测的空间。
这组测试检查的是列出的定向事件顺序,未穷举所有任意长输入历史,也没有声称覆盖全部 N。继续参数化时,可增加最大配置和更长的起停序列,但不能把未运行的用例列为通过。
六、运行命令和结果
将两份文件放在同一目录,使用配置好 Vivado 工具 PATH 的 PowerShell:
New-Item-ItemType Directory run_original|Out-NullPush-Locationrun_original xvlog-sv..\rtl.sv..\tb.svxelab work.tb-s sim-debug typical xsim sim-runallPop-Location2026-10-13 使用 Vivado Simulator 2022.1 运行,原版编译、展开、仿真退出码均为 0,日志为:
CASE_PASS TIMEOUT N=1 checks=15 CASE_PASS TIMEOUT N=2 checks=18 CASE_PASS TIMEOUT N=4 checks=24 CASE_PASS TIMEOUT N=5 checks=27 TEST_PASS TIMEOUT N=1,2,4,5再验证测试确实能发现问题。MUT_EARLY把 N>1 的终点提前一拍;MUT_TIMEOUT_WINS把到期判断放到完成判断之前。每次使用新的编译目录,示例如下:
New-Item-ItemType Directory run_early_bug|Out-NullPush-Locationrun_early_bug xvlog-sv-d MUT_EARLY..\rtl.sv..\tb.svxelab work.tb-s sim-debug typical xsim sim-runallPop-Location第二次换新目录,并将宏换成MUT_TIMEOUT_WINS。实际日志分别捕获:
Fatal: CHECK_FAIL TIMEOUT N=2 busy start ignored; before tN got=001 expected=100 Fatal: CHECK_FAIL TIMEOUT N=1 done wins at tN; busy start ignored got=001 expected=010三位输出顺序是{busy,completed,timed_out}。第一条在应该继续忙碌时提前给出超时;第二条在应该完成时给出超时。两个故障版都先成功编译、展开,再由同一 testbench 报出功能错误。
并行参数测试中的任一$fatal会终止该次故障仿真,不能把尚未完成的其他参数算作通过。本次 xsim 在这些$fatal后仍返回进程退出码 0,自动验收必须扫描CHECK_FAIL/Fatal,并要求原版出现TEST_PASS;watchdog 或编译失败也不能算成有效负对照。
七、接入状态机前最后检查四个问题
首先,谁负责产生start?如果状态机在整个等待状态里都把它置高,模块退出后的下一个边沿可能立即再次启动。
其次,done是否真的属于当前操作?本模块只有一个进行中的操作,没有事务编号;前一个操作迟到的完成脉冲若落在下一次忙碌期间,会被当成下一次操作完成。需要避免这种情况时,上层应处理迟到响应或增加身份标记。
再次,超时输出被谁读取?边沿后产生的寄存器脉冲,到下一级同步逻辑处理之间可能还有一拍,写系统时间预算时要计入。
最后,N 个周期是否等于需要的实际时长?时钟频率改变、时钟被暂停或者输入事件跨时钟域,都会改变系统观察到的时间。本文没有运行综合、时序分析或上板测试,也没有测试这些跨域与时钟管理场景。
把启动点、结束点和同拍优先级写成表,再让自检逐项对照,差一拍问题通常就能定位到一个具体边沿,而不必在比较式里反复试改常数。