Our First Rust “Blinky” Program on Raspberry Pi Pico W

Murray Todd Williams
17 min readJun 5, 2024

--

SWDIO pin on the 3 pin header unplugged so you can see the female connector better

Series part 4 has us wire up our Pico and execute the embedded version of “Hello World”, first via rp2040-hal and then using Embassy. But first, we have to wire up our hardware! I will show the steps to do this for (a) using a second Raspberry Pi Pico as the debug controller and (b) using the more convenient Raspberry Pi Debug Probe.

By the way, if you just found this article and would like to jump to earlier blogs in this series, click on the card below.

Murray's (Raspberry Pi Pico W) Embedded Rust Series

5 stories

Option 1: Wiring and flashing a second Pico

This is my best attempt at a wiring diagram for what we want to do. You might also want to look at this blog from the Rational Technologist where he has some nicer photographs and descriptions.

Wiring the debug controller (on right) to the Pico W (plus the LED)

If you’ve never worked with a breadboard before, it’s worth taking some time to understand what you’re looking at. Pause right now and read this wonderful tutorial about breadboards from the Adafruit website!

Once this is all put together, you will connect your development laptop to the debug controller on the right, which will in turn control the Pico W on the left.

My breadboard wired up and ready to go

We have also added an LED light and a 220 ohm resistor to this project. The anode (+) wire of the LED is connected to GPIO 22 (see the cyan wire), and then we add the resistor between the LED and the ground.

Unlike regular old fashioned light bulbs, the LED is not reversible. (As a matter of fact, LED stands for Light Emitting Diode, and a diode is a circuit that only lets electricity flow in one direction, so plugging it in backwards will just prevent current from flowing!) Just know that you want the “long” wire from your LED to be plugged into the voltage-providing pin (GPIO22) and the short wire we’ll plug into our ground.

Image taken from Arduino support page

So for a wire-to-wire review, using our pin-out diagram for the Pico:

And the nearly identical pin-out diagram for the Pico W:

The individual wiring for the debug connection is as follows:

  1. Connect GND (Pin 38) on both boards together. On the breadboard, I connect both to the blue (—) side rail. Anything that needs to go to ground should be plugged into this rail.
  2. Connect VSYS (Pin 39) on both boards together. Again, I use the breadboard’s red (+) side rail. Anything plugged into that rail will be powered to a nice reliable 3.3 volts.
  3. Connect Pico W UART0_TX (Pin 1) to Pico Controller UART1_RX (Pin 7)
  4. Connect Pico W UART0_RX (Pin 2) to Pico Controller UART1_TX (Pin 6)
  5. Connect Pico W SWCLK to Pico Controller I2C1 SDA (Pin 4)
  6. Connect Pico W GND to the Ground. (The blue side rail.)
  7. Connect Pico B SWDIO to Pico Controller I2C1 SCL (Pin 5)

Note that #5–7 are the three header pins sticking out of the top middle of the board.

Since I had soldered my own 3-pin header in place, I’m using three male-to-female jumper cables because the male ends will go into the breadboard connecting the controller’s pins 4 and 5 and the ground, and the female end will go onto the header pins. It you look at the photo at the beginning of this article, you’ll see I pulled on of these female connectors off so you can better see the how the header works.

If you got instead the Pico W H with the built-in-header, then you’ll use your JST cable to plug into that and connect to the controller’s ground and pins 4 and 5.

Flashing the probe

In this setup, there is a special pre-built RP2040 application that turns our Pico on the right into a debug controller. It was originally called “picoprobe” and you will find a lot of documentation out there telling you to download and flash picoprobe.uf2 to your pico.

Here’s where you may run into a little confusion: as of the version 2.0 release, they deprecated the name “picoprobe” and removed all references to that name? Why? Because with the creation of the Raspberry Pi Debug Probe product (which is just a RP2040 in a smaller, more convenient package) they didn’t want the program to be associated only with the Pico. So the whole project got renamed to “debugprobe”, as you can see by it’s GitHub page.

