Memory-Mapped (MM) Interfaces

Waveflow provides two memory-mapped interface types that share a common pair of generic endpoints:

Class Role
MMIFMaster Master (initiator) endpoint — issues read / write
MMIFSlave Slave (target) endpoint — handles rx_read_proc / rx_write_proc
AXIMMCrossBarIF Multi-master × multi-slave AXI-style crossbar
DirectMMIF Point-to-point connection (BRAM / local scratchpad)

The endpoints are independent of the interconnect. A component declares an MMIFMaster or MMIFSlave without knowing whether it will be wired to a crossbar or a direct link — that decision is made at the top level.


Endpoints

MMIFSlave

from waveflow.hw.aximm import MMIFSlave

slave_ep = MMIFSlave(
    sim=sim,
    bitwidth=32,
    rx_write_proc=self.on_write,   # called on each write transaction
    rx_read_proc=self.on_read,     # called on each read transaction
    latency_per_word=3.0,          # cycles per word (used by AXIMMCrossBarIF LITE)
)

rx_write_proc(words, local_addr) -> ProcessGen[None]
Called with the transferred word array and the local address of the first word. For LITE crossbar slaves this is called once per word with a one-element array.

rx_read_proc(nwords, local_addr) -> ProcessGen[Words]
Called to retrieve data; the generator’s return value must be a numpy array of shape (nwords,).

def on_read(self, nwords: int, local_addr: int) -> ProcessGen[Words]:
    yield self.env.timeout(0)   # model peripheral access latency here
    return np.array(
        [self._mem.get(local_addr + i, 0) for i in range(nwords)],
        dtype=np.uint32,
    )

MMIFMaster

from waveflow.hw.aximm import MMIFMaster

master_ep = MMIFMaster(sim=sim, bitwidth=32)

Raw word transfers:

yield self.process(master_ep.write(words, global_addr))

proc = self.env.process(master_ep.read(nwords, global_addr))
yield proc
data = proc.value   # numpy array of shape (nwords,)

Or via yield from inside a run_proc:

data = yield from master_ep.read(nwords, global_addr)

Schema convenience methods (no boilerplate serialization needed):

# Write / read one schema instance
yield from master_ep.write_schema(cmd_hdr, addr=CMD_ADDR)
cmd = yield from master_ep.read_schema(CmdHdr, addr=CMD_ADDR)

# Write / read a typed array
yield from master_ep.write_array(samples, Float32, addr=DATA_ADDR)
arr = yield from master_ep.read_array(Float32, count=nsamp, addr=DATA_ADDR)
# arr is np.ndarray[float32] for FloatField/IntField element types

Region — element-coordinate access (the read_array_slice twin). read_array takes a byte address; a Region binds a byte base + element type once and is then indexed by element coordinate, so callers never compute addr * elem_bytes by hand. This is the SimPy twin of the C++ read_array_slice / read_array_lane contract (and the PynQ-allocate analogue): the framework owns the element→byte conversion (using the interface’s byte_addressable), so the sim model indexes memory exactly like the generated kernel.

x = master_ep.region(base_addr=XADDR, element_type=Float32)   # byte base + element dtype
xs = yield from x.read_slice(i0, i1)                            # x[i0:i1] by element index
yield from x.write_slice(i0, xs)                               # write elements back at i0

read_slice / write_slice return just data / nothing — exactly like the hardware read_array_slice — so the timing stays off the data path. A loosely-timed component that needs the transfer timeline sets x.on_transfer, a hook (rw, i0, nwords, tstart, tend) -> None fired after each slice; the AT timing capture then lives in the framework, not hand-bracketed at every call:

x.on_transfer = lambda rw, i0, nw, t0, t1: record(rw, x.byte_of(i0), nw, t0, t1)
a = yield from x.read_slice(0, n)        # the data path never unpacks timing

The base is a byte address (host/allocator-owned, width-agnostic); indices within are element coordinates (width-agnostic — the same code works at any mem_bw). Prefer a Region over a hand-rolled byte-address helper whenever a component addresses a memory region by element.


A minimal simulation

Two raw SimObjs over a point-to-point DirectMMIF: a Cpu holding the MMIFMaster writes a burst and reads it back, and a MemBank holding the MMIFSlave is a tiny word-addressed memory model. No HwComponent. (The yield from / run_proc / ProcessGen mechanics are explained in Process generators.)

from dataclasses import dataclass

import numpy as np

from waveflow.hw.aximm import DirectMMIF, MMIFMaster, MMIFSlave
from waveflow.hw.clock import Clock
from waveflow.hw.interface import Words
from waveflow.simulation.simobj import ProcessGen, SimObj
from waveflow.simulation.simulation import Simulation


