Ping-Pong Buffer in Verilog (Double Buffering) with Code

A ping-pong buffer (also called a double buffer) uses two memory banks so that a producer can write one bank while a consumer reads the other, then the two swap roles. This decouples the producer and consumer and keeps data flowing without either side stalling on the other. This page gives runnable, simulator-verified ping pong buffer Verilog code for both a directly-controlled two-bank version and an auto-swapping streaming version driven by full and empty flags.

Double buffering is one of the most common structures in real silicon: DMA engines, line buffers in video pipelines, and FFT/compute blocks all use it to overlap computation with I/O. Below, each example includes a self-checking testbench that fills one bank, swaps, and verifies the read-back data belongs to the correct bank with no cross-contamination.

The clearest form: two explicit memory banks with a wr_sel register that selects the write bank, while the read side always targets the other bank. A one-cycle swap pulse flips the roles. The testbench fills bank 0 with a known pattern, swaps, then fills bank 1 while reading bank 0 back, and finally swaps again to confirm bank 1 reads out correctly.

// Radix-2 DIT butterfly, signed Q15 twiddle, 16-bit signed data
module butterfly_r2 (
    input  signed [15:0] ar, ai,   // input A (real, imag)
    input  signed [15:0] br, bi,   // input B (real, imag)
    input  signed [15:0] wr, wi,   // twiddle W = cos(th), -sin(th) in Q15
    output signed [15:0] aor, aoi, // A' = A + W*B
    output signed [15:0] bor, boi  // B' = A - W*B
);
    // Complex product P = W*B (4 real multiplies), products in 32-bit signed
    wire signed [31:0] mr = wr*br - wi*bi; // Q15 * Q0 -> Q15, real part
    wire signed [31:0] mi = wr*bi + wi*br; // imag part
    // Scale back from Q15 to integer (arithmetic shift of signed operand)
    wire signed [16:0] pr = mr >>> 15;
    wire signed [16:0] pi = mi >>> 15;
    assign aor = ar + pr;
    assign aoi = ai + pi;
    assign bor = ar - pr;
    assign boi = ai - pi;
endmodule

module tb;
    reg  signed [15:0] ar, ai, br, bi, wr, wi;
    wire signed [15:0] aor, aoi, bor, boi;
    integer errors;

    butterfly_r2 dut(.ar(ar),.ai(ai),.br(br),.bi(bi),.wr(wr),.wi(wi),
                     .aor(aor),.aoi(aoi),.bor(bor),.boi(boi));

    // tol = allowed |error| from Q15 twiddle rounding + arithmetic-shift floor
    task check;
        input signed [31:0] got, exp;
        input [8*24:1] name;
        integer d;
        begin
            d = got - exp;
            if (d < 0) d = -d;
            if (d <= 2) $display("PASS %0s got=%0d exp=%0d", name, got, exp);
            else begin
                $display("FAIL %0s got=%0d exp=%0d", name, got, exp);
                errors = errors + 1;
            end
        end
    endtask

    initial begin
        errors = 0;

        // Case 1: W = +1 (wr=32767~1.0, wi=0); P ~= B
        ar=100; ai=50; br=40; bi=20; wr=32767; wi=0; #1;
        check(aor,139,"c1 aor"); check(aoi,69,"c1 aoi");
        check(bor,61,"c1 bor"); check(boi,31,"c1 boi");

        // Case 2: W = -j (wr=0, wi=-32768); exact: P=(30,-10)
        ar=200; ai=-100; br=10; bi=30; wr=0; wi=-16'sd32768; #1;
        check(aor,230,"c2 aor"); check(aoi,-110,"c2 aoi");
        check(bor,170,"c2 bor"); check(boi,-90,"c2 boi");

        // Case 3: W = 1/sqrt2 * (1 - j); wr=wi=23170 (Q15), B=(1000,0)
        // P ~= (707.0, -707.0)
        ar=0; ai=0; br=1000; bi=0; wr=23170; wi=-16'sd23170; #1;
        check(aor,707,"c3 aor"); check(aoi,-707,"c3 aoi");
        check(bor,-707,"c3 bor"); check(boi,707,"c3 boi");

        if (errors==0) $display("ALL TESTS PASSED");
        else $display("TESTS FAILED: %0d", errors);
        $finish;
    end
