12 Projects of Codemas: Day #1
Adapting PiHut's Raspberry Pi Pico Advent Calendar to Rust and Embassy
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.
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.
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.
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:
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:
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:
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:
And then we need to also update our day1_blink.rs
file:
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:
And then running cargo run --bin day1_blink --release
again should produce:
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
:
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!