@dataclass
class MemBank(SimObj):
    """A tiny word-addressed memory behind a slave endpoint."""

    def __post_init__(self) -> None:
        super().__post_init__()
        self._mem: dict[int, int] = {}
        self.slave = MMIFSlave(
            sim=self.sim, bitwidth=32,
            rx_write_proc=self.on_write, rx_read_proc=self.on_read,
        )

    def on_write(self, words: Words, local_addr: int) -> ProcessGen[None]:
        for i, w in enumerate(words):
            self._mem[local_addr + i] = int(w)
        yield self.timeout(0)

    def on_read(self, nwords: int, local_addr: int) -> ProcessGen[Words]:
        yield self.timeout(0)   # model peripheral access latency here
        return np.array([self._mem.get(local_addr + i, 0) for i in range(nwords)], dtype=np.uint32)


@dataclass
class Cpu(SimObj):
    """Holds the master endpoint; writes a burst then reads it back."""

    master: MMIFMaster | None = None

    def __post_init__(self) -> None:
        super().__post_init__()
        self.readback: np.ndarray | None = None

    def run_proc(self) -> ProcessGen[None]:
        data = np.array([0xA0, 0xA1, 0xA2, 0xA3], dtype=np.uint32)
        yield from self.master.write(data, 0)
        self.readback = yield from self.master.read(4, 0)
        print(f"{self.name} read back {self.readback.tolist()}")


sim = Simulation()
clk = Clock(freq=100e6)

mem = MemBank(name="mem", sim=sim)
cpu = Cpu(name="cpu", sim=sim, master=MMIFMaster(sim=sim, bitwidth=32))

link = DirectMMIF(sim=sim, clk=clk, byte_addressable=False)   # word addresses (BRAM convention)
link.bind("master", cpu.master)
link.bind("slave", mem.slave)

sim.run_sim()

cpu.readback is [0xA0, 0xA1, 0xA2, 0xA3]: the master-initiated write then read round-trip through the slave’s rx_write_proc / rx_read_proc. DirectMMIF is point-to-point, so no address ranges are needed; for the multi-slave crossbar you also call assign_address_ranges() after bind (see the full example below). See SimObj for the base object and lifecycle.

AXIMMCrossBarIF

Multi-master × multi-slave AXI-style crossbar with address-based routing.

Address-based routing

Each slave is assigned a byte-address range [base_addr, base_addr + size) via assign_address_ranges(). When a master calls write(words, global_addr), the crossbar decodes the address, computes local_addr = global_addr - slave.base_addr, and calls rx_write_proc(words, local_addr). A RuntimeError is raised for unmapped addresses.

Protocol: FULL vs LITE

The protocol is set per slave at bind time, not on the endpoint constructor:

xbar.bind("slave_0", mem_ep)                               # FULL (default)
xbar.bind("slave_1", reg_ep, protocol=AXIMMProtocol.LITE)
Value Transfer model Typical use
AXIMMProtocol.FULL One burst call for all nwords DDR, block RAM, DMA buffers
AXIMMProtocol.LITE One call per word, auto-incremented addresses Configuration registers

For LITE slaves, a multi-word write is split into nwords single-word transactions automatically. The master does not need to know the slave’s protocol.

Latency model

All cycle counts are divided by clk.freq to produce seconds.

Path Formula
FULL write (latency_init + nwords) / clk.freq
FULL read latency_init/f + slave_access + (latency_read_return + nwords)/f
LITE write nwords × latency_per_word / clk.freq
LITE read nwords × latency_per_word / clk.freq

Construction

from waveflow.hw.aximm import AXIMMCrossBarIF

xbar = AXIMMCrossBarIF(
    sim=sim,
    clk=clk,
    nports_master=2,
    nports_slave=2,
    bitwidth=32,
    latency_init=2.0,           # wire cycles, forward direction
    latency_read_return=2.0,    # wire cycles, return direction (FULL reads)
    byte_addressable=True,      # True = AXI byte addresses (default)
)

Endpoint names: master_0master_{n-1} and slave_0slave_{m-1}.


DirectMMIF

Point-to-point interconnect: one master, one slave, no address translation. The master’s address is passed directly to the slave callback as local_addr. This models a component wired directly to a BRAM or local register file.

from waveflow.hw.aximm import DirectMMIF

direct = DirectMMIF(
    sim=sim,
    clk=clk,
    latency_write=0.0,          # cycles before rx_write_proc is called
    latency_read=0.0,           # cycles on the read request leg
    latency_read_return=0.0,    # cycles after rx_read_proc returns
    byte_addressable=False,     # False = word addresses (BRAM convention)
)
direct.bind("master", master_ep)
direct.bind("slave",  slave_ep)

Endpoint names: master and slave.


Full example

from __future__ import annotations
from dataclasses import dataclass
import numpy as np

from waveflow.hw.aximm import (
    AXIMMCrossBarIF, AXIMMProtocol,
    MMIFMaster, MMIFSlave,
    AXIMMAddressRange, assign_address_ranges,
)
from waveflow.hw.clock import Clock
from waveflow.simulation.simobj import ProcessGen, SimObj
from waveflow.simulation.simulation import Simulation


