Dependency management in shared VHDL code

In this post we discuss how we can allow changes in shared code by abstraction of interfaces in VHDL. In my previous post about object oriented design in VHDL I argued that designing for code reuse is the single most important design goal in code development. Systematic code reuse allows us to continuously increase the level of expressiveness of our code base which in turn allows ever more complex problems to be solved using blocks of code that are more complete and closer to the solution.

The obvious problem with reuse is that the shared portion of our code must remain unchanged forever or we break every piece of code where the change has an effect. This problem is partly overcome by designing very small distinct units as very small and specific design units are more likely to be reused without needing modification. This however does not address the inevitable situation where change is needed.

With the use of abstraction we can design very general interfaces to our code which allows us to work around the limitation of having unchangeable code in our ever evolving code base. I show how functions, records, packages and libraries allow us to effectively build very general interfaces to our code in a way which gives us the needed room for change.

We are only discussing about synthesizable subset of VHDL and I also show an example how the ideas are used with code that is compiled to an Efinix FPGA. The project referenced in this post can be found here.

Managing change through abstractions

There are two types of changes that need to be allowed. First is the extension of an implementation. This means that the original code is kept as is and we simply provide another way to use some part of code. The second required change is the change of implementation without changing behavior. We need to do this  when we fix a bug is or make some other improvements or generalizations.

We need to make these changes without changing the shared code and to achieve this we need abstractions. Abstraction is accomplished in VHDL through the use of records as the types of objects and then using functions and procedures with this record type object as argument. This allows the actual functional code to be collected in a package in separate source file which is then shared between designs.

An example of an abstraction with record and a function is shown below.

library ieee;
    use ieee.std_logic_1164.all;
    use ieee.numeric_std.all;

package example_pkg is

    type example_record is record
        a_number : integer range -1 to 1;
    end record;
    
    function get_data( example_object : example_record)
        return integer;

end package example_pkg;

We intentionally share these record, function and procedure declarations in name only with no internals exposed and reference them from the packages that are compiled into libraries.

The implementations of the declared functions are placed package body which can be in a separate .vhd file if needed. The body has all of the function and procedure implementations that are declared in the package. In this case it holds the implementation of the declared get_data function.

package body example_pkg is
-----
    function get_data (example_object : example_record ) return integer is
    begin
        return a_number;
    end get_data;
-----
end package body example_pkg;

Creating dependencies when reusing code

When a piece of code depends on another, we call relationship a dependency. We create these dependencies both intentionally and by accident. Dependency management is so important that I would argue that all code architectures exist to manage dependencies when reusing code.

We start with a simple example on the importance of controlling dependencies through abstraction. Let’s assume that the two lines of code shown below achieve the exact same thing. In the first line we use a field called “a_number” from a record. The second line achieves the same behavior of loading “data” from a record through a function call.

data <= object_from_example.a_number;
data <= get_data(object_from_example);

Although using a member of a record directly might seem harmless at first, it unnecessarily locks down the implementation. In this case the type and the name of this field are now part of the shared interface and cannot be changed without also changing all of the code that uses them. This also applies if we at some point noticed that the name “a_number” is not well representing the data which it holds.

With the use of a function any changes to the record can be made without it affecting the code that uses this function.  Since VHDL allows overloading, we can even supply many get_data functions that allow the record type to be interfaced to different data types. This way the use of function instead of the data inside the record opens our design for change.

Now let’s focus on the last note. With the abstraction provided by a simple function either of following example_record definitions could be used for the record type.

type example_record is record
    data : std_logic;
end record;
--
function get_data(object : example_record) return integer is
begin
    if object.data = '1' then
        return 1;
    else
        return 0;
    end if
end function;
type example_record is record
    number_of_hands_on_a_person : integer range -1 to 1;
end record;
--
function get_data(object : example_record) return integer is
begin
    return object.number_of_hands_on_a_person;
end function;

Since we can have records inside records and functions inside functions, this is also a possibility.

type example_record is record
    object_from_another_record : work.another_package.another_record;
end record;
--
function get_data(object : example_record) return integer is
begin
    return work.another_package.get_data(object.object_from_another_record);
end function;

