AXI-Stream in Verilog: TVALID/TREADY/TLAST with Code

AXI4-Stream is the point-to-point dataflow interface used everywhere in modern FPGA and SoC design: DMA engines, video pipelines, FFTs, Ethernet MACs and AI accelerators all move data as an AXI-Stream. This page gives you working, simulator-verified AXI-Stream Verilog code for the two building blocks every stream design needs — a packet source (master) and a register slice (skid buffer) — and explains the one rule that governs every transfer.

The entire protocol reduces to a single sentence: a data beat moves on a rising clock edge only when tvalid and tready are both high. tdata carries the payload and tlast marks the final beat of a packet. Get that handshake right and back-pressure, flow control and packetization all fall into place.

A stream master emits a fixed packet of N beats (values 0x10, 0x11, …), asserting tlast on the final beat, into a clocked sink that applies a repeating back-pressure pattern on tready. The self-checking testbench records every accepted beat and proves each one arrives exactly once, in order, that tlast lands only on the last beat, and that tdata stays stable — with nothing consumed — while tready is low.

// AXI4-Stream master emits a fixed packet of N beats; sink applies back-pressure.
// A beat transfers only at a rising clock edge when tvalid && tready are both high.
module axis_master #(
    parameter DW = 8,
    parameter N  = 6
)(
    input  wire          clk,
    input  wire          rst_n,
    output reg  [DW-1:0] m_tdata,
    output reg           m_tvalid,
    output reg           m_tlast,
    input  wire          m_tready
);
    reg [31:0] idx;
    // Payload/flags are a pure function of the current beat index.
    // tvalid is NOT gated by tready (avoids handshake deadlock).
    always @(*) begin
        m_tdata  = idx[DW-1:0] + 8'h10; // beat k carries value 0x10 + k
        m_tvalid = (idx < N);
        m_tlast  = (idx == (N-1));
    end
    // Advance to the next beat only when the current beat is accepted.
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            idx <= 32'd0;
        else if (m_tvalid && m_tready)
            idx <= idx + 32'd1;
    end
endmodule

// Clocked sink: records every accepted beat so the testbench can audit order.
module axis_sink #(
    parameter DW = 8,
    parameter N  = 6
)(
    input  wire          clk,
    input  wire          rst_n,
    input  wire [DW-1:0] s_tdata,
    input  wire          s_tvalid,
    input  wire          s_tlast,
    input  wire          s_tready,
    output reg  [31:0]   count,
    output reg           last_ok,     // tlast seen exactly on final beat
    output reg           last_err     // tlast seen on a non-final beat
);
    reg [DW-1:0] mem [0:N-1];   // Verilog-2005: arrays cannot be ports; keep internal
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            count    <= 32'd0;
            last_ok  <= 1'b0;
            last_err <= 1'b0;
        end else if (s_tvalid && s_tready) begin
            mem[count[2:0]] <= s_tdata;
            if (s_tlast && (count == (N-1))) last_ok  <= 1'b1;
            if (s_tlast && (count != (N-1))) last_err <= 1'b1;
            count <= count + 32'd1;
        end
    end
endmodule

