Tiny SystemVerilog exercises, one at a time, until a checker says you got it
right.
Inspired by ziglings and
rustlings: every exercise is a
single self-contained .sv file and a checker at the bottom that tells you,
unambiguously, whether you got it right. No tutorials, no multiple choice.
Most exercises hand you a spec in a comment and an empty class to write from
scratch against it -- get the constraint logic right against a checker built
to catch partial credit, not just crashes. A handful hand you working code
with a real bug already sitting in it, the kind that compiles clean and looks
fine until you read the actual compiler output or runtime failure and find
what's actually wrong.
This isn't generic SystemVerilog trivia. Every exercise here is built around
a mistake or a gap that actually shows up in real constrained-random
verification code -- something that either won't compile, silently does
nothing, or worse, compiles clean and passes while not actually testing what
it looks like it's testing. That last category shows up constantly across
exercises/, not as an afterthought.
The goal is a growing set of real interview and practice problems across
SystemVerilog, UVM, and register-map (CSR) verification. SV and UVM are here
today; CSR is next (see Tracks below). Coverage in both keeps
growing too. Starting narrow and adding real gotchas over time beats trying
to launch complete.
Companion to chipdv.io -- real DV engineering, worked
all the way through, not tutorials. A full multi-file testbench (broken RTL
plus an incomplete environment to finish) is deliberately not what this
repo does -- that's a different, bigger kind of challenge, out of scope for
a tool built around single-file, seconds-to-run exercises.
brew install verilator # or see Requirements below for your platform
git clone https://github.com/adibis/brokenBench.git brokenbench && cd brokenbench
make run EX=sv_mechanics_chain
That compiles and runs one exercise. It'll fail -- open
learn/sv_mechanics_chain.sv, fix the class and function the comment above
the checker marker asks for, and run the same command again until it prints
PASS. Then make list to see everything else.
Two top-level tracks, split by audience, not topic:
- learn/ -- never touched SystemVerilog constraints before? Start
here. Most exercises give you a spec and a stub to write against it --
constraints, foreach/implies/inside, dist weights, array
reduction, and more; a handful (rand_mode_leftover and the uvm_*
exercises) hand you real code with one call missing or misused instead --
a config_db that's never set(), a sequence that's created but never
start()ed, an objection dropped without a matching raise. Fast feedback
loop, one concept per exercise (or a short, deliberate chain of them).
make list TRACK=learn shows the current set.
- exercises/ -- interview-prep, organized by domain in subdirectories
(splitting this level by audience wouldn't make sense -- domain is what
actually changes here; difficulty is implicit in each track's order,
roughly easiest-first, not a separate tag):
- exercises/sv/ -- core SystemVerilog and constrained-random gotchas.
Every one of these is either a documented real-world gotcha or something
found by actually testing this repo's own exercises against Verilator.
make list shows the current set with tags; it changes as exercises get
added, reworked, or retired, so that's the source of truth, not a list
here that would just go stale.
- exercises/uvm/ -- UVM component-level bugs: config_db scoping
and precedence, factory-override ordering, objection lifecycle errors,
analysis-port aliasing, scoreboard correlation design, virtual-sequencer
handshake timing. make list TRACK=uvm shows the current set with tags.
- exercises/csr/ -- uvm_reg / register-map exercises: access-policy
mistakes, mirror() vs desired() confusion, predictor gaps. (Not yet
populated.)
- Verilator, free and
open source. No simulator license, no signup.
brew install verilator # macOS
# or see the Verilator install guide for your platform
- Python 3 (any reasonably modern version -- scripts/find_exercise.py's
manifest parser is hand-rolled specifically to avoid needing 3.11+ or a
third-party package).
- patch (GNU patch), for scripts/patch_one.py/patch_all.py. Usually
preinstalled on macOS and Linux.
- verible-verilog-format
(part of the verible release bundle), only needed for make format /
make format-check -- not required just to run exercises.
Exercises are referred to by a stable slug (the filename, minus .sv), not
a number -- manifest.toml is the one place that tracks each exercise's
track, order, and tags, so exercises can be reordered or retagged without
ever renaming a file or breaking a command you've already got memorized.
Browse what's available:
make list # every exercise in every track, with tags
make find TAG=multi-constraint # exercises tagged with a specific gotcha
make find TAG=tool-limitation TRACK=sv # narrow a tag search to one track
Tags in play right now:
- write-from-scratch -- you get a spec in a comment and an empty stub;
write the constraint/class logic yourself. No tag here means the opposite:
real code with a bug already in it, find and fix it.
- multi-constraint -- several requirements have to hold at once,
correctly combined, not just one clause in isolation.
- cross-call-state -- correctness depends on state that survives
across separate randomize() calls on fresh objects (a static member,
not an ordinary one).
- lifecycle-hook -- the fix lives in one of the randomization
lifecycle controls (rand_mode(), constraint_mode(), pre_randomize(),
post_randomize()), not just in a constraint block.
- statistical -- correctness includes landing on a target
distribution or weighting over many trials, not just producing one valid
output.
- tool-limitation -- the "obvious" approach hits a real, current
Verilator gap; the exercise is about finding the technique that actually
works around it. See README's tool-limitations
section.
- requires-patched-verilator -- needs a Verilator built from an
unmerged upstream patch to even compile, not the stock release -- see the
note at the top of that exercise's file.
- array -- the core challenge centers on an array-, queue-, or
matrix-typed field: uniqueness, indexing, slicing, sizing, constructing
its elements, or a reduction method (sum(), product(), and friends)
across it.
- inheritance -- involves constraints inherited across a class
hierarchy.
- best-practice -- a methodology habit or pitfall, not tied to one
specific language construct.
UVM-track tags:
- config-db -- correctness hinges on uvm_config_db::set()/get()
semantics: scope-string matching, per-instance vs. wildcard paths, or
precedence when multiple set() calls collide.
- precedence -- multiple config_db writes to the same field collide,
and which one wins isn't decided by which line runs last.
- multi-instance -- the bug only shows up across several instances of
the same component type, not a single one in isolation.
- fork-join -- correctness depends on fork/join/join_none
semantics: loop-variable capture, completion ordering, or which branch's
side effects are visible when.
- factory-override -- the fix is a UVM factory type or instance
override (set_type_override_by_type/set_inst_override_by_type),
including getting its timing right.
- phase-ordering -- correctness depends on which UVM phase something
runs in, or where within a phase a call has to land.
- virtual-sequencer -- the fix runs through a virtual sequence
coordinating multiple real sequencers, not a single sequence talking to
one.
- scoreboard -- the exercise is a scoreboard/checker component itself:
correlating, comparing, or counting transactions from one or more
analysis ports.
- analysis-port -- correctness depends on uvm_analysis_port/
uvm_analysis_imp broadcast semantics -- who gets a handle to what, and
when.
- clone -- the fix requires clone() to snapshot a uvm_object
before it's mutated or reused out from under a broadcast.
- do_copy -- overriding do_copy() correctly (deep-copying the
fields that matter, calling super.do_copy()) is part of the fix, not
just calling clone().
- objections -- correctness depends on raise_objection/
drop_objection balance or timing -- an imbalance either ends a phase
early or fatals on over-drop.
- phase-ending -- the bug is specifically about a phase ending too
early or too late.
- error-path -- the bug only shows up on the failure/error branch,
not the happy path -- the error handling itself is what's wrong.
- sequence-start -- a uvm_sequence is built correctly but the fix is
about actually running it -- body() never executes until something
calls .start(sequencer) on the sequence object.
Work one exercise at a time:
make run EX=dist_coin_and_die
make run EX=command_stream_bandwidth_cap
Work through a whole track in order, stopping at the first failure --
the rustlings-style "keep running the same command" loop:
make check TRACK=learn
make check TRACK=sv
make check TRACK=sv EX=thermal_power_fuzzer # resume partway through
TRACK is one of learn, sv, uvm, csr -- matching manifest.toml's
track field for each exercise, not the directory layout directly. sv and
uvm nest under exercises/sv/ and exercises/uvm/ on disk purely for
browsing; csr will nest the same way once that track has exercises in it
(see Tracks) -- either way, the manifest is what make check/
make find actually key off, not the directory layout.
Or skip the manual re-run loop entirely -- make watch re-runs an
exercise automatically every time you save the file, and moves on to the
next exercise in track order the moment one passes:
make watch EX=sv_mechanics_chain
Clean up build artifacts (compiled sim binaries under .build/):
Fix the class or function at the top of whichever exercise file you're stuck
on -- never touch anything at or below the // ---8<--- checker below: don't edit --- marker, that part is correct as given and is what's judging you.
Re-run make run EX=... after every change until it prints PASS.
Output is colorized (red for failures, green for pass) and respects
NO_COLOR if your terminal or pipe doesn't want
escape codes.
A contribution is a new broken exercise: the bug itself, a checker that
fails unambiguously as shipped and passes unambiguously once fixed, and a
patches/<slug>.patch diff that proves the fix works -- never a solved
.sv file committed directly. See CONTRIBUTING.md for
the full file structure, the authoring method (solve it first, outside the
repo, then decide what to strip out), and the checklist to run before
opening a PR.
Most bug reports here are one of three things: a checker that doesn't
actually test what it claims to, an exercise that behaves differently on
your Verilator version than the one this repo is pinned to, or something
broken in the patch/CI mechanics themselves. See
CONTRIBUTING.md for what to include in
each case.
Solutions never live in this repo as full solved files -- only patches/,
one unified diff per exercise, applying cleanly on top of the shipped
(broken) .sv file to produce a working one. Same approach
ziglings uses:
answers generated with diff -u, never committed in solved form, so
browsing the repo doesn't spoil an exercise the way a solutions/ folder
full of finished code would.
scripts/patch_one.py <slug> applies one exercise's patch; scripts/patch_all.py
applies every one. Both refuse to touch anything at or below the checker
marker (scripts/check_patch_safety.py enforces this as a hard gate -- a
"solution" that edits the checker to force a pass would be worse than no
patch at all) and refuse to apply if the patch doesn't cleanly apply in the
first place.
CI (.github/workflows/ci.yml) runs on every push and pull request:
- lint -- formatting checked directly against shipped files with
make format-check (verible-verilog-format), then patches applied
into a scratch copy and checked against this repo's 100-column
line-length standard with verible-verilog-lint.
- manifest-sync -- manifest.toml and the actual .sv files on disk
must agree, both directions.
- unpatched-must-fail -- every exercise, shipped as-is, must genuinely
fail. Catches an exercise accidentally committed pre-fixed, or a checker
that doesn't actually test anything.
- patches-solve -- every patch is applied into a scratch copy and must
make its own exercise's checker pass. This is how a patch is proven to be
a real, complete solution, not just a plausible-looking diff.
One exercise, dma_ring_allocator, needs a Verilator built from an
unreleased upstream patch to even compile (see manifest.toml's
requires_patched_verilator / verilator_broken_as_of fields on that
entry -- that's the only place this exclusion is recorded, not hardcoded
into any script). It's excluded from the patches-solve gate with a
documented reason rather than silently skipped, and has its own manual-only
CI job as a placeholder for testing it against a patched build once one
exists.
Real, current tool limitations found while building this
Known Verilator gaps this repo works around or documents -- not bugs in the
exercises. A few things here aren't textbook gotchas, they're things this
repo's own exercises ran into on the current Verilator toolchain while
being built and validated; worth knowing about if you hit something that
looks like a tooling quirk rather than the intended lesson:
- unique{} on an array is reliable up to about 4 elements and silently
wrong past that -- it returns success with actual duplicates present,
independent of how much headroom exists in the value space. Not built into
its own exercise; the pairwise-inequality workaround shows up in
thermal_power_fuzzer instead, alongside the row-slice crash below.
- unique{} on a row-slice of a 2D array crashes the compiler outright
(an internal fault, not a graceful error) on every released Verilator as
of this writing. thermal_power_fuzzer avoids it entirely and uses
pairwise inequality instead. A real fix is up for review upstream
(verilator/verilator#8100);
dma_ring_allocator is written for a Verilator built from
that patch -- see the note at the top of that file for how to point
make run at a patched binary.
- A rand-sized dynamic array combined with a .sum()/.product()
reduction constraint over its own contents never solves, even with
explicit solve ... before ordering hints. The real fix is pinning the
array's size, not a smarter ordering hint. Not built into an exercise.
- randomize(field) does not hold other rand fields as state, contrary
to documented LRM behavior for single-variable randomization -- calling
it re-randomizes the whole object regardless of which field was named.
Not built into an exercise (there's no reliable way to demonstrate a "fix"
for behavior that's simply not implemented as documented), but worth
knowing if you're relying on that semantic.
- randc's cyclic no-repeat guarantee holds under a static constraint but
breaks under a dynamic one -- excluding a literal value works as
documented, but excluding the previous call's result (e.g. via
post_randomize state) doesn't: repeats of an already-seen value start
almost immediately (confirmed: call 2 of 40, 16-value domain) instead of
only after the full domain is exhausted. Use a plain rand field plus a
used-value queue (inside {}) for "don't repeat a used ID" instead --
see tagged_frame_sequence.
- Indexing a non-rand array by a rand variable inside a constraint
crashes the SMT solver outright, not a graceful solve failure --
bit used[16]; constraint c { !used[id]; } (id is rand) produces
select requires 1 arguments, but was provided with 2 arguments from the
solver itself. The queue + inside {} idiom above avoids this entirely
and is the pattern to reuse for any "exclude a set of already-used
values" constraint.
- Comparing two whole unpacked arrays with != inside a constraint fails
outright, regardless of any guard around it -- constraint c { has_prev -> frame != prev_frame; } produces a solver error (Sorts (Array ...) and (_ BitVec N) are incompatible) even when has_prev is 0, so the
guard never gets a chance to short-circuit it; the solver can't translate
whole-array != to SMT at all, unconditionally. Not built into its own
exercise; avoid whole-array comparison entirely (compare element by
element instead).
- Constraining a dynamic array's .size() inside an inline randomize() with {} block reports success but doesn't actually resize the array --
f.randomize() with { payload.size() == 50; } returns ok == 1 while
f.payload.size() stays 0. The identical constraint written at the
class level (constraint c { payload.size() inside {[46:2048]}; }) works
correctly. Not built into its own exercise; pin dynamic-array contents by
assigning elements directly after a normal randomize() call instead of
trying to pin size via an inline with {}.
- A third separate randomize() call site for the same class, in the
same process, can corrupt an earlier loop's pre_randomize()-set,
non-rand field value on Verilator 5.050 -- confirmed with
l2_frame_fuzzer's checker: two loops, each calling randomize() on a
fresh instance of the same class, work correctly; adding a third,
otherwise-harmless call site anywhere later in the same initial block
makes the first loop's header value (set from an external
package-scope flag in pre_randomize()) come back stale on every
iteration. Confirmed absent on a from-source build past that release.
Not built into its own exercise (the checker routes around it by reusing
an existing loop's call site instead of adding a third one); if you hit
a pre_randomize()-derived field that looks stale only sometimes, check
how many distinct randomize() call sites exist for that class before
assuming the constraint logic is wrong.
- dist on an enum-typed rand field doesn't distribute correctly,
even though identical weights on a plain sized bit field of the same
width work exactly as specified -- confirmed over 2000 trials: an enum
field with dist {A:=20, B:=50, C:=30} lands nowhere near those weights
(25/28/47 observed), while the same weights on rand bit [1:0] land
within about a point of target (19/51/30). Don't use an enum type for a
weighted-dist field in Verilator -- use a plain sized bit field with
documented numeric meanings instead. Confirmed again inside a larger
constraint set in risc_v_forwarding_window: an isolated dist directly
on the enum landed close to target in a quick check, but combined with
the rest of that exercise's implications it skewed by several points on
Homebrew Verilator (35.8%/44.7% against a 40%/40% target) -- reliable
enough to fool a spot check, not reliable enough to trust. The exercise
routes around it with a plain-bit pick mapped onto the enum afterward.
This list is Verilator-specific gaps only. For general SystemVerilog
language semantics that are just as easy to mistake for a tool bug --
things that are surprising but are simply how the language works -- see
docs/reference.md.
If you hit something that looks like this list rather than the intended
lesson, it's probably exactly that -- see Filing bugs
rather than assume you did something wrong.
MIT. See LICENSE. See CONTRIBUTING.md if
you're thinking about sending a patch.