A new team hands you a design and a failing regression on the same day. "It is small, just a bus and a DMA." Before you open a waveform you need to know what the blocks are, where each register lives and what each test checks; without that, every signal name is a guess. How do you build that map quickly, and what does it look like for the design this course uses?
The usual start is to open the failing waveform and scroll, where every signal looks equally interesting, or to read the RTL top to bottom, which takes a day and teaches every line and none of the structure. Both skip the cheap step: a one-page map built from the top-level file and the register map, checked by running a test that passes. With a map you ask "which block could have written this word?" Without one you ask "what is this signal?"
Blocks and connections. From the top-level module only: masters, slaves, clocks, and anything that crosses between them.
The address map. Which address reaches which block, and each register's reset value: the specification you will compare against.
The tests. What each one drives and checks. A test that never reads a register cannot catch that register's bug.
One run that passes. Build, run, read the output: you learn the log format and run time before you need them.
This comes before the debug loop: you do it once per system, not once per bug.
Read it in the order a word travels:
Two masters. The CPU port (master 0) is driven by the bench, as software would drive it. dma_engine (master 1) copies words in chunks of up to four through a sync_fifo; software programs it through csr_block, and it raises done and an interrupt at the end.
One bus, one transfer at a time. A master holds valid, address and data until ready; each request gets one response pulse. Inside bus_fabric, arb_4 picks the owner round-robin and bus_decode3 selects the slave.
Four slaves. A 1024-word RAM, the CSR block, a default slave that answers unmapped addresses with an error (DECERR), and the stream peripheral.
A second clock domain. Words written to the peripheral go into an async_fifo, drained on the unrelated pclk by periph_sink, which can be paused, so the bus sees back-pressure; dreq tells the DMA when there is space. The sink's enable crosses through the synchroniser sync2_rtl.
A power gate. The sink sits in periph_pd, which software can switch off; isolation clamps its outputs meanwhile. An unpowered domain's outputs are X in Icarus and noise in Verilator, as silicon would drive garbage.
The FIFOs, synchroniser, arbiter, decoder, RAM and default slave are the RTL course's modules, unchanged: bus integration, the synchronous FIFO, the asynchronous FIFO and the register map and CSR RTL.
Every register is one 32-bit word. This is the project README's map, the specification: when the hardware disagrees with it, one of them is wrong.
Address | Name | Access | Fields and reset value |
|---|---|---|---|
0x2000 | ID | RO | 0xDB1AB001 |
0x2004 | SCRATCH | RW | software scratch |
0x2008 | CTRL | RW | [0] done_ie, [1] err_ie, [2] paced, [3] dst_fixed, [4] fill. Reset 0 |
0x200C | STATUS | RO / W1C | [0] busy, [1] done (write 1 to clear), [2] err (write 1 to clear) |
0x2010 | SRC | RW | DMA source byte address |
0x2014 | DST | RW | DMA destination byte address |
0x2018 | LEN | RW | DMA length in words, 0 to 1023 |
0x201C | START | WO | write 1 to start; ignored while busy |
0x2020 | ERR_ADDR | RO | address of the access that failed |
0x2024 | FILL | RW | value written in fill mode. Reset 0 |
0x3000 | DATA | WO | push one word; SLVERR and dropped when the FIFO is full |
0x3004 | PSTATUS | RO / W1C | [0] full, [1] overflow (write 1 to clear) |
0x3008 | PCTRL | RW | [0] sink_en, [1] iso_en, [2] pwr_on. Reset 0x05 |
The DMA modes in CTRL: paced waits for dreq before each write, dst_fixed keeps writing one address (the peripheral's DATA port), and fill writes the FILL value instead of copying. irq = (done and done_ie) or (err and err_ie). (The README calls the peripheral's registers STATUS and CTRL; the P here avoids confusion.)
Test | What it drives and checks |
|---|---|
| ID, SCRATCH, a few RAM words, error responses for unmapped addresses |
| memory-to-memory copies of 0 to 64 words |
| STATUS.done is sticky and write-1-to-clear; irq follows the enables in CTRL |
| CPU writes into the peripheral with flow control, pauses and an overflow |
| a paced DMA copy into the peripheral while the sink stops and starts |
| power the sink down and up with words waiting in the FIFO |
| seeded mixed CPU and DMA traffic, 1500 operations unless |
Four kinds of check run in every test, and each prints its own tag in the FAIL line:
A reference model of the RAM and registers: every CPU read is compared with it (SCOREBOARD), and the whole RAM at the end (SCOREBOARD (final sweep)). It keeps its own copy of the documented reset values rather than importing the RTL's.
Stream checks on the peripheral's pins: order, values, unknowns (STREAM). The interrupt has its own (IRQ).
Protocol rules on both master ports (tb/bus_rules.sv): no unknown valid, address or write flag; valid and payload held until ready; a response only for an accepted request. A break prints FAIL bus_rules.
A watchdog (WATCHDOG) when nothing moves on the bus or the stream for 2000 cycles, so a hang becomes a failure.
The seed drives the bench's own xorshift generator, so a seed gives the same stimulus, cycle for cycle, in Icarus and Verilator. Part 1 builds on that.
dbglab is a history you build, not a folder you copy. The script creates a fresh git repository from a patch series with fixed authors and dates, so the hashes match on every machine: when a page names 026f8bf, you have the same commit.
D=scripts/data/hardware-debug-course/dbglab # in this site's repository
$D/build-history.sh ~/dbglab # the folder must not exist yet
cd ~/dbglab$ git log --oneline --decorate
49c5f04 (HEAD -> main) tb: report cycles and sim time in the PASS line
122e88a periph_pd: isolate only the outputs that leave the peripheral
b357bd8 csr: no reset on the DMA address and data registers
03ebdc1 dma: one width for the beat counters
bc2bb34 dma: fill mode
c4a71b5 dma: keep the bus for a whole chunk
27acd30 periph: deepen the async FIFO to 16 entries
026f8bf csr: collect register reset values in dbglab_pkg
2e63466 Fix comments in the fabric and the DMA; README register map
a60b6f7 periph: power gate model with output isolation
246ff00 dma: stop on an error response and report it
fa71142 (tag: v0.1) tb: seeded random test and regression script
6e5cfb4 dma: peripheral-paced writes to a fixed destination
4326801 tb: valid/ready protocol checker and no-progress watchdog
bf4a3ec Add stream peripheral behind an async FIFO in the pclk domain
2f08392 Add bus fabric, RAM, CSR block and DMA engine with first testsSixteen commits over about two weeks, read from the bottom. v0.1 is the first release, with a clean regression. The eleven commits after it are ordinary project work: features, refactors, speed-ups. Some of them broke something. The course finds those bugs one at a time, each in the lesson whose method suits it, and does not name them here.
$ tb/run.sh smoke 1
dbglab: test=smoke seed=1
PASS test=smoke seed=1 cycles=76 time=756 ns
$ tb/run.sh dma_copy 3
dbglab: test=dma_copy seed=3
PASS test=dma_copy seed=3 cycles=1182 time=11816 ns
$ tb/run.sh random 7 verilator
dbglab: test=random seed=7
PASS test=random seed=7 cycles=19037 time=190366 ns
$ ls build
dbglab.vvp
icarus-build.log
icarus-dma_copy-3.log
icarus-smoke-1.log
verilator-build.log
verilator-random-7.log
vlRead it line by line:
tb/run.sh <test> [seed] [icarus|verilator], Icarus by default. The first line names the test and seed, so a log always says what produced it.
The last line is PASS with the length in cycles and nanoseconds, or FAIL test=... seed=... cycle=...: with the check's tag and message: one greppable line. The exit status is 0, 1, or 125 if it does not build, as git bisect run expects (bisection in history).
The full log is in build/<sim>-<test>-<seed>.log. An Icarus run takes well under a second; the first Verilator run compiles (8.2 s in all here), later ones reuse the build.
DUMP=1 also writes dbglab.vcd (3.2 MB for a random run that stops at cycle 7,949). +OPS=<n> sets the random test's length; XRAND=1 with Verilator starts unreset flip-flops at random values instead of zero.
tb/regress.sh runs every test over a few seeds and exits with the number of failures. At the release:
$ git checkout -q v0.1
$ time tb/regress.sh icarus 1 2 3
smoke seed 1 PASS
smoke seed 2 PASS
smoke seed 3 PASS
dma_copy seed 1 PASS
...
dma_burst_backpressure seed 3 PASS
random seed 1 PASS
random seed 2 PASS
random seed 3 PASS
0 failing run(s)
tb/regress.sh icarus 1 2 3 0.93s user 0.25s system 71% cpu 1.661 totalEighteen runs, all passing, in under two seconds. Now at the latest commit:
$ git checkout -q main
$ tb/regress.sh icarus 1 2 3 | tail -1
9 failing run(s)Nine failing runs out of 21 (a power test arrived after the release). That is where the rest of the course begins; the method comes before the waveform.
Four facts every later lesson leans on. The same command gives the same run, cycle for cycle, in either simulator. A run costs under a second, so experiments are cheap. v0.1 is known good, which turns many questions into "what changed since then?". And every check names what it saw and where, so a FAIL line is already a small report.
SCOREBOARD: RAM word 300 read 1234abcd, expected 00000000 (last written by DMA run #5). Using the map, which blocks could have put the wrong value there, and which could not?Every RAM write comes through the fabric from a master, so the suspects are the DMA (the model says it wrote last), the CPU port, the fabric and the RAM. The CSR block matters only through what it tells the DMA (length, addresses, fill value). The peripheral, the async FIFO and the pclk domain cannot write the RAM at all. That halves the design before you open a file.
A check that takes its expected values from the thing it checks can never disagree with it. If an RTL reset value changed by mistake and the model imported it, both would change together and every test would pass. An independent copy, taken from the specification, can catch that bug.
dbglab built with scripts/data/hardware-debug-course/dbglab/build-history.sh <dir> (git 2.54.0, 2026-10-17): HEAD 49c5f04, tag v0.1 at fa71142, 16 commits. The block diagram is drawn from rtl/dbglab_top.sv, rtl/bus_fabric.sv and rtl/periph_slave.sv at HEAD; the register map is the project README's at HEAD.
Runs at HEAD with Icarus Verilog 13.0: tb/run.sh smoke 1 (PASS, 76 cycles), tb/run.sh dma_copy 3 (PASS, 1182 cycles); with Verilator 5.048: tb/run.sh random 7 verilator (PASS, 19037 cycles, 8.2 s including the first compile). DUMP=1 tb/run.sh random 17 wrote a 3.2 MB VCD.
git checkout -q v0.1 && tb/regress.sh icarus 1 2 3: 18 runs, 0 failing, 1.7 s. At HEAD the same command reports 9 failing runs out of 21.
Limits: dbglab is a teaching system of about 1,500 lines, simulated only. Its bugs are real but planted; a production SoC has more blocks, more clocks and far longer tests.