Embedded Rust - Raspberry Pi Pico

In this chapter, we'll be looking at programming a Raspberry Pi Pico board. This board is relatively cheap and is great to learn embedded development. The Raspberry Pi Pico development board is equipped with an ARM Cortex M0+ processor. The full specifications can be found here: raspberry-pi-pico.

Getting prepared

Hardware

In order to build your first embedded project you need the following:

  • At least two Raspberry Pi Pico development boards,
  • Two breadboards with some common components; look for a bundle:
    • Jumper wire cables,
    • Some LEDs, resistors and push buttons.
  • One Micro USB cable.

It also helps to have some soldering gear.

If you are not soldering the pins to the Pico board, do make sure the connections to the breadboard are solid and stable. You can easily lose a few hours debugging your source code, before figuring our that one of the connections is broken, or unstable.

Software

First we'll install probe-rs which is a tool to flash and debug embedded devices.

If you have cargo-binstall installed, you can install probe-rs with:

$ cargo binstall probe-rs

If not, you can build from source with:

$ cargo install probe-rs --locked --features cli

I had to restart my terminal to get the probe-rs command to work, i.e. to be found in the path. It should be in ~/.cargo/bin/probe-rs.

Rust ARM target

We'll also need the build toolchain for the ARM Cortex M0 processor. This includes the ARM cross-compile tools, OpenOCD and the Rust ARM target.

Make sure you have the thumbv6m-none-eabi target installed. We'll use the rustup tool to install it:

$ rustup target add thumbv6m-none-eabi

Connect the Raspberry Pi Pico

In order to program the Raspberry Pi Pico board, you need to connect it to your PC using the USB cable. You need two Pico boards for this task. The first board will be the programmer, and the second board will be the target.

Schematic:

The schematic for connecting the two Pico boards is as follows:

Connect RPI Pico

Pinout of the Raspberry Pi Pico

The pinout of the Raspberry Pi Pico is as follows:

Pinout RPI Pico

Wiring

  • White: Connect pin 4 (SPIO SCK) on the programmer Pico to the SWDCLK pin on the target Pico,
  • Purple: Connect pin 5 (SPIO TX) on the programmer Pico to the SWDIO pin on the target Pico,
  • Green: Connect pin 6 (UART1 TX) on the programmer Pico to pin 2 (UARTO RX) on the target Pico,
  • Yellow: Connect pin 7 (UARTI RX) on the programmer Pico to pin 1 (UARTO TX) on the target Pico,
  • Red: Connect pin 39 (VSYS) on the programmer Pico to pin 39 (VSYS) on the target Pico,
  • Black: Connect pin 38 (GND) pin on the programmer Pico pin 38 (GND) on the target Pico.
  • The ground (GND) pin on the target Pico should be grounded; this is the black wire between the purple and white one,

Flash the programmer Pico

Once you have everything wired up you need to install the debugprobe firmware on the programmer Pico board. This is done by downloading the debugprobe_on_pico.uf2 file from the Debugging using another Raspberry Pi Pico website.

Hold down the BOOTSEL button when you plug in your programmer Pico. You should now see the pico appear as a USB drive. Drag and drop the debugprobe_on_pico.uf2 file onto the USB drive. The Pico will reboot and you should now be able to see the debug probe when you run the probe-rs command.

$ probe-rs list

The following debug probes were found:
[0]: Debugprobe on Pico _CMSIS_DAP_ (VID: 2e8a, PID: 000c, Serial: E6612483CB5A612A, CmsisDap)

Quick start

We'll be using the excellent embassy crate to program the Pico. The embassy crate is a framework for building concurrent firmware for embedded systems. It is based on the async and await syntax, and is designed to be used with the no_stdenvironment.

Create a new hello_pico_world project with the following files; take care to put them in the correct directories:

cargo new hello_pico_world
cd hello_pico_world

In the project root:

Cargo.toml

[package]
name = "hello_pico_world"
version = "0.1.0"
edition = "2021"

[dependencies]
embassy-embedded-hal = { version = "0.1.0", git = "https://github.com/embassy-rs/embassy", features = ["defmt"] }
embassy-sync = { version = "0.5.0", git = "https://github.com/embassy-rs/embassy", features = ["defmt"] }
embassy-executor = { version = "0.5.0", git = "https://github.com/embassy-rs/embassy", features = ["task-arena-size-32768", "arch-cortex-m", "executor-thread", "executor-interrupt", "defmt", "integrated-timers"] }
embassy-time = { version = "0.3", git = "https://github.com/embassy-rs/embassy", features = ["defmt", "defmt-timestamp-uptime"] }
embassy-rp = { version = "0.1.0", git = "https://github.com/embassy-rs/embassy", features = ["defmt", "unstable-pac", "time-driver", "critical-section-impl"] }

