Skip to content

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` feature
platforms/
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 shape

Family 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.

  1. The hardware layer — trait impls plus a registration macro.
  2. The kernel — an Rtos impl plus set_rtos!.
  3. The hooks — a PlatformHooks impl plus set_platform_hooks!, and a GC-root registration file.
  4. The filesystem — a backing store for core’s LittleFS, or your own HalFs.
  5. The debug bridge and installer (optional) — four small traits handed to run_pdb_task.
  6. The simulator — one register_sim_platform! call and one declare_sim_global_allocator! call.
  7. Boot, data and discipline — a main.rs, a build.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.

TraitMethodsNotes
HalDisplay8 + 4 defaultedinit, 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.
HalGpio11Direction, value, set_input(pin, Pull), read, edge IRQ enable/disable, init_gpio_irq, inject, drain_gpio_event, has_pending_event, wait_for_button_event.
HalClock2sleep(ms) and elapsed_realtime_nanos(). Do not put a debug-stop check in sleep; shared code owns it.
HalTouch7 + 1 defaultedinit, 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.
HalI2c4 + 2 defaultedinit, set_speed, write_slice, read_slice. The Java-array write/read are defaulted over the slice pair.
HalAdc2init(pin), read(pin) -> volts.
HalPwm2init(pin), apply(pin, freq_hz, duty_pct, enabled).
HalSpi4 + 2 defaultedinit, reconfigure, write_raw, transfer_raw. The Java-array transfer/write are defaulted.
HalUart4init, write_byte, read_byte (-1 when empty), reconfigure(baud, data, parity, stop, flow).
HalNet14Only on a board with has_network = true; registered with set_hal_net!, not set_hal!. See The network.
HalFs13Registered 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 machine pdb input tap drives. Keep one as a static, forward the three HalTouch override methods to it, and match its sample() in read_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 (return false); the framework copes.
  • queue_create/send/recv (a u32 word) and the pointer-width triple queue_create_ptr/send_ptr/recv_ptr.
  • task_current, task_notify, task_notify_from_isr, task_wait_notification, task_stack_unused_bytes, and scheduler_running.
  • mutex_recursive_create/lock/unlock/delete — Java monitors re-enter.
  • sem_binary_create/give/take, and sem_give_from_isr.
  • tick_timer_start/pause/resume/stop — the UI tick; pause must really quiesce the timer so the chip can idle.
  • delay_ms, and delay_until_anchor / delay_until for 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.
  • TaskKind is 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, pin FsWorker to 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_running is a real scheduler-state query. Not task_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 JvmChild is 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 before spawn returns.
  • 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.

Familydevice_serial()
RP2350from_id_bytes of the OTP chip ID (bootrom get_sys_info)
RP2040from_id_bytes of the QSPI flash’s unique ID (4Bh, read with XIP off)
STM32from_ascii of ST’s DFU 12-character fold of the 96-bit UID
nRF52from_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_timeout must busy-wait on a hardware timer instead of a tick-based wait.
  • SysmonSource — fill a SysmonSample (up to MAX_TASKS TaskSamples) 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_range and reset. PapkRegion<YourFlash> turns that into the PapkFlash the installer and the package directory want; you never write the run arithmetic, the boot-meta pages or a relocation. InstallTransport is 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:

glue.rs
#[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:

  1. Store the task’s handle so the bridge and child tasks can wake it.
  2. Loop: clear the stop flag; run_app on 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.
  3. If no flash park is pending, ask packages::next_image() what runs next — the package a cross-package startActivity asked 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.
  4. Never return from the task — its stack is the app’s.
  5. Do not put a stop check in your HalClock::sleep; shared code owns that.
  6. Every one of those waits must re-check its condition after waking. The task collects notifications it did not ask for — fs::with_fs alone leaves one latched per call when the worker outranks the JVM — and a bare wait returns at once (the bootcount re-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_PROVIDERS and the GC-root scan.
  • The core seam guard (rtos::seam_guard): shared code names no kernel primitive directly, so your Rtos is the only way it reaches one.
  • If you are multi-core, write your own placement discipline; the RP family’s task_affinity.rs and 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 — the HalNet implementation 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 shared FreeRTOSIPConfig.h policy. Your build.rs compiles it, because it must see your FreeRTOSConfig.h.
  • picodroid_net_ip_event — logs the up/down transitions.

What you write:

  • NetworkInterface_<X>.c, against FreeRTOS+TCP’s own NetworkInterface_t (pfInitialise, pfOutput, pfGetPhyLinkStatus, an RX path that posts eNetworkRxEvent). It must define NetworkInterface_t *pxPicodroidNetLink_FillInterfaceDescriptor(BaseType_t, NetworkInterface_t *), the one name the shared glue binds to. A vendored driver with its own pxXXX_FillInterfaceDescriptor gets a five-line forwarder.
  • A type implementing NetLink for the same chip: KIND (LinkKind::Wifi or Ethernet, for logs), NAME, SERVICE_TIMEOUT_MS (None when 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 vendored portable/NetworkInterface/<Vendor>/NetworkInterface.c, Common/phyHandling.c and portable/NetworkInterface/include there.
  • In board.toml: has_network = true and network_type = "<x>", with a row ("<x>", "wifi" | "ethernet") in build_support::board_cfg::KNOWN_NETWORK_TYPES and a forward of picodroid-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:

platforms/nrf/src/hal/mod.rs
#[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:

SettingDual-core (RP)Single-core (nRF52, STM32)
configCPU_CLOCK_HZ125/150 MHzvaries
configNUMBER_OF_CORES21
configUSE_CORE_AFFINITY10
configTICK_CORE0 or 1N/A
configSMP_SPINLOCK_*26, 27N/A
configSUPPORT_PICO_SYNC_INTEROP10
configENABLE_FPUchip-dependentchip-dependent
configTOTAL_HEAP_SIZEinjected 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

  1. Memory layout: emit a memory.x from build.rs and select it based on the active MCU. The RP family generates its layout at build time via boards::place_memory_x rather than committing a file.
  2. FreeRTOS port: crates/build_support/freertos.rs compiles the kernel from the keys in your mcus/<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 optional c_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’s OPT_LEVEL, and drops the frame pointers cc-rs adds for debug = 2; both RP MCUs set "s".
  3. C shims: the RP family needs pico_shim_*.c files 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.
  4. 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

KeyTypeRequiredDescription
mcustringyes"rp2040", "rp2350" or "rp2350b" — the name of a descriptor under mcus/<family>/ (the schema is family-agnostic).
hot_ram_kbintnoRAM 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_networkboolnoIf true, compiles in the networking stack (FreeRTOS+TCP + a link driver). Needs network_type.
network_typestringnoRequired 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_kbintnoLVGL 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_sizesstringnoThe 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_psramboolnoPut 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_msintnoThe 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_navboolnoFor 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_msintnoHow long BACK (or the soft-nav control) is held for HOME on a board with no HOME key; default 1000, allowed 500–5000.
handle_slotsintnoSize of the LVGL object handle table (default 256). Must be a power of two between 32 and 4096.
has_jsonboolnoIf 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_protobufboolnoIf 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_tlsboolnoIf 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_canvasboolnoOn 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_excludeslistnoFramework 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_appsintnoPackage-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_kbintnoSize 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_packagestringnoThe 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_kbintnoSize of the LittleFS region (MCU default 128 on rp2040, 256 on rp2350; the RP2350 boards set 512, pico_touch_kit 4096).
fs_system_reserve_kbintnoTail of the LittleFS volume no app may write into (default 64; system apps are exempt). Enforced on multi-app boards.
app_data_cap_kbintnoMost 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_scriptstringnoPath 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

KeyTypeDescription
driverstringRequired. 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_idintSPI peripheral ID (0 or 1).
spi_freqintSPI clock in Hz (e.g. 62500000).
spi_sck, spi_mosi, spi_misointOptional 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_csintData/command and chip-select GPIOs.
pin_blintBacklight 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_rstintReset pin (optional; some displays don’t expose one).
width, heightintPanel 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.
dpiintThe 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.
madctlint (hex)Memory-access-control register (controls rotation / mirroring).
band_heightintLVGL partial-render band in pixels (required).
draw_buffersint1 (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_limitintLVGL 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.

KeyTypeDescription
driverstringRequired. "xpt2046" or "gt911". Emits a touch_<driver> cfg.
swap_xyboolTranspose X/Y axes (for a panel mounted rotated relative to the display). Applies to both drivers.

XPT2046 only:

KeyTypeDescription
spi_freqintSPI clock in Hz. Shares [display]’s bus, switching frequency per transfer.
pin_cs, pin_irq, pin_misointChip-select, pen-down IRQ, MISO GPIOs.
cal_x_min, cal_x_max, cal_y_min, cal_y_maxintRaw ADC bounds from touch calibration.

GT911 only:

KeyTypeDescription
i2c_idintI2C peripheral ID (0 or 1).
i2c_sda, i2c_sclintOptional 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).
addrint (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_rstintInterrupt 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

KeyTypeDescription
kindstringDriver selector: "bme688" or "ltr559".
busstring"I2C0" or "I2C1".
addrint7-bit I2C address (decimal or hex).

Each entry here becomes a Sensor visible to SensorManager.

[audio] — optional sound output

KeyTypeDescription
driverstringOutput selector. "pwm_buzzer" is the only one: a piezo driven directly from one PWM pad.
pinintThe 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

KeyTypeDescription
pinintGPIO number.
lv_keystringOne of "PREV", "NEXT", "ENTER", "ESC" — drives LVGL focus navigation — or "NONE".
keycodeintAndroid 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 (19 DPAD_UP), NEXT (20 DPAD_DOWN), ENTER (23 DPAD_CENTER), ESC (4 BACK). Each lv_key must 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]] with keycode = 4 (BACK; held for HOME) or keycode = 3 (HOME). A board that cannot grow one sets the top-level soft_nav = true and the framework draws the BACK/HOME control in the window’s bottom-left corner instead (the testbenches). soft_nav needs 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.

KeyTypeDescription
threadsintWorker count (4), range 1..=32.
priorityintWorker 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_bytesintPer-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_depthintShared 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.

KeyTypeDescription
gc_alloc_thresholdintAllocations between auto-GC cycles (256), range 16..=8192.
slot_chunk_shiftintChunk size = 1 << shift for heap slot storage (6), range 3..=8.
inline_array_dataintArray elements held inline rather than in the arena (8), range 0..=32.
activity_stack_depthintMax nested Activities (8), range 1..=32.
pending_op_queueintMax queued startActivity/startService ops per frame (8), range 1..=64.
prereserve_*intSix 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:

Terminal window
# Sim smoke test (verifies picodroid business logic is not broken)
./scripts/sim.sh --app helloworld
perl -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.sh

Clippy 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:

Terminal window
cargo run --target thumbv7em-none-eabihf \
--no-default-features --features chip-nrf52840

Reference implementation

The RP family (platforms/rp/) is the reference implementation. Study these files for patterns and conventions:

FileWhat it demonstrates
src/glue.rsEvery registration in one place; delegating trait impls
src/boot_tasks.rsTask topology and the JVM supervisor loop
src/task_affinity.rsDual-core placement and the scan that enforces it
src/pdb/platform.rs, src/pdb/coordinator.rsPdbTransport, SysmonSource, CoreCoordinator
src/packagemanager/mod.rsPapkRegionFlash over two flash primitives and the XIP base
src/fs/storage.rsFsBackingStore over a linker-carved flash region
src/boot_budget.rsThe boot-budget model the simulator charges
src/hal/rp/gpio.rsDirect register access, the interrupt, GpioEventRing in use
src/hal/rp/flash.rsXIP-disabled flash operations from RAM, with core 1 parked (core1_park.rs) and the PSRAM window saved and restored
src/hal/rp/psram.rsBringing 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.rsUSB CDC ISR → queue pattern behind PdbTransport