Skip to content

RP2350 / Pico 2 Platform Guide

Xylolabs SDK integration for the Raspberry Pi Pico 2 (RP2350). This is the primary reference target for the SDK.


RP2350 specifications

Resource Specification Notes
CPU Dual Cortex-M33 @ 150 MHz ~300 MIPS total; DSP extensions
SRAM 520 KB Static allocation, no heap
Flash External QSPI (2–16 MB typical) Firmware + log storage
I2S PIO-based Handles 96kHz/24-bit with external ADC
UART 2 channels LTE-M1 modem AT commands
SPI Up to 62.5 MHz External ADC, SD card
GPIO 30 pins Sensors, motor signals, status LEDs
WDT Hardware watchdog Long-term unattended operation
DMA 12 channels Zero-copy audio capture and transfer

The built-in ADC is 12-bit/500ksps — insufficient for 96kHz/24-bit audio. An external I2S ADC (e.g. PCM1808, CS5343) is required.


Hardware setup

I2S ADC connection

RP2350 GPIO              I2S ADC (e.g. PCM1808 / CS5343)
───────────────          ────────────────────────────────
GPIO10 (PIO SCK)  ─────  BCK (bit clock)
GPIO11 (PIO WS)   ─────  LRCK (word select)
GPIO12 (PIO DIN)  ─────  DOUT (data out from ADC)
3V3               ─────  VDD
GND               ─────  GND
GPIO13 (GPIO)     ─────  /RESET or FORMAT select

Configure the PIO state machine for I2S slave mode at 96kHz, 24-bit. The SDK's platform layer uses DMA double-buffering to transfer captured frames to the audio ring buffer without CPU involvement.

LTE modem (UART0) — BG770A

RP2350 GPIO              LTE Modem
───────────────          ─────────────────
GPIO0  (UART0_TX) ─────  RXD
GPIO1  (UART0_RX) ─────  TXD
GPIO2  (GPIO)     ─────  PWRKEY
GPIO3  (GPIO)     ─────  RESET_N
VSYS (5V)         ─────  VCC (via LDO; check modem datasheet)
GND               ─────  GND

Sensor buses

Bus GPIO Typical devices
I2C0 GPIO4 (SDA), GPIO5 (SCL) BME280 (temp/humidity), LIS3DH (vibration)
I2C1 GPIO6 (SDA), GPIO7 (SCL) MAG3110 (magnetic field)
SPI0 GPIO16–GPIO19 + CS Motor ADC, current sensors

PIO for I2S

The RP2350's Programmable I/O (PIO) subsystem handles I2S timing in hardware, freeing the CPU entirely from bit-banging:

// Rust (Embassy) — two-SM I2S master-mode capture from PCM1860
// See sdk/examples/rp2350-full-hardware/src/main.rs for the full implementation.
use embassy_rp::pio::{Pio, Config as PioConfig, FifoJoin, ShiftConfig, ShiftDirection};

let Pio { mut common, sm0: mut sm_clock, sm1: mut sm_data, .. } =
    Pio::new(p.PIO0, Irqs);

// SM0: clock generator — BCK (sideset) + LRCK (set), 32 BCK per LRCK half
let clock_prg = pio_proc::pio_asm!(
    ".side_set 1",
    ".wrap_target",
    "    set pins, 0    side 0",    // LRCK=0, BCK low
    "    set x, 30      side 1",    // BCK high, x=30
    "left:",
    "    nop             side 0",
    "    jmp x--, left   side 1",
    "    set pins, 1    side 0",    // LRCK=1, BCK low
    "    set x, 30      side 1",
    "right:",
    "    nop             side 0",
    "    jmp x--, right  side 1",
    ".wrap",
);

// SM1: data receiver — samples DOUT, autopushes 32-bit words to RX FIFO
let data_prg = pio_proc::pio_asm!(
    ".wrap_target",
    "    nop",           // BCK low — data transitioning
    "    in pins, 1",    // BCK high — sample stable DOUT
    ".wrap",
);