If you want to turn a Pico into a controller, you’ll flash debugprobe_on_pico.uf2 to your board. If you wanted to update the flash program on your new Debug Probe product (see the next section) then you would be downloading debugprobe.uf2 instead. Got it?

But how to you flash the board? It’s pretty easy, really:

  1. Before you plug the board into your computer, press and hold the white BOOTSEL button.
  2. After you’ve plugged it into your USB port, release the button. In a moment the board will show up as a USB drive by the name of “RPI-RP2”.
  3. Drag and drop your .uf2 file onto the “drive”. Instantly the board will flash the program and disconnect/eject itself from your laptop.

Voila!

Option 2: Connecting a Raspberry Pi Debug Probe

Here life gets a little easier. The debug probe (pictured on the right) has two 3-wire cables, on (the UART) go to pins 1, 2, and 3 (TX, RX and Ground) and the other goes either to the debug pins (if you soldered your own header) or to the matching JST socket if you bought the Pico W H with headers pre-attached.

There’s no need to flash anything onto the probe because it comes pre-flashed with it’s program. How’s that for easy? The only thing I find a little annoying is that there’s no option to power both boards with the same USB line like I do from the other arrangement. You notice in the picture below I have a black microUSB plug going into the debug probe; that goes into my laptop. The white microUSB plug going into the actual Pico W on the breadboard can be plugged into any power source.

Our simplest Blinky program with the rp2040-project-template

I’ve created a GitHub repo for our first project here, tagged to a v0.1 tag. It was created from this rp2040-project-template that you’ll probably always want to use as a starting point for non-Embassy projects.

When creating a new project from this template, you’ll need to manually make only two related changes of note:

  1. In Cargo.html you’ll want to change the project name from “rp2040-project-template” to your project name of choice. For me, I called it “rp2040-blinky”.
  2. In .vscode/launch.json you have to point VS Code’s probe-rs-debu extension to the binary that the project will be building. On line 26, I have to change
"programBinary": "target/thumbv6m-none-eabi/debug/rp2040-project-template"

to


"programBinary": "target/thumbv6m-none-eabi/debug/rp2040-blinky"

3. In the same .vscode/launch.json there is a line

"svdFile": "./.vscode/rp2040.svd"

that is initially commented-out. You will need to manually download rp2040.svd from here and place it in the .vscode directory so that the debugger can find it. I’m guessing it’s not included in the rp2040-project-template repository because of licensing issues. As best I can understand, this .svd file gives probe-rs and VS Code’s debugger additional information about the chip’s registers and memory regions to enable additional aspects of the debugger’s displays.

The rp2040-project-template now includes the LED blinking activity as part of its starting point, but it’s not going to work for us because it assumes we have the non-wifi version of Pico where the LED light is connected to an internal GPIO pin 25. (If you look at the pinout diagrams I included at the top of this article, you’ll see that the pins on the right side of the Pico jump from GPIO 22 to GPIO 26.) For the original Raspberry Pi Pico, the RP2040 microcontroller’s pin 25 was reserved to drive that on-board LED. Unfortunately, as we’ll see in the next section, pins 23 and 25 had to be commandeered for communication between the RP2040 and the onboard CYW43 wireless chip of the Pico W.

And again, that’s why we’ve wired up an external LED to execute blinky. I picked GPIO 22 for the job. Why 22? Honestly, you could use any of the other twenty-something pins, but I noticed on the pinout diagram that 22 was the only pin that wasn’t part of any optional communications bus (I2C, SPI, UART) configurations, so it seemed a nice candidate. But I welcome you to pick any of the other available pins just to prove that it works.

In the original rp2040-project-template there’s a line that defines the pin to use with the led:

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

We are going to just change that to our GPIO 22 like this:

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

Let’s take a moment to look at that line for a second. How did Rust know in the first line, that pins.led referred to the LED pin (GPIO 25)? If I were using a SparkFun Pro Micro RP2040 instead of the Raspberry Pi Pico, the onboard LED wouldn’t be wired to that pin!

The answer is that this Rust Embedded world has been carefully designed to provide functionality in layers. If you look at this (probably imperfect) diagram, you’ll see what we’re using in this project.

