Build a Package

RPCortex v2 — a package is C, compiled, loaded at runtime

A package is a compiled .app file. The OS loads it, relocates it, runs its app_main, and the commands it registers are live immediately — no reboot. It is real machine code, not a script, and on RP2350 it runs unprivileged.

Everything below needs the RPCortex v2 source tree and an ARM cross-compiler. ./build.sh fetches the SDK the first time it runs.

The shortest one that does anything

os/apps/greet/greet.cpp
#include "rpc_app.h"

RPC_APP_VER("greet", "1.0");

static int cmd_greet(int argc, char **argv) {
    fw_printf("hello from a package\n");
    return 0;
}

extern "C" int app_main(int arg) {
    (void)arg;
    rpc_register_command("greet", "say hello", cmd_greet);
    return 0;
}

Three things are load-bearing:

A command takes (int argc, char **argv) and returns 0 for success, exactly like main. argv[0] is the command name.

Building it

Put the source at os/apps/<name>/<name>.cpp and add one line to os/CMakeLists.txt:

os/CMakeLists.txt
rpc_add_app(greet)

Then ./build.sh. The .app lands in os/build_<board>/apps/greet.app.

The build checks every symbol resolves. A package calling something the firmware does not export would otherwise load fine and fail at the call, reporting a function nobody wrote. That check runs at build time instead.

Getting it onto a device

Three ways, in order of how often they get used.

Over the serial console

on your computer
tools/rpc-push.sh os/build_pico2_w/apps/greet.app /dev/ttyACM0

Then pkg install /greet.app on the device. The script uses the device's own put command, so nothing else needs installing — but it does need a logged-in admin session, because writing files does.

Over USB, by dragging it

Run download on the device, drop the .app onto the drive that appears, press a key, then pkg install /usb/greet.app.

From the browser

The Packages page connects over Web Serial and installs with one click, including packages you drag onto it from your own machine.

What a package can call

Everything comes through fw_* functions declared in os/include/rpc_app.h, which is the only header a package includes. There are around 166 of them:

AreaExamples
Consolefw_printf, fw_log, fw_getchar
Memoryfw_malloc, fw_free, fw_heap_free
Filesfw_file_read, fw_file_write, fw_file_size, fw_dir_open
Tasksfw_task_spawn, fw_task_sleep_ms, fw_task_should_stop
Networkfw_net_connected, fw_net_resolve, fw_http_get, fw_tcp_listen
Hardwarefw_gpio_*, fw_i2c_*, fw_spi_*, fw_pio_*, fw_adc_read
Screenfw_tui_* — a full-screen drawing surface
The shellfw_shell_run — run a command line and capture its output
An ABI call is not free. A package cannot branch into the firmware — flash is unreachable from unprivileged code — so every fw_* call is a supervisor call underneath, measured at about 300 cycles. The SDK builds that for you and it never needs thinking about, except in a tight loop, where hoisting the call out is worth it.

Running a shell command

A package can run anything the shell can and read back what it printed — pipes, chaining and redirection included, because it is the same runner that handles a typed line:

inside a command
static char out[512];

int rc = fw_shell_run("df", out, sizeof(out));
if (rc == 0) fw_printf("%s", out);

fw_shell_run("ntp sync", NULL, 0);   // for its effect, output ignored
It runs with the session's privileges, exactly as if the logged-in user had typed it — not the package's. A guest session cannot use a package to reach admin-only commands.

The four rules

  1. No standard library. No printf, no malloc, no <string.h>. Use the fw_* equivalents. This is what keeps a package a few kilobytes.
  2. Keep big buffers off the stack. A package gets its own stack, and firmware called through the ABI runs on it — so a large local competes with whatever the firmware needs underneath. File-scope static is the answer.
  3. Yield in long loops. Anything that runs for a while should call an ABI function periodically; that is what feeds the watchdog and lets Ctrl+C through. fw_task_should_stop() is the polite way to notice a kill.
  4. Check what you are given. argc can be 1. A file can be missing. The firmware refuses bad pointers, but it will not invent an argument you did not check for.

What the sandbox does

On RP2350 a package runs unprivileged, with the memory protection unit describing exactly five regions it may touch: its code, its data, its call veneers, its stack and its heap. Everything else is denied — the OS's tables, the peripherals, and flash.

Every pointer a package hands the firmware is range-checked against those regions. A package cannot ask the OS to read or write memory on its behalf that it could not touch itself.

The practical effect while developing: a bug costs you the command, not the device. A bad pointer produces a report naming the package and the offset, and the shell carries on. So does a loop that never returns, and so does running out of stack. On RP2040 there are not enough protection regions to spare, so packages run with the OS's privileges — test on a Pico 2 if you can.

pkg install havoc then havoc runs 62 checks that the sandbox refuses everything it should. It is worth reading its source if you are writing something that pushes at the ABI.

Full-screen packages

fw_tui_* gives a package the same drawing surface the built-in apps use: a character grid with attributes, a diffing renderer that only sends what changed, and key events including the arrows. tuidemo in the repository is the worked example — lists, panels and input handling in about two hundred lines.

The grid lives in the firmware rather than the package, so a package that crashes mid-draw cannot leave the terminal in a state nothing can recover.

Getting it listed

The official repository is RPCortex-repo. Open a pull request adding the built .app to repo-v2/packages/ and an entry to repo-v2/index.json:

repo-v2/index.json
{
  "name": "greet",
  "ver": "1.0",
  "desc": "Worked example package - registers a 'greet' shell command",
  "kind": "app",
  "author": "your-name",
  "abi": "1.16",
  "arch": "armv6m",
  "size": 1732,
  "sha256": "...",
  "url": "https://raw.githubusercontent.com/dash1101/RPCortex-repo/main/repo-v2/packages/greet.app"
}

The size and hash have to match the file. A device refuses anything whose hash disagrees, and that presents as a corrupt download rather than as a wrong manifest.

arch is armv6m for a package built to run on every board — ARMv6-M code runs on RP2350 too, and the SDK builds packages that way by default, so one file serves all four.

Coming from v1

A v1 package is Python source in a ZIP renamed .pkg. There is no Python in v2, so it needs rewriting rather than converting. The shape carries over — register a command, do a thing, return — and the fw_* calls line up closely with what the v1 API offered.

The v1 guide is still here for devices still running it.

Questions, or something here that does not match the source? Open an issue or ask in Discord.