Skip to main content

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
note

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.

AttributeValueMeaning
COMMAND_REGISTER_OFFSET0x08200000Command register
CONFIG_REGISTER_OFFSET0x08204000Configuration register
STATUS_REGISTER_OFFSET0x08208000Status register
SD_CONFIG_REGISTER_OFFSET0x0820C000SD block-write CRC configuration
BITSTREAM_UNLOCK_REGISTER_OFFSET0x08214000Bitstream unlock key register
MMIO_BUFFER_OFFSET0x08300000Base of the 64 KiB MMIO buffer
OUTPUT_BUFFER_OFFSET0x08300000Output half, device to host, 32 KiB
INPUT_BUFFER_OFFSET0x08308000Input half, host to device, 32 KiB
BITSTREAM_OFFSET0x00000000SPI flash bitstream region
APPLICATION_BINARY_OFFSET0x00200000SPI 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

MethodPurpose
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

MethodPurpose
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 with 0.001, when latency matters.
  • timeout_waiting_to_start covers 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.

ActionEffect
core-startRelease the RISC-V core so that it runs.
core-stopHold the RISC-V core in reset.
sw-led-on, sw-led-offHand the on-board red LED to software and drive it.
red-led-on, red-led-offDrive the red LED.
green-led-on, green-led-offDrive the green LED.
blue-led-on, blue-led-offDrive the blue LED.
debug-pin-0-on, debug-pin-0-offDrive debug pin 0.
debug-pin-1-on, debug-pin-1-offDrive debug pin 1.
debug-pin-2-on, debug-pin-2-offDrive 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.

ActionEffect
write-crc-force-ok-enable, write-crc-force-ok-disableForce a CRC-OK response to a block write.
write-crc-force-write-enable, write-crc-force-write-disableCommit block-write data even on a CRC mismatch.
write-crc-irq-connect, write-crc-irq-disconnectRoute a CRC error to the core interrupt.
write-crc-irq-clearClear 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.

danger

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.

MethodPurpose
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

MethodPurpose
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 nodeMechanism
Linux block node, /dev/sdXOpened with O_DIRECT, which bypasses the page cache
macOS character node, /dev/rdiskNPositioned pread and pwrite on a descriptor held open
macOS block node, /dev/diskNReopened 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