How to Use Rust for Raspberry Pi Pico

Compile Rust for Raspberry Pi Pico by adding the armv6-none-eabi target and building with the --target flag.

The bare-metal reality

You plug a Raspberry Pi Pico into your USB port. It mounts as a drive. You drag a firmware file onto it, the onboard LED flashes, and your program runs. There is no operating system underneath. No kernel to manage memory. No standard library to fall back on when you call a function that expects a filesystem. Just silicon, a clock signal, and your instructions.

Rust handles this environment beautifully, but it requires a different workflow than the cargo run cycle you know from desktop development. The compiler needs explicit instructions about the hardware architecture, the memory layout, and the startup sequence. Once you configure those pieces, Rust gives you a systems language that catches memory bugs at compile time while running directly on the metal.

What the target triple actually means

Every Rust binary is compiled for a specific target. The target is a string that describes the CPU architecture, the vendor, the operating system, and the ABI. On a desktop, you usually compile for x86_64-unknown-linux-gnu or aarch64-apple-darwin. The unknown and darwin parts tell the compiler to link against an operating system's C library.

The Raspberry Pi Pico runs an ARM Cortex-M0+ processor. It has no operating system. The correct target string is thumbv6m-none-eabi. Let's break that down. thumb refers to the instruction set variant that packs instructions into 16-bit words to save flash space. v6m identifies the ARM architecture version and the M-profile for microcontrollers. none means there is no operating system. eabi stands for Embedded Application Binary Interface, which defines how functions pass arguments and how the stack behaves.

When you compile for this target, the Rust standard library (std) disappears. It relies on OS calls that simply do not exist on the Pico. You swap it for core, a subset of the standard library that contains no OS dependencies. You also bring in alloc if you need heap allocation, though most embedded code avoids it entirely.

Think of the target triple like a blueprint handed to a construction crew. On a desktop, the blueprint includes plumbing, electrical, and HVAC systems. On the Pico, the blueprint only specifies the foundation and the load-bearing walls. The compiler strips everything else out.

Verify the target string against the chip datasheet before you write a single line of code. A mismatched triple guarantees a hard fault on boot.

Setting up the toolchain

You need to add the embedded target to your Rust installation. Run this command in your terminal:

rustup target add thumbv6m-none-eabi

This downloads the precompiled standard library components for that architecture. You do not need to install a separate cross-compiler. Rust's rustc handles the translation entirely.

Next, you need a way to flash the binary onto the chip. The community standard is probe-rs. It communicates with the Pico's SWD (Serial Wire Debug) interface to upload code and run debuggers. Install it with:

cargo install probe-rs-tools

You also need a USB-to-serial adapter or a second Pico configured as a debugger if you plan to use SWD. For quick testing, the Pico supports mass storage flashing. You hold the BOOTSEL button while plugging it in, mount the RPI-RP2 drive, and copy a .uf2 file. The chip reboots and runs the new firmware automatically.

Keep your toolchain updated. Embedded crates evolve quickly to match silicon errata and HAL improvements. Run cargo update regularly to pull in hardware bug fixes.

Minimal example: a bare blink

Create a new binary crate. Add the required dependencies to Cargo.toml. The cortex-m crate provides low-level ARM register access. The cortex-m-rt crate generates the reset handler and exception vectors. The rp-pico crate contains the memory map and peripheral definitions for the board.

[package]
name = "pico-blink"
version = "0.1.0"
edition = "2021"

