Skip to content
VLSI Mentor

UART · Module 17

FPGA Debug, ILA Capture and Board Bring-Up

A capture buffer whose trigger schedules the stop rather than the start, the off-by-one in the ring unwrap that only unique probe data exposes, and a bring-up sequence that orders the measurements so each one is interpretable.

Everything in this module so far has been a measurement you could make if you had the waveform. On a board you usually do not. The pin is buried under a BGA, the failure happens once every four minutes, and the symptom you can observe — a dropped byte, a framing error — occurred strictly after whatever caused it.

That last point is the one that shapes the tool. By the time a condition is detectable, the evidence for it is already in the past. A capture that starts when the trigger fires records the aftermath.

1. The Trigger Schedules the Stop

An on-chip logic analyser is a ring buffer that is always writing. It does not begin recording when the trigger fires; it has been recording continuously since it was armed, overwriting its oldest samples. What the trigger does is schedule the stop: keep going for POST more samples, then freeze.

The consequence is that the buffer, once frozen, contains history from before the trigger. That is the entire value of the instrument. A framing error is detectable only at the stop bit, nine and a half bit periods after the start edge that will explain it; an overrun is detectable only when the FIFO is already full, long after the service gap that caused it.

The ring is already running when the trigger arrives

14 cycles
A timing trace showing how a capture buffer relates to its trigger. The row labelled ring write shows the buffer writing a sample on every clock from the moment it is armed, continuously, without regard to the trigger. The trigger row shows a single pulse partway through. The row labelled retained shows which samples survive in the frozen buffer: a long stretch of samples recorded before the trigger, the trigger sample itself, and a further run of post trigger samples, after which the buffer freezes and stops writing. The samples recorded before the trigger are the ones that contain the cause, and they exist only because the ring was already running.pre-trigger — holds the causepre-trigger — holds the causepost-triggerpost-triggertrigger — schedules the stoptrigger — schedules thestopbuffer freezesbuffer freezesring writetriggerretainedfrozent0t1t2t3t4t5t6t7t8t9t10t11t12t13

A capture that began at the trigger would retain only the blue band. The green band — the reason any of this happened — would have been discarded before anyone knew it mattered.

2. What to Probe

The capture is only as good as the signals fed into it, and buffer depth is scarce: every bit of probe width costs a bit of memory per sample. The useful selection for a UART is narrow and deliberate.

A block diagram of an instrumented UART receive path. Along the top row, the receive pin feeds the UART receiver, which feeds the receive FIFO, which is drained by the CPU or DMA engine. Along the bottom row, a probe bus collects taps from the UART receiver and feeds the ILA ring buffer, which also receives the FIFO level. The ring buffer is always writing and is frozen by a trigger condition, after which a readout path presents its contents oldest sample first. The diagram shows that the capture instrument sits alongside the datapath rather than inside it, observing the same signals the datapath uses without altering them.RX PINthe serial lineUART RXframe assemblyRX FIFOdepth 16CPU / DMAdrains itPROBE BUS8 bitsILA RINGalways writingREADOUToldest firstlinebytereadtaplevel8bunwrap12

Eight bits is usually enough for a UART, and these eight earn their place because each one answers a question raised by an earlier chapter in this module:

Probe bitAnswers
the raw rx lineeverything in 17.1 — the waveform itself
the receiver's sample strobewhere the receiver thought the bit centres were (17.2)
frame_err, parity_errwhich check failed (17.3)
start-candidate, start-committedqualification, and the gap between them (17.4)
FIFO level, two bitsheadroom at the moment of failure (17.5)

Note what is not on that list: the assembled byte. It is reconstructible from the raw line, and spending eight bits of probe width on it would halve the capture depth to record something the capture already contains.

3. The Capture Block

