UVM
Case Study — A UART Agent
Build a UVM agent for UART, a serial asynchronous interface with no shared clock — the frame (start, data LSB-first, parity, stop), a driver that holds each bit for one baud period, a monitor that detects the start edge and samples in the middle of every bit, and the mid-bit-sampling bug that turns a working agent into one that reads garbage.
Industry Case Studies · Module 30 · Page 30.3
The Engineering Problem
APB was single-channel and synchronous; AXI added concurrent channels. UART changes a different axis entirely: it is serial and asynchronous — one wire, one bit at a time, and no shared clock. The transmitter and receiver never exchange a clock; they only agree, in advance, on a baud rate (the bit period). Everything else the receiver must recover from the data itself: it watches an idle-high line for the start bit (a falling edge), then ticks at the agreed rate, sampling each bit in the middle where the line is stable.
The agent's shape is the same reusable pattern, but the timing model is new. The driver holds the line at each bit value for exactly one baud period — start (low), eight data bits LSB first, an optional parity bit, then the stop bit (high). The monitor does the hard part: detect the start edge, wait half a bit period to reach the center of the start bit, then sample every subsequent bit one baud period apart — landing each sample at the bit's midpoint, away from the edges where the line is transitioning. Get that half-bit offset wrong and the monitor samples on the edges, reading garbage.
How do you build a UVM agent for an asynchronous serial interface with no shared clock — a driver that emits the UART frame one baud period per bit, and a monitor that recovers timing from the start bit and samples in the middle of every bit — so the receiver reads the right byte without a clock to tell it when?
Motivation — why the clockless interface is a distinct skill
UART is small, but it teaches a model the synchronous protocols never do:
- Timing recovery is the new idea. With APB and AXI, a shared clock said exactly when to sample. UART has none, so the receiver must recover timing from the start edge and a known rate — a genuinely different driver and monitor than anything clock-gated.
- Mid-bit sampling is the correctness crux. The line is stable in the middle of a bit and transitioning at the edges, so sampling must be centered. The half-bit offset after start detection is the entire trick, and getting it wrong is the canonical UART bug.
- It generalizes to every serial link. SPI, I²C, and high-speed SerDes all share the idea of sampling a serial stream at the right instant; UART is the clearest place to learn start detection and centered sampling without protocol noise.
- The agent pattern still holds. Despite the new timing model, it is still item, driver, monitor, active/passive — proof that the reusable-agent architecture spans even a clockless protocol.
The motivation, in one line: UART teaches timing recovery and mid-bit sampling — how a receiver reads a serial stream with no clock by detecting the start edge and sampling each bit at its center — within the same reusable agent pattern, which is why it is the right first asynchronous agent.
Mental Model
Hold the UART agent as two telegraph operators with no shared clock, only a pre-agreed tapping rate:
Two telegraph operators sit at opposite ends of a wire with no shared clock between them — nothing tells the receiver when each symbol begins or ends except the agreement they made beforehand: we will send at exactly this many symbols per second. The line rests in a known idle state. When the sender has a message, it first sends an agreed attention signal — a single, unmistakable transition out of idle — that says "a frame starts now." The receiver, who has been watching the idle line, sees that transition and starts its own internal metronome, ticking at the agreed rate. But a careful receiver does not read each symbol at the instant its tick fires aligned to the edges, because right at the boundary between two symbols the line is changing and ambiguous. Instead it offsets itself by half a tick after seeing the start, so that every read lands squarely in the middle of a symbol, where the line has settled and the value is unmistakable. From then on it reads one symbol per tick, at each symbol's center, until the frame's end marker. The whole scheme rests on two things: the start signal that synchronizes the receiver's metronome to the sender, and the half-tick offset that moves every read to the stable center of a symbol instead of its shifting edge. If the receiver skipped the half-tick offset and read on the boundaries, it would catch symbols mid-transition and mis-read them — more and more as tiny rate differences accumulated across the frame. Two operators with no shared clock, only a pre-agreed rate. The line rests idle; a start transition says "a frame begins now." The receiver, watching idle, sees it and starts its metronome at the agreed rate — but offsets by half a tick so every read lands at a symbol's center, where the line has settled, not at the ambiguous edge. It reads one symbol per tick until the end marker. The scheme rests on the start signal (synchronize the metronome) and the half-tick offset (read centers, not edges).
So building the UART agent is staffing the two operators: the transaction is the byte to send; the driver is the sender that walks the frame one baud period per bit; the monitor is the receiver that watches idle, detects the start edge, offsets half a bit to reach the first center, and samples each bit at its midpoint. The bug you must never write is the monitor that skips the half-bit offset and samples on the edges.
The UART Frame in One Figure
A UART frame is a fixed sequence on one wire: idle high, a start bit, the data bits LSB first, an optional parity bit, and a stop bit — each held for exactly one baud period.
The frame is a strict order the driver emits and the monitor expects. Idle is high. The start bit is the synchronizing event: a single low baud period whose falling edge tells the receiver a frame has begun. The data bits follow least-significant-bit first — a detail that trips up anyone who assumes MSB first — one per baud period. An optional parity bit (even or odd over the data) gives a one-bit error check. The stop bit is high, returning the line to idle and guaranteeing an edge before the next frame's start. The receiver's sampling points, the dots in the figure, sit at the center of each bit, reached by the half-baud offset after the start edge — the heart of the monitor.
The Agent Hierarchy
The UART agent is the same reusable shape — sequencer, driver, monitor under an agent, scoreboard at env level. What is protocol-specific lives in the driver's frame walk and the monitor's timing recovery.
The hierarchy is the now-familiar agent pattern, unchanged by the protocol: monitor unconditional, sequencer and driver active-only, connected in connect_phase. The configuration carries the baud period (the one timing parameter both driver and monitor key on), whether parity is enabled, and active/passive. Everything UART-specific is inside the driver and monitor, below.
The Transaction and the Config
The transaction is a single byte (plus an optional error-injection flag). The config carries the baud period and parity setting — the timing contract both sides honor.
class uart_seq_item extends uvm_sequence_item;
rand bit [7:0] data;
rand bit inject_parity_error; // for error-injection tests
`uvm_object_utils_begin(uart_seq_item)
`uvm_field_int(data, UVM_ALL_ON)
`uvm_field_int(inject_parity_error, UVM_ALL_ON)
`uvm_object_utils_end
function new(string name = "uart_seq_item");
super.new(name);
endfunction
endclass
class uart_config extends uvm_object;
virtual uart_if vif;
time bit_period = 8680ns; // 1 / baud (e.g. 115200 baud)
bit parity_enable = 1;
uvm_active_passive_enum is_active = UVM_ACTIVE;
`uvm_object_utils(uart_config)
function new(string name = "uart_config"); super.new(name); endfunction
endclassThe item is just a data byte and an inject_parity_error knob the driver uses to deliberately send a bad parity bit for error tests. The config's key field is bit_period — the inverse of the baud rate — because with no clock, that delay is the timing reference for both the driver (how long to hold each bit) and the monitor (how far apart to sample). Parity and active/passive complete the contract. Both driver and monitor read this same config, which is how the two agree on the rate without a shared clock.
The Driver — emitting the frame
The driver walks the frame, holding the tx line at each bit's value for one baud period: start, eight data bits LSB first, optional parity, stop.
class uart_driver extends uvm_driver #(uart_seq_item);
`uvm_component_utils(uart_driver)
uart_config cfg;
// ... new(); build_phase: get cfg (and cfg.vif), guarded with `uvm_fatal ...
task run_phase(uvm_phase phase);
cfg.vif.tx <= 1'b1; // idle high
forever begin
uart_seq_item tr;
seq_item_port.get_next_item(tr);
drive_frame(tr);
seq_item_port.item_done();
end
endtask
task drive_frame(uart_seq_item tr);
bit parity = ^tr.data; // even parity over the data
if (tr.inject_parity_error) parity = ~parity;
cfg.vif.tx <= 1'b0; #(cfg.bit_period); // START bit (low)
for (int i = 0; i < 8; i++) begin // DATA bits, LSB first
cfg.vif.tx <= tr.data[i];
#(cfg.bit_period);
end
if (cfg.parity_enable) begin // PARITY bit
cfg.vif.tx <= parity;
#(cfg.bit_period);
end
cfg.vif.tx <= 1'b1; #(cfg.bit_period); // STOP bit (high) → back to idle
endtask
endclassdrive_frame is the protocol as a straight-line sequence of timed assignments: drive low for the start bit and wait one bit_period; loop the data bits tr.data[0] through tr.data[7] — LSB first — each held for a bit_period; drive the parity bit (even parity is the XOR-reduction ^tr.data, optionally flipped to inject an error); then drive the stop bit high and wait one more period, leaving the line idle. The timing reference is #(cfg.bit_period) everywhere — no clock, just the agreed delay. The driver runs in run_phase (it consumes time); cfg and the interface are fetched in build_phase.
The Monitor — timing recovery and mid-bit sampling
The monitor is where UART is hard. It watches the idle line for the start edge, offsets half a baud period to reach the center of the start bit, then samples each subsequent bit one full period apart — every sample at a bit's midpoint.
class uart_monitor extends uvm_monitor;
`uvm_component_utils(uart_monitor)
uart_config cfg;
uvm_analysis_port #(uart_seq_item) ap;
// ... new() creates ap; build_phase gets cfg + cfg.vif ...
task run_phase(uvm_phase phase);
forever begin
uart_seq_item tr = uart_seq_item::type_id::create("tr");
bit parity_bit;
@(negedge cfg.vif.rx); // START bit edge: idle-high → low
#(cfg.bit_period / 2); // move to the CENTER of the start bit
if (cfg.vif.rx !== 1'b0) continue; // false start (glitch) → ignore
for (int i = 0; i < 8; i++) begin // sample each DATA bit at its center
#(cfg.bit_period); // one full period → center of next bit
tr.data[i] = cfg.vif.rx; // LSB first
end
if (cfg.parity_enable) begin
#(cfg.bit_period);
parity_bit = cfg.vif.rx;
tr.inject_parity_error = (parity_bit !== ^tr.data); // flag a parity mismatch
end
#(cfg.bit_period); // STOP bit center (should be high)
ap.write(tr); // broadcast the reconstructed byte
end
endtask
endclassThe monitor's structure is the timing recovery made literal. It blocks on @(negedge cfg.vif.rx) — the start-bit falling edge out of idle. It then waits half a bit period (cfg.bit_period / 2) to land in the center of the start bit, and re-checks the line is still low to reject a glitch (a false start). From that centered position, each #(cfg.bit_period) advances exactly one bit, so every cfg.vif.rx sample lands at the center of a data bit — the whole point. It samples eight bits LSB first, checks parity against its own XOR of the data, and reads the stop bit. The reconstructed byte is broadcast on the analysis port. The half-bit offset is the single line that makes the difference between sampling at stable centers and sampling on transitioning edges — the DebugLab.
Waveform Perspective — a frame for 0x55 with centered sampling
Transmitting 0x55 (binary 0101_0101, sent LSB first) shows the frame and the sample points sitting at each bit's center.
UART frame for 0x55 (LSB first) — sampled at the center of every bit
12 cyclesThe waveform is the frame made concrete. After the start edge at the first transition, the monitor's first sample lands at the start bit's center, and from there each sample is one bit period later — at d0's center, d1's center, and so on — reading 1,0,1,0,1,0,1,0, which LSB-first is 0x55. The parity bit (even parity of 0x55 is 0) and the stop bit (high) close the frame. Every sample mark sits in the middle of a bit, never at the boundaries where rx is changing — which is exactly what the half-bit offset buys, and exactly what the next section's bug throws away.
DebugLab — the monitor that sampled on the edges
Received bytes are wrong, worse on later bits — the monitor skipped the half-bit offset
The agent passed a few smoke tests, then the scoreboard began reporting wrong received bytes — not random, but systematically off, and worse on the later bits of each frame: bit 0 was often right, bit 7 frequently wrong. Slower baud rates were mostly fine; faster ones failed more. Transmit looked correct on the waveform, but the monitor's reconstructed byte disagreed with what was sent.
The monitor detected the start edge but omitted the half-bit offset, so it sampled at the bit boundaries instead of the centers — catching the line mid-transition:
buggy: @(negedge rx); // start edge
for (i=0..7) begin #(bit_period); data[i] = rx; end // samples at boundaries ✗
missing: #(bit_period/2) after the start edge to reach the FIRST center
effect: each sample lands at a bit boundary, where rx is transitioning between bit i and i+1
result: values caught mid-edge; tiny timing skew accumulates → later bits wrong, worse at high baud
fix: @(negedge rx); #(bit_period/2); // offset to the start-bit center ✓
for (i=0..7) begin #(bit_period); data[i] = rx; end // now samples at centersWithout the half-bit offset, the first #(bit_period) after the start edge lands the sample exactly on the boundary between the start bit and bit 0 — a transition point. Every later sample is likewise on a boundary. At a boundary the line may be at the old value, the new value, or in between, so samples are unreliable, and any small skew between the driver's and monitor's notion of the period accumulates across the frame, which is why the later bits fail more and higher baud rates (shorter periods, less margin) fail more. The transmit side was correct; only the receive sampling instant was wrong.
The tell is systematically wrong receive data that worsens toward the end of the frame and at higher baud. Localize it at the monitor's sampling instant:
- Check for the half-bit offset after start detection. The monitor must do
#(bit_period/2)immediately after the start edge to center its sampling. Its absence is the bug. - Confirm samples land at bit centers in the waveform. Overlay the monitor's sample points on
rx; if they sit on the transitions between bits rather than in the middle, the offset is missing or wrong. - Note the baud and bit-position dependence. A failure that worsens with later bits and higher baud is the signature of an edge-sampling/timing-margin problem, not a data bug.
Center every sample and key all timing off one configured period:
- Always offset half a bit after the start edge. Write the monitor as
@(negedge rx); #(bit_period/2);then one#(bit_period)per bit, so every sample is centered with maximum margin to the edges. - Re-check the start bit at its center. After the half-bit offset, confirm
rxis still low to reject glitches before committing to the frame. - Drive and sample from the same
bit_periodconfig. Both the driver's hold time and the monitor's sample spacing come fromcfg.bit_period, so they cannot drift apart in the testbench.
The one-sentence lesson: a UART monitor must offset half a baud period after detecting the start edge so it samples at the center of every bit; skip the offset and it samples on the bit boundaries, reading transitioning values that fail worse on later bits and at higher baud.
Common Mistakes
- Skipping the half-bit offset in the monitor. Sampling without the
bit_period/2offset lands every read on a bit boundary, catching transitions — the DebugLab bug. - Sending or sampling MSB first. UART data is LSB first; reversing the bit order swaps the byte. The driver loops
data[0]upward and the monitor fillsdata[0]upward. - Driving and sampling from different period values. The driver's hold time and the monitor's sample spacing must be the same
bit_period, or they drift apart across a frame. - Forgetting the stop bit / idle return. The line must return high for the stop bit; without it there is no guaranteed edge before the next start, and frames run together.
- Not rejecting a false start. A glitch low can look like a start edge; re-check the line at the start-bit center before committing to the frame.
- Putting timing in
build_phase. All the#(bit_period)waits belong inrun_phase;build_phaseis zero-time and only fetches the config and interface.
Senior Design Review Notes
Interview Insights
The receiver recovers timing from the data: it watches the idle-high line for the start bit's falling edge, and once it sees that edge it runs its own internal timing at the agreed baud rate, sampling each bit at intervals of one bit period. There is no clock on the wire — the two sides only agreed in advance on the baud rate, which is the bit period. The line idles high; the first thing in a frame is the start bit, a transition to low, and its falling edge is the synchronizing event. The receiver, having been watching idle, detects that edge and now knows a frame has begun and, given the agreed bit period, when every subsequent bit will be. The crucial refinement is that it does not sample right at the edge or at bit boundaries, because there the line is transitioning. Instead it offsets half a bit period after the start edge to reach the center of the start bit, and from there samples once per bit period, so every sample lands in the middle of a bit where the line is settled. It samples the eight data bits LSB first, the parity bit if enabled, and the stop bit. So the answer to when to sample is: synchronize to the start edge, offset half a bit to center, then step one bit period at a time. This start-synchronization plus centered sampling is how an asynchronous receiver reads a serial stream with no clock to guide it. The understanding to convey is timing recovery from the start edge and mid-bit sampling, which is the defining mechanism of asynchronous serial reception.
Because the line is stable in the middle of a bit and transitioning at the edges, so sampling at the center reads a settled, unambiguous value while sampling at the edge catches the line mid-change and is unreliable. At a bit boundary the signal is moving from one bit's value to the next, and depending on exact timing, skew, and rise/fall, a sample there could read the old value, the new value, or something in between — and metastability or noise is most likely right at the transition. The middle of the bit is as far as possible from both adjoining edges, the point of maximum timing margin, where the value has fully settled. This is why the monitor offsets half a bit period after the start edge: that offset moves the very first sample to the center of the start bit, and stepping one full bit period from there keeps every subsequent sample centered. The consequence of getting it wrong is instructive: a monitor that omits the offset samples at the boundaries, and because any small difference between the transmitter's and receiver's bit period accumulates across the frame, the later bits drift further off center and fail more, and higher baud rates with shorter periods and less margin fail more. So centered sampling is both a correctness requirement and a noise-immunity strategy: it maximizes the margin to the edges so the read is robust to timing skew. The understanding to convey is that the center is the stable, maximum-margin point and the edge is the ambiguous, transitioning point, which is why mid-bit sampling is mandatory for asynchronous serial.
A UART frame is, in order: the line idle high, a start bit, eight data bits sent least-significant-bit first, an optional parity bit, and a stop bit, each bit held for one baud period. The idle state is high, so the line rests high between frames. The start bit is a single bit period low; its falling edge out of idle is what synchronizes the receiver. Then come the data bits, conventionally eight, transmitted LSB first — bit 0 of the byte goes out first, bit 7 last — each for one bit period. After the data, an optional parity bit provides a one-bit error check: even parity makes the total number of ones even, odd parity makes it odd, computed over the data bits. Finally the stop bit, high for one or more bit periods, returns the line to idle and guarantees there is a high-to-low edge available for the next frame's start. So a typical frame with parity is start, eight data LSB first, parity, stop — eleven bit periods. The configurable parts are the number of data bits, whether parity is present and its sense, and the number of stop bits, all of which both ends must agree on along with the baud rate. The two details people miss are that data is LSB first, not MSB first, and that the stop bit and idle return matter because they create the edge the next start depends on. The understanding to convey is the ordered frame with LSB-first data and the role of the start and stop bits in framing and synchronization, which is the structure the driver emits and the monitor expects.
A UART agent needs the baud rate as a bit period, the parity setting, the number of data and stop bits, the virtual interface, and the active/passive choice — and the driver and monitor both read the same config because, with no shared clock, the agreed bit period is the only common timing reference, so they must use an identical value or they drift apart. The central field is the bit period, the inverse of the baud rate: the driver uses it to decide how long to hold each bit on the line, and the monitor uses it to decide how far apart to place its samples. If the driver held bits for one period and the monitor stepped by a slightly different period, their notions of where each bit is would diverge across the frame and the monitor would sample increasingly off-center — the same failure mode as missing the half-bit offset. Reading a single shared config field guarantees they cannot disagree within the testbench. The parity setting must also match so the monitor checks the bit the driver sends, and the data and stop bit counts must agree so the frame shapes line up. The virtual interface gives both the pins, and is_active selects whether the agent drives or only observes. So the config is the timing-and-format contract that, in a real system, the two devices agree on out of band, and in the testbench is one object both components consume. The understanding to convey is that the shared bit period is the substitute for a shared clock, so a single config source for both driver and monitor is what keeps them synchronized — which shows you grasp why asynchronous timing makes the config central.
It is the same reusable UVM pattern — item, sequencer, driver, monitor, active/passive assembly, factory-built and config-driven — but the timing model is fundamentally different: APB and AXI are synchronous with a shared clock that says when to sample, while UART is asynchronous with no clock, so the driver emits bits timed by a baud-period delay and the monitor recovers timing from the start edge and samples mid-bit. In all three, the agent shape is identical: a monitor built unconditionally, a sequencer and driver built only when active and connected in connect_phase, a transaction item, and an analysis port feeding a scoreboard — the architecture does not change with the protocol, which is the whole reason to build agents. What differs is inside the driver and monitor. In APB and AXI, every action is gated on a clock edge: the driver drives on posedge and samples ready on posedge, the timing reference is the clock. In UART there is no clock on the interface, so the driver uses time delays of one bit period per bit, and the monitor cannot wait for a clock — it detects the start bit's edge, offsets half a bit to center, and samples by stepping bit periods. The transaction is also simpler than AXI's burst but the timing handling is harder than APB's. So the contrast is synchronous-clock-gated versus asynchronous-timing-recovered, within one unchanging agent pattern. The understanding to convey is same-pattern, different-timing-model — the reusable architecture spans synchronous and asynchronous protocols, and only the driver and monitor internals change to match how each protocol marks time.
Exercises
- Center the sample. Given a monitor that samples immediately after the start edge with no offset, state where each sample lands relative to the bits and the one-line fix.
- LSB first. For the byte
0xC3, write the order of data bits the driver puts on the line and what the monitor fills intodata[0..7]. - Inject a parity error. Explain how
inject_parity_errorchanges the driver's parity bit and how the monitor flags the mismatch. - Baud margin. Explain why a missing half-bit offset fails worse at higher baud rates and on later bits than on early ones.
- Go passive. Describe what a passive UART agent at SoC level observes and which component is and isn't built, and what the monitor still does without a driver.
Summary
- A UART agent recovers timing instead of sharing it: no clock, only an agreed baud rate (bit period) both driver and monitor read from one config.
- The frame is idle-high → start (low) → eight data bits LSB first → optional parity → stop (high); the driver holds the
txline at each value for onebit_period. - The monitor does timing recovery: detect the start edge, offset half a bit period to the start-bit center, then sample each bit one period apart — every sample at a bit center, away from the transitioning edges.
- The mid-bit offset is the correctness crux: omit it and the monitor samples on bit boundaries, reading transitioning values that fail worse on later bits and at higher baud — the DebugLab.
- The agent pattern is unchanged from APB and AXI — item, sequencer, driver, monitor, active/passive, factory-built — proving the reusable architecture spans synchronous and asynchronous protocols.
- The durable rule of thumb: build the UART agent as the same agent pattern with a frame-walking driver and a timing-recovering monitor — drive each bit for one baud period LSB first, and in the monitor detect the start edge, offset half a bit to center, and sample each bit at its midpoint — because with no shared clock the agreed bit period is the only timing reference, and centered sampling is what reads the right byte off a transitioning line.
Next — Register Verification: the APB, AXI, and UART agents drive and observe buses; the next case study sits a layer above them, using the register abstraction layer to verify a DUT's register map — a register model, an adapter that turns register operations into bus transactions on the APB agent, and a predictor that keeps the model's mirror in sync — so tests read and write registers by name instead of by address.