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 PASSEDThe log proves there is no cross-contamination between banks: every word read back from bank 0 while bank 1 was being written equals the
patAvalue originally written there, and after the second swap every word matchespatB. Each case is checked against an independent golden array in the testbench, so a wrong bank select or a swap-timing bug would print aFAILline instead ofPASS.A more hardware-realistic variant: parameterized width and depth, with
wr_fullandrd_validflags. 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 endmodulePASS 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 PASSEDThe log proves two full fill-and-drain cycles behave correctly:
wr_fullasserts exactly afterDEPTHwrites, the auto-swap raisesrd_validon the newly-filled bank, all sixteen words drain back in the order they were written (checked against thegoldenarray), andrd_validclears once the read bank empties. Any off-by-one in the counters or a mis-timed swap would surface as aFAIL.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_selnames the write bank andrd_sel = ~wr_selis always the read bank — the two can never alias the same bank, which is what guarantees no read/write collision. Theswappulse 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_dataupdates on the edge afterrd_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
WIDTHandDEPTH(keepAW = 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 LabRun it in the LabOpen this design in the free in-browser simulator — edit, compile and see the waveform/log instantly.
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 Hi! Ask me about courses, fees, batches or discounts. ×ChipXpert AssistantAsk about courses, fees, batches and centres