@dataclass
class MemBank(SimObj):
    """Simple word-addressed SRAM (FULL, burst)."""
    def __post_init__(self) -> None:
        super().__post_init__()
        self._mem: dict[int, int] = {}
        self.slave_ep = MMIFSlave(
            sim=self.sim, bitwidth=32,
            rx_write_proc=self.on_write, rx_read_proc=self.on_read,
        )

    def on_write(self, words, local_addr: int) -> ProcessGen[None]:
        for i, w in enumerate(words):
            self._mem[local_addr + i * 4] = int(w)
        yield self.env.timeout(0)

    def on_read(self, nwords: int, local_addr: int) -> ProcessGen[Words]:
        yield self.env.timeout(4 / self.sim._clk_ref.freq)
        return np.array(
            [self._mem.get(local_addr + i * 4, 0) for i in range(nwords)],
            dtype=np.uint32,
        )


@dataclass
class RegFile(SimObj):
    """Configuration registers (LITE, one register per word)."""
    def __post_init__(self) -> None:
        super().__post_init__()
        self._regs: dict[int, int] = {}
        self.slave_ep = MMIFSlave(
            sim=self.sim, bitwidth=32,
            rx_write_proc=self.on_write, rx_read_proc=self.on_read,
            latency_per_word=3.0,
        )

    def on_write(self, words, local_addr: int) -> ProcessGen[None]:
        self._regs[local_addr] = int(words[0])
        yield self.env.timeout(0)

    def on_read(self, nwords: int, local_addr: int) -> ProcessGen[Words]:
        yield self.env.timeout(0)
        return np.array([self._regs.get(local_addr, 0)], dtype=np.uint32)


@dataclass
class CPU(SimObj):
    def __post_init__(self) -> None:
        super().__post_init__()
        self.master_ep = MMIFMaster(sim=self.sim, bitwidth=32)

    def run_proc(self) -> ProcessGen[None]:
        env = self.env

        # Write 4 words to MemBank, then read back
        words = np.array([0xA0, 0xA1, 0xA2, 0xA3], dtype=np.uint32)
        yield self.process(self.master_ep.write(words, 0x0000))

        proc = env.process(self.master_ep.read(4, 0x0000))
        yield proc
        assert np.array_equal(proc.value, words)

        # Write 2 config words to RegFile (auto-split into 2 LITE transactions)
        cfg = np.array([0xCAFE, 0xBEEF], dtype=np.uint32)
        yield self.process(self.master_ep.write(cfg, 0x1000))

        proc = env.process(self.master_ep.read(2, 0x1000))
        yield proc
        assert np.array_equal(proc.value, cfg)


sim = Simulation()
clk = Clock(freq=100.0)

mem  = MemBank(sim=sim)
regs = RegFile(sim=sim)
cpu  = CPU(sim=sim)

xbar = AXIMMCrossBarIF(
    sim=sim, clk=clk,
    nports_master=1, nports_slave=2, bitwidth=32,
    latency_init=2.0, latency_read_return=2.0,
)
xbar.bind("master_0", cpu.master_ep)
xbar.bind("slave_0",  mem.slave_ep)                           # FULL (default)
xbar.bind("slave_1",  regs.slave_ep, protocol=AXIMMProtocol.LITE)

assign_address_ranges(
    [mem.slave_ep, regs.slave_ep],
    [(0x0000, 0x1000), (0x1000, 0x0010)],
)

sim.run_sim()

Quick reference

from waveflow.hw.aximm import (
    MMIFMaster, MMIFSlave,
    AXIMMCrossBarIF, DirectMMIF,
    AXIMMProtocol, AXIMMAddressRange,
    assign_address_ranges,
)
Operation Code
Create master ep MMIFMaster(sim=sim, bitwidth=32)
Create slave ep MMIFSlave(sim=sim, bitwidth=32, rx_write_proc=..., rx_read_proc=...)
Create crossbar AXIMMCrossBarIF(sim=sim, clk=clk, nports_master=M, nports_slave=N, ...)
Create direct link DirectMMIF(sim=sim, clk=clk)
Bind master (crossbar) xbar.bind("master_0", master_ep)
Bind FULL slave xbar.bind("slave_0", slave_ep)
Bind LITE slave xbar.bind("slave_1", slave_ep, protocol=AXIMMProtocol.LITE)
Bind direct direct.bind("master", master_ep); direct.bind("slave", slave_ep)
Assign address ranges assign_address_ranges([s0, s1], [(0x0000, 0x1000), (0x1000, 0x10)])
Write yield self.process(master_ep.write(words, global_addr))
Read data = yield from master_ep.read(nwords, global_addr)
Read schema obj = yield from master_ep.read_schema(SchemaType, addr)
Write schema yield from master_ep.write_schema(obj, addr)
Read array arr = yield from master_ep.read_array(Float32, count=n, addr=DATA_ADDR)
Write array yield from master_ep.write_array(np_array, Float32, addr=DATA_ADDR)

This site uses Just the Docs, a documentation theme for Jekyll.