// Both SMs share the same clock divider (150MHz / 12.288MHz ≈ 12.207)
// SM1 uses FifoJoin::RxOnly for 8-entry FIFO depth
// DMA ping-pong: embassy_futures::join overlaps DMA + CPU decimation
Legacy C equivalent
// Load I2S receive program into PIO0
uint offset = pio_add_program(pio0, &i2s_rx_program);
i2s_rx_program_init(pio0, 0, offset,
    GPIO_I2S_SCK, GPIO_I2S_WS, GPIO_I2S_DIN,
    96000);  // 96kHz sample rate

// DMA channel A: PIO FIFO → ping buffer
// DMA channel B: PIO FIFO → pong buffer (double-buffer)
// On DMA completion IRQ, swap buffers and call xylolabs_audio_feed()

DMA double-buffering ensures continuous capture with zero CPU cycles spent waiting for audio data.


Dual-Core Architecture

The RP2350's two cores map naturally to the SDK's audio + network workload:

Core Responsibility Budget
Core 0 I2S capture, FIR downsample (96kHz→16kHz), XAP encode, ring buffer write ~80 MIPS
Core 1 Sensor polling, XMBP assembly, LTE-M1 TCP, xylolabs_tick(), watchdog ~40 MIPS
// Rust (Embassy) - Recommended
use embassy_rp::multicore::{spawn_core1, Stack};
use embassy_executor::Spawner;

static mut CORE1_STACK: Stack<4096> = Stack::new();

// Core 1: network + sensor loop
#[embassy_executor::task]
async fn network_task(stack: embassy_net::Stack<'static>) {
    let mut client = XylolabsClient::new(stack);
    client.set_api_key("xk_your_key");
    client.set_device_id(1);
    client.session_create("pico_node_1", &streams).await;

    loop {
        poll_sensors(&mut client).await;
        client.tick().await;
        Timer::after_millis(10).await;
    }
}

// Core 0: audio capture loop
#[embassy_executor::task]
async fn audio_task(mut pio_rx: PioRx<'static>) {
    let mut buf = [0i16; AUDIO_BLOCK_SAMPLES];
    loop {
        pio_rx.read(&mut buf).await;
        xylolabs_audio_feed(&buf);
    }
}
Legacy C equivalent
// core1_entry: network + sensor loop
void core1_entry(void) {
    xylolabs_init(&g_platform);
    xylolabs_set_api_key("xk_your_key");
    xylolabs_set_device_id(1);
    xylolabs_session_create("pico_node_1", streams, stream_count);

    while (1) {
        poll_sensors();
        xylolabs_tick();
        sleep_ms(10);
    }
}

int main(void) {
    multicore_launch_core1(core1_entry);

    // Core 0: audio capture loop
    while (1) {
        // Wait for DMA ping-pong IRQ
        uint32_t notif = multicore_fifo_pop_blocking();
        int16_t *buf   = (int16_t *)(uintptr_t)notif;
        xylolabs_audio_feed(buf, AUDIO_BLOCK_SAMPLES);
    }
}

XAP Codec on Cortex-M33

XAP is the default and recommended codec for RP2350:

XAP and XMBP are patent-pending technologies of Xylolabs Inc.

Metric Value
Compression ratio ~10:1
CPU cost (pure C) ~20 MIPS/ch
CPU cost (DSP-accelerated) ~14 MIPS/ch
4ch @96kHz total ~56 MIPS (DSP)
RP2350 budget 300 MIPS
Remaining for app ~244 MIPS

The Cortex-M33 DSP extensions provide single-cycle 32×32 MAC, dual 16-bit SIMD (SMLAD), and saturating arithmetic — reducing XAP encoding cost by ~30% versus plain C. See docs/CODEC-ANALYSIS.md for detailed benchmarks.

Bandwidth result: XAP at 80 kbps/channel × 4ch = 320 kbps = 40 KB/s, within LTE-M1's ~47 KB/s budget.


Memory Budget

