Eight Games, One Cartridge: Building a Multi-App Launcher for the Pimoroni PicoSystem
The Pimoroni PicoSystem is a lovely little RP2040 handheld — 240×240 LCD, a D-pad and four buttons, a buzzer, an RGB LED, and a fat 16 MB of flash. That last number nagged at me. A typical PicoSystem game is 150–250 KB. So every time you flash one, you light up about 1.5% of the chip and leave the rest dark. Why not keep a whole shelf of games resident and pick one from a menu, like an old multicart?
That’s the picosystem_launcher: a resident menu at the base of flash plus eight 1 MB app slots, chain-loading whichever game you choose. It works now — but the road there ran straight through the single nastiest class of embedded bug: the thing boots, and the screen stays black. This is the story of that bug and the trick that killed it.
The shape of the thing
The flash map is deliberately boring — boring is good when absolute addresses are baked into every image:
1// launcher_config.h — flash map (16 MB)
2// 0x10000000 bootloader (resident launcher) 512 KB
3// 0x10080000 slot 0 (app 1) 1 MB
4// ... slot n = 0x10080000 + n*0x100000
5// 0x10780000 slot 7 (app 8) 1 MB
6#define LAUNCHER_NUM_SLOTS 8u
7#define LAUNCHER_SLOT0_ORIGIN 0x10080000u
8#define LAUNCHER_SLOT_LENGTH 0x00100000u
The first design constraint is unavoidable: you cannot relocate a stock .uf2. A compiled Pico image has its addresses hard-wired, so a game built to boot at 0x10000000 simply won’t run from slot 3. Every app has to be recompiled to its slot. I didn’t want that to mean forking eight games, so the launcher ships a tiny SDK that ports any app with one include, one macro, one CMake line:
1#include "launcher_app.h"
2LAUNCHER_DECLARE_APP("World Cup", 1); // name + 'PAPP' magic, dropped into .app_meta
1launcher_app(my_game SLOT 1) # links the image to slot 1's address
The macro stamps a small metadata struct into a dedicated .app_meta section, and the linker script KEEPs it so it survives garbage collection:
1/* memmap_slot.ld.in — kept early so the launcher can find it by magic-scanning a slot */
2.app_meta : { KEEP(*(.app_meta)) } > FLASH
The menu then discovers what’s installed by scanning each slot for that magic number — no table to maintain, no registry to keep in sync:
1// Find the app metadata block by magic-scanning the slot.
2const launcher::AppMeta* find_meta(uint32_t base) {
3 for (uint32_t off = 0x100u; off < 0x40000u; off += 4u) {
4 const auto* m = reinterpret_cast<const launcher::AppMeta*>(base + off);
5 if (m->magic == launcher::APP_MAGIC && m->meta_version == 1 &&
6 m->name[0] >= 32 && m->name[0] < 127) { // sane, printable name
7 return m;
8 }
9 }
10 return nullptr;
11}
That’s the easy 80%. Now the fun part.
The bug: chain-loading from a running app leaves the hardware filthy
My first instinct was the obvious one: when the user presses A, read the chosen slot’s vector table, set the stack pointer and VTOR, and branch to its reset handler — all from inside the launcher’s update() loop.
On hardware: the launcher menu shows, I press A, and… the backlight glows on a blank screen. Every time.
The reason is subtle and, in hindsight, obvious. By the time my launcher is running its game loop, picosystem has fully initialised the display stack — the ST7789 LCD controller, the SPI peripheral driving it, the DMA channels that blast the framebuffer out, the PIO, the clocks. When I jump straight into the slot app, all of that is still live and configured. The new app calls picosystem’s _init_hardware() expecting cold silicon, tries to re-init a display controller that’s mid-conversation with a DMA engine it knows nothing about, and wedges. Black screen.
You can try to tear it all down by hand before jumping. People do. It’s a brittle game of whack-a-mole against peripherals you don’t own.
The trick: don’t jump in-process — reboot, and jump before anything wakes up
The clean fix is to stop trying to clean up, and instead never make the mess in the first place. The only state that’s guaranteed pristine is the state right after a chip reset. So:
- The menu doesn’t jump. It writes a “boot intent” into the watchdog scratch registers (which survive a reset) and reboots the chip.
- After the reset, a high-priority C++ constructor runs before picosystem’s
main()— before the display, DMA, or clocks are touched — reads the intent, and then does the jump. - The slot app boots from near-cold hardware, exactly as if it had been flashed standalone. Display comes up normally.
The watchdog scratch registers are the key — they’re a handful of 32-bit words that a watchdog_reboot() doesn’t clear, so they’re a perfect one-way mailbox across a reset:
1void launcher_request_slot(uint32_t slot) {
2 watchdog_hw->scratch[0] = launcher::INTENT_MAGIC; // "a launch is pending"
3 watchdog_hw->scratch[1] = launcher::intent_for_slot(slot); // slot n encoded as n+1
4 watchdog_reboot(0, 0, 0); // reboot -> boot_check jumps
5 for (;;) { } // not reached
6}
The magic is making the decision early enough. GCC lets you order static constructors by priority, and picosystem’s hardware init happens later, from main() — not from a constructor. So a constructor with priority 101 is guaranteed to run first:
1void launcher_boot_check() {
2 if (watchdog_hw->scratch[0] != launcher::INTENT_MAGIC) return; // cold boot -> show menu
3 uint32_t cmd = watchdog_hw->scratch[1];
4 watchdog_hw->scratch[0] = 0; // consume intent
5 if (cmd == launcher::INTENT_SHOW_MENU) return; // app asked for the menu
6 uint32_t slot = cmd - 1u;
7 if (slot >= LAUNCHER_NUM_SLOTS) return;
8 uint32_t base = launcher_slot_base(slot);
9 if (!launcher_slot_valid(base)) return; // empty/corrupt -> menu
10 launcher_jump_to(base); // never returns
11}
12
13// Priority 101 = ahead of any default-priority ctor the runtime/picosystem registers.
14__attribute__((constructor(101)))
15static void launcher_early_boot_ctor() { launcher_boot_check(); }
A nice side-effect: this also gives you “return to launcher” for free. An app just writes scratch[1] = 0 (show-menu) and reboots; boot_check sees the menu intent and falls through to the UI. In fact I ended up deciding the apps don’t need an in-app “back” button at all — a power-cycle lands on the menu instantly from cold boot, which is the most robust “back” gesture imaginable.
Before any jump, a quick sanity check that the slot actually holds a plausible image — a sane initial stack pointer in RAM and a reset vector that points into the slot with the Thumb bit set:
1bool launcher_slot_valid(uint32_t slot_base) {
2 uint32_t vt = slot_base + 0x100u; // vectors sit after the 256-byte boot2
3 uint32_t sp = rd(vt + 0u), pc = rd(vt + 4u);
4 bool sp_ok = (sp >= 0x20000000u) && (sp <= 0x20042000u); // in RAM
5 bool pc_ok = (pc >= slot_base) && (pc < slot_base + 0x00100000u) && (pc & 1u); // thumb, in slot
6 return sp_ok && pc_ok;
7}
The second bug: the good fix had a sting in the tail
I rebuilt everything around the reboot protocol, flashed it, and… still blank. Plus a red LED I hadn’t asked for.
Two diagnoses, both instructive:
- The red LED was a red herring — literally. It’s the hardware charge LED on GP2, lit whenever the thing’s on USB power. Nothing to do with my firmware. (I’d been about to debug a “failure” that was a battery indicator.)
- The real bug was a classic. My
launcher_jump_tomasked interrupts withcpsid ito do the relocation safely… and never turned them back on. But a real cold boot starts withPRIMASK = 0(interrupts enabled), and picosystem’s screen_flip()blocks waiting on the DMA-complete interrupt. With IRQs globally masked, the slot app sailed into display init and hung forever waiting for an ISR that could never fire. Blank screen — but this time because the app was alive and stuck, not dead.
The fix is to make the jump hand over hardware in exactly the state a cold boot would: globally enable interrupts (cpsie i) right before branching, while making sure every individual NVIC line is disabled so nothing actually fires until the new app arms its own:
1void launcher_jump_to(uint32_t slot_base) {
2 uint32_t vt = slot_base + 0x100u;
3 uint32_t sp = rd(vt + 0u), pc = rd(vt + 4u);
4
5 __asm volatile("cpsid i" ::: "memory"); // mask while we relocate
6 wr(0xE000E010u,0); wr(0xE000E014u,0); wr(0xE000E018u,0); // stop SysTick
7 wr(0xE000E180u, 0xFFFFFFFFu); // NVIC ICER: disable all IRQs
8 wr(0xE000E280u, 0xFFFFFFFFu); // NVIC ICPR: clear all pending
9
10 // Re-assert reset on (almost) every peripheral so the slot app starts clean —
11 // keeping only what we need to keep executing from flash and stay flashable.
12 const uint32_t RESETS = 0x4000c000u;
13 const uint32_t keep = (1u<<6)|(1u<<9)|(1u<<12)|(1u<<13)|(1u<<18)|(1u<<24);
14 wr(RESETS + 0x2000u, (~keep) & 0x01FFFFFFu);
15
16 wr(0xE000ED08u, vt); // SCB->VTOR = app's vector table
17 __asm volatile(
18 "msr msp, %0\n" // app stack pointer
19 "cpsie i\n" // PRIMASK=0 — match a cold boot!
20 "bx %1\n" // branch to the app's reset handler
21 : : "r"(sp), "r"(pc) : "memory");
22 for (;;) { } // unreachable
23}
Note the keep mask: I re-assert reset on nearly every peripheral block to hand the app clean silicon, but I deliberately don’t reset the QSPI flash interface, the system PLLs, SYSCFG, or USB — reset those and you’d yank the floor out from under the code that’s currently executing from flash, or lose the ability to re-flash the board.
To debug all this without a working screen, the test app blinks the green RGB LED three times via raw GPIO from a constructor — before display init runs. That gave me a screen-independent signal: green flashes = “the slot app booted, it’s a display problem”; no green = “the jump or boot failed.” That one diagnostic is what turned “it’s blank, somewhere” into “it’s hung in _flip() on the DMA ISR.”
After that: menu → A → the test app’s green “OK” screen, and a power-cycle drops me right back on the menu. Six games now live in their slots — Winbledon, World Cup, Pomodoro, a Mandelbrot explorer, 2048, and my chess clock — each just LAUNCHER_DECLARE_APP(...) + a SLOT n build.
What I took away
- The cleanest way to reset peripheral state is to actually reset the chip. A watchdog reboot plus a pre-
main()constructor beats hand-rolling teardown of someone else’s display driver. - Carry intent through the reset, not through RAM. The watchdog scratch registers are a tiny, reset-surviving mailbox that’s perfect for “do X on next boot.”
- Match the cold-boot machine state precisely — including
PRIMASK. “Disable interrupts to be safe” is exactly the instinct that hangs a driver waiting on an ISR. - Get a debug signal that doesn’t depend on the thing you’re debugging. A GPIO LED blink saved hours when the screen — the thing I was trying to fix — couldn’t tell me anything.
Next time: a write-up on the World Cup game design — the seeded-knockout engine, the tiny software renderer and flag tables, and how the same foundation got reskinned into a Wimbledon-themed Pong. I’ll dig into how the match sim and the difficulty-by-seed-gap idea actually work. Stay tuned. ⚽