The listing below combines every earlier lesson: dual-port memory, binary and Gray pointers per side, one two-flop synchroniser per pointer, registered wfull and rempty, and the almost flags.
AW is the address width and must be at least 1, so the depth 2**AW is at least 2; an elaboration-time check rejects AW = 0. DW can be any width.
The ptr_sync module has two builds. The plain two-flop chain is for synthesis. SKEW = 1 is a functional old/new perturbation model with per-direction first-stage setup/hold exposure counts.
`timescale 1ns/1ps
// Functional old/new perturbation, not an analog resolution-time model.
// events counts bit/window exposures (not unique transactions or physical failures).
module meta_ff #(parameter real TCQ = 0.25, TSU = 0.3, THD = 0.15) (input logic clk, d, output logic q, output int events);
realtime t_d = -100.0, t_clk = -100.0;
initial begin q = 1'b0; events = 0; end
always @(d) begin
t_d = $realtime;
if ($realtime - t_clk < THD) begin events++; q <= #TCQ $urandom_range(0, 1); end
end
always @(posedge clk) begin
t_clk = $realtime;
if ($realtime - t_d < TSU) begin events++; q <= #TCQ $urandom_range(0, 1); end
else q <= #TCQ d;
end
endmodule
// Pointer synchroniser: two flops in the destination domain.
// SKEW = 1 is a SIMULATION model (one metastability-aware flop per bit); SKEW = 0 is the plain synthesisable circuit.
module ptr_sync #(parameter int W = 5, parameter bit SKEW = 0) (
input logic clk, rst_n,
input logic [W-1:0] d,
output logic [W-1:0] q
);
int window_hits;
if (SKEW) begin : g_meta
logic [W-1:0] q1;
int first_hits [W], second_hits [W];
always_comb begin
window_hits = 0;
for (int i = 0; i < W; i++) window_hits += first_hits[i];
end
for (genvar i = 0; i < W; i++) begin : g_bit
meta_ff f1 (.clk, .d(d[i]), .q(q1[i]), .events(first_hits[i]));
meta_ff f2 (.clk, .d(q1[i]), .q(q[i]), .events(second_hits[i]));
end
end else begin : g_plain
assign window_hits = 0;
logic [W-1:0] q1;
always_ff @(posedge clk or negedge rst_n)
if (!rst_n) begin q1 <= '0; q <= '0; end
else begin q1 <= d; q <= q1; end
end
endmodule
module async_fifo #(
parameter int DW = 8,
parameter int AW = 4, // depth = 2**AW; legal range AW >= 1 (DEPTH >= 2)
parameter bit GRAY = 1, // 0 = (wrong) binary pointers across the crossing, for the experiment
parameter bit SKEW = 0, // simulation-only skew model in the pointer synchronisers
parameter int MARGIN = 2 // legal range 0 <= MARGIN <= DEPTH
) (
input logic wclk, wrst_n, winc,
input logic [DW-1:0] wdata,
output logic wfull, walmost_full,
input logic rclk, rrst_n, rinc,
output logic [DW-1:0] rdata,
output logic rempty, ralmost_empty
);
localparam int DEPTH = 1 << AW;
if (AW < 1) begin : g_aw_check // a one-entry FIFO has no address bits, so the pointer slices below would be empty
$error("async_fifo: AW must be at least 1 (DEPTH >= 2)");
end
if (MARGIN < 0 || MARGIN > DEPTH) begin : g_margin_check
initial $fatal(1, "async_fifo: MARGIN must be between 0 and DEPTH inclusive");
end
logic [DW-1:0] mem [DEPTH];
// ---------------- write domain ----------------
logic [AW:0] wbin, wbin_next, wgray, wgray_next, wptr_out;
logic [AW:0] rptr_sync_w; // read pointer, synchronised into the write domain
logic [AW:0] rbin_in_w;
logic wfull_next;
assign wbin_next = wbin + (winc & ~wfull);
assign wgray_next = (wbin_next >> 1) ^ wbin_next;
assign wptr_out = GRAY ? wgray : wbin;
// full: next write pointer has caught up with the (synchronised) read pointer, one lap ahead
if (GRAY) begin : g_full_gray
localparam logic [AW:0] TOP2 = 3 << (AW - 1); // the two top bits set (valid down to AW = 1)
assign wfull_next = (wgray_next == (rptr_sync_w ^ TOP2)); // Gray code of (rptr + DEPTH): the two top bits inverted
end else begin : g_full_bin
assign wfull_next = (wbin_next[AW] != rptr_sync_w[AW]) && (wbin_next[AW-1:0] == rptr_sync_w[AW-1:0]);
end
always_ff @(posedge wclk or negedge wrst_n)
if (!wrst_n) begin wbin <= '0; wgray <= '0; wfull <= 1'b0; end
else begin wbin <= wbin_next; wgray <= wgray_next; wfull <= wfull_next; end
always_ff @(posedge wclk)
if (winc && !wfull) mem[wbin[AW-1:0]] <= wdata;
// ---------------- read domain ----------------
logic [AW:0] rbin, rbin_next, rgray, rgray_next, rptr_out;
logic [AW:0] wptr_sync_r; // write pointer, synchronised into the read domain
logic rempty_next;
assign rbin_next = rbin + (rinc & ~rempty);
assign rgray_next = (rbin_next >> 1) ^ rbin_next;
assign rptr_out = GRAY ? rgray : rbin;
assign rempty_next = GRAY ? (rgray_next == wptr_sync_r) : (rbin_next == wptr_sync_r);
always_ff @(posedge rclk or negedge rrst_n)
if (!rrst_n) begin rbin <= '0; rgray <= '0; rempty <= 1'b1; end
else begin rbin <= rbin_next; rgray <= rgray_next; rempty <= rempty_next; end
assign rdata = mem[rbin[AW-1:0]]; // first-word-fall-through read
// ---------------- crossings ----------------
ptr_sync #(AW + 1, SKEW) u_r2w (.clk(wclk), .rst_n(wrst_n), .d(rptr_out), .q(rptr_sync_w));
ptr_sync #(AW + 1, SKEW) u_w2r (.clk(rclk), .rst_n(rrst_n), .d(wptr_out), .q(wptr_sync_r));
// ---------------- almost flags (conservative: computed from synchronised, hence stale, pointers) ----------------
function automatic logic [AW:0] to_bin(logic [AW:0] g);
logic [AW:0] b;
b[AW] = g[AW];
for (int i = AW - 1; i >= 0; i--) b[i] = b[i+1] ^ g[i];
return b;
endfunction
logic [AW:0] r_in_w_bin, w_in_r_bin, fill_w, fill_r;
assign r_in_w_bin = GRAY ? to_bin(rptr_sync_w) : rptr_sync_w;
assign w_in_r_bin = GRAY ? to_bin(wptr_sync_r) : wptr_sync_r;
assign fill_w = wbin - r_in_w_bin; // occupancy as the write side sees it (>= true occupancy)
assign fill_r = w_in_r_bin - rbin; // occupancy as the read side sees it (<= true occupancy)
assign walmost_full = (fill_w >= DEPTH - MARGIN);
assign ralmost_empty = (fill_r <= MARGIN);
endmodule
// One experiment: a write clock, a read clock, random traffic, and a scoreboard.
module fifo_env #(parameter real WPER = 10.0, RPER = 7.3, parameter bit GRAY = 1, parameter int WPROB = 60, RPROB = 60,
parameter int MARGIN = 2, parameter bit STOP_ALMOST = 0) (
output int errors, output int transfers, output int max_fill, output bit saw_full, output bit saw_empty, output bit done
);
localparam int DW = 8, AW = 4, DEPTH = 16;
logic wclk = 0, rclk = 0, wrst_n = 0, rrst_n = 0, winc = 0, rinc = 0;
logic [DW-1:0] wdata = 0, rdata;
logic wfull, walmost_full, rempty, ralmost_empty;
async_fifo #(.DW(DW), .AW(AW), .GRAY(GRAY), .SKEW(1), .MARGIN(MARGIN)) dut (.*);
always #(WPER / 2.0) wclk = ~wclk;
always #(RPER / 2.0) rclk = ~rclk;
logic [DW-1:0] q [$];
int next = 0;
bit stopping = 0;
int writes = 0, write_cycles = 0, warning_stalls = 0;
int start_r2w, start_w2r;
wire [1:0] status_known = {wfull, rempty};
initial begin errors = 0; transfers = 0; max_fill = 0; saw_full = 0; saw_empty = 0; end
// Both resets are held long enough for both clocks to run several cycles, so the pointer
// synchronisers (which have no reset in the simulation model) are flushed of unknowns first.
localparam real RELEASE = 6.0 * ((WPER > RPER) ? WPER : RPER);
initial begin
done = 0;
fork
begin #(RELEASE); @(negedge wclk); wrst_n = 1; end
begin #(RELEASE); @(negedge rclk); rrst_n = 1; end
join
#1ps;
start_r2w = dut.u_r2w.window_hits;
start_w2r = dut.u_w2r.window_hits;
#400000ns;
stopping = 1; // next falling write edge removes all new offers
@(negedge wclk); #1ps;
begin : drain
for (int n = 0; n < 256 && !done; n++) begin
@(negedge rclk); #1ps; // rising-edge accounting and NBAs have completed
if (writes == transfers && q.size() == 0 && rempty === 1'b1) begin
done = 1;
end
end
end
if (!done) $fatal(1, "FIFO drain watchdog: writes=%0d reads=%0d queued=%0d", writes, transfers, q.size());
if ($isunknown(status_known)) errors++;
if (writes != transfers || q.size() != 0) errors++;
// Fixed 100% traffic locks to one clock phase and may never hit the window; only random traffic must sweep it.
if ((WPROB < 100 || RPROB < 100) && (dut.u_r2w.window_hits - start_r2w < 10 || dut.u_w2r.window_hits - start_w2r < 10)) errors++;
$display("%m MARGIN=%0d writes=%0d reads=%0d queued=%0d r2w_hits=%0d w2r_hits=%0d write_cycles=%0d warning_stalls=%0d accepted/cycle=%f",
MARGIN, writes, transfers, q.size(), dut.u_r2w.window_hits - start_r2w,
dut.u_w2r.window_hits - start_w2r, write_cycles, warning_stalls, real'(writes) / write_cycles);
end
// writer: pushes when allowed (never when full), data are a running counter
always @(negedge wclk) begin
winc = wrst_n && !stopping && (!STOP_ALMOST || !walmost_full) && ($urandom_range(0, 99) < WPROB);
wdata = next[DW-1:0];
end
always @(posedge wclk) if (winc && !wfull) begin
q.push_back(wdata); next++; writes++;
if (q.size() > DEPTH) errors++; // more items than the FIFO can hold: it overflowed
if (q.size() > max_fill) max_fill = q.size();
if (wfull) saw_full = 1;
end
always @(posedge wclk) if (wfull) saw_full = 1;
always @(posedge wclk) if (wrst_n && !stopping) begin
write_cycles++;
if (STOP_ALMOST && walmost_full) warning_stalls++;
end
// almost flags must never be late: if the true occupancy is within MARGIN of full / empty, the flag must be set
always @(negedge wclk) begin
#1ps;
if (wrst_n && q.size() >= DEPTH - MARGIN && !walmost_full) errors++;
end
always @(negedge rclk) begin
#1ps;
if (rrst_n && q.size() <= MARGIN && !ralmost_empty) errors++;
end
// reader: pops when not empty, checks the data and the order
always @(negedge rclk) rinc = rrst_n && !done && (stopping || ($urandom_range(0, 99) < RPROB));
always @(posedge rclk) begin
if (rempty) saw_empty = 1;
if (rinc && !rempty) begin
if (q.size() == 0) errors++; // read data that was never written: underflow
else begin
logic [DW-1:0] exp;
exp = q.pop_front();
if (rdata !== exp) errors++;
transfers++;
end
end
end
endmodule
// Directed margin endpoints and midpoint, using real resettable pointer synchronisers.
module margin_env #(parameter int MARGIN = 0) (output bit done, output int errors);
logic clk = 0, rst_n = 0, winc = 0, rinc = 0;
logic wfull, walmost_full, rempty, ralmost_empty;
logic [7:0] rdata;
always #5 clk = ~clk;
async_fifo #(.DW(8), .AW(2), .MARGIN(MARGIN)) dut (
.wclk(clk), .wrst_n(rst_n), .winc, .wdata(8'h5a), .wfull, .walmost_full,
.rclk(clk), .rrst_n(rst_n), .rinc, .rdata, .rempty, .ralmost_empty
);
task automatic check_flags(input int used);
repeat (6) @(negedge clk); // let both remote pointers settle before checking exact thresholds
#1;
if (walmost_full !== (used >= 4 - MARGIN)) errors++;
if (ralmost_empty !== (used <= MARGIN)) errors++;
if (wfull !== (used == 4) || rempty !== (used == 0)) errors++;
endtask
initial begin
done = 0; errors = 0;
repeat (6) @(negedge clk);
rst_n = 1;
check_flags(0);
for (int used = 1; used <= 4; used++) begin
@(negedge clk); winc = 1;
@(posedge clk);
@(negedge clk); winc = 0;
check_flags(used);
end
for (int used = 3; used >= 0; used--) begin
@(negedge clk); rinc = 1;
@(posedge clk);
@(negedge clk); rinc = 0;
check_flags(used);
end
done = 1;
end
endmodule
The directed margin checks are part of the regression. With DEPTH = 4 they sweep every occupancy for MARGIN = 0, 2 and 4 after pointer settling. MARGIN = 0 asserts only at full/empty; MARGIN = DEPTH keeps both almost flags asserted. Separate negative runs using margin_env #(.MARGIN(-1)) and margin_env #(.MARGIN(5)) must terminate with the parameter-contract fatal error; do not include those expected failures in the normal test top.
The regression moves 127208 words, reaching a maximum fill of 16 of 16. Each environment stops offers, drains for at most 256 read cycles and requires accepted writes = checked reads, an empty queue and asserted empty. In the random-traffic environments each direction must record at least 10 first-stage bit/window exposures after reset. The always-ready and policy environments run fixed 100 % traffic at fixed clock ratios, lock to one phase and can record zero exposures in one direction, so the threshold does not apply to them. Keep seeds, tool versions, per-direction counts and drain summaries with any reported result.
Setting GRAY = 0 sends the binary pointers across the same synchronisers (and uses a binary comparison for full and empty). This binary variant fails with errors in four experiments (26, 49, 22 and 226) while the fifth (both sides always ready) stays clean.
The waveform below is simulation output for a depth-4 FIFO using this design, written until full and then read until empty. How to read it: one slot is one cycle of wclk; rclk shows its own level at each slot.
rempty and rptr show x in the first slot because the read side is not yet reset; both settle once reset releases.
winc is high across slots 3 to 6 and wptr steps from 0 to 4; wfull rises with the fourth write in slot 6 and holds until slot 12, after the read pointer has crossed back.
rinc starts in slot 10 and rptr climbs to 4 across slots 11, 13, 15 and 17; rempty falls in slot 9, once the write pointer has crossed over, and rises again in slot 17 with the last read.
Each flag lags the far side: both are decided from the other side's synchronised pointer, so each changes a few slots after the pointer movement that causes it.
A stale read pointer (seen by the writer) is never ahead of the true read pointer, so the write side overestimates occupancy; a stale write pointer (seen by the reader) underestimates it. Both errors push the flags in the safe direction: almost-full early, almost-empty early.
Five clock-ratio experiments with skewed pointer crossings, a scoreboard and occupancy checks.
Experiment | Write clock | Read clock | Traffic | Purpose |
|---|---|---|---|---|
a | 10.0 ns | 7.3 ns | writer 70 %, reader 40 % | Writer faster: FIFO fills up |
b | 7.3 ns | 10.0 ns | writer 40 %, reader 70 % | Reader faster: FIFO runs dry |
c | 10.0 ns | 10.07 ns | 60 % / 60 % | Nearly equal clocks, slowly drifting phase |
d | 3.1 ns | 17.9 ns | 90 % / 90 % | 6 : 1 clock ratio |
e | 10.0 ns | 7.3 ns | 100 % / 100 % | Both sides always ready |
A scoreboard queue holds every word written; every read is compared with the head of the queue (order and data).
A writer that obeys wfull and a reader that obeys rempty run with random enables.
The queue size is checked against the FIFO depth (overflow) and every read must have a matching write (underflow).
The almost flags are checked for safety: whenever the true occupancy is within 2 entries of full or empty, the flag must already be set.
The experiments must really reach full and empty.
Result | Value |
|---|---|
Words transferred across the five experiments | 127,208 |
Overflow, underflow or data errors | 0 |
Maximum occupancy reached | 16 of 16 (the FIFO was truly full) |
Directed margin checks, run with Icarus Verilog 13.0 (2026-10-16): errors=0 for MARGIN = 0, 2 and 4.
Regression, run with Icarus Verilog 13.0 (2026-10-16): prints PASS with 127208 words moved and max fill 16 of 16. In the random-traffic environments each direction must record at least 10 first-stage bit/window exposures after reset; these are finite model-coverage thresholds, not a reliability target. The fixed-TCQ old/new model omits analog late resolution and correlated physical effects.
ptr_sync with SKEW = 1 is a functional old/new perturbation model. Its fixed TCQ does not model analog late resolution, characterise MTBF or prove physical safety.
Binary-pointer variant (GRAY = 0), run with Icarus Verilog 13.0 (2026-10-16): errors in four experiments (26, 49, 22 and 226); the fifth (both sides always ready) stays clean. Exact counts depend on the simulator and seed; a mutant may also fail its drain watchdog.