module tb;
    localparam DW = 8;
    localparam N  = 6;

    reg clk, rst_n, tready;
    wire [DW-1:0] tdata;
    wire tvalid, tlast;

    wire [31:0]   count;
    wire last_ok, last_err;

    axis_master #(.DW(DW), .N(N)) m (
        .clk(clk), .rst_n(rst_n),
        .m_tdata(tdata), .m_tvalid(tvalid), .m_tlast(tlast), .m_tready(tready)
    );
    axis_sink #(.DW(DW), .N(N)) s (
        .clk(clk), .rst_n(rst_n),
        .s_tdata(tdata), .s_tvalid(tvalid), .s_tlast(tlast), .s_tready(tready),
        .count(count), .last_ok(last_ok), .last_err(last_err)
    );

    initial clk = 1'b0;
    always #5 clk = ~clk;

    integer errors;
    integer k;
    integer cyc;
    reg [DW-1:0] held;      // value observed while stalled
    reg          held_armed;

    initial begin
        rst_n      = 1'b0;
        tready     = 1'b0;
        errors     = 0;
        held_armed = 1'b0;
        @(negedge clk); rst_n = 1'b1;

        // Drive a fixed, deterministic back-pressure pattern on tready.
        // Pattern 1,0,1,1,0,... repeats; stalls prove data holds and nothing drops.
        for (cyc = 0; cyc < 40; cyc = cyc + 1) begin
            @(negedge clk);
            case (cyc % 3)
                1: tready = 1'b0; // stall
                default: tready = 1'b1;
            endcase
            // While stalled with a valid beat pending, remember tdata to confirm
            // it is unchanged on the next stalled observation.
            if (!tready && tvalid) begin
                if (held_armed && (tdata !== held)) begin
                    $display("FAIL: data changed while stalled 0x%02x -> 0x%02x", held, tdata);
                    errors = errors + 1;
                end
                held       = tdata;
                held_armed = 1'b1;
            end else begin
                held_armed = 1'b0;
            end
            if (count == N) cyc = 40;
        end

        // Audit: exactly N beats, in order, each exactly once.
        if (count !== N) begin
            $display("FAIL: got %0d beats, expected %0d", count, N);
            errors = errors + 1;
        end
        for (k = 0; k < N; k = k + 1) begin
            if (s.mem[k] !== (k[DW-1:0] + 8'h10)) begin
                $display("FAIL: beat %0d = 0x%02x expected 0x%02x", k, s.mem[k], (k[DW-1:0]+8'h10));
                errors = errors + 1;
            end else begin
                $display("PASS: beat %0d received in order = 0x%02x", k, s.mem[k]);
            end
        end
        if (!last_ok)  begin $display("FAIL: tlast not seen on final beat"); errors = errors + 1; end
        else           $display("PASS: tlast landed on final beat %0d", N-1);
        if (last_err)  begin $display("FAIL: tlast asserted on a non-final beat"); errors = errors + 1; end
        else           $display("PASS: no premature tlast");

        if (errors == 0) $display("ALL TESTS PASSED");
        else             $display("TESTS FAILED: %0d error(s)", errors);
        $finish;
    end
endmodule
PASS: beat 0 received in order = 0x10
PASS: beat 1 received in order = 0x11
PASS: beat 2 received in order = 0x12
PASS: beat 3 received in order = 0x13
PASS: beat 4 received in order = 0x14
PASS: beat 5 received in order = 0x15
PASS: tlast landed on final beat 5
PASS: no premature tlast
ALL TESTS PASSED

The log shows all six beats (0x10–0x15) received in strict order with tlast asserted only on beat 5, under a deterministic stall pattern. Because the sink is a clocked recorder, a dropped or duplicated beat would corrupt the ordered mem array and print a FAIL; the clean PASS lines confirm the handshake obeys the beat-moves-only-when-both-high rule.

A 1-deep AXI-Stream register slice (skid buffer) sits between a master and a slave. It registers the payload so it can break a long combinational tvalid/tready path for timing closure, while guaranteeing no beat is ever dropped or duplicated. When the downstream stalls, the in-flight beat is captured in the skid register and s_tready is deasserted upstream. The testbench streams a 10-beat packet through it under a repeating stall pattern and checks the output stream against a golden ordered reference.

// 1-deep AXI4-Stream skid-buffer register slice.
// Registers the data path so it breaks the combinational tvalid/tready path,
// yet drops no beat and duplicates none under arbitrary back-pressure.
module axis_skid #(
    parameter DW = 8
)(
    input  wire          clk,
    input  wire          rst_n,
    input  wire [DW-1:0] s_tdata,
    input  wire          s_tlast,
    input  wire          s_tvalid,
    output wire          s_tready,
    output wire [DW-1:0] m_tdata,
    output wire          m_tlast,
    output wire          m_tvalid,
    input  wire          m_tready
);
    reg [DW-1:0] skid_data;
    reg          skid_last;
    reg          skid_full;

    assign s_tready = ~skid_full;                 // accept only when empty
    assign m_tdata  = skid_full ? skid_data : s_tdata;
    assign m_tlast  = skid_full ? skid_last : s_tlast;
    assign m_tvalid = skid_full ? 1'b1      : s_tvalid;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            skid_full <= 1'b0;
            skid_data <= {DW{1'b0}};
            skid_last <= 1'b0;
        end else if (!skid_full) begin
            // empty: capture a beat only if upstream offers one that downstream
            // is NOT taking this cycle (otherwise it passes straight through).
            if (s_tvalid && !m_tready) begin
                skid_data <= s_tdata;
                skid_last <= s_tlast;
                skid_full <= 1'b1;
            end
        end else if (m_tready) begin
            skid_full <= 1'b0;                     // drain on downstream accept
        end
    end
endmodule

module tb;
    localparam DW = 8;
    localparam N  = 10;

    reg clk, rst_n;
    reg  [DW-1:0] up_data;
    reg           up_last;
    reg           up_valid;
    wire          up_ready;
    wire [DW-1:0] dn_data;
    wire          dn_last;
    wire          dn_valid;
    reg           dn_ready;

    axis_skid #(.DW(DW)) dut (
        .clk(clk), .rst_n(rst_n),
        .s_tdata(up_data), .s_tlast(up_last), .s_tvalid(up_valid), .s_tready(up_ready),
        .m_tdata(dn_data), .m_tlast(dn_last), .m_tvalid(dn_valid), .m_tready(dn_ready)
    );

    initial clk = 1'b0;
    always #5 clk = ~clk;

    integer errors;
    integer send_idx;    // next payload value index to present upstream
    integer recv_idx;    // beats accepted downstream
    integer cyc;
    reg [DW-1:0] exp_mem [0:N-1];
    integer g;

    initial begin
        for (g = 0; g < N; g = g + 1) exp_mem[g] = g + 8'h20;

        rst_n = 1'b0; up_valid = 1'b0; up_data = 8'h00; up_last = 1'b0;
        dn_ready = 1'b0; errors = 0; send_idx = 0; recv_idx = 0;

        @(negedge clk); rst_n = 1'b1;

        for (cyc = 0; cyc < 100; cyc = cyc + 1) begin
            // Drive inputs just after the negedge, then let continuous assigns settle.
            @(negedge clk);
            if (send_idx < N) begin
                up_valid = 1'b1;
                up_data  = send_idx[DW-1:0] + 8'h20;
                up_last  = (send_idx == (N-1));
            end else begin
                up_valid = 1'b0;
            end
            case (cyc % 4)
                2:       dn_ready = 1'b0; // deterministic stall every 4th cycle
                default: dn_ready = 1'b1;
            endcase
            #1; // allow s_tready / m_tvalid / m_tdata to propagate before sampling

            // Sample stable pre-posedge handshakes.
            if (up_valid && up_ready) send_idx = send_idx + 1;

            if (dn_valid && dn_ready) begin
                if (dn_data !== exp_mem[recv_idx]) begin
                    $display("FAIL: out beat %0d = 0x%02x expected 0x%02x", recv_idx, dn_data, exp_mem[recv_idx]);
                    errors = errors + 1;
                end else begin
                    $display("PASS: out beat %0d = 0x%02x tlast=%b", recv_idx, dn_data, dn_last);
                end
                if ((recv_idx == (N-1)) && !dn_last) begin
                    $display("FAIL: tlast missing on final out beat"); errors = errors + 1;
                end
                if ((recv_idx != (N-1)) && dn_last) begin
                    $display("FAIL: premature tlast on out beat %0d", recv_idx); errors = errors + 1;
                end
                recv_idx = recv_idx + 1;
            end

            @(posedge clk);
            if (recv_idx == N) cyc = 100;
        end

        if (recv_idx !== N) begin
            $display("FAIL: delivered %0d beats, expected %0d", recv_idx, N);
            errors = errors + 1;
        end

        if (errors == 0) $display("ALL TESTS PASSED");
        else             $display("TESTS FAILED: %0d error(s)", errors);
        $finish;
    end
endmodule
PASS: out beat 0 = 0x20 tlast=0
PASS: out beat 1 = 0x21 tlast=0
PASS: out beat 2 = 0x22 tlast=0
PASS: out beat 3 = 0x23 tlast=0
PASS: out beat 4 = 0x24 tlast=0
PASS: out beat 5 = 0x25 tlast=0
PASS: out beat 6 = 0x26 tlast=0
PASS: out beat 7 = 0x27 tlast=0
PASS: out beat 8 = 0x28 tlast=0
PASS: out beat 9 = 0x29 tlast=1
ALL TESTS PASSED

The log shows all ten beats (0x20–0x29) emerging downstream in order with tlast on the final beat, despite periodic back-pressure. Each output beat is checked against a precomputed golden array, so any reordering, loss or duplication would print a FAIL — the all-PASS run demonstrates the skid buffer is lossless and transparent.

The valid/ready contract

AXI-Stream flow control rests on two signals travelling in opposite directions. The source drives tvalid to say “I have a valid beat on tdata this cycle.” The sink drives tready to say “I can accept a beat this cycle.” A transfer — one beat — happens on the rising clock edge where both are high at the same time. Nothing else moves data.

The protocol imposes a stability obligation on the source: once tvalid is asserted, the source must keep tvalid high and must hold tdata, tlast and the sideband signals unchanged until the beat is accepted. It may not withdraw an offered beat or change its value just because the sink is slow. In Example 1 the payload is a pure function of a beat index that only advances on an accepted transfer, which is exactly why the data is stable across every stall.

  • tvalid — asserted by the source; once high, stays high and data stays frozen until accepted.
  • tready — asserted by the sink; may toggle freely cycle to cycle (that is back-pressure).
  • Transfer — occurs the cycle both are high; the source may then present the next beat.

tlast and packetization

tlast marks the last beat of a packet (a frame, a descriptor, a burst — whatever the application defines). It is valid only on an accepted beat and must be asserted on exactly the final beat of each packet. Downstream blocks use it to know where one packet ends and the next begins without a separate length signal: a video line, an Ethernet frame boundary, or the end of a DMA transfer.

A common interview point: tlast is optional in the AXI-Stream spec. A pure, endless data stream (for example a continuous ADC feed) may omit it entirely and simply never signal a boundary. But if you drive it, it must track real packet boundaries — both example testbenches explicitly check that tlast is high on the final beat and low on every other beat, because a mis-placed tlast silently corrupts framing downstream.

tkeep and tstrb

Real AXI-Stream links are usually wider than one byte, and two optional byte-qualifier signals say which bytes of tdata matter. tkeep marks which byte lanes carry content that must be delivered; a null byte (tkeep low) is a position byte that can be removed from the stream — this is how you signal that the last beat of a packet is only partially full. tstrb distinguishes data bytes from position bytes within the kept set and is rarely used in practice.

For a byte-aligned stream where every beat is full, you can omit both; a receiver treats all bytes as valid. The examples here use a single-byte tdata, so no byte qualifiers are needed. When you widen the bus — say 64-bit tdata with an 8-bit tkeep — the handshake logic is unchanged; you only add the lane-enable bits alongside the payload.

Why AXI-Stream is the standard FPGA dataflow interface

AXI-Stream won because it is minimal and composable. There are no addresses, no bursts and no responses — just a ready/valid handshake plus payload — so it maps directly onto streaming compute, and any two AXI-Stream blocks can be chained as long as widths and sideband signals agree. Xilinx/AMD and Intel/Altera IP (DMA, FFT, FIR, video processing, AI engines) expose AXI-Stream ports precisely so they drop into a pipeline without glue logic.

The ready/valid discipline also gives you elastic pipelines for free. Insert a register slice (Example 2) anywhere the path is too long for timing, and the stream keeps its exact semantics — same beats, same order, same packet boundaries — just one cycle later. This is why skid buffers are the most-instantiated module in large stream designs.

Deadlock avoidance: never gate tvalid on tready

The single most dangerous mistake in AXI-Stream Verilog code is making tvalid combinationally depend on tready (for example assign tvalid = have_data & tready;). If the source only asserts tvalid when it already sees tready, and the sink only asserts tready when it sees tvalid, neither ever fires and the link deadlocks.

The rule is one-directional: a source may assert tvalid as soon as it has data, regardless of tready; a sink may assert tready regardless of tvalid. Only the sink is allowed to look at the other side's signal to form its decision cheaply — and even then, making tready depend combinationally on tvalid across many stages builds a long timing path, which is the exact problem the skid buffer solves. In Example 1, notice m_tvalid is driven purely from the beat index and never reads m_tready.

FAQ

Can tready be asserted before tvalid?

Yes. A sink may hold tready high permanently if it can always accept. The transfer still only occurs on the cycle tvalid also goes high. Asserting tready early costs nothing and avoids a combinational dependency.

What is the difference between a register slice and a FIFO?

A register slice (skid buffer) is a depth-1 or depth-2 element used purely for timing isolation and holds at most one or two beats. A FIFO provides real buffering and rate decoupling across many beats. Both present AXI-Stream ports; the skid buffer in Example 2 is the minimal lossless case.

Why does a simple pass-through register drop throughput?

A naive single register that deasserts tready whenever it holds data can only accept a new beat every other cycle. A full skid buffer adds the logic to accept while draining; the 1-deep version here guarantees correctness (no drop, no duplicate) and is the clearest starting point before moving to a 2-deep full-throughput slice.

Must tdata be held stable during a stall?

Yes — once tvalid is asserted, tdata and tlast must not change until the beat is accepted. Example 1's testbench explicitly samples and compares the held value across stall cycles to prove this.

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