Writing a Kernel in C
Runs on: the Sparsr VM (
SPARSR_BACKEND=vm). Write your kernel against the VM, and assume it does not run on a card yet. As of September 2026 the FPGA decodes the same wide encoding this page uses, but only part of it, and its scalar decoder covers a subset of RV32I that leaves out branches, jumps andlui. A compiler emits all three, so a C kernel of any size needs the VM today. The Instruction Set page has the exact support, instruction by instruction. Everything on this page keeps working as the hardware catches up.How to get it: everything on this page is in the Sparsr SDK download. You also need a RISC-V bare-metal GCC, which is not included in the SDK.
A Sparsr kernel written in C is ordinary C. You write it against the compiler you already have, and the ordinary build step is the whole build step.
The shortest complete kernel
#include "sparsr_intrinsics.h"
#define ROW_A 1
#define ROW_B 2
#define ROW_RESULT 3
void kernel_main(void) {
_sparsr_wl(SPARSR_W1, ROW_A);
_sparsr_wl(SPARSR_W2, ROW_B);
_sparsr_wxor(SPARSR_W3, SPARSR_W1, SPARSR_W2);
_sparsr_ws(SPARSR_W3, ROW_RESULT);
}
That reads two wide vectors out of wide memory, exclusive-ORs them, and writes the result back. The whole thing is four wide instructions.
The complete example, with the host program that feeds it, is in the SDK at
examples/03_kernel_in_c/. Build and run it with make run from that directory.
Three rules, and that is all of them
1. Your entry point is a function called kernel_main. Not main. The device sets up
the stack pointer and the return address before it starts your kernel, exactly as any caller
sets them up before a function call, so kernel_main is a normal C function. Returning from
it ends the batch and reports success.
To stop early, or to report a failure the host should see:
_sparsr_exit(1); /* 0 means success, by the usual convention */
2. The wide intrinsics are macros, and their register and row numbers must be constants.
_sparsr_wl(SPARSR_W1, ROW_A) compiles to exactly one instruction. The wide register number
and the WMEM row are fields inside the instruction word, with no register behind them, so
they cannot come from a variable.
_sparsr_wl(SPARSR_W1, 3); /* fine */
int row = get_row();
_sparsr_wl(SPARSR_W1, row); /* compiler error, and it names the rule */
For a row chosen at run time, reach for the register-indirect form _sparsr_wlr /
_sparsr_wsr. It takes the base address in an ordinary register.
3. There is no floating point. The header poisons float and double, so using one is a
compiler error rather than a wrong answer later.
Building it
The kernel is compiled by stock RISC-V GCC. Sparsr does not provide a compiler of its own, only the three files GCC needs.
riscv64-unknown-elf-gcc \
-march=rv32i -mabi=ilp32 -O2 \
-ffreestanding -nostdlib -nostartfiles -ffunction-sections -fno-pie -no-pie \
-I<sdk>/include \
-T<sdk>/ldscripts/sparsr.ld \
-Wl,--defsym=__sparsr_loads_every_segment=1 \
<sdk>/startup/sparsr_crt0.S kernel.c -o kernel.spex
On Debian or Ubuntu the toolchain is one command:
sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf
Each unusual flag is doing a specific job:
| Flag | Why |
|---|---|
-march=rv32i -mabi=ilp32 |
The device is RV32I. Any other architecture is a compile error. |
-nostdlib -nostartfiles |
Drop the C library and GCC's startup files. sparsr_crt0.S replaces them and is far smaller. |
-ffunction-sections |
Gives kernel_main its own section, so the link script can place it first. |
-T .../sparsr.ld |
The link script. It puts the entry point at address 0 and assigns memory regions. |
-Wl,--defsym=__sparsr_loads_every_segment=1 |
Your promise that you deliver the linked file whole. See below. |
The linked ELF is the kernel image. There is no packaging step and no objcopy. Its
program headers already say which bytes go to instruction memory and which to data memory,
which is exactly what the host needs to place both.
That is what __sparsr_loads_every_segment is about. If your build ends with
objcopy -O binary -j .text, it takes the code and silently throws your initialised data
away. The link script refuses to produce such an image unless the build declares it delivers
everything - so a build that would drop your lookup table fails at link time with a message,
instead of computing the wrong answer.
IMEM, DMEM and WMEM
A kernel has three separate memories, and none of them is the host's RAM.
| Memory | Size | Holds |
|---|---|---|
| IMEM | 64 KiB on the Sparsr VM (4 KiB on today's hardware) | your compiled kernel |
| DMEM | 64 KiB on the Sparsr VM (4 KiB on today's hardware) | your globals, your stack, scalar data |
| WMEM | 4,096 rows through the host library, 15 on a standalone sparsr-vm serve |
one whole wide vector per row |
Globals work the way you expect. .bss is cleared, and .data and .rodata are carried in
the image and placed for you - so a lookup table, a string literal, or an initialised global
all work.
A static does not survive between batches, which surprises people. The startup file
clears .bss at the start of every batch, so a zero-initialised global is zero again each
time. The device is deliberately not reset between batches. Anything that must last beyond one
batch belongs in WMEM or a wide register. A DMEM word the host controls also works.
If your host writes operands to the bottom of DMEM, move your globals out of the way:
-Wl,--defsym=__bss_origin=0x80000100
Keeping results in wide memory
WMEM rows have byte addresses, so plain C can read and write them.
_sparsr_wmem_row(n) returns a uint32_t * to the lanes of row n. Lane 0 is the row's
first four bytes.
#define ROW_INPUT 4
#define ROW_RESULT 0
void kernel_main(void) {
_sparsr_wl(SPARSR_W1, ROW_INPUT);
_sparsr_wmem_row(ROW_RESULT)[0] = _sparsr_wpopcount(SPARSR_W1);
}
The host reads the count back with sparsr_read_data_wmem(ROW_RESULT). The count stays in
WMEM, so DMEM keeps its room for your stack and globals.
Row 0 has the same address at every width. Other rows move with SPARSR_WIDE_BITS, so build
the kernel for the width of the device it runs on.
The complete program is in the SDK at examples/04_kernel_row_result/. See
Memory Model for the address map.
Registers you have
- 32 scalar registers - ordinary RV32I, used by the compiler as usual.
- 32 wide registers,
SPARSR_W0toSPARSR_W31, ofSPARSR_WIDE_BITSbits each: 8,192 on the Sparsr VM and on the hardware, and 4,096 when a host program reaches the VM through the host library. Build with-DSPARSR_WIDE_BITS=4096when your kernel sizes anything by the width.SPARSR_WIDE_BYTESandSPARSR_WIDE_LANESfollow from it.
The intrinsics, one instruction each
Every one compiles to one instruction. The full list is in sparsr_intrinsics.h; these
are the ones most kernels use.
| Intrinsic | Does |
|---|---|
_sparsr_wl(wd, row) / _sparsr_ws(ws, row) |
Load / store a wide vector at a fixed WMEM row |
_sparsr_wlr(wd, base, disp) / _sparsr_wsr(...) |
The same, with the row computed at run time |
_sparsr_wand, _sparsr_wor, _sparsr_wxor, _sparsr_wnor, _sparsr_wxnor, _sparsr_wandn, _sparsr_wnot |
Bitwise, a whole register at a time |
_sparsr_wpopcount(ws) |
Population count into a scalar register |
_sparsr_whamming(a, b) |
popcount(a XOR b) - one instruction |
_sparsr_woverlap(a, b) |
popcount(a AND b) - one instruction |
_sparsr_wany(ws) / _sparsr_wall(ws) |
OR-reduce / AND-reduce to a scalar |
_sparsr_wrnd(wd) |
Fill a wide register with random bits |
_sparsr_wcsa(wsum, wcarry, a, b) |
Carry-save add. The one instruction that writes two wide registers |
_sparsr_wreduce(f, mode, a, b) |
Any ALU function with any reduction mode, when the rows above do not cover what you want |
_sparsr_exit(status) |
End the batch early |
A wide load or store moves one whole row, and a row stores one whole wide vector. You
never have to track a second view of the same bytes. The older names _sparsr_wlu,
_sparsr_wsu, _sparsr_wlur and _sparsr_wsur still compile, and each one now means the
same as the plain load or store above.
Running it
A C kernel is RV32I, so it needs the Sparsr VM. Name it:
SPARSR_BACKEND=vm ./my_host_app
Or run the kernel on its own, with no host program at all:
sparsr-vm run kernel.spex --trace
See Sparsr VM for what that gives you.
A limit on the way in, not on the row
A WMEM row stores one whole wide vector at any density. The limit is on one of the two ways the host puts a vector there.
sparsr_write_data_wmem()compresses the vector on the way to the device, so it refuses one with more than 48 non-zero 32-bit lanes. The call rejects the vector and reports an error, and the row keeps what it had.sparsr_write_data_wmem_raw()sends the whole vector, at any density.
Through the host library, a kernel's own wide store is held to the same 48 lanes. That keeps working the libraries that detect a too-dense result by the store failing. Your host writes a dense vector with the raw call above. See Memory Model.