Skip to content

vagran/GateForge

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GateForge: Python RTL hardware design framework

License PyPI - Version

This is an open-source Python framework for designing Register-Transfer Level (RTL) hardware. It provides a domain-specific language (DSL) for creating hardware descriptions that compile to Verilog. The framework bridges the gap between high-level Python expressiveness and the constraints of open-source hardware toolchains like Yosys, which lack full support for SystemVerilog.

It does not tend to introduce any new concepts, but mostly just wraps Verilog into Python DSL, so that you can use any Python features you want for metaprogramming over your synthesizable logic. If you are familiar with Verilog and Python, then you are mostly familiar with this framework.

Table of Contents

Overview

GateForge enables hardware design through Python constructs that translate to Verilog RTL, bridging Python's expressiveness with open-source toolchain capabilities. Key advantages include:

  • High-level abstractions for complex hardware
  • Native integration with Verilator for simulation
  • Type-safe RTL generation compatible with Yosys-based flows
  • Python-native testbench development

Installation

pip install gateforge

Getting Started

Basic XOR Module:

import sys
from gateforge.dsl import wire
from gateforge.compiler import CompileModule

def SampleModule():
    in1 = wire("in1").input.port
    in2 = wire("in2").input.port
    out1 = wire("out1").output.port
    out1 <<= in1 ^ in2

CompileModule(SampleModule, sys.stdout)

Verilator Test Case:

import unittest
import SampleModule from SampleModule
from gateforge.verilator import VerilatorParams

class TestXOR(unittest.TestCase):
    def setUp(self):
        vp = VerilatorParams(buildDir="build")
        self.sim = CompileModule(SampleModule, verilatorParams=vp).simulation_model

    def test_xor_behavior(self):
        self.sim.ports.in1 = 0
        self.sim.ports.in2 = 0
        self.sim.eval()
        self.assertEqual(self.sim.ports.out1, 0)

        self.sim.ports.in1 = 1
        self.sim.eval()
        self.assertEqual(self.sim.ports.out1, 1)

Wires and Registers

Net Declarations

The framework provides Python-idiomatic ways to create wires and registers with various dimensional configurations.

Basic Wire Creation

# Single-bit anonymous wire
w1 = wire()

# 4-bit wire (big-endian, indices 0-3)
w2 = wire(4)          # Verilog: wire [3:0] w2

# 5-bit wire with custom indices
w3 = wire([7, 3])     # Verilog: wire [7:3] w3

# Little-endian 5-bit wire
w4 = wire([3, 7])     # Verilog: wire [3:7] w4

Multi-dimensional Packed Arrays

# 2D packed array (5x8 bits)
w5 = wire([4, 0], [7, 0])  # Verilog: wire [4:0][7:0] w5

# 3D packed structure
w6 = wire([3,0], [15,8], [7,4]) # Verilog: wire [3:0][15:8][7:4] w6

Unpacked Arrays

Same dimensions specifying scheme applies to unpacked arrays.

# 1D unpacked array (2 elements)
arr1 = wire(4).array(2)     # Verilog: wire [3:0] arr1[1:0]

# 2D unpacked array with custom indices
arr2 = wire(8).array([6, 2], [15, 10])
# Verilog: wire [7:0] arr2[6:2][15:10]

# Mixed packed/unpacked
arr3 = wire([7,0], [3,0]).array(4)
# Verilog: wire [7:0][3:0] arr3[3:0]

Dimension Properties

# Vector size calculation
w = wire([7,0], [3,0]).array(4)
assert w.vector_size == 8 * 4  # 32 bits (packed dimensions only)

Register Declarations

Registers follow identical declaration syntax to wires, using reg() instead:

# 8-bit register
r1 = reg(8)        # Verilog: reg [7:0] r1

# Multi-dimensional register
r2 = reg([3,0], [7,4])      # Verilog: reg [3:0][7:4] r2

Key Features

  1. Indexing Schemes:

    • wire(N) creates 0-based big-endian vectors
    • wire([high, low]) creates custom ranges
    • Little-endian semantics is propagated to Verilog corresponding declaration.
  2. Dimension Propagation:

    # Packed then unpacked dimensions
    bus = wire([3,0], [7,4]).array(2)
    # Verilog: wire [3:0][7:4] bus[1:0]

Bit Selection and Slicing

Accessing Net Elements

The framework provides flexible bit selection mechanisms that mirror Verilog's capabilities while maintaining Pythonic syntax.

Single-Bit Access

# Access bit at position 6 (actual hardware bit depends on declaration)
bit6 = w3[6]  # Verilog: w3[6]