Streamlined libraries in the non-async (Embassy) stack

The embedded-hal library is defining the overall abstraction API that we’re programming against. There’s also an rp2040-hal library that addresses all of the unique functionality of the RP2040 microcontroller that is running at the heart of our board. It is pulled in automatically as a dependency for pico-hal which is what we call our BSP (board support package). The purpose of the BSP is to further refine some information so that the Rust compiler knows even more details of how our Raspberry Pi Pico is wired, including the fact that the led pin happens to be GPIO 25.

If you didn’t have a BSP for your Pico W, you would have to include the rp2040-hal manually and live without some of the extra functionality. The commends in the original rp2040-project-template's Cargo.tomlfile even says as much:

# If you're not going to use a Board Support Package you'll need these:
# rp2040-hal = { version="0.10", features=["rt", "critical-section-impl"] }
# rp2040-boot2 = "0.3"

The cortex-m and cortex-m-rt crates are designed to simplify development for ARM Cortex-M microcontrollers, even those that lack certain hardware features.

Let’s break down what each crate does:

  • cortex-m: This crate provides low-level access to Cortex-M processors. It includes things like CPU peripheral access and intrinsics, which are basically compiler instructions that interact directly with the hardware.
  • cortex-m-rt: This crate provides the minimal startup code and runtime environment needed for a Cortex-M application. It handles things like setting up the memory layout, initializing static variables, and enabling the FPU (Floating Point Unit) if the target microcontroller supports it.

Looking at our main.rs program

Here’s a listing of the main program thus far:

//! Blinks the LED on a Pico board
//!
//! This will blink an LED attached to GP22,
#![no_std]
#![no_main]

use bsp::entry;
use defmt::*;
use defmt_rtt as _;
use embedded_hal::digital::OutputPin;
use panic_probe as _;

// Provide an alias for our BSP so we can switch targets quickly.
use rp_pico as bsp;

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

#[entry]
fn main() -> ! {
info!("Program start");
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);

// External high-speed crystal on the pico board is 12Mhz
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().to_Hz());

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

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

loop {
info!("on!");
led_pin.set_high().unwrap();
delay.delay_ms(500);
info!("off!");
led_pin.set_low().unwrap();
delay.delay_ms(500);
}
}

// End of file

In some of the next articles, I will use this as a starting point and build off of it as we do some fancier things like fading the LED up and down with the PWM (pulse width modulation) functionality and reading from both an analog sensor (using the special ADC pins) and a digital sensor via an I²C bus.

It’s worth taking a little time to look at the use statement imports at the top:

use bsp::entry;
use defmt::*;
use defmt_rtt as _;
use embedded_hal::digital::OutputPin;
use panic_probe as _;

// Provide an alias for our BSP so we can switch targets quickly.
use rp_pico as bsp;

A lot of our types some from the rp_pico package that provides implementations across the embedded_hal standard structures. We rename rp_pico as bsp to emphasize the point that its types represent the BSP (board specific package), and then we pull in a bunch of the specific types used in the package:

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

At the moment, the only embassy_hal type we use right now is OuputPin which we need to access the .set_high() and .set_low() methods.

There are a lot of lines in this program that are dedicated to getting the peripheral classes the clock set up. We are going to be intentionally lazy and accept this code as boilerplate, and instead focus on the meat of the program:

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

loop {
info!("on!");
led_pin.set_high().unwrap();
delay.delay_ms(500);
info!("off!");
led_pin.set_low().unwrap();
delay.delay_ms(500);
}

On the first line, we are configuring the RP2040 microcontroller, telling it that the GPIO pin is going to used as an output. If we wanted to use it as an input, we would have used either .into_pull_up_input() or .into_pull_down_input() to tell the chip to expect presence or absence of a system (3.3 volt) voltage and to react if things change.

