FPGA build flow

In tsfpga the build projects are set up by the modules. Any module can set up a build project as long as they follow the folder structure. The build project is represented using a Python class that abstracts all settings and operations.

Minimal build.py example

Given that we follow the folder structure, and have and least one module that sets up build projects, we can utilize a build.py like this:

Minimal build.py file.
from pathlib import Path
from tsfpga.build_project_list import BuildProjectList
from tsfpga.module import get_modules

my_modules_folders = [
    Path("path/to/my/modules"),
]
modules = get_modules(my_modules_folders)
build_path = Path("my_generated_build_projects")
projects = BuildProjectList(modules, "artyz7*")
projects.create_unless_exists(project_paths=build_path, num_parallel_builds=4)
projects.build(project_path=build_path, num_parallel_builds=4, num_threads_per_build=6)

Of course this is incredibly simplified and hard-coded, but it does show the interface to the tsfpga classes. The BuildProjectList class will work on a list of build project objects as supplied by the modules.

An example output from this script is shown below. It shows to build projects being launched in parallel, and then finishing and roughly the same time.

[/home/lukas/work/repo/tsfpga]$ python tsfpga/examples/build.py
Starting artyz7
Output file: /home/lukas/work/repo/tsfpga/generated/projects/artyz7/output.txt
Starting artyz7_dummy
Output file: /home/lukas/work/repo/tsfpga/generated/projects/artyz7_dummy/output.txt
pass (pass=1 fail=0 total=2) artyz7_dummy (229.5 seconds)

pass (pass=2 fail=0 total=2) artyz7 (229.8 seconds)

==== Summary ========================

Size of artyz7_dummy after implementation:
{
  "Total LUTs": 804,
  "Logic LUTs": 746,
  "LUTRAMs": 58,
  "SRLs": 0,
  "FFs": 1596,
  "RAMB36": 0,
  "RAMB18": 1,
  "DSP Blocks": 0
}
pass artyz7_dummy   (229.5 seconds)


Size of artyz7 after implementation:
{
  "Total LUTs": 804,
  "Logic LUTs": 746,
  "LUTRAMs": 58,
  "SRLs": 0,
  "FFs": 1596,
  "RAMB36": 0,
  "RAMB18": 1,
  "DSP Blocks": 0
}
pass artyz7         (229.8 seconds)

=====================================
pass 2 of 2
=====================================
Total time was 459.3 seconds
Elapsed time was 229.8 seconds
=====================================
All passed!

Note that before a project is built a register generation is run, so that the project is built using up-to-date register definitions.

Of course a more realistic build.py would be a little more verbose. It would probably feature command line arguments that control the behavior, output paths, etc. And example of this, which also features release artifact packaging, is available in the repo.

Example project class creation

This is an example of project creation, using the artyz7 example project from the repo.

Projects are created by modules using the file module_<module_name>.py, see folder structure for details. In tsfpga a top-level module that defines build projects is handled just like any other module. It can use register generation, set up simulations, etc. The only difference is that it overrides the BaseModule.get_build_projects() method to return a list of build project objects.

Example project creation
from pathlib import Path

from tsfpga.constraint import Constraint
from tsfpga.module import BaseModule, get_hdl_modules
from tsfpga.examples.example_env import get_tsfpga_example_modules
from tsfpga.vivado.project import VivadoProject


THIS_FILE = Path(__file__)


class Module(BaseModule):
    def get_build_projects(self):
        projects = []

        modules = get_hdl_modules() + get_tsfpga_example_modules()
        part = "xc7z020clg400-1"

        tcl_dir = self.path / "tcl"
        pinning = Constraint(tcl_dir / "artyz7_pinning.tcl")
        block_design = tcl_dir / "block_design.tcl"

        projects.append(
            VivadoProject(
                name="artyz7",
                modules=modules,
                part=part,
                tcl_sources=[block_design],
                constraints=[pinning],
                defined_at=THIS_FILE,
            )
        )

        projects.append(
            SpecialVivadoProject(
                name="artyz7_dummy",
                modules=modules,
                part=part,
                top="artyz7_top",
                generics=dict(dummy=True, value=123),
                constraints=[pinning],
                tcl_sources=[block_design],
            )
        )

        return projects


class SpecialVivadoProject(VivadoProject):
    def post_build(self, output_path, **kwargs):  # pylint: disable=arguments-differ
        print(f"We can do useful things here. In the output path {output_path} for example")
        return True

There is a lot going on here, so lets go through what happens in get_build_projects().

Get modules

Firstly we need to get a list of modules that shall be included in the build project. Source files, IP cores, scoped constraints, etc., from all these modules will be added to the project.

It can be a good idea to filter what modules are included here. If we have a huge module tree but our project only uses a subset of the modules, we might not want to slow down Vivado by adding everything. We might also use primitives and IP cores in some modules that are not available for the target part. This filtering of modules can be achieved using the arguments to get_modules().

In this case we use two wrappers, get_hdl_modules() and get_tsfpga_example_modules(), around the get_modules() function. They set the correct flags (modules paths, default registers and library_name_has_lib_suffix). It is recommended to use functions like these so the arguments don’t have to be repeated in many places.

Note that the artyz7 example build project uses modules from the separate hdl_modules project, via the get_hdl_modules() function. See Integration with hdl_modules for details.

TCL files

This module has a sub-folder tcl which contains pinning and a block design. The block design, which is added to the VivadoProject as a TCL source is simply represented using it’s path. The pinning on the other hand, which is used as a constraint in Vivado, must be represented using the Constraint class.

Creating project objects

The sources gathered are then use to create project objects which are appended to the projects list which is returned at the end.

First a VivadoProject object is created with the name artyz7. The modules, part name, TCL sources and constraints are passed to the constructor. There is also a defined_at argument, which is given the path to the module_artyz7.py file. This is used to get a useful --list result in our build.py.

The second project is created using a child class that inherits VivadoProject. It showcases how to use pre and post build hook functions. The post_build() function does nothing in this example, but the mechanism can be very useful in real-world cases.

The second project also showcases how to set some generic values. For the second project we additionally have to specify the top name. In the first one it is inferred from the project name to be artyz7_top, whereas in the second one we have to specify it explicitly.

Pre- and post- build function hooks

The VivadoProject functions pre_build() and post_build() can be convenient in certain use cases. They will receive all the arguments that are passed to VivadoProject.build(), such as project path and output path. Additional named arguments sent to VivadoProject.build() will also be available in pre_build() and post_build(). So in our example build.py above we could have passed further arguments on the line that says project.build(...).

Build result with utilization numbers

The VivadoProject.build() method will return a build_result.BuildResult object upon completion. It can be inspected to see if the run passed or failed, and what the resource utilization of the build is.