12 Projects of Codemas: Day #1

Adapting PiHut's Raspberry Pi Pico Advent Calendar to Rust and Embassy

12 Projects of Codemas: Day #1

My family and I always enjoy doing Advent Calendars, and this year I thought it would be fun to try out Pi Hut's Maker Advent Calendar: The 12 Projects of Codemas. I've wanted to tinker with a Raspberry Pi Pico for awhile, and this seemed like a great excuse.

I managed to find this one on Ebay to avoid international shipping fees.

However, I didn't want to just follow along with the included tutorials, because these were in MicroPython and, well, I wanted to do it in Rust. So what follows will be a series where I attempt to adapt the included projects to be built with Rust and specifically Embassy.

Fair warning: I am brand new to the embedded world. So, if I have made horrendous mistakes, feel free to let me know in the comments and I'll gladly update the posts!

Unboxing Day 1

In the first box we have:

  • 1x Raspberry Pi Pico H (which I've come to learn the H means the headers are already soldered on for you. Convenient!)
  • 1x Micro-USB cable (to plug it in to your computer)
  • 1x 400-point solderless breadboard (to stick everything into)

What I've gathered so far about what makes the Pico different from normal Raspberry Pi is that it is a microcontroller not a microcomputer. So it can't run an OS like Linux, but instead you compile specific instruction sets for it to do a specific task, likely through physical components like LEDs, buttons, sensors, etc through all of the pins available on those headers sticking out of the bottom of the chip.

One other thing I purchased, because I read that it seemed to make debugging with Rust easier, was a Raspberry Pi Debug Probe. This doesn't seem to be strictly necessary, but it does seem to make things like print debugging easier, so I set it up as well.

My Pico and Debug Probe all hooked up and working!

So you connect both via USB to your computer, and then connect the Debug Probe to the Pico via the two cables and the breadboard. Raspberry Pi had a quick video that I found helpful to figure out which cable went where.

Software Setup

Step 1: Install rustup

To follow along, you will need the ability to install Rust toolchains. The rustup tool takes case of this for you. If you haven't done this already, follow the instructions and verify everything is working.

Step 2: Install probe-rs tooling

probe-rs is a set of tools for working with embedded programming. I personally don't understand everything it does yet, but so far it makes it possible for me to flash the Pico with new programs, which is very necessary.

There are several ways to install these, but if you followed along with my post on how to use cargo binstall, you can install it with:

cargo binstall probe-rs-tools

Step 3: Create a new Rust project

We will want a binary project, but since we will be messing with the project structure either way, any new cargo project will be a good starting point. Let's start with:

cargo new twelve-days-of-codemas

This will create a new git repo, so feel free to already do an initial commit and push it somewhere. Mine is on GitHub if you ever need to reference it as you follow along.

Then, since we will be creating several projects, but can utilize an identical setup since we are using the same board, let's move the src/main.rs file into a src/bin directory since Cargo supports having multiple binaries in the same project this way.

mkdir src/bin
mv src/main.rs src/bin/day1_blink.rs

To make sure everything still builds we can then run:

cargo run --bin day1_blink

Finally, in your Cargo.toml, add the following at the bottom:

[profile.release]
debug = 2
lto = true
opt-level = 'z'

This will make sure, even in release, you have access to debug info, which I think will come in handy when we try to run our programs, but it still applies extra size optimizations. I'm still new to this, but all of the examples I've seen for running programs on microcontrollers have you run in --release mode, most likely so that the program still fits on the board, but then by default release mode strips out debug info, which you want when you have it attached and running.

My cursory understanding is all of the debug tooling for Rust embedded also puts all of the actual work onto the debug probe, not your program, which should mean it is fine to leave it in there, but I will need to dig more into this as we get further.

For now, I will trust the fact that every example I've seen has this release setting like this and will run with it.

Step 4: Configure the target

In order to cross-compile from our Mac/Windows/Linux machine for the Pico, we will need to let Cargo know which architecture we are targeting, since we can't actually compile on the chip itself. Thankfully Cargo/rustup make this easy to do, but we have to pass in a flag to do it, and also download the necessary artifacts to do so.

Since we want to basically always use this target for every Cargo command related to our project, we can specify this with a rust-toolchain.toml file in the root of the project.

[toolchain]
channel = "stable"
components = ["rust-src", "rustfmt"]
targets = ["thumbv7em-none-eabihf"]

rust-toolchain.toml

Once this is present, it will inform rustup to download the necessary target as well as related components we need to compile for the Pico processor.

This will make sure all of the necessary components are available, but we also need to tell Cargo to use them. To do that, we can make a special Cargo config just for this project in a .cargo/config.toml in the root of our project as well:

[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"

.cargo/config.toml

This tells Cargo to use the given target when building, and also specifies a custom runner when we want to use cargo run, which uses probe-rs to flash the Pico with our program, and also passes a specific chip, RP2040, which the Pico uses.

Step 5: Custom build.rs

We're almost there. We also need to provide all of this tooling with the memory layout of our chip. Looking at many examples, we do this with a memory.x file, also at the root of the project:

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    */
}

memory.x - I'll stick with the uncommented version for now!

I will not pretend I know how this is used, but based on reading the technical specifications of the microcontroller, this seems to be telling the tooling which parts of the memory is for loading the program, and which is available as RAM at runtime. And this makes sure our program ends up in the correct part. If you have a better explanation, please put it in the comments πŸ˜„

I think it is possible this would already get picked up for basic projects, but since I plan to create multiple binaries with the same project, it seems the consensus is to also add a build.rs file to ensure that this gets loaded for every binary:

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

build.rs

build.rs files allow for more customized build scripts, which you can read more about in the Cargo Book. But for now, it is enough to know that this makes sure all builds in this project will be ready for our chip.

Step 6: Finally, some code

And with that, we can finally revisit our src/bin/day1_blink.rs file! It is likely that your IDE is complaining, because println! requires features that aren't available for our target. So let's fix that. In order to get a version that compiles, we need to bring in a few dependencies. You can add the following to your Cargo.toml:

[dependencies]
cortex-m = { version = "0.7.7", features = ["inline-asm"] }
cortex-m-rt = "0.7.5"
defmt = "0.3.10"
defmt-rtt = "0.4.1"
embassy-executor = { version = "0.6.3", features = [
    "arch-cortex-m",
    "defmt",
    "executor-interrupt",
    "executor-thread",
    "integrated-timers",
    "task-arena-size-98304",
] }
embassy-rp = { version = "0.2.0", features = [
    "critical-section-impl",
    "defmt",
    "time-driver",
    "unstable-pac",
] }
embassy-time = { version = "0.3.2", features = [
    "defmt",
    "defmt-timestamp-uptime",
] }
panic-probe = { version = "0.3.2", features = ["print-defmt"] }

Cargo.toml. Valid versions as of 12/2/2024. Newer versions should be fine.

And then we need to also update our day1_blink.rs file:

//! This example tests the RP Pico onboard LED.

#![no_std]
#![no_main]

use embassy_executor::Spawner;
use {defmt_rtt as _, panic_probe as _};

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

src/bin/day1_blink.rs

This adds some attributes at the top to indicate we aren't using the std library, and also don't have a normal main function. Instead we use the embassy_executor to generate our main program, and also initialize the embassy_rp crate so it compiles the basics, and format and a panic handlers (defmt_rtt and panic_probe).

With this in place, you should be able to plug in your Pico and Debug Probe, and successfully run:

# Check that it all compiles
cargo build --bin day1_blink --release

# Flash the chip itself
cargo run --bin day1_blink --release

The program doesn't do anything at the moment, but we can verify that all of the tooling works at this point, which is a step in itself. Congrats!

Activity 1: Print

The first activity for the box is to just do a basic print, which is what the original bin.rs was trying to do. Let's see if we can get that to work.

Thanks to the defmt crate this should be pretty easy. We just have to add a call to info to our main and see what happens:

//! This example tests the RP Pico onboard LED.

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use {defmt_rtt as _, panic_probe as _};

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_rp::init(Default::default());
    info!("Hello, world!");
}