The meat of our program consists first of a configuration section followed by an endless loop. This is the traditional mechanism for embedded programs: you define some set of actions that will get repeated as long as the device is running. This is basic and intuitive, and I was used to this paradigm when developing electronic projects for my first Arduino. But there’s also something that doesn’t sit right in my gut: when I’m writing a program in Scala, I’m defining the beginning and end of my logic, and sometimes a processing problem is never expected to end—like with steam data processing—but that is just abstracted away. I’m personally not used to writing endless loops. (We’ll see Embassy and async programming returning to these more modern paradigms, albeit not today.)

When we use the .set_high() and .set_low() methods, we are simply telling the microcontroller to drive the GPIO with 3.3 volts or 0 volts.

Since we’re using these embedded experiments as a way to learn Rust, I’ll quickly mention a few things. (As a quick reminder: I’m assuming that you, the reader, have been spending some time at least skimming through the Rust Programming Language book.) After the .set_high and .set_low methods, we call .unwrap to explicitly say that we know that these methods return a Result object that could possibly return an error, and that any returned error should cause a panic. (Find two references here and here in the Rust Programming Book.)

Logging with defmt

We are using the .info!(msg) macro to send debugging messages. Notice the packages defmt and defmt_rtt that we import. In the Rust embedded development world, these libraries are very important to understand. If you were writing a conventional Rust program with the standard library’s log interface.

At its heart, defmt (short for "deferred formatting") is a logging framework designed for resource-constrained environments. It prioritizes speed and memory efficiency by deferring formatting until log messages are actually transmitted. This means less processing power is spent building fancy output strings that might never be seen.

Here’s how defmt works:

  • Macros for Logging: Instead of writing full-blown print statements, developers use concise macros like defmt! to log messages. These macros capture data and formatting instructions.
  • Deferred Formatting: The information is stored in a compact, unformatted state within the program’s memory.
  • Transmission Time: When it’s time to send the log message (e.g., to a debugger), defmt kicks in. It efficiently formats the message based on the stored instructions, minimizing processing overhead.

The defmt-rtt library is used to define how the debugging messages are transmitted via a standardized RTT protocol.

The defmt book can be found here as a reference, but the two important things you really need to know are:

  1. It generally conforms to the standard println!() formatting approach, so once you learn that, you can apply it pretty directly.
  2. You specify error, info, warn, debug, and trace to indicate the debugging level for any message.

Running (Debugging) Blinky

Okay, we’ve made all the necessary changes. Shall we see if blinky works? If I select “Start Debugging” from VS Code’s “Run” pulldown menu, I see some things happening:

  • Some dependency library get downloaded and built (if they haven’t done so already)
  • The binary gets compiled and then “flashed” to my Raspberry Pi via the debug controller; I see a number of progress messages in the Debug Console window as the Pico is getting programmed
  • If I go to the debug screen, I see that the program is paused.

By the Call Stack section of the debug pane on the left, there’s a message in red “Core halted due to an exception, e.g. interrupt handler” and nothing seems to be doing anything. No need to panic: for some reason VS Code seems to pause the debugger before the program starts running. However, you can see the classic debugging toolbar at the top of the window:

If I click once on the Continue button (the blue triangle on the left) the program starts running! My LED is blinking happily, and in the Terminal window I see the following debug lines being printed out:

INFO  on!
└─ rp2040_blinky::__cortex_m_rt_main @ src/main.rs:57
INFO off!
└─ rp2040_blinky::__cortex_m_rt_main @ src/main.rs:60
Green LED is blinking happily!

While the program is running, I can pause it anytime and inspect variables and even chip registers in the left window!

Our simplest Blinky program with the embassy-rp template

I first learned about Embassy on my quest to run a blinky example using the onboard LED. Ultimately this wasn’t very difficult because I was referred to an example program here on the Embassy repository. But I also come to realize that any meaningful networking applications would really depend on Embassy and it’s embassy-net library.

In the second article in my blog series, I talked a little bit about Embassy and why async development is so important. As we start working though more complex examples, especially ones dealing with networking, the purpose and value of Embassy will become more apparent. When we develop software on modern desktops or servers, we are depending on complex multitasking systems (the OS and standard C libraries) to abstract things like IO in a manner so that our programs can focus on core logic.

