Automated fault-injection campaigns for Verilator RTL simulations.
Verminator orchestrates fault-injection (FI) campaigns against Verilator simulations. You describe what signals to fault and how in a single TOML file; Verminator verilates your design, generates the instrumentation, builds one simulation binary, and runs every fault scenario in parallel — collecting traces and performance data for each.
Verminator orchestrates the campaign. The actual fault injection at runtime is performed by a companion Verilator DPI extension, which Verminator generates the configuration and C++ hooks for.
- Single-file config — one
verminator.tomldescribes the whole campaign. - Single-signal targets — fault one signal at a time across fault types and time windows.
- Multi-signal combinations — inject several signals together in one run, each with its own fault type and (optionally) its own timing.
- Fault types — bit-flip, stuck-at-0, stuck-at-1, with optional time windows.
- Parallel execution — every fault run is a separate process; scales across all your cores.
- Three testbench modes — auto-generated driver, your own C++ driver, or a SystemVerilog-driven testbench.
- Waveforms + perf — FST/VCD traces and a per-run performance summary.
1. Verilate golden -> discover the design's signal tree (no simulation)
2. Generate -> fault_config.vlt, faultModel.cpp, sim_main.cpp
3. Build hooked -> one instrumented V<top> binary
4. Run campaign -> golden run, then all fault runs in parallel
5. Summarise -> perf_summary.csv
Results land in a structured directory:
<results_dir>/
├── 01_golden/build/ # netlist JSON
├── 02_generate/ # generated .vlt + .cpp
├── 03_hooked/build/V<top> # the instrumented binary
└── 04_simulation/
├── golden/ # golden trace + perf.json
├── run_0000/ run_0001/ # one dir per fault run
├── perf_summary.csv
└── campaign.json # full fault matrix metadata
- Python 3.11+ (uses
tomllib) - Verilator built with the Verminator DPI instrumentation extension — get it here: Verilator draft PR
- Python packages:
pydantic,typer
Verminator finds Verilator in this order:
input.hw.verilator_pathin your config$VERILATOR_ROOT/bin/verilatorverilatoron your$PATH
git clone https://github.com/hm-aemy/verminator
cd verminator
python -m venv .venv && source .venv/bin/activate
pip install -e .-
Discover what you can fault:
verminator run verminator.toml --list-signals
-
Preview the campaign without running anything:
verminator run verminator.toml --dry-run
-
Run the full campaign (parallel):
verminator run verminator.toml --jobs 8
A minimal verminator.toml:
[input.hw]
top_module = "tb_counter"
design_files = ["rtl/counter.sv", "tb/tb_counter.sv"]
[simulation]
tb_mode = "cpp_driven" # auto | cpp_driven | sv_driven
driver_file = "tb/driver.cpp" # required for cpp_driven
timeout_cycles = 1000
seed = 42
[simulation.waveform]
enabled = true
format = "fst" # fst | vcd
[simulation.fault_injection]
depth = "custom" # only "custom" is supported today
# --- Single-signal target ---
[[simulation.fault_injection.targets]]
name = "counter_register"
path = "tb_counter.uut.cut.count_reg"
sv_type = "logic"
width = 64
fault_types = ["bit_flip", "stuck_at_0"]
time_windows = [{ begin = 100, end = 250 }] # optional; omit = whole sim
# --- Multi-signal combination ---
[[simulation.fault_injection.combinations]]
name = "reg_and_count"
# Optional: one time window for the whole combination (applies to every signal
# that does not define its own). Omit for whole-simulation injection.
time_windows = [{ begin = 100, end = 250 }]
signals = [
# count_reg is also a [[targets]] above, so width/sv_type are inherited.
{ path = "tb_counter.uut.cut.count_reg", fault_type = "bit_flip" },
# count is NOT a target, so it must declare width/sv_type itself.
{ path = "tb_counter.uut1.cut.count", fault_type = "stuck_at_0", width = 8, sv_type = "logic" },
]
[output]
results_dir = "results/"- Each target produces one run per
(fault_type x time_window), each faulting that single signal. - Each combination produces one run that faults all its signals in the same run, each with its own fault type.
width / sv_type are required, but can be inherited. Every combination
signal needs a width and SV type. You can either:
- declare them inline on the signal (always works), or
- omit them — Verminator then inherits them from a
[[targets]]entry with the same path.
If a signal is neither given inline values nor has a matching target, the build
fails with a clear error. (In the example above, count_reg inherits from its
target; count has no target, so it declares width/sv_type itself.)
Timing is flexible. Faults in a combination do not have to happen at the same instant. The time window is resolved per signal:
- a signal's own
time_windowswins, else - the combination-level
time_windowsapplies, else - the fault runs for the whole simulation.
So you can fault the whole combination within one shared window (set it once at the combination level), stagger each signal independently (give each its own window), or mix both (combination default + per-signal overrides):
# Staggered: each signal faults in its own window
[[simulation.fault_injection.combinations]]
name = "staggered"
signals = [
{ path = "...count_reg", fault_type = "bit_flip", width = 64, sv_type = "logic", time_windows = [{ begin = 100, end = 250 }] },
{ path = "...count", fault_type = "stuck_at_0", width = 8, sv_type = "logic", time_windows = [{ begin = 300, end = 500 }] },
]By default a fault is applied to the whole signal. To restrict a fault to a
single bit or a contiguous bit range, add bit or bit_range to a target or a
combination signal (they are mutually exclusive):
# Fault only bit 5 of the register
[[simulation.fault_injection.targets]]
path = "tb_counter.uut.cut.count_reg"
sv_type = "logic"
width = 64
fault_types = ["stuck_at_1"]
bit = 5 # single bit -> emits -bit-pos 5
# Fault only bits [15:8]
[[simulation.fault_injection.targets]]
path = "tb_counter.uut1.cut.count_reg"
sv_type = "logic"
width = 64
fault_types = ["bit_flip"]
bit_range = [15, 8] # [hi, lo] -> emits -bit-range "15:8"Notes:
- Omit both
bitandbit_rangeto fault the entire signal (the default, and the common case). bit/bit_rangemust fit insidewidth(0 <= lo <= hi < width).- A given signal path must use one consistent selection everywhere it appears (across targets and combinations); a conflicting selection for the same path is rejected at build time.
- The selection is threaded straight into the generated
fault_config.vlt(-bit-pos/-bit-range) and the matchingfaultModel.cppcallback. See How the DPI callback signature is derived.
| Mode | You provide | Verminator generates |
|---|---|---|
auto |
nothing | sim_main.cpp and a driver.cpp template to fill in |
cpp_driven |
a driver.cpp with drive_simulation() |
sim_main.cpp |
sv_driven |
a self-driving SV testbench | sim_main.cpp (event loop) |
For auto/cpp_driven, your drive_simulation() has this shape (with waveforms):
void drive_simulation(V<top>* dut, VerilatedContext* ctx,
<TraceClass>* tracep, uint64_t max_time);(without waveforms, the tracep argument is dropped).
verminator run <config> [options]
verminator validate <config>
verminator example <name>
run options
| Flag | Description |
|---|---|
--output-dir PATH |
Override the results directory. |
--dry-run |
Validate and print the plan; execute nothing. |
--list-signals |
Verilate, list injectable signals, exit. |
--jobs, -j N |
Number of parallel fault runs (default: CPU count). |
--debug |
Verbose output. |
--random-signals N |
(planned — not yet implemented) |
04_simulation/run_NNNN/trace.fst— waveform per fault run.04_simulation/run_NNNN/perf.json— timing per run.04_simulation/perf_summary.csv— golden vs. average-FI plus per-run detail.04_simulation/campaign.json— the full fault matrix (effects + runs).
Verminator generates two artifacts that must agree on a single C ABI: the
fault_config.vlt hook directives (consumed by the Verilator DPI-hook
extension) and the faultModel.cpp callback definitions. The extension builds
the callback's parameter list from the hook directive, so Verminator emits both
from the same rules:
| Config | .vlt directive |
Callback signature |
|---|---|---|
no bit / bit_range |
(none) | fault_<w>(int id, svBit trigger, <value>) |
bit = N |
-bit-pos N |
fault_<w>_bitpos(int id, svBit trigger, int bitPos, <value>) |
bit_range = [hi, lo] |
-bit-range "hi:lo" |
fault_<w>_bitrange(int id, svBit trigger, int bitStartPos, int bitEndPos, <value>) |
Two consequences worth knowing:
- The bit-selection shape is part of the callback name (
_bitpos/_bitrangesuffix). Signals that share a width but differ in shape would otherwise call the same C symbol with incompatible argument lists — encoding the shape in the name gives each signature its own function. <value>is a pointer for packed vectors, not a by-value scalar:const svLogicVecVal*for 4-state types (logic/reg/wire, accessed via.aval) andconst svBitVecVal*for 2-statebit. The generated body dereferences it into auint64_tbefore applying the fault. (Emitting a spurious-bit-rangefor a whole-signal target used to shift every argument by one, so the callback read a bit index where it expected the value.)
Verminator is under active development. What works today:
- Golden verilation and signal discovery
- Custom-depth campaigns (single targets + multi-signal combinations)
- Whole-signal, single-bit (
bit) and bit-range (bit_range) targeting - Bit-flip / stuck-at-0 / stuck-at-1 with time windows
- Parallel per-run execution
- Waveforms and performance summary
Planned / not yet implemented:
- Depth presets (
minimal/medium/extensive) — onlycustomworks now - Analysis layer: golden-vs-injected diff and fault classification
--random-signalsselectionrandomfault type- In-pipeline firmware build (CMake / custom commands)
Signal widths above 64 bits are not currently supported.
For a complete, function-by-function breakdown of the implementation, see
ARCHITECTURE.md.
TBD.