Skip to main content

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.

note

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.

RegionAddress rangeSize
SPI flash, bitstream0x000000000x001FFFFC2 MiB
SPI flash, user data0x002000000x00FFFFFC14 MiB
Flash OTP0x080000000x080001FC512 bytes
LRAM, main memory0x081000000x0814FFFC320 KiB
CSR registers0x082000000x08214003See the register table
MMIO buffer0x083000000x0830FFFC64 KiB
DMA registers0x100000000x1000001C32 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.

danger

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.

note

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.

warning

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.

RegisterAddressSizeWritten byRead byReset value
COMMAND0x082000004 bytesHostDevice0x00000000
CONFIG0x082040004 bytesHost, deviceHost, device0x00000000
STATUS0x082080004 bytesDeviceHost0x00000000
SD_CONFIG0x0820C0004 bytesHostHost0x00000001
TRAP_MCAUSE0x082100004 bytesTrap handlerHost, device0xFFFFFFFF
TRAP_MEPC0x082100044 bytesTrap handlerHost, device0x00000000
TRAP_MTVAL0x082100084 bytesTrap handlerHost, device0x00000000
BITSTREAM_UNLOCK0x082140004 bytesHostHost0x00000000
  • COMMAND carries 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.
  • CONFIG carries the core reset, LED, and debug pin controls. The bit assignments are in CONFIG register bits.
  • STATUS carries an application-defined status value from the device application back to the host. The device application writes it, and the host polls it.
  • SD_CONFIG controls 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_UNLOCK gates writes to the bitstream region of the SPI flash. Write the ASCII key UNLK to 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.

note

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_MCAUSEMeaning
0Instruction address misaligned
1Instruction access fault
2Illegal instruction
3Breakpoint
4Load address misaligned
5Load access fault
6Store or AMO address misaligned
7Store or AMO access fault
11Environment 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

BitNameUsage
0rstnCore reset, active low. Set to 1 to run the core.
1ReservedUnassigned. Has no effect; write as 0.
2swLedEnableHand LED control to software.
3swLedSoftware-controlled LED state.
4redLedOn-board red LED.
5greenLedOn-board green LED.
6blueLedOn-board blue LED.
7debugPin0Debug pin 0 output state.
8debugPin1Debug pin 1 output state.
9debugPin2Debug pin 2 output state.
31:10ReservedWrite 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 valueEffect
0x4B4C4E55, the ASCII bytes UNLKThe region accepts writes.
Any other value, including 0x00000000The 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.

HalfDirectionAddress rangeSizeDevice operation
Output (MISO)Device to host0x083000000x08307FFC32 KiBW
Input (MOSI)Host to device0x083080000x0830FFFC32 KiBR
  • 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.

note

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.

  1. The host writes operand data into the input half of the MMIO buffer, starting at 0x08308000.
  2. The host writes an application-defined operation code to the COMMAND register at 0x08200000.
  3. The device application reads COMMAND, sets STATUS at 0x08208000 to its calculating value, and processes the operands.
  4. The device application writes its results into the output half of the MMIO buffer, starting at 0x08300000, and sets STATUS to its done value.
  5. The host polls STATUS until it reads the completion value.
  6. The host reads the results from the output half of the buffer, then clears COMMAND to acknowledge the round trip. The device application returns to its waiting for command state.

The Signaloid device-side headers define four conventional status values.

ValueNameMeaning
0kSignaloidSoCStatusWaitingForCommandIdle and ready to accept a new command.
1kSignaloidSoCStatusCalculatingProcessing the requested command.
2kSignaloidSoCStatusDoneA result is available in the output buffer.
3kSignaloidSoCStatusInvalidCommandThe 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

StateSurvives a power cycle
Device application binary in the SPI flashYes
Data your application wrote to the SPI flashYes
FPGA bitstreamYes
Every CSR, including CONFIG, STATUS, and BITSTREAM_UNLOCKNo
Contents of the LRAMNo
Contents of the MMIO bufferNo

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.

  1. Read the CSRs to confirm that the module is present and responding.
  2. Start the core, which the module left in reset.
  3. Write back any CONFIG and SD_CONFIG values 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