Using the Python Host Interface
The Signaloid-Compute-Module-Utilities repository ships a Python package for the code that runs on the host. It gives your host application a class whose methods read and write the registers and buffers of the C0-microSD+, so you exchange data with your device application without writing raw block-device code of your own.
For the addresses these methods reach, see Host Interface and Protocol. For the device-side half of the application implementation, see Using the Hardware Abstraction Layer.
Install the package
The distribution is named signaloid-utilities and the importable package is
signaloid_utilities. It requires Python 3.10 or later. See Python Environment.
Install it from the Signaloid-Compute-Module-Utilities repository. The
Signaloid-Compute-Module-Application-Template lists it in
python-host-application/requirements.txt, so a template-based project installs it with the
rest of its dependencies.
python3 -m venv .venv
.venv/bin/pip install -r python-host-application/requirements.txt
Host applications need root privileges, because reaching the module means opening a raw block
device. Invoke the interpreter of your virtual environment directly under sudo, for example
sudo .venv/bin/python3. A plain sudo python3 may resolve to a different interpreter that
does not have your installed packages.
Construct the interface
The C0-microSD+ class lives in the c0sd subpackage.
from signaloid_utilities.c0sd.interface import C0microSDPlusInterface
compute_module = C0microSDPlusInterface("/dev/disk4")
The constructor signature is
C0microSDPlusInterface(target_device, force_transactions=False, regmap_path=None). Pass the
raw device-node path as target_device, which looks like /dev/sdX on Linux and /dev/diskN
or /dev/rdiskN on macOS. Leave force_transactions at its default, because the C0-SD family
interface does not read it. Leave regmap_path at its default so the built-in register map
for this module is used.
Constructing the interface loads the register map, so the instance carries the addresses of this module as attributes you can read.
| Attribute | Value | Meaning |
|---|---|---|
COMMAND_REGISTER_OFFSET | 0x08200000 | Command register |
CONFIG_REGISTER_OFFSET | 0x08204000 | Configuration register |
STATUS_REGISTER_OFFSET | 0x08208000 | Status register |
SD_CONFIG_REGISTER_OFFSET | 0x0820C000 | SD block-write CRC configuration |
BITSTREAM_UNLOCK_REGISTER_OFFSET | 0x08214000 | Bitstream unlock key register |
MMIO_BUFFER_OFFSET | 0x08300000 | Base of the 64 KiB MMIO buffer |
OUTPUT_BUFFER_OFFSET | 0x08300000 | Output half, device to host, 32 KiB |
INPUT_BUFFER_OFFSET | 0x08308000 | Input half, host to device, 32 KiB |
BITSTREAM_OFFSET | 0x00000000 | SPI flash bitstream region |
APPLICATION_BINARY_OFFSET | 0x00200000 | SPI flash user data region |
The buffer sizes are available as MMIO_BUFFER_SIZE_BYTES, INPUT_BUFFER_SIZE_BYTES, and
OUTPUT_BUFFER_SIZE_BYTES. Size your transfers from those attributes rather than from
literals, so your application keeps working if a future module changes them.
Move data through the buffers
| Method | Purpose |
|---|---|
write_input_buffer(buffer) | Write operand bytes into the input half. |
read_output_buffer(size=None) | Read result bytes from the output half, all of it by default. |
write_MMIO_buffer(buffer) | Write from the base of the whole 64 KiB buffer. |
read_MMIO_buffer(size=None) | Read from the base of the whole 64 KiB buffer. |
read_debug_log_buffer(size=512) | Read the device log window at the end of the output half. |
Each write raises ValueError when the buffer you pass is longer than the region it targets.
Keep size at 32768 or below when you call read_debug_log_buffer.
Python struct is all you need to lay bytes out. The following packs four floats for a
device application that reads them from the input half.
import struct
operands = struct.pack("<4f", 5.0, 6.0, 4.0, 7.5)
compute_module.write_input_buffer(operands)
The layout you pack here is the layout your device application reads, and the RISC-V core of the module requires every load to be naturally aligned. Place each field on a multiple of its own size, adding padding where a mixed layout would otherwise misalign a later field. See Using the Hardware Abstraction Layer → Memory alignment.
Drive the round trip
| Method | Purpose |
|---|---|
set_command(value) | Write a 32-bit command word. |
get_command() | Read the command word back. |
get_status() | Read the 32-bit status word. |
calculate_command(...) | Run a whole blocking round trip. |
verbose_status() | Print the command, configuration, status, and SD CRC registers. |
calculate_command() is the method most host applications call. Its signature is
calculate_command(command, idle_command=0, poll_sleep_time=0.5, skip_MMIO_buffer_read=False, verbose=True, timeout_waiting_to_start=0.5). It writes the command, polls the status
register until the device reports done, optionally reads the buffer back, and then drives the
device to idle by writing idle_command until the status returns to waiting for command.
The module-level constants mirror the conventional status values of the device-side header,
namely SIGNALOID_SOC_STATUS_WAIT_FOR_COMMAND, SIGNALOID_SOC_STATUS_CALCULATING,
SIGNALOID_SOC_STATUS_DONE, and SIGNALOID_SOC_STATUS_INVALID_COMMAND, alongside
K_CALCULATE_NO_COMMAND for the idle command.
Three behaviors are worth knowing before you rely on this method.
- The default poll cadence is 0.5 seconds, so a fast computation still takes about that
long to report. Pass a smaller
poll_sleep_time, as the application template does with0.001, when latency matters. timeout_waiting_to_startcovers only the start of the work. Once the device reports calculating there is no timeout.- A failure prints rather than raises. An invalid command, a start timeout, or an
unrecognized status makes the method report the problem and return
None.
Passing skip_MMIO_buffer_read=True and then calling read_output_buffer() with an explicit
size transfers less data when you only need a few result words, because the default reads
the entire 64 KiB window.
compute_module.calculate_command(
1,
poll_sleep_time=0.001,
skip_MMIO_buffer_read=True,
)
mean, variance = struct.unpack("<2f", compute_module.read_output_buffer(8))
Control the core
Core control, the LEDs, and the debug pins are all driven through the configuration register,
and the interface gives you three ways to reach it, from the most convenient to the most
direct. Bitstream locking is not one of them: it is driven by the separate
BITSTREAM_UNLOCK register, covered in
Lock and unlock the bitstream region.
Apply a named action
compute_module.apply_configure_action("core-start")
apply_configure_action(action) is the method to reach for first. It looks the action name
up in a table, then performs one read, one modify, and one write against a bit mask. Because
the mask covers only the bits that the action owns, the LED and debug pin bits of the register
keep their current values. A configuration-register action never overwrites the whole register.
The two bitstream actions are the exception: they use a full 0xFFFFFFFF mask, because each
writes the whole BITSTREAM_UNLOCK register.
An unknown action name raises UnsupportedConfigureAction, which carries the name you passed
and the list of names this module accepts.
The C0-microSD+ accepts twenty-five actions. Sixteen target the configuration register, two
target the BITSTREAM_UNLOCK register, and the remaining seven target the SD block-write CRC
configuration register.
| Action | Effect |
|---|---|
core-start | Release the RISC-V core so that it runs. |
core-stop | Hold the RISC-V core in reset. |
sw-led-on, sw-led-off | Hand the on-board red LED to software and drive it. |
red-led-on, red-led-off | Drive the red LED. |
green-led-on, green-led-off | Drive the green LED. |
blue-led-on, blue-led-off | Drive the blue LED. |
debug-pin-0-on, debug-pin-0-off | Drive debug pin 0. |
debug-pin-1-on, debug-pin-1-off | Drive debug pin 1. |
debug-pin-2-on, debug-pin-2-off | Drive debug pin 2. |
The seven SD block-write CRC actions target the SD_CONFIG register. Leave these alone unless
you are bringing up an SD host controller whose CRC generation you are still debugging.
| Action | Effect |
|---|---|
write-crc-force-ok-enable, write-crc-force-ok-disable | Force a CRC-OK response to a block write. |
write-crc-force-write-enable, write-crc-force-write-disable | Commit block-write data even on a CRC mismatch. |
write-crc-irq-connect, write-crc-irq-disconnect | Route a CRC error to the core interrupt. |
write-crc-irq-clear | Clear the CRC error status bit. |
The two remaining actions, unlock-bitstream and lock-bitstream, write the
BITSTREAM_UNLOCK register rather than the configuration register. Drive them through the
dedicated methods described in
Lock and unlock the bitstream region rather than
through apply_configure_action(), because unlocking has to be retried until the key takes.
Reset the core
compute_module.reset_core(0.5)
reset_core(timeout=1.0) runs a stop, a wait, a start, and a second wait, so the call blocks
for roughly twice the timeout you pass. The argument is a settle delay rather than a
deadline.
The method does not poll to confirm that the core came back, so follow it by waiting for your device application to report its waiting-for-command status before you send the first command.
Read the register field by field
rstn, sw_enable, sw_led, red, green, blue, dbg0, dbg1, dbg2 = (
compute_module.get_config_register_unpacked()
)
get_config_register_unpacked() returns the nine fields of the configuration register as
booleans, in the order rstn, sw_led_enable, sw_led, red_led, green_led, blue_led,
debug_pin_0, debug_pin_1, and debug_pin_2. Bit 1 is unassigned and is not reported. Use
it to inspect the current state of the module rather than to change it.
Write chosen fields
compute_module.modify_config_register(green_led=True)
modify_config_register() changes only the fields you name and leaves every other field at
its current value, which makes it the safe way to drive the register directly.
compute_module.set_config_register_unpacked(rstn=True, green_led=True)
set_config_register_unpacked() writes a complete register value instead. Every field you do
not pass takes its default of False, so this call clears the rest of the register. Reach for
it only when you intend to set the whole register at once, and prefer
modify_config_register() otherwise.
The rawest pair, get_config_register() and set_config_register(value), read and write the
register as a single 32-bit integer. Use them when you want to snapshot the register and
restore it later.
Lock and unlock the bitstream region
The C0-microSD+ is an FPGA-based module, and the bitstream at the bottom of the SPI flash configures the FPGA to implement the Signaloid SoC and the SD interface itself. Unlocking that region is only ever needed to install an official Signaloid bitstream update. Flashing a device application does not need it.
Writing a wrong or corrupted image leaves the module permanently inoperable, because there is
no second bitstream to fall back on. Use the flash-bitstream subcommand of
C0_SD_toolkit.py, which brackets the write with an unlock and a re-lock, rather than these
methods.
Writes into the bitstream region of the SPI flash are gated by the BITSTREAM_UNLOCK register.
The region accepts writes only while that register holds the ASCII key UNLK (0x4B4C4E55),
and any other value locks it. Reads are never gated, so the region always reads back its real
contents.
The hardware forces the key to 0x00000000 whenever the Signaloid SoC core is running, so stop
the core before you unlock, and expect starting the core to re-lock the region. See
BITSTREAM_UNLOCK register.
| Method | Purpose |
|---|---|
unlock_bitstream(confirm_callback) | Write the unlock key, retrying until it takes. |
lock_bitstream() | Write 0x00000000 to lock the region again. |
get_bitstream_unlock_key() | Read the key back as a 32-bit integer. |
read_bitstream_unlock_key_bytes() | Read the raw four bytes of the key. |
unlock_bitstream(confirm_callback=None, verbose=False) asks for confirmation before it
writes. Pass a callback that returns True to proceed, and without one the call returns
without doing anything. It then writes the key and reads it back, retrying up to ten times at
0.2-second intervals, because a core stop issued moments earlier may still be draining. If the
key has still not taken, it raises BitstreamUnlockFailed.
get_bitstream_unlock_key() returns 0x4B4C4E55 while the region is unlocked and 0x00000000
while it is locked. read_bitstream_unlock_key_bytes() returns the same value as four raw
bytes, which read UNLK in order while the region is unlocked.
compute_module.apply_configure_action("core-stop")
compute_module.unlock_bitstream(lambda: True)
# Write the bitstream region here.
compute_module.lock_bitstream()
No sleep is needed between the stop and the unlock, because unlock_bitstream() retries until
the key reads back.
Inspect the module
| Method | Purpose |
|---|---|
get_trap_status() | Read the three trap registers in one transaction. |
read_bitstream_metadata() | Decode the JSON metadata embedded in the on-device bitstream. |
verify_bitstream_crc() | Check the bitstream against the CRC in its metadata. |
get_bitstream_unlock_key() | Read the bitstream unlock key as a 32-bit integer. |
get_trap_status() returns (mcause, mepc, mtval) in a single transaction, and an mcause
of 0xFFFFFFFF means no trap has been recorded. Because the trap handler halts the core, any
other value means your device application faulted and stopped rather than stalling. For what
each register holds and how to act on a recorded trap, see
Host Interface and Protocol.
verify_bitstream_crc() needs no unlock, because the bitstream region is always readable and
only writes are gated. It reads just the number of bytes the metadata declares and returns
None when the metadata carries no CRC field.
A minimal host application
import struct
from signaloid_utilities.c0sd.interface import (
C0microSDPlusInterface,
SIGNALOID_SOC_STATUS_WAIT_FOR_COMMAND,
)
K_MY_COMMAND_ADDITION = 1
compute_module = C0microSDPlusInterface("/dev/disk4")
# Bring the core up and wait for the device application to be ready.
compute_module.reset_core(0.5)
while compute_module.get_status() != SIGNALOID_SOC_STATUS_WAIT_FOR_COMMAND:
pass
# Send the operands, run the command, and read the results back.
compute_module.write_input_buffer(struct.pack("<4f", 5.0, 6.0, 4.0, 7.5))
compute_module.calculate_command(
K_MY_COMMAND_ADDITION,
poll_sleep_time=0.001,
skip_MMIO_buffer_read=True,
)
mean, variance = struct.unpack("<2f", compute_module.read_output_buffer(8))
print(f"Mean {mean}, variance {variance}")
# Leave the module idle and release the device descriptor.
compute_module.apply_configure_action("core-stop")
compute_module.device.close()
The command number has to match the command your device application recognizes. Nothing in the library enforces that agreement, so treat the command numbers as a contract between your two halves.
The interface has no close() method and no context manager. Call
compute_module.device.close() when you want the underlying descriptor released
deterministically.
Device access model
A register read issued through an ordinary buffered file descriptor is subject to page-cache coherence rules, so a poll loop can observe a stale status word and a command write can sit in the cache instead of reaching the hardware. The package therefore inspects the device node and selects a cache-bypassing strategy for it.
| Device node | Mechanism |
|---|---|
Linux block node, /dev/sdX | Opened with O_DIRECT, which bypasses the page cache |
macOS character node, /dev/rdiskN | Positioned pread and pwrite on a descriptor held open |
macOS block node, /dev/diskN | Reopened per operation, because macOS invalidates the cache on close |
Two consequences are worth knowing.
- Transfers are aligned for you. The unbuffered strategies require block-aligned access, so a read fetches the covering blocks and returns your slice, and a write fetches them, patches your range, and writes them back. Any offset and length are valid, and bytes next to your range inside the same block survive. This holds for the registers and the MMIO buffer. It does not hold for the SPI flash, where a write can erase a whole 4 KiB sector. See SPI Flash Write and Erase Semantics.
- Each instance serializes its own access. One lock covers every read and write, including the read-modify-write that a partial write performs, so an instance is safe to share across the threads of a single process. Coordinating two processes on the same device remains your responsibility.
Failures name the device path. A missing node raises FileNotFoundError, denied access raises
PermissionError, and any other device-level failure raises OSError.
Read the device log
When your device application is built with logging enabled, read_debug_log_buffer() returns
the raw bytes of the log window. The package also ships a ready-made reader, the
C0_debug_logger.py script at the root of the
Signaloid-Compute-Module-Utilities repository, which polls that window
and prints it.
sudo python3 C0_debug_logger.py --variant C0-microSD+ /dev/disk4
Pass --variant C0-microSD+ explicitly, because the script defaults to a different module.
To read the log from inside your own host application instead, see Embed the Log Reader in Your Host Application.
Next steps
- Developing UxHw Applications, the end-to-end workflow this interface fits into.
- Using the Hardware Abstraction Layer, the device-side library your host application talks to.
- Host Interface and Protocol, the registers, buffers, and handshake behind these methods.