Ok, let's get a blinky program to work. As the ARM based RP2040 implementation of embedded Rust is one of the more developed ones and it's easy for me to plug in my Pico to my USB port, I will start with this, and expect to readily be able to port it to the WCH32V platform later.
Here's a suggested blinky program in Rust. The core of the program is recognisable, you can easily guess what's happening. I forget where I got this example from. No matter, you will see many similar examples on the Internet, and I can tweak the code later as my understanding improves.
#![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);
info!("blinky started");
loop {
led.set_high();
Timer::after_millis(500).await;
led.set_low();
Timer::after_millis(500).await;
}
}
In a project directory called rp2040-rust-blinky created with cargo new, or cargo init. I put the above code in src/main.rs. We also need a Cargo.toml at the top level which came from the example, which looks like this:
[package]
name = "rp2040-rust-blinky"
version = "0.1.0"
edition = "2021"
[dependencies]
defmt = "0.3"
defmt-rtt = "0.4"
panic-probe = { version = "0.3", features = ["print-defmt"] }
cortex-m-rt = "0.7"
#embassy-rp = { version = "0.4", features = ["rp2040","time-driver"] }
embassy-rp = { version = "0.10.0", features = ["rp2040","time-driver","critical-section-impl"] }
#embassy-time = { version = "0.4" }
embassy-time = "0.5.1"
#embassy-executor = { version = "0.6", features = ["arch-cortex-m","executor-thread","integrated-timers"] }
embassy-executor = { version = "0.10.0", features = ["platform-cortex-m","executor-thread"] }
# RP2040 HAL (Hardware Abstraction Layer)
#rp2040-hal = { version = "0.10", features = ["rt", "critical-section-impl"] }
rp2040-hal = { version = "0.12.0", features = ["rt", "critical-section-impl"] }
rp-pico = "0.9.0"
cortex-m = "0.7.7"
[profile.release]
debug = 2 # keep debug symbols for defmt
lto = true
codegen-units = 1
opt-level = "s"
This file states the dependency crates that are needed for this application. You can see that I updated some of the dependency versions as the example is 5 years old and many advances have been made.
There's another file called memory.x which contain ld instructions for the RP2040 layout. I won't explain it at this time.
MEMORY {
BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
RAM : ORIGIN = 0x20000000, LENGTH = 256K
}
EXTERN(BOOT2_FIRMWARE)
SECTIONS {
.boot2 ORIGIN(BOOT2) :
{
KEEP(*(.boot2));
} > BOOT2
} INSERT BEFORE .text;
Then I did cargo build.
After a huge amount of downloading of crates (dependencies pull in their dependencies recursively), then compiling, the result was an ELF binary in target/thumbv6m-none-eabi/debug/rp2040-rust-blinky.
The method I'm used to installing embedded programs on the RP2040 is by mounting it as USB storage and then copying a UF2 binary onto it. When you hold down reset button, plug the RP2040 in a USB port, and release the button, a new USB storage device appears and you can use your desktop manager to mount it at /run/media/<user>/RPI-RP2.
I found an ELF to UF2 converter. This also needs the libudev-devel package to build, just like probe-rs. I ran the converter on the ELF binary.
Once the RP2040 was mounted, I copied target/thumbv6m-none-eabi/debug/rp2040-rust-blinky.uf2 to it, and the onboard LED blinked. Note that the RP2040 will disappear as a USB device when the embedded blinky runs.
I'll figure out how to download the ELF binary directly another time.
About the binary
So how big is our blinky binary?
$ ls -lh target/thumbv6m-none-eabi/debug/rp2040-rust-blinky
-rwxr-xr-x 2 me users 2.9M Jul 23 21:09 target/thumbv6m-none-eabi/debug/rp2040-rust-blinky
Arrgh, how is this going to fit into my MCU's memory?
$ cargo-size
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
text data bss dec hex filename
258 0 0 258 102 rp2040-rust-blinky
Not to worry, most of the size consists of debug information which doesn't get downloaded to the MCU but is used by the debugger.
Unpacking the blinky program
Let's go through the program using line numbers:
1 #![no_std]
2 #![no_main]
3
4 use defmt::info;
5 use embassy_executor::Spawner;
6 use embassy_rp::gpio::{Level, Output};
7 use embassy_time::Timer;
8 use {defmt_rtt as _, panic_probe as _};
9
10 #[embassy_executor::main]
11 async fn main(_spawner: Spawner) {
12 let p = embassy_rp::init(Default::default());
13 let mut led = Output::new(p.PIN_25, Level::Low);
14
15 info!("blinky started");
16
17 loop {
18 led.set_high();
19 Timer::after_millis(500).await;
20 led.set_low();
21 Timer::after_millis(500).await;
22 }
23 }
Line 1 is an attribute that tells the Rust compiler that we don't want the standard framework for programs on an OS, but we are going bare metal. Line 2 says that the entry point is not the usual main as in normal Rust programs. Hold your horses if you see the mains further down.
The use statements are like use or import in other languages. They bring the names in the named crate into the current namespace so that you don't have to use fully qualified paths to refer to them. defmt is a lightweight logging crate suited for embedded systems, and we will use the info macro from it.
Embassy is a Rust framework for async execution. If your embedded application is non-trivial you will have to deal with multi-tasking because you have real-world hardware that need attention, e.g. buttons, sensors, communication channels, displays. The simplest way is via a poll loop. Often the periods are quantised in ticks which is the heartbeat of the system. When it's not doing work the MCU waits for the next tick. This is acceptable for simple applications and also if your MCU is memory constrained. A more advanced solution is to use something like FreeRTOS and assign threads to the tasks. Embassy provides async capabilities for resource constrained MCUs. It also offers Hardware Abstraction Layers (HALs) for models of MCUs.
I won't explain Embassy but refer you to the online documentation. In lines 5-7 we are bringing in the embassy_executor crate which offers tasking, the embassy_rp crate specific to Raspberry Pi Picos and we will use the module gpio which has the structs Level and Output, the embassy_time crate and we'll use the Timer struct. Line 8 is bringing in two crates anonymously; ignore it for now.
Line 10 is how we tell Rust where to start the application. It says that the entry point is the next function starting line 11, and note that it's async, so is a thread, and other threads could be running in a more complex application. I actually didn't have to call it main(). In fact I renamed it to blinky() and that worked.
Line 12 initialises the Embassy system. Default::default() is a trait to say just give me the defaults for this argument.
Line 12 declares a mutable called led. Without mut it would be an immutable, and led wouldn't be allowed to change state. It's created from embassy_rp::gpio::Output (remember this is the fully qualified path) it's connected to PIN25 of the embassy_rp instance, and the starting state is Level::Low.
The loop in lines 17-22 needs one explanation. Timer::after_millis() is a function with the Future trait. This means the function holds state about where it's at. The await indicates a point where it will wait until the function completes, then continue. In the meantime, the Embassy scheduler will run other threads. So you get to write asynchronous code as if it were synchronous.
This is just a superficial analysis of a simple embedded Rust application. There's a whole field of rabbit holes you can dive into by following the documentation for the crates used, and of course, the Rust language.
Ken Yap
Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.