Verilog · lesson 13

Common RTL Building Blocks

An edge detector, a synchronizer, a register file, a small ALU, and a synchronous FIFO -- five blocks common in real RTL, each verified against a real testbench.

14 min read · Free, no login

Lesson 7 already built two of the most common RTL building blocks โ€” a counter and a shift register. This lesson covers five more, each one genuinely common in real designs and in interviews, each built from templates this course has already established (lesson 7's clocked register, lesson 10's self-checking testbench), and each verified against a real simulation rather than asserted to work.

Edge detector

A level signal that changes rarely sometimes needs to trigger something for exactly one clock cycle at the moment it changes, not for as long as it stays changed. An edge detector does this by comparing a signal to a one-cycle-delayed copy of itself.

edge_detector.v
module edge_detector (
    input  wire clk,
    input  wire rst_n,
    input  wire sig_in,
    output wire rise_pulse
);
    reg sig_d;
    always @(posedge clk or negedge rst_n)
        if (!rst_n) sig_d <= 1'b0;
        else        sig_d <= sig_in;
    assign rise_pulse = sig_in & ~sig_d;
endmodule
just before the next posedge: sig_in=1 rise_pulse=1
just after that posedge: sig_in=1 rise_pulse=0 (sig_d caught up, pulse gone)

sig_d holds whatever sig_in was as of the last clock edge. The instant sig_in rises, rise_pulse = sig_in & ~sig_d goes high immediately (both are plain combinational signals), and it stays high only until the next clock edge updates sig_d to match โ€” a pulse exactly as wide as the time between the signal's transition and the following clock edge, which is the standard, correct shape for this circuit. Swapping the sense of the final assign to ~sig_in & sig_d detects a falling edge instead, by the identical logic.

2-flop synchronizer

A signal that originates outside the current clock domain โ€” from a different clock, or from the outside world entirely โ€” can change at any instant relative to the local clock edge, which risks violating setup or hold time and driving a flip-flop metastable, exactly the failure mode the Digital Electronics path's fifth lesson describes. The standard mitigation is to pass the signal through two flip-flops in series before using it anywhere else in the design.

sync2ff.v
module sync2ff (
    input  wire clk,
    input  wire rst_n,
    input  wire async_in,
    output wire sync_out
);
    reg stage1, stage2;
    always @(posedge clk or negedge rst_n)
        if (!rst_n) {stage2, stage1} <= 2'b00;
        else        {stage2, stage1} <= {stage1, async_in};
    assign sync_out = stage2;
endmodule
async_in just changed, sync_out=0 (not through yet)
one clock later, sync_out=0 (stage1 caught it, sync_out still old)
two clocks later, sync_out=1 (now fully synchronized)

Changing async_in deliberately mid-cycle, not aligned to any clock edge, takes exactly two clock cycles to reach sync_out โ€” the first flop may itself go metastable if the timing is unlucky, but it has a full clock period to resolve to a clean 0 or 1 before the second flop samples it, which is the entire point of the second stage. It is worth being precise about what this circuit does and does not solve: it makes a single-bit signal safe to bring into a new clock domain. It is not a general solution for moving a multi-bit bus between clock domains, since the individual bits of a bus are not guaranteed to all resolve on the same cycle โ€” that problem needs a different structure (commonly an asynchronous FIFO with a gray-coded pointer crossing between the two clock domains, a different and more involved design than the single-clock-domain FIFO this lesson builds) and is outside this course's scope.

Dual-read register file

A register file is a small, addressable memory read and written entirely inside the design โ€” the structure that holds a processor's working registers, among other things. This version has one write port and two independent read ports, a very common shape since an instruction frequently needs to read two source registers in the same cycle.

regfile.v
module regfile #(
    parameter WIDTH = 8,
    parameter DEPTH = 16
) (
    input  wire                      clk,
    input  wire                      wr_en,
    input  wire [$clog2(DEPTH)-1:0]  wr_addr,
    input  wire [WIDTH-1:0]          wr_data,
    input  wire [$clog2(DEPTH)-1:0]  rd_addr0,
    input  wire [$clog2(DEPTH)-1:0]  rd_addr1,
    output wire [WIDTH-1:0]          rd_data0,
    output wire [WIDTH-1:0]          rd_data1
);
    reg [WIDTH-1:0] mem [0:DEPTH-1];

    always @(posedge clk)
        if (wr_en) mem[wr_addr] <= wr_data;

    assign rd_data0 = mem[rd_addr0];
    assign rd_data1 = mem[rd_addr1];
endmodule
read port 0 (addr 5) = ab, read port 1 (addr 9) = cd  (simultaneous, independent reads)

This uses lesson 12's $clog2 to size each address port directly from DEPTH, and a reg [WIDTH-1:0] mem [0:DEPTH-1] array โ€” Verilog's standard memory declaration, a vector of vectors, indexed with mem[address]. The write port is clocked and gated by wr_en, following lesson 7's register template exactly; both read ports are plain combinational assign statements, so a read reflects whatever was most recently written to that address with no clock latency at all. Writing two different values to two different addresses and reading both back on the same cycle confirms the two read ports are genuinely independent, not sharing any hardware that would force one to wait for the other.

A small ALU

An arithmetic-logic unit selects one of several operations based on a control code โ€” the combinational core of a processor's datapath, and a near-universal interview exercise in miniature.

alu4.v
module alu4 (
    input  wire [3:0] a,
    input  wire [3:0] b,
    input  wire [1:0] op,       // 00=add 01=sub 10=and 11=or
    output reg  [3:0] result,
    output wire        zero
);
    localparam ADD = 2'b00, SUB = 2'b01, AND_OP = 2'b10, OR_OP = 2'b11;
    always @* begin
        case (op)
            ADD:     result = a + b;
            SUB:     result = a - b;
            AND_OP:  result = a & b;
            OR_OP:   result = a | b;
            default: result = 4'b0;
        endcase
    end
    assign zero = (result == 4'b0);
endmodule
5 + 3   =  8  zero=0
5 - 5   =  0  zero=1  (zero flag correctly set)
C & A   = 8
C | A   = e

This is lesson 5 and lesson 6's combinational case template exactly โ€” always @*, blocking assignment, a default branch so the case is complete and no latch is inferred. The zero flag, a plain continuous assign reading result, is the kind of status flag a real ALU almost always exposes alongside the arithmetic result itself, and the subtraction case above confirms it sets correctly the moment the result is genuinely zero.

Synchronous FIFO

A FIFO (first-in, first-out queue) is the single most-asked "build something real" interview request in this whole course's scope, because it exercises nearly everything taught so far at once: a parameterized memory, two independent pointers, and full/empty flag logic that has to be exactly right or the whole structure silently corrupts data.

sync_fifo.v
module sync_fifo #(
    parameter WIDTH = 8,
    parameter DEPTH = 4                      // must be a power of 2
) (
    input  wire             clk,
    input  wire             rst_n,
    input  wire             wr_en,
    input  wire [WIDTH-1:0] wr_data,
    input  wire             rd_en,
    output wire [WIDTH-1:0] rd_data,
    output wire             full,
    output wire             empty
);
    localparam AW = $clog2(DEPTH);
    reg [WIDTH-1:0] mem [0:DEPTH-1];
    reg [AW:0] wr_ptr, rd_ptr;   // one extra bit: distinguishes full from empty

    wire wr_fire = wr_en & ~full;
    wire rd_fire = rd_en & ~empty;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            wr_ptr <= 0;
            rd_ptr <= 0;
        end else begin
            if (wr_fire) begin
                mem[wr_ptr[AW-1:0]] <= wr_data;
                wr_ptr <= wr_ptr + 1'b1;
            end
            if (rd_fire)
                rd_ptr <= rd_ptr + 1'b1;
        end
    end

    assign rd_data = mem[rd_ptr[AW-1:0]];
    assign empty = (wr_ptr == rd_ptr);
    assign full  = (wr_ptr[AW-1:0] == rd_ptr[AW-1:0]) && (wr_ptr[AW] != rd_ptr[AW]);
endmodule

The classic trick in this design is the pointers' extra bit: wr_ptr and rd_ptr are each one bit wider than the memory actually needs for addressing (AW bits would address DEPTH locations on their own). empty is true when the two pointers are exactly equal, extra bit included. full is true when their lower AW bits (the part that actually addresses memory) are equal but their extra top bits differ โ€” meaning the write pointer has wrapped around the memory exactly one more time than the read pointer. Without that extra bit, "the two pointers point at the same memory location" would be genuinely ambiguous between completely empty and completely full, since both conditions look identical using only the address bits.

after reset: empty=1 full=0
after 4 pushes into a DEPTH=4 fifo: full=1 empty=0
attempted push while full -- wr_ptr ignored it (wr_fire gated by ~full)
PASS: popped 11 as expected
PASS: popped 22 as expected
PASS: popped 33 as expected
PASS: popped 44 as expected
after popping all 4: empty=1
ALL FIFO TESTS PASSED

This run exercises the whole lifecycle with a self-checking testbench in lesson 10's style: starts empty, fills to exactly full after four pushes into a four-entry FIFO, correctly refuses a fifth push while full (wr_fire's ~full gate prevents wr_ptr from advancing and silently overwriting unread data), and pops all four values back out in the exact order they were pushed, ending empty again. First-in-first-out ordering, overflow protection, and the full/empty flags are the three things any FIFO review โ€” in an interview or in a real design โ€” checks first, and all three are confirmed here against a real simulation.

Interview questions

Why does a 2-flop synchronizer use two flip-flops instead of one? A single flop can go metastable if the asynchronous input changes too close to the clock edge, and metastability needs time to resolve. The second flop gives the first flop a full clock period to settle to a clean value before that value is used anywhere else, at the cost of two cycles of latency instead of one.

Why does the synchronous FIFO's pointer need to be one bit wider than the memory's address width? Using only the address-width bits, "write pointer equals read pointer" is ambiguous between completely empty and completely full. The extra bit tracks how many times each pointer has wrapped around the memory, so empty (pointers fully equal, extra bit included) and full (same address, but the write pointer has wrapped one more time) become distinguishable.

What does gating a FIFO's write enable with ~full actually protect against? Without that gate, a write arriving while the FIFO is already full would advance the write pointer anyway, silently overwriting the oldest unread entry before it has been popped โ€” corrupting the queue's contents with no indication anything went wrong. Gating the write with ~full makes an overflow attempt a safe no-op instead.

Key takeaways

  • An edge detector compares a signal to a one-cycle-delayed copy of itself; the resulting pulse is exactly as wide as the gap between the signal's transition and the next clock edge.
  • A 2-flop synchronizer makes a single asynchronous bit safe to use in a new clock domain at the cost of two cycles of latency โ€” it is not, by itself, a safe way to move a multi-bit bus between clock domains.
  • A dual-read register file pairs lesson 7's clocked write with two independent combinational reads, confirmed here to return two different, correctly written values on the same cycle.
  • A small ALU is lesson 6's combinational case template with a status flag (zero) added as a plain continuous assignment.
  • A synchronous FIFO's pointers need one extra bit beyond the memory's address width specifically to make full and empty distinguishable โ€” verified here with a complete fill-and-drain cycle and a correctly refused overflow attempt.