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.
./build.sh fetches the SDK the
first time it runs.
The shortest one that does anything
#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:
RPC_APP_VER(name, version)stamps a header the loader checks. Without it the file is not a package.app_mainruns once, when the package loads. Register commands here and return — it is not a place to loop.rpc_register_command(name, help, fn)is what puts a word in the shell. The help string is whathelp allshows.
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:
rpc_add_app(greet)
Then ./build.sh. The .app lands in
os/build_<board>/apps/greet.app.
Getting it onto a device
Three ways, in order of how often they get used.
Over the serial console
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:
| Area | Examples |
|---|---|
| Console | fw_printf, fw_log, fw_getchar |
| Memory | fw_malloc, fw_free, fw_heap_free |
| Files | fw_file_read, fw_file_write, fw_file_size, fw_dir_open |
| Tasks | fw_task_spawn, fw_task_sleep_ms, fw_task_should_stop |
| Network | fw_net_connected, fw_net_resolve, fw_http_get, fw_tcp_listen |
| Hardware | fw_gpio_*, fw_i2c_*, fw_spi_*, fw_pio_*, fw_adc_read |
| Screen | fw_tui_* — a full-screen drawing surface |
| The shell | fw_shell_run — run a command line and capture its output |
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:
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
The four rules
- No standard library. No
printf, nomalloc, no<string.h>. Use thefw_*equivalents. This is what keeps a package a few kilobytes. - 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
staticis the answer. - 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 akill. - Check what you are given.
argccan 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:
{
"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.