This last one is especially interesting, since the field is actually another record and the function that returns some integer actually calls another function that is provided by the package where the record and its get_data function are defined. With the last one we actually inject both a method and the data with which it is used to a function through the use of the “work” library.

The "work" library

The use of “work.another_package” actually means “use a type another_type from another_package which is found in the same library in which this file is compiled”. In fact the work library does not designate a specific library at all and this is how VHDL gives us the possibility to change both a data and a method of as long as the interface is behaviorally similar. Since we can nest both procedures and functions, this actually allows us to take any abstract interface and add any behaviorally compliant functionality to it.

With the use of the “work” we could reference for example either of these two records

package another_package
-- unsigned version
type another_record is record
(
    some_data : unsigned(22 downto 0);
);

function get_data(object : another_record) return integer is
begin
    return to_integer(object.some_data);
end get_data;
package another_package
-- std_logic_version
type another_record is record
(
    some_data : std_logic_vector(15 downto 0);
);

function get_data(object : another_record) return integer is
begin
    return to_integer(unsigned(object.some_data));
end get_data;

Referencing packages from “work” gives us the key that solves our original problem of locking down our shared code. With the use of the libarary we can have an interface that can access any functionality that behaves the same. Importantly this new behavior can be added into the code when needed. We can even have multiple versions of the same interface with different implementations as long as they are just compiled into differently named libraries.

With the use of work, we reduce the dependency to be in name only as the code which uses a package from work, does not need to know which library it is compiled to and from which library the package it uses is from.

A motivating example

This notion of adding any behaviorally equivalent functionality to an interface is extremely powerful when it is combined with the object oriented design style which I explained in my previous blog post. Lets now consider this following process.

    stimulus : process(simulator_clock)

    begin
        if rising_edge(simulator_clock) then
            simulation_counter <= simulation_counter + 1;

            create_example(object);

            if example_is_ready(object) or simulation_counter = 1 then
                request_example(object, simulation_counter*100 mod 65536);
            end if;

            if example_is_ready(object) then
                integer_data_from_object <= get_data(object);
            end if;

        end if; -- rising_edge
    end process stimulus;	

The behavior of the object is that it is first created, then its action is requested, it tells when the objects action is ready and then result is fetched from it. With the addition that the action is requested with an integer and that the data returned is an integer. This very general behavior is the dependency and we can indeed make it do anything that fits these very broad constraints. As the object tells when its action is ready through the is_ready function, this interface also works with any latency the objects action has.

The point where we inject the change is at the package definition. Thus for any implementation of this interface, we need to supply a package called example_pkg that implements the functions. Note that we can compile the example package to any library we need to, the only significance here is that the implementation is compiled in the same library where the example_pkg is since that we use the library inside the package through reference to “work”.

As this a very loose constraint on our design we can supply almost any behavior to this process by compiling it into a library that also contains our desired functionality. For example, we calculate a sine wave which is given to the object with an implementation of example_package as given below

library ieee;
    use ieee.std_logic_1164.all;
    use ieee.numeric_std.all;

    use work.multiplier_pkg.all;
    use work.sincos_pkg.all;

package example_pkg is
------------------------------------------------------------------------
    type example_record is record
        multiplier : multiplier_record;
        sincos : sincos_record;
    end record;

    constant init_example : example_record := (multiplier => init_multiplier, sincos => init_sincos);
------------------------------------------------------------------------
    procedure create_example (
        signal example_object : inout example_record);
------------------------------------------------------------------------
    procedure request_example (
        signal example_object : inout example_record; data : in integer);
------------------------------------------------------------------------
    function example_is_ready (example_object : example_record)
        return boolean;
------------------------------------------------------------------------
    function get_data ( example_object : example_record)
        return integer;
------------------------------------------------------------------------
end package example_pkg;

package body example_pkg is
------------------------------------------------------------------------
    procedure create_example 
    (
        signal example_object : inout example_record
    ) 
    is
    begin
        create_multiplier(example_object.multiplier);
        create_sincos(example_object.multiplier, example_object.sincos);
    end procedure;

------------------------------------------------------------------------
    procedure request_example
    (
        signal example_object : inout example_record;
        data : in integer
    ) is
    begin
        request_sincos(example_object.sincos, data);
        
    end request_example;

