Values and I/O#
This page is the canonical reference for how Python code reads from and writes to hardware registers in PeakRDL-pybind11, and for what those reads return. Every register and field has the same small set of primitive operations, with explicit bus costs and predictable typed return values.
The design principles that drive the rules on this page:
One transaction per primitive op.
reg.write(v)is exactly one bus write.reg.read()is exactly one bus read. RMW (read-modify-write) only appears where the abstraction makes it unavoidable, and those names —field.writeandreg.modify— are chosen to read clearly without lying about cost.Returns are typed. A field with
encode = BaudRatereads back asBaudRate.BAUD_115200, not2.Side effects are loud. Attribute assignment outside a context manager raises rather than silently issuing an RMW.
The four primitive ops#
Every register exposes the same four methods. The bus cost is fixed and documented:
Operation |
Semantics |
Bus cost |
|---|---|---|
|
Read the register; return a |
1 read |
|
Raw write, no read first. Bits not represented in |
1 write |
|
Read-modify-write: read the register, splice in the named fields, write it back. |
1 read + 1 write |
|
Same as |
1 write — same as |
poke is provided so that a code review can tell at a glance that a raw
write (rather than an RMW) was intentional. It does not bypass any safety
check that write performs.
Field reads and writes#
Field operations sit on top of the same four primitives. field.read()
issues one bus read and slices out the field’s bits. field.write(v) is a
single-field RMW — it cannot be a single bus write on a multi-field
register, because that would clobber the other fields.
soc.uart.control.baudrate.read() # 1 bus read → BaudRate.BAUD_19200
soc.uart.control.baudrate.write(BaudRate.BAUD_115200)
# 1 read + 1 write (RMW)
The name write is intentional on a field: it is named to read clearly
without lying about the cost. If you want to update several fields without
paying for one RMW per field, use reg.modify(**fields).
Multi-field atomic update#
When you need to change more than one field at once, reg.modify(**fields)
is the canonical form. It is a single RMW regardless of how many fields
are passed:
# One bus read + one bus write, no matter how many fields.
soc.uart.control.modify(
enable=1,
baudrate=BaudRate.BAUD_115200,
parity=Parity.NONE,
)
Keyword arguments map to fields by name and are type-checked against the
generated .pyi stubs, so unknown names and wrong enum types are caught
before you hit hardware.
If you already know the full register value (no host-side read required), build a value directly and write it:
# Compose-then-write — no bus read needed.
soc.uart.control.write(
UartControl.build(enable=1, baudrate=2)
)
# Fields not passed to .build() take their reset value.
UartControl.build(...) is a class method emitted by the exporter for every
register; it returns a RegisterValue (see below) that round-trips through
write().
The same factory is also reachable as RegisterValue.build(), which takes
a register class (any object exposing .fields, .address, .width,
and an optional .reset) followed by field keyword arguments. Use it when
you have a handle to the descriptor but not the per-class subclass — for
example, in generic tooling or tests:
from peakrdl_pybind11.runtime.values import RegisterValue
# Equivalent to UartControl.build(enable=1, baudrate=2)
v = RegisterValue.build(UartControl, enable=1, baudrate=2)
soc.uart.control.write(v)
Either form returns a fully-formed RegisterValue: every field not passed
in starts at the descriptor’s reset value (or zero), so the result can be
written without an intervening read.
Context manager (secondary)#
The context manager is the right tool when a single hardware transaction should bundle multiple staged reads and writes — for example, reading a field to decide what to write to another field, all on one register, with a single bus read and a single bus write at exit.
with soc.uart.control as r:
r.enable = 1
r.baudrate = BaudRate.BAUD_115200
if r.parity.read() == Parity.NONE:
r.parity = Parity.EVEN
# 1 read + 1 write hits the bus on exit.
Inside the with block, r.enable = 1 is sugar for
r.enable.stage(1). Reads (r.parity.read()) come from the staged
register value, not the bus, so a context with mixed reads and writes is
still one round trip.
For the common single-RMW case, prefer reg.modify(**fields): the kwargs
form is shorter, type-checked end to end, and reads as a single transaction
at the call site. The context manager earns its keep when the staged writes
depend on intermediate reads.
Attribute writes outside contexts#
To kill the most common footgun (reg.enable = 1 while expecting a bus
transaction), bare attribute assignment on a register outside a context
manager raises:
soc.uart.control.enable = 1
Produces:
AttributeError: assigning to a field outside a context. Use:
soc.uart.control.enable.write(1) # RMW
soc.uart.control.modify(enable=1) # RMW
with soc.uart.control as r: r.enable = 1 # batched
The error message lists the three correct alternatives so the fix is always one copy-paste away.
Strict-fields opt-out (--strict-fields=false)#
A build-time toggle, --strict-fields=false, exists for teams porting C
drivers that depend on attribute-assign-as-RMW semantics. The default is
strict.
Warning
--strict-fields=false makes reg.field = value silently issue an RMW
on every assignment. It is a footgun by design. Silent RMW on bare
attribute assignment is the single most common source of “I thought that
wrote” test bugs.
When the opt-out is enabled, the generated module emits a
DeprecationWarning at import and on every loose assignment. The
warning is annoying on purpose. Per-instance toggling intentionally does not
exist — the policy is a single bit, set at build time.
Use --strict-fields=false only as a porting bridge. New code should use
modify(**fields) or the context manager.
Typed return values#
Reads do not return bare int. They return RegisterValue (for a
register read) or FieldValue (for a field read). Both are immutable and
hashable — safe as dict keys for snapshots, coverage maps, and
golden-state checks. Both are picklable and JSON-serializable for
distributed test harnesses and CI artefacts.
Mutation goes through .replace(**fields) (returns a new value) and never
through assignment. .replace(**fields) returns a new RegisterValue
— the original is unchanged and remains safe to reuse as a snapshot key.
RegisterValue and FieldValue are concrete int subclasses#
These are not opaque wrappers — they are real int subclasses with a
small bag of decode metadata bolted on. That has a few practical
implications worth knowing:
Note
isinstance(rv, int) is True— values passinttype checks and drop into any function that acceptsint(NumPy,struct.pack,ctypes, third-party math) without a conversion.int(rv) == rv— converting back to a plainintis a no-op; it simply returns the same numeric value, with metadata stripped.hash(rv) == hash(int(rv))— hashing degenerates to the underlyinginthash, so a value-equalRegisterValueand a plainintland in the same dict slot.{rv: "x"}[0x22]works.Metadata (
fields,address,width,name,path,description,encode) lives on the instance__dict__. CPython forbids__slots__onintsubclasses, so the metadata is treated as private and accessed through properties.Pickle and JSON round-trips preserve the metadata.
pickle.loads( pickle.dumps(rv))andRegisterValue.from_json(rv.to_json())both give back a value-equalRegisterValuewith its fields intact.
rv = soc.uart.control.read()
isinstance(rv, int) # True
int(rv) == rv # True
hash(rv) == hash(int(rv)) # True
rv + 1 # plain int arithmetic; result is a plain int
{rv: "saved"}[0x22] # 'saved' — int-compatible hash slot
import pickle
pickle.loads(pickle.dumps(rv)).enable == rv.enable # True
In day-to-day use, the metadata on top of the int gives you printable repr,
field accessors, and replace:
v = soc.uart.control.read()
print(v)
# UartControl(0x00000022)
# enable[0] = 1
# baudrate[3:1]= BaudRate.BAUD_19200 (1)
# parity[5:4] = Parity.NONE (0)
v == 0x22 # True, RegisterValue is int-compatible
v.enable # 1 (also v["enable"])
v.baudrate # <BaudRate.BAUD_19200: 1>
v.replace(enable=0) # → new RegisterValue with field swapped
soc.uart.control.write(v) # round-trips
Format helpers#
RegisterValue and FieldValue provide the formatting helpers users
always end up wanting:
v.hex() # "0x00000022"
v.hex(group=4) # "0x0000_0022"
v.bin() # "0b00000000_00000000_00000000_00100010"
v.bin(group=8, fields=True) # annotates groups with field boundaries
print(reg, fmt="bin") # alt-format on the live read
soc.uart.control.read().table() # ASCII table of fields, ready for logs
The .table() form is especially useful in CI logs and bug reports: it
prints field-by-field rows that survive copy-paste.
Why immutable values?#
There is one allocation per read. The alternative — mutable shared state
pretending to be a value — leaks bugs (a stale read silently mutating, a
snapshot dict whose keys all alias the same int). The cost is paid on
purpose. Snapshot follows the same rule.
Field reads return enums when the field has encode, and bool-compatible
FieldValue for 1-bit fields:
soc.uart.control.baudrate.read() # → BaudRate.BAUD_19200
soc.uart.intr.tx_done.read() # → FieldValue (1-bit, bool-compatible)
FieldValue.__bool__ returns int(self) != 0, so
bool(field_value) == bool(int(field_value)). That means the natural
truthiness pattern just works:
if soc.uart.intr.tx_done.read(): # truthy iff the bit is 1
drain_tx_fifo()
reg = soc.uart.intr.read()
if reg.tx_done: # same — reg.tx_done is a FieldValue
drain_tx_fifo()
If you need the literal True/False object — for is True checks,
JSON serialization, or for forwarding into APIs that distinguish bool from
int — call bool(...) explicitly: bool(reg.tx_done). FieldValue
is an int subclass, so reg.tx_done is True is always False even
when the bit is set.
Bit-level access in multi-bit fields#
Multi-bit fields often pack N independent flags (e.g. a 16-bit direction
where each bit is one GPIO line). The .bits accessor gives single-bit and
slice-level access without breaking the field abstraction:
soc.gpio.direction.bits[5].read() # bool
soc.gpio.direction.bits[5].write(1) # RMW that touches one bit only
soc.gpio.direction.bits[0:8].read() # ndarray[bool], length 8
soc.gpio.direction.bits[:].write(0xFF00) # bitmask to bool array
bits[i].write(...) is a single-bit RMW, with the same bus cost as a
single-field RMW: 1 read + 1 write. Slicing (bits[0:8]) returns a
NumPy array of bool and is the right way to dump or apply a bitmask.