Let's just add a "Hello, world!" at the bottom.

And then running cargo run --bin day1_blink --release again should produce:

    Finished `release` profile [optimized + debuginfo] target(s) in 0.69s
     Running `probe-rs run --chip RP2040 target/thumbv6m-none-eabi/release/day1_blink`
      Erasing βœ” [00:00:00] [#############################################################] 12.00 KiB/12.00 KiB @ 82.44 KiB/s (eta 0s )
  Programming βœ” [00:00:00] [#############################################################] 12.00 KiB/12.00 KiB @ 33.36 KiB/s (eta 0s )    Finished in 0.535s
INFO  Hello, world!
└─ day1_blink::____embassy_main_task::{async_fn#0} @ src/bin/day1_blink.rs:15

Magic!

And there it is! We are able to print from our Pico!

Activity 2: Light the Onboard LED

The Pico comes with an onboard LED, which according to the Pinout map is GPIO 25 (not one of the pins on the edge of the board).

We can tell the program to turn this pin off and on, which will be our first attempt at getting the Pico to control something in the "real world"!

GPIO stands for General Purpose Input Output, but in this case we just want to use it as an Output, so we'll need to tell our program that.

//! This example tests the RP Pico onboard LED.

#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_rp::gpio::{Level, Output};
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());
    let mut led = Output::new(p.PIN_25, Level::Low);

    loop {
        info!("led on!");
        led.set_high();
        Timer::after_secs(1).await;

        info!("led off!");
        led.set_low();
        Timer::after_secs(1).await;
    }
}

We first define our led variable as an Output with the Pin number 25 and an initial output level of low (or "off").

We then loop indefinitely (as long as there is power to this thing), and print "led on!", set the output to high ("on") and wait for 1 second. Then we print "led off!" and set it back to low ("off") and wait for another second.

Note that we are using await here because Embassy uses async/await for stuff like timers.

And let's run the program one more time with cargo run --bin day1_blink --release:

   Compiling twelve-days-of-codemas v0.1.0 (/Users/benjamin.brandt/github/twelve-days-of-codemas)
    Finished `release` profile [optimized + debuginfo] target(s) in 1.22s
     Running `probe-rs run --chip RP2040 target/thumbv6m-none-eabi/release/day1_blink`
      Erasing βœ” [00:00:00] [#############################################################] 12.00 KiB/12.00 KiB @ 79.03 KiB/s (eta 0s )
  Programming βœ” [00:00:00] [#############################################################] 12.00 KiB/12.00 KiB @ 33.02 KiB/s (eta 0s )    Finished in 0.544s
INFO  led on!
└─ day1_blink::____embassy_main_task::{async_fn#0} @ src/bin/day1_blink.rs:18
INFO  led off!
└─ day1_blink::____embassy_main_task::{async_fn#0} @ src/bin/day1_blink.rs:22
INFO  led on!
└─ day1_blink::____embassy_main_task::{async_fn#0} @ src/bin/day1_blink.rs:18
INFO  led off!
└─ day1_blink::____embassy_main_task::{async_fn#0} @ src/bin/day1_blink.rs:22
INFO  led on!
└─ day1_blink::____embassy_main_task::{async_fn#0} @ src/bin/day1_blink.rs:18

So cool!

And there it goes! Hopefully you are seeing your little green LED light go on and off, with matching print statements!

Day #1 Complete!

I know it is such a little thing, but it really is so cool to be able to code something that affects the "real world" more so than a web server or a simple program on your usual computer.

I'm excited to see what the other projects bring, and now that the basic setup is ready, we should be able to jump right into the next project. Until next time!