commit c079c089981251599950d859101f33b39b7a7322 Author: Asherah Connor Date: Thu Jun 13 21:51:30 2024 +0300 init. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..583d76c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +/build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3162d1d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +Copyright (C) 2024 Asherah Connor + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..56866fa --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# niar + +A small framework for building projects with [Amaranth]. + +See the [template project] for usage. + +[Amaranth]: https://amaranth-lang.org/ +[template project]: https://github.com/kivikakk/niar-template + diff --git a/niar/__init__.py b/niar/__init__.py new file mode 100644 index 0000000..b52fcf9 --- /dev/null +++ b/niar/__init__.py @@ -0,0 +1,26 @@ +from argparse import ArgumentParser + +from . import build, cxxrtl +from .cxxrtl_platform import CxxrtlPlatform +from .project import Project + +__all__ = ["Project", "cli", "CxxrtlPlatform"] + + +def cli(np: Project): + parser = ArgumentParser(prog=np.name) + subparsers = parser.add_subparsers(required=True) + + build.add_arguments( + np, + subparsers.add_parser( + "build", help="build the design, and optionally program it" + ), + ) + if getattr(np, "cxxrtl_targets"): + cxxrtl.add_arguments( + np, subparsers.add_parser("cxxrtl", help="run the C++ simulator tests") + ) + + args = parser.parse_args() + args.func(args) diff --git a/niar/build.py b/niar/build.py new file mode 100644 index 0000000..e427be1 --- /dev/null +++ b/niar/build.py @@ -0,0 +1,108 @@ +import importlib +import inspect +import logging +import re +from functools import partial +from typing import Optional + +from amaranth.build import Platform + +from .logger import logger, logtime +from .project import Project + +__all__ = ["add_arguments"] + + +def add_arguments(np: Project, parser): + parser.set_defaults(func=partial(main, np)) + match sorted(t.__name__ for t in np.targets): + case []: + raise RuntimeError("no buildable targets defined") + case [first, *rest]: + parser.add_argument( + "-b", + "--board", + choices=[first, *rest], + help="which board to build for", + required=bool(rest), + **({"default": first} if not rest else {}), + ) + parser.add_argument( + "-p", + "--program", + action="store_true", + help="program the design onto the board after building", + ) + parser.add_argument( + "-v", + "--verilog", + action="store_true", + help="output debug Verilog", + ) + + +def main(np: Project, args): + logger.info("building %s for %s", np.name, args.board) + + platform = np.target_by_name(args.board) + design = construct_top(np, platform) + + with logtime(logging.DEBUG, "elaboration"): + plan = platform.prepare( + design, + np.name, + debug_verilog=args.verilog, + yosys_opts="-g", + ) + fn = f"{np.name}.il" + size = len(plan.files[fn]) + logger.debug(f"{fn!r}: {size:,} bytes") + + with logtime(logging.DEBUG, "synthesis/pnr"): + products = plan.execute_local("build") + + if args.program: + with logtime(logging.DEBUG, "programming"): + platform.toolchain_program(products, np.name) + + heading = re.compile(r"^\d+\.\d+\. Printing statistics\.$", flags=re.MULTILINE) + next_heading = re.compile(r"^\d+\.\d+\. ", flags=re.MULTILINE) + log_file_between(logging.INFO, f"build/{np.name}.rpt", heading, next_heading) + + logger.info("Device utilisation:") + heading = re.compile(r"^Info: Device utilisation:$", flags=re.MULTILINE) + next_heading = re.compile(r"^Info: Placed ", flags=re.MULTILINE) + log_file_between( + logging.INFO, f"build/{np.name}.tim", heading, next_heading, prefix="Info: " + ) + + +def construct_top(np: Project, platform: Platform, **kwargs): + sig = inspect.signature(np.top) + if "platform" in sig.parameters: + kwargs["platform"] = platform + return np.top(**kwargs) + + +def log_file_between( + level: int, + path: str, + start: re.Pattern, + end: re.Pattern, + *, + prefix: Optional[str] = None, +): + with open(path, "r") as f: + for line in f: + if start.match(line): + break + else: + return + + for line in f: + if end.match(line): + return + line = line.rstrip() + if prefix is not None: + line = line.removeprefix(prefix) + logger.log(level, line) diff --git a/niar/cxxrtl.py b/niar/cxxrtl.py new file mode 100644 index 0000000..70dca4f --- /dev/null +++ b/niar/cxxrtl.py @@ -0,0 +1,197 @@ +import json +import logging +import subprocess +from enum import Enum +from functools import partial +from pathlib import Path +from typing import Any + +from amaranth import Elaboratable +from amaranth._toolchain.yosys import YosysBinary, find_yosys +from amaranth.back import rtlil + +from .build import construct_top +from .cxxrtl_platform import CxxrtlPlatform +from .logger import logger, logtime +from .project import Project + +__all__ = ["add_arguments"] + +CXXFLAGS = [ + "-std=c++17", + "-g", + "-pedantic", + "-Wall", + "-Wextra", + "-Wno-zero-length-array", + "-Wno-unused-parameter", +] + + +class _Optimize(Enum): + none = "none" + rtl = "rtl" + + def __str__(self): + return self.value + + @property + def opt_rtl(self) -> bool: + return self in (self.rtl,) + + +def add_arguments(np: Project, parser): + parser.set_defaults(func=partial(main, np)) + match sorted(t.__name__ for t in np.cxxrtl_targets or []): + case []: + raise RuntimeError("no cxxrtl targets defined") + case [first, *rest]: + parser.add_argument( + "-t", + "--target", + choices=[first, *rest], + help="which CXXRTL target to build", + required=bool(rest), + **({"default": first} if not rest else {}), + ) + parser.add_argument( + "-c", + "--compile", + action="store_true", + help="compile only; don't run", + ) + parser.add_argument( + "-O", + "--optimize", + type=_Optimize, + choices=_Optimize, + help="build with optimizations (default: rtl)", + default=_Optimize.rtl, + ) + parser.add_argument( + "-d", + "--debug", + action="store_true", + help="generate source-level debug information", + ) + parser.add_argument( + "-v", + "--vcd", + action="store", + type=str, + help="output a VCD file", + ) + + +def main(np: Project, args): + yosys = find_yosys(lambda ver: ver >= (0, 10)) + + platform = np.cxxrtl_target_by_name(args.target) + design = construct_top(np, platform) + + cxxrtl_cc_path = np.path.build(f"{np.name}.cc") + with logtime(logging.DEBUG, "elaboration"): + _cxxrtl_convert_with_header( + yosys, + cxxrtl_cc_path, + design, + np.name, + platform, + black_boxes={}, + ) + + cc_o_paths = { + cxxrtl_cc_path: np.path.build(f"{np.name}.o"), + } + for path in np.path("cxxrtl").glob("*.cc"): + cc_o_paths[path] = np.path.build(f"{path.stem}.o") + + cxxflags = CXXFLAGS + [ + f"-DCLOCK_HZ={int(platform.default_clk_frequency)}", + *(["-O3"] if args.optimize.opt_rtl else ["-O0"]), + *(["-g"] if args.debug else []), + ] + + procs = [] + compile_commands = {} + for cc_path, o_path in cc_o_paths.items(): + cmd = [ + "c++", + *cxxflags, + f"-I{np.path("build")}", + f"-I{yosys.data_dir() / "include" / "backends" / "cxxrtl" / "runtime"}", + "-c", + str(cc_path), + "-o", + str(o_path), + ] + compile_commands[o_path] = cmd + logger.debug(" ".join(str(e) for e in cmd)) + procs.append((cc_path, subprocess.Popen(cmd))) + + with open(np.path.build("compile_commands.json"), "w") as f: + json.dump( + [ + { + "directory": str(np.path()), + "file": str(file), + "arguments": arguments, + } + for file, arguments in compile_commands.items() + ], + f, + ) + + failed = [] + for cc_path, p in procs: + if p.wait() != 0: + failed.append(cc_path) + + if failed: + logger.error("Failed to build paths:") + for p in failed: + logger.error(f"- {p}") + raise RuntimeError("failed compile step") + + exe_o_path = np.path.build("cxxrtl") + cmd = [ + "c++", + *cxxflags, + *cc_o_paths.values(), + "-o", + exe_o_path, + ] + logger.debug(" ".join(str(e) for e in cmd)) + subprocess.run(cmd, check=True) + + if not args.compile: + cmd = [exe_o_path] + if args.vcd: + cmd += ["--vcd", args.vcd] + logger.debug(" ".join(str(e) for e in cmd)) + subprocess.run(cmd, check=True) + + +def _cxxrtl_convert_with_header( + yosys: YosysBinary, + cc_out: Path, + design: Elaboratable, + name: str, + platform: CxxrtlPlatform, + *, + black_boxes: dict[Any, str], +): + if cc_out.is_absolute(): + try: + cc_out = cc_out.relative_to(Path.cwd()) + except ValueError: + raise AssertionError( + "cc_out must be relative to cwd for builtin-yosys to write to it" + ) + rtlil_text = rtlil.convert(design, name=name, platform=platform) + script = [] + for box_source in black_boxes.values(): + script.append(f"read_rtlil < Platform: + for t in self.targets: + if t.__name__ == name: + return t() + raise KeyError(f"unknown target {name!r}") + + def cxxrtl_target_by_name(self, name: str) -> CxxrtlPlatform: + for t in self.cxxrtl_targets or []: + if t.__name__ == name: + return t() + raise KeyError(f"unknown CXXRTL target {name!r}") + + def main(self): + from . import cli + + cli(self) + + @property + def path(self): + return ProjectPath(self) + + +class ProjectPath: + def __init__(self, np: Project): + self.np = np + + def __call__(self, *components): + return self.np.origin.joinpath(*components) + + def build(self, *components): + return self("build", *components) diff --git a/niar/test_cli.py b/niar/test_cli.py new file mode 100644 index 0000000..9093a3c --- /dev/null +++ b/niar/test_cli.py @@ -0,0 +1,32 @@ +import unittest +from argparse import ArgumentParser + +from amaranth import Elaboratable, Module +from amaranth_boards.icebreaker import ICEBreakerPlatform + +from . import Project, build, logger + +calling_test = False + + +class FixtureTop(Elaboratable): + def elaborate(self, platform): + return Module() + + +class FixtureProject(Project): + name = "fixture" + top = FixtureTop + targets = [ICEBreakerPlatform] + + +class TestCLI(unittest.TestCase): + def setUp(self): + logger.disable() + self.addCleanup(logger.enable) + + def test_build_works(self): + parser = ArgumentParser() + build.add_arguments(FixtureProject(), parser) + args, _argv = parser.parse_known_args() + args.func(args) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0a3ffb3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "niar" +version = "0.1" +description = "A small framework for building projects with Amaranth" +authors = [ + {name = "Asherah Connor", email = "ashe@kivikakk.ee"}, +] +dependencies = [ + "amaranth >= 0.4.5, < 0.6", +] +requires-python = ">=3.8" +license = {text = "BSD-2-Clause"} + +[project.optional-dependencies] +build = [ + "amaranth-boards", # for test +] + +[project.urls] +Homepage = "https://github.com/kivikakk/niar" + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend"