Skip to main content

Runtime Loadable Procedures (RLP)

Runtime Loadable Procedures (RLP) call native ARM C/C++ code from a C# app — loaded onto the device at runtime, with no firmware rebuild. The C is compiled into a small ELF file, placed on the device (SD card, USB drive, or embedded resource), then functions are looked up by name and called from managed code.

Think of it as the embedded equivalent of calling a native DLL from desktop .NET — application logic stays in convenient, safe C#, and the parts that need raw speed drop into compiled native code.

Use RLP for raw speed or low-level work the managed APIs don't cover:

  • Fast math & scanning — tight compute loops (DSP, CRC, image/pixel processing, fast sensor sampling) run in native ARM, far faster than the interpreted C# loop.
  • Direct GPIO access — read, write, and toggle pins (with microsecond delays) from native code.
  • Lookup tables & persistent stateconst tables in .rodata, counters and state in .bss.
  • Periodic background tasks — schedule a native callback that runs on its own while C# does other work.
  • Native → managed events — post events from native code up to a C# event handler.
  • GPIO interrupts — handle pin-change interrupts entirely in native code (for example, mirror a button to an LED).

NuGet package: GHIElectronics.TinyCLR.RuntimeLoadableProcedures.

info

RLP is designed for computation and GPIO — fast native loops plus the GPIO, timing, task, and event helpers shown below. To read or write a specific peripheral register directly from C# instead, see Marshal.

RLP project template

The TinyCLR SDK ships a TinyCLR RLP Application project template. In Visual Studio, choose File → New → Project, search for "RLP", and pick it:

Selecting the TinyCLR RLP Application template in Visual Studio's New Project dialog

It's a complete, working solution — seven ready-to-run examples that show the whole feature without writing any code first:

ExampleWhat it shows
SpeedThe same 10,000-iteration loop in C# vs RLP, timed side by side
SumPure math — the simplest load → look-up → call
ToggleDrive a GPIO pin with microsecond delays
SquareLookupA const lookup table (.rodata) plus a static counter (.bss)
TaskA periodic native callback running in the background
EventNative code posting events back to a C# handler
ButtonA GPIO interrupt mirrored to an LED, entirely in native code

The template targets the SC20260 Dev Board by default (LED PH11, button input PD7, LED output PB0). For another SC20xxx board, edit the pin constants at the top of Program.cs.

Workflow

  1. Write the C in rlp_src/template_src.c.
  2. Double-click rlp_src/build.bat to compile it into rlp_src/app.elf. (Requires the Arm GNU toolchain arm-none-eabi-gcc 8.3.1 or newer at C:\gcc; edit GCC_ROOT in the script if the toolchain is installed elsewhere.)
  3. Get app.elf onto the device — copy it to an SD card, drop it on the device's USB drive, or embed it as a resource (see Loading the ELF).
  4. Deploy the C# app with F5.
tip

Changing only the C/C++ code? No need to rebuild the C# project. Just re-run build.bat to regenerate app.elf, then reload it onto the device — copy the new file to the SD card, drop it on the USB drive, or rebuild if it's an embedded resource. The C# app reads the fresh ELF at startup.

How it works

The ELF is just a byte[]. Hand the bytes to ElfImage, which loads the code and data into device memory and exposes exported functions by name:

using GHIElectronics.TinyCLR.RuntimeLoadableProcedures;

byte[] elfBytes = /* from SD card, USB, or resource — see below */;

var image = new RuntimeLoadableProcedures.ElfImage(elfBytes);
image.InitializeBssRegion(); // zero the static (.bss) data

var sum = image.FindFunction("Sum"); // look up by exported name
int result = sum.Invoke(3, 4); // call it → 7
sum.Dispose();

image.Dispose(); // unload when done

