David Clift
FirstEDA

David Clift
FirstEDA
A practical guide to setting up, running, and merging HDL code coverage in a Python-driven simulation flow.
What is VUnit?
VUnit is an open-source unit testing framework for VHDL and SystemVerilog. It is designed to bring the same kind of automated, repeatable testing to HDL development that tools like pytest and JUnit have long provided to software engineers. Rather than manually loading files and issuing commands in a simulator GUI, VUnit lets you describe your test libraries and source files in a Python script and then drives the entire compile-simulate-report cycle from the command line.
VUnit has two distinct layers that work together. The Python layer provides the test runner infrastructure: it discovers testbenches, manages compilation, drives the simulator, and collects pass/fail results. The HDL layer provides a set of VHDL and SystemVerilog packages that are compiled into the simulation and used directly within the testbench source code.
The Python layer provides:
The HDL layer provides a rich set of VHDL packages for testbench development, which are added to the simulation environment by calling add_vhdl_builtins() in the run script:
run_pkg — the core test runner package used inside the VHDL testbench to define and control test cases via the runner_cfg generic, and to signal pass/fail back to the Python layer.logging_pkg — a structured logging framework providing severity levels such as debug, info, warning, error and failure, with filtering and output control.check_pkg — a library of assertion procedures, including check_equal, check_true, check_stable and check_next, that integrate with the logging framework to produce informative failure messages.com_pkg — a message-passing library for communication between concurrent processes and verification components, enabling transaction-level testbench architectures without shared signal busses.sync_pkg and event_pkg — synchronisation primitives for coordinating activity between independent testbench processes.integer_array_pkg and string_ops_pkg — utility packages providing data structures and string manipulation not available in standard VHDL.
For SystemVerilog users, an equivalent set of packages is available via add_verilog_builtins(). Third-party libraries such as OSVVM can also be integrated alongside VUnit’s own packages, allowing teams to combine VUnit’s runner infrastructure with OSVVM’s constrained-random and functional coverage capabilities.
The entry point for a VUnit project is a run.py script. A minimal example looks like this:
from pathlib import Path
from vunit import VUnit
VU = VUnit.from_argv()
VU.add_vhdl_builtins()
LIB = VU.add_library("lib")
LIB.add_source_files(Path(__file__).parent / "RTL" / "*.vhd")
LIB.add_source_files(Path(__file__).parent / "Testbench" / "*.vhd")
VU.main()
Running python run.py will compile all source files and execute every testbench it discovers, reporting results to the terminal.
Why Run Code Coverage on an FPGA Design?
For software engineers, code coverage is a routine metric — most CI pipelines reject a pull request if line or branch coverage drops below a threshold. In the FPGA world, code coverage is often overlooked, yet the arguments for it are just as strong. In safety-critical applications such as DO-254, IEC 61508 and ISO 26262, structural coverage of HDL source is frequently a mandated activity.
Consider the following simple VHDL encoder:
library ieee;
use ieee.std_logic_1164.all;
entity priority_encoder is
port (
req : in std_logic_vector(3 downto 0);
grant : out std_logic_vector(1 downto 0);
valid : out std_logic
);
end entity priority_encoder;
architecture rtl of priority_encoder is
begin
process(req)
begin
valid <= '1';
grant <= "00";
if req(3) = '1' then grant <= "11";
elsif req(2) = '1' then grant <= "10";
elsif req(1) = '1' then grant <= "01";
elsif req(0) = '1' then grant <= "00";
else
valid <= '0'; -- no request active
end if;
end process;
end architecture rtl;
A testbench that only ever drives req with a single bit set at a time will achieve reasonable statement coverage, but it may never exercise the priority behaviour — the fact that req = "1100" grants to bit 3, not bit 2. Branch coverage exposes exactly this gap. Expression coverage goes further still, checking that every sub-expression in a compound condition has been evaluated in both senses.
In summary, code coverage helps you answer four questions:
if, else and case arm been taken?
In Riviera-PRO, the coverage types are selected with a flag shorthand: s = statement, b = branch, e = expression, c = condition. The combination -coverage sbec enables all four simultaneously.
Setting Up the VUnit Runner for Coverage
Enabling coverage in a VUnit/Riviera-PRO flow requires two separate instrumentation steps: the HDL compiler must be told to instrument the RTL source, and the simulator must be told to write a coverage database at the end of each run.
Compile-time instrumentation
Coverage instrumentation is applied at compile time via vcom_flags. Only the RTL source files should be instrumented — applying coverage to the testbench itself produces noise in the report:
# Instrument RTL only
LIB.set_compile_option("rivierapro.vcom_flags", ["-coverage", "sbec"])
Simulation-time database output
Each simulation run must be configured to write a coverage database (.acdb file). VUnit provides a dedicated option for this:
LIB.set_sim_option("enable_coverage", True)
VU.set_sim_option("rivierapro.vsim_flags", ["-acdb_cov", "sbec"])
When enable_coverage is True, VUnit instructs Riviera-PRO to save a coverage.acdb file into each test’s output folder. VUnit creates a unique output directory for every test run, following the path structure:
vunit_out/rivierapro/test_output/lib.<testbench_name>.<test_name>/coverage.acdb
This per-test isolation is important: it means that if you run ten tests, you get ten independent coverage databases, each reflecting only the stimulus applied by that one test. To get a meaningful picture of overall design coverage, these databases must be merged.
Merging Coverage Databases with a post_run Callback
VUnit’s main() function accepts an optional post_run callback:
VU.main(post_run=my_function)
The callback receives a Results object after all tests have completed, which makes it the natural place to merge coverage databases. It is important to note that any code placed after VU.main() will never execute because VUnit calls sys.exit() on completion — the post_run callback is the correct way to run post-simulation logic.
The Results class provides a merge_coverage method that handles per-test database discovery and invokes the simulator’s merge tool directly:
results.merge_coverage(file_name="merged.acdb", args=None)
The file_name argument specifies the path to the merged output database. The optional args parameter accepts a list of additional tool arguments to pass to the underlying merge command. Because VUnit manages the merge internally, there is no need to manually search for individual .acdb files or construct a Tcl macro for the merge step itself.
Generating the HTML report from the merged database still requires a vsim -c -do call, since acdb report is a Riviera-PRO Tcl command rather than a standalone executable. The macro must end with an exit command, otherwise vsim will hang waiting for further input.
Complete Runner Script
Putting it all together, the complete run.py with coverage merge is:
from pathlib import Path
from subprocess import run as sp_run
from vunit import VUnit
VU = VUnit.from_argv()
VU.add_vhdl_builtins()
LIB = VU.add_library("lib")
LIB.add_source_files(Path(__file__).parent / "RTL" / "*.vhd")
# Instrument RTL source for coverage
LIB.set_compile_option("rivierapro.vcom_flags", ["-coverage", "sbec"])
LIB.add_source_files(Path(__file__).parent / "Testbench" / "enc_tb.vhd")
LIB.set_sim_option("enable_coverage", True)
VU.set_sim_option("rivierapro.vsim_flags", ["-acdb_cov", "sbec"])
def merge_coverage(results):
merged_dir = Path(__file__).parent / "acdb"
merged_dir.mkdir(exist_ok=True)
merged_acdb = merged_dir / "merged.acdb"
merged_html = merged_dir / "merged.html"
# VUnit handles file discovery and calls the simulator merge tool
results.merge_coverage(file_name=str(merged_acdb), args=["-cov", "sbec"])
# Generate the HTML report from the merged database
sp_run(
[
"vsim",
"-c",
"-do",
"acdb report -i {acdb} -html -o {html}; exit".format(
acdb=merged_acdb,
html=merged_html,
),
],
check=True,
)
print("Merged coverage report written to: {}".format(merged_html))
VU.main(post_run=merge_coverage)
How It Works End to End
When python run.py is executed, the following sequence takes place:
-coverage sbec flag, embedding coverage instrumentation into the compiled model.vsim configured to write the coverage.acdb file into the test output directory on completion.merge_coverage(results).results.merge_coverage() locates all per-test coverage databases and merges them into a single merged.acdb file.vsim -c -do call executes an acdb report command, producing a merged.html report in the acdb/ directory.
Because results.merge_coverage() handles database discovery internally, adding a new testbench or a new test case requires no changes to the runner script — new coverage databases will be found and included in the merge automatically.
Optional: Gating the Merge on Test Results
The Results object passed to the callback can be used to skip the merge if any tests have failed, preventing a misleading coverage report from incomplete simulation runs:
def merge_coverage(results):
report = results.get_report()
failed = [name for name, t in report.tests.items() if t.status == "failed"]
if failed:
print("Skipping coverage merge: {} test(s) failed:".format(len(failed)))
for name in failed:
print(" {}".format(name))
return
# ... merge logic ...
Whether to gate on failures depends on your workflow. During early development, it is often useful to see partial coverage even when some tests are failing. For a formal sign-off run — for example, when generating evidence for a DO-254 or IEC 61508 review — you would typically want a clean pass before treating the coverage report as meaningful.
Conclusion
VUnit provides a clean, scriptable framework for driving HDL simulation from Python, and its compile/sim option APIs map naturally onto Riviera-PRO’s coverage instrumentation flags. The Results.merge_coverage() method removes the need to manually manage per-test database paths or construct simulator Tcl macros for the merge step, keeping the post_run callback concise and maintainable.
The resulting workflow is fully automated: a single python run.py invocation compiles the design with coverage instrumentation, runs all testbenches, and produces a merged HTML coverage report without any manual simulator interaction. This makes it straightforward to integrate HDL code coverage into a continuous integration pipeline and to generate reproducible coverage evidence for functional safety reviews.
Supporting FPGA Design and Verification Teams Across Northern Europe
At FirstEDA, we support FPGA design and verification teams across Northern Europe through our partnerships with Aldec, Sigasi, Agnisys and SynthWorks, as well as our own FirstEDA Training courses. This blend of tools, methodologies and practical expertise enables engineering teams to streamline workflows, improve design quality and adopt modern verification practices that scale with project demands. Whether you are beginning a new development effort or enhancing an established process, we work with you to build a verification flow that is efficient, maintainable and aligned with your goals.
If you’d like to explore how we can support your next project, we’d be delighted to discuss your requirements.