Multi-Bit Slicing (MSB:LSB)

# Standard Verilog-style slice (inclusive)
upper_bits = w3[7:3]  # Verilog: w3[7:3]

# Python-style open ranges
first_bits = w3[:3]   # Verilog: w3[<msb>:3]
last_bits = w3[5:]    # Verilog: w3[5:<lsb>]

Note that slice follows verilog notation, the first is MSB index, the second is LSB inclusive index. Endianness should correspond to the net declaration - little-endian wire should be accessed as w3[3:7].

Dynamic Bit Selection

# Single-bit selection using another wire
dynamic_bit = w3[selector]  # Verilog: w3[selector]

# Valid for unpacked arrays
array_element = arr[index]  # Verilog: arr[index]

Dynamic slicing is not supported (tools like Verilator do not support this case).

Key Rules and Conversions

Python Operation Verilog Equivalent Notes
wire[7] wire[7] Actual bit position depends on declaration
wire[7:3] wire[7:3] Inclusive range, MSB first
wire[:3] wire[<msb>:3] Full range from MSB to 3
wire[5:] wire[5:<lsb>] From 5 to LSB inclusive
wire[var] wire[var] Dynamic single-bit selection

Error Checking Examples

# Valid
wire8 = wire(8)
wire8[7:0]    # Full range
wire8[3]      # Single bit

# Invalid
wire8[8]      # IndexError: Bit out of range
wire8[3:7]    # ValueError: Reverse slice (MSB < LSB), mismatched endianness.
wire8[myReg:1]  # ValueError: Non-constant slice indices

Signal Naming and Direction

Explicit Signal Naming

# Anonymous single-bit wire (auto-generated name)
w1 = wire()

# Named wire with explicit 1-bit width
cs = wire("CS")        # Verilog: wire CS;
cs = wire("CS", 1)     # Equivalent explicit form

# Named 8-bit register
counter = reg("COUNT", 8)  # Verilog: reg [7:0] COUNT;

Names collisions are resolved automatically by appending number suffix for conflicting name in the resulting Verilog.

Names are only required if a net is used to define module port (see below). All the internal nets may be anonymous, however it might be more convenient to provide descriptive names for most internal nets for debugging and waveforms analyzing.

Signal Directions

Signal directions are specified using method chaining:

# Input wire
clk = wire("CLK").input       # Verilog: input wire CLK;

# Output register
result = reg("RESULT", 8).output  # Verilog: output reg [7:0] RESULT;

This can be used either for module ports (described below), or for internal signals. In former case it does not have any special effect on the generated Verilog, but used for internal checks to validate usage.

Hierarchical Namespaces

with namespace("PCIe"):
    # Creates wire PCIe_REQ
    req = wire("REQ").input

    with namespace("Tx"):
        # Creates wire PCIe_Tx_DATA_VALID
        valid = wire("DATA_VALID")

Namespace Features

  • Namespace prefixes are cumulative in nested contexts
  • Supports arbitrary depth of nesting
  • Affects all signal types (wires, registers, ports)
  • Generated Verilog uses underscore concatenation

Assignments and Concatenation

Basic Assignments

Use <<= to assign signal. In non-procedural context it always corresponds to continuous assignment.

# Continuous assignment
cs <<= w1  # Verilog: assign cs = w1;

Remember that regular Python assignment just assigns a Python reference to the specified signal.

This is alternative assignment syntax which might be useful in some cases:

cs.assign(w1)

Concatenation Operators

# Basic concatenation
w1 % w2 % w3[7:5]  # Verilog: {w1, w2, w3[7:5]}

# Assignment requires special handling to overcome Python restriction on augmented operators.
# `w1 % w2 % w3[7:5] <<= c1 % r1` is compilation error in Python.
(w1 % w2 % w3[7:5]).assign(c1 % r1)     # Verilog: assign {w1, w2, w3[7:5]} = {c1, r1};

# Alternative using intermediate variable
result = w1 % w2 % w3[7:5]
result <<= c1 % r1

# Function-style concatenation
result <<= concat(w1, w2, w3[7:5])

Constants and Literals

Constant Declaration Methods

# Verilog-style string declaration
hex_const = const("5'ha")       # 5-bit hex: 5'h0a
wide_const = const("16'hxz2")   # 16'bxxxx_zzzz_0000_0010

# Python numeric declaration
dec_const = const(0xaa, 8)      # 8-bit 0xaa
# Boolean type is converted to single bit constant.
bool_const = const(True)

In most places Python int and bool type values can be used as is, corresponding constant is inferred.

cs <<= True
address <<= 0x8000

