C0-microSD+ Host Interface and Protocol
The C0-microSD+ communicates with a host over the SD interface, using either the 4-wire SD protocol or SD-over-SPI. All communication is achieved through host-initiated block read and write operations, the same operations a host uses for any SD card. The module never initiates a transfer of its own and raises no interrupt toward the host, so a host application that needs to observe device state polls the relevant address. For transport-level details, see SD Interface.
Communication over the SD interface uses block read and write operations, not the SDIO extension of the SD interface. No additional host drivers are required.
The address map of the C0-microSD+ is unified. The host and the RISC-V core inside the
Signaloid SoC use the same addresses for every register and every buffer, so the
tables on this page are the single address reference for both sides. There is no separate
host-side offset map, and there is nothing to translate between the two views. To read the
STATUS register, a host application seeks to 0x08208000 on the block device and reads
four bytes, and the device application reads that same register at 0x08208000.
Unified address map
The map below runs from the highest address at the top to the lowest address at the bottom. Dashed regions are reserved and are not decoded by the module.
The labels to the right of the map show how far each side reaches. A host over the SD
interface can read and write every region from the bottom of the SPI flash up to and
including the MMIO buffer, which ends at 0x0830FFFC. The Signaloid SoC reaches all of
those regions and, in addition, the DMA registers above them.
The MMIO buffer is therefore the highest address a host can reach. The DMA registers are the only decoded region above it, and the SD interface does not reach them. A host drives the module through the CSRs and the MMIO buffer, and the DMA controller is an internal facility that the device application programs from the Signaloid SoC side.
| Region | Address range | Size |
|---|---|---|
| SPI flash, bitstream | 0x00000000 – 0x001FFFFC | 2 MiB |
| SPI flash, user data | 0x00200000 – 0x00FFFFFC | 14 MiB |
| Flash OTP | 0x08000000 – 0x080001FC | 512 bytes |
| LRAM, main memory | 0x08100000 – 0x0814FFFC | 320 KiB |
| CSR registers | 0x08200000 – 0x08214003 | See the register table |
| MMIO buffer | 0x08300000 – 0x0830FFFC | 64 KiB |
| DMA registers | 0x10000000 – 0x1000001C | 32 bytes |
The two SPI flash regions together form the 16 MiB of on-board flash. The Signaloid-supplied
FPGA bitstream lives in the bitstream region at 0x00000000, and your device application is
flashed into the user data region at 0x00200000. The user data region is always
accessible, so flashing an application needs no unlock. The bitstream region is always
readable, but writes into it are rejected unless the BITSTREAM_UNLOCK register at
0x08214000 holds the unlock key, so an accidental host write cannot reach the region that
the module boots from.
The LRAM region is the 320 KiB of main memory that the device application links its code and
data into. That describes a lram build. A device application built with MODE set to
xip executes in place from the user data region of the SPI flash instead, and the LRAM
holds only its writable data.
SPI flash write and erase semantics
Writing to the SPI flash is not like writing to memory. The flash erases in 4 KiB sectors, and the module performs that erase for you, so a single write can discard data you never meant to touch. The rules below apply to every write that reaches the flash, whether it comes from the host over the SD interface or from your device application.
Writes erase whole sectors
A write to a 4 KiB-aligned address erases that whole 4 KiB sector before it writes, which discards everything else the sector held. A write long enough to cross into the next sector erases that sector too, at the point where it crosses.
A write that starts part way into a sector erases nothing. It writes into a sector that still holds its previous contents, and flash bits only ever clear, so the stored result combines the old and the new values instead of matching what you wrote.
Two approaches follow from this. Write whole 4 KiB sectors, so that each write starts on a sector boundary and replaces the sector completely, or read a sector, change the bytes you need, and write the whole sector back.
Allow for the write latency
A flash write does not complete until the flash has finished erasing and programming, which
takes far longer than a write to the LRAM or to the MMIO buffer. There is no
busy flag to poll, because the write itself blocks until the flash is done. In an
xip build the same wait also stalls the core, which fetches its instructions from that
flash.
Writes are word-sized and word-aligned
Every flash write updates a full 32-bit word, and the address must be word-aligned. The SPI flash has no byte-granular write, so writing a single byte still programs the whole word that contains it, carrying whatever the other three bytes of that word hold.
The bitstream region lock
The C0-microSD+ is an FPGA-based module, and the bitstream region configures the FPGA to implement the Signaloid SoC and the SD interface itself. The bitstream region is locked when the module powers up. While it is locked, a write into the region does nothing, and the module still reports that write as successful, so confirm any intentional change by reading it back. Reads are never gated, so the region always reads back its real contents whether it is locked or not, both for a host over the SD interface and for a device application. 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 in the bitstream region leaves the module permanently inoperable. Keep the region locked.
The lock covers writes from the host and from your device application alike. Because the hardware clears the unlock key whenever the core is running, the region is unconditionally locked while a device application executes, unlocking requires the core to be stopped first, and starting the core re-locks the region immediately. See BITSTREAM_UNLOCK register.
To change the bitstream, use the C0_SD_toolkit.py script, which stops the SoC core, unlocks
the region, writes it, verifies it, and locks it again. If the region is still locked when a
write arrives, the module drops the write without reporting an error, so it surfaces as a
verify mismatch from the toolkit rather than as a failed write.
The unlock key is volatile. A power cycle returns BITSTREAM_UNLOCK to 0x00000000, so the
region comes back locked and no recovery step is needed after a power loss.
Keep application data clear of the binary
The module flashes your device application at 0x00200000, at the start of the user data
region, and nothing protects it. A write that erases any sector the binary occupies corrupts
the binary, and the core then runs a damaged image the next time you start it.
Place persistent application data well above the binary. The top of the user data region,
for example the last megabyte from 0x00F00000, is a safe home for it. An xip build runs
directly from the user data region, so a device application that writes to the flash must
stay clear of its own image. C0_SD_toolkit.py stops the core for you before it flashes; if
you flash from your own host code, stop the core first.
Never remove power while a flash write is in progress. A sector caught part way through an erase or a program holds neither its old contents nor its new contents. See Handling and ESD.
For the device-application side of this, including where to place persistent data and how to read it back safely, see Using the SPI Flash.
MMIO memory map and registers
The C0-microSD+ realizes the shared
MMIO Interfacing Model
in the Signaloid SoC. The control and status registers, referred to as the CSRs, occupy the
region based at 0x08200000. Every CSR is 32 bits wide.
| Register | Address | Size | Written by | Read by | Reset value |
|---|---|---|---|---|---|
COMMAND | 0x08200000 | 4 bytes | Host | Device | 0x00000000 |
CONFIG | 0x08204000 | 4 bytes | Host, device | Host, device | 0x00000000 |
STATUS | 0x08208000 | 4 bytes | Device | Host | 0x00000000 |
SD_CONFIG | 0x0820C000 | 4 bytes | Host | Host | 0x00000001 |
TRAP_MCAUSE | 0x08210000 | 4 bytes | Trap handler | Host, device | 0xFFFFFFFF |
TRAP_MEPC | 0x08210004 | 4 bytes | Trap handler | Host, device | 0x00000000 |
TRAP_MTVAL | 0x08210008 | 4 bytes | Trap handler | Host, device | 0x00000000 |
BITSTREAM_UNLOCK | 0x08214000 | 4 bytes | Host | Host | 0x00000000 |
COMMANDcarries an application-defined operation code from the host to the device application. The host writes it to request work, and the device application reads it.CONFIGcarries the core reset, LED, and debug pin controls. The bit assignments are in CONFIG register bits.STATUScarries an application-defined status value from the device application back to the host. The device application writes it, and the host polls it.SD_CONFIGcontrols how the module handles the CRC of an incoming SD block write. Leave its defaults in place unless you are bringing up an SD host whose CRC generation you are still debugging.BITSTREAM_UNLOCKgates writes to the bitstream region of the SPI flash. Write the ASCII keyUNLKto unlock the region and any other value to lock it. The core must be stopped for a key write to take effect. See BITSTREAM_UNLOCK register.
The Reset value column gives what each register holds when the module powers up. Every
CSR is volatile, so a power cycle returns all of them to these values. A CONFIG of
0x00000000 holds the core in reset, which means the module always powers up with your
device application stopped. See
Power Cycles and State.
The values carried by COMMAND and STATUS are application-defined. The Signaloid
device-side headers define four conventional SignaloidSoCStatus values, but your
application is free to define its own.
The device-side C API reaches these registers through helper functions rather than through
raw pointers, for example C0HALGetCommandRegister(), C0HALSetStatusRegister(), and
C0HALGetConfigRegister(). See
Developing UxHw Applications.
Trap registers
The Signaloid UxHw Toolchain bundles a trap handler with every device application it builds for the C0-microSD+. When the RISC-V core takes a trap, execution vectors to that handler, the handler publishes the cause of the trap into the three trap registers where a host can read it, and it then halts the core. A trap therefore stops your device application rather than resuming it, and the trap registers hold the cause of that halt. They are the first place to look when a device application stops responding.
TRAP_MCAUSE holds the trap cause, TRAP_MEPC holds the program counter at the point of
the trap, and TRAP_MTVAL holds the associated trap value. The three registers are
contiguous, so a host can read all twelve bytes in a single transaction starting at
0x08210000. Because the handler halts the core before returning, the values stay stable
while you read them, and they remain readable over the SD interface after the application
has stopped.
TRAP_MCAUSE reads 0xFFFFFFFF when the core has recorded no trap. Treat that value as
no trap recorded rather than as a valid RISC-V cause code, and check it before you read
any meaning into TRAP_MEPC and TRAP_MTVAL. A TRAP_MCAUSE value other than
0xFFFFFFFF means the trap handler ran and halted the core rather than the application
stalling.
Any other value is a standard RISC-V machine-mode exception code. The codes below are the ones a device application on this module can realistically record, because the core runs in machine mode only and has no virtual memory.
TRAP_MCAUSE | Meaning |
|---|---|
0 | Instruction address misaligned |
1 | Instruction access fault |
2 | Illegal instruction |
3 | Breakpoint |
4 | Load address misaligned |
5 | Load access fault |
6 | Store or AMO address misaligned |
7 | Store or AMO access fault |
11 | Environment call from M-mode |
The encoding is the one defined by the RISC-V privileged specification, which lists every architecturally defined code. A value with its most significant bit set denotes an interrupt rather than an exception.
A recorded trap is therefore the first thing to look for when a device application stops responding, because the application does not resume on its own. Note the three values, stop the core, correct the application, flash it again, then start the core.
The most common cause is an unaligned memory access, because the RISC-V core of this module requires every load and store to be naturally aligned. See Using the Hardware Abstraction Layer → Memory alignment.
CONFIG register bits
| Bit | Name | Usage |
|---|---|---|
| 0 | rstn | Core reset, active low. Set to 1 to run the core. |
| 1 | Reserved | Unassigned. Has no effect; write as 0. |
| 2 | swLedEnable | Hand LED control to software. |
| 3 | swLed | Software-controlled LED state. |
| 4 | redLed | On-board red LED. |
| 5 | greenLed | On-board green LED. |
| 6 | blueLed | On-board blue LED. |
| 7 | debugPin0 | Debug pin 0 output state. |
| 8 | debugPin1 | Debug pin 1 output state. |
| 9 | debugPin2 | Debug pin 2 output state. |
| 31:10 | Reserved | Write as 0. |
Every bit resets to 0 when the module powers up, which holds the core in reset. Unlocking
the bitstream region is done through the BITSTREAM_UNLOCK register at 0x08214000, not
through CONFIG. See BITSTREAM_UNLOCK register.
CONFIG is a single register, so read it, modify the bits you want, and write the whole
32-bit value back. Writing a bare bit mask clears every other control in the register,
including rstn, which halts a running core.
BITSTREAM_UNLOCK register
BITSTREAM_UNLOCK sits at 0x08214000 and is the only control over whether the bitstream
region of the SPI flash accepts writes. No CONFIG bit affects it. The register holds a
single 32-bit key field and resets to 0x00000000.
| Key value | Effect |
|---|---|
0x4B4C4E55, the ASCII bytes UNLK | The region accepts writes. |
Any other value, including 0x00000000 | The region rejects writes. |
The key is a little-endian word, so a hexdump of the register reads U, N, L, K in
order while the region is unlocked.
The hardware forces the key to 0x00000000 whenever the Signaloid SoC core is running. Three
consequences follow.
- The bitstream region is always locked while a device application executes.
- Unlocking requires the core to be stopped first. See Starting and stopping the core.
- A key write issued while the core is running is discarded, and the register reads back as
0x00000000.
Starting the core re-locks the region immediately, so an unlock lasts only as long as the core stays stopped.
Only writes are gated. Reads of the bitstream region are never gated, so a host can always read the region back to verify its contents. See The bitstream region lock.
Data buffers
The C0-microSD+ provides one 64 KiB MMIO buffer based at 0x08300000. The module
decodes it as a single region, and the address map above shows it that way.
Software divides that one region down the middle into two halves of 32 KiB each, an output half for data travelling from the device to the host and an input half for data travelling from the host to the device. The division is a convention rather than a hardware boundary. The Signaloid-Compute-Module-Utilities package and the device-side hardware abstraction layer both present the two halves as separate buffers, which keeps application code on each side clear about the direction it is reading or writing.
| Half | Direction | Address range | Size | Device operation |
|---|---|---|---|---|
| Output (MISO) | Device to host | 0x08300000 – 0x08307FFC | 32 KiB | W |
| Input (MOSI) | Host to device | 0x08308000 – 0x0830FFFC | 32 KiB | R |
- MOSI, Manager-Out, Subordinate-In, carries operand data from the host application to the device application.
- MISO, Manager-In, Subordinate-Out, carries result data from the device application back to the host application.
The host writes operands into the input half and reads results back from the output half. The device application does the reverse, reading its operands from the input half and writing its results into the output half. Because the address map is unified, the host and the device application address the same byte of the buffer by the same address.
The device-side C API exposes each half as a set of typed arrays, for example
kC0HALInputBufferFloat and kC0HALOutputBufferFloat, together with the Uint8, Uint32,
Int32, and Double variants and a matching length constant for each array.
When you build a device application with ENABLE_DEBUG_LOGGING, the logging support in
C0Logger.h claims a 512-byte window at the top of the output half of the buffer and writes
log records into it. Keep your result data clear of that window in debug builds, and read
the records back with the C0_debug_logger.py script from the
Signaloid-Compute-Module-Utilities repository.
Communication model, a polled round trip
The module raises no interrupt toward the host, so the host discovers state changes by polling the read-only registers. A typical command round trip runs as follows.
- The host writes operand data into the input half of the MMIO buffer, starting at
0x08308000. - The host writes an application-defined operation code to the
COMMANDregister at0x08200000. - The device application reads
COMMAND, setsSTATUSat0x08208000to its calculating value, and processes the operands. - The device application writes its results into the output half of the MMIO buffer,
starting at
0x08300000, and setsSTATUSto its done value. - The host polls
STATUSuntil it reads the completion value. - The host reads the results from the output half of the buffer, then clears
COMMANDto acknowledge the round trip. The device application returns to its waiting for command state.
The Signaloid device-side headers define four conventional status values.
| Value | Name | Meaning |
|---|---|---|
0 | kSignaloidSoCStatusWaitingForCommand | Idle and ready to accept a new command. |
1 | kSignaloidSoCStatusCalculating | Processing the requested command. |
2 | kSignaloidSoCStatusDone | A result is available in the output buffer. |
3 | kSignaloidSoCStatusInvalidCommand | The requested command was not recognized. |
The values of COMMAND and STATUS are application-defined, but the round-trip mechanics
above are common to every C0-microSD+ application. Open the block device so that each polling
read bypasses the host page cache, because a poll loop over a buffered descriptor can spin on
a stale status value forever. On Linux, open /dev/sdX with O_DIRECT and keep every
transfer block-aligned. On macOS, use the raw /dev/rdiskN node. The Python host interface
does this for you. See
Using the Python Host Interface → Device Access Model.
Starting and stopping the core
The RISC-V core is released from reset and halted again through bit 0 (rstn) of the
CONFIG register. Setting the bit starts the core running the flashed application, and
clearing it stops the core. The C0_SD_toolkit.py script from the
Signaloid-Compute-Module-Utilities repository wraps both operations,
and it takes the device path as a positional argument before the subcommand.
# Start the core running the flashed application
sudo python3 C0_SD_toolkit.py /dev/disk4 config core-start
# Stop the core when the workload is complete
sudo python3 C0_SD_toolkit.py /dev/disk4 config core-stop
To reset the core, stop it, wait about one second, then start it again. C0_SD_toolkit.py
stops the core for you when it flashes, so stop it yourself only when you flash from your own
host code. A stopped core is also a precondition for unlocking the bitstream region, because
the hardware forces the unlock key to 0x00000000 while the core runs. A device application
can also halt itself by calling C0HALStopCore(). For the full procedure, see
Start and stop the core.
Power cycles and state
Every register in the module is volatile. The module powers up with the core held in reset and every register at its default value, so a power cycle stops your device application and discards the state that it held.
What a power cycle resets
| State | Survives a power cycle |
|---|---|
| Device application binary in the SPI flash | Yes |
| Data your application wrote to the SPI flash | Yes |
| FPGA bitstream | Yes |
Every CSR, including CONFIG, STATUS, and BITSTREAM_UNLOCK | No |
| Contents of the LRAM | No |
| Contents of the MMIO buffer | No |
Your device application restarts from the beginning with its variables reinitialized, rather
than resuming where it stopped. Because CONFIG returns to 0x00000000, a power cycle also
returns the LEDs to their default behavior, and because BITSTREAM_UNLOCK returns to
0x00000000, the bitstream region comes back locked.
The trap registers deserve a separate note. They survive stopping and starting the core, which is what lets you read them after the trap handler halts a crashed application, but a power cycle clears them to their reset values. Read them before you power-cycle a module that you are debugging.
Recovering after a power cycle
Bring the module back to a working state in three steps.
- Read the CSRs to confirm that the module is present and responding.
- Start the core, which the module left in reset.
- Write back any
CONFIGandSD_CONFIGvalues that your application relies on, because the module restored their defaults.
Change CONFIG by reading it, modifying the bits you need, and writing the whole value
back. See CONFIG register bits.
Hosts that power down an idle module
Some SD host controllers like laptop card readers power down a card that has been idle and then power it back up on the next transaction. Expect this if your host application leaves the module idle for long periods.
The module comes back with the core in reset, so your device application has stopped and its
state is gone. Detect this by reading the CONFIG register, which returns 0x00000000 after
a power-down.
To prevent the power-down, read from the module at least once per second while it is otherwise idle. A read of any CSR is enough to keep the host controller from cutting power.
Host application example
The example below runs on a macOS host with the module at /dev/disk4. It writes the value
0x00000001 to the COMMAND register. Because the address map is unified, the host seeks to
the same address that the device application uses to read the register.
#include <stdio.h>
#include <errno.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
int
main(void)
{
char * devicePath = "/dev/disk4";
uint32_t command = 0x00000001;
off_t commandRegisterAddress = 0x08200000;
int fd;
off_t seekResult;
ssize_t writeResult;
/*
* Open the C0-microSD+ device, committing writes synchronously.
*/
fd = open(devicePath, O_WRONLY | O_SYNC | O_DSYNC);
if (fd == -1)
{
perror("Error opening device");
return -1;
}
/*
* Seek to the address of the COMMAND register.
*/
seekResult = lseek(fd, commandRegisterAddress, SEEK_SET);
if (seekResult == (off_t)-1)
{
perror("Error seeking to target address");
close(fd);
return -1;
}
/*
* Write the COMMAND register data.
*/
writeResult = write(fd, &command, sizeof(uint32_t));
if (writeResult != sizeof(uint32_t))
{
perror("Error writing data to the device");
}
close(fd);
return 0;
}
Reading the STATUS register follows the same shape, with O_RDONLY in place of O_WRONLY
and a seek to 0x08208000. Header files and helper functions for building C and Python host
applications are in the Signaloid-Compute-Module-Utilities repository,
and the Python package is published as signaloid-utilities. For a complete worked round
trip, see Examples and Demos.
Next steps
- Using the Hardware Abstraction Layer, the C library that wraps these registers and buffers on the device side.
- Using the Python Host Interface, the package that wraps them on the host side.
- MMIO Interfacing Model, the model these registers and buffers implement.
- Using the C0_SD_toolkit, reading and writing the same registers from the command line.