Region Size Config macro
Audio ring buffer 32 KB XYLOLABS_AUDIO_RING_SIZE
XAP batch buffer ~4 KB XYLOLABS_XAP_BATCH_BYTES
XMBP packet buffer 16 KB XYLOLABS_XMBP_BUF_SIZE
HTTP buffer 4 KB XYLOLABS_HTTP_BUF_SIZE
Metadata accumulation ~12 KB 26ch × 100 samples × f32
XAP encoder state (4ch) ~8 KB
Stack + SDK internals ~12 KB
Total SDK ~88 KB 17% of 520 KB
Available for app ~432 KB

DMA Double-Buffering for Audio

// Rust (Embassy) - Recommended
use embassy_rp::dma::{AnyChannel, Channel};

// Embassy handles DMA double-buffering automatically via async read.
// The PIO DMA transfer yields to the executor while waiting,
// then returns the completed buffer for processing.
#[embassy_executor::task]
async fn audio_capture(mut rx_dma: AnyChannel, pio_rx: &mut PioStateMachine<'static>) {
    let mut ping = [0i16; AUDIO_BLOCK_SAMPLES * AUDIO_CHANNELS];
    let mut pong = [0i16; AUDIO_BLOCK_SAMPLES * AUDIO_CHANNELS];
    let mut use_ping = true;

    loop {
        let buf = if use_ping { &mut ping } else { &mut pong };
        pio_rx.rx().dma_pull(&mut rx_dma, buf).await;
        xylolabs_audio_feed(buf);
        use_ping = !use_ping;
    }
}
Legacy C equivalent
// Two ping-pong buffers for continuous DMA capture
static int16_t s_audio_ping[AUDIO_BLOCK_SAMPLES * AUDIO_CHANNELS];
static int16_t s_audio_pong[AUDIO_BLOCK_SAMPLES * AUDIO_CHANNELS];
static volatile bool s_use_ping = true;

void dma_irq_handler(void) {
    dma_hw->ints0 = 1u << DMA_CHANNEL_AUDIO;

    // Restart DMA on the other buffer
    int16_t *next = s_use_ping ? s_audio_pong : s_audio_ping;
    dma_channel_set_write_addr(DMA_CHANNEL_AUDIO, next, true);

    // Signal Core 0 to process completed buffer
    int16_t *done = s_use_ping ? s_audio_ping : s_audio_pong;
    multicore_fifo_push_blocking((uint32_t)(uintptr_t)done);
    s_use_ping = !s_use_ping;
}

This pattern guarantees zero-copy audio capture: the PIO fills one buffer via DMA while Core 0 encodes the other.


# Install dependencies
rustup target add thumbv8m.main-none-eabihf
cargo install probe-rs-tools

# Build
cd sdk/examples/rp2350-sensor
cargo build --release

# Flash
cargo run --release  # Uses probe-rs via .cargo/config.toml

See sdk/examples/ for all RP2350 examples.


Legacy C Build System

cmake_minimum_required(VERSION 3.13)
include($ENV{PICO_SDK_PATH}/external/pico_sdk_import.cmake)

project(my_xylolabs_pico C CXX ASM)
pico_sdk_init()

add_subdirectory(sdk/c/pico)

add_executable(my_xylolabs_pico
    src/main.c
    src/platform_pico.c
    src/sensors.c
)

target_link_libraries(my_xylolabs_pico
    pico_stdlib
    pico_multicore
    hardware_pio
    hardware_dma
    hardware_i2c
    hardware_spi
    hardware_watchdog
    hardware_timer
    xylolabs_sdk
)

# Generate UF2 for drag-and-drop flashing
pico_add_extra_outputs(my_xylolabs_pico)

Requirements: - Pico SDK >= 2.0.0 (for RP2350 support) - CMake >= 3.13 - arm-none-eabi-gcc toolchain


Platform Callbacks Implementation

// Rust (Embassy) - Recommended
use embassy_rp::uart::{Uart, Config as UartConfig};
use embassy_rp::watchdog::Watchdog;
use embassy_time::Instant;

