Writing a Kernel in C

Writing a Kernel in C

Runs on: the Sparsr VM (SPARSR_BACKEND=vm). A C kernel is RV32I and cannot run on Sparsr FPGA hardware today — the hardware decodes the older MIPS instruction set. To target hardware, write the kernel in Sparsr Assembly instead.

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 shipped with the SDK.

A Sparsr kernel written in C is ordinary C. There is no framework, no runtime, and no special 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 4096-bit vectors out of co-processor memory, exclusive-ORs them, and writes the result back. Three 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 CMEM 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 */

If you need a row chosen at run time, use the register-indirect form _sparsr_wlr / _sparsr_wsr, which 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 ships no compiler — it ships three files that 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 ship 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 in order 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 ships everything — so a build that would drop your lookup table fails at link time with a message, instead of computing the wrong answer.

Where your data lives

A kernel has three separate memories, and none of them is the host's RAM.

Memory Size Holds
IMEM 1,024 words (4 KiB) your compiled kernel
DMEM 1,024 words (4 KiB) your globals, your stack, scalar data
CMEM 32 slots (7,680 bytes) 4096-bit wide vectors, stored compressed

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.

One surprise worth knowing: a static does not survive between batches. 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, so anything that must outlive one belongs in CMEM, a wide register, or a DMEM word the host owns.

If your host writes operands to the bottom of DMEM, move your globals out of the way:

-Wl,--defsym=__bss_origin=0x80000100

Registers you have

  • 32 scalar registers — ordinary RV32I, used by the compiler as usual.
  • 32 wide registers, SPARSR_W0 to SPARSR_W31, 4096 bits each.

What the intrinsics give you

Every one compiles to a single 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 CMEM 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, 4096 bits 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_exit(status) End the batch early

Running it

A C kernel is RV32I, so it needs the Sparsr VM. The default backend runs a different instruction set and would read your image as something else entirely.

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 that catches people

A CMEM row holds 128 lanes of 32 bits, stored compressed, and at most 48 of those lanes may be non-zero. That is a property of the compression format, not of your kernel. See Memory Model for the format and what happens if you exceed it.