UART · Module 18
Case Study: SoC Boot and Debug Console UART
The 16550 interrupt identification register in three HDLs, the one extra register read that permanently hangs a console, and the counter imbalance that turns a silent board into a measurement.
The console UART is the chip's only voice. Everything else — DRAM training, clock trees, secondary cores, the boot loader — reports through it, which means every other bring-up problem is discovered via the console and none of them can be discovered before it.
That gives console failures a distinctive character. The board is alive. The cores are running. Something is very definitely happening. And the console is silent, so there is no way to find out what.
This chapter builds the register that causes the most common version of that silence, and shows why the failure is permanent rather than intermittent.
1. What Boots on the Console
The console exists before almost anything else does. That constrains it more than its complexity suggests:
| Constraint | Consequence |
|---|---|
| It runs before DRAM is trained | no buffering beyond the hardware FIFO; the driver is polling or a tiny ISR |
| It runs before the interrupt controller is configured | the first output must work with interrupts entirely disabled |
| It runs before the MMU | physical addresses, no caching, every access goes to the device |
| It is the only way to report failures | a console bug masquerades as a failure of whatever it was reporting on |
The last row is the one that makes console bugs expensive. When DRAM training fails and the console is also broken, the symptom is a silent board, and the DRAM problem is invisible behind the console problem.
2. The Register Map, and Its Acknowledge Rules
A 16550-compatible console presents a small register set. What matters for this chapter is not the layout but the acknowledge rule for each interrupt source, because they are not the same:
source IIR priority acknowledged by
----------------- --- -------- -----------------------------------
line status 0x6 1 reading LSR
data available 0x4 2 reading RBR (the data itself)
transmit empty 0x2 3 reading IIR <-- or writing THR
modem status 0x0 4 reading MSR
(nothing pending) 0x1 - -Three of the four are acknowledged by touching the thing the interrupt is about. Read the status to clear the status interrupt; read the data to clear the data interrupt.
Transmit-empty is the exception. It is acknowledged either by writing new data — which is what servicing it means — or by reading the interrupt identification register, which is what asking about it means.
3. The Register
Verilog
// ---------------------------------------------------------------------------
// uart_irq_ident -- the 16550 interrupt identification register, including the
// read-to-clear behaviour that makes a live board look dead.
//
// Four sources share one interrupt line, and the driver discovers which one
// fired by reading IIR. The register reports only the HIGHEST-priority pending
// source, and each source is acknowledged by a different action:
//
// priority 1 0x6 line status cleared by reading LSR
// priority 2 0x4 data available cleared by reading RBR
// priority 3 0x2 transmit empty cleared by reading IIR <-- the trap
// priority 4 0x0 modem status cleared by reading MSR
// 0x1 no interrupt pending
//
// THRE is the odd one out: it is acknowledged by the very act of ASKING what
// happened. A handler that reads IIR twice -- to log it, or because a shared
// handler re-reads before dispatching -- consumes the transmit interrupt with
// the first read and sees "no interrupt pending" on the second. It then
// returns without writing THR, no further THRE interrupt is generated because
// the condition never re-asserts, and the console stops dead while every other
// part of the chip keeps running.
//
// n_thre_by_iir_o counts THRE interrupts retired by an IIR read. Comparing it
// against the number of THR writes is what turns that hang from a mystery into
// a measurement.
// ---------------------------------------------------------------------------
module uart_irq_ident (
input wire clk,
input wire rst_n,
// ---- interrupt sources ----
input wire lsr_err_i, // overrun / parity / framing / break
input wire rx_avail_i, // receive data available (level)
input wire thre_i, // transmit holding register went empty
input wire msr_chg_i, // modem status changed
// ---- interrupt enables: {msr, lsr, thre, rx} ----
input wire [3:0] ier_i,
// ---- register accesses ----
input wire rd_iir_i,
input wire rd_lsr_i,
input wire rd_msr_i,
input wire wr_thr_i,
output wire [3:0] iir_o,
output wire irq_o,
// ---- instrumentation ----
output reg [15:0] n_iir_rd_o, // IIR reads
output reg [15:0] n_thre_by_iir_o, // THRE retired by an IIR read
output reg [15:0] n_thr_wr_o // THR writes (actual transmit service)
);
localparam [3:0] IIR_NONE = 4'h1,
IIR_LSR = 4'h6,
IIR_RX = 4'h4,
IIR_THRE = 4'h2,
IIR_MSR = 4'h0;
// ier_i bit assignment
localparam IER_RX = 0, IER_THRE = 1, IER_LSR = 2, IER_MSR = 3;
reg lsr_pend;
reg thre_pend;
reg msr_pend;
reg thre_q;
// Receive-data-available is a LEVEL, not a latched event: it is pending
// exactly while there is data to read, and reading RBR clears it by
// emptying the FIFO rather than by touching this block.
wire rx_pend = rx_avail_i;
wire lsr_act = lsr_pend && ier_i[IER_LSR];
wire rx_act = rx_pend && ier_i[IER_RX];
wire thre_act = thre_pend && ier_i[IER_THRE];
wire msr_act = msr_pend && ier_i[IER_MSR];
// Strict priority. Only the highest-priority active source is reported,
// which is why a driver must loop until IIR reads back "none".
assign iir_o = lsr_act ? IIR_LSR :
rx_act ? IIR_RX :
thre_act ? IIR_THRE :
msr_act ? IIR_MSR :
IIR_NONE;
assign irq_o = lsr_act || rx_act || thre_act || msr_act;
// The IIR read retires THRE only when THRE is what it reported.
wire iir_retires_thre = rd_iir_i && (iir_o == IIR_THRE);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
lsr_pend <= 1'b0;
thre_pend <= 1'b0;
msr_pend <= 1'b0;
thre_q <= 1'b0;
n_iir_rd_o <= 16'd0;
n_thre_by_iir_o <= 16'd0;
n_thr_wr_o <= 16'd0;
end else begin
thre_q <= thre_i;
// ---- line status: set on the event, cleared by reading LSR ---
if (lsr_err_i) lsr_pend <= 1'b1;
else if (rd_lsr_i) lsr_pend <= 1'b0;
// ---- modem status: set on change, cleared by reading MSR -----
if (msr_chg_i) msr_pend <= 1'b1;
else if (rd_msr_i) msr_pend <= 1'b0;
// ---- transmit empty: the read-to-clear source -----------------
// Set on the rising edge of THRE. Retired either by servicing it
// (writing THR) or merely by asking about it (reading IIR).
if (thre_i && !thre_q) begin
thre_pend <= 1'b1;
end else if (wr_thr_i || iir_retires_thre) begin
thre_pend <= 1'b0;
end
// ---- instrumentation ----
if (rd_iir_i) n_iir_rd_o <= n_iir_rd_o + 16'd1;
if (iir_retires_thre) n_thre_by_iir_o <= n_thre_by_iir_o + 16'd1;
if (wr_thr_i) n_thr_wr_o <= n_thr_wr_o + 16'd1;
end
end
endmoduleSystemVerilog
// ---------------------------------------------------------------------------
// uart_irq_ident -- the 16550 interrupt identification register, including the
// read-to-clear behaviour that makes a live board look dead.
//
// Four sources share one interrupt line, and the driver discovers which one
// fired by reading IIR. The register reports only the HIGHEST-priority pending
// source, and each source is acknowledged by a different action:
//
// priority 1 0x6 line status cleared by reading LSR
// priority 2 0x4 data available cleared by reading RBR
// priority 3 0x2 transmit empty cleared by reading IIR <-- the trap
// priority 4 0x0 modem status cleared by reading MSR
// 0x1 no interrupt pending
//
// THRE is the odd one out: it is acknowledged by the very act of ASKING what
// happened. A handler that reads IIR twice -- to log it, or because a shared
// handler re-reads before dispatching -- consumes the transmit interrupt with
// the first read and sees "no interrupt pending" on the second. It then
// returns without writing THR, no further THRE interrupt is generated because
// the condition never re-asserts, and the console stops dead while every other
// part of the chip keeps running.
//
// n_thre_by_iir_o counts THRE interrupts retired by an IIR read. Comparing it
// against the number of THR writes is what turns that hang from a mystery into
// a measurement.
// ---------------------------------------------------------------------------
module uart_irq_ident (
input logic clk,
input logic rst_n,
// ---- interrupt sources ----
input logic lsr_err_i, // overrun / parity / framing / break
input logic rx_avail_i, // receive data available (level)
input logic thre_i, // transmit holding register went empty
input logic msr_chg_i, // modem status changed
// ---- interrupt enables: {msr, lsr, thre, rx} ----
input logic [3:0] ier_i,
// ---- register accesses ----
input logic rd_iir_i,
input logic rd_lsr_i,
input logic rd_msr_i,
input logic wr_thr_i,
output logic [3:0] iir_o,
output logic irq_o,
// ---- instrumentation ----
output logic [15:0] n_iir_rd_o, // IIR reads
output logic [15:0] n_thre_by_iir_o, // THRE retired by an IIR read
output logic [15:0] n_thr_wr_o // THR writes (actual transmit service)
);
localparam [3:0] IIR_NONE = 4'h1,
IIR_LSR = 4'h6,
IIR_RX = 4'h4,
IIR_THRE = 4'h2,
IIR_MSR = 4'h0;
// ier_i bit assignment
localparam IER_RX = 0, IER_THRE = 1, IER_LSR = 2, IER_MSR = 3;
logic lsr_pend;
logic thre_pend;
logic msr_pend;
logic thre_q;
// Receive-data-available is a LEVEL, not a latched event: it is pending
// exactly while there is data to read, and reading RBR clears it by
// emptying the FIFO rather than by touching this block.
wire rx_pend = rx_avail_i;
wire lsr_act = lsr_pend && ier_i[IER_LSR];
wire rx_act = rx_pend && ier_i[IER_RX];
wire thre_act = thre_pend && ier_i[IER_THRE];
wire msr_act = msr_pend && ier_i[IER_MSR];
// Strict priority. Only the highest-priority active source is reported,
// which is why a driver must loop until IIR reads back "none".
assign iir_o = lsr_act ? IIR_LSR :
rx_act ? IIR_RX :
thre_act ? IIR_THRE :
msr_act ? IIR_MSR :
IIR_NONE;
assign irq_o = lsr_act || rx_act || thre_act || msr_act;
// The IIR read retires THRE only when THRE is what it reported.
wire iir_retires_thre = rd_iir_i && (iir_o == IIR_THRE);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
lsr_pend <= 1'b0;
thre_pend <= 1'b0;
msr_pend <= 1'b0;
thre_q <= 1'b0;
n_iir_rd_o <= 16'd0;
n_thre_by_iir_o <= 16'd0;
n_thr_wr_o <= 16'd0;
end else begin
thre_q <= thre_i;
// ---- line status: set on the event, cleared by reading LSR ---
if (lsr_err_i) lsr_pend <= 1'b1;
else if (rd_lsr_i) lsr_pend <= 1'b0;
// ---- modem status: set on change, cleared by reading MSR -----
if (msr_chg_i) msr_pend <= 1'b1;
else if (rd_msr_i) msr_pend <= 1'b0;
// ---- transmit empty: the read-to-clear source -----------------
// Set on the rising edge of THRE. Retired either by servicing it
// (writing THR) or merely by asking about it (reading IIR).
if (thre_i && !thre_q) begin
thre_pend <= 1'b1;
end else if (wr_thr_i || iir_retires_thre) begin
thre_pend <= 1'b0;
end
// ---- instrumentation ----
if (rd_iir_i) n_iir_rd_o <= n_iir_rd_o + 16'd1;
if (iir_retires_thre) n_thre_by_iir_o <= n_thre_by_iir_o + 16'd1;
if (wr_thr_i) n_thr_wr_o <= n_thr_wr_o + 16'd1;
end
end
endmoduleVHDL
-- ---------------------------------------------------------------------------
-- uart_irq_ident -- the 16550 interrupt identification register, including the
-- read-to-clear behaviour that makes a live board look dead.
--
-- Four sources share one interrupt line, and the driver discovers which one
-- fired by reading IIR. The register reports only the HIGHEST-priority pending
-- source, and each source is acknowledged by a different action:
--
-- priority 1 0x6 line status cleared by reading LSR
-- priority 2 0x4 data available cleared by reading RBR
-- priority 3 0x2 transmit empty cleared by reading IIR <-- the trap
-- priority 4 0x0 modem status cleared by reading MSR
-- 0x1 no interrupt pending
--
-- THRE is the odd one out: it is acknowledged by the very act of ASKING what
-- happened. A handler that reads IIR twice -- to log it, or because a shared
-- handler re-reads before dispatching -- consumes the transmit interrupt with
-- the first read and sees "no interrupt pending" on the second. It then
-- returns without writing THR, no further THRE interrupt is generated because
-- the condition never re-asserts, and the console stops dead while every other
-- part of the chip keeps running.
--
-- n_thre_by_iir_o counts THRE interrupts retired by an IIR read. Comparing it
-- against the number of THR writes is what turns that hang from a mystery into
-- a measurement.
-- ---------------------------------------------------------------------------
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity uart_irq_ident is
port (
clk : in std_logic;
rst_n : in std_logic;
-- interrupt sources
lsr_err_i : in std_logic; -- overrun / parity / framing / break
rx_avail_i : in std_logic; -- receive data available (level)
thre_i : in std_logic; -- transmit holding register empty
msr_chg_i : in std_logic; -- modem status changed
-- interrupt enables: (msr, lsr, thre, rx)
ier_i : in std_logic_vector(3 downto 0);
-- register accesses
rd_iir_i : in std_logic;
rd_lsr_i : in std_logic;
rd_msr_i : in std_logic;
wr_thr_i : in std_logic;
iir_o : out unsigned(3 downto 0);
irq_o : out std_logic;
-- instrumentation
n_iir_rd_o : out unsigned(15 downto 0); -- IIR reads
n_thre_by_iir_o : out unsigned(15 downto 0); -- THRE retired by a read
n_thr_wr_o : out unsigned(15 downto 0) -- THR writes (real service)
);
end entity uart_irq_ident;
architecture rtl of uart_irq_ident is
constant IIR_NONE : unsigned(3 downto 0) := x"1";
constant IIR_LSR : unsigned(3 downto 0) := x"6";
constant IIR_RX : unsigned(3 downto 0) := x"4";
constant IIR_THRE : unsigned(3 downto 0) := x"2";
constant IIR_MSR : unsigned(3 downto 0) := x"0";
-- ier_i bit assignment
constant IER_RX : natural := 0;
constant IER_THRE : natural := 1;
constant IER_LSR : natural := 2;
constant IER_MSR : natural := 3;
signal lsr_pend : std_logic := '0';
signal thre_pend: std_logic := '0';
signal msr_pend : std_logic := '0';
signal thre_q : std_logic := '0';
-- Receive-data-available is a LEVEL, not a latched event: it is pending
-- exactly while there is data to read, and reading RBR clears it by
-- emptying the FIFO rather than by touching this block.
signal rx_pend : std_logic;
signal lsr_act, rx_act, thre_act, msr_act : std_logic;
signal iir : unsigned(3 downto 0);
signal iir_retires_thre : std_logic;
signal n_iir_rd, n_thre_by_iir, n_thr_wr : unsigned(15 downto 0)
:= (others => '0');
begin
rx_pend <= rx_avail_i;
lsr_act <= lsr_pend and ier_i(IER_LSR);
rx_act <= rx_pend and ier_i(IER_RX);
thre_act <= thre_pend and ier_i(IER_THRE);
msr_act <= msr_pend and ier_i(IER_MSR);
-- Strict priority. Only the highest-priority active source is reported,
-- which is why a driver must loop until IIR reads back "none".
iir <= IIR_LSR when lsr_act = '1' else
IIR_RX when rx_act = '1' else
IIR_THRE when thre_act = '1' else
IIR_MSR when msr_act = '1' else
IIR_NONE;
iir_o <= iir;
irq_o <= lsr_act or rx_act or thre_act or msr_act;
-- The IIR read retires THRE only when THRE is what it reported.
iir_retires_thre <= '1' when (rd_iir_i = '1' and iir = IIR_THRE) else '0';
n_iir_rd_o <= n_iir_rd;
n_thre_by_iir_o <= n_thre_by_iir;
n_thr_wr_o <= n_thr_wr;
process (clk, rst_n)
begin
if rst_n = '0' then
lsr_pend <= '0';
thre_pend <= '0';
msr_pend <= '0';
thre_q <= '0';
n_iir_rd <= (others => '0');
n_thre_by_iir <= (others => '0');
n_thr_wr <= (others => '0');
elsif rising_edge(clk) then
thre_q <= thre_i;
-- ---- line status: set on the event, cleared by reading LSR ---
if lsr_err_i = '1' then
lsr_pend <= '1';
elsif rd_lsr_i = '1' then
lsr_pend <= '0';
end if;
-- ---- modem status: set on change, cleared by reading MSR -----
if msr_chg_i = '1' then
msr_pend <= '1';
elsif rd_msr_i = '1' then
msr_pend <= '0';
end if;
-- ---- transmit empty: the read-to-clear source -----------------
-- Set on the rising edge of THRE. Retired either by servicing it
-- (writing THR) or merely by asking about it (reading IIR).
if thre_i = '1' and thre_q = '0' then
thre_pend <= '1';
elsif wr_thr_i = '1' or iir_retires_thre = '1' then
thre_pend <= '0';
end if;
-- ---- instrumentation ----
if rd_iir_i = '1' then
n_iir_rd <= n_iir_rd + 1;
end if;
if iir_retires_thre = '1' then
n_thre_by_iir <= n_thre_by_iir + 1;
end if;
if wr_thr_i = '1' then
n_thr_wr <= n_thr_wr + 1;
end if;
end if;
end process;
end architecture rtl;The instrumentation at the bottom is the part most real IPs lack and the part that matters here. n_thre_by_iir_o counts transmit-empty interrupts retired by an IIR read; n_thr_wr_o counts actual transmit service. In a healthy system those track each other. §5 shows what their divergence means.
4. Two Drivers, Identical Hardware
Here are the two interrupt handlers, reduced to the part that differs:
CORRECT BUGGY
----------------------------- -----------------------------
id = read(IIR); id = read(IIR); // for the log
log(id);
id = read(IIR); // now dispatch
if (id == THRE) write(THR, ch); if (id == THRE) write(THR, ch);The buggy version is not careless in any way that looks careless. Reading a register twice is free on every other source. Logging before dispatching is good practice. Shared interrupt handlers commonly re-read an identification register after calling into a sub-handler. Each individual decision is defensible.
5. The Measurement
Both handlers were run against the same register block:
handler IIR reads THRE retired by read THR writes IRQ after
-------- --------- -------------------- ---------- ---------
correct 1 1 1 0
buggy 2 1 0 0The correct handler's counters balance: one interrupt retired, one transmit serviced.
The buggy handler's do not. One interrupt was retired and nothing was transmitted. That imbalance is the signature, and it is directly readable from two counters.
One extra read, and the interrupt is gone
12 cyclesWhy it never recovers
The interrupt line is low after both handlers, which looks the same. It is not:
200 clocks later: irq = 0 iir = 0x1 (nothing pending)The transmit-empty interrupt is generated on the rising edge of the empty condition. After the buggy handler, the transmitter is still empty — it was never given anything to send — so the condition is still true and will not rise again. No further interrupt is possible.
Meanwhile the driver is waiting for an interrupt that cannot come, and the transmitter is waiting for data that will not arrive. Neither side is spinning, neither side has crashed, and nothing will ever change.
6. What Makes This Survive Review
The bug is in the driver, and the hardware is behaving exactly as the 16550 specification requires. So it is worth asking why it keeps happening.
- It is invisible in the common case. A driver that reads IIR once works perfectly. The second read is added later — for logging, for a shared handler, for a debug build — by someone who has no reason to think a read has a side effect.
- It passes on other sources. Reading IIR twice is harmless when the pending source is data-available or line-status, because those are acknowledged elsewhere. The bug only appears when the pending source is transmit-empty, which on a console is most output.
- It looks like a different bug. The console dies mid-line, often part-way through a message, which reads as a crash in whatever was printing.
- It is timing-dependent at first. Early boot output is often polled, not interrupt-driven. The failure appears only once the driver switches to interrupts, which on many systems is after the first few lines have already printed successfully — making the console look like it was working.
7. The Testbench
Verilog
`timescale 1ns/1ps
// ---------------------------------------------------------------------------
// Testbench for uart_irq_ident.
//
// Two driver models are run against identical hardware. The only difference
// between them is that one reads IIR once and the other reads it twice -- a
// change that looks entirely harmless and is the difference between a working
// console and a dead one.
//
// T5 does not merely show the hang. It shows the hang PERSISTING: after the
// buggy handler returns, the interrupt line stays low forever, because THRE is
// edge-triggered and the transmitter has no reason to become empty again. The
// board is alive and the console is gone.
// ---------------------------------------------------------------------------
module tb_uart_irq_ident;
localparam [3:0] IIR_NONE=4'h1, IIR_LSR=4'h6, IIR_RX=4'h4,
IIR_THRE=4'h2, IIR_MSR=4'h0;
reg clk = 1'b0;
reg rst_n = 1'b0;
reg lsr_err = 1'b0, rx_avail = 1'b0, thre = 1'b0, msr_chg = 1'b0;
reg [3:0] ier = 4'hF;
reg rd_iir = 1'b0, rd_lsr = 1'b0, rd_msr = 1'b0, wr_thr = 1'b0;
wire [3:0] iir;
wire irq;
wire [15:0] n_iir_rd, n_thre_by_iir, n_thr_wr;
integer checks = 0;
integer fails = 0;
reg [3:0] seen; // what the last IIR read returned
always #5 clk = ~clk;
uart_irq_ident dut (
.clk(clk), .rst_n(rst_n),
.lsr_err_i(lsr_err), .rx_avail_i(rx_avail), .thre_i(thre), .msr_chg_i(msr_chg),
.ier_i(ier),
.rd_iir_i(rd_iir), .rd_lsr_i(rd_lsr), .rd_msr_i(rd_msr), .wr_thr_i(wr_thr),
.iir_o(iir), .irq_o(irq),
.n_iir_rd_o(n_iir_rd), .n_thre_by_iir_o(n_thre_by_iir), .n_thr_wr_o(n_thr_wr));
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 %0h expected %0h", name, got, exp);
end
end
endtask
task do_reset;
begin
lsr_err=0; rx_avail=0; thre=0; msr_chg=0; ier=4'hF;
rd_iir=0; rd_lsr=0; rd_msr=0; wr_thr=0; rst_n=0;
repeat (3) @(posedge clk);
@(negedge clk); rst_n = 1'b1;
repeat (2) @(posedge clk);
end
endtask
// ---- bus cycles, each one clock wide ----
task read_iir; // captures the value the read returned
begin
@(negedge clk); rd_iir = 1'b1; seen = iir;
@(posedge clk);
@(negedge clk); rd_iir = 1'b0;
end
endtask
task read_lsr; begin @(negedge clk); rd_lsr=1'b1; @(posedge clk); @(negedge clk); rd_lsr=1'b0; end endtask
task read_msr; begin @(negedge clk); rd_msr=1'b1; @(posedge clk); @(negedge clk); rd_msr=1'b0; end endtask
task write_thr; begin @(negedge clk); wr_thr=1'b1; @(posedge clk); @(negedge clk); wr_thr=1'b0; end endtask
task pulse_thre; // the transmitter drains and goes empty
begin
@(negedge clk); thre = 1'b0;
@(posedge clk);
@(negedge clk); thre = 1'b1;
@(posedge clk);
end
endtask
// ---- the two driver models ----
// Correct: read IIR once, dispatch on what it returned.
task driver_good;
begin
read_iir;
if (seen == IIR_THRE) write_thr;
end
endtask
// Buggy: read IIR to log it, then read it again to dispatch. The first
// read has already retired THRE, so the second returns "no interrupt".
task driver_bad;
begin
read_iir; // "let's log what fired"
read_iir; // "now let's see what to do"
if (seen == IIR_THRE) write_thr;
end
endtask
integer i;
initial begin
// ---------------- T1: strict priority -----------------------------
do_reset;
@(negedge clk); lsr_err=1'b1; rx_avail=1'b1; msr_chg=1'b1;
@(posedge clk);
@(negedge clk); lsr_err=1'b0; msr_chg=1'b0;
pulse_thre;
#1;
$display("T1 all four pending : iir=%0h irq=%0b", iir, irq);
chk("T1 line status wins", iir, IIR_LSR);
chk("T1 interrupt asserted", irq, 1);
// ---------------- T2: they drain in priority order ----------------
read_lsr; #1; chk("T2 then data available", iir, IIR_RX);
@(negedge clk); rx_avail = 1'b0; #1;
chk("T2 then transmit empty", iir, IIR_THRE);
read_iir; #1; // retires THRE
chk("T2 then modem status", iir, IIR_MSR);
read_msr; #1;
$display("T2 fully drained : iir=%0h irq=%0b", iir, irq);
chk("T2 nothing left", iir, IIR_NONE);
chk("T2 interrupt deasserted", irq, 0);
// ---------------- T3: a masked source raises nothing --------------
do_reset;
@(negedge clk); ier = 4'b0000;
pulse_thre;
#1;
chk("T3 masked THRE is not reported", iir, IIR_NONE);
chk("T3 masked THRE raises no irq", irq, 0);
@(negedge clk); ier = 4'b0010; // enable THRE only
#1;
chk("T3 unmasking reveals it", iir, IIR_THRE);
// ---------------- T4: the correct driver --------------------------
do_reset;
pulse_thre;
#1;
chk("T4 THRE is pending", iir, IIR_THRE);
driver_good;
#1;
$display("T4 correct driver : iir_reads=%0d thre_by_iir=%0d thr_writes=%0d irq=%0b",
n_iir_rd, n_thre_by_iir, n_thr_wr, irq);
chk("T4 the transmitter was serviced", n_thr_wr, 1);
chk("T4 service balances the retire", n_thre_by_iir, n_thr_wr);
chk("T4 interrupt cleared", irq, 0);
// ---------------- T5: the buggy driver ----------------------------
// Identical hardware. One extra IIR read.
do_reset;
pulse_thre;
#1;
chk("T5 THRE is pending", iir, IIR_THRE);
driver_bad;
#1;
$display("T5 buggy driver : iir_reads=%0d thre_by_iir=%0d thr_writes=%0d irq=%0b",
n_iir_rd, n_thre_by_iir, n_thr_wr, irq);
chk("T5 the second read saw nothing", seen, IIR_NONE);
chk("T5 the transmitter was NOT serviced", n_thr_wr, 0);
chk("T5 but the interrupt WAS retired", n_thre_by_iir, 1);
// ---------------- T5b: and the hang is permanent ------------------
// THRE is edge-triggered and the transmitter is already empty, so no
// further interrupt will ever be generated. Nothing recovers this.
for (i = 0; i < 200; i = i + 1) @(posedge clk);
#1;
$display("T5b 200 clocks later : irq=%0b iir=%0h <-- console is dead", irq, iir);
chk("T5b still no interrupt", irq, 0);
chk("T5b still nothing pending", iir, IIR_NONE);
chk("T5b the imbalance is the signature",
(n_thre_by_iir > n_thr_wr) ? 1 : 0, 1);
// ---------------- T6: writing THR also acknowledges ---------------
do_reset;
pulse_thre;
#1;
chk("T6 THRE pending", iir, IIR_THRE);
write_thr;
#1;
$display("T6 THR write ack : iir=%0h thre_by_iir=%0d", iir, n_thre_by_iir);
chk("T6 THR write cleared it", iir, IIR_NONE);
chk("T6 and no IIR read was needed", n_thre_by_iir, 0);
// ---------------- T7: THRE needs a fresh edge to re-arm -----------
pulse_thre;
#1;
chk("T7 a new edge re-arms it", iir, IIR_THRE);
$display("");
$display("== %0d checks, %0d failures ==", checks, fails);
if (fails == 0) $display(" RESULT: ALL VERILOG IRQ-IDENT TESTS PASSED");
else $display(" RESULT: %0d FAILURE(S)", fails);
$finish;
end
endmoduleSystemVerilog
`timescale 1ns/1ps
// ---------------------------------------------------------------------------
// Testbench for uart_irq_ident.
//
// Two driver models are run against identical hardware. The only difference
// between them is that one reads IIR once and the other reads it twice -- a
// change that looks entirely harmless and is the difference between a working
// console and a dead one.
//
// T5 does not merely show the hang. It shows the hang PERSISTING: after the
// buggy handler returns, the interrupt line stays low forever, because THRE is
// edge-triggered and the transmitter has no reason to become empty again. The
// board is alive and the console is gone.
// ---------------------------------------------------------------------------
module tb_uart_irq_ident;
localparam [3:0] IIR_NONE=4'h1, IIR_LSR=4'h6, IIR_RX=4'h4,
IIR_THRE=4'h2, IIR_MSR=4'h0;
logic clk = 1'b0;
logic rst_n = 1'b0;
logic lsr_err = 1'b0, rx_avail = 1'b0, thre = 1'b0, msr_chg = 1'b0;
logic [3:0] ier = 4'hF;
logic rd_iir = 1'b0, rd_lsr = 1'b0, rd_msr = 1'b0, wr_thr = 1'b0;
logic [3:0] iir;
logic irq;
logic [15:0] n_iir_rd, n_thre_by_iir, n_thr_wr;
integer checks = 0;
integer fails = 0;
logic [3:0] seen; // what the last IIR read returned
always #5 clk = ~clk;
uart_irq_ident dut (
.clk(clk), .rst_n(rst_n),
.lsr_err_i(lsr_err), .rx_avail_i(rx_avail), .thre_i(thre), .msr_chg_i(msr_chg),
.ier_i(ier),
.rd_iir_i(rd_iir), .rd_lsr_i(rd_lsr), .rd_msr_i(rd_msr), .wr_thr_i(wr_thr),
.iir_o(iir), .irq_o(irq),
.n_iir_rd_o(n_iir_rd), .n_thre_by_iir_o(n_thre_by_iir), .n_thr_wr_o(n_thr_wr));
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 %0h expected %0h", name, got, exp);
end
end
endtask
task automatic do_reset();
begin
lsr_err=0; rx_avail=0; thre=0; msr_chg=0; ier=4'hF;
rd_iir=0; rd_lsr=0; rd_msr=0; wr_thr=0; rst_n=0;
repeat (3) @(posedge clk);
@(negedge clk); rst_n = 1'b1;
repeat (2) @(posedge clk);
end
endtask
// ---- bus cycles, each one clock wide ----
task automatic read_iir(); // captures the value the read returned
begin
@(negedge clk); rd_iir = 1'b1; seen = iir;
@(posedge clk);
@(negedge clk); rd_iir = 1'b0;
end
endtask
task automatic read_lsr(); begin @(negedge clk); rd_lsr=1'b1; @(posedge clk); @(negedge clk); rd_lsr=1'b0; end endtask
task automatic read_msr(); begin @(negedge clk); rd_msr=1'b1; @(posedge clk); @(negedge clk); rd_msr=1'b0; end endtask
task automatic write_thr(); begin @(negedge clk); wr_thr=1'b1; @(posedge clk); @(negedge clk); wr_thr=1'b0; end endtask
task automatic pulse_thre(); // the transmitter drains and goes empty
begin
@(negedge clk); thre = 1'b0;
@(posedge clk);
@(negedge clk); thre = 1'b1;
@(posedge clk);
end
endtask
// ---- the two driver models ----
// Correct: read IIR once, dispatch on what it returned.
task automatic driver_good();
begin
read_iir;
if (seen == IIR_THRE) write_thr;
end
endtask
// Buggy: read IIR to log it, then read it again to dispatch. The first
// read has already retired THRE, so the second returns "no interrupt".
task automatic driver_bad();
begin
read_iir; // "let's log what fired"
read_iir; // "now let's see what to do"
if (seen == IIR_THRE) write_thr;
end
endtask
integer i;
initial begin
// ---------------- T1: strict priority -----------------------------
do_reset;
@(negedge clk); lsr_err=1'b1; rx_avail=1'b1; msr_chg=1'b1;
@(posedge clk);
@(negedge clk); lsr_err=1'b0; msr_chg=1'b0;
pulse_thre;
#1;
$display("T1 all four pending : iir=%0h irq=%0b", iir, irq);
chk("T1 line status wins", iir, IIR_LSR);
chk("T1 interrupt asserted", irq, 1);
// ---------------- T2: they drain in priority order ----------------
read_lsr; #1; chk("T2 then data available", iir, IIR_RX);
@(negedge clk); rx_avail = 1'b0; #1;
chk("T2 then transmit empty", iir, IIR_THRE);
read_iir; #1; // retires THRE
chk("T2 then modem status", iir, IIR_MSR);
read_msr; #1;
$display("T2 fully drained : iir=%0h irq=%0b", iir, irq);
chk("T2 nothing left", iir, IIR_NONE);
chk("T2 interrupt deasserted", irq, 0);
// ---------------- T3: a masked source raises nothing --------------
do_reset;
@(negedge clk); ier = 4'b0000;
pulse_thre;
#1;
chk("T3 masked THRE is not reported", iir, IIR_NONE);
chk("T3 masked THRE raises no irq", irq, 0);
@(negedge clk); ier = 4'b0010; // enable THRE only
#1;
chk("T3 unmasking reveals it", iir, IIR_THRE);
// ---------------- T4: the correct driver --------------------------
do_reset;
pulse_thre;
#1;
chk("T4 THRE is pending", iir, IIR_THRE);
driver_good;
#1;
$display("T4 correct driver : iir_reads=%0d thre_by_iir=%0d thr_writes=%0d irq=%0b",
n_iir_rd, n_thre_by_iir, n_thr_wr, irq);
chk("T4 the transmitter was serviced", n_thr_wr, 1);
chk("T4 service balances the retire", n_thre_by_iir, n_thr_wr);
chk("T4 interrupt cleared", irq, 0);
// ---------------- T5: the buggy driver ----------------------------
// Identical hardware. One extra IIR read.
do_reset;
pulse_thre;
#1;
chk("T5 THRE is pending", iir, IIR_THRE);
driver_bad;
#1;
$display("T5 buggy driver : iir_reads=%0d thre_by_iir=%0d thr_writes=%0d irq=%0b",
n_iir_rd, n_thre_by_iir, n_thr_wr, irq);
chk("T5 the second read saw nothing", seen, IIR_NONE);
chk("T5 the transmitter was NOT serviced", n_thr_wr, 0);
chk("T5 but the interrupt WAS retired", n_thre_by_iir, 1);
// ---------------- T5b: and the hang is permanent ------------------
// THRE is edge-triggered and the transmitter is already empty, so no
// further interrupt will ever be generated. Nothing recovers this.
for (i = 0; i < 200; i = i + 1) @(posedge clk);
#1;
$display("T5b 200 clocks later : irq=%0b iir=%0h <-- console is dead", irq, iir);
chk("T5b still no interrupt", irq, 0);
chk("T5b still nothing pending", iir, IIR_NONE);
chk("T5b the imbalance is the signature",
(n_thre_by_iir > n_thr_wr) ? 1 : 0, 1);
// ---------------- T6: writing THR also acknowledges ---------------
do_reset;
pulse_thre;
#1;
chk("T6 THRE pending", iir, IIR_THRE);
write_thr;
#1;
$display("T6 THR write ack : iir=%0h thre_by_iir=%0d", iir, n_thre_by_iir);
chk("T6 THR write cleared it", iir, IIR_NONE);
chk("T6 and no IIR read was needed", n_thre_by_iir, 0);
// ---------------- T7: THRE needs a fresh edge to re-arm -----------
pulse_thre;
#1;
chk("T7 a new edge re-arms it", iir, IIR_THRE);
$display("");
$display("== %0d checks, %0d failures ==", checks, fails);
if (fails == 0) $display(" RESULT: ALL SYSTEMVERILOG IRQ-IDENT TESTS PASSED");
else $display(" RESULT: %0d FAILURE(S)", fails);
$finish;
end
endmoduleVHDL
-- ---------------------------------------------------------------------------
-- Testbench for uart_irq_ident.
--
-- Two driver models are run against identical hardware. The only difference
-- between them is that one reads IIR once and the other reads it twice -- a
-- change that looks entirely harmless and is the difference between a working
-- console and a dead one.
--
-- T5 does not merely show the hang. It shows the hang PERSISTING: after the
-- buggy handler returns, the interrupt line stays low forever, because THRE is
-- edge-triggered and the transmitter has no reason to become empty again. The
-- board is alive and the console is gone.
-- ---------------------------------------------------------------------------
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity tb_uart_irq_ident is
end entity tb_uart_irq_ident;
architecture sim of tb_uart_irq_ident is
constant TCLK : time := 10 ns;
constant IIR_NONE : unsigned(3 downto 0) := x"1";
constant IIR_LSR : unsigned(3 downto 0) := x"6";
constant IIR_RX : unsigned(3 downto 0) := x"4";
constant IIR_THRE : unsigned(3 downto 0) := x"2";
constant IIR_MSR : unsigned(3 downto 0) := x"0";
signal clk : std_logic := '0';
signal rst_n : std_logic := '0';
signal lsr_err : std_logic := '0';
signal rx_avail : std_logic := '0';
signal thre : std_logic := '0';
signal msr_chg : std_logic := '0';
signal ier : std_logic_vector(3 downto 0) := "1111";
signal rd_iir : std_logic := '0';
signal rd_lsr : std_logic := '0';
signal rd_msr : std_logic := '0';
signal wr_thr : std_logic := '0';
signal sim_done : boolean := false;
signal iir : unsigned(3 downto 0);
signal irq : std_logic;
signal n_iir_rd, n_thre_by_iir, n_thr_wr : unsigned(15 downto 0);
signal seen : unsigned(3 downto 0) := IIR_NONE; -- last value IIR returned
begin
clk <= '0' when sim_done else not clk after TCLK/2;
dut : entity work.uart_irq_ident
port map (clk => clk, rst_n => rst_n,
lsr_err_i => lsr_err, rx_avail_i => rx_avail,
thre_i => thre, msr_chg_i => msr_chg, ier_i => ier,
rd_iir_i => rd_iir, rd_lsr_i => rd_lsr,
rd_msr_i => rd_msr, wr_thr_i => wr_thr,
iir_o => iir, irq_o => irq,
n_iir_rd_o => n_iir_rd, n_thre_by_iir_o => n_thre_by_iir,
n_thr_wr_o => n_thr_wr);
stim : process
variable checks, fails : 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
lsr_err <= '0'; rx_avail <= '0'; thre <= '0'; msr_chg <= '0';
ier <= "1111"; rd_iir <= '0'; rd_lsr <= '0'; rd_msr <= '0';
wr_thr <= '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;
-- ---- bus cycles, each one clock wide ----
procedure read_iir is -- captures the value the read returned
begin
wait until falling_edge(clk);
rd_iir <= '1'; seen <= iir;
wait until rising_edge(clk);
wait until falling_edge(clk); rd_iir <= '0';
end procedure;
procedure read_lsr is
begin
wait until falling_edge(clk); rd_lsr <= '1';
wait until rising_edge(clk);
wait until falling_edge(clk); rd_lsr <= '0';
end procedure;
procedure read_msr is
begin
wait until falling_edge(clk); rd_msr <= '1';
wait until rising_edge(clk);
wait until falling_edge(clk); rd_msr <= '0';
end procedure;
procedure write_thr is
begin
wait until falling_edge(clk); wr_thr <= '1';
wait until rising_edge(clk);
wait until falling_edge(clk); wr_thr <= '0';
end procedure;
procedure pulse_thre is -- the transmitter drains and goes empty
begin
wait until falling_edge(clk); thre <= '0';
wait until rising_edge(clk);
wait until falling_edge(clk); thre <= '1';
wait until rising_edge(clk);
end procedure;
-- ---- the two driver models ----
-- Correct: read IIR once, dispatch on what it returned.
procedure driver_good is
begin
read_iir;
if seen = IIR_THRE then write_thr; end if;
end procedure;
-- Buggy: read IIR to log it, then read it again to dispatch. The first
-- read has already retired THRE, so the second returns "no interrupt".
procedure driver_bad is
begin
read_iir; -- "let's log what fired"
read_iir; -- "now let's see what to do"
if seen = IIR_THRE then write_thr; end if;
end procedure;
begin
-- ---------------- T1: strict priority -----------------------------
do_reset;
wait until falling_edge(clk);
lsr_err <= '1'; rx_avail <= '1'; msr_chg <= '1';
wait until rising_edge(clk);
wait until falling_edge(clk); lsr_err <= '0'; msr_chg <= '0';
pulse_thre;
wait for 1 ns;
report "T1 all four pending : iir=" & integer'image(to_integer(iir)) &
" irq=" & std_logic'image(irq)(2);
chk("T1 line status wins", to_integer(iir), to_integer(IIR_LSR));
chk("T1 interrupt asserted", to_integer(unsigned'("" & irq)), 1);
-- ---------------- T2: they drain in priority order ----------------
read_lsr; wait for 1 ns;
chk("T2 then data available", to_integer(iir), to_integer(IIR_RX));
wait until falling_edge(clk); rx_avail <= '0'; wait for 1 ns;
chk("T2 then transmit empty", to_integer(iir), to_integer(IIR_THRE));
read_iir; wait for 1 ns; -- retires THRE
chk("T2 then modem status", to_integer(iir), to_integer(IIR_MSR));
read_msr; wait for 1 ns;
report "T2 fully drained : iir=" & integer'image(to_integer(iir)) &
" irq=" & std_logic'image(irq)(2);
chk("T2 nothing left", to_integer(iir), to_integer(IIR_NONE));
chk("T2 interrupt deasserted", to_integer(unsigned'("" & irq)), 0);
-- ---------------- T3: a masked source raises nothing --------------
do_reset;
wait until falling_edge(clk); ier <= "0000";
pulse_thre;
wait for 1 ns;
chk("T3 masked THRE is not reported", to_integer(iir), to_integer(IIR_NONE));
chk("T3 masked THRE raises no irq", to_integer(unsigned'("" & irq)), 0);
wait until falling_edge(clk); ier <= "0010"; -- enable THRE only
wait for 1 ns;
chk("T3 unmasking reveals it", to_integer(iir), to_integer(IIR_THRE));
-- ---------------- T4: the correct driver --------------------------
do_reset;
pulse_thre;
wait for 1 ns;
chk("T4 THRE is pending", to_integer(iir), to_integer(IIR_THRE));
driver_good;
wait for 1 ns;
report "T4 correct driver : iir_reads=" & integer'image(to_integer(n_iir_rd)) &
" thre_by_iir=" & integer'image(to_integer(n_thre_by_iir)) &
" thr_writes=" & integer'image(to_integer(n_thr_wr)) &
" irq=" & std_logic'image(irq)(2);
chk("T4 the transmitter was serviced", to_integer(n_thr_wr), 1);
chk("T4 service balances the retire", to_integer(n_thre_by_iir), to_integer(n_thr_wr));
chk("T4 interrupt cleared", to_integer(unsigned'("" & irq)), 0);
-- ---------------- T5: the buggy driver ----------------------------
-- Identical hardware. One extra IIR read.
do_reset;
pulse_thre;
wait for 1 ns;
chk("T5 THRE is pending", to_integer(iir), to_integer(IIR_THRE));
driver_bad;
wait for 1 ns;
report "T5 buggy driver : iir_reads=" & integer'image(to_integer(n_iir_rd)) &
" thre_by_iir=" & integer'image(to_integer(n_thre_by_iir)) &
" thr_writes=" & integer'image(to_integer(n_thr_wr)) &
" irq=" & std_logic'image(irq)(2);
chk("T5 the second read saw nothing", to_integer(seen), to_integer(IIR_NONE));
chk("T5 the transmitter was NOT serviced", to_integer(n_thr_wr), 0);
chk("T5 but the interrupt WAS retired", to_integer(n_thre_by_iir), 1);
-- ---------------- T5b: and the hang is permanent ------------------
-- THRE is edge-triggered and the transmitter is already empty, so no
-- further interrupt will ever be generated. Nothing recovers this.
for i in 0 to 199 loop wait until rising_edge(clk); end loop;
wait for 1 ns;
report "T5b 200 clocks later : irq=" & std_logic'image(irq)(2) &
" iir=" & integer'image(to_integer(iir)) & " <-- console is dead";
chk("T5b still no interrupt", to_integer(unsigned'("" & irq)), 0);
chk("T5b still nothing pending", to_integer(iir), to_integer(IIR_NONE));
if n_thre_by_iir > n_thr_wr then
chk("T5b the imbalance is the signature", 1, 1);
else
chk("T5b the imbalance is the signature", 0, 1);
end if;
-- ---------------- T6: writing THR also acknowledges ---------------
do_reset;
pulse_thre;
wait for 1 ns;
chk("T6 THRE pending", to_integer(iir), to_integer(IIR_THRE));
write_thr;
wait for 1 ns;
report "T6 THR write ack : iir=" & integer'image(to_integer(iir)) &
" thre_by_iir=" & integer'image(to_integer(n_thre_by_iir));
chk("T6 THR write cleared it", to_integer(iir), to_integer(IIR_NONE));
chk("T6 and no IIR read was needed", to_integer(n_thre_by_iir), 0);
-- ---------------- T7: THRE needs a fresh edge to re-arm -----------
pulse_thre;
wait for 1 ns;
chk("T7 a new edge re-arms it", to_integer(iir), to_integer(IIR_THRE));
report "";
report "== " & integer'image(checks) & " checks, " &
integer'image(fails) & " failures ==";
if fails = 0 then
report " RESULT: ALL VHDL IRQ-IDENT TESTS PASSED";
else
report " RESULT: " & integer'image(fails) & " FAILURE(S)" severity error;
end if;
sim_done <= true;
wait;
end process;
end architecture sim;Twenty-five checks per language. T1 and T2 establish the priority encoding and that sources drain in order; T3 covers masking; T4 and T5 are the two handlers; T5b runs 200 clocks past the hang to show it is permanent; T6 and T7 confirm the other acknowledge path and the edge-triggered re-arm.
8. Proving the Tests Can Fail
mutation checks failed verdict
------------------------------------------------------ ------------- -------
M4 let any IIR read retire THRE, not just a THRE read 1 killed
M5 swap data-available and transmit-empty priority 1 killed
M6 remove read-to-clear entirely 6 killedM6 is the interesting one, because it is a mutation that fixes the bug. Removing the IIR read's ability to retire THRE makes the buggy driver work.
Six checks fail — the ones that assert the hang happens. That is correct and important: this behaviour is specified, other software depends on the single-read idiom, and a test suite that did not notice its removal would not be protecting the specification. A test that pins down a surprising but required behaviour is doing exactly its job, and it is the reason M6 must be killed rather than tolerated.
9. Design Guidance
For the hardware:
- Instrument the acknowledge paths. Two counters — interrupts retired by a read, and actual services — cost almost nothing and turn this class of hang into a register read.
- Consider a non-destructive alias. A read-only mirror of IIR that reports without retiring makes logging safe. Several modern UARTs provide one.
- Document the side effect at the register, not in a footnote. "Reading this register clears the THRE interrupt" belongs in the same table row as the field description.
For the driver:
- Read IIR exactly once per interrupt and dispatch from that value. Never re-read to decide.
- Loop on the value you already have, servicing sources until IIR reports nothing pending — the priority encoding means one read per iteration, not one per source.
- Be suspicious of any register read in a log statement. On a device, a read is a transaction with side effects, not an inspection.
Continue learning
Related tutorials
- 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
Case Study: Verifying and Debugging a Production UART IP
A UART taken to sign-off with full functional and code coverage, the silicon escape that followed, the malformed frame measured off the wire in three HDLs, and the missing coverage axis that explains both.
- Related topic
USB Interrupt Integration
Hardware sets a status bit on the same cycle the driver writes 1 to clear it, and if the clear wins that interrupt is not delayed — it is gone, along with every event behind it.
- 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.
Where this fits
Part of the UART curriculum.
