Rust & Raspberry Pi Pico (Blink)

Written by
Kevin Asbury

This article will show you how to use Rust instead of C for the Raspberry Pi Pico Microcontroller

While looking for a new hobby, I picked up microcontrollers out of curiosity. I like to take things apart and see how they work, so it’s become a joy for me to collect and pick apart electronics and begin learning how to make things with them. I started with Arduino — a wonderful place to begin, and I highly recommend it for people who are new to microcontrollers and programming. 

After Arduino, I wanted to explore the world of Raspberry Pi, but I ran into issues finding a Raspberry Pi due to a shortage of microchips. I found one chip in abundance and bought several of them: the Raspberry Pi Pico. They had just hit the market, and my timing couldn’t have been better. 

Programming the Pico is arguably pretty simple because using C/C++ isn’t a far stretch from Arduino C. The more programs I built, however, I really started to dread using C/C++, most notably because of the CMake headaches. I won’t turn this into a CMake bashing blog post or an I love Rust post and instead dig into the basics of starting with Rust on the pico controller-for those with an interest in trying it out.

Assumptions

This article is going to assume you are familiar with microcontroller and Rust language basics and that we can skip the introductions both along with environment setup and tooling setup.

Materials

  • 1 (preferably 2) Raspberry Pi Pico boards
  • Electronics breadboard
  • Breadboard wires (for connecting 2 Picos together)

Environment

  • Rust language
  • VSCode or a preferred IDE
  • Python3

Rust Setup

Run the following commands to get your rust environment setup:


$ rustup target add thumbv6m-none-eabi
$ cargo install cargo-binutils
$ rustup component add llvm-tools-preview
 

One Pico Board Setup

If you want to work with one Pico microcontroller, then you will need to convert the cargo build output binary (.ELF) into the.UF2 binary format. You can do this with a tool called elf2uf2. In your terminal, run the following in the project’s directory:


$ cargo install elf2uf2-rs
 

After the tool is installed, you can use the tool after a build of your project to produce a.uf2 file.


$ cargo build -release
$ elf2uf2-rs target/thumbv6m-none-eabi/release/app
 

If you want to automatically run elf2uf2 when you type cargo run or cargo build -  in the .cargo/config.toml, you need to set your runner to elf2uf2-rs:


[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "elf2uf2-rs"
 

And for further automation, if you want to automatically flash your Pico board with the new uf2 binary, put your Pico in transfer mode, connect it to the usb, and add the -d option like so:


$ cargo build -release
$ elf2uf2-rs -d target/thumbv6m-none-eabi/release/app
 

Two Pico Board Setup

If you haven’t setup your board for debugging yet, you can start here: https://www.raspberrypi.com/documentation/microcontrollers/raspberry-pi-pico.html#debugging-using-another-raspberry-pi-pico

Make sure to download the picoprobe UF2 file from the link above and flash one of the Picos with this file. This will become your debug Pico.

Also, see Apendix A of https://datasheets.raspberrypi.com/pico/getting-started-with-pico.pdf for a diagram of how to wire your two Picos together for debugging. This is where you will be using the breadboard and wires.

With a picoprobe debugger, we can use the Rust default output format binary (.ELF). To flash the.elf file onto the Pico, we will use OpenoCD.

Let’s install OpenoCD

If you have used the C/C++ Pico SDK, you probably already have this installed. You can skip this section if you do.

For MacOS:


$ brew install open-ocd
 

For Ubuntu:


$ sudo apt update
$ sudo apt -y install openocd
 

For Windows: 

There are several approaches, and the simplest (at least for me) is to use WSL and install Ubuntu from the Microsoft Store. You’ll then need to install some open source software to connect USB devices to WSL: https://docs.microsoft.com/en-us/windows/wsl/connect-usb 

If you prefer MINGW, then checkout the Windows section of https://mynewt.apache.org/v1_6_0/get_started/native_install/cross_tools.html

Let’s Install GDB

If you have used the C/C++ Pico SDK, you probably already have this installed. You can skip this section if you do.

For MacOS:


$ brew tap ArmMbed/homebrew-formulae
$ brew install arm-none-eabi-gcc
 

For Ubuntu:


$ sudo apt install git gdb-multiarch
 

For Windows:

Same as installing Openocd. I would suggest using WSL and Ubuntu. But if you prefer not to use WSL, you can obtain the ARM-GCC compilation toolchain for Windows and install it with a simple installer. https://developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-rm/downloads

Make The LED Blink

Finally the fun part – the coding! 

Project Layout:


|	(root folder)
|	-.cargo
|		|	config.toml
|	-src
|		|	main.rs
|	build.rs
|	Cargo.toml
|	memory.x
|	release.py
 

.cargo/config.toml


[target.'cfg(all(target_arch = "arm", target_os = "none"))']
# runner = "elf2uf2-rs -d"
runner = "python3 release.py"

rustflags = [
 "-C", "linker=flip-link",
 "-C", "link-arg=--nmagic",
 "-C", "link-arg=-Tlink.x",
 "-C", "link-arg=-Tdefmt.x",
 "-C", "inline-threshold=5",
 "-C", "no-vectorize-loops",
]

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

[env]
DEFMT_LOG = "debug"
 

Notably, I use a custom runner because passing several command line arguments to OpenOCD directly through the config.toml is an exercise in frustration. The code for this custom runner is below. You will need Python 3 installed on your system.

If you are using one Pico, uncomment # runner = "elf2uf2-rs -d" and then comment out runner = "python3 release.py".

src/main.rs


#![no_std]
#![no_main]

use cortex_m_rt::entry;
use defmt_rtt as _;
use embedded_hal::digital::v2::OutputPin;
use embedded_time::fixed_point::FixedPoint;
use panic_probe as _;
use rp2040_hal as hal;

use hal::{
   clocks::{init_clocks_and_plls, Clock},
   pac,
   watchdog::Watchdog,
   Sio,
};

#[link_section = ".boot2"]
#[used]
pub static BOOT2: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;

#[entry]
fn main() -> ! {
   let mut pac = pac::Peripherals::take().unwrap();
   let core = pac::CorePeripherals::take().unwrap();
   let mut watchdog = Watchdog::new(pac.WATCHDOG);
   let sio = Sio::new(pac.SIO);

   let external_xtal_freq_hz = 12_000_000u32;
   let clocks = init_clocks_and_plls(
       external_xtal_freq_hz,
       pac.XOSC,
       pac.CLOCKS,
       pac.PLL_SYS,
       pac.PLL_USB,
       &mut pac.RESETS,
       &mut watchdog,
   )
   .ok()
   .unwrap();

   let mut delay = cortex_m::delay::Delay::new(core.SYST, clocks.system_clock.freq().integer());

   let pins = hal::gpio::Pins::new(
       pac.IO_BANK0,
       pac.PADS_BANK0,
       sio.gpio_bank0,
       &mut pac.RESETS,
   );

   let mut led_pin = pins.gpio25.into_push_pull_output();

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

Ok, wow, there’s a lot going on here! But don’t sweat it. You can pick up an understanding of most of this by reading the Embedded Rust Book: https://docs.rust-embedded.org/book/intro/index.html

build.rs


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");
}
 

Cargo.toml


[package]
authors = ["Kevin Asbury <kevin@fullstacklabs.co>"]
edition = "2018"
readme = "README.md"
name = "app"
version = "0.1.0"

[dependencies]
rp-pico = "0.3.0"
cortex-m = "0.7.3"
cortex-m-rt = "0.7.0"
embedded-time = "0.12.0"
panic-probe = { version = "0.3.0", features = ["print-defmt"] }
rp2040-hal = { version="0.4.0", features=["rt"] }
rp2040-boot2 = "0.2.0"
defmt = "0.3.0"
defmt-rtt = "0.3.0"
embedded-hal ="0.2.5"
panic-halt= "0.2.0"

# this lets you use `cargo fix`!
[[bin]]
name = "app"
test = false
bench = false

[profile.release]
codegen-units = 1
debug = false
debug-assertions = false
overflow-checks = false
panic = 'abort'
lto = true
opt-level = "z"
incremental = false
strip = true

[profile.dev]
opt-level = 0
debug = 2
debug-assertions = true
overflow-checks = true
lto = false
panic = 'unwind'
incremental = true
codegen-units = 256
 

Most importantly, this is where we create the release and development profiles. I have optimized the release settings to make a small rust executable because on microcontrollers we are limited on space. You find the executable after running cargo build –release at target/thumbv6m-none-eabi/release/

Example output showing the rust app file is only 13 kilobytes:


$ -rwxr-xr-x    1 kevin  staff    13K Jun 13 19:00 app
 

The same blink program written in C++ with the same compiler optimizations is 30 kilobytes:


$ -rwxr-xr-x   1 kevin  staff    30K Jun 13 19:48 blink.elf
 

memory.x


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

EXTERN(BOOT2_FIRMWARE)

SECTIONS {
    /* ### Boot loader */
    .boot2 ORIGIN(BOOT2) :
    {
        KEEP(*(.boot2));
    } > BOOT2
} INSERT BEFORE .text;
 

Without diving too deep into computer science terminology here, this file describes the memory layout of your pico board. Pico has 264 kilobytes of RAM available and part of it is used for the boot and the flash. The compiler needs to know how to find where each section begins and ends.

release.py


import os
import sys

interface = "interface/picoprobe.cfg"
target = "target/rp2040.cfg"
program = f'-c "program {sys.argv[1]} verify reset exit"'
cmd = f'-f {interface} -f {target} {program}'
os.system(f'openocd {cmd}')  # execute openocd
 

Here is my amazing and auto-magical Python script! Actually, I’m not much of a Python programmer, but this script gets the job done much more easily than my attempts at doing this in the config.toml file. It runs the command:


$ openocd -f interface/picoprobe.cfg -f target/rp2040.cfg -c "program app verify reset exit”
 

You can very much run yourself from the command line – just make sure you type out the full path to your app file or cd into the directory with the app file (that’s what I do).

And that is it!

Once you have this working, it becomes the basic template for all your pico projects.

I hope you enjoyed the read. Thank you!