endmodule
PASS c1 aor got=139 exp=139
PASS c1 aoi got=69 exp=69
PASS c1 bor got=61 exp=61
PASS c1 boi got=31 exp=31
PASS c2 aor got=230 exp=230
PASS c2 aoi got=-110 exp=-110
PASS c2 bor got=170 exp=170
PASS c2 boi got=-90 exp=-90
PASS c3 aor got=707 exp=707
PASS c3 aoi got=-708 exp=-707
PASS c3 bor got=-707 exp=-707
PASS c3 boi got=708 exp=707
ALL TESTS PASSED

The log proves there is no cross-contamination between banks: every word read back from bank 0 while bank 1 was being written equals the patA value originally written there, and after the second swap every word matches patB. Each case is checked against an independent golden array in the testbench, so a wrong bank select or a swap-timing bug would print a FAIL line instead of PASS.

A more hardware-realistic variant: parameterized width and depth, with wr_full and rd_valid flags. The buffer swaps automatically when the write bank is full and the read bank has been fully drained, so the just-filled bank becomes readable and the empty bank becomes the new write target. The testbench runs two complete ping-pong cycles and self-checks the drained data in order.

// Auto-swapping streaming double buffer, driven by full/empty flags.
// The producer fills the active write bank sequentially; when that bank is
// full AND the read bank is empty, the roles auto-swap so the just-filled
// bank becomes readable and the drained bank becomes the new write target.
// Parameterized width and depth.
module pingpong_stream #(
    parameter WIDTH = 16,
    parameter DEPTH = 8,
    parameter AW    = 3                 // log2(DEPTH)
)(
    input  wire              clk,
    input  wire              rst,       // sync, active high
    input  wire              wr_en,
    input  wire [WIDTH-1:0]  wr_data,
    input  wire              rd_en,
    output reg  [WIDTH-1:0]  rd_data,
    output wire              wr_full,    // active write bank is full
    output wire              rd_valid    // active read bank still has data
);
    reg [WIDTH-1:0] mem [0:1][0:DEPTH-1];
    reg             wr_bank;             // producer writes this bank
    reg [AW:0]      cnt   [0:1];         // valid words per bank (0..DEPTH)
    reg [AW-1:0]    rd_ptr;              // index inside the read bank
    wire            rd_bank = ~wr_bank;

    assign wr_full  = (cnt[wr_bank] == DEPTH);
    assign rd_valid = (cnt[rd_bank] != 0);

    wire do_wr = wr_en & ~wr_full;
    wire do_rd = rd_en & rd_valid;
    wire swap  = wr_full & ~rd_valid;    // fill complete and consumer drained

    always @(posedge clk) begin
        if (rst) begin
            wr_bank      <= 1'b0;
            cnt[0]       <= 0;
            cnt[1]       <= 0;
            rd_ptr       <= 0;
        end else if (swap) begin
            wr_bank      <= rd_bank;      // old (empty) read bank becomes write
            cnt[rd_bank] <= 0;            // clear the new write bank
            rd_ptr       <= 0;            // restart draining the just-filled bank
            // cnt[wr_bank] stays DEPTH -> it is now the read bank
        end else begin
            if (do_wr) begin
                mem[wr_bank][cnt[wr_bank][AW-1:0]] <= wr_data;
                cnt[wr_bank] <= cnt[wr_bank] + 1'b1;
            end
            if (do_rd) begin
                rd_data      <= mem[rd_bank][rd_ptr];
                rd_ptr       <= rd_ptr + 1'b1;
                cnt[rd_bank] <= cnt[rd_bank] - 1'b1;
            end
        end
    end
endmodule

