Porting Guide: Adding a New MCU to picodroid
This guide explains how picodroid is split between shared code and a chip
family, and everything a new family has to provide. The list below is also
in the code: picodroid_core::porting re-exports every item a port
implements, and a test fails if that module or this page stops naming one.
Architecture overview
Chip-specific code lives under platforms/<family>/, one cargo crate per
family. Everything that does not know what chip it is running on — the JVM
natives, the widget set, the LVGL engine, the lifecycle, the simulator, the
debug bridge protocol, the installer, the filesystem — lives in
crates/picodroid-core/. The boundary between them is a set of Rust traits and
macros a family implements, bound at link time.
crates/picodroid-core/ # everything shared, including the simulator src/porting.rs # the checklist: re-exports every seam item src/hal/ # HAL CONTRACT v2 traits, facade, set_hal_*! macros src/hal/sim/ # the simulator (shared, never copied per family) src/rtos/ # the Rtos trait (crates/pd-rtos, re-exported); rtos/freertos.rs for FreeRTOS families src/host.rs # PlatformHooks src/pdb/, src/install/ # debug bridge and installer (crates/pd-install), generic over your impls src/packages.rs # the package directory: what is installed, what boots, what runs next src/fs/ # LittleFS behind the `littlefs` featureplatforms/ rp/ # RP2040 + RP2350: the reference implementation boards/<board>/board.toml mcus/rp/<chip>.toml, FreeRTOSConfig.h, linker scripts src/glue.rs # the one file that binds core's seams to this family src/hal/mod.rs # routes `mod chip` between rp/ and the shared simulator src/hal/contract.rs # assertions for the two things no trait covers (boot, flash) src/hal/rp/ # this family's peripheral drivers src/boot_tasks.rs # task topology and the JVM supervisor loop <your-family>/ # your new MCU family, same shapeFamily vs. board
The family crate exposes chip-level capability. Per-board configuration —
display controller, touch controller, buttons, sensors, network — lives in
boards/<board>/board.toml and mcus/<family>/<chip>.toml. A new board on
an already-supported family needs only new TOML entries.
What a port provides
Seven kinds of thing, and no edits to picodroid-core. Each is described
in its own section below; picodroid_core::porting is the canonical list.
- The hardware layer — trait impls plus a registration macro.
- The kernel — an
Rtosimpl plusset_rtos!. - The hooks — a
PlatformHooksimpl plusset_platform_hooks!, and a GC-root registration file. - The filesystem — a backing store for core’s LittleFS, or your own
HalFs. - The debug bridge and installer (optional) — four small traits handed
to
run_pdb_task. - The simulator — one
register_sim_platform!call and onedeclare_sim_global_allocator!call. - Boot, data and discipline — a
main.rs, abuild.rs,board.toml, and a JVM supervisor loop that meets a short checklist.
You get the simulator, the widget set, the LVGL engine, the lifecycle, the JVM natives, the debug bridge protocol, the installer, sensor plumbing and every registry guard for free.
Do not copy hal/sim/
The simulator lives in crates/picodroid-core/src/hal/sim/ and is shared by every
family. Your src/hal/mod.rs routes to it in simulator and test builds (see
HAL dispatch); you write no simulator code. An earlier
revision of this guide said to copy it; the ESP32-S3 scaffold did, grew
seventeen stub modules that drifted from the originals, and was removed.
scripts/pre-commit now fails if any path exists under both
platforms/*/src and crates/picodroid-core/src, apart from four allowlisted seam
pairs (gc_root_registration.rs, hal/mod.rs, pdb/mod.rs, fs/mod.rs),
each of which is one name for two ends of one seam, not a copy.
Your crate must not drive the JVM
Hand off to picodroid_core::boot::run_app(image) — the image the package
directory chose — and let core own every Jvm. A family crate that constructs its own Jvm gets the whole
interpreter monomorphised a second time — measured at ~38 KB, which
overflowed the RP2040 flash ceiling. LTO does not rescue this.
1. The hardware layer (HAL CONTRACT v2)
The contract is the trait set in picodroid_core::hal. Implement each for
one type and register with set_hal!, which emits the #[no_mangle] shims
core’s facade binds to; a signature that drifts fails to compile at your
impl. The RP family implements them in glue.rs by delegating to free
functions in hal/rp/, but that is a convention, not a requirement — the
traits are the whole contract.
| Trait | Methods | Notes |
|---|---|---|
HalDisplay | 8 + 4 defaulted | init, window and pixel push, backlight, sleep/wake, update_window, is_window_open. Geometry comes from board_cfg::display, not from you. write_pixels_start / write_pixels_wait are the asynchronous flush a board with draw_buffers = 2 uses; they default to the synchronous write_pixels and a no-op. set_vertical_scroll_area / set_vertical_scroll_start (ST7796 VSCRDEF / VSCRSADD) default to no-ops and are only ever called on a board whose [display] is a portrait ST7796, where the framework scrolls a full-width ScrollView by rotating the panel’s frame memory instead of repainting it. |
HalGpio | 11 | Direction, value, set_input(pin, Pull), read, edge IRQ enable/disable, init_gpio_irq, inject, drain_gpio_event, has_pending_event, wait_for_button_event. |
HalClock | 2 | sleep(ms) and elapsed_realtime_nanos(). Do not put a debug-stop check in sleep; shared code owns it. |
HalTouch | 7 + 1 defaulted | init, read_point, read_raw_unfiltered, set_calibration, and the three scripted-touch overrides. wait_irq(timeout_ms) blocks the touch sampler until the panel’s interrupt line reports activity; the default is a plain sleep for the timeout. |
HalI2c | 4 + 2 defaulted | init, set_speed, write_slice, read_slice. The Java-array write/read are defaulted over the slice pair. |
HalAdc | 2 | init(pin), read(pin) -> volts. |
HalPwm | 2 | init(pin), apply(pin, freq_hz, duty_pct, enabled). |
HalSpi | 4 + 2 defaulted | init, reconfigure, write_raw, transfer_raw. The Java-array transfer/write are defaulted. |
HalUart | 4 | init, write_byte, read_byte (-1 when empty), reconfigure(baud, data, parity, stop, flow). |
HalNet | 14 | Only on a board with has_network = true; registered with set_hal_net!, not set_hal!. See The network. |
HalFs | 13 | Registered with set_hal_fs!. Usually set_hal_fs!(picodroid_core::fs::LittleFsHal) — see The filesystem. |
set_hal! takes the nine bus and display traits at once; there are also
per-trait forms (set_hal_display!, set_hal_gpio!, set_hal_clock!,
set_hal_touch!, set_hal_i2c!, set_hal_adc!, set_hal_pwm!,
set_hal_spi!, set_hal_uart!) for a family that registers them in
separate files. Exactly one registration of each may be linked: your device
and simulator arms must be cfg-exclusive.
Types that cross the seam live in picodroid_core::hal::types and are
re-exported from porting: Pull { None, Up, Down }, EdgeTrigger { Rising, Falling, Both }, GpioEvent { pin, rising, t_us } and
NetError. Stamp t_us at enqueue time (in the interrupt): the contact
debounce compares enqueue-time deltas, because edges can sit in the queue
for hundreds of milliseconds while the UI task is busy.
Two building blocks are shared so you do not write them:
GpioEventRing<N>— the queue between your GPIO interrupt and the UI task. One producer (the interrupt, or a task that has masked it), one consumer. Stamp the time and give your wake semaphore beside it; the ring keeps the drop tally and warns on overflow. The simulator uses the same ring behind a mutex.TouchOverride— the scripted-touch state machinepdb input tapdrives. Keep one as astatic, forward the threeHalTouchoverride methods to it, andmatchitssample()inread_point.
The Java-array bus methods are defaulted through hal::array_io, which
stages up to 64 bytes on the stack. A family whose bus takes more overrides
the methods, not the constant.
2. The kernel (Rtos)
Implement picodroid_core::rtos::Rtos (an unsafe trait: it promises real
mutual exclusion and real cross-task wake-ups) and register it with
set_rtos!. The trait is defined in the pd-rtos crate and re-exported as
picodroid_core::rtos; the same crate holds the JVM run lock, which the
seam’s own wrappers release around every blocking wait, so your impl does
nothing about it. Its 28 methods, grouped:
spawn(&TaskSpec, body)— a task may be declined (returnfalse); the framework copes.queue_create/send/recv(au32word) and the pointer-width triplequeue_create_ptr/send_ptr/recv_ptr.task_current,task_notify,task_notify_from_isr,task_wait_notification,task_stack_unused_bytes, andscheduler_running.mutex_recursive_create/lock/unlock/delete— Java monitors re-enter.sem_binary_create/give/take, andsem_give_from_isr.tick_timer_start/pause/resume/stop— the UI tick;pausemust really quiesce the timer so the chip can idle.delay_ms, anddelay_until_anchor/delay_untilfor a fixed-rate loop.
Rules that are easy to get wrong:
- Stack sizes are bytes. FreeRTOS counts words; ESP-IDF counts bytes. Convert in your impl, once.
TaskKindis your policy hook. Shared code says what a task is for —Jvm,JvmChild,BgWorker,Sensor,Touch,FsWorker,DebugBridge— and you decide its stack, core and any bookkeeping. On a family that runs from the flash it writes, pinFsWorkerto the core that does the writing: a dual-core device corrupts only when a flash write races an instruction fetch on the other core, and the simulator can never show it.scheduler_runningis a real scheduler-state query. Nottask_current() != 0: FreeRTOS assigns a current task at the first task creation, so that spelling reports “running” during boot, and a caller that trusts it blocks on a notification no running task can send — a boot hang on the device that passes every host test.- A
JvmChildis counted before it is created, and registers itself as its first act, if you track children for a debug bridge. A child at a higher priority than its parent can finish beforespawnreturns. - Priorities come from
picodroid_core::task_priority. The background pool runs on the JVM’s tier by design (PRIORITY_JVM_NORM, 15). - On FreeRTOS, call
picodroid_core::rtos::freertos::install_heap_atomic_hooks()before the first task exists. It makes the JVM’s heap compounds and the GC scheduler-atomic; the simulator’s boot calls the same function.
3. The hooks (PlatformHooks)
Implement picodroid_core::host::PlatformHooks and register it with
set_platform_hooks!. Eight methods: stop_requested (has a debugger asked
the JVM to stop — false if you have no bridge), heap_bypass_enter/exit
and heap_checkpoint (no-ops on hardware; the simulator’s heap model uses
them), native_heap_stats (what the memory monitor prints),
register_gc_roots, uninstall_run(first_sector, sectors), which erases
an installed run for a Java PackageInstaller.uninstall while the app that
asked keeps running (the RP family runs the erase on the LittleFS worker),
and device_serial.
The device serial
device_serial is the only part of the device’s identity you write. Return
your chip’s ID formatted the way your vendor’s own USB bootloader shows it,
so lsusb reads the same in both modes (where it shows no per-chip serial,
as the RP2040’s does not, follow your vendor SDK’s unique ID): Serial::from_id_bytes for the
usual uppercase hex of a raw ID (up to 16 bytes), Serial::from_ascii for
anything else ([0-9A-Za-z._:-], at most 32 characters). Return None for
a chip with no ID; core then makes a random serial once from your TRNG and
keeps it in /system/serial.
| Family | device_serial() |
|---|---|
| RP2350 | from_id_bytes of the OTP chip ID (bootrom get_sys_info) |
| RP2040 | from_id_bytes of the QSPI flash’s unique ID (4Bh, read with XIP off) |
| STM32 | from_ascii of ST’s DFU 12-character fold of the 96-bit UID |
| nRF52 | from_id_bytes of FICR.DEVICEID |
Call picodroid_core::identity::init() once from boot, after the
filesystem mounts and before the scheduler, a second core or USB starts;
a hook that needs XIP off may rely on that. Everything else is core’s: the
USB iSerialNumber string (your driver serves every string descriptor
through pdb::usb_cdc::string_descriptor), the PING greeting,
Build.getSerial(), and pdb -s <serial> on the host. Define
picodroid_port_entropy_bytes too (return 0 without a TRNG).
register_gc_roots is required, not defaulted, on purpose: a family with no
native module holding Java references writes an empty body, which is a
decision, where a default would let the question go unasked. Keep your own
gc_root_registration.rs with an EXPECTED_PROVIDERS constant (probably
0), include the shared crates/test_support/gc_root_scan.rs guard as the RP file
does, and assert at boot that gc_roots::provider_count() equals core’s
EXPECTED_PROVIDERS plus yours — a real assert!, because device builds
compile debug_assert! out.
4. The filesystem
Two ways. The usual one: enable picodroid-core’s littlefs feature,
implement FsBackingStore (which extends littlefs_rust::Storage) over your
flash region, and register set_hal_fs!(picodroid_core::fs::LittleFsHal).
The block arithmetic is FsGeometry’s — resolve for bounds,
check_prog for page alignment, DEFAULT for 4 KB / 256 B / 16 B, which
you can assert! your flash matches at compile time. Call
picodroid_core::fs::init_device(store) and then spawn_worker() before
the scheduler starts. Reserve the region in your linker script (the RP
family reads __fs_start/__fs_end).
The other way: implement HalFs yourself over whatever filesystem you
have, and never link a byte of LittleFS.
5. The debug bridge and installer (optional)
The protocol, the install orchestration, the sysmon encoder and the input
injection all live in picodroid_core::pdb and picodroid_core::install,
tested against mocks. You supply four things and hand them to
run_pdb_task(transport, coordinator, sysmon, flash) from your bridge task:
PdbTransport— a byte pipe:init,read_byte,read_byte_timeout,write_bytes,drain_tx. If your tick source stops during a flash write (the RP2350’s does),read_byte_timeoutmust busy-wait on a hardware timer instead of a tick-based wait.SysmonSource— fill aSysmonSample(up toMAX_TASKSTaskSamples) from your kernel’s statistics.CoreCoordinator— stop the JVM and park the core that executes from the flash being written:request_stop_and_park,wait_for_park,release,cancel_park_request.PapkRegionFlash— four constants (where the app region sits and how big it is, the sector size, the directory capacity), its mapped address,erase_range,program_rangeandreset.PapkRegion<YourFlash>turns that into thePapkFlashthe installer and the package directory want; you never write the run arithmetic, the boot-meta pages or a relocation.InstallTransportis satisfied by the bridge’s own framing.
At boot, before the scheduler, packages::register_system(board_cfg::system_apks::SYSTEM_APKS)
adds the system apps the build linked into the firmware (the launcher, on a
multi-app board), packages::rescan(region_base, len) walks the region’s
runs, packages::cleanup(&mut region) erases what a power loss left
unfinished, and packages::boot_image() is what you hand to run_app
(None means wait for an install). When run_app returns and no install
is pending, packages::next_image() says what runs next: the package a
cross-package startActivity asked for, else the launcher, else None.
Wire layouts (frames, sysmon, input
events, keycode names) are types in the pdb-protocol crate, and the USB
vendor and product ID and strings are pdb_protocol::usb; core’s
pdb::usb_cdc holds a reference CDC-ACM descriptor set built from them.
Never retype any of it.
6. The simulator
Two macro calls and no simulator code. Which app runs, and what runs after it, is the package directory’s business, shared with the device:
#[cfg(any(test, feature = "sim"))]picodroid_core::register_sim_platform! { gc_roots = crate::gc_root_registration::register_all, boot_budget = crate::boot_budget::MODEL, // static BootBudgetModel}
// main.rs#[cfg(any(test, feature = "sim"))]picodroid_core::declare_sim_global_allocator!();
#[cfg(feature = "sim")]fn main() { glue::sim_main()}BootBudgetModel lists the tasks your device creates at boot
(BootTask { name, stack_bytes, sim_real }), a TCB estimate, a queue
bucket, and your default_stack_bytes function. The simulator charges its
heap arena from it so the memory picture matches the device, and asserts at
the end of boot that the charges reconcile. sim_real is true for tasks the
simulator creates for real (they charge themselves), false for ones it only
models (a debug bridge, a WiFi driver, the kernel’s own idle tasks).
Your simulator Cargo feature must be named sim; the generated sim_main
is gated on it. Your crate’s own cargo test runs on the host and routes to
the shared simulator, but a dependency is never compiled with the
dependent’s cfg(test), so add:
[dev-dependencies]picodroid-core = { path = "…", features = ["sim"] }7. Boot, data and discipline
main.rs
The entry point, panic handler, exception handlers, the device
#[global_allocator], and your kernel’s application hooks
(vApplicationMallocFailedHook should be a no-op so allocation failure
reaches the GC). Read the installed app, mount the filesystem, call
identity::init(), then start your tasks.
The JVM supervisor loop
The task that runs the app on a device loops for the life of the device,
because an install replaces the app. It stays in your crate — the “park for a
flash write” half encodes your flash topology — but it owes a fixed
checklist. platforms/rp/src/boot_tasks.rs is the reference:
- Store the task’s handle so the bridge and child tasks can wake it.
- Loop: clear the stop flag;
run_appon the current image; raise the stop flag if Java threads are still alive (a natural exit or a launch of another package raises none itself); abort any child task delays;picodroid_core::threads::wake_all_parked(); wait until the count of live child tasks reaches zero. - If no flash park is pending, ask
packages::next_image()what runs next — the package a cross-packagestartActivityasked for, else the launcher, else nothing — and go round again with it when there is one. Otherwise block until the bridge asks for a flash park (an install always opens with one), acknowledge, and block until it releases you or resets the chip; when released, go round again with the same image. - Never return from the task — its stack is the app’s.
- Do not put a stop check in your
HalClock::sleep; shared code owns that. - Every one of those waits must re-check its condition after waking. The
task collects notifications it did not ask for —
fs::with_fsalone leaves one latched per call when the worker outranks the JVM — and a bare wait returns at once (thebootcountre-run of 2026-09).
build.rs
Copy platforms/rp/build.rs and change the family-specific middle. It
build-depends on the shared picodroid-build-support crate
(crates/build_support, feature freertos-device for the kernel build;
modules config, board_cfg, boards, freertos, network, papk,
jvm_defaults) and must call, in order:
board_cfg::resolve, boards::emit_board_imports, your memory-layout and
kernel build, board_cfg::emit_neutral(out, &board, Pins::Owned),
config::emit_display_config, config::emit_touch_config,
board_cfg::emit_jvm_env_vars, and the three papk::* calls
(emit_framework_map_version, embed_framework_classes,
embed_papk_flash_init).
config::repo_root and config::is_embedded are shared; do not derive
them by hand. The capability cfgs — has_display, has_touch,
has_buttons, has_network, network_<type>, network_link_<kind>, any_sensor,
sensor_<kind>, and the feature switches has_json, has_protobuf, has_tls,
has_canvas, has_multi_app and has_psram — are emitted from board.toml by board_cfg, not set by
Cargo features. Do not compile LVGL: picodroid-core’s build script
owns it, and two builders mean duplicate symbols.
Waiting on hardware
A wait of a millisecond or more belongs on the kernel: a semaphore an
interrupt gives, a task notification, or Rtos::delay_ms. Never a cycle
count — a panel’s 120 ms settle run through cortex_m::asm::delay on the
UI task is 120 ms in which nothing at that priority runs. The few register
waits that genuinely have to be spins (a DMA channel retiring an abort, a
block leaving reset, a cross-core handshake whose other side runs on the
other core) are written with picodroid_core::spin_until!, which names and
caps each one and evaluates to Result<(), SpinTimeout> so a wedged
peripheral is a logged fault rather than a silent hang. The RP family’s
spin_guard test rejects a bare while … {} register poll; a spin that
must stay bare carries a spin-ok: comment saying why. The rules and the
audit behind them: docs/scheduling-audit-2026-09.md.
Logging
pd_trace!, pd_debug!, pd_info!, pd_warn! and pd_error! from
picodroid_core::pd_log. On a device they are defmt; on the host,
eprintln. A family has no choice here: link a defmt sink (defmt-rtt)
and panic-probe.
Guards you inherit
- The shadow-twin check in
scripts/pre-commit(no same-path file in both trees, four allowlisted seam pairs). - The cfg-hygiene check: never write
not(feature = "family-<yours>")to mean “the simulator” — simulator builds keep the family feature on. EXPECTED_PROVIDERSand the GC-root scan.- The core seam guard (
rtos::seam_guard): shared code names no kernel primitive directly, so yourRtosis the only way it reaches one. - If you are multi-core, write your own placement discipline; the RP
family’s
task_affinity.rsand its source scan are the reference.
The network
Only needed when a board sets has_network = true. The socket contract is
the HalNet trait (14 functions, registered with set_hal_net!). If your
family runs FreeRTOS and FreeRTOS+TCP, you do not implement it: core does,
and you write only the link driver for your chip. Design:
docs/designs/network-seam-2026-09.md.
What core gives you (feature freertos-tcp):
FreeRtosTcpNet— theHalNetimplementation over FreeRTOS+TCP sockets. Register it:picodroid_core::set_hal_net!(picodroid_core::hal::freertos_tcp::FreeRtosTcpNet);run_link_task— the bring-up every link needs, in order: driver init, MAC, IP stack start, bring-up, then the service loop.crates/picodroid-core/net-freertos-tcp/— the shared C:net_init.c(stack start and the five FreeRTOS+TCP application hooks),libc_str.c, and the sharedFreeRTOSIPConfig.hpolicy. Yourbuild.rscompiles it, because it must see yourFreeRTOSConfig.h.picodroid_net_ip_event— logs the up/down transitions.
What you write:
NetworkInterface_<X>.c, against FreeRTOS+TCP’s ownNetworkInterface_t(pfInitialise,pfOutput,pfGetPhyLinkStatus, an RX path that postseNetworkRxEvent). It must defineNetworkInterface_t *pxPicodroidNetLink_FillInterfaceDescriptor(BaseType_t, NetworkInterface_t *), the one name the shared glue binds to. A vendored driver with its ownpxXXX_FillInterfaceDescriptorgets a five-line forwarder.- A type implementing
NetLinkfor the same chip:KIND(LinkKind::WifiorEthernet, for logs),NAME,SERVICE_TIMEOUT_MS(Nonewhen the vendored driver runs its own task),init,mac,bring_up,service. src/hal/<family>/port/FreeRTOSIPConfig_family.h: your IP-task affinity (required on a multi-core family), optionally priority and stack size.uint32_t picodroid_port_entropy32(void), in Rust with#[no_mangle]: one random word per call, never failing (hardware RNG when you have one, a timer-mixed fallback when you do not).- The spawn, in your boot code, on a task with your own core and stack:
run_link_task(MyLink). - In
build.rs:build_support::network::build_freertos_tcp(&NetStackBuild { kernel_port_include, family_port_dir, link_sources: [your NetworkInterface_<X>.c, …], … }). For an on-chip MAC add the vendoredportable/NetworkInterface/<Vendor>/NetworkInterface.c,Common/phyHandling.candportable/NetworkInterface/includethere. - In
board.toml:has_network = trueandnetwork_type = "<x>", with a row("<x>", "wifi" | "ethernet")inbuild_support::board_cfg::KNOWN_NETWORK_TYPESand a forward ofpicodroid-core/network-<kind>in your board feature.
Sockets are handles core never looks inside; addresses are IPv4 packed into
a u32 (first octet in the top byte). Java learns the link kind from the
network_link_<kind> cfg: PackageManager.FEATURE_WIFI / FEATURE_ETHERNET
and NetworkInfo.getType().
Reference: the RP family’s cyw43 WiFi driver — hal/rp/cyw43/
(bindings and Cyw43Link), hal/rp/port/net/ (NetworkInterface_CYW43.c,
cyw43_port.c), hal/rp/entropy.rs, and the spawn in boot_tasks.rs.
HAL dispatch
Your family gets its own crate, so this is a #[cfg] inside your
src/hal/mod.rs, choosing between your peripherals and the shared
simulator:
#[cfg(any(feature = "sim", test))]use picodroid_core::hal::sim as chip;
#[cfg(not(any(feature = "sim", test)))]#[path = "nrf52/mod.rs"]mod chip;
pub use chip::{adc, display, gpio, i2c, pwm, spi, system_clock, touch, uart};Two things have no trait because they have no shared counterpart — a reset
vector and an XIP flash region are not things a host process has. The RP
family keeps boot::clock_init and its flash constants shape-asserted in
src/hal/contract.rs; do the same for whatever your boot needs.
FreeRTOSConfig.h
Each MCU family provides its own FreeRTOSConfig.h. Key settings that differ
per family:
| Setting | Dual-core (RP) | Single-core (nRF52, STM32) |
|---|---|---|
configCPU_CLOCK_HZ | 125/150 MHz | varies |
configNUMBER_OF_CORES | 2 | 1 |
configUSE_CORE_AFFINITY | 1 | 0 |
configTICK_CORE | 0 or 1 | N/A |
configSMP_SPINLOCK_* | 26, 27 | N/A |
configSUPPORT_PICO_SYNC_INTEROP | 1 | 0 |
configENABLE_FPU | chip-dependent | chip-dependent |
configTOTAL_HEAP_SIZE | injected from the MCU toml’s heap_kb (less the RAM the hot code takes) | depends on RAM |
Leave configIDLE_AFFINITY and configTASK_DEFAULT_CORE_AFFINITY unset on
a dual-core part: pinning the idle tasks pins the reaper, and a
Thread.start/join loop then leaks every finished child’s stack. Settings
that are the same across families are in platforms/rp/mcus/rp/FreeRTOSConfig.h.
Cargo features
Add your chip and family features to Cargo.toml:
[features]chip-nrf52840 = ["dep:nrf52840-hal", "family-nrf"]family-nrf = ["picodroid-core/family-nrf"]board-my-board = ["chip-nrf52840", "picodroid-core/board-my-board"]Each board-* feature forwards a marker feature of the same name to
picodroid-core, plus every capability feature the board declares
(picodroid-core/sensor-bme688, picodroid-core/network-wifi, …);
board_cfg::assert_forwarded_features_match fails the build otherwise. Add
the HAL crate as an optional, target-gated dependency:
[target.'cfg(target_arch = "arm")'.dependencies]nrf52840-hal = { version = "...", optional = true }Build system
- Memory layout: emit a
memory.xfrombuild.rsand select it based on the active MCU. The RP family generates its layout at build time viaboards::place_memory_xrather than committing a file. - FreeRTOS port:
crates/build_support/freertos.rscompiles the kernel from the keys in yourmcus/<family>/<chip>.toml(freertos_port,pico_shim,freertos_port_extra_includes,freertos_c_defines,freertos_vector_aliases,init_array_segment); populate the keys, no code changes. An optionalc_opt_level(0–3,s,z) pins the optimisation level of every C object built for that MCU’s target — the kernel, LVGL and the network stack — instead of cargo’sOPT_LEVEL, and drops the frame pointers cc-rs adds fordebug = 2; both RP MCUs set"s". - C shims: the RP family needs
pico_shim_*.cfiles that fake the pico-sdk C API the RP FreeRTOS SMP ports expect. Standard Cortex-M ports (ARM_CM4F, ARM_CM33) use CMSIS directly and need none. - LVGL: compiled by
picodroid-core’s build script. Do not add it.
.cargo/config.toml
The device tables in .cargo/config.toml are keyed by cfg, not by target
triple: cfg(all(target_arch = "arm", target_os = "none")) carries the
linker (flip-link) and the three link arguments (--nmagic, -Tlink.x,
-Tdefmt.x) for every bare-metal ARM target, and two narrower tables pick
the RP runner by target_abi (eabi for the RP2040, eabihf for the
RP2350). They are spelled that way because cargo reads a table named by a
dotted triple (thumbv8m.main-none-eabihf) only unquoted up to 1.98 and only
quoted from nightly 1.101.
A new ARM family therefore inherits the linker and the link arguments and adds only its runner:
[target.thumbv7em-none-eabihf]runner = "probe-rs run --chip nRF52840_xxAA --protocol swd"Two cfg tables that both match a target and both set a runner are an error,
which is why the RP pair is kept disjoint; a family on another architecture
needs a table of its own with its linker and link arguments.
Single-core vs dual-core considerations
picodroid’s RP port pins both the debug bridge and the JVM to core 0 (the
bridge preempts by priority); core 1 hosts a dedicated flashpark parker
task, plus the cyw43 WiFi task on WiFi boards. Every task goes through
task_affinity::spawn, which makes create-and-pin one scheduler-atomic
step, and a source scan enforces that nothing else creates a task. Before
each erase/program window, core 0 notifies the parker via a cross-core task
notification (hal/rp/core1_park.rs), spins until core 1 reports parked,
and releases it when the window closes.
A module with QSPI PSRAM (the RP2350B’s second chip select, brought up by
hal/rp/psram.rs from the MCU toml’s psram_kb / psram_origin /
psram_cs_pin) sits behind the same XIP window, and the erase/program
window owes it three more things, all inside with_xip_disabled!: write
every dirty XIP cache line back before the ROM’s flush invalidates the cache
(the RP2350’s cache is write-back for that window), save the window’s QMI
registers before flash_exit_xip resets them, and write them back after.
The rule that goes with it: between XIP-off and XIP-restore, no code on
either core may read or write PSRAM — the parked core and the masked
interrupts already guarantee that for instruction fetches, and the same
guarantee has to hold for data. Two corollaries: an installer must not
stage an app image in PSRAM, and nothing reached from inside the window may
touch it. Contents survive a flash write (the part self-refreshes while
deselected); only access during the window is unsafe. The simulator models
none of this, so a violation is a hardware-only bug: the on-device check is
an install — real erases and programs — while the UI is live, which the
pdb install rows of hil-tests.conf perform on every bench board.
On single-core MCUs:
- Task scheduling: both tasks run on the same core; the bridge preempts the JVM via higher priority.
- Flash writes: erase/program disables interrupts, performs the operation, and re-enables — the same window the RP family uses.
- No core parking: there is no second core to park; omit the handshake.
- No core affinity: omit it from your
Rtos::spawn.
board.toml reference
Every physical board ships a board.toml under boards/<name>/. The build script parses it and emits Rust cfgs and consts — do not edit boards/*/mod.rs to configure a display or sensor, configure it here. All coordinates are GPIO numbers.
Top-level properties
| Key | Type | Required | Description |
|---|---|---|---|
mcu | string | yes | "rp2040", "rp2350" or "rp2350b" — the name of a descriptor under mcus/<family>/ (the schema is family-agnostic). |
hot_ram_kb | int | no | RAM for the hot sets that execute from SRAM: the JVM’s invoke, field and constant-pool helpers and the LVGL and FreeRTOS functions listed in mcus/rp/hot-ram-*.txt. Taken out of the heap arena, like the MCU toml’s jvm_loop_ram_kb. Must agree with the hot-in-ram Cargo feature (which chip-rp2350 enables), or the build stops. Every RP2350 board sets 48; absent means none. |
has_network | bool | no | If true, compiles in the networking stack (FreeRTOS+TCP + a link driver). Needs network_type. |
network_type | string | no | Required when has_network = true. Must be a row of build_support::board_cfg::KNOWN_NETWORK_TYPES ("cyw43" = wifi today); the build emits network_<type> and network_link_<kind> and checks the kind against the forwarded picodroid-core/network-<kind> feature. |
lv_mem_kb | int | no | LVGL render-pool size in KiB (default 64; every board but pico_touch_kit sets 48). The pool is a static array, so it comes off the core-0 main stack’s budget, which the linker script holds to at least 8,192 bytes. |
text_sizes | string | no | The pixel sizes of the Montserrat faces TextView.setTextSize can snap to, ;-separated; the MCU toml’s value applies when unset (14;20;28;64 on the RP2350s, 14 on the RP2040). 14 is required; every other size must exist as a generated face in crates/pd-lvgl-sys/lvgl/fonts/ (scripts/gen-fonts.sh). Flash per face: Limits. |
lv_mem_in_psram | bool | no | Put the LVGL pool in the module’s PSRAM instead of .bss (the MCU toml must declare psram_kb). Frees the pool’s size from the main-stack budget; render targets stay in SRAM. Device builds only — the simulator keeps its .bss pool. |
idle_timeout_ms | int | no | The board’s default screen timeout: idle time, with no key edge or touch, before the display dozes (default 60000; 0 never). Every board, touch or keys; Settings → Display stores a user’s choice at /system/display (Settings.System.SCREEN_OFF_TIMEOUT), which overrides it. An app that must stay lit calls View.setKeepScreenOn(true). |
soft_nav | bool | no | For a touchscreen board with no button: the framework draws a round BACK (tap) / HOME (hold) control in the bottom-left corner of the window, and input keyevent can send any key. Needs [touch]. The testbenches set it; a new touch board declares a [[button]] instead. |
home_hold_ms | int | no | How long BACK (or the soft-nav control) is held for HOME on a board with no HOME key; default 1000, allowed 500–5000. |
handle_slots | int | no | Size of the LVGL object handle table (default 256). Must be a power of two between 32 and 4096. |
has_json | bool | no | If true, ships picodroid.json (JSONObject/JSONArray/JSONException and the native node pool behind them). Off by default: a board that leaves it off drops those classes from its embedded SDK and compiles the parser out, and apps built for it fail the API contract if they reference them. |
has_protobuf | bool | no | If true, ships picodroid.protobuf (CodedInputStream/CodedOutputStream/MessageLite/WireFormat/InvalidProtocolBufferException and the native wire codec behind them). Off by default, like has_json: a board that leaves it off drops those classes from its embedded SDK and compiles the codec out, and apps built for it fail the API contract if they reference them. |
has_tls | bool | no | If true, ships HTTPS: picodroid.net.ssl.HttpsURLConnection over the pd-tls client. Off by default. The board’s Cargo feature must forward picodroid-core/tls, and the build checks the two agree. On for every RP2350 WiFi board. |
has_canvas | bool | no | On unless set to false. false drops View.onDraw(Canvas): the Canvas and Paint classes, their natives and the display list, about 16 KB of flash. testbench_rp2040 sets it. |
framework_class_excludes | list | no | Framework classes to leave out of this board’s embedded SDK, to save flash. Classes owned by a feature switch (has_json, has_protobuf, has_canvas) are added automatically; listing one by hand while the switch is on fails the build. |
max_installed_apps | int | no | Package-directory capacity, 1..=64 (MCU default 1 = a single-app board; the RP2350 boards set 8, pico_touch_kit 16). Above 1 the build emits has_multi_app and the debug bridge gains list/uninstall. |
app_region_kb | int | no | Size of the app region (PAPK_FLASH), a multiple of 4 (MCU default 1024). What the app region and LittleFS do not take is the program region, so this key is also how a board buys room for its image: testbench_rp2350 and pico_enviro_mon set 1536 (program region 2048 KB), the three 4 MB WiFi boards 1024 (2560 KB), pico_touch_kit 9728 (2560 KB), testbench_rp2040 768 (1152 KB). Changing it, or fs_kb, moves LittleFS and the app region, so the next flash reformats the volume. |
boot_package | string | no | The package to boot when it is installed, for a board that always runs one app. Otherwise the usual order applies: the app flash.sh --app baked in, then the launcher, then the lowest run. flash.sh --boot overrides it. |
fs_kb | int | no | Size of the LittleFS region (MCU default 128 on rp2040, 256 on rp2350; the RP2350 boards set 512, pico_touch_kit 4096). |
fs_system_reserve_kb | int | no | Tail of the LittleFS volume no app may write into (default 64; system apps are exempt). Enforced on multi-app boards. |
app_data_cap_kb | int | no | Most one app’s /data/<package> may hold, in the framework’s 4 KB-block accounting (default a quarter of fs_kb; 0 lifts the cap). Enforced on multi-app boards. |
linker_script | string | no | Path to a linker script used verbatim, MEMORY block and all (by default the MEMORY block is generated from the flash layout above and the MCU’s mcus/<family>/<mcu>.x supplies only its SECTIONS). |
The flash layout is laid out top-down from the end of flash — the app region, then LittleFS, then the program image in front of them (behind boot2_bytes on rp2040) — by crates/build_support/flash_layout.rs, from the MCU toml’s flash_origin, flash_kb, ram_origin, ram_kb, boot2_bytes and the three tunable keys above. It renders the linker script’s MEMORY block and the FLASH_ORIGIN, PROGRAM_LEN, FS_OFFSET/FS_LEN, PAPK_REGION_OFFSET/PAPK_REGION_LEN and MAX_INSTALLED_APPS constants the firmware reads, and scripts/lib.sh computes the program-image ceiling from the same keys. A module with QSPI PSRAM declares psram_kb and psram_origin (and, on the RP family, psram_cs_pin) in the MCU toml as well; the layout then renders a PSRAM region nothing is placed in, the PSRAM_ORIGIN/PSRAM_LEN constants, and the has_psram cfg.
[display] — display controller over SPI
| Key | Type | Description |
|---|---|---|
driver | string | Required. The controller: "st7789" or "st7796". Emits a display_<driver> cfg that selects the driver module and the concrete type in the family’s display facade, so an unknown name fails the build rather than compiling the display out. |
spi_id | int | SPI peripheral ID (0 or 1). |
spi_freq | int | SPI clock in Hz (e.g. 62500000). |
spi_sck, spi_mosi, spi_miso | int | Optional SPI pad overrides; default to the chip’s SPI pins (e.g. SPI0 SCK=GP2/MOSI=GP3 on RP2350). The Enviro+ Pack uses these to route SPI0 to GP18/GP19. |
pin_dc, pin_cs | int | Data/command and chip-select GPIOs. |
pin_bl | int | Backlight pin (optional; a module may light the panel from power, as the 52Pi EP-0172 does). Omitted, the driver’s backlight calls become no-ops and idle sleep blanks the panel through DISPOFF alone. |
pin_rst | int | Reset pin (optional; some displays don’t expose one). |
width, height | int | Panel dimensions in pixels (required when [display] is present). Each must be at least 240: that is the floor every app is laid out against, and a layout that fits 240×240 fits every board. |
dpi | int | The panel’s physical pixel pitch (required): pixels per inch along the diagonal, sqrt(w² + h²) / inches — a 2.8” 320×240 is 143, a 2.0” 320×240 is 200. It is what DisplayMetrics.xdpi/ydpi report and what sizes the minimum touch target on a touch board. It does not change LVGL’s theme: LV_DPI_DEF is pinned to 160 on every board so one logical layout gives one set of pixels. The old top-level lv_dpi key, which did scale the theme, is refused. |
madctl | int (hex) | Memory-access-control register (controls rotation / mirroring). |
band_height | int | LVGL partial-render band in pixels (required). |
draw_buffers | int | 1 (default) or 2. With two band buffers LVGL renders the next band while the panel is still taking the previous one — worth it only on a family whose display flush is asynchronous (HalDisplay::write_pixels_start), and it costs a second buffer of width × band_height × 2 bytes. Refused when [touch] names a controller on the display’s SPI bus (the XPT2046): a transfer left running would share the bus with the touch read. |
scroll_limit | int | LVGL scroll hysteresis threshold (required). |
Omit the whole [display] section for a headless board; the build then falls back to safe 320×240 defaults and leaves has_display unset.
[touch] — touch controller
driver decides which of the remaining keys the section owes, because the two
supported controllers have almost nothing in common: the XPT2046 is a resistive
ADC on the display’s SPI bus that needs calibration bounds, the GT911 a
capacitive controller on its own I2C bus that reports finished pixels. Naming
one and supplying the other’s keys fails the build.
| Key | Type | Description |
|---|---|---|
driver | string | Required. "xpt2046" or "gt911". Emits a touch_<driver> cfg. |
swap_xy | bool | Transpose X/Y axes (for a panel mounted rotated relative to the display). Applies to both drivers. |
XPT2046 only:
| Key | Type | Description |
|---|---|---|
spi_freq | int | SPI clock in Hz. Shares [display]’s bus, switching frequency per transfer. |
pin_cs, pin_irq, pin_miso | int | Chip-select, pen-down IRQ, MISO GPIOs. |
cal_x_min, cal_x_max, cal_y_min, cal_y_max | int | Raw ADC bounds from touch calibration. |
GT911 only:
| Key | Type | Description |
|---|---|---|
i2c_id | int | I2C peripheral ID (0 or 1). |
i2c_sda, i2c_scl | int | Optional I2C pad overrides, like the display’s SPI pads; default to the chip’s pins for that bus (I2C0 SDA=GP4/SCL=GP5 on RP2350). |
addr | int (hex) | 7-bit address, 0x5D or 0x14. The driver’s reset drives INT to the level that makes the part take this one. |
pin_int, pin_rst | int | Interrupt and reset GPIOs. Both are driven as outputs for the reset that latches the address; INT is flipped to an input immediately afterwards, because the controller drives it from then on. |
[[sensor]] — array of environmental sensors
| Key | Type | Description |
|---|---|---|
kind | string | Driver selector: "bme688" or "ltr559". |
bus | string | "I2C0" or "I2C1". |
addr | int | 7-bit I2C address (decimal or hex). |
Each entry here becomes a Sensor visible to SensorManager.
[audio] — optional sound output
| Key | Type | Description |
|---|---|---|
driver | string | Output selector. "pwm_buzzer" is the only one: a piezo driven directly from one PWM pad. |
pin | int | The PWM pad the buzzer is wired to. |
Declaring this section enables picodroid.media.ToneGenerator
on the board. Omit it and the SDK classes still ship — they are not board-excluded
the way picodroid.json is — but every method becomes a safe no-op, with
startTone returning false. An app therefore runs unmodified on a board with
no buzzer and simply stays quiet.
A PWM slice’s divisor, wrap and enable bit are shared between its two channels,
so a pad whose slice partner is also driven as PWM will fight the buzzer for
timing. Slice and channel are (pin / 2) % 8 and pin % 2.
Square waves only: there is no DAC or I2S seam, and none is planned. See the Audio API page for what that rules out.
[[button]] — array of hardware buttons
| Key | Type | Description |
|---|---|---|
pin | int | GPIO number. |
lv_key | string | One of "PREV", "NEXT", "ENTER", "ESC" — drives LVGL focus navigation — or "NONE". |
keycode | int | Android KeyEvent.KEYCODE_* value delivered to Java listeners. |
lv_key = "NONE" is the choice for a button that should reach Java but must
not touch the focus ring: the keycode is queued as usual and nothing is
injected into the LVGL keypad. That is what a system key needs, and what a
board with a touchscreen wants for every button — the finger drives focus, so
a button mapped to ENTER would fire the focused widget on its way past. The
52Pi EP-0172 board uses it for its HOME button.
KEYCODE_HOME (3) and KEYCODE_BACK (4) are handled by the framework before
any app sees them. BACK dismisses the soft keyboard, then a showing dialog,
then goes to the focused View’s OnKeyListener, and finally to
Activity.onBackPressed — an app can override that last step. HOME goes
straight to the launcher on a multi-app board and cannot be intercepted at all,
as on Android. A board with no HOME key gets it by holding BACK for
home_hold_ms (default 1000): the app sees its 400 ms long-press, then a
release flagged FLAG_CANCELED.
Every board with a display meets one input profile, checked by the build
(build_support::board_cfg::check_input_conformance;
docs/designs/app-portability-2026-10.md
K1–K4):
- KEYS (no
[touch]): the four logical keys as[[button]]entries —PREV(19DPAD_UP),NEXT(20DPAD_DOWN),ENTER(23DPAD_CENTER),ESC(4BACK). Eachlv_keymust carry that keycode, and no two buttons may share a pin or a keycode. Every stock widget navigates with these four. - TOUCH (a
[touch]panel): plus at least one system button — a[[button]]withkeycode = 4(BACK; held for HOME) orkeycode = 3(HOME). A board that cannot grow one sets the top-levelsoft_nav = trueand the framework draws the BACK/HOME control in the window’s bottom-left corner instead (the testbenches).soft_navneeds a touch panel.
The build emits has_nav_keys (any PREV/NEXT/ENTER button: a focus
ring exists, and View.isInTouchMode() is false) and soft_nav as cfgs, and
board_cfg::input::{HAS_NAV_KEYS, HAS_HOME_KEY, HAS_BACK_KEY, SOFT_NAV, HOME_HOLD_MS} as constants.
Every board dozes its display after the screen timeout (idle_timeout_ms, default 60 s, or what
Settings → Display stored) with no key edge or touch, and any button wakes it; so does a finger
where the touch controller is read while the panel is dark (the GT911’s sampler task, or the
XPT2046 polled inline). The app keeps running with the panel off — Runnables, alarms, scheduled
tasks, the network and the sensors continue — and the wake press is swallowed. An app that must
stay lit holds the panel with View.setKeepScreenOn(true); an Activity that must be seen when it
starts (an alarm) calls Activity.setTurnScreenOn(true). KEYCODE_SLEEP, KEYCODE_WAKEUP and
KEYCODE_POWER on a [[button]] (or injected) doze, wake and toggle. See api/ui.md → Key
events and the Button-only navigation guide.
[background_pool] — optional thread-pool tuning
All keys optional; defaults in parentheses.
| Key | Type | Description |
|---|---|---|
threads | int | Worker count (4), range 1..=32. |
priority | int | Worker priority (15). Must be 15 — the JVM’s own tier — and the build fails otherwise: the pool runs Java, and Java runs on one tier by design. |
stack_bytes | int | Per-worker stack in bytes (6144, which is also the minimum the build accepts). A job that calls a native from a couple of Java calls deep needs that much: the settings app’s Storage screen, which counts each package’s data on a worker, measured 4,088 B, and the Enviro W’s NTP/weather job 4,712 B. The framework puts file writes on a worker itself (SharedPreferences.apply()), which overflowed a 4 KB stack on testbench_rp2040; that board runs two workers at 8192 in the RAM four at 4096 took. The stacks come out of the FreeRTOS arena. A job that fetches over https also runs the TLS record layer on the worker (the handshake has a task of its own): the software AES key schedule on top of the interpreter’s frames hard-faulted a 6 KB worker; pico_enviro_mon_w runs 8 KB. |
queue_depth | int | Shared job queue depth (32). |
Surfaced via Executors.backgroundExecutor().
[jvm] — optional CPU↔memory tradeoff knobs
Compile-time pub consts sourced from this section, all optional. See JVM tunables for the full schema, tuning workflow, and worked recipes.
| Key | Type | Description |
|---|---|---|
gc_alloc_threshold | int | Allocations between auto-GC cycles (256), range 16..=8192. |
slot_chunk_shift | int | Chunk size = 1 << shift for heap slot storage (6), range 3..=8. |
inline_array_data | int | Array elements held inline rather than in the arena (8), range 0..=32. |
activity_stack_depth | int | Max nested Activities (8), range 1..=32. |
pending_op_queue | int | Max queued startActivity/startService ops per frame (8), range 1..=64. |
prereserve_* | int | Six pre-reservation sizes for the heap’s slot chunks and arenas; see the JVM tunables page. |
Verification
After implementing your port, run the sim smoke and the local checks:
# Sim smoke test (verifies picodroid business logic is not broken)./scripts/sim.sh --app helloworldperl -e 'alarm 5; exec @ARGV' ./scripts/sim.sh --app blinky
# Source guards (shadow twins, cfg hygiene) and formatters -- builds nothing./scripts/pre-commit
# Host tests, including the porting checklist guard./scripts/test.shClippy for every board, the debug and release builds and the example APKs run in GitHub CI on push.
cargo test -p picodroid-core includes the checklist guard: if you add a
seam trait or registration macro to core, it fails until this page and
porting.rs name it.
For on-device testing, flash with your chip’s target:
cargo run --target thumbv7em-none-eabihf \ --no-default-features --features chip-nrf52840Reference implementation
The RP family (platforms/rp/) is the reference implementation. Study these
files for patterns and conventions:
| File | What it demonstrates |
|---|---|
src/glue.rs | Every registration in one place; delegating trait impls |
src/boot_tasks.rs | Task topology and the JVM supervisor loop |
src/task_affinity.rs | Dual-core placement and the scan that enforces it |
src/pdb/platform.rs, src/pdb/coordinator.rs | PdbTransport, SysmonSource, CoreCoordinator |
src/packagemanager/mod.rs | PapkRegionFlash over two flash primitives and the XIP base |
src/fs/storage.rs | FsBackingStore over a linker-carved flash region |
src/boot_budget.rs | The boot-budget model the simulator charges |
src/hal/rp/gpio.rs | Direct register access, the interrupt, GpioEventRing in use |
src/hal/rp/flash.rs | XIP-disabled flash operations from RAM, with core 1 parked (core1_park.rs) and the PSRAM window saved and restored |
src/hal/rp/psram.rs | Bringing up QSPI PSRAM on the QMI’s second chip select from RAM-resident code, and proving it at boot |
src/hal/rp/pdb_usb/mod.rs | USB CDC ISR → queue pattern behind PdbTransport |