Register Maps
A register map is the conventional way to expose a small block of named, individually-addressable control and status fields to a host over AXI-Lite. Each named field gets its own bus offset, so the host can read or write one field at a time without paying for the others.
PySilicon provides the register-map abstraction as a thin layer on top of the existing MM interfaces. The slave endpoint is an MMIFSlave that is wired by the framework — the component author declares a RegMap, and the slave’s read/write callbacks dispatch to fields automatically.
| Class | Role |
|---|---|
RegAccess |
Enum of access modes (R, W, RW, W1C, W1S) |
RegField |
Declaration of one field: schema, access mode, hooks |
RegMap |
Ordered collection of RegFields; owns the backing values |
RegMapMMIFSlave |
Subclass of MMIFSlave that dispatches reads/writes to a RegMap |
The register map matches the model that Vitis HLS generates from s_axilite scalars and arrays: each field becomes one or more 32-bit registers in a single auto-generated AXI-Lite slave. When PySilicon eventually generates the HLS pragmas for a kernel, the offsets in the Python RegMap are the offsets the host driver uses.
Quick example
from enum import IntEnum
from pysilicon.hw.aximm import AXIMMCrossBarIF, AXIMMProtocol, MMIFMaster
from pysilicon.hw.dataschema import EnumField, IntField
from pysilicon.hw.regmap import RegMap, RegField, RegAccess, RegMapMMIFSlave
class ErrorCode(IntEnum):
OK = 0
BAD_FRAMING = 1
WRONG_LENGTH = 2
Bit = IntField.specialize(bitwidth=1, signed=False)
ErrorCodeField = EnumField.specialize(enum_type=ErrorCode)
regmap = RegMap({
"ap_start": RegField(Bit, RegAccess.W1S, description="Start the kernel"),
"halted": RegField(Bit, RegAccess.R, description="1 = halted on error"),
"error": RegField(ErrorCodeField, RegAccess.R, description="Last error code"),
})
slave_ep = RegMapMMIFSlave(sim=sim, bitwidth=32, regmap=regmap)
xbar.bind("slave_0", slave_ep, protocol=AXIMMProtocol.LITE)
The host then reads or writes each field at its own auto-assigned offset:
yield from cpu_master.write_schema(Bit(1), addr=regmap.offset_of("ap_start"))
err = yield from cpu_master.read_schema(ErrorCodeField, addr=regmap.offset_of("error"))
RegField
@dataclass
class RegField:
schema: type[DataSchema]
access: RegAccess
description: str = ""
on_write: Callable[[str, int, int], None] | None = None
on_read: Callable[[str, int, int], None] | None = None
offset: int | None = None # None = auto-assign
schema— anyDataSchemasubclass:IntField,EnumField,FloatField,DataList,DataArray. The field occupiesschema.nwords_per_inst(bus_bw)consecutive bus words.access— one ofRegAccess.R,W,RW,W1C,W1S(see below).description— free-text; included in generated documentation.on_write,on_read— hook callbacks; see Hooks.offset— optional manual byte offset within the slave’s address range. WhenNone, the offset is auto-assigned in declaration order.
Validation rules (checked at RegMap construction):
W1CandW1Srequire single-word scalar fields (schema.nwords_per_inst(bus_bw) == 1); these modes are bit-level semantics that are not meaningful for multi-word fields.- All offsets must be aligned to the bus word size (
bus_bw / 8bytes). - No two fields’ word ranges may overlap.
RegAccess
| Mode | Host read | Host write | Owner read | Owner write |
|---|---|---|---|---|
R |
OK | rejected | OK | OK |
W |
rejected | OK | OK | OK |
RW |
OK | OK | OK | OK |
W1C |
OK | OK (bits set in the written value clear the corresponding bits in the backing store) | OK | OK |
W1S |
OK | OK (bits set in the written value set the bit, the hook fires, then the bit auto-clears to 0) | OK | OK |
Rejected host operations raise RegMapAccessError (caught and logged by the slave; the bus transaction completes returning 0 for the read path).
W1S (write-1-to-set, auto-clearing)
Models trigger registers like ap_start. The sequence on a host write of 1:
- Backing word is set to
1. on_write(name, 0, 1)hook fires. The hook shouldsucceed()a SimPy event, increment a counter, etc.- Backing word is set back to
0.
Subsequent host reads return 0 until another host write of 1 re-triggers the cycle. The hook always sees the value 1 during its invocation.
W1C (write-1-to-clear)
Models sticky status bits. On a host write of value v:
- Backing word is updated as
backing &= ~v(each bit set invclears the corresponding bit in the backing store). on_write(name, 0, v)fires.
Owner-side regmap.set(name, value) does not apply W1C semantics — owner writes overwrite the backing store directly. This matches how a kernel writes its sticky-status registers (set on event, host clears).
RegMap
class RegMap:
def __init__(self, fields: dict[str, RegField], bitwidth: int = 32) -> None: ...
# Layout
def offset_of(self, name: str) -> int
def nwords_of(self, name: str) -> int
def total_size_bytes(self) -> int
# Owner-side value access (deserialized form)
def get(self, name: str) -> Any
def set(self, name: str, value: Any) -> None
Offset assignment
Fields are auto-assigned offsets in declaration order, packed tightly with bus-word alignment:
RegMap({
"ap_start": RegField(Bit, RegAccess.W1S), # 1 word → offset 0x00
"halted": RegField(Bit, RegAccess.R), # 1 word → offset 0x04
"coeffs": RegField(CoeffArray, RegAccess.RW), # 4 words → offset 0x08, 0x0C, 0x10, 0x14
"error": RegField(ErrorCode, RegAccess.R), # 1 word → offset 0x18
})
Manual override per field:
RegMap({
"control": RegField(ControlReg, RegAccess.RW, offset=0x00),
"status": RegField(StatusReg, RegAccess.R, offset=0x40),
})
Manually-placed fields establish fixed positions; auto-placed fields fill the gaps in declaration order. Overlap raises ValueError at construction.
Owner-side API
The owning component reads and writes fields using the deserialized Python value, not raw words:
self.regmap.set("error", PolyError.TLAST_EARLY_CMD_HDR)
self.regmap.set("tx_id", cmd_hdr.tx_id)
self.regmap.set("halted", 1)
current_coeffs = self.regmap.get("coeffs") # SchemaArray of Float32
Internally each field’s backing store is a numpy array of nwords_per_inst(bus_bw) words. get() calls schema().deserialize(buffer); set() calls value.serialize() (or wraps a raw value via schema(value) first) and stores. Host bus reads/writes touch the same underlying word buffer at the appropriate sub-word offset.
RegMapMMIFSlave
@dataclass
class RegMapMMIFSlave(MMIFSlave):
regmap: RegMap = ...
A subclass of MMIFSlave that wires its own rx_read_proc and rx_write_proc:
- Decodes
local_addrto(field_name, sub_word_index)against theRegMap’s offset table. - For LITE crossbar binds, each callback receives one word at a time. Reads return the appropriate slice of the field’s backing word buffer; writes update the buffer (applying access-mode rules) and fire the hook.
- For FULL binds (a register file connected via FULL is unusual but supported), multi-word transfers are decoded contiguously, one field at a time.
- Out-of-range or unaligned addresses raise
RegMapAccessError.
The slave is bound exactly like any other MMIFSlave:
xbar.bind("slave_0", regmap_slave, protocol=AXIMMProtocol.LITE)
assign_address_ranges([regmap_slave], [(0x4000, regmap.total_size_bytes())])
Or via DirectMMIF for a single-master/single-slave register file:
direct = DirectMMIF(sim=sim, clk=clk, byte_addressable=True)
direct.bind("master", host_master)
direct.bind("slave", regmap_slave)
Composite fields
Any DataSchema may be used as a field. Multi-word schemas occupy consecutive bus-word offsets; the host accesses individual words via LITE transactions, while the owner sees the deserialized value as a single Python object.
class CoeffArray(DataArray):
ncoeff = 4
element_type = Float32
static = True
max_shape = (ncoeff,)
regmap = RegMap({
"coeffs": RegField(CoeffArray, RegAccess.RW),
})
# Owner: writes/reads the whole array as one schema instance
self.regmap.set("coeffs", CoeffArray([1.0, 0.0, 0.5, 0.25]))
arr = self.regmap.get("coeffs") # SchemaArray of Float32, length 4
# Host: reads element 2 (one LITE transaction at offset 0x08)
word2 = yield from master.read_schema(Float32, addr=regmap.offset_of("coeffs") + 0x08)
This matches Vitis HLS behavior for s_axilite arrays and structs: the host sees nwords_per_inst consecutive registers, and the kernel sees the field as a single typed object.
Hooks
Hook callbacks fire per host bus transaction, not per logical field write. AXI-Lite has no notion of a “field write complete” — the host writes one word at a time — so the hook contract is per-word.
on_write(name: str, sub_word: int, word_value: int) -> None
on_read (name: str, sub_word: int, word_value: int) -> None
name— the field’s declared name in theRegMap.sub_word— index of the word within the field (always0for single-word scalars).word_value— foron_write, the raw word value the host wrote (before any access-mode transformation); foron_read, the value about to be returned.
Ordering:
on_writefires after the backing store update (after W1C masking) and before the W1S auto-clear. Hooks readingregmap.get(name)see the just-written value.on_readfires after the value is read from the backing store, before it is returned on the bus.
Hooks must not yield. To gate a SimPy generator on a host write (the typical ap_start pattern), have the hook succeed() an event that another run_proc is yield-ing on:
self._start_event = self.env.event()
def _on_ap_start(name, sub_word, value):
self._start_event.succeed()
self._start_event = self.env.event()
regmap = RegMap({
"ap_start": RegField(Bit, RegAccess.W1S, on_write=_on_ap_start),
...
})
For composite fields, callers that need “all words written” semantics must track that themselves (e.g., by maintaining a bitmask of which sub-words have been written since the last reset).
VitisRegMap
A VitisRegMap is a RegMap subclass that auto-prepends the standard Vitis HLS ap_ctrl_hs control register at offset 0x00. The user only declares their own kernel-specific fields; ap_start is added automatically.
class VitisRegMap(RegMap):
"""RegMap with Vitis ap_ctrl_hs control conventions auto-applied.
v1: prepends `ap_start` (W1S) at offset 0x00. User fields start at 0x04.
v2: expands to the full bit-packed control word (ap_done, ap_idle, ap_ready,
auto_restart) plus optional GIE/IER/ISR interrupt registers.
"""
def __init__(self, fields: dict[str, RegField], bitwidth: int = 32) -> None: ...
def start(self, master: MMIFMaster, base_addr: int = 0) -> ProcessGen[None]:
"""Convenience: host-side launch. Equivalent to writing 1 to
`base_addr + offset_of("ap_start")` over the master endpoint."""
Use site:
POLY_REGMAP = VitisRegMap({
"status_clear": RegField(Bit, RegAccess.W1C, description="Clear halted/error"),
"halted": RegField(Bit, RegAccess.R, description="1 = halted on error"),
"error": RegField(PolyErrorField, RegAccess.R, description="Last error code"),
"tx_id": RegField(TxIdField, RegAccess.R, description="TX id of halted txn"),
"coeffs": RegField(CoeffArray, RegAccess.RW, description="Default coefficients"),
})
# offset_of("ap_start") == 0x00, offset_of("status_clear") == 0x04, etc.
User-declared field names beginning with ap_ are rejected at construction time to prevent collisions with current and future Vitis-reserved names.
The bit layout of the v1 control register is a simplification of Vitis’s real control word — Vitis packs ap_start, ap_done, ap_idle, ap_ready, and auto_restart into one 32-bit register with bit-level access semantics. In v1, only ap_start is exposed and it occupies the entire word at 0x00. Host driver code that writes 1 to 0x00 to launch is bit-compatible with Vitis; reads of 0x00 are not (Vitis returns the live state of the other bits). See Planned (v2).
VitisRegMapMMIFSlave
A RegMapMMIFSlave subclass that owns the kernel launch lifecycle. The component author writes the kernel body as an on_start generator and registers it with the slave; the slave invokes it as a SimPy process whenever the host writes ap_start = 1.
@dataclass
class VitisRegMapMMIFSlave(RegMapMMIFSlave):
regmap: VitisRegMap = ...
on_start: Callable[[], ProcessGen[None]] | None = None
Launch semantics
- Host writes
1to theap_startregister. - If
on_startis already running (a previous launch hasn’t returned), the write is silently ignored. This mirrors Vitisap_ctrl_hs, whereap_startwrites are gated byap_idle. The W1S auto-clear ofap_startstill fires. - Otherwise the slave spawns
env.process(on_start())and marks itself busy. - When
on_startreturns, the slave marks itself idle. Subsequentap_startwrites will launch a new invocation.
What on_start should do
on_start is the kernel body. It is expected to be a generator that runs until either:
- It reaches an unrecoverable error condition, sets any user-defined status fields via
regmap.set(...), andreturns. The slave will accept subsequentap_startwrites once it returns. - It is intentionally written as a long-running
while True:loop that processes back-to-back transactions and only returns on error (the persistent kernel pattern, which matches the Vitis halt-on-error design we use for poly).
on_start must not be invoked from anywhere except the slave’s launch path. Component authors do not write a run_proc for the kernel logic — there is no outer SimPy process waiting on a start_event. The slave is the sole entry point.
What the slave does not do
- The slave does not set any status field automatically. Error codes, transaction IDs, sticky flags, etc. are kernel-specific and remain the kernel author’s responsibility (set via
regmap.set(name, value)beforereturning). - The slave does not model
ap_done/ap_idle/ap_readyas readable registers in v1 (deferred to v2 — see below).
Worked example: poly accelerator
The polynomial-evaluation kernel from examples/poly uses a VitisRegMap for control and status. The kernel implements the persistent-kernel pattern: the host writes ap_start once, the kernel processes transactions back-to-back from its AXI-Stream input, and only halts (returning) when an error is detected. On halt, the error code and offending transaction ID are latched into the register map for the host to read.
Field declarations
from enum import IntEnum
from pysilicon.hw.dataschema import IntField, EnumField, FloatField, DataArray
from pysilicon.hw.regmap import VitisRegMap, RegField, RegAccess
class PolyError(IntEnum):
NO_ERROR = 0
TLAST_EARLY_CMD_HDR = 1
NO_TLAST_CMD_HDR = 2
TLAST_EARLY_SAMP_IN = 3
NO_TLAST_SAMP_IN = 4
WRONG_NSAMP = 5
Bit = IntField.specialize(bitwidth=1, signed=False)
TxIdField = IntField.specialize(bitwidth=16, signed=False)
PolyErrorField = EnumField.specialize(enum_type=PolyError)
Float32 = FloatField.specialize(bitwidth=32)
class CoeffArray(DataArray):
ncoeff = 4
element_type = Float32
static = True
max_shape = (ncoeff,)
# Only user-defined fields are declared; ap_start is auto-prepended at 0x00.
POLY_REGMAP_FIELDS = {
"status_clear": RegField(Bit, RegAccess.W1C, description="Clear halted/error"),
"halted": RegField(Bit, RegAccess.R, description="1 = halted on error"),
"error": RegField(PolyErrorField, RegAccess.R, description="Last error code"),
"tx_id": RegField(TxIdField, RegAccess.R, description="TX id of halted txn"),
"coeffs": RegField(CoeffArray, RegAccess.RW, description="Default coefficients"),
}
Kernel side
The component declares its endpoints and an on_start method. There is no run_proc, no start_event, and no post-construction hook wiring — the slave owns the launch lifecycle.
from pysilicon.hw.regmap import VitisRegMap, VitisRegMapMMIFSlave, RegField, RegAccess
@dataclass
class PolyAccelComponent(HwComponent):
def __post_init__(self) -> None:
super().__post_init__()
self.s_in = StreamIFSlave (name=f'{self.name}_s_in', sim=self.sim, bitwidth=self.in_bw)
self.m_out = StreamIFMaster(name=f'{self.name}_m_out', sim=self.sim, bitwidth=self.out_bw)
# Build a per-instance VitisRegMap with hooks bound to component methods.
self.regmap = VitisRegMap({
"status_clear": RegField(Bit, RegAccess.W1C, on_write=self._on_status_clear,
description="Clear halted/error"),
"halted": RegField(Bit, RegAccess.R, description="1 = halted on error"),
"error": RegField(PolyErrorField, RegAccess.R, description="Last error code"),
"tx_id": RegField(TxIdField, RegAccess.R, description="TX id of halted txn"),
"coeffs": RegField(CoeffArray, RegAccess.RW, description="Default coefficients"),
})
self.s_lite = VitisRegMapMMIFSlave(
name=f'{self.name}_s_lite', sim=self.sim, bitwidth=32,
regmap=self.regmap, on_start=self.on_start,
)
for ep in (self.s_in, self.m_out, self.s_lite):
self.add_endpoint(ep)
def _on_status_clear(self, name, sub_word, value):
self.regmap.set("halted", 0)
self.regmap.set("error", PolyError.NO_ERROR)
def on_start(self) -> ProcessGen[None]:
"""Kernel body — invoked by VitisRegMapMMIFSlave on host ap_start write."""
while True:
cmd_hdr = yield from self.s_in.get(PolyCmdHdr)
err = yield from self.evaluate(cmd_hdr, self.s_in, self.m_out)
if err != PolyError.NO_ERROR:
self.regmap.set("error", err)
self.regmap.set("tx_id", cmd_hdr.tx_id)
self.regmap.set("halted", 1)
return # halt → slave goes idle; host can re-launch via ap_start
Host side
# Configure default coefficients (one LITE transaction per word, auto-split)
yield from cpu.write_schema(CoeffArray([1.0, 0.0, 0.5, 0.25]),
addr=POLY_BASE + poly.regmap.offset_of("coeffs"))
# Launch via the VitisRegMap convenience method
yield from poly.regmap.start(cpu, base_addr=POLY_BASE)
# ... time passes; host issues stream transactions on the data path ...
# On suspected halt: poll status
halted = yield from cpu.read_schema(Bit, addr=POLY_BASE + poly.regmap.offset_of("halted"))
if halted:
err = yield from cpu.read_schema(PolyErrorField, addr=POLY_BASE + poly.regmap.offset_of("error"))
tx_id = yield from cpu.read_schema(TxIdField, addr=POLY_BASE + poly.regmap.offset_of("tx_id"))
log.error(f"poly halted on tx {tx_id}: {err}")
yield from cpu.write_schema(Bit(1), addr=POLY_BASE + poly.regmap.offset_of("status_clear"))
yield from poly.regmap.start(cpu, base_addr=POLY_BASE) # re-launch
The same VitisRegMap object drives the SimPy simulation, the (planned) HLS pragma generation, and the (planned) host driver class — see below.
Planned: VitisRegMap v2 control register
The v1 VitisRegMap simplifies the Vitis control register to a single W1S ap_start bit occupying the entire word at offset 0x00. v2 expands it to the full bit-packed ap_ctrl_hs register that Vitis HLS actually generates, with bit-level access semantics within one bus word.
Bit layout at offset 0x00
| Bit | Name | Access | Notes |
|---|---|---|---|
| 0 | ap_start |
W1S | auto-clears when slave begins running on_start |
| 1 | ap_done |
COR | set when on_start returns; cleared on host read |
| 2 | ap_idle |
R | 1 when on_start is not running |
| 3 | ap_ready |
R | 1 when ready to accept the next ap_start |
| 7 | auto_restart |
RW | when 1, slave re-invokes on_start immediately on return |
Three new infrastructure pieces are needed to support this:
RegAccess.COR(clear-on-read): host reads return the current value, then the backing store is zeroed.BitField/ packed-field support inRegField: multiple named bit fields at the same byte offset, each with its own access mode.RegField.bits = {"ap_start": 0, "ap_done": 1, ...}or a parallel declaration syntax.auto_restartsemantics inVitisRegMapMMIFSlave: when the bit is set andon_startreturns, the slave immediately re-invokeson_startwithout requiring another host write.
Optional interrupt registers
A VitisRegMap(..., interrupts=True) flag adds the standard Vitis interrupt registers:
| Offset | Name | Width | Description |
|---|---|---|---|
| 0x04 | GIE | 1 | Global interrupt enable |
| 0x08 | IER | 2 | Interrupt enable for ap_done, ap_ready |
| 0x0C | ISR | 2 | Interrupt status (W1C) |
When ap_done asserts and the corresponding IER bit is set, the slave fires an interrupt_event (a SimPy event) that the host model can yield on instead of polling.
Host-side accessors
The auto-generated driver class gets per-bit accessors mirroring Vitis’s generated xpoly.h:
drv.start() # write 1 to ap_start
drv.is_done() # read ap_done (clears it)
drv.is_idle() # read ap_idle
drv.set_auto_restart(True) # write bit 7 of control
drv.enable_interrupts() # write GIE, IER
yield from drv.wait_interrupt() # yield on the slave's interrupt_event
User code that targets v1 (writes 1 to 0x00 to launch) continues to work in v2 — the only change is that more bits at 0x00 become readable and the control register loses its “single ap_start bit” simplification.
Planned: artifact generation (v2)
The register map is declarative Python data, so it can drive generation of host-side artifacts. The following are designed-for but not yet implemented in v1. Names and signatures are specified here so the generators can be added without breaking changes.
Markdown table
def to_markdown(self, *, title: str | None = None) -> str
Renders a table suitable for inclusion in design docs:
### POLY register map
| Offset | Name | Access | Width | Description |
|--------|--------------|--------|-------|------------------------------|
| 0x00 | ap_start | W1S | 1 | Start kernel |
| 0x04 | status_clear | W1C | 1 | Clear halted/error |
| 0x08 | halted | R | 1 | 1 = halted on error |
| 0x0C | error | R | 8 | Last error code |
| 0x10 | tx_id | R | 16 | TX id of halted txn |
| 0x14 | coeffs[4] | RW | 4×32 | Default coefficients |
C header
def to_c_header(self, *, prefix: str) -> str
Generates #defines for offsets and bit widths, plus a packed struct for composite fields:
/* Auto-generated from POLY_REGMAP — do not edit. */
#define POLY_AP_START_OFFSET 0x00u
#define POLY_STATUS_CLEAR_OFFSET 0x04u
#define POLY_HALTED_OFFSET 0x08u
#define POLY_ERROR_OFFSET 0x0Cu
#define POLY_TX_ID_OFFSET 0x10u
#define POLY_COEFFS_OFFSET 0x14u
#define POLY_COEFFS_COUNT 4u
Python driver class
def to_python_driver(self, *, class_name: str) -> str
Generates a class that wraps an MMIFMaster with one accessor per field, returning deserialized Python values:
class PolyDriver:
def __init__(self, master: MMIFMaster, base_addr: int) -> None: ...
def write_ap_start(self) -> ProcessGen[None]: ...
def write_status_clear(self) -> ProcessGen[None]: ...
def read_halted(self) -> ProcessGen[bool]: ...
def read_error(self) -> ProcessGen[PolyError]: ...
def read_tx_id(self) -> ProcessGen[int]: ...
def write_coeffs(self, value: CoeffArray | list[float]) -> ProcessGen[None]: ...
def read_coeffs(self) -> ProcessGen[CoeffArray]: ...
The driver is the single touchpoint for host-side firmware and software-in-the-loop tests. Because the same RegMap object also drives the simulation and the (eventual) HLS pragma generation, the offsets cannot drift between the three.
Quick reference
from pysilicon.hw.regmap import (
RegMap, RegField, RegAccess, RegMapMMIFSlave,
VitisRegMap, VitisRegMapMMIFSlave,
)
| Operation | Code |
|---|---|
| Declare a field | RegField(SchemaType, RegAccess.RW, description="…", on_write=cb) |
| Declare a generic regmap | RegMap({"name": RegField(...), ...}, bitwidth=32) |
| Declare a Vitis regmap | VitisRegMap({"name": RegField(...), ...}) |
| Look up offset | regmap.offset_of("name") |
| Total size in bytes | regmap.total_size_bytes() |
| Owner-side write | regmap.set("error", PolyError.NO_TLAST) |
| Owner-side read | regmap.get("coeffs") |
| Create generic slave | RegMapMMIFSlave(sim=sim, bitwidth=32, regmap=regmap) |
| Create Vitis slave | VitisRegMapMMIFSlave(sim=sim, bitwidth=32, regmap=regmap, on_start=self.on_start) |
| Bind to crossbar | xbar.bind("slave_0", slave_ep, protocol=AXIMMProtocol.LITE) |
| Bind direct | direct.bind("slave", slave_ep) |
| Host write a field | yield from master.write_schema(value, addr=base + regmap.offset_of("name")) |
| Host read a field | val = yield from master.read_schema(SchemaType, addr=base + regmap.offset_of("name")) |
| Host launch a Vitis kernel | yield from regmap.start(master, base_addr=BASE) |