cortex-m = { version = "0.7.6", features = ["inline-asm"] }
cortex-m-rt = "0.7.0"
panic-probe = { version = "0.3", features = ["print-defmt"] }
defmt = "0.3"
defmt-rtt = "0.4"

Next to the Cargo.toml, create a build script:

build.rs

//! This build script copies the `memory.x` file from the crate root into
//! a directory where the linker can always find it at build time.
//! For many projects this is optional, as the linker always searches the
//! project root directory -- wherever `Cargo.toml` is. However, if you
//! are using a workspace or have a more complicated build setup, this
//! build script becomes required. Additionally, by requesting that
//! Cargo re-run the build script whenever `memory.x` is changed,
//! updating `memory.x` ensures a rebuild of the application with the
//! new memory settings.

use std::env;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;

fn main() {
    // Put `memory.x` in our output directory and ensure it's
    // on the linker search path.
    let out = &PathBuf::from(env::var_os("OUT_DIR").unwrap());
    File::create(out.join("memory.x"))
        .unwrap()
        .write_all(include_bytes!("memory.x"))
        .unwrap();
    println!("cargo:rustc-link-search={}", out.display());

    // By default, Cargo will re-run a build script whenever
    // any file in the project changes. By specifying `memory.x`
    // here, we ensure the build script is only re-run when
    // `memory.x` is changed.
    println!("cargo:rerun-if-changed=memory.x");

    println!("cargo:rustc-link-arg-bins=--nmagic");
    println!("cargo:rustc-link-arg-bins=-Tlink.x");
    println!("cargo:rustc-link-arg-bins=-Tlink-rp.x");
    println!("cargo:rustc-link-arg-bins=-Tdefmt.x");
}

Add this memory.x file:

memory.x

MEMORY {
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100

/* Pick one of the two options for RAM layout     */

/* OPTION A: Use all RAM banks as one big block   */
/* Reasonable, unless you are doing something     */
/* really particular with DMA or other concurrent */
/* access that would benefit from striping        */
RAM   : ORIGIN = 0x20000000, LENGTH = 264K

/* OPTION B: Keep the unstriped sections separate */
/* RAM: ORIGIN = 0x20000000, LENGTH = 256K        */
/* SCRATCH_A: ORIGIN = 0x20040000, LENGTH = 4K    */
/* SCRATCH_B: ORIGIN = 0x20041000, LENGTH = 4K    */
}

Then add a project-specific configuration file:

.cargo/config.toml

[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip RP2040"

[build]
target = "thumbv6m-none-eabi"        # Cortex-M0 and Cortex-M0+

[env]
DEFMT_LOG = "debug"

And finally edit the src/main.rs file to look like:

src/main.rs

#![no_std]
#![no_main]

use defmt::*;
use embassy_executor::Spawner;
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let _p = embassy_rp::init(Default::default());

    loop {
        info!("Hello Pico World!");
        Timer::after_secs(1).await;
    }
}

Your project structure should look like this:

.
├── Cargo.toml
├── memory.x
├── .cargo
│   └── config.toml
├── build.rs
└── src
    └── main.rs

First run

Now that you have the project set up, you can build and flash it to the Pico board:

$ cargo run --release

After the standard build steps are complete, you should see something like this:

      Erasing ✔ [00:00:00] [##########################################################################################################################################################] 16.00 KiB/16.00 KiB @ 90.04 KiB/s (eta 0s )
  Programming ✔ [00:00:00] [##########################################################################################################################################################] 16.00 KiB/16.00 KiB @ 41.75 KiB/s (eta 0s )    Finished in 0.587s

0.002258 INFO  Hello Pico World!
└─ <mod path> @ └─ <invalid location: defmt frame-index: 4>:0   
1.002317 INFO  Hello Pico World!
└─ <mod path> @ └─ <invalid location: defmt frame-index: 4>:0   
2.002338 INFO  Hello Pico World!
└─ <mod path> @ └─ <invalid location: defmt frame-index: 4>:0   

If you encounter any build issues, double-check that the config.toml, memory.x and build.rs files are in the correct locations.

If you see the Hello Pico World! message, you have successfully flashed the Pico board with your first Rust program.

This project structure will be the foundation for the next steps in building more complex embedded projects. You may want to make a copy of this project structure to use as a template for future projects.

Running without the debugger.

Once loaded, your application is automatically executed when you power up the Pico board.

  • Connect a USB charger to the USB port on the board. Make sure to disconnect the programmer Pico board when doing this
  • Connect a 3.3v battery to the V3 and GND pins (red & black)

Next steps

Next we'll be building our first Blinky project using the breadboard and a few components.