When constant does not have size specified (either inferred from int or declared without size), its size is unbound, and implies some consequences, mostly the same as in Verilog in the same situation.

unsized = const("'h800")
otherUnsized = const(someIntValue)

Concatenation rules

w4 <<= 5 % w1     # Allowed: 3-bit + 1-bit = 4-bit
w4 <<= w1 % 5     # Error: Right constant needs explicit width
w4 <<= w1 % const(5, 3)  # Valid: 1 + 3 = 4-bit

Ternary Operator Implementation

The framework provides two equivalent syntaxes for conditional assignments:

# Functional style
w1 <<= cond(condition, true_expr, false_expr)

# Method-chaining style
w1 <<= condition.cond(true_expr, false_expr)

Procedural Logic

Sequential Logic

# Edge-triggered
with always(clk.posedge | rst.negedge):
    # Non-blocking assignment
    counter <<= next_counter
    # Blocking assignment
    temp //= a + b

Combinational Logic

with always():
    with _if(sel == 0):
        out <<= a
    # Note that using parenthesis is mandatory since Python bitwise operators
    # have higher precedence over comparison operators.
    with _elseif((sel == 1) | (sel == 3)):
        out <<= b
    with _else():
        out <<= c

_when statement

Wrapper for Verilog case statement is _when:

with _when(w2):
    with _case(1):
        r1 <<= w1
    with _default():
        r1 <<= 5

There are _whenz and _whenx versions for casez and casex correspondingly.

SystemVerilog procedural blocks

# Combinational logic (auto-sensitivity)
with always_comb():
    y <<= a & b

# Clock-driven sequential logic
with always_ff(clk.posedge):
    q <<= d

# Explicit latch declaration
with always_latch():
    if en:
        q <<= d

Initial Blocks

# Power-up initialization (FPGA synthesis)
with initial():
    r1 <<= 42        # Verilog: initial r1 = 42;

Operators

Bitwise Operators

# Standard bitwise operations
and_result = a & b   # Verilog: a & b
or_result = a | b    # Verilog: a | b
xor_result = a ^ b   # Verilog: a ^ b
not_result = ~a      # Verilog: ~a

# XNOR operation (Verilog-specific)
xnor_result = a.xnor(b)  # Verilog: a ~^ b

Shift Operations

# Logical left shift
shift_left = w1.sll(2)   # Verilog: w1 << 2

# Logical right shift (zero fill)
shift_right_log = w1.srl(3)  # Verilog: w1 >> 3

# Arithmetic right shift (sign extend)
shift_right_arith = w1.signed.sra(1)  # Verilog: $signed(w1) >>> 1

Reduction Operators

# Single-bit results from vector operations
all_and = w8.reduce_and    # Verilog: &w8
any_or = w8.reduce_or      # Verilog: |w8
parity = w8.reduce_xor     # Verilog: ^w8

# Inverted reductions
nand = w8.reduce_nand      # Verilog: ~(&w8)
nor = w8.reduce_nor        # Verilog: ~(|w8)
xnor_red = w8.reduce_xnor  # Verilog: ~^(w8)

Replication Operator

# Create repeated patterns
replicated = w4.replicate(3)  # Verilog: {3{w4}}

Operator Precedence Solutions

# Dangerous chained comparison ("Comparison operators chaining" Python feature)
if w1 < w2 == w3:  # Python: (w1 < w2) and (w2 == w3)
    ...             # Not equivalent to Verilog!

# Correct Verilog-style comparison
if (w1 < w2) == w3:  # Verilog: (w1 < w2) == w3
    ...

Operator Reference Table

Python Expression Verilog Equivalent Notes
a & b a & b Bitwise AND
a | b a | b Bitwise OR
a ^ b a ^ b Bitwise XOR
~a ~a Bitwise NOT
a.xnor(b) a ~^ b XNOR gate
w.reduce_and &w Vector AND reduction
w.replicate(n) {n{w}} Replication operator
a.sll(3) a << 3 Logical left shift
a.srl(3) a >> 3 Logical right shift
a.sra(2) a >>> 2 Arithmetic right shift

Functions

Signed Signal Handling

.signed property is a shorthand for calling Verilog $signed built-in function.

# Convert wire to signed interpretation
signed_wire = w1.signed  # Verilog: $signed(w1)

Custom Function Calls

# Some function call which do not have pre-defined wrapper. Result dimensions should be
# specified (omitting produces dimensionless result).
checksum <<= call("calc_crc32", data, Dimensions.Vector(32))

Verilator Warning Suppression

w1 = wire("w1", 2)
w2 = wire("w2")

