Capstone: The UART Monitor, Checker and Bug Hunt
Write a bit-level monitor that reports at the right moment, a checker of concurrent assertions on the receiver's tick clock, and a stimulus set that exposes every fault in the bench.
- Lesson
- P6.7
- Time
- 40 min
- Runs on
- Verilator
- Check
- 5 questions
A UART monitor in a testbench reported each byte the moment it saw a start edge. It passed every directed test. During a break test it logged a burst of bytes nobody sent, the scoreboard flagged forty mismatches, and the team spent a day debugging a receiver that was fine.
Read the diagram as text
- rx wire: no clock, no frame marker
- Find the start: a falling edge, low at mid-bit
- Sample each middle: one bit-time apart
- Stop sample: the frame now exists
- Byte log: UART 55 ok / FE / PE / -- BREAK
- The twin reports here: at the start edge, too early
- rx wire → Find the start
- Find the start → Sample each middle
- Sample each middle → Stop sample
- Stop sample → Byte log: report
- Find the start → The twin reports here: report
Until the stop sample there is no frame, only a candidate. The log grammar is fixed, so checkers and problems can read it.
Report at the Stop Sample
A monitor on a clock-less wire must find the bits the way the receiver does. It finds the start, goes to the middle of each bit, and decides only after the stop sample. The bench monitor prints one of four lines.
| Line | Means |
|---|---|
UART 55 ok | a frame with a 1 stop and, if enabled, the right parity |
UART 55 FE | the stop sample read 0 |
UART a5 PE | the parity sample disagreed |
UART -- BREAK | a frame of zeros with a 0 stop, and the line still low after a frame's length |
The monitor's grammar. It follows the transmitter's format and rate, so its line is what was sent.
The bench monitor
send_break();
send(8'h55);
Measured: UART -- BREAK, UART 55 ok.
The twin: report at the start edge
// uart_mon_early: wait (rx == 0);
// report, then sample, then look again
Measured: 21 lines of UART 00 ok for the same wire (early monitor: 21 lines, monitor: 1 frame).
The twin reports before the frame exists, and it looks for a start on a line that is still low. A correct wire becomes a failing test.
The Checker
The checker is a set of concurrent assertions on the receiver's tick clock. It sees the wire through the trusted monitor, the bytes the stimulus sent, and the receiver's outputs. It never reads the receiver's insides.
both_ways = 0 switches off the inverse overrun rule.`timescale 1ns/1ps
// Rules on the receiver's tick clock. The checker watches the wire through the trusted monitor (mon_done,
// mon_value, mon_fe, mon_pe), the stimulus (exp_value, the byte that frame should carry) and the
// receiver's outputs. It never looks inside the receiver.
module uart_rules(input wire clk, rst_n, rx, take, valid, frame_err, parity_err, overrun, break_seen,
input wire [7:0] data, input wire mon_done, input wire [7:0] mon_value,
input wire mon_fe, mon_pe, input wire [7:0] exp_value, input wire frame_done);
bit both_ways = 1; // 0: only "overrun has a cause", not its inverse
bit verbose = 0;
int fired[6];
int ticks = 0, wire_frames = 0, delivered = 0, unread = 0, since_wire = -1, since_dut = -1, since_ovr = -1;
reg [7:0] dut_value = 0; reg dut_fe = 0, dut_pe = 0, valid_q = 0, ovr_q = 0;
wire dut_check = (since_dut == 12); // 12 ticks after the receiver delivered
wire wire_check = (since_wire == 16); // 16 ticks after the monitor finished a frame
wire ovr_check = (since_ovr == 40); // 40 ticks after overrun rose
always @(posedge clk) begin
if (!rst_n) begin ticks = 0; wire_frames = 0; delivered = 0; unread = 0; since_wire = -1; since_dut = -1; since_ovr = -1; end
else begin
ticks++;
if (since_wire >= 0 && since_wire < 16) since_wire++; else since_wire = -1;
if (since_dut >= 0 && since_dut < 12) since_dut++; else since_dut = -1;
if (since_ovr >= 0 && since_ovr < 40) since_ovr++; else since_ovr = -1;
if (overrun && !ovr_q) since_ovr = 0;
ovr_q = overrun;
if (mon_done) begin wire_frames++; since_wire = 0; end
if (frame_done) begin delivered++; dut_value = data; dut_fe = frame_err; dut_pe = parity_err; since_dut = 0; end
valid_q = valid;
if (take) unread = 0; else if (wire_check) unread++;
end
end
task automatic note(input int r, input string name);
fired[r]++; if (verbose) $display("RULE %s fired at tick %0d", name, ticks);
endtask
// 0: the wire carries the byte the stimulus sent, with no flag.
wire_as_sent: assert property (@(posedge clk) disable iff (!rst_n)
mon_done |-> (mon_value == exp_value && !mon_fe && !mon_pe)) else note(0, "wire_as_sent");
// 1: what the receiver delivered is what the wire carried (checked only while bytes are read).
rx_as_wire: assert property (@(posedge clk) disable iff (!rst_n || !take)
dut_check |-> (dut_value == mon_value && dut_fe == mon_fe && dut_pe == mon_pe)) else note(1, "rx_as_wire");
// 2: one delivery per frame on the wire, no more and no fewer.
one_per_frame: assert property (@(posedge clk) disable iff (!rst_n || !take)
(wire_check || dut_check) |-> delivered == wire_frames) else note(2, "one_per_frame");
// 3: overrun has a cause: at least two frames arrived since the last take.
overrun_cause: assert property (@(posedge clk) disable iff (!rst_n)
ovr_check |-> unread >= 2) else note(3, "overrun_cause");
// 4: the inverse: two unread frames must raise overrun.
overrun_flagged: assert property (@(posedge clk) disable iff (!rst_n || !both_ways)
(unread >= 2) |-> overrun) else note(4, "overrun_flagged");
// 5: nothing is delivered while the line is in a break.
quiet_in_break: assert property (@(posedge clk) disable iff (!rst_n)
break_seen |-> !frame_done) else note(5, "quiet_in_break");
endmodule
| Rule | When it looks | What must hold |
|---|---|---|
| wire_as_sent | the monitor finishes a frame | the wire carried the byte the stimulus sent, no flag |
| rx_as_wire | 12 ticks after the receiver delivers | the receiver's byte and flags equal the monitor's |
| one_per_frame | after each frame on either side | deliveries equal frames on the wire |
| overrun_cause | 40 ticks after overrun rises | at least two frames arrived since the last take |
| overrun_flagged | every tick | two unread frames have raised overrun |
| quiet_in_break | every tick | nothing is delivered while break_seen |
What the rules cannot see: the transmitter's bit period. Only a sweep of rate errors measures that (lesson P6.4).
Rules about a byte only hold while the consumer reads every byte. With take low the holding register keeps one byte and the rest are not visible, so those rules are switched off.
The Fault Matrix
| Switch | clean | +3 % | -5 % back to back | break | 3-tick glitch | no take | parity | sample-tick hit |
|---|---|---|---|---|---|---|---|---|
none | pass | pass | pass | pass | pass | pass | pass | pass |
rx0.sample_at_edge | rx_as_wire | rx_as_wire, one_per_frame | rx_as_wire | rx_as_wire | rx_as_wire | pass | rx_as_wire | rx_as_wire |
rx0.qualify_1_tick | rx_as_wire | rx_as_wire | pass | rx_as_wire | rx_as_wire, one_per_frame | pass | rx_as_wire | rx_as_wire |
rx0.no_vote | pass | pass | pass | pass | pass | pass | pass | rx_as_wire |
rx0.no_resync | rx_as_wire, one_per_frame | rx_as_wire, one_per_frame | rx_as_wire, one_per_frame | pass | pass | overrun_flagged | rx_as_wire, one_per_frame | pass |
rx0.stop_as_data | rx_as_wire | rx_as_wire | rx_as_wire | rx_as_wire | rx_as_wire | pass | rx_as_wire | rx_as_wire |
rx0.no_overrun_flag | pass | pass | pass | pass | pass | overrun_flagged | pass | pass |
rx0.parity_includes_stop | pass | pass | pass | pass | pass | pass | rx_as_wire | pass |
tx0.lsb_first = 0 | wire_as_sent | wire_as_sent | wire_as_sent | wire_as_sent | wire_as_sent | wire_as_sent | pass | wire_as_sent |
tx0.short_stop | pass | pass | wire_as_sent | pass | pass | pass | pass | pass |
tx0.parity_wrong_sense | pass | pass | pass | pass | pass | pass | wire_as_sent | pass |
tx0.parity_includes_stop | pass | pass | pass | pass | pass | pass | wire_as_sent | pass |
rx0.rearm_on_low | pass | pass | pass | rx_as_wire, one_per_frame | pass | pass | pass | pass |
tx0.no_idle_between (legal) | pass | pass | pass | pass | pass | pass | pass | pass |
Measured on Verilator, one run, 112 cells: the rules that fired, or pass. Green is right (a clean or legal bench passes, a fault is caught); amber is a fault this program misses.
The clean bench and the legal back-to-back variation pass everywhere; every fault is caught by at least one program. Read the amber cells: rx0.no_vote is caught only by sample-tick hit, and rx0.no_overrun_flag only by no take.
tx0.lsb_first = 0 passes the parity program: it sends 0xA5 and 0x3C, both their own mirror image. The stimulus decides what a checker can see.
Where You Will Meet It
Every UART verification environment has this monitor, a checker on the receiver and a fault list to prove the checker works. The matrix is how you show that: each fault caught by something, and nothing legal flagged.
Asked in interviews: monitoring an asynchronous link
When should a UART monitor report a byte?
After the stop sample. Before that there is no frame, and on a break there never will be one.
What can assertions on the receiver's clock check?
Spacing, levels at sample points, flag consistency and counts. Not the peer's bit period: that needs a sweep.
How do you know your checker works?
Run it against a bank of known faults and a legal variation: every fault must fire something, and the legal one nothing.
Try It
Three frames, a break and one more frame, with the checker bound to a correct receiver. The monitor counts four frames on the wire, the receiver delivered four bytes, and no rule fired.
Add rx0.no_vote = 1; after fresh(); and compare with that row of the matrix: does any frame here hit a sample tick? Replace the last frame with send_hit(8'h55, 88, 1); and run again. Then set ck.verbose = 1; to see where each rule fires.
checker: 4 frames on the wire, 4 bytes delivered, rules: passRead included file: uart_tx.sv
`timescale 1ns/1ps
module uart_tx(output reg tx=1);
real error=0.0; int bits=8, parity=0; real stop_bits=1.0;
bit lsb_first=1, parity_includes_start=0, parity_includes_stop=0, parity_wrong_sense=0, short_stop=0, no_idle_between=0;
task automatic bit_time(input bit v); tx=v; #(16.0*(1.0+error)); endtask
task automatic send_byte(input byte unsigned value);
bit p; p=(parity==2);
bit_time(0);
for(int b=0;b<bits;b++) begin bit_time(value[lsb_first?b:bits-1-b]); p=p^value[b]; end
if(parity!=0) bit_time(p ^ parity_includes_stop ^ parity_wrong_sense);
tx=1; #(16.0*(1.0+error)*(short_stop?0.25:stop_bits));
// no_idle_between is a legal back-to-back frame control, not a fault.
if(!no_idle_between) #(16.0*(1.0+error));
endtask
task automatic send_break(); tx=0; #(200*16); tx=1; #32; endtask
task automatic set_error(input real e); error=e; endtask
endmodule
Read included file: uart_rx.sv
`timescale 1ns/1ps
module uart_rx(input wire clk, rst_n, rx, take,
output reg [7:0] data=0, output reg valid=0, frame_err=0, parity_err=0, overrun=0, break_seen=0, frame_done=0);
int bits=8, parity=0; real stop_bits=1.0;
bit trace=0;
bit parity_includes_start=0; // XOR of the zero-valued START is an equivalent control.
bit sample_at_edge=0, qualify_1_tick=0, no_vote=0, no_resync=0, rearm_on_low=0, stop_as_data=0, no_overrun_flag=0, parity_includes_stop=0;
int state=0, low_run=0, left_ticks=0, index=0, votes=0, frames=0, ticks=0, next_data_tick=0;
bit armed=1, sampled=0, p=0, pe=0, pending_zero_fe=0; reg [7:0] shift=0;
always @(posedge clk) begin
frame_done=0;
if(!rst_n) begin state=0;low_run=0;left_ticks=0;index=0;votes=0;valid=0;data=0;frame_err=0;parity_err=0;overrun=0;break_seen=0;armed=1;frames=0;ticks=0;next_data_tick=0;pending_zero_fe=0; end
else begin
ticks++;
if(take)valid=0;
low_run=rx?0:low_run+1;
if(rx)break_seen=0;
if(low_run>=176&&!rearm_on_low)begin break_seen=1;state=4;pending_zero_fe=0;end
if(state==4)begin if(rx)begin
if(pending_zero_fe)begin
if(valid&&!no_overrun_flag)overrun=1;
data=0;valid=1;frame_err=1;parity_err=pe;frames++;frame_done=1;pending_zero_fe=0;
end
state=0;armed=1;
end end
else if(state==0)begin
if(rx)begin armed=1;end
else if(armed&&low_run>=(qualify_1_tick?1:8))begin
state=1;left_ticks=sample_at_edge?8:16;index=0;shift=0;p=(parity==2)^parity_includes_stop;votes=0;pe=0;
// Fault: later frames keep the first frame's free-running schedule.
// At nominal back-to-back pacing it can work; a new START must normally
// reset this timing so baud error cannot accumulate across frames.
if(no_resync&&frames>0)begin
while(next_data_tick<=ticks)next_data_tick+=16*(1+bits+(parity!=0))+$rtoi(16*stop_bits);
left_ticks=next_data_tick-ticks;
end else next_data_tick=ticks+left_ticks;
next_data_tick+=16*(1+bits+(parity!=0))+$rtoi(16*stop_bits);
end
end else begin
if(left_ticks<=3)votes=votes+(rx?1:0);
left_ticks=left_ticks-1;
if(left_ticks==0)begin
sampled=no_vote?rx:(votes>=2);votes=0;left_ticks=16;
if(trace)$display("UART SAMPLE t=%0t state=%0d bit=%0d value=%0d",$time,state,index,sampled);
if(state==1)begin shift[index]=sampled;p=p^sampled;index=index+1;if(index==bits)state=parity==0?3:2;end
else if(state==2)begin pe=(sampled!=p);state=3;end
else begin
if(shift==0&&!sampled&&!rearm_on_low)pending_zero_fe=1;
else begin
if(valid&&!no_overrun_flag)overrun=1;
data=stop_as_data?{sampled,shift[7:1]}:shift;
frame_err=!sampled;parity_err=pe;valid=1;frames=frames+1;frame_done=1;
end
state=(sampled||rearm_on_low)?0:4;armed=1;
end
end
end
end
end
endmodule
Read included file: uart_mon.sv
`timescale 1ns/1ps
// Independent wire decoder: completion is reported after the stop sample, never at START.
module uart_mon(input wire rx);
int bits=8,parity=0; real bit_period=16.0; int frames=0; bit trace=0;
reg [7:0] value; bit p,pe,fe;realtime began;
initial forever begin
@(negedge rx);began=$realtime; #(bit_period/2); if(rx==0)begin
value=0;p=(parity==2);pe=0;
for(int i=0;i<bits;i++)begin #(bit_period);value[i]=rx;p=p^rx;end
if(parity!=0)begin #(bit_period);pe=(rx!=p);end
#(bit_period);fe=!rx;
if(value==0&&fe)begin
while(rx==0&&$realtime-began<11*bit_period)#(bit_period/16);
if(rx==0)begin wait(rx==1);if(trace)$display("UART -- BREAK");end
else begin frames++;if(trace)$display("UART 00 FE");end
end
else begin frames++;if(trace)$display("UART %02h %s",value,fe?"FE":pe?"PE":"ok");end
end
end
endmodule
Read included file: uart_tasks.svh
task automatic send_byte(input byte unsigned b); tx0.send_byte(b); endtask
task automatic send_break(); tx0.send_break(); endtask
task automatic set_error(input real e); tx0.set_error(e); endtask
task automatic consume(); @(negedge clk);take=1;@(negedge clk);take=0;endtask
Read included file: uart_rules.sv
`timescale 1ns/1ps
// Rules on the receiver's tick clock. The checker watches the wire through the trusted monitor (mon_done,
// mon_value, mon_fe, mon_pe), the stimulus (exp_value, the byte that frame should carry) and the
// receiver's outputs. It never looks inside the receiver.
module uart_rules(input wire clk, rst_n, rx, take, valid, frame_err, parity_err, overrun, break_seen,
input wire [7:0] data, input wire mon_done, input wire [7:0] mon_value,
input wire mon_fe, mon_pe, input wire [7:0] exp_value, input wire frame_done);
bit both_ways = 1; // 0: only "overrun has a cause", not its inverse
bit verbose = 0;
int fired[6];
int ticks = 0, wire_frames = 0, delivered = 0, unread = 0, since_wire = -1, since_dut = -1, since_ovr = -1;
reg [7:0] dut_value = 0; reg dut_fe = 0, dut_pe = 0, valid_q = 0, ovr_q = 0;
wire dut_check = (since_dut == 12); // 12 ticks after the receiver delivered
wire wire_check = (since_wire == 16); // 16 ticks after the monitor finished a frame
wire ovr_check = (since_ovr == 40); // 40 ticks after overrun rose
always @(posedge clk) begin
if (!rst_n) begin ticks = 0; wire_frames = 0; delivered = 0; unread = 0; since_wire = -1; since_dut = -1; since_ovr = -1; end
else begin
ticks++;
if (since_wire >= 0 && since_wire < 16) since_wire++; else since_wire = -1;
if (since_dut >= 0 && since_dut < 12) since_dut++; else since_dut = -1;
if (since_ovr >= 0 && since_ovr < 40) since_ovr++; else since_ovr = -1;
if (overrun && !ovr_q) since_ovr = 0;
ovr_q = overrun;
if (mon_done) begin wire_frames++; since_wire = 0; end
if (frame_done) begin delivered++; dut_value = data; dut_fe = frame_err; dut_pe = parity_err; since_dut = 0; end
valid_q = valid;
if (take) unread = 0; else if (wire_check) unread++;
end
end
task automatic note(input int r, input string name);
fired[r]++; if (verbose) $display("RULE %s fired at tick %0d", name, ticks);
endtask
// 0: the wire carries the byte the stimulus sent, with no flag.
wire_as_sent: assert property (@(posedge clk) disable iff (!rst_n)
mon_done |-> (mon_value == exp_value && !mon_fe && !mon_pe)) else note(0, "wire_as_sent");
// 1: what the receiver delivered is what the wire carried (checked only while bytes are read).
rx_as_wire: assert property (@(posedge clk) disable iff (!rst_n || !take)
dut_check |-> (dut_value == mon_value && dut_fe == mon_fe && dut_pe == mon_pe)) else note(1, "rx_as_wire");
// 2: one delivery per frame on the wire, no more and no fewer.
one_per_frame: assert property (@(posedge clk) disable iff (!rst_n || !take)
(wire_check || dut_check) |-> delivered == wire_frames) else note(2, "one_per_frame");
// 3: overrun has a cause: at least two frames arrived since the last take.
overrun_cause: assert property (@(posedge clk) disable iff (!rst_n)
ovr_check |-> unread >= 2) else note(3, "overrun_cause");
// 4: the inverse: two unread frames must raise overrun.
overrun_flagged: assert property (@(posedge clk) disable iff (!rst_n || !both_ways)
(unread >= 2) |-> overrun) else note(4, "overrun_flagged");
// 5: nothing is delivered while the line is in a break.
quiet_in_break: assert property (@(posedge clk) disable iff (!rst_n)
break_seen |-> !frame_done) else note(5, "quiet_in_break");
endmodule
Read included file: uart_mon_early.sv
`timescale 1ns/1ps
// The twin: a monitor that reports at the start edge, before the frame exists, and re-arms on a low line.
module uart_mon_early(input wire rx);
reg [7:0] value = 0; bit trace = 0; int lines = 0;
initial forever begin
wait (rx == 0);
lines++; if (trace) $display("UART %02h ok", value);
#8; for (int i = 0; i < 8; i++) begin #16; value[i] = rx; end
#24;
end
endmodule
Check Yourself
Remember
- A monitor on a clock-less wire finds the bits as a receiver does and reports only after the stop sample.
- A monitor that reports at the start edge logs bytes during a break that were never sent.
- Assertions on the receiver's tick clock check counts, levels and flags against the wire, never the receiver's insides.
- A one-way rule can pass without ever looking; the inverse rule catches the missing flag.
- A fault matrix proves the checker: every fault caught by some stimulus, the legal variation green everywhere.
Practice
- A UART Monitor That Earns Its Verdict Verify · Hard
- Find Five Faulty UART Receivers Break it · Hard
- Find the Lost Frame After Recovery Debug · Hard