# Embedded crates require specific feature flags
[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
# The rt feature includes the linker script
rp-pico = { version = "0.5", features = ["rt"] }

Create a .cargo/config.toml file to tell Cargo how to link and flash. This file lives in your project root.

[build]
# Force cross-compilation for the Pico
target = "thumbv6m-none-eabi"

[target.thumbv6m-none-eabi]
# Automatically flash after successful build
runner = "probe-rs run --chip RP2040"
# Pass linker scripts provided by the HAL
rustflags = [
  "-C", "link-arg=-Tlink.x",
  "-C", "link-arg=-Tdefmt.x",
]

The link.x file is provided by the rp-pico crate. It tells the linker where RAM starts, where flash starts, and how to arrange the vector table. Without it, the linker produces a generic binary that the Pico cannot execute.

Write the entry point in src/main.rs. Embedded Rust requires a specific attribute to mark the reset handler.

#![no_std]
#![no_main]

// Import runtime and hardware abstraction layers
use cortex_m_rt::entry;
use rp_pico::hal;
use rp_pico::hal::pac;
use rp_pico::hal::Sio;

/// Entry point for the Cortex-M0+ reset vector
#[entry]
fn main() -> ! {
    // Initialize peripheral access crate registers
    let mut pac = pac::init();
    // Disable watchdog to prevent accidental resets
    let mut watchdog = hal::Watchdog::disable(&mut pac.RESETS);
    // Create SIO instance for GPIO and system control
    let mut cores = hal::Sio::new(pac.SIO, &mut pac.RESETS);
    
    // Configure system clock to 125 MHz for performance
    let mut clocks = hal::clocks::init_clocks_and_plls(
        12_000_000,
        pac.XOSC,
        pac.CLOCKS,
        pac.PLL_SYS,
        pac.PLL_USB,
        &mut pac.RESETS,
        &mut watchdog,
    ).ok().unwrap();

    let sio = Sio::new(pac.SIO);
    // Map physical pins to Rust types
    let pins = rp_pico::Pins::new(
        &mut pac.IO_BANK0,
        &mut pac.PADS_BANK0,
        sio.gpio_bank0,
        &mut pac.RESETS,
    );

    // Configure onboard LED as push-pull output
    let mut led = pins.led.into_push_pull_output();
    // Create a software delay using the system timer
    let mut delay = cortex_m::delay::Delay::new(cores.core0.sys, 125_000_000);

    loop {
        led.set_high();
        delay.delay_ms(500);
        led.set_low();
        delay.delay_ms(500);
    }
}

// Panic handler required for no_std environments
use cortex_m_rt::exception;
use defmt_rtt as _;

#[exception]
fn HardFault(ef: &cortex_m_rt::ExceptionFrame) -> ! {
    // Infinite loop prevents undefined behavior on crash
    loop {}
}

The #![no_std] attribute removes the standard library. The #![no_main] attribute tells the compiler you will provide your own entry point. The #[entry] macro generates the reset handler that the CPU jumps to on boot. The function signature -> ! indicates it never returns, which matches the infinite loop at the end.

Trust the type system here. The HAL prevents you from configuring the same pin twice. If you try, the compiler stops you before you can brick your board.

What happens during the build

When you run cargo build --release, the compiler translates your Rust code into ARM Thumb instructions. It does not produce an executable that the host OS can run. It produces an ELF file containing raw machine code, symbol tables, and memory layout metadata.

The linker reads the link.x script. It places the vector table at the very beginning of flash memory. The first entry is the initial stack pointer. The second entry is the reset handler address. The CPU hardware reads those two values on power-up. If the addresses are wrong, the chip hard faults immediately.

The --release flag matters more here than on desktop code. Debug builds include stack unwinding tables and panic handlers that consume significant RAM. The Pico has only 264 KB of SRAM. A debug build often exceeds that limit or leaves too little room for your application stack. Release builds strip debug metadata, optimize aggressively, and produce a binary that fits comfortably in the 2 MB flash partition.

After linking, probe-rs takes the ELF file, extracts the raw binary sections, and streams them over SWD to the Pico's flash controller. The chip verifies the checksum, programs the memory, and resets. Your code starts executing from the reset vector.

Always build in release mode for embedded targets. Debug binaries are diagnostic tools, not deployment artifacts.

Realistic workflow: handling peripherals

Blinking an LED is the embedded equivalent of Hello World. Real projects interact with sensors, displays, or communication buses. The rp2040 HAL abstracts the hardware registers into safe Rust types. You request a pin, configure its mode, and the compiler prevents you from using it twice simultaneously.

// Configure a pin for I2C communication
let i2c = hal::I2C::i2c0(
    pac.I2C0,
    pins.gpio4.into_floating_input(),  // SDA line
    pins.gpio5.into_floating_input(),  // SCL line
    &mut pac.RESETS,
    // Set bus speed to standard mode
    hal::config::I2CConfig::default().frequency(100_000),
);

The HAL uses Rust's type system to enforce hardware constraints. You cannot pass a pin configured as an output to a function that expects an I2C SDA line. The compiler rejects it with a type mismatch error. This eliminates entire classes of wiring mistakes before you even plug in the board.

When you need to log data, you cannot use println!. The std library's print macro relies on OS file descriptors. Embedded Rust uses defmt. It is a compact, low-level logging framework that encodes messages into a binary format. You connect it to RTT (Real Time Transfer) or a UART peripheral to stream logs to your host machine.

use defmt::*;
use defmt_rtt as _;

// Encodes message efficiently for RTT transport
info!("I2C initialized at {} Hz", 100_000);

The defmt macro compiles to a few bytes of machine code. It avoids string formatting overhead and runs safely in interrupt contexts. The community convention is to use defmt for all embedded logging. It integrates with probe-rs to print formatted output directly in your terminal.

Treat defmt as your primary debugging interface. Standard print macros will silently fail or panic in bare-metal contexts.

Common pitfalls and compiler signals

Cross-compiling for embedded hardware introduces specific failure modes. The most frequent one is forgetting the --release flag. The linker fails with a memory region overflow error. The debug build tries to allocate stack space for unwinding that the Pico simply does not have. Switch to release mode and the error disappears.

Another trap is mismatched target triples. If you accidentally compile for armv7m-none-eabi, the binary targets a Cortex-M3. The Pico's Cortex-M0+ does not support the instruction set extensions. The chip resets repeatedly or hard faults on the first unsupported instruction. Always verify the target string matches the datasheet.

You will also encounter E0463 (can't find crate for std) if you forget #![no_std]. The compiler assumes you want the standard library and searches for it in the target directory. It fails because std does not exist for bare-metal targets. Add the attribute to your root file and the error resolves.

Linker errors about missing link.x happen when the .cargo/config.toml is missing or misconfigured. The linker script is not optional. It defines the memory map. Without it, the linker places code at address zero and expects a generic OS loader to relocate it. The Pico has no loader. The binary crashes on boot.

Type mismatches appear as E0308 when you pass the wrong pin type to a peripheral constructor. The HAL enforces configuration states at compile time. If you try to use a pin as both an interrupt trigger and a PWM output, the borrow checker catches the conflict.

Treat the linker script as a hardware contract. If the memory regions in link.x do not match the chip's actual SRAM and flash sizes, your program will silently corrupt memory or overwrite its own instructions.

Choosing your tooling

Use thumbv6m-none-eabi when targeting the Raspberry Pi Pico or any ARM Cortex-M0+ microcontroller. Use thumbv7m-none-eabi when working with Cortex-M3 or M4 chips that support the Thumb-2 instruction set. Use probe-rs when you need fast flashing, hardware breakpoints, and live variable inspection. Use openocd when you require legacy GDB server compatibility or are working with older debugger hardware. Use defmt when you need structured logging that survives interrupt contexts and minimizes flash usage. Use panic-probe when you want panic messages to stream over SWD instead of halting the chip.

Where to go next