One module, two clocks, dual-port RAM, registered Gray pointers, combinational full/empty from next Gray. This is the block you should be able to reconstruct in an interview.
module async_fifo #(
parameter WIDTH = 8,
parameter DEPTH = 16, // MUST be power of 2
parameter ADDR_W = $clog2(DEPTH)
)(
input wire wr_clk,
input wire wr_rst_n,
input wire wr_en,
input wire [WIDTH-1:0] wdata,
output wire full,
input wire rd_clk,
input wire rd_rst_n,
input wire rd_en,
output reg [WIDTH-1:0] rdata,
output wire empty
);
localparam PTR_W = ADDR_W + 1;
reg [WIDTH-1:0] mem [0:DEPTH-1];
// Pointers declared first so the 2FF can sample rgray / wgray.
reg [PTR_W-1:0] wbin, wgray, rbin, rgray;
reg [PTR_W-1:0] wq1_rgray, wq2_rgray;
reg [PTR_W-1:0] rq1_wgray, rq2_wgray;
// Registered-pointer flags (no combinational loop). The beat that fills
// the last slot is legal; full rises on the following wr_clk.
assign full = (wgray == {~wq2_rgray[PTR_W-1:PTR_W-2], wq2_rgray[PTR_W-3:0]});
assign empty = (rgray == rq2_wgray);
wire wr_fire = wr_en & ~full;
wire rd_fire = rd_en & ~empty;
wire [PTR_W-1:0] wbin_next = wbin + wr_fire;
wire [PTR_W-1:0] wgray_next = wbin_next ^ (wbin_next >> 1);
wire [PTR_W-1:0] rbin_next = rbin + rd_fire;
wire [PTR_W-1:0] rgray_next = rbin_next ^ (rbin_next >> 1);
// ---- write domain ----
always @(posedge wr_clk or negedge wr_rst_n) begin
if (!wr_rst_n) begin
wbin <= {PTR_W{1'b0}};
wgray <= {PTR_W{1'b0}};
end else begin
wbin <= wbin_next;
wgray <= wgray_next;
end
end
always @(posedge wr_clk)
if (wr_fire) mem[wbin[ADDR_W-1:0]] <= wdata;
always @(posedge wr_clk or negedge wr_rst_n) begin
if (!wr_rst_n) {wq2_rgray, wq1_rgray} <= {{PTR_W{1'b0}},{PTR_W{1'b0}}};
else {wq2_rgray, wq1_rgray} <= {wq1_rgray, /* rgray from rd domain */ rgray};
end
always @(posedge rd_clk or negedge rd_rst_n) begin
if (!rd_rst_n) begin
rbin <= {PTR_W{1'b0}};
rgray <= {PTR_W{1'b0}};
end else begin
rbin <= rbin_next;
rgray <= rgray_next;
end
end
always @(posedge rd_clk)
if (rd_fire) rdata <= mem[rbin[ADDR_W-1:0]];
always @(posedge rd_clk or negedge rd_rst_n) begin
if (!rd_rst_n) {rq2_wgray, rq1_wgray} <= {{PTR_W{1'b0}},{PTR_W{1'b0}}};
else {rq2_wgray, rq1_wgray} <= {rq1_wgray, wgray};
end
endmodule
For PTR_W == 2 there are no [PTR_W-3:0] bits — full is just wgray == ~wq2_rgray (both bits inverted). The listing assumes DEPTH >= 4 (PTR_W >= 3), which is the usual teaching and product case.
The listing reads rgray inside the write-domain 2FF and wgray inside the read-domain 2FF. Those source flops must be the registered Gray nets. The first stage of each 2FF is the metastability absorber. Do not add combo logic on that path. Some lint tools still flag the cross-module clock; the intent is a synchronizer — constrain it in SDC as a false path into FF1, or use a dedicated sync cell.
wr_fire / rd_fire never commit when the registered flag is already set.wgray_next is Gray of the next binary — converted before the flop, then registered.full / empty compare registered Gray (no combinational loop through the incrementer).wbin (the location being filled this cycle).rgray vs 2FF-synced write Gray.Icarus in the browser uses one simulation time wheel — you can still generate two clocks with different periods:
reg wr_clk=0, rd_clk=0; always #4 wr_clk = ~wr_clk; // 125 MHz always #7 rd_clk = ~rd_clk; // ~71 MHz
Each flag is a flop/combo in one domain. A waveform that shows both flags changing on a shared clock is the wrong picture for this RTL.
Fork two procedural threads. Writer: wait posedge wr_clk, if (!full) drive wr_en and increment a source counter. Reader: wait posedge rd_clk, if (!empty) pulse rd_en and check rdata against a queue one cycle later. Use different seed rates (write every cycle, read every other) until full, then drain. Pass criteria: no scoreboard mismatch, no X, full and empty both observed, blocked-write count > 0 when you hammer writes.
Do not use #0 races between the two clocks to sample flags. Sample flags with the domain that owns them: full only after wr_clk, empty only after rd_clk.
Simple dual-port: write on wr_clk, read on rd_clk, different addresses. Avoid read/write to the same address in a way the BRAM collision mode does not match your RTL (new vs old data). When occupancy is correct, the writer never uses an address the reader is popping except at the exact full/empty boundaries your flags already forbid. Still set the RAM to “no change” or “old data” consistently in the generator if you instance a primitive instead of inferred mem[].
Write binary+Gray incrementer, read binary+Gray incrementer, 2FF of rgray on wr_clk, 2FF of wgray on rd_clk, plus dual-port RAM write and read processes.
PTR_W=2 has no [PTR_W-3:0] slice. Full is wgray == ~wq2_rgray (both bits inverted). Use DEPTH>=4 for the textbook slice.
Two always blocks with different delays, e.g. always #4 wr_clk=~wr_clk; always #7 rd_clk=~rd_clk. Do not derive rd_clk from wr_clk with a divider if you are claiming to test async behavior.
Yes: that is the CDC. Lint will complain. Mark ASYNC_REG, constrain FF1, and do not add combo on that D input.