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.write and reg.modify — are chosen to read clearly without lying about cost.

  • Returns are typed. A field with encode = BaudRate reads back as BaudRate.BAUD_115200, not 2.

  • 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

reg.read()

Read the register; return a RegisterValue.

1 read

reg.write(value)

Raw write, no read first. Bits not represented in value are written as zero.

1 write

reg.modify(**fields)

Read-modify-write: read the register, splice in the named fields, write it back.

1 read + 1 write

reg.poke(value)

Same as write(value) but explicit; reads as “I know what I’m doing” at call sites.

1 write — same as write but explicit

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 pass int type checks and drop into any function that accepts int (NumPy, struct.pack, ctypes, third-party math) without a conversion.

  • int(rv) == rv — converting back to a plain int is a no-op; it simply returns the same numeric value, with metadata stripped.

  • hash(rv) == hash(int(rv)) — hashing degenerates to the underlying int hash, so a value-equal RegisterValue and a plain int land 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__ on int subclasses, so the metadata is treated as private and accessed through properties.

  • Pickle and JSON round-trips preserve the metadata. pickle.loads( pickle.dumps(rv)) and RegisterValue.from_json(rv.to_json()) both give back a value-equal RegisterValue with 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.