On the C side, every exported function has the same shape: it takes void** args (one slot per argument passed from C#) and returns an int. Mark each export with RLP_EXPORT so the linker keeps it:

#include "RLP.h"

RLP_EXPORT int Sum(void** args) {
int a = *(int*)args[0];
int b = *(int*)args[1];
return a + b;
}
note

Invoke accepts integers (any width, signed or unsigned), float, double, bool, and arrays of those. The first call fixes the argument count — later calls to the same function must pass the same number of arguments.

Examples

The C snippets below are from the template's rlp_src/template_src.c; the C# is from Program.cs.

Speed

The headline reason to use RLP. The same loop, run in interpreted C# and in native RLP, timed:

// template_src.c
RLP_EXPORT int Speed(void** args) {
int count = *(int*)args[0];
int total = 0;
for (int i = 0; i < count; i++)
total += i * i + i;
return total;
}
// Program.cs
const int N = 10000;

// Same loop in C#…
var t1 = DateTime.Now;
int csTotal = 0;
for (int i = 0; i < N; i++) csTotal += i * i + i;
var csMs = (DateTime.Now - t1).Ticks / 10000L;

// …and in RLP.
var speed = image.FindFunction("Speed");
var t2 = DateTime.Now;
int rlpTotal = speed.Invoke(N);
var rlpMs = (DateTime.Now - t2).Ticks / 10000L;
speed.Dispose();

Debug.WriteLine("C#: " + csMs + " ms, RLP: " + rlpMs + " ms"); // RLP is many times faster

GPIO

Open a pin, toggle it with a microsecond delay between transitions, then release it — all in native code:

// template_src.c — args: pin, count, delay (microseconds)
RLP_EXPORT int Toggle(void** args) {
uint32_t pin = *(uint32_t*)args[0];
uint32_t count = *(uint32_t*)args[1];
uint32_t delayUs = *(uint32_t*)args[2];

if (RLP_Gpio_EnableOutput(pin, 0) != 0) return -1; // -1: pin already in use

for (uint32_t i = 0; i < count; i++) {
RLP_Gpio_Write(pin, 1);
RLP_Time_DelayMicroseconds(delayUs);
RLP_Gpio_Write(pin, 0);
RLP_Time_DelayMicroseconds(delayUs);
}

RLP_Gpio_Release(pin);
return (int)count;
}
// Program.cs — pin number uses the TinyCLR encoding (port * 16 + index)
var toggle = image.FindFunction("Toggle");
toggle.Invoke((uint)SC20260.GpioPin.PH11, 10u, 200000u); // blink 10x, 200 ms each
toggle.Dispose();

Make sure C# isn't already holding the pin — Dispose() the managed GpioPin first so the native code can open it.

Tables & state

const data lands in .rodata; static variables land in .bss (zeroed by InitializeBssRegion()). Both survive across calls:

// template_src.c
static const uint32_t s_squares[16] = {
0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169, 196, 225
};
static int s_callCount; // lives in .bss

RLP_EXPORT int SquareLookup(void** args) {
int idx = *(int*)args[0];
s_callCount++;
if (idx < 0 || idx >= 16) return -1;
return (int)s_squares[idx];
}

RLP_EXPORT int GetCallCount(void** args) {
(void)args;
return s_callCount; // persists between Invoke calls
}
var lookup = image.FindFunction("SquareLookup");
var count = image.FindFunction("GetCallCount");

Debug.WriteLine("5² = " + lookup.Invoke(10)); // → 25
Debug.WriteLine("calls so far = " + count.Invoke());

lookup.Dispose();
count.Dispose();

Background task

Schedule a native callback that runs on its own. C# gets back an opaque handle and is free to do other work while the task fires:

// template_src.c
typedef struct {
RLP_Task task; // must be first
uint32_t pin, periodUs;
volatile uint32_t tickCount;
volatile uint8_t ledState;
} TaskState;

static void OnTaskTick(void* arg) {
TaskState* s = (TaskState*)arg;
s->tickCount++;
s->ledState ^= 1;
RLP_Gpio_Write(s->pin, s->ledState);
RLP_Task_ScheduleAfter(&s->task, s->periodUs); // re-arm
}

RLP_EXPORT int TaskStart(void** args) {
uint32_t pin = *(uint32_t*)args[0], periodUs = *(uint32_t*)args[1];
TaskState* s = (TaskState*)RLP_Memory_Allocate(sizeof(TaskState));
if (s == 0 || RLP_Gpio_EnableOutput(pin, 0) != 0) return 0;
s->pin = pin; s->periodUs = periodUs; s->tickCount = 0; s->ledState = 0;
RLP_Task_Initialize(&s->task, OnTaskTick, s);
RLP_Task_Schedule(&s->task);
return (int)s; // handle
}

RLP_EXPORT int TaskStop(void** args) {
TaskState* s = (TaskState*)*(uint32_t*)args[0];
if (s == 0) return -1;
RLP_Task_Abort(&s->task);
RLP_Gpio_Release(s->pin);
return (int)s->tickCount;
}
var start = image.FindFunction("TaskStart");
var stop = image.FindFunction("TaskStop");

int handle = start.Invoke((uint)SC20260.GpioPin.PH11, 100000u); // blink every 100 ms
Thread.Sleep(5000); // do other work…
int ticks = stop.Invoke(handle); // ~50 ticks
Debug.WriteLine("task fired " + ticks + " times");

start.Dispose();
stop.Dispose();

Events to C#

Native code can push events up to a managed handler with RLP_PostManagedEvent. The handler runs on a managed thread (never in interrupt context):

// template_src.c
RLP_EXPORT int EventFire(void** args) {
uint32_t data = *(uint32_t*)args[0];
RLP_PostManagedEvent(data); // delivered to the C# NativeEvent handler
return 0;
}
static void OnRlpEvent(uint data) {
Debug.WriteLine("RLP event: 0x" + data.ToString("X8"));
}

RuntimeLoadableProcedures.NativeEvent += OnRlpEvent;

var fire = image.FindFunction("EventFire");
fire.Invoke(0xCAFEBABEu); // native posts → OnRlpEvent runs
fire.Dispose();

The template also shows the asynchronous variant (EventStartPeriodic / EventStopPeriodic), where a background task posts a stream of events that the C# handler receives one by one.

GPIO interrupts

Wire a button to an LED entirely in native code. The ISR runs in completion-engine thread context (≈10 ms latency — fine for a human button), so it can safely call other RLP_* helpers:

// template_src.c
typedef struct { uint32_t btnPin, ledPin; } BtnState;

static void OnButtonChange(uint32_t pin, uint32_t state, void* param) {
BtnState* s = (BtnState*)param;
RLP_Gpio_Write(s->ledPin, state ? 0 : 1); // pull-up button: pressed (0) → LED on
}

RLP_EXPORT int BtnStart(void** args) {
uint32_t btnPin = *(uint32_t*)args[0], ledPin = *(uint32_t*)args[1];
BtnState* s = (BtnState*)RLP_Memory_Allocate(sizeof(BtnState));
if (s == 0) return 0;
s->btnPin = btnPin; s->ledPin = ledPin;
if (RLP_Gpio_EnableOutput(ledPin, 0) != 0) return 0;
RLP_Gpio_EnableInterruptInput(btnPin,
RLP_GPIO_PACK_EDGE_RESISTOR(RLP_GPIO_EDGE_BOTH, RLP_GPIO_RESISTOR_PULLUP),
OnButtonChange, (void*)s);
return (int)s;
}
var start = image.FindFunction("BtnStart");
var stop = image.FindFunction("BtnStop");

int handle = start.Invoke((uint)SC20260.GpioPin.PD7, (uint)SC20260.GpioPin.PB0);
// …button now drives the LED with no managed code involved…
stop.Invoke(handle);

start.Dispose();
stop.Dispose();

Loading the ELF

app.elf is just bytes — the device doesn't care where they come from. The template's LoadAppElf() helper shows three options; pick the one that fits the hardware:

  • (A) SD card (default) — copy app.elf to the card root, insert it, and read it with File.ReadAllBytes.
  • (B) USB mass storage — plug the device into a PC; it appears as a removable drive. Drop app.elf on it, mount UsbHostMassStorage on the device, and read the file.
  • (C) Embedded resource — add app.elf to the project as a binary resource and load it with Resources.GetBytes(...). No removable media required; the ELF ships inside the app.

Options (A) and (B) allow updating the native code without rebuilding the C# app — just replace the ELF file.

// (A) SD card
var sd = StorageController.FromName(SC20260.StorageController.SdCard);
var drive = FileSystem.Mount(sd.Hdc);
byte[] elfBytes = File.ReadAllBytes(new DriveInfo(drive.Name).Name + "app.elf");
warning

RLP is an advanced, native-code feature. The C runs directly on the CPU, so bugs in the logic — bad pointers, infinite loops, and the like — can hang the device. Keep RLP functions small and focused, and validate the arguments coming from C#.

API reference

NamespaceDescription
GHIElectronics.TinyCLR.RuntimeLoadableProceduresLoad ELF blobs, look up symbols, and call native functions