Enable Your Simulator to Handle Complex Top-Level Generics#

A powerful feature in VUnit is the ability to run testbenches and test cases with different configurations (not to be confused with VHDL configurations). The typical use case is to run tests with different generics but you can also run with different simulator settings and register Python functions to be run before and after the test. The latter can be used to create stimuli and verify test outputs using the power of Python or some other external program like Matlab.

Over time VUnit users tend to get more advanced in the use of generics which inevitably leads to more complex data types. Rather than passing many generics of scalar types they want to create composite types like records and arrays. Unfortunately, many simulators have restrictions on what type of generics you can pass to the top-level testbench entity. Typically you’re limited to a small subset of the standard composite types like string and std_logic_vector and can’t use custom composite types. This is a limitation when trying to write clean and efficient code but something that can be worked around using VUnit. The trick is to encode your composite data type into something that the simulator _can_ handle and then decode back to the original type within the VHDL testbench. string is something most (all?) simulators can handle and what I will use in these examples.

Let’s say that you want pass an integer_vector generic called image_resolution to your testbench. Such a vector can be represented with a list in Python which can be encoded into a comma-separated string.

image_resolution = [640, 480]
encoded_image_resolution = ", ".join(map(str, image_resolution))

The Python map function applies the provided str function to all elements of the image_resolution list to convert the integers to strings. The resulting string elements are then joined together to create a comma-separated string "640, 480".

Assuming my testbench entity is named tb_composite_generics, is compiled into library tb_lib, and has a test case (badly) named Test 1, I can create a configuration for that test case with these lines in my Python run script.

image_resolution = [640, 480]
encoded_image_resolution = ", ".join(map(str, image_resolution))

testbench = tb_lib.entity("tb_composite_generics")
test_1 = testbench.test("Test 1")

generics = dict(encoded_image_resolution=encoded_image_resolution)
test_1.add_config(name='VGA', generics=generics)

Generics are represented with a Python dictionary (list of key=value pairs) where the key is the name of the generic, in this case encoded_image_resolution which is assigned our variable with the same name. We name our configuration to VGA and when listing our test cases we will see our test case and its only configuration.

> python run.py -l
tb_lib.tb_composite_generics.VGA.Test 1
Listed 1 tests

The beginning of our example testbench at this point looks like this

library vunit_lib;
context vunit_lib.vunit_context;

entity tb_composite_generics is
  generic (
    encoded_image_resolution : string;
    runner_cfg : string);
end tb_composite_generics;

architecture tb of tb_composite_generics is
  impure function decode(encoded_integer_vector : string) return integer_vector is
    variable parts : lines_t := split(encoded_integer_vector, ", ");
    variable return_value : integer_vector(parts'range);
    for i in parts'range loop
      return_value(i) := integer'value(parts(i).all);
    end loop;

    return return_value;

  constant image_resolution : integer_vector := decode(encoded_image_resolution);

The decoded image_resolution constant is initialized by calling the decode function with the encoded resolution. decode is based on VUnit’s split function located in the string_ops package. It splits a string into its parts based on a defined separator, in this case ", ", and returns a pointer to a vector with line type elements. These line elements are converted back to integers which are inserted into the returned integer_vector. The overall solution is compact. Both the encoding and the decoding are single lines of code once the reusable decode function has been placed in a support package.

Sometimes it’s more convenient with record generics. Maybe you want your complete testbench configuration in a single tb_cfg generic to avoid the hassle of re-routing generics through your design when new ones are added or removed. Just add/remove elements in that record. Such a generic can be represented with a Python dictionary

tb_cfg = dict(image_width=640, image_height=480, dump_debug_data=False)
encoded_tb_cfg = ", ".join(["%s:%s" % (key, str(tb_cfg[key])) for key in tb_cfg])

The encoding joins a list of string elements into a comma-separated string like we did before but each element in the list is a key:value pair taken from the tb_cfg dictionary. The resulting string is "image_width:640, image_height:480, dump_debug_data:True". This is the same key:value format used in the runner_cfg generic present in every VUnit testbench so we have built-in support for decoding such a string.

type tb_cfg_t is record
  image_width     : positive;
  image_height    : positive;
  dump_debug_data : boolean;
end record tb_cfg_t;

impure function decode(encoded_tb_cfg : string) return tb_cfg_t is
  return (image_width => positive'value(get(encoded_tb_cfg, "image_width")),
          image_height => positive'value(get(encoded_tb_cfg, "image_height")),
          dump_debug_data => boolean'value(get(encoded_tb_cfg, "dump_debug_data")));
end function decode;

constant tb_cfg : tb_cfg_t := decode(encoded_tb_cfg);

The get function returns the value for the provided key as a string so it has to be converted before assigning the target record.

Note that you can also use the tb_cfg to configure the structure of the test bench, not only parameter values like image resolution. For example

dump_debug_data: if tb_cfg.dump_debug_data generate
  process is
    for y in 0 to tb_cfg.image_height - 1 loop
      for x in 0 to tb_cfg.image_width - 1 loop
        wait until rising_edge(clk) and data_valid = '1';
        debug("Dumping tons of debug data");
      end loop;
    end loop;

    dumping_done <= true;
  end process;
end generate dump_debug_data;

Let’s create two configurations this time. One configuration with a VGA image not dumping the extra debug data and one configuration with a tiny image (for a fast simulation) and complete debug information.

def encode(tb_cfg):
    return ", ".join(["%s:%s" % (key, str(tb_cfg[key])) for key in tb_cfg])

vga_tb_cfg = dict(image_width=640, image_height=480, dump_debug_data=False)
test_1.add_config(name='VGA', generics=dict(encoded_tb_cfg=encode(vga_tb_cfg)))

tiny_tb_cfg = dict(image_width=4, image_height=3, dump_debug_data=True)
test_1.add_config(name='tiny', generics=dict(encoded_tb_cfg=encode(tiny_tb_cfg)))

The result is

> python run.py -v
Running test: tb_lib.tb_composite_generics.tiny.Test 1
Running test: tb_lib.tb_composite_generics.VGA.Test 1
Running 2 tests

Starting tb_lib.tb_composite_generics.tiny.Test 1
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
DEBUG: Dumping tons of debug data
simulation stopped @23ns with status 0
pass (P=1 S=0 F=0 T=2) tb_lib.tb_composite_generics.tiny.Test 1 (0.3 seconds)

Starting tb_lib.tb_composite_generics.VGA.Test 1
simulation stopped @0ms with status 0
pass (P=2 S=0 F=0 T=2) tb_lib.tb_composite_generics.VGA.Test 1 (0.3 seconds)

==== Summary ====================================================
pass tb_lib.tb_composite_generics.tiny.Test 1 (0.3 seconds)
pass tb_lib.tb_composite_generics.VGA.Test 1  (0.3 seconds)
pass 2 of 2
Total time was 0.5 seconds
Elapsed time was 0.5 seconds
All passed!

That’s all for this time. You can find the code for the final (dummy) testbench here.