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 cyclesA 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.
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 bit | Answers |
|---|---|
the raw rx line | everything in 17.1 — the waveform itself |
| the receiver's sample strobe | where the receiver thought the bit centres were (17.2) |
frame_err, parity_err | which check failed (17.3) |
| start-candidate, start-committed | qualification, and the gap between them (17.4) |
| FIFO level, two bits | headroom 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
// ---------------------------------------------------------------------------
// 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
endmoduleSystemVerilog
// ---------------------------------------------------------------------------
// 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
endmoduleVHDL
-- ---------------------------------------------------------------------------
-- 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.
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:
- 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.
- 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.
- A start candidate that was rejected (17.4). Catches noise events that never became frames and so are invisible to every byte-level counter.
- 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
`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
endmoduleSystemVerilog
`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
endmoduleVHDL
-- ---------------------------------------------------------------------------
-- 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
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 killedM16 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.
- 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.
- 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.
- Transmit before receive. Send a continuous
0x55and scope the pin. This exercises the divider, the shift register and the pin mux with nothing else in the loop, and0x55alternates so the bit period is directly measurable from the waveform (17.1). - 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.
- Loopback at the pin, shorting TX to RX. If this passes and an external link does not, the fault is outside the FPGA. Use
0x55and0xAA, never0xA5— Chapter 17.3 §2. - 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.
- 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.
- 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
Related tutorials
- Related topic
Missing Start Bits, False Starts and Noise
A naive start detector and a majority-vote qualifier racing on one wire, a rejection boundary measured rather than assumed, and the counter that separates a transmitter that never sent from a receiver that never listened.
- Related topic
Case Study: FPGA UART Implementation and Bring-Up
Divider arithmetic and clock choice, the constraints an FPGA UART actually needs, and a self-test in three HDLs that reports a diagnosis instead of pass or fail — including a board it correctly passes while reporting it could not have failed.
- Related topic
Reading a UART Waveform as Evidence
Separating what a capture observes from what it implies, the run-length structure that makes a bit period measurable, a measuring block in three HDLs, and the ordinary payload that makes it report nine times the right answer.
- Related topic
Baud Mismatch and Sampling-Error Signatures
Why sampling error accumulates across a frame and corrupts the high bits first, the arithmetic that fixes the tolerance at 5.26 percent, and the measured drift table for five receiver dividers sharing one wire.
Where this fits
Part of the UART curriculum.