Verilog

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ---------------------------------------------------------------------------
// uart_ila_capture -- an on-chip logic analyser, reduced to the part that is
// actually hard.
//
// A capture buffer is a ring that is ALWAYS writing. The trigger does not
// start the recording; it schedules the STOP. That is the whole reason an ILA
// is worth having: by the time a condition is detectable, the cause is already
// in the past, and only a ring that was running beforehand still holds it.
//
// Two things about this block are worth reading carefully.
//
// * The pre-trigger window is a BEST EFFORT, not a guarantee. If the trigger
//   fires before DEPTH-POST samples have been recorded, you get fewer
//   pre-trigger samples and filled_o says so. A capture that silently returns
//   a short history is how people end up debugging the wrong microsecond.
//
// * The readout is ordered oldest-first regardless of where the ring happened
//   to wrap. Index 0 is the oldest retained sample and trig_pos_o is where
//   the trigger landed in that same ordering. Getting this unwrap off by one
//   is the classic on-chip-analyser bug, and it is invisible unless the probe
//   data is unique per sample.
// ---------------------------------------------------------------------------
module uart_ila_capture #(
    parameter W    = 8,              // probe width
    parameter AW   = 5,              // address width; DEPTH = 1 << AW
    parameter POST = 8               // samples retained after the trigger
)(
    input  wire           clk,
    input  wire           rst_n,
    input  wire           arm_i,     // begin a new capture
    input  wire [W-1:0]   probe_i,   // the signals under observation
    input  wire           trig_i,    // the condition worth stopping on

    output reg            armed_o,
    output reg            done_o,    // the capture is frozen and readable
    output reg            trig_seen_o,
    output reg  [AW:0]    filled_o,  // how many samples are valid
    output wire [AW:0]    trig_pos_o,// index of the trigger in readout order

    input  wire [AW-1:0]  rd_addr_i, // 0 = OLDEST retained sample
    output wire [W-1:0]   rd_data_o
);

    localparam DEPTH = (1 << AW);

    reg [W-1:0]  mem [0:DEPTH-1];
    reg [AW-1:0] wr_ptr;
    reg [AW-1:0] trig_wr;            // where the trigger sample was written
    reg [AW:0]   post_cnt;

    wire wrapped = (filled_o == DEPTH[AW:0]);

    // The oldest retained sample sits at the write pointer once the ring has
    // wrapped, and at zero before that.
    wire [AW-1:0] oldest = wrapped ? wr_ptr : {AW{1'b0}};

    assign rd_data_o  = mem[oldest + rd_addr_i];          // AW bits: wraps by itself
    assign trig_pos_o = {1'b0, (trig_wr - oldest)};

    integer i;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            armed_o     <= 1'b0;
            done_o      <= 1'b0;
            trig_seen_o <= 1'b0;
            filled_o    <= {(AW+1){1'b0}};
            wr_ptr      <= {AW{1'b0}};
            trig_wr     <= {AW{1'b0}};
            post_cnt    <= {(AW+1){1'b0}};
            for (i = 0; i < DEPTH; i = i + 1) mem[i] <= {W{1'b0}};
        end else if (arm_i) begin
            armed_o     <= 1'b1;
            done_o      <= 1'b0;
            trig_seen_o <= 1'b0;
            filled_o    <= {(AW+1){1'b0}};
            wr_ptr      <= {AW{1'b0}};
            trig_wr     <= {AW{1'b0}};
            post_cnt    <= {(AW+1){1'b0}};
        end else if (armed_o && !done_o) begin
            // ---- the ring never stops writing while armed ----------------
            mem[wr_ptr] <= probe_i;
            wr_ptr      <= wr_ptr + {{(AW-1){1'b0}}, 1'b1};
            if (!wrapped) filled_o <= filled_o + {{AW{1'b0}}, 1'b1};

            // ---- the first trigger schedules the stop --------------------
            // POST counts samples retained FROM THE TRIGGER INCLUSIVE, so the
            // trigger sample itself is post-sample number one.
            if (trig_i && !trig_seen_o) begin
                trig_seen_o <= 1'b1;
                trig_wr     <= wr_ptr;       // this clock's sample IS the trigger
                post_cnt    <= {{AW{1'b0}}, 1'b1};
            end else if (trig_seen_o) begin
                if (post_cnt == POST[AW:0] - 1) begin
                    done_o  <= 1'b1;
                    armed_o <= 1'b0;
                end else begin
                    post_cnt <= post_cnt + {{AW{1'b0}}, 1'b1};
                end
            end
        end
    end

endmodule

SystemVerilog

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
// ---------------------------------------------------------------------------
// uart_ila_capture -- an on-chip logic analyser, reduced to the part that is
// actually hard.
//
// A capture buffer is a ring that is ALWAYS writing. The trigger does not
// start the recording; it schedules the STOP. That is the whole reason an ILA
// is worth having: by the time a condition is detectable, the cause is already
// in the past, and only a ring that was running beforehand still holds it.
//
// Two things about this block are worth reading carefully.
//
// * The pre-trigger window is a BEST EFFORT, not a guarantee. If the trigger
//   fires before DEPTH-POST samples have been recorded, you get fewer
//   pre-trigger samples and filled_o says so. A capture that silently returns
//   a short history is how people end up debugging the wrong microsecond.
//
// * The readout is ordered oldest-first regardless of where the ring happened
//   to wrap. Index 0 is the oldest retained sample and trig_pos_o is where
//   the trigger landed in that same ordering. Getting this unwrap off by one
//   is the classic on-chip-analyser bug, and it is invisible unless the probe
//   data is unique per sample.
// ---------------------------------------------------------------------------
module uart_ila_capture #(
    parameter int W    = 8,              // probe width
    parameter int AW   = 5,              // address width; DEPTH = 1 << AW
    parameter int POST = 8               // samples retained after the trigger
)(
    input  logic          clk,
    input  logic          rst_n,
    input  logic          arm_i,     // begin a new capture
    input  logic [W-1:0]  probe_i,   // the signals under observation
    input  logic          trig_i,    // the condition worth stopping on

    output logic          armed_o,
    output logic          done_o,    // the capture is frozen and readable
    output logic          trig_seen_o,
    output logic [AW:0]   filled_o,  // how many samples are valid
    output logic [AW:0]   trig_pos_o,// index of the trigger in readout order

    input  logic [AW-1:0] rd_addr_i, // 0 = OLDEST retained sample
    output logic [W-1:0]  rd_data_o
);

    localparam DEPTH = (1 << AW);

    logic [W-1:0]  mem [0:DEPTH-1];
    logic [AW-1:0] wr_ptr;
    logic [AW-1:0] trig_wr;            // where the trigger sample was written
    logic [AW:0]   post_cnt;

    wire wrapped = (filled_o == DEPTH[AW:0]);

    // The oldest retained sample sits at the write pointer once the ring has
    // wrapped, and at zero before that.
    wire [AW-1:0] oldest = wrapped ? wr_ptr : {AW{1'b0}};

    assign rd_data_o  = mem[oldest + rd_addr_i];          // AW bits: wraps by itself
    assign trig_pos_o = {1'b0, (trig_wr - oldest)};

    int i;

    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            armed_o     <= 1'b0;
            done_o      <= 1'b0;
            trig_seen_o <= 1'b0;
            filled_o    <= {(AW+1){1'b0}};
            wr_ptr      <= {AW{1'b0}};
            trig_wr     <= {AW{1'b0}};
            post_cnt    <= {(AW+1){1'b0}};
            for (i = 0; i < DEPTH; i = i + 1) mem[i] <= {W{1'b0}};
        end else if (arm_i) begin
            armed_o     <= 1'b1;
            done_o      <= 1'b0;
            trig_seen_o <= 1'b0;
            filled_o    <= {(AW+1){1'b0}};
            wr_ptr      <= {AW{1'b0}};
            trig_wr     <= {AW{1'b0}};
            post_cnt    <= {(AW+1){1'b0}};
        end else if (armed_o && !done_o) begin
            // ---- the ring never stops writing while armed ----------------
            mem[wr_ptr] <= probe_i;
            wr_ptr      <= wr_ptr + {{(AW-1){1'b0}}, 1'b1};
            if (!wrapped) filled_o <= filled_o + {{AW{1'b0}}, 1'b1};

            // ---- the first trigger schedules the stop --------------------
            // POST counts samples retained FROM THE TRIGGER INCLUSIVE, so the
            // trigger sample itself is post-sample number one.
            if (trig_i && !trig_seen_o) begin
                trig_seen_o <= 1'b1;
                trig_wr     <= wr_ptr;       // this clock's sample IS the trigger
                post_cnt    <= {{AW{1'b0}}, 1'b1};
            end else if (trig_seen_o) begin
                if (post_cnt == POST[AW:0] - 1) begin
                    done_o  <= 1'b1;
                    armed_o <= 1'b0;
                end else begin
                    post_cnt <= post_cnt + {{AW{1'b0}}, 1'b1};
                end
            end
        end
    end

endmodule

VHDL

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-- ---------------------------------------------------------------------------
-- uart_ila_capture -- an on-chip logic analyser, reduced to the part that is
-- actually hard.
--
-- A capture buffer is a ring that is ALWAYS writing. The trigger does not
-- start the recording; it schedules the STOP. That is the whole reason an ILA
-- is worth having: by the time a condition is detectable, the cause is already
-- in the past, and only a ring that was running beforehand still holds it.
--
-- Two things about this block are worth reading carefully.
--
-- * The pre-trigger window is a BEST EFFORT, not a guarantee. If the trigger
--   fires before DEPTH-POST samples have been recorded, you get fewer
--   pre-trigger samples and filled_o says so. A capture that silently returns
--   a short history is how people end up debugging the wrong microsecond.
--
-- * The readout is ordered oldest-first regardless of where the ring happened
--   to wrap. Index 0 is the oldest retained sample and trig_pos_o is where
--   the trigger landed in that same ordering. Getting this unwrap off by one
--   is the classic on-chip-analyser bug, and it is invisible unless the probe
--   data is unique per sample.
-- ---------------------------------------------------------------------------
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity uart_ila_capture is
    generic (
        W    : natural := 8;                   -- probe width
        AW   : natural := 5;                   -- address width; DEPTH = 2**AW
        POST : natural := 8                    -- samples retained after trigger
    );
    port (
        clk         : in  std_logic;
        rst_n       : in  std_logic;
        arm_i       : in  std_logic;                     -- begin a new capture
        probe_i     : in  std_logic_vector(W-1 downto 0);
        trig_i      : in  std_logic;                     -- condition worth stopping on

        armed_o     : out std_logic;
        done_o      : out std_logic;                     -- frozen and readable
        trig_seen_o : out std_logic;
        filled_o    : out unsigned(AW downto 0);         -- valid sample count
        trig_pos_o  : out unsigned(AW downto 0);         -- trigger in readout order

        rd_addr_i   : in  unsigned(AW-1 downto 0);       -- 0 = OLDEST sample
        rd_data_o   : out std_logic_vector(W-1 downto 0)
    );
end entity uart_ila_capture;

architecture rtl of uart_ila_capture is

    constant DEPTH : natural := 2**AW;

    type mem_t is array (0 to DEPTH-1) of std_logic_vector(W-1 downto 0);
    signal mem : mem_t := (others => (others => '0'));

    signal wr_ptr   : unsigned(AW-1 downto 0) := (others => '0');
    signal trig_wr  : unsigned(AW-1 downto 0) := (others => '0');
    signal post_cnt : unsigned(AW downto 0)   := (others => '0');

    signal armed    : std_logic := '0';
    signal done     : std_logic := '0';
    signal tseen    : std_logic := '0';
    signal filled   : unsigned(AW downto 0) := (others => '0');

    signal wrapped  : std_logic;
    signal oldest   : unsigned(AW-1 downto 0);

begin

    wrapped <= '1' when filled = to_unsigned(DEPTH, AW+1) else '0';

    -- The oldest retained sample sits at the write pointer once the ring has
    -- wrapped, and at zero before that.
    oldest <= wr_ptr when wrapped = '1' else (others => '0');

    rd_data_o   <= mem(to_integer(oldest + rd_addr_i));   -- AW bits: wraps itself
    trig_pos_o  <= resize(trig_wr - oldest, AW+1);

    armed_o     <= armed;
    done_o      <= done;
    trig_seen_o <= tseen;
    filled_o    <= filled;

    process (clk, rst_n)
    begin
        if rst_n = '0' then
            armed    <= '0';
            done     <= '0';
            tseen    <= '0';
            filled   <= (others => '0');
            wr_ptr   <= (others => '0');
            trig_wr  <= (others => '0');
            post_cnt <= (others => '0');
            mem      <= (others => (others => '0'));
        elsif rising_edge(clk) then
            if arm_i = '1' then
                armed    <= '1';
                done     <= '0';
                tseen    <= '0';
                filled   <= (others => '0');
                wr_ptr   <= (others => '0');
                trig_wr  <= (others => '0');
                post_cnt <= (others => '0');
            elsif armed = '1' and done = '0' then
                -- ---- the ring never stops writing while armed ------------
                mem(to_integer(wr_ptr)) <= probe_i;
                wr_ptr                  <= wr_ptr + 1;
                if wrapped = '0' then
                    filled <= filled + 1;
                end if;

                -- ---- the first trigger schedules the stop ----------------
                -- POST counts samples retained FROM THE TRIGGER INCLUSIVE, so
                -- the trigger sample itself is post-sample number one.
                if trig_i = '1' and tseen = '0' then
                    tseen    <= '1';
                    trig_wr  <= wr_ptr;          -- this clock's sample IS the trigger
                    post_cnt <= to_unsigned(1, AW+1);
                elsif tseen = '1' then
                    if post_cnt = to_unsigned(POST - 1, AW+1) then
                        done  <= '1';
                        armed <= '0';
                    else
                        post_cnt <= post_cnt + 1;
                    end if;
                end if;
            end if;
        end if;
    end process;

end architecture rtl;

4. The Pre-Trigger Window Is a Best Effort

The most common misreading of a capture buffer is to assume the pre-trigger window is always full. It is not. If the trigger fires before the ring has accumulated DEPTH - POST samples, the capture contains less history than requested — and a buffer that returned that silently would be inviting you to debug the wrong microsecond.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  test  when the trigger fired          filled   trig_pos   pre-trigger history
  ----  ----------------------------   -------   --------   -------------------
  T2    after 52 samples                    32         24   24 samples (full)
  T3    after 5 samples                     13          5    5 samples (short)

In T3 the capture is honest about being short: filled_o reads 13 rather than 32, and trig_pos_o reads 5, so the reader can see there are only five samples of history and judge accordingly.

5. The Unwrap, and the Bug That Hides in It

A ring buffer's oldest sample is wherever the write pointer happens to be sitting. Reading the memory out in physical address order therefore returns the capture rotated by an arbitrary amount — correct data, wrong order, with a discontinuity at the wrap point.

The readout logic exists to undo that: index 0 is the oldest retained sample, and trig_pos_o reports where the trigger landed in that same ordering.

This is the classic on-chip-analyser bug, and it is nearly invisible in normal use. A rotated capture of a UART line still looks like a UART line — still has start bits, still has plausible bytes — just with an inexplicable glitch somewhere in the middle that everyone assumes is the fault being investigated.

There is a second subtlety, and the testbench got it wrong before it got it right: a running capture cannot be read coherently. While the ring is still armed, the write pointer advances, so the oldest sample moves under the reader and successive reads belong to different rotations. An early version of T1 read the buffer while armed and produced non-contiguous data. The block was correct; the test was asking an incoherent question. A capture must be frozen before it can be trusted, and T1 now asserts the structural facts only.

6. Choosing a Trigger Worth Spending a Buffer On

Buffer depth is the scarce resource, and a trigger that fires constantly wastes it on the ordinary. Useful triggers for a UART, in rough order of value:

  1. The first error of any kind — framing, parity, or overrun, ORed together. On a link that fails rarely, this is the highest-value trigger available: it catches the first occurrence with full history, and the first occurrence is usually the cleanest.
  2. A framing error while the FIFO is nearly full. Conjunctions are what on-chip triggers are for. Either condition alone may be common; the pair may be the actual failure.
  3. A start candidate that was rejected (17.4). Catches noise events that never became frames and so are invisible to every byte-level counter.
  4. A FIFO drop (17.5), with enough pre-trigger depth to cover the service gap that caused it — which means the pre-window must be longer than the interrupt latency you suspect.

And the trigger to avoid: any byte arriving. On a busy link that fires thousands of times a second, and every capture it produces is of ordinary traffic. The one thing you want is the one thing it will not catch.

7. The Testbench

Verilog

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
`timescale 1ns/1ps
// ---------------------------------------------------------------------------
// Testbench for uart_ila_capture.
//
// The probe is driven with a RAMP -- every sample is a distinct value -- so an
// off-by-one in the ring unwrap cannot hide. A capture that is ordered wrongly,
// rotated, or short by one stops being a strictly ascending run of consecutive
// values, and the contiguity check fails immediately.
//
// The second thing under test is the honesty of the pre-trigger window. T3
// triggers deliberately early and asserts that the block REPORTS the short
// history rather than quietly returning a buffer that looks full.
// ---------------------------------------------------------------------------
module tb_uart_ila_capture;

    localparam W     = 8;
    localparam AW    = 5;
    localparam DEPTH = (1 << AW);       // 32
    localparam POST  = 8;

    reg clk = 1'b0;
    reg rst_n = 1'b0;
    reg arm = 1'b0;
    reg trig = 1'b0;
    reg [W-1:0] probe = 8'd0;
    reg [AW-1:0] rd_addr = 5'd0;

    wire armed, done, trig_seen;
    wire [AW:0]  filled, trig_pos;
    wire [W-1:0] rd_data;

    integer checks = 0;
    integer fails  = 0;

    always #5 clk = ~clk;

    uart_ila_capture #(.W(W), .AW(AW), .POST(POST)) dut (
        .clk(clk), .rst_n(rst_n), .arm_i(arm), .probe_i(probe), .trig_i(trig),
        .armed_o(armed), .done_o(done), .trig_seen_o(trig_seen),
        .filled_o(filled), .trig_pos_o(trig_pos),
        .rd_addr_i(rd_addr), .rd_data_o(rd_data));

    task chk;
        input [255:0] name;
        input integer got;
        input integer exp;
        begin
            checks = checks + 1;
            if (got !== exp) begin
                fails = fails + 1;
                $display("  FAIL %0s: got %0d expected %0d", name, got, exp);
            end
        end
    endtask

    task do_reset;
        begin
            arm = 1'b0; trig = 1'b0; probe = 8'd0; rd_addr = 5'd0;
            rst_n = 1'b0;
            repeat (3) @(posedge clk);
            @(negedge clk); rst_n = 1'b1;
            repeat (2) @(posedge clk);
        end
    endtask

    // ---- clocking invariant -------------------------------------------
    // Every task below drives on a negedge and returns immediately AFTER the
    // posedge that samples it. One call = exactly one captured sample. An
    // earlier version let `ramp` return on a negedge, so the next task's
    // `@(negedge clk)` waited for the FOLLOWING one and an untagged sample
    // slipped into the capture -- which showed up only as an off-by-one in
    // filled_o, long after the cause.
    task step;
        input do_trig;
        begin
            @(negedge clk);
            arm   = 1'b0;
            probe = probe + 8'd1;
            trig  = do_trig;
            @(posedge clk);
        end
    endtask

    task do_arm;
        begin
            @(negedge clk); arm = 1'b1; trig = 1'b0;
            @(posedge clk);
        end
    endtask

    // advance `n` clocks, driving the probe with an ascending ramp
    task ramp;
        input integer n;
        integer i;
        begin
            for (i = 0; i < n; i = i + 1) step(1'b0);
        end
    endtask

    task pulse_trig;
        begin
            step(1'b1);
        end
    endtask

    // Read the whole capture out and check it is a strictly consecutive run.
    // Returns the first value in `first_val` and a 1/0 verdict in `contig`.
    integer contig;
    integer first_val;
    task readout;
        integer i;
        reg [W-1:0] prev;
        begin
            contig = 1;
            for (i = 0; i < filled; i = i + 1) begin
                rd_addr = i[AW-1:0];
                #1;
                if (i == 0) first_val = rd_data;
                else if (rd_data !== ((prev + 8'd1) & 8'hFF)) contig = 0;
                prev = rd_data;
            end
        end
    endtask

    integer tp_val;

    initial begin
        // ---------------- T1: armed, never triggered ----------------------
        // The ring keeps the most recent DEPTH samples. That is still useful
        // -- it is just not a triggered capture, and done_o says so.
        do_reset; do_arm;
        ramp(DEPTH + 20);
        #1;
        $display("T1 no trigger     : armed=%0b done=%0b filled=%0d", armed, done, filled);
        chk("T1 still armed",        armed,  1);
        chk("T1 not done",           done,   0);
        chk("T1 trigger not seen",   trig_seen, 0);
        chk("T1 the ring is full",   filled, DEPTH);
        // Deliberately NOT read out here. While armed the ring is still
        // writing, so `oldest` moves under the reader and any readout is
        // incoherent. A capture must be frozen before it can be trusted --
        // the contiguity checks live in T2 and T3, where done_o is set.

        // ---------------- T2: a full pre-trigger window -------------------
        do_reset; do_arm;
        ramp(DEPTH + 20);              // fill the ring several times over
        pulse_trig;
        ramp(POST + 10);               // run past the post-trigger count
        #1;
        tp_val = trig_pos;
        $display("T2 late trigger   : done=%0b filled=%0d trig_pos=%0d",
                 done, filled, tp_val);
        chk("T2 the capture froze",        done,   1);
        chk("T2 it is no longer armed",    armed,  0);
        chk("T2 the ring is full",         filled, DEPTH);
        chk("T2 the pre-window is DEPTH-POST", tp_val, DEPTH - POST);
        readout;
        $display("T2 readout        : first=%0d contiguous=%0d", first_val, contig);
        chk("T2 readout is contiguous", contig, 1);

        // ---------------- T3: triggering too early ------------------------
        // Only 5 samples of history exist when the trigger fires. The block
        // must report a SHORT capture, not pretend to a full pre-window.
        do_reset; do_arm;
        ramp(5);
        pulse_trig;
        ramp(POST + 10);
        #1;
        tp_val = trig_pos;
        $display("T3 early trigger  : done=%0b filled=%0d trig_pos=%0d",
                 done, filled, tp_val);
        chk("T3 the capture froze",           done, 1);
        chk("T3 the buffer is NOT full",      (filled < DEPTH) ? 1 : 0, 1);
        chk("T3 the pre-window is short",     tp_val, 5);
        chk("T3 filled = pre + post",         filled, 5 + POST);
        readout;
        chk("T3 the short readout is still contiguous", contig, 1);

        // ---------------- T4: a second trigger is ignored -----------------
        do_reset; do_arm;
        ramp(DEPTH + 4);
        pulse_trig;
        ramp(2);
        pulse_trig;                    // must not restart the post-count
        ramp(POST + 10);
        #1;
        $display("T4 double trigger : done=%0b trig_pos=%0d", done, trig_pos);
        chk("T4 the capture froze",           done, 1);
        chk("T4 the first trigger won", trig_pos, DEPTH - POST);

        // ---------------- T5: re-arming starts a clean capture ------------
        do_arm;
        #1;
        $display("T5 re-armed       : armed=%0b done=%0b filled=%0d trig_seen=%0b",
                 armed, done, filled, trig_seen);
        chk("T5 armed again",       armed,     1);
        chk("T5 done cleared",      done,      0);
        chk("T5 history cleared",   filled,    0);
        chk("T5 trigger cleared",   trig_seen, 0);

        // ---------------- T6: the trigger sample is the one at trig_pos ---
        // Freeze a capture, then read the value sitting at trig_pos and check
        // it is the probe value that was live when the trigger was asserted.
        do_reset; do_arm;
        probe = 8'd100;
        ramp(DEPTH + 4);
        begin : trigsamp
            reg [W-1:0] at_trigger;
            pulse_trig;
            at_trigger = probe;      // `step` drove this value into the trigger clock
            ramp(POST + 10);
            @(negedge clk); rd_addr = trig_pos[AW-1:0];
            #1;
            $display("T6 trigger sample : expected=%0d read=%0d", at_trigger, rd_data);
            chk("T6 trig_pos indexes the triggering sample", rd_data, at_trigger);
        end

        $display("");
        $display("== %0d checks, %0d failures ==", checks, fails);
        if (fails == 0) $display("   RESULT: ALL VERILOG ILA-CAPTURE TESTS PASSED");
        else            $display("   RESULT: %0d FAILURE(S)", fails);
        $finish;
    end

endmodule

SystemVerilog

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
`timescale 1ns/1ps
// ---------------------------------------------------------------------------
// Testbench for uart_ila_capture.
//
// The probe is driven with a RAMP -- every sample is a distinct value -- so an
// off-by-one in the ring unwrap cannot hide. A capture that is ordered wrongly,
// rotated, or short by one stops being a strictly ascending run of consecutive
// values, and the contiguity check fails immediately.
//
// The second thing under test is the honesty of the pre-trigger window. T3
// triggers deliberately early and asserts that the block REPORTS the short
// history rather than quietly returning a buffer that looks full.
// ---------------------------------------------------------------------------
module tb_uart_ila_capture;

    localparam W     = 8;
    localparam AW    = 5;
    localparam DEPTH = (1 << AW);       // 32
    localparam POST  = 8;

    logic clk = 1'b0;
    logic rst_n = 1'b0;
    logic arm = 1'b0;
    logic trig = 1'b0;
    logic [W-1:0] probe = 8'd0;
    logic [AW-1:0] rd_addr = 5'd0;

    logic armed, done, trig_seen;
    logic [AW:0]  filled, trig_pos;
    logic [W-1:0] rd_data;

    integer checks = 0;
    integer fails  = 0;

    always #5 clk = ~clk;

    uart_ila_capture #(.W(W), .AW(AW), .POST(POST)) dut (
        .clk(clk), .rst_n(rst_n), .arm_i(arm), .probe_i(probe), .trig_i(trig),
        .armed_o(armed), .done_o(done), .trig_seen_o(trig_seen),
        .filled_o(filled), .trig_pos_o(trig_pos),
        .rd_addr_i(rd_addr), .rd_data_o(rd_data));

    task automatic chk(input string name, input int got, input int exp);
        begin
            checks = checks + 1;
            if (got !== exp) begin
                fails = fails + 1;
                $display("  FAIL %0s: got %0d expected %0d", name, got, exp);
            end
        end
    endtask

    task automatic do_reset();
        begin
            arm = 1'b0; trig = 1'b0; probe = 8'd0; rd_addr = 5'd0;
            rst_n = 1'b0;
            repeat (3) @(posedge clk);
            @(negedge clk); rst_n = 1'b1;
            repeat (2) @(posedge clk);
        end
    endtask

    // ---- clocking invariant -------------------------------------------
    // Every task below drives on a negedge and returns immediately AFTER the
    // posedge that samples it. One call = exactly one captured sample. An
    // earlier version let `ramp` return on a negedge, so the next task's
    // `@(negedge clk)` waited for the FOLLOWING one and an untagged sample
    // slipped into the capture -- which showed up only as an off-by-one in
    // filled_o, long after the cause.
    task automatic step(input logic do_trig);
        begin
            @(negedge clk);
            arm   = 1'b0;
            probe = probe + 8'd1;
            trig  = do_trig;
            @(posedge clk);
        end
    endtask

    task automatic do_arm();
        begin
            @(negedge clk); arm = 1'b1; trig = 1'b0;
            @(posedge clk);
        end
    endtask

    // advance `n` clocks, driving the probe with an ascending ramp
    task automatic ramp(input int n);
        int i;
        begin
            for (i = 0; i < n; i = i + 1) step(1'b0);
        end
    endtask

    task automatic pulse_trig();
        begin
            step(1'b1);
        end
    endtask

    // Read the whole capture out and check it is a strictly consecutive run.
    // Returns the first value in `first_val` and a 1/0 verdict in `contig`.
    integer contig;
    integer first_val;
    task automatic readout();
        int i;
        logic [W-1:0] prev;
        begin
            contig = 1;
            for (i = 0; i < filled; i = i + 1) begin
                rd_addr = i[AW-1:0];
                #1;
                if (i == 0) first_val = rd_data;
                else if (rd_data !== ((prev + 8'd1) & 8'hFF)) contig = 0;
                prev = rd_data;
            end
        end
    endtask

    integer tp_val;

    initial begin
        // ---------------- T1: armed, never triggered ----------------------
        // The ring keeps the most recent DEPTH samples. That is still useful
        // -- it is just not a triggered capture, and done_o says so.
        do_reset; do_arm;
        ramp(DEPTH + 20);
        #1;
        $display("T1 no trigger     : armed=%0b done=%0b filled=%0d", armed, done, filled);
        chk("T1 still armed",        armed,  1);
        chk("T1 not done",           done,   0);
        chk("T1 trigger not seen",   trig_seen, 0);
        chk("T1 the ring is full",   filled, DEPTH);
        // Deliberately NOT read out here. While armed the ring is still
        // writing, so `oldest` moves under the reader and any readout is
        // incoherent. A capture must be frozen before it can be trusted --
        // the contiguity checks live in T2 and T3, where done_o is set.

        // ---------------- T2: a full pre-trigger window -------------------
        do_reset; do_arm;
        ramp(DEPTH + 20);              // fill the ring several times over
        pulse_trig;
        ramp(POST + 10);               // run past the post-trigger count
        #1;
        tp_val = trig_pos;
        $display("T2 late trigger   : done=%0b filled=%0d trig_pos=%0d",
                 done, filled, tp_val);
        chk("T2 the capture froze",        done,   1);
        chk("T2 it is no longer armed",    armed,  0);
        chk("T2 the ring is full",         filled, DEPTH);
        chk("T2 the pre-window is DEPTH-POST", tp_val, DEPTH - POST);
        readout;
        $display("T2 readout        : first=%0d contiguous=%0d", first_val, contig);
        chk("T2 readout is contiguous", contig, 1);

        // ---------------- T3: triggering too early ------------------------
        // Only 5 samples of history exist when the trigger fires. The block
        // must report a SHORT capture, not pretend to a full pre-window.
        do_reset; do_arm;
        ramp(5);
        pulse_trig;
        ramp(POST + 10);
        #1;
        tp_val = trig_pos;
        $display("T3 early trigger  : done=%0b filled=%0d trig_pos=%0d",
                 done, filled, tp_val);
        chk("T3 the capture froze",           done, 1);
        chk("T3 the buffer is NOT full",      (filled < DEPTH) ? 1 : 0, 1);
        chk("T3 the pre-window is short",     tp_val, 5);
        chk("T3 filled = pre + post",         filled, 5 + POST);
        readout;
        chk("T3 the short readout is still contiguous", contig, 1);

        // ---------------- T4: a second trigger is ignored -----------------
        do_reset; do_arm;
        ramp(DEPTH + 4);
        pulse_trig;
        ramp(2);
        pulse_trig;                    // must not restart the post-count
        ramp(POST + 10);
        #1;
        $display("T4 double trigger : done=%0b trig_pos=%0d", done, trig_pos);
        chk("T4 the capture froze",           done, 1);
        chk("T4 the first trigger won", trig_pos, DEPTH - POST);

        // ---------------- T5: re-arming starts a clean capture ------------
        do_arm;
        #1;
        $display("T5 re-armed       : armed=%0b done=%0b filled=%0d trig_seen=%0b",
                 armed, done, filled, trig_seen);
        chk("T5 armed again",       armed,     1);
        chk("T5 done cleared",      done,      0);
        chk("T5 history cleared",   filled,    0);
        chk("T5 trigger cleared",   trig_seen, 0);

        // ---------------- T6: the trigger sample is the one at trig_pos ---
        // Freeze a capture, then read the value sitting at trig_pos and check
        // it is the probe value that was live when the trigger was asserted.
        do_reset; do_arm;
        probe = 8'd100;
        ramp(DEPTH + 4);
        begin : trigsamp
            logic [W-1:0] at_trigger;
            pulse_trig;
            at_trigger = probe;      // `step` drove this value into the trigger clock
            ramp(POST + 10);
            @(negedge clk); rd_addr = trig_pos[AW-1:0];
            #1;
            $display("T6 trigger sample : expected=%0d read=%0d", at_trigger, rd_data);
            chk("T6 trig_pos indexes the triggering sample", rd_data, at_trigger);
        end

        $display("");
        $display("== %0d checks, %0d failures ==", checks, fails);
        if (fails == 0) $display("   RESULT: ALL SYSTEMVERILOG ILA-CAPTURE TESTS PASSED");
        else            $display("   RESULT: %0d FAILURE(S)", fails);
        $finish;
    end

endmodule

VHDL

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-- ---------------------------------------------------------------------------
-- Testbench for uart_ila_capture.
--
-- The probe is driven with a RAMP -- every sample is a distinct value -- so an
-- off-by-one in the ring unwrap cannot hide. A capture that is ordered wrongly,
-- rotated, or short by one stops being a strictly ascending run of consecutive
-- values, and the contiguity check fails immediately.
--
-- The second thing under test is the honesty of the pre-trigger window. T3
-- triggers deliberately early and asserts that the block REPORTS the short
-- history rather than quietly returning a buffer that looks full.
-- ---------------------------------------------------------------------------
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity tb_uart_ila_capture is
end entity tb_uart_ila_capture;

architecture sim of tb_uart_ila_capture is

    constant W     : natural := 8;
    constant AW    : natural := 5;
    constant DEPTH : natural := 2**AW;         -- 32
    constant POST  : natural := 8;
    constant TCLK  : time    := 10 ns;

    signal clk     : std_logic := '0';
    signal rst_n   : std_logic := '0';
    signal arm     : std_logic := '0';
    signal trig    : std_logic := '0';
    signal probe   : unsigned(W-1 downto 0) := (others => '0');
    signal rd_addr : unsigned(AW-1 downto 0) := (others => '0');
    signal done_s  : boolean := false;

    signal armed, done, trig_seen : std_logic;
    signal filled, trig_pos       : unsigned(AW downto 0);
    signal rd_data                : std_logic_vector(W-1 downto 0);

begin

    clk <= '0' when done_s else not clk after TCLK/2;

    dut : entity work.uart_ila_capture
        generic map (W => W, AW => AW, POST => POST)
        port map (clk => clk, rst_n => rst_n, arm_i => arm,
                  probe_i => std_logic_vector(probe), trig_i => trig,
                  armed_o => armed, done_o => done, trig_seen_o => trig_seen,
                  filled_o => filled, trig_pos_o => trig_pos,
                  rd_addr_i => rd_addr, rd_data_o => rd_data);

    stim : process
        variable checks, fails : integer := 0;
        variable contig        : integer := 1;
        variable first_val     : integer := 0;
        variable prev          : integer := 0;
        variable at_trigger    : integer := 0;
        variable tp_val        : integer := 0;

        procedure chk (name : string; got : integer; exp : integer) is
        begin
            checks := checks + 1;
            if got /= exp then
                fails := fails + 1;
                report "  FAIL " & name & ": got " & integer'image(got) &
                       " expected " & integer'image(exp) severity error;
            end if;
        end procedure;

        procedure do_reset is
        begin
            arm <= '0'; trig <= '0'; probe <= (others => '0');
            rd_addr <= (others => '0'); rst_n <= '0';
            for i in 0 to 2 loop wait until rising_edge(clk); end loop;
            wait until falling_edge(clk); rst_n <= '1';
            for i in 0 to 1 loop wait until rising_edge(clk); end loop;
        end procedure;

        -- ---- clocking invariant ---------------------------------------
        -- Every procedure below drives on a falling edge and returns
        -- immediately AFTER the rising edge that samples it. One call = exactly
        -- one captured sample. Letting a procedure return on a falling edge
        -- instead lets an untagged sample slip into the capture, which shows up
        -- only as an off-by-one in filled_o, long after the cause.
        procedure step (do_trig : std_logic) is
        begin
            wait until falling_edge(clk);
            arm   <= '0';
            probe <= probe + 1;
            trig  <= do_trig;
            wait until rising_edge(clk);
        end procedure;

        procedure do_arm is
        begin
            wait until falling_edge(clk);
            arm <= '1'; trig <= '0';
            wait until rising_edge(clk);
        end procedure;

        -- advance `n` clocks, driving the probe with an ascending ramp
        procedure ramp (n : integer) is
        begin
            for i in 0 to n-1 loop step('0'); end loop;
        end procedure;

        procedure pulse_trig is
        begin
            step('1');
        end procedure;

        -- Read the whole capture out and check it is a strictly consecutive
        -- run. Purely combinational -- a frozen capture needs no clocking.
        procedure readout is
        begin
            contig := 1;
            for i in 0 to to_integer(filled)-1 loop
                rd_addr <= to_unsigned(i, AW);
                wait for 1 ns;
                if i = 0 then
                    first_val := to_integer(unsigned(rd_data));
                elsif to_integer(unsigned(rd_data)) /= (prev + 1) mod 256 then
                    contig := 0;
                end if;
                prev := to_integer(unsigned(rd_data));
            end loop;
        end procedure;

    begin
        -- ---------------- T1: armed, never triggered ----------------------
        -- The ring keeps the most recent DEPTH samples. That is still useful
        -- -- it is just not a triggered capture, and done_o says so.
        do_reset; do_arm;
        ramp(DEPTH + 20);
        wait for 1 ns;
        report "T1 no trigger     : armed=" & std_logic'image(armed)(2) &
               " done=" & std_logic'image(done)(2) &
               " filled=" & integer'image(to_integer(filled));
        chk("T1 still armed",      to_integer(unsigned'("" & armed)),     1);
        chk("T1 not done",         to_integer(unsigned'("" & done)),      0);
        chk("T1 trigger not seen", to_integer(unsigned'("" & trig_seen)), 0);
        chk("T1 the ring is full", to_integer(filled), DEPTH);
        -- Deliberately NOT read out here. While armed the ring is still
        -- writing, so `oldest` moves under the reader and any readout is
        -- incoherent. A capture must be frozen before it can be trusted --
        -- the contiguity checks live in T2 and T3, where done_o is set.

        -- ---------------- T2: a full pre-trigger window -------------------
        do_reset; do_arm;
        ramp(DEPTH + 20);                      -- fill the ring several times
        pulse_trig;
        ramp(POST + 10);                       -- run past the post-trigger count
        wait for 1 ns;
        tp_val := to_integer(trig_pos);
        report "T2 late trigger   : done=" & std_logic'image(done)(2) &
               " filled=" & integer'image(to_integer(filled)) &
               " trig_pos=" & integer'image(tp_val);
        chk("T2 the capture froze",            to_integer(unsigned'("" & done)),  1);
        chk("T2 it is no longer armed",        to_integer(unsigned'("" & armed)), 0);
        chk("T2 the ring is full",             to_integer(filled), DEPTH);
        chk("T2 the pre-window is DEPTH-POST", tp_val, DEPTH - POST);
        readout;
        report "T2 readout        : first=" & integer'image(first_val) &
               " contiguous=" & integer'image(contig);
        chk("T2 readout is contiguous", contig, 1);

        -- ---------------- T3: triggering too early ------------------------
        -- Only 5 samples of history exist when the trigger fires. The block
        -- must report a SHORT capture, not pretend to a full pre-window.
        do_reset; do_arm;
        ramp(5);
        pulse_trig;
        ramp(POST + 10);
        wait for 1 ns;
        tp_val := to_integer(trig_pos);
        report "T3 early trigger  : done=" & std_logic'image(done)(2) &
               " filled=" & integer'image(to_integer(filled)) &
               " trig_pos=" & integer'image(tp_val);
        chk("T3 the capture froze", to_integer(unsigned'("" & done)), 1);
        if filled < DEPTH then chk("T3 the buffer is NOT full", 1, 1);
        else                   chk("T3 the buffer is NOT full", 0, 1); end if;
        chk("T3 the pre-window is short", tp_val, 5);
        chk("T3 filled = pre + post",     to_integer(filled), 5 + POST);
        readout;
        chk("T3 the short readout is still contiguous", contig, 1);

        -- ---------------- T4: a second trigger is ignored -----------------
        do_reset; do_arm;
        ramp(DEPTH + 4);
        pulse_trig;
        ramp(2);
        pulse_trig;                            -- must not restart the post-count
        ramp(POST + 10);
        wait for 1 ns;
        report "T4 double trigger : done=" & std_logic'image(done)(2) &
               " trig_pos=" & integer'image(to_integer(trig_pos));
        chk("T4 the capture froze",     to_integer(unsigned'("" & done)), 1);
        chk("T4 the first trigger won", to_integer(trig_pos), DEPTH - POST);

        -- ---------------- T5: re-arming starts a clean capture ------------
        do_arm;
        wait for 1 ns;
        report "T5 re-armed       : armed=" & std_logic'image(armed)(2) &
               " done=" & std_logic'image(done)(2) &
               " filled=" & integer'image(to_integer(filled)) &
               " trig_seen=" & std_logic'image(trig_seen)(2);
        chk("T5 armed again",     to_integer(unsigned'("" & armed)),     1);
        chk("T5 done cleared",    to_integer(unsigned'("" & done)),      0);
        chk("T5 history cleared", to_integer(filled), 0);
        chk("T5 trigger cleared", to_integer(unsigned'("" & trig_seen)), 0);

        -- ---------------- T6: the trigger sample is the one at trig_pos ---
        -- Freeze a capture, then read the value sitting at trig_pos and check
        -- it is the probe value that was live when the trigger was asserted.
        do_reset; do_arm;
        probe <= to_unsigned(100, W);
        ramp(DEPTH + 4);
        pulse_trig;
        at_trigger := to_integer(probe);       -- `step` drove this into the trigger clock
        ramp(POST + 10);
        rd_addr <= trig_pos(AW-1 downto 0);
        wait for 1 ns;
        report "T6 trigger sample : expected=" & integer'image(at_trigger) &
               " read=" & integer'image(to_integer(unsigned(rd_data)));
        chk("T6 trig_pos indexes the triggering sample",
            to_integer(unsigned(rd_data)), at_trigger);

        report "";
        report "== " & integer'image(checks) & " checks, " &
               integer'image(fails) & " failures ==";
        if fails = 0 then
            report "   RESULT: ALL VHDL ILA-CAPTURE TESTS PASSED";
        else
            report "   RESULT: " & integer'image(fails) & " FAILURE(S)" severity error;
        end if;
        done_s <= true;
        wait;
    end process;

end architecture sim;

8. Proving the Tests Can Fail

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  mutation                                                checks failed   verdict
  -----------------------------------------------------   -------------   -------
  M14  return the buffer in physical order, not time order             3    killed
  M15  point trig_pos one sample past the trigger                      4    killed
  M16  count the trigger sample outside the POST window                3    killed

M16 is a regression test for a bug that was actually present. The first version of this block set post_cnt to zero on the trigger clock, which meant it retained the trigger sample plus POST more — so POST did not mean what the port documentation said, and trig_pos_o came out one short of DEPTH - POST. The tests caught it, the semantics were fixed to "POST samples from the trigger inclusive", and M16 now re-applies the original mistake to confirm the tests still catch it.

9. Board Bring-Up: an Order for the Measurements

First light on a new board is where this whole module gets used at once. The sequence below is ordered so that each step's evidence is interpretable — which requires the previous step to have been settled, because almost every UART symptom has multiple candidate causes and the only way to reduce them is to eliminate in order.

  1. Clock first, everything else second. Measure the actual clock frequency at the UART's clock input — not the crystal, not the intended PLL output. A wrong clock produces a wrong baud, which produces 17.2's entire symptom set, and no amount of UART debugging will find it.
  2. Idle level. With no traffic, the line must sit at MARK. If it idles at SPACE, the polarity is inverted — a transceiver, an opto-isolator, or a missing inversion — and the receiver is seeing a permanent break, not silence.
  3. Transmit before receive. Send a continuous 0x55 and scope the pin. This exercises the divider, the shift register and the pin mux with nothing else in the loop, and 0x55 alternates so the bit period is directly measurable from the waveform (17.1).
  4. Measure the transmitted bit period and compare it against the intended baud. A mismatch here is a clock or divider fault and stops the investigation from going any further afield.
  5. Loopback at the pin, shorting TX to RX. If this passes and an external link does not, the fault is outside the FPGA. Use 0x55 and 0xAA, never 0xA5 — Chapter 17.3 §2.
  6. Then the external link, and read the candidate counter first (17.4). Zero candidates means nothing is arriving and the receiver is not the problem.
  7. Then load, watching the high-water mark and the drop classification (17.5). A link that works at low rate and fails under load is a buffering question, not a signalling one.
  8. Arm the capture for the first error and let it run. By this point you know what "normal" looks like, which is what makes an anomalous capture interpretable.

Continue learning

Where this fits

Part of the UART curriculum.