Project 4, Part 3: A Testbench That Enforces the Timing
The previous article built the FSM. This one verifies it, and the verification trap to avoid is this: test the controller against the idealised Section 7.1 memory, which returns correct data whenever you read it, and a controller with a real timing bug (reading a cycle early, say) passes every test. The bug would only appear against a real chip whose timing does not forgive an early read. The fix is a memory model that enforces the contract and fails loudly when the controller cheats.
A timing-accurate memory model
module timing_accurate_sram #(
parameter DATA_WIDTH = 8,
parameter DEPTH = 256,
parameter T_AA_CYCLES = 2 // matches this project's READ_WAIT_CYCLES
) (
input logic clk,
input logic ce_n, oe_n, we_n,
input logic [$clog2(DEPTH)-1:0] addr,
input logic [DATA_WIDTH-1:0] data_in,
output logic [DATA_WIDTH-1:0] data_out
);
logic [DATA_WIDTH-1:0] mem [0:DEPTH-1];
logic [$clog2(DEPTH)-1:0] addr_latched;
logic access_active = 0;
integer cycles_since_access = 0;
always_ff @(posedge clk) begin
if (!ce_n && !oe_n) begin
if (!access_active) begin
access_active <= 1'b1;
cycles_since_access <= 1;
addr_latched <= addr;
end else begin
cycles_since_access <= cycles_since_access + 1;
end
end else begin
access_active <= 1'b0;
cycles_since_access <= 0;
end
if (!ce_n && !we_n) begin
mem[addr] <= data_in; // writes rely on the controller's t_WP enforcement
end
end
always_comb begin
if (access_active && cycles_since_access >= T_AA_CYCLES)
data_out = mem[addr_latched]; // timing satisfied: real data
else
data_out = {DATA_WIDTH{1'bx}}; // timing NOT satisfied: X, deliberately
end
endmodule
The critical line is the else of the final always_comb. If the controller samples data_out before T_AA_CYCLES have elapsed since the access began, the model returns X on purpose, not a plausible wrong value that might coincidentally match, but an unmistakable X that a self-checking testbench flags immediately. That is the X-propagation behaviour from Section 8.4, deliberately engineered into a verification model rather than being an accidental bug in one.
Assembling and verifying correct operation
module tb_sram_controller;
logic clk = 0, rst_n;
logic read_req, write_req, ready;
logic [7:0] addr, write_data, read_data;
sram_controller dut (.clk(clk), .rst_n(rst_n), .addr(addr),
.read_req(read_req), .write_req(write_req),
.write_data(write_data), .read_data(read_data), .ready(ready));
timing_accurate_sram #(.T_AA_CYCLES(2)) mem (
.clk(clk), .ce_n(dut.ce_n), .oe_n(dut.oe_n), .we_n(dut.we_n),
.addr(addr), .data_in(write_data), .data_out(read_data)
);
always #5 clk = ~clk;
task automatic write_and_verify(bit [7:0] a, bit [7:0] d);
addr = a; write_data = d; write_req = 1'b1;
@(posedge clk); write_req = 1'b0;
wait (ready); @(posedge clk);
addr = a; read_req = 1'b1;
@(posedge clk); read_req = 1'b0;
wait (ready);
if (read_data === d)
$display("PASS: addr=%0d wrote %h, read back %h", a, d, read_data);
else
$display("FAIL: addr=%0d wrote %h, read back %h (X means the timing was violated)", a, d, read_data);
@(posedge clk);
endtask
initial begin
rst_n = 0; read_req = 0; write_req = 0;
repeat (3) @(posedge clk); rst_n = 1;
write_and_verify(8'd10, 8'hA5);
write_and_verify(8'd20, 8'h3C);
write_and_verify(8'd0, 8'hFF);
$finish;
end
endmodule
Note the === comparison. The Section 6.2 distinction matters directly here: === distinguishes a matching value from an X, whereas == would evaluate to X (neither true nor false) if read_data were X, silently failing to flag the violation the model was built to catch.
The test that proves the enforcement works
This is what the project has been building to: not only “does the controller work” but “would this test infrastructure catch a controller that cheated on timing”:
// A deliberately BROKEN controller variant for this one test: reads
// one cycle too early, violating T_AA_CYCLES
module sram_controller_broken_for_test (/* same ports as sram_controller */);
// Identical to sram_controller EXCEPT:
// localparam READ_WAIT_CYCLES = 1; <-- BUG: should be 2, per Part 1
endmodule
initial begin
// ... instantiate sram_controller_broken_for_test against the SAME
// timing_accurate_sram model, T_AA_CYCLES=2 ...
write_and_verify(8'd10, 8'hA5);
// Expected: this SHOULD report FAIL with read_data reading X,
// confirming the timing-accurate model catches an early read
// that an idealised memory never would
end
Running this negative test and confirming it fails as expected is arguably the most important step in the project. It is direct proof that the testbench is not passing by coincidence, that it can catch the class of bug (an early read, a timing violation) the project exists to prevent. A verification environment that cannot demonstrably catch a deliberately introduced bug is not proven to be catching anything. This technique, injecting a known bug and confirming the environment catches it, is called mutation testing or bug injection, and it is a standard way of measuring whether a verification environment is doing its job rather than merely running without complaint.
An idealised memory that always returns correct data would let a broken controller pass every test. The timing-accurate model closes that gap by returning X whenever the controller samples before the access time has elapsed, turning an invisible timing bug into a ===-checkable failure. Proving the environment itself works, by injecting a known bug and confirming it is caught, is the most convincing evidence the testing is real. That closes Project 4. In the next article, Project 5, the last of the course, moves to a standardized bus protocol, AXI4-Lite, bringing together Module 9’s interfaces and assertions with everything this project taught about respecting an external contract.