# Suppress specific warnings for a code block
with verilator_lint_off("WIDTH"):
    w1 <<= w2
    # Generates:
    # // verilator lint_off WIDTH
    # assign w1 = w2;
    # // verilator lint_on WIDTH

verilator_lint_off may take multiple arguments for suppressing multiple warning types.

External Modules

Module Definition

In order to use external modules (provided by target platform or defined in separate Verilog files) they should be defined first.

# Define module interface
UART = module("UART",
    # Port list
    wire("TX").output,
    wire("RX").input,
    wire("CLK").input,
    # Parameters
    parameter("BAUD_RATE", default=115200),
    parameter("DATA_BITS", default=8)
)

Module Instantiation

def TopModule():
    # Instantiate with port connections
    UART(
        TX=tx_wire,
        RX=rx_reg,
        CLK=clk,
        BAUD_RATE=9600,
        DATA_BITS=8
    )

Simulation-time assertion

_assert statement exists to validate conditions in simulator. It is compiled to Verilog-compatible check which calls $fatal if condition evaluates to false.

with always_comb():
    _assert(w1 == 42)

Module Compilation and Structure

A design is compiled into a single top-level Verilog module. Any part of the entire design can be taken, just inputs and outputs should be provided.

Module-level IO ports should be defined by taking .port property of a signal. The signal direction must be specified as well for each port. Port names should be unique, errors produced for name conflicts.

Design internal structure may pass and store Python references to signals and expressions. Python replaces Verilog functionality for components parametrization and configuring (i.e. Verilog parameters and generate blocks).

def MyComponent(cs: Wire, d: Reg):
    # Internal logic using provided ports
    cs <<= d.reduce_xor()

def TopModule():
    # Declare and expose top-level ports
    cs = wire("CS").input.port  # Becomes module input
    d_out = reg("D_OUT", 8).output.port

    # Instantiate component with ports
    MyComponent(cs, d_out)

# Compilation entry point
CompileModule(TopModule, sys.stdout)

CompileModule() Parameters

Parameter Description Default
moduleFunc Python function defining module structure Required
outputStream Text stream for Verilog output Null output
renderOptions Code generation settings (see below) RenderOptions()
moduleName Override generated module name Function name
moduleArgs Positional args to pass to moduleFunc []
moduleKwargs Keyword args to pass to moduleFunc {}
verilatorParams Verilator configuration (enables simulation) None

RenderOptions Configuration

# Custom rendering settings
options = RenderOptions(
    indent="  ",  # 2-space indentation
    sourceMap=True,  # Generate source mapping
    prohibitUndeclaredNets=False,  # Allow implicit nets
    svProceduralBlocks=True  # Use `always_ff`/`always_comb` for `always(sensList)` and `always()`
)

CompileModule(MyModule, renderOptions=options)

Compilation example

Parameterized Modules:

def ParamModule(width=8):
    data = reg("DATA", width).output.port

# Compile with parameter override
CompileModule(ParamModule,
              module_kwargs={"width": 16},
              module_name="WideModule")

Verilator integration

Providing verilatorParams argument for CompileModule() function enables simulation of the module. Here is a complete example:

from pathlib import Path
import unittest

from gateforge.compiler import CompileModule
from gateforge.dsl import wire
from gateforge.verilator import VerilatorParams


def SampleModule():
    in1 = wire("in1").input.port
    in2 = wire("in2").input.port
    out1 = wire("out1").output.port
    out1 <<= in1 ^ in2


class TestBase(unittest.TestCase):

    def setUp(self):
        verilatorParams = VerilatorParams(buildDir=str(Path(__file__).parent / "workspace"),
                                          quite=False)
        self.result = CompileModule(SampleModule, verilatorParams=verilatorParams)
        self.sim = self.result.simulationModel
        self.ports = self.sim.ports
        self.sim.OpenVcd(workspaceDir / "test.vcd")


class TestBasic(TestBase):

    def test_basic(self):

        self.ports.in1 = 0
        self.ports.in2 = 0
        self.sim.Eval()
        self.sim.DumpVcd()
        self.assertEqual(self.ports.out1, 0)

        self.ports.in1 = 1
        self.sim.Eval()
        self.sim.DumpVcd()
        self.assertEqual(self.ports.out1, 1)

        self.ports.in2 = 1
        self.sim.Eval()
        self.sim.DumpVcd()
        self.assertEqual(self.ports.out1, 0)

Use .OpenVcd() and .DumpVcd() methods if waveform dump is needed.

High-level helpers

The above functionality is mostly one-to-one mapped to generated Verilog. It is up to the framework user to decide how to organize the design at higher level using all the power of Python. However, several helpers are provided for typical tasks.