struct PicoPlatform {
    uart: Uart<'static, embassy_rp::peripherals::UART0>,
    watchdog: Watchdog,
}

impl PicoPlatform {
    async fn tcp_connect(&mut self, host: &str, port: u16) -> Result<(), XylolabsError> {
        let cmd = format!("AT+QIOPEN=1,0,\"TCP\",\"{}\",{},0,1\r\n", host, port);
        at_send_expect(&mut self.uart, &cmd, "CONNECT", 10_000).await
    }

    async fn tcp_send(&mut self, data: &[u8]) -> Result<(), XylolabsError> {
        at_tcp_send(&mut self.uart, data).await
    }

    fn get_time_us(&self) -> u64 {
        Instant::now().as_micros()
    }

    fn watchdog_feed(&mut self) {
        self.watchdog.feed();
    }
}
Legacy C equivalent
// platform_pico.c
#include "xylolabs/client.h"
#include "pico/stdlib.h"
#include "hardware/watchdog.h"
#include "hardware/timer.h"
#include "uart_at.h"  // AT command driver for LTE modem

static xylolabs_err_t pico_tcp_connect(const char *host, uint16_t port) {
    // AT+QIOPEN or AT+CIPOPEN depending on modem
    char cmd[128];
    snprintf(cmd, sizeof(cmd),
        "AT+QIOPEN=1,0,\"TCP\",\"%s\",%u,0,1\r\n", host, port);
    return at_send_expect(cmd, "CONNECT", 10000) == AT_OK
        ? XYLOLABS_OK : XYLOLABS_ERR_NETWORK;
}

static xylolabs_err_t pico_tcp_send(const uint8_t *data, size_t len) {
    return at_tcp_send(data, len) == AT_OK
        ? XYLOLABS_OK : XYLOLABS_ERR_NETWORK;
}

static uint64_t pico_get_time_us(void) {
    return time_us_64();
}

static void pico_sleep_ms(uint32_t ms) {
    sleep_ms(ms);
}

static void pico_watchdog_feed(void) {
    watchdog_update();
}

const xylolabs_platform_t g_platform = {
    .tcp_connect      = pico_tcp_connect,
    .tcp_send         = pico_tcp_send,
    .tcp_recv         = pico_tcp_recv,
    .tcp_close        = pico_tcp_close,
    .tcp_is_connected = pico_tcp_is_connected,
    .get_time_us      = pico_get_time_us,
    .sleep_ms         = pico_sleep_ms,
    .watchdog_feed    = pico_watchdog_feed,
};

LTE-M1 Modem Integration

Tested modems and recommended AT command sets:

Modem Interface Notes
BG770A UART Cat-M1/NB-IoT; PSM and eDRX support
SIM7670G UART LTE-M1/NB2/GNSS combo

Modem Boot Sequence

// Rust (Embassy) - Recommended
use embassy_rp::gpio::{Output, Level};
use embassy_time::Timer;

async fn modem_boot(pwrkey: &mut Output<'static>, uart: &mut Uart<'static>) {
    // 1. Assert PWRKEY for 1.5s to power on
    pwrkey.set_high();
    Timer::after_millis(1500).await;
    pwrkey.set_low();
    Timer::after_millis(5000).await; // Wait for modem to register

    // 2. Init AT sequence
    at_send_expect(uart, "AT\r\n",             "OK", 1000).await.unwrap();
    at_send_expect(uart, "ATE0\r\n",           "OK", 1000).await.unwrap();
    at_send_expect(uart, "AT+CMEE=2\r\n",      "OK", 1000).await.unwrap();
    at_send_expect(uart, "AT+CEREG=1\r\n",     "OK", 1000).await.unwrap();
    at_send_expect(uart, "AT+CGDCONT=1,\"IP\",\"your.apn\"\r\n", "OK", 5000).await.unwrap();
    at_send_expect(uart, "AT+CGACT=1,1\r\n",   "OK", 30000).await.unwrap();
}
Legacy C equivalent
// 1. Assert PWRKEY for 1.5s to power on
gpio_put(GPIO_MODEM_PWRKEY, 1);
sleep_ms(1500);
gpio_put(GPIO_MODEM_PWRKEY, 0);
sleep_ms(5000);  // Wait for modem to register

