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.
- Overview
- Installation
- Getting Started
- Wires and Registers
- Bit Selection and Slicing
- Signal Naming and Direction
- Assignments and Concatenation
- Constants and Literals
- Ternary Operator Implementation
- Procedural Logic
- Operators
- Functions
- Verilator Warning Suppression
- External Modules
- Simulation-time assertion
- Module Compilation and Structure
- Verilator integration
- High-level helpers
- Advanced example
- License
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
pip install gateforgeBasic 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)The framework provides Python-idiomatic ways to create wires and registers with various dimensional configurations.
# 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# 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] w6Same 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]# Vector size calculation
w = wire([7,0], [3,0]).array(4)
assert w.vector_size == 8 * 4 # 32 bits (packed dimensions only)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-
Indexing Schemes:
wire(N)creates 0-based big-endian vectorswire([high, low])creates custom ranges- Little-endian semantics is propagated to Verilog corresponding declaration.
-
Dimension Propagation:
# Packed then unpacked dimensions bus = wire([3,0], [7,4]).array(2) # Verilog: wire [3:0][7:4] bus[1:0]
The framework provides flexible bit selection mechanisms that mirror Verilog's capabilities while maintaining Pythonic syntax.
# Access bit at position 6 (actual hardware bit depends on declaration)
bit6 = w3[6] # Verilog: w3[6]# 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].
# 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).
| 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 |
# 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# 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 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.
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 prefixes are cumulative in nested contexts
- Supports arbitrary depth of nesting
- Affects all signal types (wires, registers, ports)
- Generated Verilog uses underscore concatenation
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)# 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])# 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 <<= 0x8000When 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)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-bitThe 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)# Edge-triggered
with always(clk.posedge | rst.negedge):
# Non-blocking assignment
counter <<= next_counter
# Blocking assignment
temp //= a + bwith 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 <<= cWrapper for Verilog case statement is _when:
with _when(w2):
with _case(1):
r1 <<= w1
with _default():
r1 <<= 5There are _whenz and _whenx versions for casez and casex correspondingly.
# 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# Power-up initialization (FPGA synthesis)
with initial():
r1 <<= 42 # Verilog: initial r1 = 42;# 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# 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# 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)# Create repeated patterns
replicated = w4.replicate(3) # Verilog: {3{w4}}# 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
...| 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 |
.signed property is a shorthand for calling Verilog $signed built-in function.
# Convert wire to signed interpretation
signed_wire = w1.signed # Verilog: $signed(w1)# 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))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 WIDTHverilator_lint_off may take multiple arguments for suppressing multiple warning types.
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)
)def TopModule():
# Instantiate with port connections
UART(
TX=tx_wire,
RX=rx_reg,
CLK=clk,
BAUD_RATE=9600,
DATA_BITS=8
)_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)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)| 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 |
# 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)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")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.
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.
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"
}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 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 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)For more advanced example see RISC-V core example implementation.
Apache 2.0 - See LICENSE for details