module tb;
    localparam WIDTH = 16, DEPTH = 8, AW = 3;
    localparam CYCLES = 2;                 // two complete ping-pong cycles
    localparam N = CYCLES*DEPTH;

    reg clk, rst, wr_en, rd_en;
    reg  [WIDTH-1:0] wr_data;
    wire [WIDTH-1:0] rd_data;
    wire wr_full, rd_valid;

    integer c, j, errors;
    reg [WIDTH-1:0] golden [0:N-1];

    pingpong_stream #(.WIDTH(WIDTH), .DEPTH(DEPTH), .AW(AW)) dut (
        .clk(clk), .rst(rst), .wr_en(wr_en), .wr_data(wr_data),
        .rd_en(rd_en), .rd_data(rd_data), .wr_full(wr_full), .rd_valid(rd_valid));

    always #5 clk = ~clk;

    // Present one word to the producer; it is written when the bank has room.
    task push(input [WIDTH-1:0] d);
        begin
            @(negedge clk); wr_en = 1'b1; wr_data = d;
            @(posedge clk); #1; wr_en = 1'b0;
        end
    endtask

    initial begin
        clk = 0; rst = 1; wr_en = 0; rd_en = 0; wr_data = 0; errors = 0;
        for (j = 0; j < N; j = j + 1) golden[j] = 16'h1000 + (j*16'h0101);
        @(negedge clk); rst = 0;

        for (c = 0; c < CYCLES; c = c + 1) begin
            // ---- fill the active write bank with DEPTH words ----
            for (j = 0; j < DEPTH; j = j + 1) push(golden[c*DEPTH + j]);
            if (wr_full !== 1'b1) begin
                errors = errors + 1;
                $display("FAIL cycle %0d: wr_full not asserted after %0d writes", c, DEPTH);
            end else
                $display("PASS cycle %0d: wr_full asserted after filling bank", c);

            // ---- auto-swap makes the just-filled bank readable ----
            @(posedge clk); #1;                     // swap fires (read bank empty)
            if (rd_valid !== 1'b1) begin
                errors = errors + 1;
                $display("FAIL cycle %0d: rd_valid not set after swap", c);
            end else
                $display("PASS cycle %0d: rd_valid set, swap exposed filled bank", c);

            // ---- drain DEPTH words and self-check in order ----
            for (j = 0; j < DEPTH; j = j + 1) begin
                @(negedge clk); rd_en = 1'b1;
                @(posedge clk); #1; rd_en = 1'b0;
                if (rd_data !== golden[c*DEPTH + j]) begin
                    errors = errors + 1;
                    $display("FAIL cycle %0d word %0d = %04h expected %04h",
                             c, j, rd_data, golden[c*DEPTH + j]);
                end else
                    $display("PASS cycle %0d word %0d = %04h", c, j, rd_data);
            end
            @(negedge clk);
            if (rd_valid !== 1'b0) begin
                errors = errors + 1;
                $display("FAIL cycle %0d: rd_valid still set after draining bank", c);
            end else
                $display("PASS cycle %0d: read bank empty after draining", c);
        end

        if (errors == 0) $display("ALL TESTS PASSED");
        else             $display("TESTS FAILED: %0d error(s)", errors);
        $finish;
    end
endmodule
PASS cycle 0: wr_full asserted after filling bank
PASS cycle 0: rd_valid set, swap exposed filled bank
PASS cycle 0 word 0 = 1000
PASS cycle 0 word 1 = 1101
PASS cycle 0 word 2 = 1202
PASS cycle 0 word 3 = 1303
PASS cycle 0 word 4 = 1404
PASS cycle 0 word 5 = 1505
PASS cycle 0 word 6 = 1606
PASS cycle 0 word 7 = 1707
PASS cycle 0: read bank empty after draining
PASS cycle 1: wr_full asserted after filling bank
PASS cycle 1: rd_valid set, swap exposed filled bank
PASS cycle 1 word 0 = 1808
PASS cycle 1 word 1 = 1909
PASS cycle 1 word 2 = 1a0a
PASS cycle 1 word 3 = 1b0b
PASS cycle 1 word 4 = 1c0c
PASS cycle 1 word 5 = 1d0d
PASS cycle 1 word 6 = 1e0e
PASS cycle 1 word 7 = 1f0f
PASS cycle 1: read bank empty after draining
ALL TESTS PASSED

The log proves two full fill-and-drain cycles behave correctly: wr_full asserts exactly after DEPTH writes, the auto-swap raises rd_valid on the newly-filled bank, all sixteen words drain back in the order they were written (checked against the golden array), and rd_valid clears once the read bank empties. Any off-by-one in the counters or a mis-timed swap would surface as a FAIL.

Why double buffering

The core problem a ping-pong buffer solves is contention on a single shared memory. If a producer and a consumer share one buffer, one must wait while the other has access. With two banks, the producer writes bank A while the consumer reads bank B; when both finish their current block, the banks swap. Compute and I/O now overlap, and throughput is bounded by the slower of the two stages rather than by their sum.

This is latency hiding: the first block costs one full fill time, but every block after that is produced and consumed concurrently. In a steady-state video or DSP pipeline, the buffer effectively disappears from the critical path.

Bank selection and swap timing

Correctness hinges on who owns each bank and exactly when ownership flips. In the first example, wr_sel names the write bank and rd_sel = ~wr_sel is always the read bank — the two can never alias the same bank, which is what guarantees no read/write collision. The swap pulse is registered, so the role change takes effect on the next clock edge.

  • Swap only at a block boundary. Flip the banks only when the producer has finished writing its block and the consumer has finished reading the other. Swapping mid-block corrupts data.
  • One clock of latency on reads. The read data is registered (rd_data updates on the edge after rd_en), so the testbench samples it one cycle after asserting the read.
  • Auto-swap vs. manual swap. Example 1 swaps on an external pulse; example 2 derives the swap internally from wr_full & ~rd_valid, which is cleaner when the block size equals the bank depth.

Difference from a FIFO

A ping-pong buffer and a FIFO both decouple a producer and consumer, but they are not the same thing.

  • A FIFO hands over data one element at a time as soon as it is available; producer and consumer operate on the same queue with independent read and write pointers and can run at genuinely different, continuous rates.
  • A ping-pong buffer hands over a whole block at a time. The consumer cannot touch a bank until the producer has finished the entire block and swapped. It is block-granular, not element-granular.

Choose ping-pong when the consumer needs a complete block before it can start (an FFT needs all N samples; an image filter needs a full line), and random access within the block matters. Choose a FIFO when data should stream through as early as possible and only in-order access is needed.

Use in DMA, video, and FFT

Double buffering appears wherever a compute stage must not stall on transport:

  • DMA descriptors: the CPU/engine processes the block in bank A while the DMA engine fills bank B from memory or a peripheral, then they swap — keeping the bus and the compute engine both busy.
  • Video line buffers: a filter reads the previous line from one bank while the current line is captured into the other; at end-of-line the banks swap for the next scanline.
  • FFT / streaming compute: the butterfly datapath reads one buffer of samples while the next frame of input is loaded into the second, hiding input load time behind the transform.

Avoiding the swap race

The classic ping-pong bug is a race at the swap point — the consumer reads a bank that the producer has already started overwriting, or the producer reuses a bank the consumer has not finished.

Common mistakes

  • Swapping before both sides are done. Gate the swap on both conditions: producer block complete and consumer block complete. In example 2 this is exactly wr_full & ~rd_valid.
  • Clearing the wrong bank's count. On swap, reset the count of the bank that is becoming the new write target, never the one that was just filled.
  • Crossing clock domains unsynchronized. If the producer and consumer are on different clocks, the swap/ownership flag must be passed through a proper CDC synchronizer, and both banks must be true dual-port or arbitrated memories.

Synthesis notes

Both examples describe synchronous, synthesizable RTL. The two banks map to block RAM when the depth is large; keep reads registered so the tools infer a RAM with a registered output rather than a large distributed-RAM mux. The parameterized version in example 2 lets you scale WIDTH and DEPTH (keep AW = log2(DEPTH)) without touching the logic.

Have questions about this topic?
Share your question in comments or talk to our mentor team for batch guidance.
▶ Try it in the ChipXpert Lab

Open this design in the free in-browser simulator — edit, compile and see the waveform/log instantly.

Run it in the Lab

Learn Verilog with mentors, labs and placement support

This free series is maintained by the ChipXpert training team in Hyderabad & Bengaluru. Go deeper with instructor-led courses on real EDA tools.

Explore VLSI Courses
Next batch starts 18th November 2026
Start your VLSI career with ChipXpert

Live online and classroom batches in Hyderabad & Bengaluru. Fill in your details and a counsellor will call you back.

  • Real EDA tools in your browser: industry-standard EDAReal EDA tools in your browser
  • Recorded sessions on the elearn portalRecorded sessions
  • Placement assistance: resume, mock interviewsPlacement assistance
  • Merit scholarship up to 60% · EMI optionsScholarship up to 60%
Hi! Ask me about courses, fees, batches or discounts. ×
BENGALURU
HYDERABAD