Typing

The helpers below assume type annotations used for class members to provide the functionality. You can use types from gateforge.core package to annotate members, arguments and return values like Expression, Net, Wire, Reg, etc. Besides a type we also use dimensions specification in type annotation which is not compatible with conventions used in Python. It does not cause any runtime failures because type annotation in Python can technically be any object, but it causes warnings for some linting tools. So it may require to disable some warnings for those tools for convenient development.

mypy requires this line in the beginning of file with GateForge annotations:

# mypy: disable-error-code="type-arg, valid-type"

VSCode Pylance requires this entry in settings.json:

"python.analysis.diagnosticSeverityOverrides": {
    "reportInvalidTypeForm": "none"
}

Nets construction by annotations

You can use ConstructNets() function from gateforge.concepts package to create instances for all nets declared in a class. It does not override existing attributes, so typically some non-trivially constructed nets are created explicitly first, then ConstructNets() is called. Size specification follows the same approach as wires and registers creations by wire() and reg() functions. Attribute name is used as net name. ConstructNets() may be called in namespace context to make necessary prefix for the created net names.

class MyComponent:
    w1: Wire # self.w1 = wire("w1)
    w2: Wire[32] # self.w2 = wire("w2", 32)
    # Single tuples cannot be used in type annotation due to Python limitations. Use list to provide single range.
    w3: Wire[[31, 16]] # self.w3 = wire("w3, [31, 16])
    # Single tuple is interpreted as two values
    w3_tuple: Wire[(31, 16)] # self.w3_tuple = wire("w3, 31, 16)
    w4: Wire[4, [31, 16]] # self.w4 = wire("w4", 4, [31, 16])
    r1: Reg[16].array(8) # self.r1 = reg("r1", 16).array(8)
    r2: Reg[16].array(8, [15, 8]) # self.r1 = reg("r1", 16).array(8, [15, 8])
    r3: Reg # Value assigned in constructor so it is untouched by `ConstructNets()`

    def __init__(self, size: int):
        with namespace("MyComponent"):
            # Dynamically sized so construct explicitly
            self.r3 = reg("r3", size)
            # Construct the rest
            ConstructNets(self)

Bus

Bus is used to group nets into a class. Bus requires all nets have specified direction. Use proxy types InputNet and OutputNet parametrized by net type and optional dimensions. The bus class should be inherited from Bus class parameterized by your class name to ensure proper type inference for provided methods.

class SampleBus(Bus["SampleBus"]):
    w: InputNet[Wire]
    r: OutputNet[Reg]

class SizedBus(Bus["SizedBus"]):
    w: InputNet[Wire, (11, 8)]
    r: OutputNet[Reg, 8]
    uw: InputNet[Wire]
    ur: OutputNet[Reg]

It provides static method .Create() to create an instance. It expects all member values are provided as keyword arguments:

b = SampleBus.Create(w=wire().input, r=reg().output)

Note, that each signal direction should be specified by corresponding .input or .output property.

.CreateDefault() creates missing nets like ConstructNets() does:

b = SampleBus.CreateDefault(w=wire())

Use .Construct() method for calling it from the class constructor:

class SampleBusConstr(Bus["SampleBusConstr"]):
    w: InputNet[Wire]
    r: OutputNet[Reg]

    def __init__(self):
        self.Construct(w=wire().input, r=reg().output)

.ConstructDefault() creates missing nets.

.Assign() instance method used for bulk assignments. It validates directions and checks all the specified nets are declared:

b.Assign(w=True, r=myPort)

.Adjacent() instance method returns new bus instance which has direction inverted for all member nets.

Interface

Interface is a replacement for SystemVerilog interfaces which are, for example, not available in Yosys. It looks very similar to bus:

class SampleInterface(Interface["SampleInterface"]):
    w: InputNet[Wire]
    r: OutputNet[Reg]

In contrast with Bus it provides two properties - .internal and .external of type Bus, which represent internal and external port of the interface. The directions specified in the interface members declarations corresponds to internal port, i.e. looking towards a component implementation. External port is adjacent, and is looking towards the component external periphery.

It has the same creation and construction methods as Bus. Typically you want use .Assign() method of .internal and .external buses.

self.memIface.internal.Assign(valid=self.memValid,
                              insn=~self.insnFetched,
                              address=self.memAddress,
                              dataWrite=self.memWData,
                              writeMask=self.memWriteMask)

Advanced example

For more advanced example see RISC-V core example implementation.

License

Apache 2.0 - See LICENSE for details

About

Python-based RTL-synthesis framework

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages