Skip to content

Integrating Verilog Modules (Example: Block RAM)

While noRTL provides powerful high-level constructs for sequential design, you will often need to integrate existing or third-party IP cores. noRTL makes this process seamless by allowing you to wrap any Verilog module in a Python class and connect it to your engine's logic. Let's walk through this using a Block RAM as an example.

Providing a Verilog Module

First, we need our target hardware description. Here is a simple synchronous Block RAM with a pipeline register for reads and clock-gating support:

// hdl/generated/nortl/nortl_block_ram.sv
module nortl_block_ram #(
    parameter ADDR_WIDTH = 8,
    parameter DATA_WIDTH = 32,
    parameter RAM_DEPTH = 1 << ADDR_WIDTH
) (
    input  logic              CLK_I, // Clock and reset names are defined by convention. Leave unconnected if you dont need them
    input  logic              RST_ASYNC_I,
    input  logic              WR_EN,
    input  logic [ADDR_WIDTH-1:0] ADDR_I,
    input  logic [DATA_WIDTH-1:0] WR_DATA_I,
    output logic [DATA_WIDTH-1:0] RD_DATA_O
);
    logic [DATA_WIDTH-1:0] ram [0:RAM_DEPTH-1];

    // Synchronous write
    always_ff @(posedge CLK_I or posedge RST_ASYNC_I) begin
        if (RST_ASYNC_I) begin
            // Optional: initialize RAM contents
        end else if (WR_EN) begin
            ram[ADDR_I] <= WR_DATA_I;
        end
    end

    // Synchronous read with pipeline register
    always_ff @(posedge CLK_I or posedge RST_ASYNC_I) begin
        if (RST_ASYNC_I) begin
            RD_DATA_O <= 0;
        end else begin
            RD_DATA_O <= ram[ADDR_I];
        end
    end

endmodule

Adding the Module to NoRTL

Before we can instantiate this module in Python, we need to register it with noRTL's library. This is done in src/nortl/verilog_library/__init__.py. We add the file path to the BUILT_IN_LIB dictionary and define the module's interface in the get_modules() function:

# src/nortl/verilog_library/__init__.py
# ... existing imports ...

BUILT_IN_LIB = {
    # ... existing entries ...
    'nortl_block_ram': (VERILOG_LIBRARY_DIR / 'nortl_block_ram.sv').resolve(),
}

def get_modules() -> List[Module]:
    module_list = []
    # ... existing module parsing ...

    hdl_ram = ''
    with open(BUILT_IN_LIB['nortl_block_ram'], 'r') as file:
        hdl_ram = file.read()

    ram = Module('nortl_block_ram', hdl_ram)
    ram.add_port('WR_EN')
    ram.add_port('ADDR_I')
    ram.add_port('WR_DATA_I')
    ram.add_port('RD_DATA_O')
    ram.add_parameter('ADDR_WIDTH', 8)
    ram.add_parameter('DATA_WIDTH', 32)
    ram.add_port('CLK_REQ')
    ram.set_clk_request('CLK_REQ')
    module_list.append(ram)

    return module_list

Note

It is recommended that you create your own get_modules function in your own package to collect all verilog components you need for your noRTL engine.

Wrapping the Module in a Python Class

We create a Python class that handles instantiation, parameter overriding, and port mapping. This class acts as a bridge between your sequential Python code and the synthesized Verilog logic.

# src/nortl/components/block_ram.py
from typing import Union, List
from nortl.core.protocols import EngineProto, Renderable

class BlockRam:
    def __init__(
        self,
        engine: EngineProto,
        addr_width: int = 8,
        data_width: int = 32,
        instance_name_prefix: str = 'I_RAM'
    ) -> None:
        self.engine = engine

        # The following block prevents name collisions
        ram_idx = 0
        while (instance_name := f'{instance_name_prefix}_{ram_idx}') in engine.module_instances:
            ram_idx += 1
        self.instance_name = instance_name

        self.ram_module = self.engine.create_module_instance('nortl_block_ram', self.instance_name)
        self.engine.override_module_parameter(self.instance_name, 'ADDR_WIDTH', addr_width)
        self.engine.override_module_parameter(self.instance_name, 'DATA_WIDTH', data_width)

        # Define internal signals for port mapping
        self.wr_en = self.engine.define_local(f'{self.instance_name}_wr_en')
        self.addr = self.engine.define_local(f'{self.instance_name}_addr', width=addr_width)
        self.wr_data = self.engine.define_local(f'{self.instance_name}_wr_data', width=data_width)
        self.rd_data = self.engine.define_local(f'{self.instance_name}_rd_data', width=data_width)

        # Connect Python signals to Verilog ports
        self.engine.connect_module_port(self.instance_name, 'WR_EN', self.wr_en)
        self.engine.connect_module_port(self.instance_name, 'ADDR_I', self.addr)
        self.engine.connect_module_port(self.instance_name, 'WR_DATA_I', self.wr_data)
        self.engine.connect_module_port(self.instance_name, 'RD_DATA_O', self.rd_data)

Gluing Logic: Read and Write Operations

Now that our hardware is wrapped, we can control it using noRTL's sequential constructs. The engine.set() and engine.sync() methods allow us to schedule operations across clock cycles.

Single Operation (Scratch Variable)

For standard use cases, a read operation will schedule an address, wait for one clock cycle, and return a signal object. This signal acts as a "scratch variable" that you can directly use in subsequent logic, comparisons, or assignments.

    def write(self, addr: Renderable, data: Renderable) -> None:
        """Perform a synchronous write operation."""
        self.engine.set(self.addr, addr)
        self.engine.set(self.wr_data, data)
        self.engine.set(self.wr_en, 1)
        self.engine.sync()
        self.engine.set(self.wr_en, 0)
        self.engine.sync()

    def read(self, addr: Renderable) -> Renderable:
        """Perform a synchronous read operation. Returns a scratchs signal representing the read data."""
        ret = self.define_scratch(self.data_width)
        self.engine.set(self.addr, addr)
        self.engine.set(self.wr_en, 0)
        self.engine.sync()
        # rd_data is now valid on the next cycle
        self.engine.set(ret, rd_data)
        self.engine.sync()
        return ret

Batch Operations

Sometimes you need to transfer multiple values efficiently without breaking your sequential flow. noRTL's context managers and sync() calls make it easy to implement burst interfaces that accept lists of addresses or data.

    def burst_write(self, addresses: List[Renderable], data: List[Renderable]) -> None:
        """Write a list of values to consecutive or specified addresses."""
        for addr, val in zip(addresses, data):
            self.write(addr, val)

    def burst_read(self, addresses: List[Renderable]) -> List[Renderable]:
        """Read a list of values from specified addresses. Returns a list of signal objects."""
        results = []
        for addr in addresses:
            results.append(self.read(addr))
        return results

With this wrapper in place, you can now instantiate BlockRam in your engine, feed it addresses and data using engine.set(), and chain read/write operations alongside your other noRTL constructs. The resulting Verilog will automatically include your custom RAM module, correctly instantiated and connected to your control logic!