// 2. Init AT sequence
at_send_expect("AT\r\n",             "OK", 1000);
at_send_expect("ATE0\r\n",           "OK", 1000);  // Echo off
at_send_expect("AT+CMEE=2\r\n",      "OK", 1000);  // Verbose errors
at_send_expect("AT+CEREG=1\r\n",     "OK", 1000);  // Network registration URC
at_send_expect("AT+CGDCONT=1,\"IP\",\"your.apn\"\r\n", "OK", 5000);
at_send_expect("AT+CGACT=1,1\r\n",   "OK", 30000); // Activate PDP

Power Management

Dormant Mode Between Batches

The RP2350 supports dormant mode (deep sleep with GPIO wakeup) and sleep mode (timer wakeup). For continuous streaming, use sleep between ticks:

// Rust (Embassy) - Recommended
// Embassy executor automatically enters WFE between async polls.
// PLL stays on; wakeup via timer interrupt.
Timer::after_millis(10).await;
Legacy C equivalent
// Lightweight sleep: PLL stays on, wakeup via timer
best_effort_wfe_or_timeout(make_timeout_time_ms(10));

Watchdog Configuration

The SDK feeds the watchdog on every xylolabs_tick(). Configure the hardware watchdog:

// Rust (Embassy) - Recommended
use embassy_rp::watchdog::Watchdog;

// 8-second watchdog timeout
let mut watchdog = Watchdog::new(p.WATCHDOG);
watchdog.start(Duration::from_millis(8_000));
// SDK feeds watchdog on every tick
watchdog.feed();
Legacy C equivalent
// 8-second watchdog timeout
watchdog_enable(XYLOLABS_WATCHDOG_TIMEOUT_MS, true);
// pause_on_debug = true prevents false resets during debugging

Rust is the recommended language for new RP2350 projects. The Embassy-based Rust example provides async task management with compile-time safety guarantees.

cd sdk/examples/rp2350-audio
cargo build --release
# Flash the resulting ELF via probe-rs
probe-rs run --chip RP2350 target/thumbv8m.main-none-eabihf/release/rp2350-audio

The Rust example implements the same dual-core audio + sensor streaming, using Embassy tasks instead of raw C callbacks. See sdk/examples/README.md for detailed build instructions and all 10 MCU variants.


C Examples (alternative) ### C Examples The `sdk/c/pico/examples/` directory contains ready-to-run C examples: | Example | Description | |---------|-------------| | `continuous_stream.c` | Full dual-core audio (XAP) + 26ch sensor streaming | | `periodic_sampling.c` | Sensor-only node with periodic LTE-M1 uploads | | `audio_upload.c` | XAP audio encoding demo with bandwidth measurement |

Troubleshooting

Symptom Likely cause Fix
Watchdog reset loop xylolabs_tick() not called frequently enough Ensure tick runs at least every 4 seconds
Audio ring overflow Core 1 network blocking for too long Increase XYLOLABS_AUDIO_RING_SIZE or reduce batch interval
Modem not responding UART baud mismatch or PWRKEY timing Check baud rate (default 115200) and PWRKEY pulse duration
XAP encode too slow Clock too low or optimization flags missing Ensure -O2 or -O3 and -mcpu=cortex-m33+nodsp with CMSIS-DSP
I2S data corrupted PIO clock divider miscalculated Recalculate: clkdiv = sys_clk / (96000 * 64 * 2)
High latency on send TCP send blocking Core 1 Move TCP into its own task with mutex-protected ring handoff

Further Reading

  • docs/FEASIBILITY-RP2350.md — Full bandwidth and CPU feasibility analysis
  • docs/CODEC-ANALYSIS.md — Detailed XAP vs ADPCM benchmark results
  • sdk/c/pico/README.md — SDK file listing and quick-start build instructions
  • Pico SDK documentation
  • RP2350 datasheet