The first line in our main.rs file is:

#![no_std]

which tells the compiler that we can’t make any assumptions on the functionality that would normally come from the Rust standard library. That helps keep the binary size down to something that can fit on a cheap $1 chip, and it comes with a lot of consequences that we’ll learn and understand as we continue to experiment.

In my view, Embassy fits that in-between gap the bare-bones embedded-hal libraries that give us direct access to the hardware but not enough to support things like a networking subsystem and the broader “stdlib” functionality expected by a full-fledged modern (multitasking) operating system.

This diagram shows (my own mental model) how these libraries are layered.

Libraries used when programming against Embassy

Embassy uses the powerful abstractions and implementations around the embedded_hal ecosystem that we saw in our previous example, but it creates a high-level abstraction.

My embassy-rp-blinky project

In the interest of wrapping this article up, I’m going to breeze quickly through my project setup. You can find the v0.1 tagged version of my GitHub repository here. Very much like how I took the rp2040-project-template to start my embedded-hal-based project, I started with the embassy-rp-quickstart template that creates a starting point for RP2040-based projects made similar changes to get things working:

  • I renamed the project in Cargo.toml and updated a few library dependency versions.
  • I changed launch.json so that the binary name matched that new project name, and also uncommented the line pointing to the rp2040.svd file. (Don’t forget to download that file from here and put it in your .vscode directory.) I also did some notable cleanup where some outdated settings had been left in the JSON file that my VS Code IDE was warning me about.
  • In the main.rs program, I changed pin 25 to 22 to support our LED that we have wired up for the previous experiment.

That done, our main.rs project looks like this:

#![no_std]
#![no_main]

use defmt::*;
use embassy_executor::Spawner;
use embassy_rp::gpio;
use embassy_time::{Duration, Timer};
use gpio::{Level, Output};
use {defmt_rtt as _, panic_probe as _};

#[cortex_m_rt::pre_init]
unsafe fn before_main() {
// Soft-reset doesn't clear spinlocks. Clear the one used by critical-section
// before we hit main to avoid deadlocks when using a debugger
embassy_rp::pac::SIO.spinlock(31).write_value(1);
}

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
info!("Program start");
let p = embassy_rp::init(Default::default());
let mut led = Output::new(p.PIN_22, Level::Low);

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

info!("led off!");
led.set_low();
Timer::after(Duration::from_secs(1)).await;
}
}

Apart from the before_main() logic which I guess is boilerplate to compensate for a potential debugging deadlock situation, the program both looks similar and different from the other one. Both essentially initialize structures, set the pin 22 to an output mode, and alternately set the voltage high and low on an endless loop.

There is a lot of contrast at the same time. I’m not importing any libraries from embedded_hal, nor have I included the Pico BSP anywhere. I’m not using GPIO objects or needing to call unwrap while trying to configure my pin for output. The API is really completely different! I find that interesting given the degree by which Embassy is built on top of the embedded libraries.

Looking at what’s next

There are a lot of refinements that we’ll want to try, as we move beyond our initial “blinky” version of Hello World!

  • In part 5 of the series, we’ll interface with some sensors to take some readings of the outside world.
  • Starting with part 6, we’ll finally use the cyw43 wifi chip that puts the “W” in our “Pico W”! We’ll start by finishing our “blinky” series by (finally!) blinking the onboard LED, which is controlled through the wifi chip.
  • Parts 7and on will dig deeper into the networking. We’ll connect to the wifi network via DHCP and lookup the address of our test server. Then we’ll explore a few approaches to sending data, starting with simple TCP and UDP protocols, and then we’ll see if we can find some libraries to promote ourselves to MQTT. (This may have to wait for some developed to happen, as most MQTT libraries haven’t been designed for async yet. I hope the new renaissance I’m seeing this year will bring both MQTT and maybe even ZMQ to Embassy-land!)

As these articles get written, I’ll update links above.

Murray's (Raspberry Pi Pico W) Embedded Rust Series

5 stories

--

--

Murray Todd Williams

Life-long learner, foodie and wine enthusiast, living in Austin, Texas.