------------------------------------------------------------------------
    function example_is_ready
    (
        example_object : example_record
    )
    return boolean
    is
    begin
        return sincos_is_ready(example_object.sincos);
    end example_is_ready;

------------------------------------------------------------------------
    function get_data
    (
        example_object : example_record
    )
    return integer
    is
    begin
        return get_sine(example_object.sincos);
    end get_data;
------------------------------------------------------------------------
end package body example_pkg;

Since I am using vunit, the sources are compiled to appropriate libraries using a python script shown below. The sources are compiled to a library called “mult” and the example package then injects the functionality into the process which is found in the tb_example.

#!/usr/bin/env python3

from pathlib import Path
from vunit import VUnit

# ROOT
ROOT = Path(__file__).resolve().parent
VU = VUnit.from_argv()

mult = VU.add_library("mult");
mult.add_source_files(ROOT / "source/math_library/multiplier" / "multiplier_base_types_pkg.vhd")
mult.add_source_files(ROOT / "source/math_library/multiplier" / "multiplier_pkg.vhd")
mult.add_source_files(ROOT / "source/math_library/sincos" / "sincos_pkg.vhd")
mult.add_source_files(ROOT / "source/testi/example_pkg.vhd")

example = VU.add_library("example");
example.add_source_files(ROOT /"source/testi/tb_example.vhd")

VU.main()

The result of the sine wave is shown in Figure 1. The design of sine a multiplier packages have been shown in previous blog posts. Since the multiplier and the sine are both synthesizable, we could compile this to any FPGA.

Figure 1. Sine calculation called through abstracted interface

Lastly I show how the idea of abstracted interface is used in the code base to create multiple implementations of a filter with varying word lengths.

Example of a configurable math library with a FPGA

To show how this use of libraries is used in an actual design we are going to build a first order filter using 18, 22 and 26 bit implementations. The module hierarchy is shown in Figure 2. The application uses a filter and a multiplier packages. Multiplier package defines the multiplier object behavior and the multiplier is used by the first order filter object. These are then compiled into three different libraries which differ only by the base types package which define the word length.The multiplier and filter packages are reused with all implementations.

Due to this design we inject the word length to all arithmetic modules that use the multiplier through this base types package.

Figure 2. Module hierarchy of a first order filter

The implementation of the base types package is shown below for the 18 bit version. The 22 and 26 bit versions vary only by the constant.

library ieee;
    use ieee.std_logic_1164.all;
    use ieee.numeric_std.all;

package multiplier_base_types_pkg is

    constant number_of_input_bits : integer := 18;

    type input_array is array (integer range 1 downto 0) of signed(number_of_input_bits-1 downto 0);
    constant init_input_array :  input_array := (0=> (others => '0'), 1 => (others => '0'));

    type output_array is array (integer range 1 downto 0) of signed(init_input_array(0)'length*2-1 downto 0);
    constant init_output_array :  output_array := (0=> (others => '0'), 1 => (others => '0'));

    type multiplier_base_record is record
        signed_data_a                  : input_array;
        signed_data_b                  : input_array;
        multiplier_result              : output_array;
        shift_register                 : std_logic_vector(3 downto 0);
        multiplier_is_busy             : boolean;
        multiplier_is_requested_with_1 : std_logic;
    end record;

    constant initialize_multiplier_base : multiplier_base_record := (init_input_array, init_input_array, init_output_array, (others => '0'), false, '0');
    constant output_word_bit_width      : natural := init_input_array(0)'length;
    constant output_left_index          : natural := output_word_bit_width-1;

end package multiplier_base_types_pkg;

The internal implementation of the multiplier is only dependent on the base type to which we give it and the multiplier derives the widths using attributes. To be able to do this, the multiplier is written in a way to only use type definitions through constants and attributes thus the definitions are obtained from the base_types_pkg.

library ieee;
    use ieee.std_logic_1164.all;
    use ieee.numeric_std.all;

    use work.multiplier_base_types_pkg.all;

package multiplier_pkg is

    subtype int is integer range -2**(number_of_input_bits-1) to 2**(number_of_input_bits-1)-1;

    subtype multiplier_record is multiplier_base_record;

    constant init_multiplier : multiplier_record := multiplier_init_values;

The first order filter uses the multiplier with reference to work library, thus all of the definitions found in the multiplier package are further propagated to the filter package and used in the first_order_filter record definition. For example the “int” datatype is obtained from multiplier_pkg in which it is defined using the “number_of_input_bits” constant that is defined in the base_type_package.

library ieee;
    use ieee.std_logic_1164.all;
    use ieee.numeric_std.all;

    use work.multiplier_pkg.all;

package first_order_filter_pkg is

------------------------------------------------------------------------
    type first_order_filter_record is record
        multiplier_counter : natural range 0 to 15;
        process_counter    : natural range 0 to 15;
        filterin_is_ready  : boolean;
        filter_is_busy     : boolean;
        filter_input       : int;
        filter_output      : int;
        filter_memory      : int;
    end record;

--------------------------------------------------
Compilation with Efinix Efinity software and implementation with Trion FPGA

The library compilation is tool dependent, but all VHDL synthesis tools that I know of support the use of VHDL libraries. With the Efinix Efinity, the library information is added to the projects .xml file with the source file as seen in the snippet below.

The different word length implementations are compiled to libraries that carry in name the length of the multiplier as seen in the snippet below.

        <efx:design_file name="../source/math_library/multiplier/multiplier_base_types_pkg.vhd       " version ="default" library="math_library_18x18"/>
        <efx:design_file name="../source/math_library/multiplier/multiplier_pkg.vhd                  " version ="default" library="math_library_18x18"/>
        <efx:design_file name="../source/math_library/first_order_filter/first_order_filter_pkg.vhd  " version ="default" library="math_library_18x18"/>

        <efx:design_file name="../source/math_library/multiplier/multiplier_base_types_22bit_pkg.vhd " version ="default" library="math_library_22x22"/>
        <efx:design_file name="../source/math_library/multiplier/multiplier_pkg.vhd                  " version ="default" library="math_library_22x22"/>
        <efx:design_file name="../source/math_library/first_order_filter/first_order_filter_pkg.vhd  " version ="default" library="math_library_22x22"/>

        <efx:design_file name="../source/math_library/multiplier/multiplier_base_types_26bit_pkg.vhd " version ="default" library="math_library_26x26"/>
        <efx:design_file name="../source/math_library/multiplier/multiplier_pkg.vhd                  " version ="default" library="math_library_26x26"/>
        <efx:design_file name="../source/math_library/first_order_filter/first_order_filter_pkg.vhd  " version ="default" library="math_library_26x26"/>

In other synthesis tools, like ISE, Vivado or Quartus compilation to library is done with tcl command that performs the same functionality.

The important point here is that the multiplier and first order filter source files are the same in all cases, the variation in the word lengths is achieved only through compiling the sources with the base type package of specific bit width. The different versions of the multiplier and filter are referenced in the source code through the library.package.unit syntax


library math_library_18x18;
    use math_library_18x18.multiplier_pkg.all;
    use math_library_18x18.first_order_filter_pkg.all;

library math_library_22x22;
    use math_library_22x22.multiplier_pkg.all;
    use math_library_22x22.first_order_filter_pkg.all;

library math_library_26x26;
    use math_library_26x26.multiplier_pkg.all;
    use math_library_26x26.first_order_filter_pkg.all;
    
entity system_control is
    port (
        system_control_clocks   : in system_clocks_record;
        system_control_FPGA_in  : in system_control_FPGA_input_group;
        system_control_FPGA_out : out system_control_FPGA_output_group
    );
end entity system_control;
    
architecture rtl of system_control is
    signal multiplier_18x18  : math_library_18x18.multiplier_pkg.multiplier_record := math_library_18x18.multiplier_pkg.init_multiplier;
    signal multiplier_22x22  : math_library_22x22.multiplier_pkg.multiplier_record := math_library_22x22.multiplier_pkg.init_multiplier;
    signal multiplier_26x26  : math_library_26x26.multiplier_pkg.multiplier_record := math_library_26x26.multiplier_pkg.init_multiplier;

    signal filter18 : math_library_18x18.first_order_filter_pkg.first_order_filter_record := math_library_18x18.first_order_filter_pkg.init_first_order_filter;
    signal filter22 : math_library_22x22.first_order_filter_pkg.first_order_filter_record := math_library_22x22.first_order_filter_pkg.init_first_order_filter;
    signal filter26 : math_library_26x26.first_order_filter_pkg.first_order_filter_record := math_library_26x26.first_order_filter_pkg.init_first_order_filter;

begin

 

In the example code we filter a triangle wave with a filter using three different word lengths. Due to the very tight typing VHDL knows from the types of the arguments from which library the procedure implementations are read.Therefore the application does not need to reference the libraries, this information is already in the signal declarations.


------------------------------------------------------------------------
    main_system_controller : process(clock_120Mhz)
    begin
        if rising_edge(clock_120Mhz) then

            init_bus(bus_out);
            create_multiplier(multiplier_18x18);
            create_multiplier(multiplier_22x22);
            create_multiplier(multiplier_26x26);

            create_first_order_filter( filter => filter18, multiplier => multiplier_18x18, time_constant => 0.0002);
            create_first_order_filter( filter => filter22, multiplier => multiplier_22x22, time_constant => 0.0002);
            create_first_order_filter( filter => filter26, multiplier => multiplier_26x26, time_constant => 0.0002);

            connect_read_only_data_to_address(bus_in , bus_out , system_control_data_address , register_in_system_control);
            connect_read_only_data_to_address(bus_in , bus_out , 5588 , filter_input);
            connect_read_only_data_to_address(bus_in , bus_out , 5589 , get_filter_output(filter18)/2);
            connect_read_only_data_to_address(bus_in , bus_out , 5590 , get_filter_output(filter22)/32);
            connect_read_only_data_to_address(bus_in , bus_out , 5591 , get_filter_output(filter26)/512);

            count_down_from(counter, 1199);
            if counter = 0 then
                testi <= testi + 1;
                filter_input <= (testi mod 16384);
                filter_data(filter18, filter_input*8);
                filter_data(filter22, filter_input*128);
                filter_data(filter26, filter_input*2048);
            end if;

        end if; --rising_edge
    end process main_system_controller;	
The count_down_from -procedure creates a down counter that resets every 1200 clock cycles. At counter =  0 an integer signal called testi is incremented and a 0 to 16383 triangle wave is generated. This is then the test input to the filters. The test input is then filtered with the same filter that is just implemented using 18, 22 and 26 bit word lengths in the intermediate calculations. The inputs are multiplied with 8, 128 or 2048 in order to use the full range of the word length of the implementation.
 
The filters are calculated at 100kHz and the outputs of the filters are connected to internal bus in the design, which allows the data to be transmitted out with UART. The connect_read_only_data_to_address is a procedure call that assigns the outputs of the filters to an address and makes them callable from the internal bus in the design. I recorded the internal bus design in a live coding session that can be found in the video section.
 
The full code of the example can be found here.

Test results obtained from Efinix FPGA

The filtered data is captured from the FPGA using an uart. The filter is intentionally designed in a way for the 18 bit word length to cause issues in order to highlight the difference between 18 bit and wider word lengths. Note that the 22 bits is already high enough that there is no perceivable difference between 22 and 26 bit calculation.

Figure 2. filtered data and input
Figure 3. closeup of filtered data showing the distortion with 18 bit calculation. Note that the filter is intentionally calculated in a way to cause the distortion due to word length

Concluding remarks

As the examples showed, the problem with shared code can be solved using the high level features of VHDL. Since we have the ability to change the packages by referring to them as work, we can design modules to which we give both the data types and the functions the data is used with. This allows us to make almost any change to our code without breaking any code that has dependency to it.

There is also a one layer higher abstraction, which is the build system itself. If our build is managed with automated scripts we allow room for adding, removing or renaming source files with as the changes are automatically propagated to every project where the sources are used. Commonly we need new source files for existing modules when package bodies are moved to their own sources.

Although it is not covered here, all of the changes, modifications, library management and generalizations are held together by testing. Continuous testing of our solutions is the only thing that gives us quick feedback on when we eventually break something. I have found it invaluable to get into habit of running a fast compiling set of tests preferably every time I change the test bench which in which I am developing a solution. This makes it so that when something breaks, it is seen instantly. There is a very convenient way to run simulations during VHDL development by using VUnit and GHDL. How this works in practice can be seen in live coding videos that I have recorded.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top