A designer hands you a one-page spec and says the block is "simple, maybe two weeks of verification". Three months later the escape that reaches silicon is in a sentence you read on day one: writes to LEN are ignored while the engine is busy. Nobody wrote a test that wrote LEN mid-job, so nobody noticed that the scoreboard believed the new value while the hardware kept the old one.
Every lesson in this course verifies one design, a small stream DMA. Before you write any UVM, this page reads its spec the way a verification engineer does: not as a description of what the block does, but as a list of promises, each of which needs a check and a way to prove it was exercised. Which promises would you have missed?
A designer reads a spec to know what to build. A verification engineer reads it to know what can go wrong, and the useful mental model is a contract with fine print. The headline ("moves LEN words and XORs them with KEY") is the part everyone tests. The fine print (what happens when the packet is short, when software writes mid-job, when the output stalls) is where bugs live, because the designer handled those cases last and the testbench author thought about them least.
So for every sentence of the spec, ask three questions. What stimulus makes it happen? What check proves it happened correctly? What coverage proves the stimulus really occurred? Those three questions are the rows of a testplan, the document that defines when verification is done. The stimulus, check and coverage triangle turns them into UVM parts; this page produces the list.
The design has three interfaces, and each one is a different kind of traffic. APB is a request-response register bus with wait states. AXI-Stream is a valid/ready handshake where either side can stall. The interrupt is a level that stays high until software clears its cause. Almost every UVM topic, from reactive agents to the register layer, has a natural home in one of the three.
Offset | Register | Fields | Access and fine print |
|---|---|---|---|
0x00 | CTRL | bit 0 GO, bit 1 IRQ_EN | GO starts a job and reads back 0. GO is ignored while BUSY, but IRQ_EN still takes the write. |
0x04 | STATUS | bit 0 BUSY, bit 1 DONE, bit 2 ERR | BUSY is read-only. DONE and ERR are write 1 to clear (W1C): writing 0 leaves them alone. |
0x08 | LEN | bits 15:0 | Words per job. Writes are ignored while BUSY. |
0x0C | KEY | bits 31:0 | XOR key. Writes are ignored while BUSY. |
0x10 | COUNT | bits 15:0 | Words moved in the current or last job. Read-only; GO resets it. |
other | none | Any other address completes with PSLVERR = 1 and reads 0. |
All registers reset to 0. Every APB access takes one wait state: the slave holds PREADY low for the first access cycle. A driver that assumes zero wait states, or a monitor that samples PRDATA on the first access cycle, fails on this design immediately, which is exactly why it has the wait state.
After GO with LEN = N, the DMA takes N words from the input stream and sends each one out as word ^ KEY, with TLAST on the N-th output word. It does not need TLAST on the input to finish: it counts.
There is one output register. The input is ready only when the DMA is busy and that register is empty or being emptied this cycle (s_tready = busy && (!m_tvalid || m_tready)). Stall the output, and the input stalls as soon as that one register is full.
An input word with TLAST before the N-th word ends the job early: that word goes out with TLAST, BUSY drops, and ERR is set instead of DONE.
If the input packet is longer than LEN, the DMA takes N words, sets DONE and drops TREADY. The extra words are never accepted, so a stimulus driver that waits for TREADY on them waits forever.
LEN = 0 is legal: GO sets DONE at once, BUSY never rises, and no word moves.
irq = IRQ_EN && (DONE || ERR). It is a level: it rises when a job ends with IRQ_EN set and stays high until software clears DONE or ERR, or clears IRQ_EN. Two consequences catch people. GO clears DONE (for a non-zero LEN) but does not clear ERR, so an uncleared error from the previous job keeps the interrupt high the moment the next job enables it. And because the register block writes STATUS before it updates DONE in the same clock, a W1C of DONE in the very cycle a job finishes loses: set wins over clear.
Promise | Stimulus | Check |
|---|---|---|
Data is | Random KEY, LEN and data | Scoreboard predicts from observed input and KEY |
LEN = 0 finishes at once | GO with LEN = 0 | DONE set, no output word, IRQ if enabled |
Early TLAST ends the job with ERR | Packet shorter than LEN | ERR set, DONE clear, COUNT = words taken, last word has TLAST |
Long packets do not over-run | Packet longer than LEN | Exactly LEN words out; stimulus does not hang |
Backpressure loses nothing | Random TREADY low on the output | Same words, same order; input stalls |
DONE and ERR are W1C | Write 0, then 1, to each bit | 0 leaves the bit, 1 clears it, BUSY ignores writes |
Set beats clear | W1C DONE in the finishing cycle | DONE stays set |
KEY and LEN ignored while busy | Write them mid-job | Job uses the old values; the scoreboard must too |
GO ignored while busy | Second GO mid-job | No restart; COUNT keeps counting |
ERR survives GO | Error job, no clear, next job | IRQ high immediately; test must clear first |
Unmapped addresses error | Access 0x14 and up | PSLVERR = 1, read data 0, no register changes |
Reset in the middle of a job abandons it cleanly | Assert | BUSY, DONE, ERR, COUNT, LEN, KEY and IRQ_EN read 0; no output beat after reset; the abandoned words are never compared; the next job is correct |
Look at the "ignored while busy" row again, because it is the hook's bug. The course's base scoreboard learns KEY from every KEY write the APB monitor sees. That is correct for every test in the bench today, because they only write KEY between jobs. A test that writes KEY mid-job would expose a gap in the model, not in the DUT: the scoreboard needs to know BUSY, just as the hardware does. You will meet this again when the course builds a register model, which is the standard way to keep that knowledge in one place.
The whole design is about 130 lines of plain SystemVerilog. Read it once now with the table above beside you: each row maps to two or three lines, and finding them is the fastest way to learn the block.
// dma_dut.sv - the course's running example: a small stream DMA.
//
// APB registers (32-bit, word addressed, byte offsets):
// 0x00 CTRL [0] GO (write 1 to start, reads 0) [1] IRQ_EN
// 0x04 STATUS [0] BUSY (RO) [1] DONE (W1C) [2] ERR (W1C)
// 0x08 LEN [15:0] words to move (RW)
// 0x0C KEY [31:0] every word is XORed with KEY on the way through (RW)
// 0x10 COUNT [15:0] words moved in the current or last job (RO)
// Any other address answers with PSLVERR = 1. Every access takes one wait state
// when WAIT_STATES = 1 (PREADY low for one access cycle).
//
// Data: after GO, LEN words are taken from the s_axis input and sent to the
// m_axis output as (word ^ KEY), with m_tlast on the last one. An input word
// with s_tlast before the LEN-th word ends the job early with STATUS.ERR.
// irq = IRQ_EN & (DONE | ERR).
module dma_dut #(parameter bit WAIT_STATES = 1) (
input logic clk,
input logic rst_n,
// APB
input logic [7:0] paddr,
input logic psel,
input logic penable,
input logic pwrite,
input logic [31:0] pwdata,
output logic [31:0] prdata,
output logic pready,
output logic pslverr,
// AXI-Stream in
input logic [31:0] s_tdata,
input logic s_tvalid,
input logic s_tlast,
output logic s_tready,
// AXI-Stream out
output logic [31:0] m_tdata,
output logic m_tvalid,
output logic m_tlast,
input logic m_tready,
// Interrupt
output logic irq
);
logic irq_en, busy, done, err;
logic [15:0] len, count;
logic [31:0] key;
// ---------------- APB ----------------
logic waited; // one wait state per access
wire access = psel && penable;
assign pready = !WAIT_STATES || waited;
wire xfer = access && pready; // the cycle the access completes
wire mapped = (paddr == 8'h00) || (paddr == 8'h04) || (paddr == 8'h08) ||
(paddr == 8'h0C) || (paddr == 8'h10);
assign pslverr = xfer && !mapped;
always_comb begin
unique case (paddr)
8'h00: prdata = {30'b0, irq_en, 1'b0};
8'h04: prdata = {29'b0, err, done, busy};
8'h08: prdata = {16'b0, len};
8'h0C: prdata = key;
8'h10: prdata = {16'b0, count};
default: prdata = 32'h0;
endcase
end
wire go = xfer && pwrite && (paddr == 8'h00) && pwdata[0] && !busy;
// ---------------- data path ----------------
// One output register: the input is accepted when the output slot is free
// or is being emptied this cycle.
wire out_free = !m_tvalid || m_tready;
assign s_tready = busy && out_free;
wire take = s_tvalid && s_tready;
wire last_word = (count + 16'd1 == len);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
waited <= 1'b0;
irq_en <= 1'b0;
busy <= 1'b0;
done <= 1'b0;
err <= 1'b0;
len <= 16'd0;
key <= 32'd0;
count <= 16'd0;
m_tvalid <= 1'b0;
m_tdata <= 32'd0;
m_tlast <= 1'b0;
end else begin
waited <= access && !waited;
if (xfer && pwrite) begin
unique case (paddr)
8'h00: irq_en <= pwdata[1];
8'h04: begin // write 1 to clear
if (pwdata[1]) done <= 1'b0;
if (pwdata[2]) err <= 1'b0;
end
8'h08: if (!busy) len <= pwdata[15:0];
8'h0C: if (!busy) key <= pwdata;
default: ;
endcase
end
if (go) begin
busy <= (len != 16'd0);
done <= (len == 16'd0); // a zero-length job is done at once
count <= 16'd0;
end
if (m_tvalid && m_tready) m_tvalid <= 1'b0;
if (take) begin
`ifdef DMA_BUG_WORD6
m_tdata <= s_tdata ^ (count == 16'd5 ? ~key : key); // planted bug for the mismatch playbook
`else
m_tdata <= s_tdata ^ key;
`endif
m_tvalid <= 1'b1;
m_tlast <= last_word || s_tlast;
count <= count + 16'd1;
if (last_word) begin
busy <= 1'b0;
done <= 1'b1;
end else if (s_tlast) begin // input packet ended early
busy <= 1'b0;
err <= 1'b1;
end
end
end
end
assign irq = irq_en && (done || err);
endmoduleThe bench exercises the headline promise in dma_smoke_test and the early-TLAST promise in dma_truncate_test, which swaps in a driver that forces TLAST on beat 3 of an 8-word packet. From the real runs (Verilator with the open-source UVM library; times in ps):
+UVM_TESTNAME=dma_smoke_test
UVM_INFO dma_tb.sv(411) @ 655000: uvm_test_top.env.sb [SB] 8 words matched, 0 mismatched
+UVM_TESTNAME=dma_truncate_test
UVM_INFO dma_tb.sv(520) @ 275000: uvm_test_top.env.in_agt.drv [TRUNC] TLAST forced on beat 3
UVM_INFO dma_tb.sv(761) @ 385000: uvm_test_top [TRUNC] DMA reported ERR for the short packet, as expected
UVM_INFO dma_tb.sv(411) @ 485000: uvm_test_top.env.sb [SB] 4 words matched, 0 mismatched8 words matched in the smoke test: every output word was predicted and compared.
4 words matched in the truncate test: beats 0 to 3 went in (beat 3 carrying TLAST), the DMA stopped there, and the scoreboard saw exactly those four come out. The other four beats were dropped by the driver, so they were never predicted either.
The ERR line comes from the test reading STATUS after the interrupt. The data check and the status check are separate, and both are needed.
This design invites a specific bug, and it is worth walking through before you meet it in a regression. Suppose a test runs the truncate scenario, reads STATUS, sees ERR, and moves straight on to a normal job without writing 1 to ERR. The interrupt line never went low: IRQ_EN is still set from the last CTRL write and ERR is still set. The next job writes KEY, LEN and GO, and the test's wait (irq) returns at once, it reads STATUS = BUSY | ERR, and it reports a failure on a job that has not even started. Worse, if the test only checks DONE, it may drop its objection while the job is still running, and the scoreboard's leftover check reports words that "never came out".
The root cause is not in the DUT. It behaves exactly as specified: GO clears DONE and leaves ERR alone. The bug is a test that treated the interrupt as an edge ("something just finished") when the spec defines it as a level ("something is waiting to be cleared"). The bench's run_job task avoids it by always writing 0x6 to STATUS after each job, clearing both bits. When you write your own tests, clear before you start, and make your interrupt handling check why the line is high.
The DMA takes words 1 to 4, puts TLAST on output word 4 (it counts, it does not wait for input TLAST), sets DONE and drops TREADY. Words 5 and 6 are never accepted. A driver that waits for TREADY on word 5 waits forever, so the test hangs unless something else ends it, such as a timeout.
Set. In the RTL the STATUS write is handled first and the end-of-job update later in the same always_ff block, so the later assignment (DONE = 1) wins. Set beats clear. A test that wants a clean STATUS must clear after the job has finished, and a testplan should have a row that forces this collision on purpose.
They check different promises. The scoreboard proves the words that did move were correct and that no extra words appeared. The STATUS read proves the DMA knew the job ended early (ERR set, DONE clear). A DUT that moved the right four words but reported DONE would pass the scoreboard and fail the spec.
Next: you know what the design should do. Running UVM on EDA Playground shows how to run its testbench yourself, for free, and how to read what comes back.