Pico Pico - Intro
In this book, we use the Raspberry Pi Pico 2 and program it in Rust to explore various exciting projects. You’ll work on exercises like dimming an LED, controlling a servo motor, measuring distance with an ultrasonic sensor, displaying the Ferris (🦀) image on an OLED display, using an RFID reader, playing songs on a buzzer, turning on an LED when the room light is off, measuring temperature, and much more.
Meet the hardware - Pico 2
We will be using the Raspberry Pi Pico 2, which is based on the new RP2350 chip. It offers dual-core flexibility with support for ARM Cortex-M33 cores and optional Hazard3 RISC-V cores. By default, it operates using the standard ARM cores, but developers can choose to experiment with the RISC-V architecture if needed.
You find more details from the official website.
Note
There is an older Raspberry Pi Pico that uses the RP2040 chip. In this book, we will be using the newer Pico 2 with the RP2350 chip. When buying hardware, make sure to get the correct one!
There is also a variant called the Pico 2 W, which includes Wi‑Fi and Bluetooth capabilities and is powered by the RP2350 chip. However, it is not fully compatible with the examples we’ve provided. If you want to follow along without adjustments, we recommend using the standard Pico 2 (non‑wireless) version. If you choose to buy the Pico 2 W or already have one, you still can follow along. Expect small differences, such as the onboard LED being used by Wi-Fi by default, but the core concepts remain the same.
Datasheets
For detailed technical information, specifications, and guidelines, refer to the official datasheets:
License
The Pico Pico book(this project) is distributed under the following licenses:
- The code samples and free-standing Cargo projects contained within this book are licensed under the terms of both the MIT License and the Apache License v2.0.
- The written prose contained within this book is licensed under the terms of the Creative Commons CC-BY-SA v4.0 license.
- Circuit diagrams in this book were created with Fritzing.
Support this project
You can support this book by starring this project on GitHub or sharing this book with others 😊
Disclaimer
The experiments and projects shared in this book have worked for me, but results may vary. I’m not responsible for any issues or damage that may occur while you’re experimenting. Please proceed with caution and take necessary safety precautions.
Additional Hardware
In this section we will look at some of the extra hardware you might use along with the Raspberry Pi Pico.
Electronic kits
You can start with a basic electronics kit or buy components as you need them. A simple, low cost kit is enough to begin, as long as it includes resistors, jumper wires, and a breadboard. These are required throughout the lessons.
Additional components used in this book include LEDs, the HC SR04 ultrasonic sensor, active and passive buzzers, the SG90 micro servo motor, an LDR, an NTC thermistor, the RC522 RFID reader, a micro SD card adapter, the HD44780 display, and a joystick module.
Optional Hardware: Debug Probe
The Raspberry Pi Debug Probe makes flashing the Pico 2 much easier. Without it you must press the BOOTSEL button each time you want to upload new firmware. The probe also gives you proper debugging support, which is very helpful.
This tool is optional. You can follow the entire book without owning one(except the one specific to debug probe). When I first started with the Pico, I worked without a probe and only bought it later.
How to decide?
If you are on a tight budget, you can skip it for now because its price is roughly twice the cost of a Pico 2. If the cost is not an issue, it is a good purchase and becomes very handy. You can also use another Pico as a low cost debug probe if you have a second board available.
Breadboard
A breadboard is a small board that helps you build circuits without soldering. It has many holes where you can plug in wires and electronic parts. Inside the board, metal strips connect some of these holes. This makes it easy to join parts together and complete a circuit.
The picture shows how the holes are connected inside the breadboard.
Power rails
The long vertical lines on both sides are called power rails. People usually connect the power supply to the rail marked with “+” and the ground to the rail marked with “-”. Each hole in a rail is connected from top to bottom.
Let’s say you want to give power to many parts. You only need to connect your power source (for example, 3.3V or 5V) to one point on the “+” rail. After that, you can use any other hole on the same rail to power your components.
Middle area
The middle part of the breadboard is where you place most of your components. The holes here are connected in small horizontal rows. Each row has five holes that are linked together inside the board.
As you can see in the image, each row is separate, and the groups marked as a b c d e are separated from the groups marked as f g h i j. The center gap divides these two sides, so the connections do not cross from one side to the other.
Here are some simple examples:
- If you plug a wire into 5a and another wire into 5c, they are connected because they are in the same row.
- If you plug one wire into 5a and another into 5f, they are
notconnected because they are on different sides of the gap. - If you plug one wire into 5a and the other into 6a, they are
notconnected because they are in different rows.
Components
In this book, we will use various external components together with the Pico to build examples and experiments. These components are used to interact with the outside world, such as reading inputs, controlling outputs, and displaying information.
Some components are simple parts like LEDs, buttons, resistors, LDRs, and thermistors. Others are assembled units such as displays, sensors, and interface devices that connect to the Pico using a small number of pins.
Important
Some components may require soldering, for example attaching header pins to boards such as displays or sensor modules. Many parts are also available in pre-soldered form.
If a component does not have pins attached, you will need to solder them before use. Soldering is a practical skill in embedded work, so take your time and follow basic safety precautions.
This section only provides an overview of the components that appear throughout the book. Detailed explanations, wiring, and code examples are covered later in their respective chapters. You do not need to collect all components in advance. You can add them gradually as you progress through the book.
Components Used in This Book
Component names are provided for reference. Before purchasing, check the corresponding chapter to ensure the module type and pin layout match the examples used in this book.
- Breadboard
- Jumper wires (male-male, male-female)
- LED
- Resistors (common values: 330 ohm, 1k ohm, 2.2k ohm, 10k ohm)
- Push button (tactile switch)
- LDR (Light Dependent Resistor)
- NTC Thermistor (10k)
- OLED display (SSD1306, 128x64, I2C)
- LCD display (16x2, HD44780 compatible)
- Ultrasonic sensor (HC-SR04 / HC-SR04+)
- Buzzer (active and passive)
- Servo motor (SG90)
- MAX7219 8X8 Dot LED Matrix Display Module
- RFID module (RC522)
- Micro SD Card Reader Module (SPI)
- Joystick module (PS2 type)
Raspberry Pi Pico 2 Pinout Diagram
Tip
You don’t need to memorize or understand every pin right now. We will refer back to this section as needed while working through the exercises in this book.

Power Pins
Power pins are essential for keeping your Raspberry Pi Pico 2 running and supplying electricity to the sensors, LEDs, motors, and other components you connect to it.
The Raspberry Pi Pico 2 has the following power pins. These are marked in red (power) and black (ground) in the pinout diagrams. These pins are used to supply power to the board and to external components.
-
VBUS is connected to the 5V coming from the USB port. When the board is powered over USB, this pin will carry about 5V. You can use it to power small external circuits, but it’s not suitable for high-current loads.
-
VSYS is the main power input for the board. You can connect a battery or regulated supply here with a voltage between 1.8V and 5.5V. This pin powers the onboard 3.3V regulator, which supplies the RP2350 and other parts.
-
3V3(OUT) provides a stable 3.3V output from the onboard regulator. It can be used to power external components like sensors or displays, but it’s best to limit the current draw to under 300mA.
-
GND pins are used to complete electrical circuits and are connected to the system ground. The Pico 2 provides multiple GND pins spread across the board for convenience when connecting external devices.
GPIO Pins
When you want your microcontroller(i.e Pico) to interact with the world; like turning on lights, reading button presses, sensing temperature, or controlling motors; you need a way to connect and communicate with these external components. That’s exactly what GPIO pins do: they’re your Raspberry Pi Pico 2’s connection points to external components.
The Raspberry Pi Pico 2 includes 26 General Purpose Input/Output (GPIO) pins, labeled GPIO0 through GPIO29, though not all numbers are exposed on the headers. These GPIOs are highly flexible and can be used to read inputs like switches or sensors, or to control outputs such as LEDs, motors, or other devices.
All GPIOs operate at 3.3V logic. This means any input signal you connect should not exceed 3.3 volts, or you risk damaging the board. While many GPIOs support basic digital I/O, some also support additional functions like analog input (ADC), or act as communication lines for protocols like I2C, SPI, or UART.
Pin Numbering
Each GPIO pin can be referenced in two ways: by its GPIO number (used in software) and by its physical pin location on the board. When writing code, you will use the GPIO number (like GPIO0). When connecting wires, you need to know which GPIO is connected to which physical pin.
GPIO25 is special, it is connected to the onboard LED and can be controlled directly in code without any external wiring.
For example, when your code references GPIO0, you’ll connect your wire to physical pin 1 on the board. Similarly, GPIO2 connects to physical pin 4.
ADC Pins
Most pins on the Raspberry Pi Pico 2 work with simple on/off signals; perfect for things like LEDs or buttons. But what if you want to measure how bright a room is to automatically turn on lights? Or monitor soil moisture to water plants? Or read how far someone turned a volume knob? These tasks need pins that can sense gradual changes, not just on/off states.
Most of the pins on the Raspberry Pi Pico 2 are digital - they can only read or send values like ON (high) or OFF (low). But some devices, like light sensors or temperature sensors, produce signals that change gradually. To understand these kinds of signals, we need special pins called ADC pins.
ADC stands for Analog-to-Digital Converter. It takes a voltage and turns it into a number your program can understand. For example, a voltage of 0V might become 0, and 3.3V might become 4095 (the highest number the ADC can produce, since it uses 12-bit resolution). We will take a closer look at the ADC later in this book.
The Raspberry Pi Pico 2 has three ADC-capable pins. These are GPIO26, GPIO27, and GPIO28, which correspond to ADC0, ADC1, and ADC2 respectively. You can use these pins to read analog signals from sensors such as light sensors, temperature sensors.
There are also two special pins that support analog readings:
-
ADC_VREF is the reference voltage for the ADC. By default, it’s connected to 3.3V, meaning the ADC will convert anything between 0V and 3.3V into a number. But you can supply a different voltage here (like 1.25V) if you want more precise measurements in a smaller range.
-
AGND is the analog ground, used to provide a clean ground for analog signals. This helps reduce noise and makes your analog readings more accurate. If you’re using an analog sensor, it’s a good idea to connect its ground to AGND instead of a regular GND pin.
I2C Pins
The Raspberry Pi Pico 2 supports I2C, a communication protocol used to connect multiple devices using just two wires. It is commonly used with sensors, displays, and other peripherals.
I2C uses two signals: SDA (data line) and SCL (clock line). These two lines are shared by all connected devices. Each device on the bus has a unique address, so the Pico 2 can talk to many devices over the same pair of wires.
The Raspberry Pi Pico 2 has two I2C controllers: I2C0 and I2C1. Each controller can be mapped to multiple GPIO pins, giving you flexibility depending on your circuit needs.
-
I2C0 can use these GPIOs:
- SDA (data): GPIO0, GPIO4, GPIO8, GPIO12, GPIO16, or GPIO20
- SCL (clock): GPIO1, GPIO5, GPIO9, GPIO13, GPIO17, or GPIO21
-
I2C1 can use these GPIOs:
- SDA (data): GPIO2, GPIO6, GPIO10, GPIO14, GPIO18, or GPIO26
- SCL (clock): GPIO3, GPIO7, GPIO11, GPIO15, GPIO19, or GPIO27
You can choose any matching SDA and SCL pair from the same controller (I2C0 or I2C1).
SPI Pins
SPI (Serial Peripheral Interface) is another communication protocol used to connect devices like displays, SD cards, and sensors. Unlike I2C, SPI uses more wires but offers faster communication. It works with one controller (like the Pico 2) and one or more devices.
SPI uses four main signals:
- SCK (Serial Clock): Controls the timing of data transfer.
- MOSI (Master Out Slave In): Data sent from the controller to the device.
- MISO (Master In Slave Out): Data sent from the device to the controller.
- CS/SS (Chip Select or Slave Select): Used by the controller to select which device to talk to.
On Pico 2 pinout diagrams, MOSI is labeled as Tx, MISO as Rx, and CS as Csn.
The Raspberry Pi Pico 2 has two SPI controllers: SPI0 and SPI1. Each can be connected to multiple GPIO pins, so you can choose whichever set fits your circuit layout.
-
SPI0 can use:
- SCK: GPIO2, GPIO6, GPIO10, GPIO14, GPIO18
- MOSI: GPIO3, GPIO7, GPIO11, GPIO15, GPIO19
- MISO: GPIO0, GPIO4, GPIO8, GPIO12, GPIO16
-
SPI1 can use:
- SCK: GPIO14, GPIO18
- MOSI: GPIO15, GPIO19
- MISO: GPIO8, GPIO12, GPIO16
You can choose a group of compatible pins from the same controller depending on your circuit layout. The CS (chip select) pin is not fixed-you can use any free GPIO for that purpose. We will explore how to configure SPI and connect devices in upcoming chapters.
UART Pins
UART (Universal Asynchronous Receiver/Transmitter) is one of the simplest ways for two devices to talk to each other. It uses just two main wires:
- TX (Transmit): Sends data out.
- RX (Receive): Receives data in.
UART is often used to connect to serial devices like GPS modules, Bluetooth adapters, or even to your computer for debugging messages.
The Raspberry Pi Pico 2 has two UART controllers: UART0 and UART1. Each one can be mapped to several different GPIO pins, giving you flexibility when wiring your circuit.
-
UART0 can use:
- TX: GPIO0, GPIO12, GPIO16
- RX: GPIO1, GPIO13, GPIO17
-
UART1 can use:
- TX: GPIO4, GPIO8
- RX: GPIO5, GPIO9
You need to use a matching TX and RX pin from the same UART controller. For example, you could use UART0 with TX on GPIO0 and RX on GPIO1, or UART1 with TX on GPIO8 and RX on GPIO9.
SWD Debugging Pins
The Raspberry Pi Pico 2 provides a dedicated 3-pin debug header for SWD (Serial Wire Debug), which is the standard ARM debugging interface. SWD allows you to flash firmware, inspect registers, set breakpoints, and perform real-time debugging.

This interface consists of the following signals:
- SWDIO - Serial data line
- SWCLK - Serial clock line
- GND - Ground reference
These pins are not shared with general-purpose GPIO and are located on a separate debug header at the bottom edge of the board. You will typically use an external debug probe like the Raspberry Pi Debug Probe, CMSIS-DAP adapter, or other compatible tools (e.g., OpenOCD, probe-rs) to connect to these pins.
Onboard Temperature Sensor
The Raspberry Pi Pico 2 includes a built-in temperature sensor that is connected internally to ADC4. This means you can read the chip’s temperature using the ADC, just like you would with an external analog sensor.
This sensor measures the temperature of the RP2350 chip itself. It does not reflect the room temperature accurately, especially if the chip is under load and heating up.
Control Pins
These pins control the board’s power behavior and can be used to reset or shut down the chip.
-
3V3(EN) is the enable pin for the onboard 3.3V regulator. Pulling this pin low will disable the 3.3V power rail and effectively turn off the RP2350.
-
RUN is the reset pin for the RP2350. It has an internal pull-up resistor and stays high by default. Pulling it low will reset the microcontroller. This is helpful if you want to add a physical reset button or trigger a reset from another device.
Setup
Picotool
picotool is a tool for working with RP2040/RP2350 binaries, and interacting with RP2040/RP2350 devices when they are in BOOTSEL mode.
Tip
Alternatively, you can download the pre-built binaries of the SDK tools from here, which is a simpler option than following these steps.
Here’s a quick summary of the steps I followed:
# Install dependencies
sudo apt install build-essential pkg-config libusb-1.0-0-dev cmake
mkdir embedded && cd embedded
# Clone the Pico SDK
git clone https://github.com/raspberrypi/pico-sdk
cd pico-sdk
git submodule update --init lib/mbedtls
cd ../
# Set the environment variable for the Pico SDK
PICO_SDK_PATH=/MY_PATH/embedded/pico-sdk
# Clone the Picotool repository
git clone https://github.com/raspberrypi/picotool
Build and install Picotool
cd picotool
mkdir build && cd build
# cmake ../
cmake -DPICO_SDK_PATH=/MY_PATH/embedded/pico-sdk/ ../
make -j8
sudo make install
On Linux you can add udev rules in order to run picotool without sudo:
cd ../
# In picotool cloned directory
sudo cp udev/60-picotool.rules /etc/udev/rules.d/
Rust Targets
To build and deploy Rust code for the RP2350 chip, you’ll need to add the appropriate targets:
rustup target add thumbv8m.main-none-eabihf
rustup target add riscv32imac-unknown-none-elf
probe-rs - Flashing and Debugging Tool
probe-rs is a modern, Rust-native toolchain for flashing and debugging embedded devices. It supports ARM and RISC-V targets and works directly with hardware debug probes. When you use a Debug Probe with the Pico 2, probe-rs is the tool you rely on for both flashing firmware and debugging.
Install probe-rs using the official installer script:
curl -LsSf https://github.com/probe-rs/probe-rs/releases/latest/download/probe-rs-tools-installer.sh | sh
For latest installation instructions, better refer to the official probe-rs documentation.
By default, debug probes on Linux can only be accessed with root privileges. To avoid using sudo for every command, you should install the appropriate udev rules that allow regular users to access the probe. Follow the instructions provided here.
Quick summary:
- Download the udev rules file from the probe-rs repository
- Copy it to
/etc/udev/rules.d/ - Reload udev rules with
sudo udevadm control --reload - Unplug and replug your Debug Probe
After this setup, you can use probe-rs without root privileges.
Quick Start
Before diving into the theory and concepts of how everything works, let’s jump straight into action. Use this simple code to turn on the onboard LED of the Pico2.
We’ll use Embassy, a Rust framework built for microcontrollers like the Raspberry Pi Pico 2. Embassy lets you write async code that can handle multiple tasks at the same time; like blinking an LED while reading a button press, without getting stuck waiting for one task to finish before starting another.
The following code creates a blinking effect by switching the pin’s output between high (on) and low (off) states. As we mentioned in the pinout section, the Pico 2 has its onboard LED connected to GPIO pin 25. In this program, we configure that pin as an Output pin (we configure a pin as Output whenever we want to control something like turning LEDs on/off, driving motors, or sending signals to other devices) with a low (off) initial state.
The code snippet
We’re looking at just the main function code here. There are other initialization steps and imports required to make this work. We’ll explore these in depth in the next chapter to understand what they do and why they’re needed. For now, our focus is just to see something working in action. You can clone the quick start project I created and run it to get started immediately.
Important
This code is incompatible with the Pico 2 W variant. On the Pico 2 W, GPIO25 is dedicated to controlling the wireless interface, we will need to follow a different procedure to control the onboard LED.
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// The onboard LED is actually connected to pin 25
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
led.set_high(); // Turn on the LED
Timer::after_millis(500).await;
led.set_low(); // Turn off the LED
Timer::after_millis(500).await;
}
}
Clone the Quick start project
git clone https://github.com/ImplFerris/pico2-quick
cd pico2-quick
How to Run?
To Flash your application onto the Pico 2, press and hold the BOOTSEL button. While holding it, connect the Pico 2 to your computer using a micro USB cable. You can release the button once the USB is plugged in.
# Run the program
cargo run
This will flash (write) our program into the Pico 2’s memory and run it automatically. If successful, you should see the onboard LED blinking at regular intervals. If you encounter any errors, verify that you have set up your development environment correctly and connected the Pico properly. If you’re still unable to resolve the issue, please raise a GitHub issue with details so i can update and improve this guide
With Debug Probe
If you’re using a debug probe, you don’t need to press the BOOTSEL button. You can just run cargo flash or cargo embed instead. These commands are covered in detail later in the book, though you can jump ahead to the Debug Probe chapter if you’d like to explore them now.
Abstraction Layers
When working with embedded Rust, you will often come across terms like PAC, HAL, and BSP. These are the different layers that help you interact with the hardware. Each layer offers a different balance between flexibility and ease of use.
Let’s start from the highest level of abstraction down to the lowest.
Board Support Package (BSP)
A BSP, also referred as Board Support Crate in Rust, tailored to specific development boards. It combines the HAL with board-specific configurations, providing ready to use interfaces for onboard components like LEDs, buttons, and sensors. This allows developers to focus on application logic instead of dealing with low-level hardware details. Since there is no popular BSP specifically for the Raspberry Pi Pico 2, we will not be using this approach in this book.
Hardware Abstraction Layer (HAL)
The HAL sits just below the BSP level. If you work with boards like the Raspberry Pi Pico or ESP32 based boards, you’ll mostly use the HAL level. HALs are typically written for the specific chip (like the RP2350 or ESP32) rather than for individual boards, which is why the same HAL can be used across different boards that share the same microcontroller. For Raspberry Pi’s family of microcontrollers, there’s the rp-hal crate that provides this hardware abstraction.
The HAL builds on top of the PAC and provides simpler, higher-level interfaces to the microcontroller’s peripherals. Instead of handling low-level registers directly, HALs offer methods and traits that make tasks like setting timers, setting up serial communication, or controlling GPIO pins easier.
HALs for the microcontrollers usually implement the embedded-hal traits, which are standard, platform-independent interfaces for peripherals like GPIO, SPI, I2C, and UART. This makes it easier to write drivers and libraries that work across different hardware as long as they use a compatible HAL.
Embassy for RP
Embassy sits at the same level as HAL but provides an additional runtime environment with async capabilities. Embassy (specifically embassy-rp for Raspberry Pi Pico) is built on top of the HAL layer and provides an async executor, timers, and additional abstractions that make it easier to write concurrent embedded applications.
Embassy provides a separate crate called embassy-rp specifically for Raspberry Pi microcontrollers (RP2040 and RP235x). This crate builds directly on top of the rp-pac (Raspberry Pi Peripheral Access Crate).
Throughout this book, we will use both rp-hal and embassy-rp for different exercises.
Note
The layers below the HAL are rarely used directly. In most cases, the PAC is accessed through the HAL, not on its own. Unless you are working with a chip that does not have a HAL available, there is usually no need to interact with the lower layers directly. In this book, we will focus on the HAL layer.
Peripheral Access Crate (PAC)
PACs are the lowest level of abstraction. They are auto-generated crates that provide type-safe access to a microcontroller’s peripherals. These crates are typically generated from the manufacturer’s SVD (System View Description) file using tools like svd2rust. PACs give you a structured and safe way to interact directly with hardware registers.
Raw MMIO
Raw MMIO (memory-mapped IO) means directly working with hardware registers by reading and writing to specific memory addresses. This approach mirrors traditional C-style register manipulation and requires the use of unsafe blocks in Rust due to the potential risks involved. We will not touch this area; I haven’t seen anyone using this approach.
Project Template with cargo-generate
“cargo-generate is a developer tool to help you get up and running quickly with a new Rust project by leveraging a pre-existing git repository as a template.”
Read more about here.
Prerequisites
Before starting, ensure you have the following tools installed:
- Rust
- cargo-generate for generating the project template.
Install the OpenSSL development package first because it is required by cargo-generate:
sudo apt install libssl-dev
You can install cargo-generate using the following command:
cargo install cargo-generate
Step 1: Generate the Project
Run the following command to generate the project from the template:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
This will prompt you to answer a few questions: Project name: Name your project. HAL choice: You can choose between embassy or rp-hal.
Step 2: Default LED Blink Example
By default, the project will be generated with a simple LED blink example. The code structure may look like this:
src/main.rs: Contains the default blink logic.
Cargo.toml: Includes dependencies for the selected HAL.
Step 3: Choose Your HAL and Modify Code
Once the project is generated, you can decide to keep the default LED blink code or remove it and replace it with your own code based on the HAL you selected.
Removing Unwanted Code
You can remove the blink logic from src/main.rs and replace it with your own code. Modify the Cargo.toml dependencies and project structure as needed for your project.
Running the program
Before we explore further examples, let’s cover the general steps to build and run any program on the Raspberry Pi Pico 2. The Pico 2 contains both ARM Cortex-M33 and Hazard3 RISC-V processors, and we’ll provide instructions for both architectures.
Note: These commands should be run from your project folder. This is included here as a general step to avoid repetition. If you haven’t created a project yet, begin with the Quick Start or Blink LED section.
Build and Run for ARM
Use this command to build and run programs on the Raspberry Pi Pico 2 in ARM mode, utilizing the Cortex-M33 processors.
# build the program
cargo build --target=thumbv8m.main-none-eabihf
To Flash your application onto the Pico 2, press and hold the BOOTSEL button. While holding it, connect the Pico 2 to your computer using a micro USB cable. You can release the button once the USB is plugged in.
# Run the program
cargo run --target=thumbv8m.main-none-eabihf
Note
The example codes include a runner configuration in the
.cargo/config.tomlfile, defined as:runner = "picotool load -u -v -x -t elf". This means that when you executecargo run, it actually invokes thepicotoolwith theloadsubcommand to flash the program.
Build and Run for RISC-V
Use this command to build and run programs on the Raspberry Pi Pico 2 n RISC-V mode, utilizing the Hazard3 processors.
Important
This book focuses on ARM. Some examples may need changes before they work on RISC V mode. For simplicity, it is recommended to follow the ARM workflow while reading this book.
# build the program
cargo build --target=riscv32imac-unknown-none-elf
Follow the same BOOTSEL steps as described above.
# Run the program
cargo run --target=riscv32imac-unknown-none-elf
With Debug Probe
When using a Debug Probe, you can flash your program directly onto the Pico 2 with:
# cargo flash --chip RP2350
# cargo flash --chip RP2350 --release
cargo flash --release
If you want to flash your program and also view its output in real time, use:
# cargo embed --chip RP2350
# cargo embed --chip RP2350 --release
cargo embed --release
cargo-embed is a more advanced version of cargo-flash. It can flash your program, and it can also open an RTT terminal and a GDB server.
Help & Troubleshooting
If you face any bugs, errors, or other issues while working on the exercises, here are a few ways to troubleshoot and resolve them.
1. Compare with Working Code
Check the complete code examples or clone the reference project for comparison. Carefully review your code and Cargo.toml dependency versions. Look out for any syntax or logic errors. If a required feature is not enabled or there is a feature mismatch, make sure to enable the correct features as shown in the exercise.
If you find a version mismatch, either adjust your code(research and find a solution; it’s a great way for you to learn and understand things better) to work with the newer version or update the dependencies to match the versions used in the tutorial.
2. Search or Report GitHub Issues
Visit the GitHub issues page to see if someone else has encountered the same problem: https://github.com/ImplFerris/pico-pico/issues?q=is%3Aissue
If not, you can raise a new issue and describe your problem clearly.
3. Ask the Community
The Rust Embedded community is active in the Matrix Chat. The Matrix chat is an open network for secure, decentralized communication.
Here are some useful Matrix channels related to topics covered in this book:
-
Embedded Devices Working Group
#rust-embedded:matrix.org
General discussions around using Rust for embedded development. -
RP Series Development
#rp-rs:matrix.org
For Rust development and discussions around the Raspberry Pi RP series chips. -
Debugging with Probe-rs
#probe-rs:matrix.org
For support and discussion around the probe-rs debugging toolkit. -
Embedded Graphics
#rust-embedded-graphics:matrix.org
For working withembedded-graphics, a drawing library for embedded systems.
You can create a Matrix account and join these channels to get help from experienced developers.
You can find more community chat rooms in the Awesome Embedded Rust - Community Chat Rooms section.
4. Discord
There is an unofficial Discord community for Embedded Rust where you can ask questions, discuss topics, share your experiences, and showcase your projects. It is especially useful for learners and general discussion.
Keep in mind that most HAL and embedded ecosystem maintainers are more active on Matrix. Still, this Discord server can be a good place to learn and interact with others.
Join here: https://discord.gg/NHenanPUuG
Debug Probe for Raspberry Pi Pico 2
Pressing the BOOTSEL button every time you want to flash a new program is annoying. On devboards like the ESP32 DevKit this step is mostly automatic because the devboard can reset the chip into bootloader mode when needed. The Pico 2 does not have this feature, but you can get the same convenience and even more capability by using a debug probe.
This chapter explains why a debug probe is helpful, and step-by-step how to set one up and use it to flash and debug your Pico 2 without pressing BOOTSEL each time.
Raspberry Pi Debug Probe
The Raspberry Pi Debug Probe is the official tool recommended for SWD debugging on the Pico and Pico 2. It is a small USB device that acts as a CMSIS-DAP adapter. CMSIS-DAP is an open standard for debuggers that lets your computer talk to microcontrollers using the SWD protocol.
The probe provides two main features:
-
SWD (Serial Wire Debug) interface - This connects to the Pico’s debug pins and is used to flash firmware and perform real time debugging. You can set breakpoints, inspect variables, and debug your program just like you would in a normal desktop application.
-
UART bridge - This provides a USB to serial connection so you can view console output or communicate with the board.
Both features work through the same USB cable that goes into your computer, which keeps the setup simple because you do not need a separate UART device.
Soldering SWD Pins
Before you can connect the Debug Probe to the Pico 2, you need to make the SWD pins accessible. These pins are located at the bottom edge of the Pico board, in a small 3-pin debug header separate from the main GPIO pins.
Once the SWD pins are soldered, your Pico is ready to connect to the Debug Probe.
Preparing Debug Probe
Your Debug Probe may not ship with the latest firmware, especially the version that adds support for the Pico 2 (RP2350 chip). Updating the firmware is recommended before you start.
The official Raspberry Pi documentation provides clear instructions for updating the Debug Probe. Follow the steps provided here.
Connecting Pico with Debug Probe
The Debug Probe has two ports on its side:
- D port - For the SWD (debug) connection
- U port - For the UART (serial) connection
SWD Connection (Required)
The SWD connection is what allows flashing firmware and using a debugger. Use the JST to Dupont cable that comes with your Debug Probe.
Connect the wires from the Debug Probe’s D port to the Pico 2 pins as follows:
| Probe Wire | Pico 2 Pin |
|---|---|
| Orange | SWCLK |
| Black | GND |
| Yellow | SWDIO |
Make sure the Pico 2 SWD pins are properly soldered before you attempt the connection.
UART Connection (Optional)
The UART connection is useful if you want to see serial output (like println! logs from Rust) in your computer’s terminal. This is separate from the SWD connection.
Connect the wires from the Debug Probe’s U port to the Pico 2 pins:
| Probe Wire | Pico 2 Pin | Physical Pin Number |
|---|---|---|
| Yellow | GP0 (TX on Pico) | Pin 1 |
| Orange | GP1 (RX on Pico) | Pin 2 |
| Black | GND | Pin 3 |
You can use any GPIO pins configured for UART, but GP0 and GP1 are the Pico’s default UART0 pins.
Powering the Pico
The Debug Probe does not supply power to the Pico 2, it only provides the SWD and UART signals. To power the Pico 2, connect the Debug Probe to your PC through its USB port, then power the Pico 2 separately through its own USB connection. Both devices must be powered for debugging to work properly.
Final Setup
Once connected:
- Plug the Debug Probe into your computer via USB
- Ensure your Pico 2 is powered
- The Debug Probe’s red LED should light up, indicating it has power
- Your setup is ready - no BOOTSEL button pressing needed from now on
You can now flash and debug your Pico 2 directly through your development environment without any manual intervention.
Test it
To verify that your Debug Probe and Pico 2 are connected correctly, you can use the quick start project. Flash it and test that everything works.
git clone https://github.com/ImplFerris/pico2-quick
cd pico2-quick
You cannot just use cargo run like we did before, unless you modified the config.toml. Because the quick start project is set up to use picotool as its runner. You can comment out the picotool runner and enable the probe-rs runner. Then you can use the cargo run command.
Or more simply (i recommend this), you can just use the following commands provided by probe-rs. This will flash your program using the Debug Probe:
cargo flash
# or
cargo flash --release
cargo embed
You can use cargo embed to flash your program and watch the log output in your terminal. The quick start project is already set up to send its log messages over RTT, so you do not need to configure anything before trying it out.
cargo embed
# or
cargo embed --release
If RTT is new to you, we will explain it later, but for now you can simply run the command to see your program run and print logs.
If everything works, you should see the “Hello, World!” message in the system terminal.
Reference
Real-Time Transfer (RTT)
When developing embedded systems, you need a way to see what’s happening inside your program. On a normal computer, you would use println! to print messages to the terminal. But on a microcontroller, there’s no screen or terminal attached. Real-Time Transfer (RTT) solves this problem by letting you print debug messages and logs from your microcontroller to your computer.
What is RTT?
RTT is a communication method that lets your microcontroller send messages to your computer through the debug probe you’re already using to flash your programs.
When you connect the Raspberry Pi Debug Probe to Pico, you’re creating a connection that can do two things:
- Flash new programs onto the chip
- Read and write the chip’s memory
RTT uses this memory access capability. It creates special memory buffers on your microcontroller, and the debug probe reads these buffers to display messages on your computer. This happens in the background while your program runs normally.
Using Defmt for Logging
Defmt (short for “deferred formatting”) is a logging framework designed specifically for resource-constrained devices like microcontrollers. In your Rust embedded projects, you’ll use defmt to print messages and debug your programs.
Defmt achieves high performance using deferred formatting and string compression. Deferred formatting means that formatting is not done on the machine that’s logging data but on a second machine.
Your Pico sends small codes instead of full text messages. Your computer receives these codes and turns them into normal text. This keeps your firmware small and avoids slow string formatting on the microcontroller.
You can add the defmt crate in your project:
defmt = "1.0.1"
Then use it like this:
#![allow(unused)]
fn main() {
use defmt::{info, warn, error};
...
info!("Starting program");
warn!("You shall not pass!");
error!("Something went wrong!");
}
Defmt RTT
By itself, defmt doesn’t know how to send messages from your Pico to your computer. It needs a transport layer. That’s where defmt-rtt comes in.
The defmt-rtt crate connects defmt to RTT, so your log messages get transmitted through the debug probe to your computer.
You can add the defmt-rtt crate in your project:
defmt-rtt = "1.0"
Tip
To see RTT and defmt logs, you need to run your program using probe-rs tools like the
cargo embedcommand. These tools automatically open an RTT session and show the logs in your terminal
Then include it in your code:
#![allow(unused)]
fn main() {
use defmt_rtt as _;
}
The line sets up the connection between defmt and RTT. You don’t call any functions from it directly, but it needs to be imported to make it work.
Panic Messages with Panic-Probe
When your program crashes (panics), you want to see what went wrong. The panic-probe crate makes panic messages appear through defmt and RTT.
You can add the panic-probe crate in your project:
# The print-defmt feature - tells panic-probe to use defmt for output.
panic-probe = { version = "1.0", features = ["print-defmt"] }
Then include it in your code:
#![allow(unused)]
fn main() {
use panic_probe as _;
}
You can manually trigger a panic to see how panic messages work. Try adding this to your code:
#![allow(unused)]
fn main() {
panic!("something went wrong");
}
Async In Embedded Rust
When I first started this book, I wrote most of the examples using rp-hal only. In this revision, I have rewritten the book to focus mainly on async programming with Embassy. The official Embassy book already has good documentation, but I want to give a short introduction here. Let’s have a brief look at async and understand why it’s so valuable in embedded systems.
Imagine You’re Cooking Dinner
If you’re familiar with concurrency and async concepts, you don’t need this analogy; Embassy is basically like Tokio for embedded systems, providing an async runtime. If you’re new to async, let me explain with this analogy.
You are making dinner and you put water on to boil. Instead of standing there watching, you chop vegetables. You glance at the pot occasionally, and when you see bubbles, you’re ready for the next step. Now while the main dish cooks, you prepare a side dish in another pan. You even check a text message on your phone. You’re one person constantly moving between tasks, checking what needs attention, doing work whenever something is ready, and never just standing idle waiting.
That’s async programming. You’re the executor, constantly deciding what needs attention. Each cooking task is an async operation. The stove does its heating without you watching it. That’s the non-blocking wait. You don’t freeze in place staring at boiling water. You go do other productive work and come back when it’s ready. The key insight is efficient orchestration: one person (the executor), multiple waiting tasks, and you’re always doing something useful by switching your attention to whatever is ready right now. This is exactly what async programming does for your microcontroller.
Different Approaches
In embedded systems, your microcontroller spends a lot of time waiting. It waits for a button press, for a timer to expire, or for an LED to finish blinking for a set duration. Without async, you have two main approaches.
Blocking
The first approach is blocking code. Your program literally stops and waits. If you’re waiting for a button press, your code sits in a loop checking if the button state has changed. During this time, your microcontroller can’t do anything else. It can’t blink an LED, it can’t check other buttons, it can’t respond to timers. All of your processor’s power is wasted in a tight loop asking “is it ready yet?” over and over again.
Interrupt
The second approach is using interrupts directly. When hardware events happen, like a button being pressed or a timer expiring, the interrupt handler runs. This is better because your main code can keep running, but interrupt-based code quickly becomes complex and error-prone. You need to carefully manage shared state between your main code and interrupt handlers.
Do not worry about interrupts for now. We will go into them in more depth in later chapters.
Async
Async programming gives you the best of both worlds. Your code looks clean and sequential, like blocking code, but it doesn’t actually block. When you await something, your code says “I need to wait for this, but feel free to do other work in the meantime.” The async runtime, which Embassy provides for us, handles all the complexity of switching between tasks efficiently.
How Async Works in Rust
When you write an async function in Rust, you use the async keyword before fn. Inside that function, you can use the await keyword on operations that might take time. Here’s what it looks like:
#![allow(unused)]
fn main() {
async fn blink_led(mut led: Output<'static>) {
loop {
led.set_high();
Timer::after_millis(500).await;
led.set_low();
Timer::after_millis(500).await;
}
}
}
The important part is the .await. When you write Timer::after_millis(500).await, you’re telling the runtime “I need to wait 500 milliseconds, but I don’t need the CPU during that time.” The runtime can then go run other tasks. When the 500 milliseconds are up, your task resumes right where it left off.
Think back to our cooking analogy. When you put something on the stove and walk away, you’re essentially “awaiting” it to be ready. You do other things, and when it’s done, you return to that task. Just like you act as the executor in the kitchen, keeping track of what needs attention and when, the async runtime plays the same role for your program.
Embassy
Embassy is one of the popular async runtime that makes all of this work in embedded Rust. It provides the executor that manages your tasks, handles hardware interrupts.
Executor
When you use #[embassy_executor::main], Embassy automatically sets everything up - it runs your tasks, puts the CPU to sleep when everything is waiting, and wakes it up when hardware events occur. The Executor is the coordinator that decides which task to poll when. The executor maintains a queue of tasks that are ready to run. When a task hits await and yields, the executor moves to the next ready task. When there are no tasks ready to run, the executor puts the CPU to sleep. Interrupts wake the executor back up, which then polls any tasks that became ready.
RTIC
RTIC (Real-Time Interrupt-driven Concurrency) is another popular framework for embedded Rust. Unlike Embassy, which provides an async runtime along with hardware drivers, RTIC focuses only on execution and scheduling. In RTIC, you declare tasks with fixed priorities and shared resources upfront, and the framework checks at compile time that resources are shared safely without data races. Higher-priority tasks can preempt lower-priority ones, and the scheduling is handled by hardware interrupts, which makes timing very predictable. This makes RTIC a good fit for hard real-time systems where precise control and determinism matter. You can refer the official RTIC book for more info.
In this book, we will mainly use Embassy.
Blinking an External LED
From now on, we’ll use more external parts with the Pico. Before we get there, it helps to get comfortable with simple circuits and how to connect components to the Pico’s pins. In this chapter, we’ll start with something basic: blinking an LED that’s connected outside the board.
Hardware Requirements
- LED
- Resistor
- Jumper wires
Components Overview
-
LED: An LED (Light Emitting Diode) lights up when current flows through it. The longer leg (anode) connects to positive, and the shorter leg (cathode) connects to ground. We’ll connect the anode to GP13 (with a resistor) and the cathode to GND.
-
Resistors: A resistor limits the current in a circuit to protect components like LEDs. Its value is measured in Ohms (Ω). We’ll use a 330 ohm resistor to safely power the LED.
| Pico Pin | Wire | Component |
|---|---|---|
| GPIO 13 |
|
Resistor |
| Resistor |
|
Anode (long leg) of LED |
| GND |
|
Cathode (short leg) of LED |
You can connect the Pico to the LED using jumper wires directly, or you can place everything on a breadboard. If you’re unsure about the hardware setup, you can also refer the Raspberry pi guide.
Tip
On the Pico, the pin labels are on the back of the board, which can feel inconvenient when plugging in wires. I often had to check the pinout diagram whenever I wanted to use a GPIO pin. Use the Raspberry Pi logo on the front as a reference point and match it with the pinout diagram to find the correct pins. Pin positions 2 and 39 are also printed on the front and can serve as additional guides.
LED Blink - Simulation
In this simulation I set the default delay to 5000 milliseconds so the animation is calmer and easier to follow. You can lower it to something like 500 milliseconds to see the LED blink more quickly. When we run the actual code on the Pico, we will use a 500 millisecond delay.
Blink an External LED on the Raspberry Pi Pico with Embedded Rust
Let’s start by creating our project. We’ll use cargo-generate and use the template we prepared for this book.
In your terminal, type:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
You will be asked a few questions:
-
For the project name, you can give anything. We will use external-led.
-
Next, it asks us to Select HAL. We should choose “Embassy”.
-
Then, it will ask whether we want to enable defmt logging. This works only if we use a debug probe, so you can choose based on your setup. Anyway we are not going to write any log in this exercise.
Imports
Most of the required imports are already in the project template. For this exercise, we only need to add the Output struct and the Level enum from gpio:
#![allow(unused)]
fn main() {
use embassy_rp::gpio::{Level, Output};
}
While writing the main code, your editor will normally suggest missing imports. If something is not suggested or you see an error, check the full code section and add the missing imports from there.
Main Logic
The code is almost the same as the quick start example. The only change is that we now use GPIO 13 instead of GPIO 25. GPIO 13 is where we connected the LED (through a resistor).
Let’s add these code the main function :
#![allow(unused)]
fn main() {
let mut led = Output::new(p.PIN_13, Level::Low);
loop {
led.set_high(); // Turn on the LED
Timer::after_millis(500).await;
led.set_low(); // Turn off the LED
Timer::after_millis(500).await;
}
}
We are using the Output struct here because we want to send signals from the Pico to the LED. We set up GPIO 13 as an output pin and start it in the low (off) state.
Note
If you want to read signals from a component (like a button or sensor), you’ll need to configure the GPIO pin as Input instead.
Then we call set_high and set_low on the pin with a delay between them. This switches the pin between high and low, which turns the LED on and off.
The Full code
Here is the complete code for reference:
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_rp::gpio::{Level, Output};
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let mut led = Output::new(p.PIN_13, Level::Low);
loop {
led.set_high(); // Turn on the LED
Timer::after_millis(500).await;
led.set_low(); // Turn off the LED
Timer::after_millis(500).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"external-led"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone the project I created and navigate to the external-led folder:
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/external-led
How to Run?
You refer the “Running The Program” section
Blinky Example using rp-hal
In the previous section, we used Embassy. We keep the same circuit and wiring. For this example, we switch to rp-hal to show how both approaches look. You can choose Embassy if you want async support, or rp-hal if you prefer the blocking style. In this book, we will mainly use Embassy.
We will create a new project again with cargo-generate and the same template.
In your terminal, type:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When it asks you to select HAL, choose “rp-hal” this time.
Imports
The template already includes most imports. For this example, we need to add the OutputPin trait from embedded-hal:
#![allow(unused)]
fn main() {
// Embedded HAL trait for the Output Pin
use embedded_hal::digital::OutputPin;
}
This trait provides the set_high() and set_low() methods we’ll use to control the LED.
Main Logic
If you compare this with the Embassy version, there’s not much difference in how the LED is toggled. The main difference is in how the delay works. Embassy uses async and await, which lets the program pause without blocking and allows other tasks to run in the background. rp-hal uses a blocking delay, which stops the program until the time has passed.
#![allow(unused)]
fn main() {
let mut led_pin = pins.gpio13.into_push_pull_output();
loop {
led_pin.set_high().unwrap();
timer.delay_ms(200);
led_pin.set_low().unwrap();
timer.delay_ms(200);
}
}
Full code
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// Embedded HAL trait for the Output Pin
use embedded_hal::digital::OutputPin;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// External high-speed crystal on the Raspberry Pi Pico 2 board is 12 MHz.
/// Adjust if your board has a different frequency
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
// Grab our singleton objects
let mut pac = hal::pac::Peripherals::take().unwrap();
// Set up the watchdog driver - needed by the clock setup code
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Configure the clocks
//
// The default is to generate a 125 MHz system clock
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// The single-cycle I/O block controls our GPIO pins
let sio = hal::Sio::new(pac.SIO);
// Set the pins up according to their function on this particular board
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
let mut led_pin = pins.gpio13.into_push_pull_output();
loop {
led_pin.set_high().unwrap();
timer.delay_ms(200);
led_pin.set_low().unwrap();
timer.delay_ms(200);
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
Clone the existing project
You can clone the project I created and navigate to the external-led folder:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/external-led
From std to no_std
We have successfully flashed and run our first program, which creates a blinking effect. However, we have not yet explored the code or the project structure in detail. In this section, we will recreate the same project from scratch. I will explain each part of the code and configuration along the way. Are you ready for the challenge?
Tip
If you find this chapter overwhelming, especially if you’re just working on a hobby project, feel free to skip it for now. You can come back to it later after building some fun projects and working through exercises.
Create a Fresh Project
We will start by creating a standard Rust binary project. Use the following command:
#![allow(unused)]
fn main() {
cargo new pico-from-scratch
}
At this stage, the project will contain the usual files as expected.
├── Cargo.toml
└── src
└── main.rs
Our goal is to reach the following final project structure:
├── build.rs
├── .cargo
│ └── config.toml
├── Cargo.toml
├── memory.x
├── rp235x_riscv.x
├── src
│ └── main.rs
Cross Compilation
You probably know about cross compilation already. In this section, we’ll explore how this works and what it means to deal with things like target triples. In simple terms, cross compilation is building programs for different machine than the one you’re using.
You can write code on one computer and make programs that run on totally different computers. For example, you can work on Linux and build .exe files for Windows. You can even target bare-metal microcontrollers like the RP2350, ESP32, or STM32.
TL;DR
We have to use either “thumbv8m.main-none-eabihf” or “riscv32imac-unknown-none-elf” as the target when building our binary for the Pico 2.
cargo build --target thumbv8m.main-none-eabihfWe can also configure the target in
.cargo/config.tomlso that we don’t need to type it every time.
Building for Your Host System
Let’s say we are on a Linux machine. When you run the usual build command, Rust compiles your code for your current host platform, which in this case is Linux:
cargo build
You can confirm what kind of binary it just produced using the file command:
file ./target/debug/pico-from-scratch
This will give an output like the following. This tells you it is a 64-bit ELF binary, dynamically linked, and built for Linux.
./target/debug/pico-from-scratch: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, Build...
Cross compiling for Windows
Now let’s say you want to build a binary for Windows without leaving your Linux machine. That’s where cross-compilation comes into play.
First, you need to tell Rust about the target platform. You only have to do this once:
rustup target add x86_64-pc-windows-gnu
This adds support for generating 64-bit Windows binaries using the GNU toolchain (MinGW).
Now build your project again, this time specifying the target:
cargo build --target x86_64-pc-windows-gnu
That’s it. Rust will now create a Windows .exe binary, even though you’re still on Linux. The output binary will be located at target/x86_64-pc-windows-gnu/debug/pico-from-scratch.exe
You can inspect the file type like this:
file target/x86_64-pc-windows-gnu/debug/pico-from-scratch.exe
It will give you output like this, a 64 bit PE32+ File format file for windows.
target/x86_64-pc-windows-gnu/debug/pico-from-scratch.exe: PE32+ executable (console) x86-64, for MS Windows
What Is a Target Triple?
So what’s this x86_64-pc-windows-gnu string all about?
That’s what we call a target triple, and it tells the compiler exactly what kind of output you want. It usually follows this format:
`<architecture>-<vendor>-<os>-<abi>`
But the pattern is not always consistent. Sometimes the ABI part won’t be there. In other cases, even the vendor or both vendor and ABI might be absent. The structure can get messy, and there are plenty of exceptions. If you want to dive deeper into all the quirks and edge cases, check out the article “What the Hell Is a Target Triple?” linked in the references.
Let’s break down what this target triple actually means:
-
Architecture (x86_64): This just means 64-bit x86, which is the type of CPU most modern PCs use. It’s also called AMD64 or x64.
-
Vendor (pc): This is basically a placeholder. It’s not very important in most cases. If it is for mac os, the vendor name will be “apple”.
-
OS (windows): This tells Rust that we want to build something that runs on Windows.
-
ABI (gnu): This part tells Rust to use the GNU toolchain to build the binary.
Reference
Compiling for Microcontroller
Now let’s talk about embedded systems. When it comes to compiling Rust code for a microcontroller, things work a little differently from normal desktop systems. Microcontrollers don’t usually run a full operating system like Linux or Windows. Instead, they run in a minimal environment, often with no OS at all. This is called a bare-metal environment.
Rust supports this kind of setup through its no_std mode. In normal Rust programs, the standard library (std) handles things like file systems, threads, heap allocation, and I/O. But none of those exist on a bare-metal microcontroller. So instead of std, we use a much smaller core library, which provides only the essential building blocks.
The Target Triple for Pico 2
The Raspberry Pi Pico 2 (RP2350 chip), as you already know that it is unique; it contains selectable ARM Cortex-M33 and Hazard3 RISC-V cores . You can choose which processor architecture to use.
ARM Cortex-M33 Target
For ARM mode, we have to use the target [thumbv8m.main-none-eabi](https://doc.rust-lang.org/nightly/rustc/platform-support/thumbv8m.main-none-eabi.html):
Let’s break this down:
- Architecture (thumbv8m.main): The Cortex-M33 uses the ARM Thumb-2 instruction set for ARMv8-M architecture.
- Vendor (none): No specific vendor designation.
- OS (none): No operating system - it’s bare-metal.
- ABI (eabi): Embedded Application Binary Interface, the standard calling convention for embedded ARM systems.
To install and use this target:
rustup target add thumbv8m.main-none-eabi
cargo build --target thumbv8m.main-none-eabi
RISC-V Hazard3 Target
For RISC-V mode, use the target [riscv32imac-unknown-none-elf](https://doc.rust-lang.org/nightly/rustc/platform-support/riscv32-unknown-none-elf.html):
riscv32imac-unknown-none-elf
Let’s break this down:
- Architecture (riscv32imac): 32-bit RISC-V with I (integer), M (multiply/divide), A (atomic), and C (compressed) instruction sets.
- Vendor (unknown): No specific vendor.
- OS (none): No operating system - it’s bare-metal.
- Format (elf): ELF (Executable and Linkable Format), the object file format commonly used in embedded systems.
To install and use this target:
rustup target add riscv32imac-unknown-none-elf
cargo build --target riscv32imac-unknown-none-elf
In our exercises, we’ll mostly use the ARM mode. Some crates like panic-probe don’t work in RISC-V mode.
Cargo Config
In the quick start, you might have noticed that we never manually passed the –target flag when running the cargo command. So how did it know which target to build for? That’s because the target was already configured in the .cargo/config.toml file.
This file lets you store cargo-related settings, including which target to use by default. To set it up for Pico 2 in ARM mode, create a .cargo folder in your project root and add a config.toml file with the following content:
[build]
target = "thumbv8m.main-none-eabihf"
Now you don’t have to pass –target every time. Cargo will use this automatically.
no_std
Rust has two main foundational crates: std and core.
-
The std crate is the standard library. It gives you things like heap allocation, file system access, threads, and println!.
-
The core crate is a minimal subset. It contains only the most essential Rust features, like basic types (Option, Result, etc.), traits, and few other operations. It doesn’t depend on an operating system or runtime.
When you try to build the project at this stage, you’ll get a bunch of errors. Here’s what it looks like:
error[E0463]: can't find crate for `std`
|
= note: the `thumbv8m.main-none-eabihf` target may not support the standard library
= note: `std` is required by `pico_from_scratch` because it does not declare `#![no_std]`
error: cannot find macro `println` in this scope
--> src/main.rs:2:5
|
2 | println!("Hello, world!");
| ^^^^^^^
error: `#[panic_handler]` function required, but not found
For more information about this error, try `rustc --explain E0463`.
error: could not compile `pico-from-scratch` (bin "pico-from-scratch") due to 3 previous errors
There are so many errors here. Lets fix one by one. The first error says the target may not support the standard library. That’s true. We already know that. The problem is, we didn’t tell Rust that we don’t want to use std. That’s where no_std attribute comes into play.
#![no_std]
The #![no_std] attribute disables the use of the standard library (std). This is necessary most of the times for embedded systems development, where the environment typically lacks many of the resources (like an operating system, file system, or heap allocation) that the standard library assumes are available.
In the top of your src/main.rs file, add this line:
#![no_std]
That’s it. Now Rust knows that this project will only use the core library, not std.
Println
The println! macro comes from the std crate. Since we’re not using std in our project, we can’t use println!. Let’s go ahead and remove it from the code.
Now the code should be like this
#![no_std]
fn main() {
}
With this fix, we’ve taken care of two errors and cut down the list. There’s still one more issue showing up, and we’ll fix that in the next section.
Resources:
Panic Handler
At this point, when you try to build the project, you’ll get this error:
error: `#[panic_handler]` function required, but not found
When a Rust program panics, it is usually handled by a built-in panic handler that comes from the standard library. But in the last step, we added #![no_std], which tells Rust not to use the standard library. So now, there’s no panic handler available by default.
In a no_std environment, you are expected to define your own panic behavior, because there’s no operating system or runtime to take over when something goes wrong.
We can fix this by adding our own panic handler. Just create a function with the #[panic_handler] attribute. The function must accept a reference to PanicInfo, and its return type must be !, which means the function never returns.
Add this to your src/main.rs:
#![allow(unused)]
fn main() {
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
loop {}
}
}
Panic crates
There are some ready-made crates that provide a panic handler function for no_std projects. One simple and commonly used crate is “panic_halt”, which just halts the execution when a panic occurs.
#![allow(unused)]
fn main() {
use panic_halt as _;
}
This line pulls in the panic handler from the crate. Now, if a panic happens, the program just stops and stays in an infinite loop.
In fact, the panic_halt crate’s code implements a simple panic handler, which looks like this:
#![allow(unused)]
fn main() {
use core::panic::PanicInfo;
use core::sync::atomic::{self, Ordering};
#[inline(never)]
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {
atomic::compiler_fence(Ordering::SeqCst);
}
}
}
You can either use an external crate like this, or write your own panic handler function manually. It’s up to you.
Resources:
no_main
When you try to build at this stage, you’ll get an error saying the main function requires the standard library. What?! (I controlled my temptation to insert a Mr. Bean meme here since not everyone will like meme.) So what now? Where does the program even start?
In embedded systems, we don’t use the regular “fn main” that relies on the standard library. Instead, we have to tell Rust that we’ll bring our own entry point. And for that, we use the no_main attribute.
The #![no_main] attribute is to indicate that the program won’t use the standard entry point (fn main).
In the top of your src/main.rs file, add this line:
#![no_main]
Declaring the Entry Point
Now that we’ve opted out of the default entry point, we need to tell Rust which function to start with. Each HAL crates in the embedded Rust ecosystem provides a special proc macro attribute that allows us to mark the entry point. This macro initializes and sets up everything needed for the microcontroller.
If we were using rp-hal, we could use rp235x_hal::entry for the RP2350 chip. However, we’re going to use Embassy (the embassy-rp crate). Embassy provides the embassy_executor::main macro, which sets up the async runtime for tasks and calls our main function.
The Embassy Executor is an async/await executor designed for embedded usage along with support functionality for interrupts and timers. You can read the official Embassy book to understand in depth how Embassy works.
Cortex-m Run Time
If you follow the embassy_executor::main macro, you’ll see it uses another macro depending on the architecture. Since the Pico 2 is Cortex-M, it uses cortex_m_rt::entry. This comes from the cortex_m_rt crate, which provides startup code and minimal runtime for Cortex-M microcontrollers.
If you run cargo expand in the quick-start project, you can see how the macro expands and the full execution flow. If you follow the rabbit hole, the program starts at the __cortex_m_rt_main_trampoline function. This function calls __cortex_m_rt_main, which sets up the Embassy executor and runs our main function.
To make use of this, we need to add the cortex-m and cortex-m-rt crates to our project. Update the Cargo.toml file:
cortex-m = { version = "0.7.6" }
cortex-m-rt = "0.7.5"
Now, we can add the embassy executor crate:
embassy-executor = { version = "0.9", features = [
"arch-cortex-m",
"executor-thread",
] }
Then, in your main.rs, set up the entry point like this:
use embassy_executor::Spawner;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {}
We have changed the function signature. The function must accept a Spawner as its argument to satisfy embassy’s requirements, and the function is now marked as async.
Are we there yet?
Hoorah! Now try building the project - it should compile successfully.
You can inspect the generated binary using the file command:
file target/thumbv8m.main-none-eabihf/debug/pico-from-scratch
It will show something like this:
target/thumbv8m.main-none-eabihf/debug/pico-from-scratch: ELF 32-bit LSB executable, ARM, EABI5 version 1 (GNU/Linux), statically linked, with debug_info, not stripped
As you can see, the binary is built for a 32-bit ARM. That means our base setup for Pico is working.
But are we there yet? Not quite. We’ve crossed half the stage - we now have a valid binary ready for Pico, but there’s more to do before we can run it on real hardware.
Resources:
Peripherals
Before we move on to the next part, let’s quickly look at what peripherals are.
In embedded systems, peripherals are hardware components that extend the capabilities of a microcontroller (MCU). They allow the MCU to interact with the outside world by handling inputs and outputs, communication, timing, and more.
While the CPU is responsible for executing program logic, peripherals do the heavy lifting of interacting with hardware, often offloading work from the CPU. This allows the CPU to focus on critical tasks while peripherals handle specialized functions independently or with minimal supervision.
Offloading
Offloading refers to the practice of delegating certain tasks to hardware peripherals instead of doing them directly in software via the CPU. This improves performance, reduces power consumption, and enables concurrent operations. For example:
- A UART peripheral can send and receive data in the background using DMA (Direct Memory Access), while the CPU continues processing other logic.
- A Timer can be configured to generate precise delays or periodic interrupts without CPU intervention.
- A PWM controller can drive a motor continuously without the CPU constantly toggling pins.
Offloading is a key design strategy in embedded systems to make efficient use of limited processing power.
Common Types of Peripherals
Here are some of the most common types of peripherals found in embedded systems:
| Peripheral | Description |
|---|---|
| GPIO (General Purpose Input/Output) | Digital pins that can be configured as inputs or outputs to interact with external hardware like buttons, LEDs, and sensors. |
| UART (Universal Asynchronous Receiver/Transmitter) | Serial communication interface used for sending and receiving data between devices, often used for debugging. |
| SPI (Serial Peripheral Interface) | High-speed synchronous communication protocol used to connect microcontrollers to peripherals like SD cards, displays, and sensors using a master-slave architecture. |
| I2C (Inter-Integrated Circuit) | Two-wire serial communication protocol used for connecting low-speed peripherals such as sensors and memory chips to a microcontroller. |
| ADC (Analog-to-Digital Converter) | Converts analog signals from sensors or other sources into digital values that the microcontroller can process. |
| PWM (Pulse Width Modulation) | Generates signals that can control power delivery, used commonly for LED dimming, motor speed control, and servo actuation. |
| Timer | Used for generating delays, measuring time intervals, counting events, or triggering actions at specific times. |
| RTC (Real-Time Clock) | Keeps track of current time and date even when the system is powered off, typically backed by a battery. |
Peripherals in Rust
In embedded Rust, peripherals are accessed using a singleton model. One of Rust’s core goals is safety, and that extends to how it manages hardware access. To ensure that no two parts of a program can accidentally control the same peripheral at the same time, Rust enforces exclusive ownership through this singleton approach.
The Singleton Pattern
The singleton pattern ensures that only one instance of each peripheral exists in the entire program. This avoids common bugs caused by multiple pieces of code trying to modify the same hardware resource simultaneously.
In embassy, peripherals are also exposed using this singleton model. But we won’t be calling Peripherals::take() directly. Instead, we will use the embassy_rp::init(Default::default()) function. This function takes care of basic system setup and internally calls Peripherals::take() for us. So we get access to all peripherals in a safe and ready-to-use form.
Embassy for Raspberry Pi Pico
We already introduced the concept of HAL in the introduction chapter. For the Pico, we will use the Embassy RP HAL. The Embassy RP HAL targets the Raspberry Pi RP2040, as well as RP235x microcontrollers.
The HAL supports blocking and async peripheral APIs. Using async APIs is better because the HAL automatically handles waiting for peripherals to complete operations in low power mode and manages interrupts, so you can focus on the primary functionality.
Let’s add the embassy-rp crate to our project.
embassy-rp = { version = "0.8.0", features = [
"rp235xa",
] }
We’ve enabled the rp235xa feature because our chip is the RP2350. If we were using the older Pico, we would instead enable the rp2040 feature.
Initialize the embassy-rp HAL
Let’s initialize the HAL. We can pass custom configuration to the initialization function if needed. The config currently allows us to modify clock settings, but we’ll stick with the defaults for now:
#![allow(unused)]
fn main() {
let peripherals = embassy_rp::init(Default::default());
}
This gives us the peripheral singletons we need. Remember, we should only call this once at startup; calling it again will cause a panic.
Timer
We are going to replicate the quick start example by blinking the onboard LED. To create a blinking effect, we need a timer to add delays between turning the LED on and off. Without delays, the blinking would be too fast to see.
To handle timing, we’ll use the “embassy-time” crate, which provides essential timing functions:
#![allow(unused)]
fn main() {
embassy-time = { version = "0.5.0" }
}
We also need to enable the time-driver feature in the embassy-rp crate. This configures the TIMER peripheral as a global time driver for embassy-time, running at a tick rate of 1MHz:
embassy-rp = { version = "0.8.0", features = [
"rp235xa",
"time-driver",
"critical-section-impl",
] }
We’ve almost added all the essential crates. Now let’s write the code for the blinking effect.
Blinking onboard LED on Raspberry Pi Pico 2
When you start with embedded programming, GPIO is the first peripheral you’ll work with. “General-Purpose Input/Output” means exactly what it sounds like: we can use it for both input and output. As an output, the Pico can send signals to control components like LEDs. As an input, components like buttons can send signals to the Pico.
For this exercise, we’ll control the onboard LED by sending signals to it. If you check page 8 of the Pico 2 datasheet, you’ll see that the onboard LED is wired to GPIO Pin 25.
We’ll configure GPIO Pin 25 as an output pin and set its initial state to low (off):
#![allow(unused)]
fn main() {
let mut led = Output::new(peripherals.PIN_25, Level::Low);
}
Most code editors like VS Code have shortcuts to automatically add imports for you. If your editor doesn’t have this feature or you’re having issues, you can manually add these imports:
#![allow(unused)]
fn main() {
use embassy_rp::gpio::{Level, Output};
}
Blinking Logic
Now we’ll create a simple loop to make the LED blink. First, we turn on the LED by calling the set_high() function on our GPIO instance. Then we add a short delay using Timer. Next, we turn off the LED with set_low(). Then we add another delay. This creates the blinking effect.
Let’s import Timer into our project:
#![allow(unused)]
fn main() {
use embassy_time::Timer;
}
Here’s the blinking loop:
#![allow(unused)]
fn main() {
loop {
led.set_high();
Timer::after_millis(250).await;
led.set_low();
Timer::after_millis(250).await;
}
}
Flashing the Rust Firmware into Raspberry Pi Pico 2
After building our program, we’ll have an ELF binary file ready to flash.
For a debug build (cargo build), you’ll find the file here:
./target/thumbv8m.main-none-eabihf/debug/pico-from-scratch
For a release build (cargo build --release), you’ll find it here:
./target/thumbv8m.main-none-eabihf/release/pico-from-scratch
To load our program onto the Pico, we’ll use a tool called Picotool. Here’s the command to flash our program:
#![allow(unused)]
fn main() {
picotool load -u -v -x -t elf ./target/thumbv8m.main-none-eabihf/debug/pico-from-scratch
}
Here’s what each flag does:
-ufor update mode (only writes what’s changed)-vto verify everything wrote correctly-xto run the program immediately after loading-t elftells picotool we’re using an ELF file
cargo run command
Typing that long command every time gets tedious. Let’s simplify it by updating the “.cargo/config.toml” file. We can configure Cargo to automatically use picotool when we run cargo run:
[target.thumbv8m.main-none-eabihf]
runner = "picotool load -u -v -x -t elf"
Now, you can just type:
cargo run --release
#or
cargo run
and your program will be flashed and executed on the Pico.
But at this point, it still won’t actually flash. We’re missing one important step.
Linker Script
The program now compiles successfully. However, when you attempt to flash it onto the Pico, you may encounter an error like the following:
ERROR: File to load contained an invalid memory range 0x00010000-0x000100aa
Comparing our project with quick start project
To understand why flashing fails, let’s inspect the compiled program using the arm-none-eabi-readelf tool. This tool shows how the compiler and linker organized the program in memory.
I took the binary from the quick-start project and compared it with the binary our project produces at its current state.
You don’t need to understand every detail in this output. The important part is simply noticing that the two binaries look very different, even though our Rust code is almost the same.
The big difference is that our project is missing some important sections like .text, .rodata, .data, and .bss. These sections are normally created by the linker:
- .text : this is where the actual program instructions (the code) go
- .rodata : read-only data, such as constant values
- .data : initialized global or static variables
- .bss : uninitialized global or static variables
You can also use cargo size command provided by the cargo-binutils toolset to compare them.
Linker:
This is usually taken care of by something called linker. The role of the linker is to take all the pieces of our program, like compiled code, library code, startup code, and data, and combine them into one final executable that the device can actually run. It also decides where each part of the program should be placed in memory, such as where the code goes and where global variables go.
However, the linker does not automatically know the memory layout of the RP2350. We have to tell it how the flash and RAM are arranged. This is done through a linker script. If the linker script is missing or incorrect, the linker will not place our code in the proper memory regions, which leads to the flashing error we are seeing.
Linker Script
We are not going to write the linker script ourselves. The cortex-m-rt crate already provides the main linker script (link.x), but it only knows about the Cortex-M core. It does not know anything about the specific microcontroller we are using. Every microcontroller has its own flash size, RAM size, and memory layout, and cortex-m-rt cannot guess these values.
Because of this, cortex-m-rt expects the user or the board support crate to supply a small linker script called memory.x. This file describes the memory layout of the target device.
In memory.x, we must define the memory regions that the device has. At minimum, we need two regions: one named FLASH and one named RAM. The .text and .rodata sections of the program are placed in the FLASH region. The .bss and .data sections, along with the heap, are placed in the RAM region.
For the RP2350, the datasheet (chapter 2.2, Address map) specifies that flash starts at address 0x10000000 and SRAM starts at 0x20000000. So our memory.x file will look something like this:
MEMORY {
FLASH : ORIGIN = 0x10000000, LENGTH = 2048K
RAM : ORIGIN = 0x20000000, LENGTH = 512K
SRAM4 : ORIGIN = 0x20080000, LENGTH = 4K
SRAM5 : ORIGIN = 0x20081000, LENGTH = 4K
...
...
}
...
...
There are a few more settings required in memory.x for RP2350. We do not need to write those by hand. Instead, we will use the file provided in the embassy-rp examples repository. You can download it from here and place it in the root of your project.
Codegen Option for Linker
Putting the memory.x file in the project folder is not enough. We also need to make sure the linker actually uses the linker script provided by cortex-m-rt.
To fix this, we tell Cargo to pass the linker script (link.x) to the linker. There are multiple ways we can pass the argument to the rust. we can use the method like .cargo/config.toml or build script (build.rs) file. In the quick start, we are using the build.rs. So we will use the .cargo/config.toml approach. In the file, update the target section with the following
[target.thumbv8m.main-none-eabihf]
runner = "picotool load -u -v -x -t elf" # we alerady added this
rustflags = ["-C", "link-arg=-Tlink.x"] # This is the new line
Run Pico Run
With everything set up, you can now flash the program to the Pico:
#![allow(unused)]
fn main() {
cargo run --release
}
Phew… we took a normal Rust project, turned it into a no_std firmware for the Pico. Finally, we can now see the LED blinking.
Resources
Creating a Rust Project for Raspberry Pi Pico in VS Code (with extension)
We’ve already created the Rust project for the Pico manually and through the template. Now we are going to try another approach: using the Raspberry Pi Pico extension for VS Code.
Using the Pico Extension
In Visual Studio Code, search for the extension “Raspberry Pi Pico” and ensure you’re installing the official one; it should have a verified publisher badge with the official Raspberry Pi website. Install that extension.
Just installing the extension might not be enough though, depending on what’s already on your machine. On Linux, you’ll likely need some basic dependencies:
sudo apt install build-essential libudev-dev
Create Project
Let’s create the Rust project with the Pico extension in VS Code. Open the Activity Bar on the left and click the Pico icon. Then choose “New Rust Project.”
Since this is the first time setting up, the extension will download and install the necessary tools, including the Pico SDK, picotool, OpenOCD, and the ARM and RISC-V toolchains for debugging.
Project Structure
If the project was created successfully, you should see folders and files like this:
Running the Program
Now you can simply click “Run Project (USB)” to flash the program onto your Pico and run it. Don’t forget to press the BOOTSEL button when connecting your Pico to your computer. Otherwise, this option will be in disabled state.
Once flashing is complete, the program will start running immediately on your Pico. You should see the onboard LED blinking.
Pulse Width Modulation (PWM)
In this section, we will explore what is PWM and why we need it.
Digital vs Analog
To understand PWM, we first need to understand what is digital and analog signal.
Digital Signals
A digital signal has only two states: HIGH or LOW. In microcontrollers, HIGH typically means the full voltage (5V or 3.3V), and LOW means 0V. There’s nothing in between. Think of it like a light switch that can only be fully ON or fully OFF.
When you use a digital pin on your microcontroller, you can only output these two values. If you write HIGH to a pin, it outputs 3.3V. If you write LOW, it outputs 0V. You cannot tell a digital pin to output 1.5V or 2.7V or any value in between.
Analog Signals
An analog signal can have any voltage value within a range. Instead of just ON or OFF, it varies continuously and smoothly. Think of it like a dimmer switch that can set brightness anywhere from completely off to fully bright, with infinite positions in between.
For example, an analog signal could be 0V, 0.5V, 1.5V, 2.8V, 3.1V, or any other value within the allowed range. This smooth variation allows you to have precise control over devices.
The Problem
Here’s the challenge: most microcontroller pins are digital. They can only output HIGH or LOW. But what if you want to:
Dim an LED to 50% brightness instead of just fully ON or fully OFF (like we did in the quick-start blinking example)? Or Control a servo motor to any position between 0° and 180°? Or Adjust the speed of a fan or control temperature gradually?
You need something that acts like an analog output, but you only have digital pins. This is where PWM comes in.
Pulse Width Modulation (PWM)
PWM stands for Pulse Width Modulation. It is a technique that uses a digital signal switching rapidly between HIGH and LOW to produce an output that behaves like an analog voltage.
In the image above, the first chart shows a simple 3.3 V signal. This is what we normally use on a GPIO pin, for example to turn an LED fully on or fully off, which creates a blinking effect.
To produce a voltage between 0 V and 3.3 V, we do not keep the signal HIGH all the time. Instead, we repeatedly switch the pin between 0 V and 3.3 V.
When this switching happens very quickly, the connected device cannot follow each individual change. It does not see a clean 0 V or a clean 3.3 V. What it responds to is how long the signal stays at 3.3 V compared to how long it stays at 0 V.
In this example, the signal is at 3.3 V for half the time and at 0 V for the other half. Because of this, the device receives about half the voltage on average, which behaves like approximately 1.65 V.
Pulse Width & Duty Cycle
Pulse width is simply how long a signal stays ON before it turns OFF. It is measured in time, such as microseconds or milliseconds.
For example, if a pulse has a width of 1 millisecond, the signal stays HIGH for 1 millisecond and then turns LOW for the rest of the cycle.
The duty cycle describes the same idea, but in a different way. Instead of using time, it describes how much of the cycle the signal stays ON, written as a percentage.
So instead of saying the signal is ON for 1 millisecond, we can say it is ON for 50% of the time.
For example:
- A 0% duty cycle means the signal is always LOW (0V average).
- A 50% duty cycle means the signal is HIGH and LOW for equal amounts of time (1.65V average on a 3.3V system).
- A 75% duty cycle means the signal is HIGH for 75% of the time and LOW for 25% of the time.
- A 100% duty cycle means the signal is always HIGH (3.3V).
Changing the duty cycle changes how much power is delivered to the load, which is why an LED appears dim, medium bright, or fully bright in the image above.
Example Usage 1: Dimming an LED
An LED flashes so quickly that your eyes can’t see individual ON and OFF pulses, so you perceive only the average brightness. A low duty cycle makes it look dim, a higher one makes it look brighter, even though the LED is always switching between full voltage and zero. In the next chapter, we will do this.
Example Usage 2: Controlling a Servo Motor
A servo reads the width of a pulse to decide its angle. It expects a pulse every 20 milliseconds, and the pulse width - about 1ms for 0°, 1.5ms for 90°, and 2ms for 180° - tells the servo where to move.
Period and Frequency
By now, you should have a basic idea of pulse width and duty cycle. Next, we will look at two more important concepts used in PWM: period and frequency.
These two ideas describe how fast the PWM signal repeats.
Period
The period is the total time it takes for one complete ON-OFF cycle to finish. In other words, it is the time from one point in the signal until that same point appears again in the next cycle.
For example:
- In the top part of the diagram, one complete cycle takes 1 second, so the period is 1 second. This is a slow-changing signal.
- In the bottom part of the diagram, one complete cycle takes 0.2 seconds, so the period is 0.2 seconds. This is a faster-changing signal.
Frequency
Frequency tells us how many complete cycles happen in one second. It’s measured in Hertz (Hz).
For example:
- 1 Hz = 1 cycle per second (like the top part of the diagram)
- 5 Hz = 5 cycles per second (like the bottom part of the diagram)
Relationship
The frequency of a signal and its period are inversely related.
\[ \text{Frequency (Hz)} = \frac{1}{\text{Period (s)}} \]
This means:
- When the period gets shorter, the frequency gets higher
- When the period gets longer, the frequency gets lower
Still Confusing? Think of It Like This:
Imagine you and your friend are counting from 0 to 99, over and over again.
You count fast and finish one round quickly. Your friend counts slowly and takes much longer to finish the same round. You both count the same numbers. Only the speed is different.
The time it takes to finish one round is the period. How fast you repeat the rounds is the frequency. Counting faster means a shorter period and a higher frequency. Counting slower means a longer period and a lower frequency.
Examples
So if the period is 1 second, then the frequency will be 1Hz.
\[ 1 \text{Hz} = \frac{1 \text{ cycle}}{1 \text{ second}} = \frac{1}{1 \text{ s}} \]
For example, if the period is 20ms (0.02s), the frequency will be 50Hz.
\[ \text{Frequency} = \frac{1}{20 \text{ ms}} = \frac{1}{0.02 \text{ s}} = 50 \text{ Hz} \]
Simulation
Here is the interactive simulation. Use the sliders to adjust the duty cycle and frequency, and watch how the pulse width and LED brightness change. The upper part of the square wave represents when the signal is high (on). The lower part represents when the signal is low (off). The width of the high portion changes with the duty cycle.
If you change the duty cycle from “low to high” and “high to low” in the simulation, you should notice the LED kind of giving a dimming effect.
PWM Peripheral in RP2350
The RP2350 has a PWM peripheral with 12 PWM generators called slices. Each slice contains two output channels (A and B), giving you a total of 24 PWM output channels. For detailed specifications, see page 1077 of the RP2350 Datasheet.
Let’s have a quick look at some of the key concepts.
PWM Generator (Slice)
A slice is the hardware block that generates PWM signals. Each of the 12 slices (PWM0-PWM11) is an independent timing unit with its own 16-bit counter, compare registers, control settings, and clock divider. This independence means you can configure each slice with different frequencies and resolutions.
Channel
Each slice contains two output channels: Channel A and Channel B. Both channels share the same counter, so they run at the same frequency and are synchronized. However, each channel has its own compare register, allowing independent duty cycle control. This lets you generate two related but distinct PWM signals from a single slice.
Mapping of PWM channels to GPIO Pins
Each GPIO pin connects to a specific slice and channel. You’ll find the complete mapping table on page 1078 of the RP2350 Datasheet. For example, GP25 (the onboard LED pin) maps to PWM slice 4, channel B, labeled as 4B.
Initialize the PWM peripheral and get access to all slices:
#![allow(unused)]
fn main() {
let mut pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
}
Get a reference to PWM slice 4 for configuration:
#![allow(unused)]
fn main() {
let pwm = &mut pwm_slices.pwm4;
}
GPIO to PWM
I have created a small form that helps you figure out which GPIO pin maps to which PWM channel and also generates sample code.
Phase-Correct Mode
In standard PWM (fast PWM), the counter counts up from 0 to TOP, then immediately resets to 0. This creates asymmetric edges where the output changes at different points in the cycle.
Phase-correct PWM counts up to TOP, then counts back down to 0, creating a triangular waveform. The output switches symmetrically - once going up and once coming down. This produces centered pulses with edges that mirror each other, reducing electromagnetic interference and creating smoother transitions. The trade-off is that phase-correct mode runs at half the frequency of standard PWM for the same TOP value.
Configure PWM4 to operate in phase-correct mode for smoother output transitions.
#![allow(unused)]
fn main() {
pwm.set_ph_correct();
}
Get a mutable reference to channel B of PWM4 and direct its output to GPIO pin 25.
#![allow(unused)]
fn main() {
let channel = &mut pwm.channel_b;
channel.output_to(pins.gpio25);
}
Dimming LED
In this section, we will learn how to create a dimming effect(i.e. reducing and increasing the brightness gradually) for an LED using the Raspberry Pi Pico 2. First, we will dim the onboard LED, which is connected to GPIO pin 25 (based on the datasheet).
To make it dim, we use a technique called PWM (Pulse Width Modulation). You can refer to the intro to the PWM section here.
We will gradually increment the PWM’s duty cycle to increase the brightness, then we gradually decrement the PWM duty cycle to reduce the brightness of the LED. This effectively creates the dimming LED effect.
The Eye
“ Come in close… Closer…
Because the more you think you see… The easier it’ll be to fool you…
Because, what is seeing?…. You’re looking but what you’re really doing is filtering, interpreting, searching for meaning… “
Here’s the magic: when this switching happens super quickly, our eyes can’t keep up. Instead of seeing the blinking, it just looks like the brightness changes! The longer the LED stays ON, the brighter it seems, and the shorter it’s ON, the dimmer it looks. It’s like tricking your brain into thinking the LED is smoothly dimming or brightening.
Core Logic
What we will do in our program is gradually increase the duty cycle from a low value to a high value in the first loop, with a small delay between each change. This creates the fade-in effect. After that, we run another loop that decreases the duty cycle from high to low, again with a small delay. This creates the fade-out effect.
You can use the onboard LED, or if you want to see the dimming more clearly, use an external LED. Just remember to update the PWM slice and channel to match the GPIO pin you are using.
Simulation - LED Dimming with PWM
Here is a simulation to show the dimming effect on an LED based on the duty cycle and the High and Low parts of the square wave. I set the default speed very slow so it is clear and not annoying to watch. To start it, click the “Start animation” button. You can increase the speed by reducing the delay time and watching the changes.
LED Dimming on Raspberry Pi Pico with Embassy
Let’s create a dimming LED effect using PWM on the Raspberry Pi Pico with Embassy.
Generate project using cargo-generate
By now you should be familiar with the steps. We use the cargo-generate command with our custom template, and when prompted, select Embassy as the HAL.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
Update Imports
Add the import below to bring the PWM types into scope:
#![allow(unused)]
fn main() {
use embassy_rp::pwm::{Pwm, SetDutyCycle};
}
Initialize PWM
Let’s set up the PWM for the LED. Use the first line for the onboard LED, or uncomment the second one if you want to use an external LED on GPIO 16.
#![allow(unused)]
fn main() {
// For Onboard LED
let mut pwm = Pwm::new_output_b(p.PWM_SLICE4, p.PIN_25, Default::default());
// For external LED connected on GPIO 16
// let mut pwm = Pwm::new_output_a(p.PWM_SLICE0, p.PIN_16, Default::default());
}
Main logic
In the main loop, we create the fade effect by increasing the duty cycle from 0 to 100 percent and then bringing it back down. The small delay between each step makes the dimming smooth. You can adjust the delay and observe how the fade speed changes.
#![allow(unused)]
fn main() {
loop {
for i in 0..=100 {
Timer::after_millis(8).await;
let _ = pwm.set_duty_cycle_percent(i);
}
for i in (0..=100).rev() {
Timer::after_millis(8).await;
let _ = pwm.set_duty_cycle_percent(i);
}
Timer::after_millis(500).await;
}
}
The full code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_rp::pwm::{Pwm, SetDutyCycle};
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// For Onboard LED
let mut pwm = Pwm::new_output_b(p.PWM_SLICE4, p.PIN_25, Default::default());
// For external LED connected on GPIO 16
// let mut pwm = Pwm::new_output_a(p.PWM_SLICE0, p.PIN_16, Default::default());
loop {
for i in 0..=100 {
Timer::after_millis(8).await;
let _ = pwm.set_duty_cycle_percent(i);
}
for i in (0..=100).rev() {
Timer::after_millis(8).await;
let _ = pwm.set_duty_cycle_percent(i);
}
Timer::after_millis(500).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"led-dimming"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone the project I created and navigate to the external-led folder:
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/led-dimming
Dimming LED Program with RP HAL
rp-hal is an Embedded-HAL for RP series microcontrollers, and can be used as an alternative to the Embassy framework for pico.
This example code is taken from rp235x-hal repo (It also includes additional examples beyond just the blink examples):
“https://github.com/rp-rs/rp-hal/tree/main/rp235x-hal-examples”
The main code
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
// Traig for PWM
use embedded_hal::pwm::SetDutyCycle;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// External high-speed crystal on the Raspberry Pi Pico 2 board is 12 MHz.
/// Adjust if your board has a different frequency
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
/// The minimum PWM value (i.e. LED brightness) we want
const LOW: u16 = 0;
/// The maximum PWM value (i.e. LED brightness) we want
const HIGH: u16 = 25000;
#[hal::entry]
fn main() -> ! {
// Grab our singleton objects
let mut pac = hal::pac::Peripherals::take().unwrap();
// Set up the watchdog driver - needed by the clock setup code
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Configure the clocks
//
// The default is to generate a 125 MHz system clock
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// The single-cycle I/O block controls our GPIO pins
let sio = hal::Sio::new(pac.SIO);
// Set the pins up according to their function on this particular board
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
// Init PWMs
let mut pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
// Configure PWM4
let pwm = &mut pwm_slices.pwm4;
pwm.set_ph_correct();
pwm.enable();
// Output channel B on PWM4 to GPIO 25
let channel = &mut pwm.channel_b;
channel.output_to(pins.gpio25);
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
loop {
for i in LOW..=HIGH {
timer.delay_us(8);
let _ = channel.set_duty_cycle(i);
}
for i in (LOW..=HIGH).rev() {
timer.delay_us(8);
let _ = channel.set_duty_cycle(i);
}
timer.delay_ms(500);
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone the blinky project I created and navigate to the led-dimming folder to run this version of the blink program:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/led-dimming
Buttons
Now that we know how to blink an LED, let’s learn how to read input from a button. This will let us interact with our Raspberry Pi Pico and make our programs respond to what we do.
A button is a small tactile switch. You will find these in most beginner electronic kits. When you press it, the two pins inside make contact and the circuit closes. When you release it, the pins separate and the circuit opens again. Your program can read this open or closed state and do something based on it.
How a Tactile Button Works
A tactile button has four legs arranged in pairs. Looking at the button from above, the legs form a rectangle. The two legs on each side of the button are electrically connected together internally.
I will update this section later with a clearer diagram that shows the internal connections more explicitly. For now, this illustration is enough to understand the concept. The light line indicates that the pins on the left are connected to each other, and the same is true for the pins on the right. When the button is pressed, the left and right sides become connected.
Connecting Buttons to the Pico
Connect one side of the button to Ground and the other side to a GPIO pin (for example, GPIO 15). When the button is pressed, both sides become connected internally, and the GPIO 15 pin gets pulled low. We can check if the pin is pulled low in our code and trigger actions based on it.
Wait. What happens when the button is not pressed? What voltage or level is the GPIO pin reading now? For this to make sense logically, the pin should be in a High state so we can detect the Low state as a button press. But without anything else in the circuit, the GPIO pin will be in something called a floating state. This is unreliable, the pin can randomly switch between High and Low even when no button is pressed. How do we fix this? Let’s see in the next section.
Pull-up and Pull-down Resistors
When working with buttons, switches, and other digital inputs on your Raspberry Pi Pico, you’ll quickly encounter a curious problem: what happens when nothing is connected to an input pin? The answer might surprise you; the pin becomes “floating,” picking up electrical noise and giving you random, unpredictable readings. This is where pull-up and pull-down resistors come to the rescue.
The Floating Pin Problem
Imagine you connect a button directly to a GPIO pin on your Pico. When the button is pressed, it connects the pin to ground (0V). When released, you might expect the pin to read as HIGH, but it doesn’t work that way. Instead, the pin is disconnected from everything. It’s floating in an undefined state, acting like an antenna that picks up electrical noise from nearby circuits, your hand, or even radio waves in the air.
This floating state will cause your code to read random values, making your button appear to press itself or behave erratically. We need a way to give the pin a default, predictable state.
By the way, you can also connect the button the other way around; connecting one side to 3.3V instead of ground (though I wouldn’t recommend this for the RP2350, and I’ll explain why shortly). However, you’ll face the same issue. When the button is pressed, it connects to the High state. When released, you might expect it to go Low, but instead it’s in a floating state again.
What Are Pull-up and Pull-down Resistors?
Pull-up and pull-down resistors are simple solutions that ensure a pin always has a known voltage level, even when nothing else is driving it.
Pull-up resistor: Connects the pin to the positive voltage (3.3V on the Pico) through a resistor. This “pulls” the pin HIGH by default. When you press a button that connects the pin to ground, the pin reads LOW.
Pull-down resistor: Connects the pin to ground (0V) through a resistor. This “pulls” the pin LOW by default. When you press a button that connects the pin to 3.3V, the pin reads HIGH.
How Pull-up Resistors Work
Let’s look at a typical button circuit with a pull-up resistor:
When the button is not pressed, current flows through the resistor to the GPIO pin, holding it at 3.3V (HIGH). When you press the button, you create a direct path to ground. Since electricity follows the path of least resistance, current flows through the button to ground instead of to the pin, and the pin reads LOW.
How Pull-down Resistors Work
A pull-down resistor works in the opposite direction:W
When the button is not pressed, the GPIO pin is connected to ground through the resistor, reading LOW. When pressed, the button connects the pin directly to 3.3V, and the pin reads HIGH.
Internal Pull Resistors
The Raspberry Pi Pico has built-in pull-up and pull-down resistors on every GPIO pin. You don’t need to add external resistors for basic button inputs. You can enable them in software.
Using Pull Resistors in Embedded Rust
Let’s see how to configure internal pull resistors when setting up a button input on the Pico.
As you can see in the diagram, when we enable the internal pull-up resistor, the GPIO pin is pulled to 3.3V by default. The resistor sits inside the Pico chip itself, so we don’t need any external components; just the button connected between the GPIO pin and ground.
Here’s how to set it up in code:
#![allow(unused)]
fn main() {
let button = Input::new(p.PIN_16, Pull::Up);
// Read the button state
if button.is_low() {
// Button is pressed (connected to ground)
// Do something
}
}
With a pull-up resistor enabled, the GPIO pin gets pulled to HIGH voltage by default. When you press the button, it connects the pin to ground, and brings the pin LOW. So the logic is: button not pressed = HIGH, button pressed = LOW.
Setting up a Button with a Pull-down Resistor
Here’s similar code, but this time we use the internal pull-down resistor. With pull-down, the pin is pulled LOW by default. When the button is pressed, connecting the pin to 3.3V, it reads HIGH.
#![allow(unused)]
fn main() {
let button = Input::new(p.PIN_16, Pull::Down);
// Read the button state
if button.is_high() {
// Button is pressed (connected to 3.3V)
// Do something
}
}
Important
There’s a hardware bug (E9) in the initial RP2350 chip released in 2024 that affects internal pull-down resistors.
The bug causes the GPIO pin to read HIGH even when the button isn’t pressed, which is the opposite of what should happen. You can read more about this issue in this blog post.
The bug was fixed in the newer RP2350 A4 chip revision. If you’re using an older chip, avoid using
Pull::Downin your code. Instead, you can use an external pull-down resistor and setPull::Nonein the code.
With a pull-down resistor enabled, the button should connect to 3.3V when pressed. The pin reads LOW when not pressed, and HIGH when pressed.
Using a Floating Input
You can also configure a pin without any internal pull resistor:
#![allow(unused)]
fn main() {
let button = Input::new(p.PIN_16, Pull::None);
}
However, as we discussed earlier, floating inputs are unreliable for buttons because they pick up electrical noise and read random values. This option is only useful when you have an external pull-up or pull-down resistor in your circuit, or when connecting to devices that actively drive the pin HIGH or LOW (like some sensors).
LED on Button Press
Let’s build a simple project that turns on an LED whenever the button is pressed. You can use an external LED or the built in LED. Just change the LED pin number in the code to match the one you are using.
We will start by creating a new project with cargo generate and our template.
In your terminal, type:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
Button as Input
So far, we’ve been using the Output struct because our Pico was sending signals to the LED. This time, the Pico will receive a signal from the button, so we’ll configure it as an Input.
#![allow(unused)]
fn main() {
let button = Input::new(p.PIN_15, Pull::Up);
}
We’ve connected one side of the button to GPIO 15. The other side is connected to Ground. This means when we press the button, the pin gets pulled to the LOW state. As we discussed earlier, without a pull resistor, the input would be left in a floating state and read unreliable values. So we enable the internal pull-up resistor to keep the pin HIGH by default.
Led as Output
We configure the LED pin as an output, starting in the LOW state (off). If you’re using an external LED, uncomment the first line for GPIO 16. If you’re using the Pico’s built-in LED, use GPIO 25 as shown. Just make sure your circuit matches whichever pin you choose.
#![allow(unused)]
fn main() {
// let mut led = Output::new(p.PIN_16, Level::Low);
let mut led = Output::new(p.PIN_25, Level::Low);
}
Main loop
Now in a loop, we constantly check if the button is pressed by testing whether it’s in the LOW state. We add a small 5-millisecond delay between checks to avoid overwhelming the system. When the button reads LOW (pressed), we set the LED pin HIGH to turn it on, then wait for 3 seconds so we can visually observe it. You can adjust this delay to your preference.
#![allow(unused)]
fn main() {
loop {
if button.is_low() {
defmt::info!("Button pressed");
led.set_high();
Timer::after_secs(3).await;
} else {
led.set_low();
}
Timer::after_millis(5).await;
}
}
Note
Debounce: If you reduce the delay, you might notice that sometimes a single button press triggers multiple detections. This is called “button bounce”. When you press a physical button, the metal contacts inside briefly bounce against each other, creating multiple electrical signals in just a few milliseconds. In this example, the 3-second LED delay effectively masks any bounce issues, but in applications where you need to count individual button presses accurately, you’ll need debouncing logic.
We also log “Button pressed” using defmt. If you’re using a debug probe, use the cargo embed --release command to see these logs in your terminal.
The Full code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp::block::ImageDef;
use embassy_rp::gpio::Pull;
use embassy_rp::{
self as hal,
gpio::{Input, Level, Output},
};
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let button = Input::new(p.PIN_15, Pull::Up);
// let mut led = Output::new(p.PIN_16, Level::Low);
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
if button.is_low() {
defmt::info!("Button pressed");
led.set_high();
Timer::after_secs(3).await;
} else {
led.set_low();
}
Timer::after_millis(5).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"button"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone (or refer) project I created and navigate to the button folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/button
PWM’s Top and Divider
Sometimes you need to specify a precise frequency for PWM output. For example, hobby servos typically operate at 50Hz. However, neither embassy-rp nor rp-hal provide a straightforward method to set the frequency directly (at least to my knowledge). Instead, we need to work with the underlying PWM hardware configuration. The embassy-rp crate allows you to configure PWM through a Config struct that has various fields, with our focus being on the top and divider parameters. Let’s explore how these work.
How PWM Works Inside the RP2350
The RP2350’s PWM slice is driven by a clock. This clock is much faster than the PWM signal you actually want on the pin. The PWM hardware uses a counter that repeatedly counts from 0 up to TOP, then wraps back to 0. The TOP register controls how high the counter goes before wrapping.
Each time the counter reaches the top value, it wraps back to zero and starts again. One full count from zero to top is one PWM period.
If the counter increases very quickly, the PWM frequency will be high. If it increases more slowly, the PWM frequency will be lower. This is where the divider comes in.
The RP2350 uses two main 16-bit registers for PWM generation: the Capture/Compare (CC) register and the TOP register. The CC register controls how long the output pulse stays high within each cycle (the duty cycle). The TOP register controls how long each complete cycle takes (the period).
For simple explanation, let’s say the TOP value is 9 and CC value is 3. It will count from 0 to 9 as illustrated in the digram. The signal stay high until it reaches CC value. After that, it remains low in that cycle. Take your own time and try to understand the above diagram. In the diagram, each count, we have drawn as steps(red colored). The pulse stays in high (colored in green) until the count matches CC value. As you can see, after 3 till 9, the pulse becomes low.
How TOP Controls Frequency
The PWM counter counts from 0 up to the TOP value, then wraps back to 0. One complete count cycle (0 to TOP) produces one PWM period. The frequency is simply how many of these complete cycles happen per second.
The system clock of the RP2350 runs at 150MHz (150 million cycles per second). If we ignore the divider for now and keep it at 1, the counter increments once per system clock cycle. Understanding how TOP affects frequency is crucial:
- Higher TOP value => Counter takes more steps to complete one cycle => Lower PWM frequency
- Lower TOP value => Counter takes fewer steps to complete one cycle => Higher PWM frequency
Let’s look at some concrete examples to make this clear:
Example 1: TOP = 149
The counter counts: 0, 1, 2, 3, … 148, 149, then wraps to 0.
That’s 150 total counts per cycle (counting from 0 through 149 inclusive).
At 150MHz system clock, the PWM frequency is:
Note
Don’t rely on this simplified formula yet. It’s not accurate enough because there’s one more factor to add (the clock divider). We’re showing it here just to understand the basic TOP-frequency relationship.
\[ f_{PWM} = \frac{150,000,000}{150} = 1,000,000 \text{ Hz (1 MHz)} \]
Example 2: TOP = 1,499
The counter counts through 1,500 values (0 through 1,499)
PWM frequency:
\[ f_{PWM} = \frac{150,000,000}{1,500} = 100,000 \text{ Hz (100 kHz)} \]
Why TOP Alone Is Not Enough
The TOP register is 16 bits wide, so the maximum value it can hold is 65,535. This means the counter goes through 65,536 steps before wrapping back to zero. If we apply this maximum TOP value to the same calculation we used in the examples above, the resulting PWM frequency comes out to about 2,288 Hz.
That is the lowest frequency we can reach using TOP alone with the system clock running at 150 MHz.
This is still far too high for many real-world uses. For example, hobby servo motors require a PWM frequency of around 50 Hz. With TOP alone, there is simply no way to slow the PWM down enough to reach that range.
To solve this, the RP2350 provides an additional control: the PWM clock divider. By dividing down the clock that feeds the PWM counter, we can generate much lower frequencies, including the 50 Hz required by servos.
The Clock Divider
The clock divider slows down the clock that drives the PWM counter. Instead of counting at the full 150 MHz system clock speed, the counter increments more slowly based on the divider value.
When the divider is increased, each count takes longer. This means the counter needs more time to go from 0 to TOP, so the PWM frequency becomes lower. This is what allows us to reach low frequencies like 50 Hz, which are impossible using TOP alone.
Let’s look at one more simplified example before introducing the actual formula from the RP2350’s datasheet.
Suppose we set TOP to 1,499, so the counter goes through 1,500 steps (0 through 1,499). Now, if we set the clock divider to 10, each step takes 10 system clock cycles instead of 1.
\[ f_{PWM} = \frac{150{,}000{,}000}{1{,}500 \times 10} = 10{,}000\ \text{Hz (10 kHz)} \]
Without the divider, we got 100 kHz for the same TOP value. Now with a divider of 10, we get 10 kHz; ten times slower. This shows how the divider gives us control over slowing down the PWM frequency.
Phase Correct Mode
Bear with me for a moment. Before introducing the actual formula, there is one more important concept we need to understand.
So far, we have assumed that the PWM counter counts in one direction, from 0 up to TOP, and then immediately wraps back to 0. This is not the only way PWM can work. In phase correct mode, the counter behaves differently, and that has a direct effect on the PWM frequency.
In phase correct mode, the PWM counter does not jump back to zero when it reaches TOP. Instead, it counts up from 0 to TOP, then counts back down from TOP to 0. This creates a symmetric, up-and-down counting pattern.
Because of this, one full PWM cycle now includes both the upward count and the downward count. In other words, the counter takes roughly twice as long to complete a full cycle compared to the normal up-counting mode.
The important takeaway is simple: enabling phase correct mode halves the PWM frequency for the same TOP and divider values.
This mode is often used when you want cleaner, more symmetric PWM signals, especially for things like motor control.
The PWM Frequency Formula
The RP2350 datasheet defines exactly how the PWM period is calculated. The period tells you how many system clock cycles are needed for one full PWM cycle.
Calculate the period in clock cycles with the following equation:
\[ \text{period} = (\text{TOP} + 1) \times (\text{CSR_PH_CORRECT} + 1) \times \left( \text{DIV_INT} + \frac{\text{DIV_FRAC}}{16} \right) \]
To determine the output frequency based on the system clock frequency, use the following equation:
\[ f_{PWM} = \frac{f_{sys}}{\text{period}} = \frac{f_{sys}}{(\text{TOP} + 1) \times (\text{CSR_PH_CORRECT} + 1) \times \left( \text{DIV_INT} + \frac{\text{DIV_FRAC}}{16} \right)} \]
Where:
- \( f_{PWM} \) is the PWM output frequency.
- \( f_{sys} \) is the system clock frequency. For the pico2, it is is 150MHZ.
Divider and Fraction
In the formula we discussed earlier, there is one important part we have not explained yet: DIV_FRAC. This controls the fractional part of the clock divider in the RP2350.
The RP2350 clock divider is split into two parts. DIV_INT is the integer part and sets the whole number division. DIV_FRAC is the fractional part and allows finer control over the division ratio. Together, they let you slow down the PWM counter more precisely than using an integer divider alone. One important rule is that when DIV_INT is set to 0, you must not set any DIV_FRAC bits.
Manually Calculate Top
In this section, we will manually derive the TOP value for a given PWM frequency. This method requires trying different divider values and checking whether the resulting TOP value falls within the valid range.
There is a better approach in the next section, where you can use either Rust code or a calculator form to compute both TOP and the divider automatically. For now, this manual method is useful because it helps build intuition about how the clock, divider, and TOP value relate to each other.
The TOP value must be within the range 0 to 65534. Although TOP is stored in a 16-bit unsigned register, setting it to 65535 prevents achieving a true 100 percent duty cycle. This is because the duty cycle compare register (CC) must be set to TOP + 1 to achieve a true 100 percent duty cycle, and CC itself is also only 16 bits wide. By keeping TOP at or below 65534, the value TOP + 1 still fits in the CC register, allowing the full 0 to 100 percent duty cycle range to be represented correctly.
To ensure TOP stays within this limit, we will choose divider values that are powers of two, such as 8, 16, 32, or 64. This approach does not work for every possible frequency. In some cases, you may need other integer values or even fractional dividers. To keep things simple, we will start with this approach.
As an example, we will calculate the values required to generate a 50 Hz PWM signal for a servo motor.
PWM Frequency Formula
The RP2350 datasheet defines the PWM frequency as:
\[ f_{PWM} = \frac{f_{sys}}{\text{period}} = \frac{f_{sys}}{(\text{TOP} + 1) \times (\text{CSR_PH_CORRECT} + 1) \times \left( \text{DIV_INT} + \frac{\text{DIV_FRAC}}{16} \right)} \]
Here’s the derived formula to get the TOP for the target frequency:
\[ \text{TOP} = \frac{f_{sys}} {f_{PWM} \times (\text{CSR_PH_CORRECT} + 1) \times \left( \text{DIV_INT} + \frac{\text{DIV_FRAC}}{16} \right)} - 1 \]
Where:
- \( f_{PWM} \) is the desired PWM frequency.
- \( f_{sys} \) is the system clock frequency. For the pico2, it is is 150MHZ.
We’re not going to use phase correct mode and we’re not using fraction for the divider either, so let’s simplify the formula further:
\[ \text{TOP} = \frac{f_{sys}} {f_{PWM} \times \text{DIV_INT}} - 1 \]
TOP for 50Hz
We want the PWM frequency to be 50 Hz. To achieve that, we substitute the system clock frequency, target frequency and the chosen divider integer, and we get the following TOP value:
\[ \text{top} = \frac{150,000,000}{50 \times 64} - 1 \]
\[ \text{top} = \frac{150,000,000}{3,200} - 1 \]
\[ \text{top} = 46,875 - 1 \]
\[ \text{top} = 46,874 \]
You can experiment with different divider values (even including fraction) and corresponding top values.
TOP and Divider Finder for the Target Frequency
In MicroPython, you can set the PWM frequency directly without manually calculating the TOP and divider values. Internally, MicroPython computes these values from the target frequency and the system clock.
I wanted to see if there was something similar available in Rust. While discussing this in the rp-rs Matrix chat, 9names ported the relevant C code from MicroPython that calculates TOP and divider values into Rust. This code takes the target frequency and source clock frequency as input and gives us the corresponding TOP and divider values. You can find that implementation here.
You can use that Rust code directly in your own project. I compiled the same code to WASM and built a small form around it so that you can try it out here.
By default, the source clock frequency is set to the RP2350 system clock frequency of 150 MHz, and the target frequency is set to 50 Hz. You can change both values if needed.
TOP and divider calculation
Result
Note
The divider is shown as an integer part and a fractional part.
The fractional value is not a decimal fraction. It represents a 4-bit fixed-point fraction.
The effective divider is:
DIV = DIV_INT + (DIV_FRAC / 16)For example,
DIV_INT = 45andDIV_FRAC = 13means the divider is45 + 0.8125, not45.13.
Code
If you are using rp-hal, you set the integer and fractional parts separately, like this:
#![allow(unused)]
fn main() {
pwm.set_top(65483);
pwm.set_div_int(45);
pwm.set_div_frac(13);
}
If you are using embassy-rp, both parts are combined into a single divider field inside the Config struct. Nope, this is not a floating-point value. Internally, it uses a fixed-point number to represent the integer and fractional parts together. If you are not familiar with fixed-point numbers, I have a separate blog post explaining them in detail, which you can read here:
If you only need an integer divider, you can simply convert a u8 value:
#![allow(unused)]
fn main() {
let mut servo_config: PwmConfig = Default::default();
servo_config.top = 46_874;
servo_config.divider = 64.into();
}
If you also want a fractional part, you need to add the “fixed” crate as a dependency and construct the divider using a fixed-point type:
#![allow(unused)]
fn main() {
let mut servo_config: PwmConfig = Default::default();
servo_config.top = 65483;
servo_config.divider = FixedU16::<U4>::from_num(45.8125);
// or
// servo_config.divider = fixed::types::U12F4::from_num(45.8125);
}
Servo Motors
Servo motors let you control position accurately. You might use them to point a camera, move parts of a small robot, or control switches automatically. They’re different from regular DC motors. Instead of spinning continuously, a servo moves to a specific angle and stays there.
In this chapter, we’ll make a servo sweep through three positions: 0°, 90°, and 180°.
Hardware Used
For this chapter, we will use the following components:
- SG90 Micro Servo Motor
- Jumper Wires:
- Female-to-Male(or Male to Male depending on how you are connecting) jumper wires for connecting the Pico 2 to the servo motor pins (Ground, Power, and Signal).
The SG90 is small, cheap, and easy to find. It is commonly used in learning projects and works well for demonstrations.
Servo Motor Basics
A typical hobby servo has three wires: Ground, Power, Signal. The power and ground wires supply energy to the motor. The signal wire is used to tell the servo which position to move to. The servo expects a PWM signal on this pin. Different pulse widths correspond to different angles.
You do not need to know the internal details to use a servo. You just need to generate the correct PWM signal.
How Servo Control Works
A servo motor uses PWM (Pulse Width Modulation) signals to control its position. The width of each pulse tells the servo which angle to move to, and continuously repeating that pulse keeps it there.
Basic Operation
Servos operate on a 50Hz frequency, meaning they expect a control pulse every 20 milliseconds. Within each 20ms cycle, the duration that the signal stays high determines the servo’s position.
Think of it like this: every 20ms, you send the servo a brief instruction. That instruction’s length tells the servo where to point.
Pulse Width
The position of the servo is controlled by how long the signal pulse stays high. A short pulse moves it to the minimum position (typically 0°), a medium pulse moves it to center (typically 90°), and a long pulse moves it to the maximum position (typically 180°).
Standard vs. Reality
You’ll often see these “standard” values referenced: 1.0ms pulse for 0°, 1.5ms pulse for 90°, and 2.0ms pulse for 180°. However, cheap servos rarely follow these numbers exactly. Manufacturing variations mean each servo has its own characteristics.
For example, my servo required 0.5ms for minimum position, 1.5ms for center, and 2.4ms for maximum position. This is completely normal and expected.
Treat published pulse widths as starting points, not absolute values. Always test and calibrate your specific servo. A logic analyzer or oscilloscope helps, but simple trial and error works fine too. The examples in this guide use values that worked for my servo, so you may need to adjust them for yours.
Calculating Duty Cycle
The duty cycle represents the percentage of time the signal stays high during each 20ms cycle. Understanding this helps you configure PWM correctly in your code.
Example Calculations
For a 0.5ms pulse (0° position), the duty cycle is calculated as:
A 0.5ms pulse means the signal is “high” for 0.5 milliseconds within each 20ms cycle. The servo interprets this as a command to move to the 0-degree position.
\[ \text{Duty Cycle (%)} = \frac{0.5 \text{ms}}{20 \text{ms}} \times 100 = 2.5\% \]
This means that for just 2.5% of each 20ms cycle, the signal stays “high” causing the servo to rotate to the 0-degree position.
For a 1.5ms pulse (90° position), the calculation gives us:
A 1.5ms pulse means the signal is “high” for 1.5 milliseconds in the 20ms cycle. The servo moves to its neutral position, around 90 degrees (middle position).
\[ \text{Duty Cycle (%)} = \frac{1.5 \text{ms}}{20 \text{ms}} \times 100 = 7.5\% \]
Here, the signal stays “high” for 7.5% of the cycle, which positions the servo at 90 degrees (neutral).
For a 2.4ms pulse (180° position), we get:
A 2.4ms pulse means the signal is “high” for 2.4 milliseconds in the 20ms cycle. The servo will move to its maximum position, typically 180 degrees (full rotation to one side).
\[ \text{Duty Cycle (%)} = \frac{2.4 \text{ms}}{20 \text{ms}} \times 100 = 12\% \]
In this case, the signal is “high” for 12% of the cycle, which causes the servo to rotate to 180 degrees.
Reference
Servo with Raspberry Pi Pico 2 (RP2350)
The required power supply and pulse width can vary depending on the servo motor you use, so it is always best to check the datasheet or product specifications. The servo I am using operates in the 4.8V to 6V range, so I will power it with 5V.
- Ground (GND): Connect the servo’s GND pin (typically the brown wire, though it may vary) to any ground pin on the Pico 2.
- Power (VCC): Connect the servo’s VCC pin (usually the red wire) to the Pico 2’s 5V power pin(VBUS).
- Signal (PWM): Connect the servo’s control (signal) pin to GPIO15 on the Pico 2, configured for PWM. This is commonly the orange wire (may vary).
| Pico Pin | Wire | Servo Motor | Notes |
|---|---|---|---|
| VBUS |
|
Power (Red Wire) | Supplies 5V power to the servo. |
| GND |
|
Ground (Brown Wire) | Connects to ground. |
| GPIO 15 |
|
Signal (Orange/yellow Wire) | Receives PWM signal to control the servo's position. |
Position and Duty Cycle
Position and Duty Cycle
To control a servo with the Raspberry Pi Pico, we need to set a 50 Hz PWM frequency. There is no straightforward way to set the frequency directly in embassy or rp-hal, at least to my knowledge.
In embassy-rp, PWM is configured using a Config struct with multiple fields. For our use case, we mainly care about the top and divider values. The same applies to rp-hal, where we can set top, div_int, and div_frac separately.
You can either use the manual method to find a suitable TOP value, or use the form to automatically calculate both TOP and the divider for the target frequency of 50 Hz.
Using the manual method, I calculated a TOP value of 46,874 with a divider of 64. Using the form, I got a divider of 45.8125 with a TOP value of 65,483. We can use either of these configurations.
Note
In rp-hal, you have to set the divider integer and fraction separately. So a divider of 64 becomes div_int = 64 and div_frac = 0. A divider of 45.8125 becomes div_int = 45 and div_frac = 13.
Position calculation based on top
Once the TOP value for a 50 Hz PWM signal is known, we can calculate the duty cycle values required to position the servo.
The servo determines its position by measuring the pulse width, which is the amount of time the signal stays high during each 20 ms PWM cycle. The exact pulse widths are not identical for all servos and can vary slightly depending on the specific servo model.
In my case, the values were:
-
0° at about 0.5 ms, which corresponds to a 2.5% duty cycle since 0.5 ms is 2.5% of a 20 ms period.
-
90° at about 1.5 ms, which corresponds to a 7.5% duty cycle since 1.5 ms is 7.5% of a 20 ms period.
-
180° at about 2.4 ms, which corresponds to a 12% duty cycle since 2.4 ms is 12% of a 20 ms period.
In the LED dimming chapter, changing the duty cycle was straightforward. We only cared about brightness, not frequency, so using set_duty_cycle_percent was sufficient. That function accepts a u8 value from 0 to 100, which works well for whole-number percentages.
For servo control, this approach is not suitable because the required duty cycles include fractional values such as 2.5%, 7.5%, and 12%.
We therefore have two alternatives. One option is to calculate the duty value directly from TOP and use set_duty_cycle, which accepts a u16. The other option is to use set_duty_cycle_fraction, which lets you specify the duty cycle as a numerator and denominator.
Option 1: Manual calculation with set_duty_cycle
We first convert the pulse width into a percentage of the period. That percentage is then multiplied by TOP + 1 to obtain the duty value that configures the PWM output.
#![allow(unused)]
fn main() {
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = 46_874;
const TOP: u16 = PWM_TOP + 1;
// 0.5ms is 2.5% of 20ms; 0 degrees in servo
const MIN_DUTY: u16 = (TOP as f64 * (2.5 / 100.)) as u16;
// 1.5ms is 7.5% of 20ms; 90 degrees in servo
const HALF_DUTY: u16 = (TOP as f64 * (7.5 / 100.)) as u16;
// 2.4ms is 12% of 20ms; 180 degree in servo
const MAX_DUTY: u16 = (TOP as f64 * (12. / 100.)) as u16;
}
Once the duty value is calculated, it can be applied like this:
#![allow(unused)]
fn main() {
servo.set_duty_cycle(MIN_DUTY)
.expect("invalid min duty cycle");
}
Option 2: Using set_duty_cycle_fraction
Another option is to use set_duty_cycle_fraction. This will help us to set percentage with fraction.
In fact, set_duty_cycle_percent is a convenience method provided by embedded-hal that internally calls set_duty_cycle_fraction. It simply divides the input percentage by 100 and forwards the result as a fraction.
From embedded-hal:
#![allow(unused)]
fn main() {
/// Set the duty cycle to `percent / 100`
///
/// The caller is responsible for ensuring that `percent` is less than or equal to 100.
#[inline]
fn set_duty_cycle_percent(&mut self, percent: u8) -> Result<(), Self::Error> {
self.set_duty_cycle_fraction(u16::from(percent), 100)
}
/// Set the duty cycle to `num / denom`.
///
/// The caller is responsible for ensuring that `num` is less than or equal to `denom`,
/// and that `denom` is not zero.
fn set_duty_cycle_fraction(&mut self, num: u16, denom: u16) -> Result<(), Self::Error> {
debug_assert!(denom != 0);
debug_assert!(num <= denom);
let duty = u32::from(num) * u32::from(self.max_duty_cycle()) / u32::from(denom);
// This is safe because we know that `num <= denom`, so `duty <= self.max_duty_cycle()` (u16)
#[allow(clippy::cast_possible_truncation)]
{
self.set_duty_cycle(duty as u16)
}
}
}
This function does not accept floating-point values. Instead, it takes a numerator and a denominator, both as u16. To represent fractional percentages, we simply scale them into integers.
Remember that 2.5% can be written as the fraction 2.5/100. Since we can’t use decimals in the numerator, we multiply both the numerator and denominator by 10 to get equivalent integer fractions:
#![allow(unused)]
fn main() {
2.5/100 = (2.5 × 10)/(100 × 10) = 25/1000
}
Now we have an equivalent fraction using only integers. We can apply the same conversion to our other percentages:
For example:
- 2.5% can be written as 25 / 1000 (in other words, 25 is 2.5% of 1000)
- 7.5% can be written as 75 / 1000 (in other words, 75 is 7.5% of 1000)
- 12% can be written as 120 / 1000 (in other words, 120 is 12% of 1000)
So in our code, we can apply it like this:
#![allow(unused)]
fn main() {
// Move servo to 0° position (2.5% duty cycle = 25/1000)
servo.set_duty_cycle_fraction(25, 1000)
.expect("invalid duty cycle");
// 90° position (7.5% duty cycle)
servo.set_duty_cycle_fraction(75, 1000)
.expect("invalid duty cycle");
// 180° position (12% duty cycle)
servo.set_duty_cycle_fraction(120, 1000)
.expect("invalid duty cycle");
}
Servo Motor Control on Raspberry Pi Pico Using Embassy and Rust
In this section, we will create a simple program that moves the servo horn from 0 to 90 to 180 and then back to 0. This basic movement is enough to understand how PWM controls a servo. Once you are comfortable with the idea, you can experiment further and build more interesting applications.
We will start by creating a new project using the Embassy framework. After that, we wll build the same project again using rp-hal. As usual, generate the project from the template with cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name like “servo-motor” and choose “embassy” as the HAL. Enable defmt logging, if you have a debug probe so you can view logs also.
Additional Imports
In addition to the usual boilerplate imports, you’ll need to add these specific imports to your project.
#![allow(unused)]
fn main() {
// For PWM
use embassy_rp::pwm::{Config as PwmConfig, Pwm, SetDutyCycle};
}
PWM Config
In the LED dimming chapter, we left the PWM configuration at its default values. That was sufficient there, because only the duty cycle mattered.
This time, we cannot do that. For servo control, we have to configure the TOP value and the divider ourselves so that the PWM frequency comes out to 50 Hz, based on the values we calculated earlier.
Here, I am using the manually calculated TOP and divider values directly in the code instead of using the calculator form. The divider I am using is a whole number, so I can simply convert it using the into() method. If the divider had a fractional part, I would need to use the fixed crate, which we already looked at earlier. To keep things simple, I am sticking to the integer version for now.
#![allow(unused)]
fn main() {
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = 46_874;
}
Note
You can also try this with a fractional divider. We already looked at the code snippet for that earlier, so you can reuse it and experiment with fractional values if you want.
Once we have those values, we just apply them to the PWM configuration like this.
#![allow(unused)]
fn main() {
let mut servo_config: PwmConfig = Default::default();
servo_config.top = PWM_TOP;
servo_config.divider = PWM_DIV_INT.into();
}
Initialize PWM
Once the PWM configuration is ready, the next step is to create a PWM output and bind it to the GPIO pin connected to the servo signal wire.
In our case, we are using the GPIO 15. Feel free to change these if your wiring is different.
#![allow(unused)]
fn main() {
let mut servo = Pwm::new_output_b(p.PWM_SLICE7, p.PIN_15, servo_config);
}
Main loop
Now we move on to the main loop. Here, we simply change the duty cycle value, wait for a short delay, and then move to the next position.
#![allow(unused)]
fn main() {
loop {
// Move servo to 0° position (2.5% duty cycle = 25/1000)
servo
.set_duty_cycle_fraction(25, 1000)
.expect("invalid min duty cycle");
Timer::after_millis(1000).await;
// 90° position (7.5% duty cycle)
servo
.set_duty_cycle_fraction(75, 1000)
.expect("invalid half duty cycle");
Timer::after_millis(1000).await;
// 180° position (12% duty cycle)
servo
.set_duty_cycle_fraction(120, 1000)
.expect("invalid max duty cycle");
Timer::after_millis(1000).await;
}
}
If everything works, you should see the servo horn move to the first position, pause briefly, move to the next position, and then move to the final position before returning back again.
Clone the existing project
You can clone (or refer) project I created and navigate to the servo-motor folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/servo-motor
Debugging
If your servo is not moving, start by checking the wiring. Make sure the signal wire is connected to the correct GPIO pin, the servo has a proper power source, and the ground is shared with the Pico.
Next, double check that the code was flashed correctly and that the program is actually running on the board. If you are using a debug probe with defmt enabled, the log output can help confirm this.
If everything looks correct and the servo still does not move as expected, the most likely reason is that your servo uses slightly different pulse widths for each position. In that case, refer to the datasheet for your specific servo model, or check the manufacturer or vendor website if they provide timing information. You may need to adjust the duty cycle values to match your servo.
Do not worry if this does not work perfectly the first time. This is one of the things I struggled with when I started as well. I have tried my best to explain the calculations and the reasoning behind them clearly. I hope this helps.
The Full Code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// PWM
use embassy_rp::pwm::{Config as PwmConfig, Pwm, SetDutyCycle};
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = 46_874;
// Alternative method:
// const TOP: u16 = PWM_TOP + 1;
// const MIN_DUTY: u16 = (TOP as f64 * (2.5 / 100.)) as u16;
// const HALF_DUTY: u16 = (TOP as f64 * (7.5 / 100.)) as u16;
// const MAX_DUTY: u16 = (TOP as f64 * (12. / 100.)) as u16;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let mut servo_config: PwmConfig = Default::default();
servo_config.top = PWM_TOP;
servo_config.divider = PWM_DIV_INT.into();
let mut servo = Pwm::new_output_b(p.PWM_SLICE7, p.PIN_15, servo_config);
loop {
// Move servo to 0° position (2.5% duty cycle = 25/1000)
servo
.set_duty_cycle_fraction(25, 1000)
.expect("invalid min duty cycle");
Timer::after_millis(1000).await;
// 90° position (7.5% duty cycle)
servo
.set_duty_cycle_fraction(75, 1000)
.expect("invalid half duty cycle");
Timer::after_millis(1000).await;
// 180° position (12% duty cycle)
servo
.set_duty_cycle_fraction(120, 1000)
.expect("invalid max duty cycle");
Timer::after_millis(1000).await;
}
// Alternative method
// loop {
// servo
// .set_duty_cycle(MIN_DUTY)
// .expect("invalid min duty cycle");
// Timer::after_millis(1000).await;
// servo
// .set_duty_cycle(HALF_DUTY)
// .expect("invalid half duty cycle");
// Timer::after_millis(1000).await;
// servo
// .set_duty_cycle(MAX_DUTY)
// .expect("invalid max duty cycle");
// Timer::after_millis(1000).await;
// }
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"servo-motor"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Servo Motor Control on Raspberry Pi Pico Using rp-hal
In this exercise, we repeat the same servo control example, but this time using rp hal instead of Embassy. The overall idea stays exactly the same. The main difference here is that we will use a fractional divider instead of a whole number divider.
For this, we will rely on the calculator form to generate the TOP value and both the integer and fractional parts of the divider.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name like “servo-motor” and choose “rp-hal” as the HAL. Enable defmt logging, if you have a debug probe so you can view logs also.
Additional Imports
Along with the usual rp hal boilerplate, we need to bring in the trait that allows us to update the PWM duty cycle.
#![allow(unused)]
fn main() {
// For PWM
use embedded_hal::pwm::SetDutyCycle;
}
Initialize PWM Slice
Next, we initialize the PWM peripheral and select the slice we want to use. In our case, we are using PWM slice 7 (since we are using GPIO 15).
#![allow(unused)]
fn main() {
let mut pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
let pwm = &mut pwm_slices.pwm7;
}
Configure Divider and TOP
Now we apply the TOP value and the divider that were generated using the calculator form. This time, we explicitly set both the integer and fractional parts of the divider.
#![allow(unused)]
fn main() {
pwm.set_div_int(45);
pwm.set_div_frac(13);
pwm.set_top(65483);
pwm.enable();
}
Attach PWM Channel to GPIO
#![allow(unused)]
fn main() {
let servo = &mut pwm.channel_b;
servo.output_to(pins.gpio15);
}
Main Loop
Finally, inside the main loop, we update the duty cycle to move the servo between different positions. Just like in the Embassy example, we use set_duty_cycle_fraction.
#![allow(unused)]
fn main() {
loop {
// Move servo to 0° position (2.5% duty cycle = 25/1000)
servo
.set_duty_cycle_fraction(25, 1000)
.expect("invalid min duty cycle");
timer.delay_ms(1000);
// 90° position (7.5% duty cycle)
servo
.set_duty_cycle_fraction(75, 1000)
.expect("invalid half duty cycle");
timer.delay_ms(1000);
// 180° position (12% duty cycle)
servo
.set_duty_cycle_fraction(120, 1000)
.expect("invalid max duty cycle");
timer.delay_ms(1000);
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the servo-motor folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/servo-motor
The Full Code
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
use embedded_hal::pwm::SetDutyCycle;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// External high-speed crystal on the Raspberry Pi Pico 2 board is 12 MHz.
/// Adjust if your board has a different frequency
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
// Grab our singleton objects
let mut pac = hal::pac::Peripherals::take().unwrap();
// Set up the watchdog driver - needed by the clock setup code
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Configure the clocks
//
// The default is to generate a 125 MHz system clock
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// The single-cycle I/O block controls our GPIO pins
let sio = hal::Sio::new(pac.SIO);
// Set the pins up according to their function on this particular board
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
let mut pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
let pwm = &mut pwm_slices.pwm7;
pwm.set_div_int(45);
pwm.set_div_frac(13);
pwm.set_top(65483);
pwm.enable();
let servo = &mut pwm.channel_b;
servo.output_to(pins.gpio15);
loop {
// Move servo to 0° position (2.5% duty cycle = 25/1000)
servo
.set_duty_cycle_fraction(25, 1000)
.expect("invalid min duty cycle");
timer.delay_ms(1000);
// 90° position (7.5% duty cycle)
servo
.set_duty_cycle_fraction(75, 1000)
.expect("invalid half duty cycle");
timer.delay_ms(1000);
// 180° position (12% duty cycle)
servo
.set_duty_cycle_fraction(120, 1000)
.expect("invalid max duty cycle");
timer.delay_ms(1000);
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
// End of file
Buzzinga
In this section, we will explore some fun activities using a buzzer. I chose the title Buzzinga just for fun (just playful reference to Sheldon’s “Bazinga” from The Big Bang Theory). It is not a technical term.
What is a Buzzer?
A buzzer is a small electronic component that produces sound when powered or driven by an electrical signal. It is used to generate beeps, alerts, or simple melodies, providing audible feedback in electronic systems.
Buzzers are commonly found in alarms, timers, notification systems, computers, and simple user interfaces, where they help confirm user actions or signal events.
Common Types of Buzzers
There are two types you will commonly encounter in embedded projects:
Active Buzzer:
This type has a built-in oscillator. You only need to supply power, and it will start making sound immediately. Active buzzers are very easy to use but offer limited control over pitch.
How to identify:
An active buzzer usually has a white covering on top and a smooth black casing at the bottom. The simplest way to identify it is to connect it directly to a battery. If it produces sound without any additional circuitry, it is an active buzzer.
Passive Buzzer:
A passive buzzer does not generate sound on its own. You must drive it using a PWM or square wave signal. This allows you to control the frequency, making it possible to generate different tones or even simple melodies.
How to identify:
A passive buzzer typically has no white covering on top and often looks like a small PCB with a blue or green base. When connected directly to a battery, it will not produce any sound.
Which One to Choose?
Choose an active buzzer if you only need a simple, fixed tone or beep. It works well for basic alerts, alarms, or confirming user input, and it requires minimal setup.
Choose a passive buzzer if you want more control over sound. Since it must be driven by a PWM or square-wave signal, you can generate different tones, melodies, or sound patterns.
For our exercises, a passive buzzer is recommended because it lets us control the output frequency directly(play better tone). However, if you only have an active buzzer, you can still follow along. In fact, I personally used an active buzzer at first for this.
Hardware requirements
- Passive buzzer
- Jumper wires
A buzzer typically has two pins: a positive pin used for the signal and a ground pin. The positive side is often marked with a “+” symbol and is usually the longer pin, while the negative side is shorter, similar to an LED.
That said, some passive buzzers are non-polarized. In those cases, either pin can be connected to the signal or ground. Always check the markings or the datasheet if you are unsure.
Reference
Connecting Buzzer with Raspberry Pi Pico
We will connect GPIO 15 to the buzzer’s positive (signal) pin and the Pico’s GND to the buzzer’s ground pin. You are free to use a different GPIO pin if needed.
| Pico Pin | Wire | Buzzer Pin | Notes |
|---|---|---|---|
| GPIO 15 |
|
Positive Pin | Receives PWM signals to produce sound. |
| GND |
|
Ground Pin | Connects to ground. |
Buzzer Beep Using PWM on Raspberry Pi Pico with Embedded Rust
In this exercise, we will generate a beep sound using a buzzer. The idea is similar to blinking an LED, but instead of toggling a GPIO HIGH and LOW, we control the PWM duty cycle.
We will repeatedly switch the PWM duty cycle between 50 percent and 0 percent, with a delay in between. When the duty cycle is 50 percent, the buzzer produces sound. When it is 0 percent, the sound stops. Repeating this creates a clear beep.
You can try this without changing the PWM frequency. In this example, we set the PWM frequency to 440.0 Hz, which corresponds to the A4 musical note. You do not need to know anything about musical notes for this. The important point is that we generate a fixed-frequency tone and turn the sound on and off by changing the duty cycle.
Create Project from template
We will start by creating a new project using the Embassy framework. As usual, generate the project from the template with cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name like “buzzer-beep” and choose “embassy” as the HAL. Enable defmt logging, if you have a debug probe so you can view logs also.
Additional imports
As we have done before, we import the SetDutyCycle trait and the Pwm and Config types for PWM configuration.
#![allow(unused)]
fn main() {
use embassy_rp::pwm::{Config as PwmConfig, Pwm, SetDutyCycle};
}
Calculate TOP
You can either calculate the TOP value manually or use the calculator form shown in the previous chapter. This time, we will take a different approach.
We will keep the divider fixed at 64 and use a const fn to calculate the TOP value. Since we are not using phase-correct mode or the fractional divider, the calculation is simple.
#![allow(unused)]
fn main() {
const fn get_top(freq: f64, div_int: u8) -> u16 {
assert!(div_int != 0, "Divider must not be 0");
let result = 150_000_000. / (freq * div_int as f64);
assert!(result >= 1.0, "Frequency too high");
assert!(
result <= 65535.0,
"Frequency too low: TOP exceeds 65534 max"
);
result as u16 - 1
}
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = get_top(440., PWM_DIV_INT);
}
Main logic
First, we configure the PWM with the calculated TOP value and the fixed divider. Then we create a PWM output for the buzzer. Inside the loop, we switch the duty cycle between 50 percent and 0 percent with a delay in between, which produces a repeating beep sound.
#![allow(unused)]
fn main() {
let mut pwm_config = PwmConfig::default();
pwm_config.top = PWM_TOP;
pwm_config.divider = PWM_DIV_INT.into();
let mut buzzer = Pwm::new_output_b(p.PWM_SLICE7, p.PIN_15, pwm_config);
loop {
buzzer
.set_duty_cycle_percent(50)
.expect("50 is valid duty percentage");
Timer::after_millis(1000).await;
buzzer
.set_duty_cycle_percent(0)
.expect("0 is valid duty percentage");
Timer::after_millis(1000).await;
}
}
If you want the beep to be shorter or faster, you can adjust the delay values.
Clone the existing project
You can clone (or refer to) the project I created and navigate to the buzzer-beep folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/buzzer-beep
rp-hal version
If you want to see the same example implemented using rp-hal, you can find it here.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/buzzer-beep
Playing Songs on a Passive Buzzer Using Rust and Raspberry Pi Pico
In this section, we will play songs on a buzzer using the Raspberry Pi Pico.
If you are not familiar with musical notes or sheet music, you can check the basic theory explained here. This part is optional and only meant to give enough background to follow the example.
For clarity, the code is split into Rust modules. You can also keep everything in a single file, as we have done so far, but splitting it makes the example easier to follow:
A passive buzzer is recommended for this exercise, though you can use either a passive or an active buzzer.
PWM
We will use PWM to control the frequency of the signal sent to the buzzer. Each frequency corresponds to a musical note. The frequency (musical note) is held for a specific duration before switching to the next note, based on the music data.
For example, the note A4 is 440 Hz. To play this note, we configure the PWM output to 440 Hz and keep it active for the required duration before moving to the next note.
If you are not familiar with PWM on the Pico, I recommend reading the PWM section before continuing.
Song Repository
In this exercise, we will play a theme on the buzzer as a demonstration.
You can also refer to the rust-embedded-songs repository and try other songs:
https://github.com/ImplFerris/rust-embedded-songs/
Submodules
Update main.rs to define the submodules, then create the corresponding source files.
#![allow(unused)]
fn main() {
pub mod music;
pub mod got;
}
Music notes
Introduction to Music Notes and Sheet Music
This is a brief guide to music notes and sheet music. While it may not cover everything, it provides a quick reference for key concepts.
Music Sheet
The notes for the music are based on the following sheet. You can refer to this Musescore link for more details.
In music, note durations are represented by the following types, which define how long each note is played:
- Whole note: The longest note duration, lasting for 4 beats.
- Half note: A note that lasts for 2 beats.
- Quarter note: A note that lasts for 1 beat.
- Eighth note: A note that lasts for half a beat, or 1/8th of the duration of a whole note.
- Sixteenth note: A note that lasts for a quarter of a beat, or 1/16th of the duration of a whole note.
Dotted Notes
A dotted note is a note that has a dot next to it. The dot increases the note’s duration by half of its original value. For example:
- Dotted half note: A half note with a dot lasts for 3 beats (2 + 1).
- Dotted quarter note: A quarter note with a dot lasts for 1.5 beats (1 + 0.5).
Tempo and BPM (Beats Per Minute)
Tempo refers to the speed at which a piece of music is played. It is usually measured in beats per minute (BPM), indicating how many beats occur in one minute.
Music module(music.rs)
In the music module, we define constants for musical notes and their frequency values.
Each note is stored as an f64 value so it can be used directly when configuring PWM frequency. A special REST value is also defined to represent silence between notes.
#![allow(unused)]
fn main() {
// Note frequencies in Hertz as f64
pub const NOTE_B0: f64 = 31.0;
pub const NOTE_C1: f64 = 33.0;
pub const NOTE_CS1: f64 = 35.0;
pub const NOTE_D1: f64 = 37.0;
pub const NOTE_DS1: f64 = 39.0;
pub const NOTE_E1: f64 = 41.0;
pub const NOTE_F1: f64 = 44.0;
pub const NOTE_FS1: f64 = 46.0;
pub const NOTE_G1: f64 = 49.0;
pub const NOTE_GS1: f64 = 52.0;
pub const NOTE_A1: f64 = 55.0;
pub const NOTE_AS1: f64 = 58.0;
pub const NOTE_B1: f64 = 62.0;
pub const NOTE_C2: f64 = 65.0;
pub const NOTE_CS2: f64 = 69.0;
pub const NOTE_D2: f64 = 73.0;
pub const NOTE_DS2: f64 = 78.0;
pub const NOTE_E2: f64 = 82.0;
pub const NOTE_F2: f64 = 87.0;
pub const NOTE_FS2: f64 = 93.0;
pub const NOTE_G2: f64 = 98.0;
pub const NOTE_GS2: f64 = 104.0;
pub const NOTE_A2: f64 = 110.0;
pub const NOTE_AS2: f64 = 117.0;
pub const NOTE_B2: f64 = 123.0;
pub const NOTE_C3: f64 = 131.0;
pub const NOTE_CS3: f64 = 139.0;
pub const NOTE_D3: f64 = 147.0;
pub const NOTE_DS3: f64 = 156.0;
pub const NOTE_E3: f64 = 165.0;
pub const NOTE_F3: f64 = 175.0;
pub const NOTE_FS3: f64 = 185.0;
pub const NOTE_G3: f64 = 196.0;
pub const NOTE_GS3: f64 = 208.0;
pub const NOTE_A3: f64 = 220.0;
pub const NOTE_AS3: f64 = 233.0;
pub const NOTE_B3: f64 = 247.0;
pub const NOTE_C4: f64 = 262.0;
pub const NOTE_CS4: f64 = 277.0;
pub const NOTE_D4: f64 = 294.0;
pub const NOTE_DS4: f64 = 311.0;
pub const NOTE_E4: f64 = 330.0;
pub const NOTE_F4: f64 = 349.0;
pub const NOTE_FS4: f64 = 370.0;
pub const NOTE_G4: f64 = 392.0;
pub const NOTE_GS4: f64 = 415.0;
pub const NOTE_A4: f64 = 440.0;
pub const NOTE_AS4: f64 = 466.0;
pub const NOTE_B4: f64 = 494.0;
pub const NOTE_C5: f64 = 523.0;
pub const NOTE_CS5: f64 = 554.0;
pub const NOTE_D5: f64 = 587.0;
pub const NOTE_DS5: f64 = 622.0;
pub const NOTE_E5: f64 = 659.0;
pub const NOTE_F5: f64 = 698.0;
pub const NOTE_FS5: f64 = 740.0;
pub const NOTE_G5: f64 = 784.0;
pub const NOTE_GS5: f64 = 831.0;
pub const NOTE_A5: f64 = 880.0;
pub const NOTE_AS5: f64 = 932.0;
pub const NOTE_B5: f64 = 988.0;
pub const NOTE_C6: f64 = 1047.0;
pub const NOTE_CS6: f64 = 1109.0;
pub const NOTE_D6: f64 = 1175.0;
pub const NOTE_DS6: f64 = 1245.0;
pub const NOTE_E6: f64 = 1319.0;
pub const NOTE_F6: f64 = 1397.0;
pub const NOTE_FS6: f64 = 1480.0;
pub const NOTE_G6: f64 = 1568.0;
pub const NOTE_GS6: f64 = 1661.0;
pub const NOTE_A6: f64 = 1760.0;
pub const NOTE_AS6: f64 = 1865.0;
pub const NOTE_B6: f64 = 1976.0;
pub const NOTE_C7: f64 = 2093.0;
pub const NOTE_CS7: f64 = 2217.0;
pub const NOTE_D7: f64 = 2349.0;
pub const NOTE_DS7: f64 = 2489.0;
pub const NOTE_E7: f64 = 2637.0;
pub const NOTE_F7: f64 = 2794.0;
pub const NOTE_FS7: f64 = 2960.0;
pub const NOTE_G7: f64 = 3136.0;
pub const NOTE_GS7: f64 = 3322.0;
pub const NOTE_A7: f64 = 3520.0;
pub const NOTE_AS7: f64 = 3729.0;
pub const NOTE_B7: f64 = 3951.0;
pub const NOTE_C8: f64 = 4186.0;
pub const NOTE_CS8: f64 = 4435.0;
pub const NOTE_D8: f64 = 4699.0;
pub const NOTE_DS8: f64 = 4978.0;
pub const REST: f64 = 0.0; // No sound, for pauses
}
Song structure
We define a small helper struct to represent a song and handle note timing.
#![allow(unused)]
fn main() {
pub struct Song {
whole_note: u64,
}
}
The whole_note field stores how long a whole note lasts, measured in milliseconds. All other note lengths are calculated from this value. Using milliseconds makes it easy to apply delays when playing notes on a buzzer.
Creating a song
When creating a Song, we calculate the duration of a whole note from the tempo.
#![allow(unused)]
fn main() {
impl Song {
pub fn new(tempo: u16) -> Self {
let whole_note = (60_000 * 4) / tempo as u64;
Self { whole_note }
}
}
}
Tempo is given in beats per minute. One minute has 60,000 milliseconds, and a whole note is equal to four beats. Dividing by the tempo gives the time, in milliseconds, that one whole note should last.
For example, at 120 BPM, one beat lasts 500 ms, so a whole note lasts 2000 ms.
Calculating note duration
This method converts a note value into a time duration.
#![allow(unused)]
fn main() {
pub fn calc_note_duration(&self, divider: i16) -> u64 {
if divider > 0 {
self.whole_note / divider as u64
} else {
let duration = self.whole_note / divider.unsigned_abs() as u64;
(duration as f64 * 1.5) as u64
}
}
}
The divider tells the code how the note relates to a whole note. A value of 1 means a whole note. A value of 2 means a half note. A value of 4 means a quarter note. The duration is calculated by dividing the whole note duration by this value.
Negative values are used to represent dotted notes. A dotted note lasts one and a half times longer than the normal version of the same note. When the divider is negative, the code first calculates the normal duration using the absolute value, then multiplies it by 1.5.
This positive and negative logic is a custom approach (based on an Arduino example I referred to) to differentiate dotted notes. It is not part of standard musical notation.
Melody Example: Game of Thrones Theme
This section contains code snippets for the Rust module got.
Importing music definitions
The got module uses note constants and helper types defined in the music module. We bring them into scope using the following import:
#![allow(unused)]
fn main() {
use crate::music::*;
}
This allows the melody to use note constants like NOTE_E4 and NOTE_A4 directly, without writing the module name each time.
Tempo
We declare the tempo for the song. You can change this value and observe how it affects playback speed.
#![allow(unused)]
fn main() {
pub const TEMPO: u16 = 85;
}
Melody Array
We define the melody of the Game of Thrones theme as an array of notes and durations. Each entry is a tuple containing a note frequency and its duration.
The duration is represented by an integer. Positive values represent normal notes. Negative values represent dotted notes.
#![allow(unused)]
fn main() {
pub const MELODY: [(f64, i16); 92] = [
// Game of Thrones Theme
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_E4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_E4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_E4, 16),
(NOTE_F4, 16),
(NOTE_G4, 8),
(NOTE_C4, 8),
(NOTE_E4, 16),
(NOTE_F4, 16),
(NOTE_G4, -4),
(NOTE_C4, -4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 4),
(NOTE_C4, 4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_D4, -1),
(NOTE_F4, -4),
(NOTE_AS3, -4),
(NOTE_DS4, 16),
(NOTE_D4, 16),
(NOTE_F4, 4),
(NOTE_AS3, -4),
(NOTE_DS4, 16),
(NOTE_D4, 16),
(NOTE_C4, -1),
// Repeat
(NOTE_G4, -4),
(NOTE_C4, -4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 4),
(NOTE_C4, 4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_D4, -1),
(NOTE_F4, -4),
(NOTE_AS3, -4),
(NOTE_DS4, 16),
(NOTE_D4, 16),
(NOTE_F4, 4),
(NOTE_AS3, -4),
(NOTE_DS4, 16),
(NOTE_D4, 16),
(NOTE_C4, -1),
(NOTE_G4, -4),
(NOTE_C4, -4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_G4, 4),
(NOTE_C4, 4),
(NOTE_DS4, 16),
(NOTE_F4, 16),
(NOTE_D4, -2),
(NOTE_F4, -4),
(NOTE_AS3, -4),
(NOTE_D4, -8),
(NOTE_DS4, -8),
(NOTE_D4, -8),
(NOTE_AS3, -8),
(NOTE_C4, -1),
(NOTE_C5, -2),
(NOTE_AS4, -2),
(NOTE_C4, -2),
(NOTE_G4, -2),
(NOTE_DS4, -2),
(NOTE_DS4, -4),
(NOTE_F4, -4),
(NOTE_G4, -1),
];
}
Code
Playing the Game of Thrones Melody
In this section, we put everything together and work in the main.rs file.
By this point, we already have the note frequencies, song timing logic, and melody data. Here, we just wire them together using PWM and timers.
Imports
Add the required imports for PWM, timers, and song handling.
#![allow(unused)]
fn main() {
// For PWM
use embassy_rp::pwm::{Config as PwmConfig, Pwm, SetDutyCycle};
use crate::music::Song;
}
PWM and Buzzer
Unlike the previous example, we clone PwmConfig. The Pwm constructor consumes the config object, but inside the loop we need to modify top to change the PWM frequency for each musical note.
#![allow(unused)]
fn main() {
let mut pwm_config = PwmConfig::default();
pwm_config.top = PWM_TOP;
pwm_config.divider = PWM_DIV_INT.into();
let mut buzzer = Pwm::new_output_b(p.PWM_SLICE7, p.PIN_15, pwm_config.clone());
}
Create the Song object
Create a Song using the tempo defined for the Game of Thrones theme.
#![allow(unused)]
fn main() {
let song = Song::new(got::TEMPO);
}
Playing the notes
The melody is played by looping through the MELODY array. Each entry contains a note frequency and a duration value.
#![allow(unused)]
fn main() {
// One time play the song
for (note, duration_type) in got::MELODY {
let top = get_top(note, PWM_DIV_INT);
pwm_config.top = top;
buzzer.set_config(&pwm_config);
let note_duration = song.calc_note_duration(duration_type);
let pause_duration = note_duration / 10; // 10% of note_duration
buzzer
.set_duty_cycle_percent(50)
.expect("50 is valid duty percentage"); // Set duty cycle to 50% to play the note
Timer::after_millis(note_duration - pause_duration).await; // Play 90%
buzzer
.set_duty_cycle_percent(0)
.expect("50 is valid duty percentage"); // Stop tone
Timer::after_millis(pause_duration).await; // Pause for 10%
}
}
For each note, the PWM frequency is updated by setting a new top value. This makes the buzzer produce the correct pitch.
The note duration is calculated from the song tempo. Most of that time is spent playing the note, and a small part is left silent. That short silence helps separate notes so the melody sounds cleaner.
The buzzer is played by setting the duty cycle to 50 percent and stopped by setting it to zero.
Keeping the Program Running
After the melody finishes, this is just to keep the program alive.
#![allow(unused)]
fn main() {
loop {
Timer::after_millis(100).await;
}
}
Clone the existing project
You can clone (or refer to) the project I created and navigate to the buzzer-song folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/buzzer-song
rp-hal version
If you want to see the same example implemented using rp-hal, you can find it here.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/got-buzzer
Active Beep
Beeping with an Active Buzzer
Since you already know how an active buzzer works, we can make it beep by simply turning a GPIO pin on and off. In this exercise, we use a GPIO pin to power the buzzer, wait for a short time, turn it off, and repeat. This creates a clear beeping sound.
Note
This example is meant for an active buzzer. If you use a passive buzzer instead, the sound may be strange or inconsistent. Try this exercise only with an active buzzer.
Hardware Requirements
- Active buzzer
- Jumper wires (female-to-male or male-to-male, depending on your setup)
Project from template
Create a new project:
To set up the project, run:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name, like “active-beep” and select embassy as the HAL.
Main logic
Ensure the buzzer is connected to GPIO 15. The pin is toggled every 500 milliseconds to turn the buzzer on and off.
#![allow(unused)]
fn main() {
let mut buzzer = Output::new(p.PIN_15, Level::Low);
loop {
buzzer.set_high();
Timer::after_millis(500).await;
buzzer.set_low();
Timer::after_millis(500).await;
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the active-beep folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/active-beep
Voltage Divider
A voltage divider is a simple circuit that reduces a higher input voltage to a lower output voltage using two resistors connected in series. You might need a voltage divider from time to time when working with sensors or modules that output higher voltages than your microcontroller can safely handle.
The resistor connected to the input voltage is called \( R_{1} \), and the resistor connected to ground is called \( R_{2} \). The output voltage \( V_{out} \) is measured at the point between \( R_{1} \) and \( R_{2} \), and it will be a fraction of the input voltage \( V_{in} \).
Circuit
The output voltage (Vout) is calculated using this formula:
\[ V_{out} = V_{in} \times \frac{R_2}{R_1 + R_2} \]
Example Calculation for \( V_{out} \)
Given:
- \( V_{in} = 3.3V \)
- \( R_1 = 10 k\Omega \)
- \( R_2 = 10 k\Omega \)
Substitute the values:
\[ V_{out} = 3.3V \times \frac{10 k\Omega}{10 k\Omega + 10 k\Omega} = 3.3V \times \frac{10}{20} = 3.3V \times 0.5 = 1.65V \]
The output voltage \( V_{out} \) is 1.65V.
fn main() {
// You can edit the code
// You can modify values and run the code
let vin: f64 = 3.3;
let r1: f64 = 10000.0;
let r2: f64 = 10000.0;
let vout = vin * (r2 / (r1 + r2));
println!("The output voltage Vout is: {:.2} V", vout);
}
Use cases
Voltage dividers are used in applications like potentiometers, where the resistance changes as the knob is rotated, adjusting the output voltage. They are also used to measure resistive sensors such as light sensors and thermistors, where a known voltage is applied, and the microcontroller reads the voltage at the center node to determine sensor values like temperature.
Voltage Divider Simulation
Formula: Vout = Vin × (R2 / (R1 + R2))
Filled Formula: Vout = 3.3 × (10000 / (10000 + 10000))
Output Voltage (Vout): 1.65 V
Simulator in Falstad website
I used the website https://www.falstad.com/circuit/ to create the diagram. It’s a great tool for drawing circuits. You can download the file I created, voltage-divider.circuitjs.txt, and import it to experiment with the circuit.
Bat Beacon: Distance Sensor Project 🦇
If you’ve seen the Batman Begins movie, you’ll remember the scene where Batman uses a device that emits ultrasonic signals to summon a swarm of bats. It’s one of the coolest gadgets in his arsenal! While we won’t be building a bat-summoning beacon today, we will be working with the similar ultrasonic technology.
Ultrasonic
Ultrasonic waves are sound waves with frequencies above 20,000 Hz, beyond what human ears can detect. But many animals can. Bats use ultrasonic waves to fly in the dark and avoid obstacles. Dolphins use them to communicate and to sense objects underwater.
Ultrasonic Technology Around You
Humans have borrowed this natural sonar principle for everyday inventions:
- Car parking sensors use ultrasonic sensors to detect obstacles when you reverse. As you get closer to an object, the beeping gets faster.
- Submarines use sonar to navigate and detect underwater objects
- Medical ultrasound allows doctors to see inside the human body
- Automatic doors and robot navigation rely on ultrasonic distance sensing
Today, you’ll build your own distance sensor using an ultrasonic module; sending out sound waves, measuring how long they take to bounce back, and calculating distance.
Meet the Hardware
The HC-SR04+ is a simple and low cost ultrasonic distance sensor. It can measure distances from about 2 cm up to 400 cm. It works by sending out a short burst of ultrasonic sound and then listening for the echo. By measuring how long the echo takes to return, the sensor can calculate how far the object is.
Tip
The HC-SR04 normally operates at 5V, which can be problematic for the Raspberry Pi Pico. If possible, purchase the HC-SR04+ version, which works with both 3.3V and 5V, making it more suitable for the Pico.
Why This Matters: The HC-SR04’s Echo pin outputs a 5V signal, but the Pico’s GPIO pins can only safely handle 3.3V. Connecting 5V directly to the Pico could damage it.
Your Options:
- Buy the HC-SR04+ variant (recommended and easiest solution)
- Use a voltage divider on the Echo pin to reduce the 5V signal to 3.3V
- Use a logic level converter to safely step down the voltage
- Power the HC-SR04 with 3.3V (not recommended, as it may work unreliably or not at all)
In this project, we’ll build a proximity detector that gradually brightens an LED as objects get closer. When the sensor detects something within 30 cm, the LED will glow brighter using PWM. You can change the distance value if you want to try different ideas.
Prerequisites
Before starting, get familiar with yourself on these topics
Hardware Requirements
To complete this project, you will need:
- HC-SR04+ or HC-SR04 Ultrasonic Sensor
- Breadboard
- Jumper wires
- External LED (You can also use the onboard LED, but you’ll need to modify the code accordingly)
- If you are using the standard HC-SR04 module that operates at 5V, you will need two resistors (1kΩ and 2kΩ or 2.2kΩ) to form a voltage divider.
The HC-SR04 Sensor module has a transmitter and receiver. The module has Trigger and Echo pins which can be connected to the GPIO pins of a pico. When the receiver detects the returning sound wave, the Echo pin goes HIGH for a duration equal to the time it takes for the wave to return to the sensor.
Datasheet
Most electronic components come with a datasheet. It’s a technical document that tells you everything you need to know about how the component works, its electrical characteristics, and how to use it properly.
For the HC-SR04 ultrasonic sensor, you can find the datasheet here: https://cdn.sparkfun.com/datasheets/Sensors/Proximity/HCSR04.pdf
Datasheets can look intimidating at first with all their technical specifications and diagrams, but you don’t need to understand everything in them.
How Does an Ultrasonic Sensor Work?
Ultrasonic sensors work by emitting sound waves at a frequency too high (40kHz) for humans to hear. These sound waves travel through the air and bounce back when they hit an object. The sensor calculates the distance by measuring how long it takes for the sound waves to return.
- Transmitter: Sends out ultrasonic sound waves.
- Receiver: Detects the sound waves that bounce back from an object.
Formula to calculate distance:
Distance = (Time x Speed of Sound) / 2
The speed of sound is approximately 0.0343 cm/µs (or 343 m/s) at normal air pressure and a temperature of 20°C.
Example Calculation:
Let’s say the ultrasonic sensor detects that the sound wave took 2000 µs to return after hitting an object.
Step 1: Calculate the total distance traveled by the sound wave:
Total distance = Time x Speed of Sound
Total distance = 2000 µs x 0.0343 cm/µs = 68.6 cm
Step 2: Since the sound wave traveled to the object and back, the distance to the object is half of the total distance:
Distance to object = 68.6 cm / 2 = 34.3 cm
Thus, the object is 34.3 cm away from the sensor.
HC-SR04 Pinout
The module has four pins: VCC, Trig, Echo, and GND.
| Pin | Function |
|---|---|
| VCC | Power Supply |
| Trig | Trigger Signal |
| Echo | Echo Signal |
| GND | Ground |
Measuring Distance with the HC-SR04 module
The HC-SR04 module has a transmitter and receiver, responsible for sending ultrasonic waves and detecting the reflected waves. We will use the Trig pin to send sound waves. And read from the Echo pin to measure the distance.
As you can see in the diagram, we connect the Trig and Echo pins to the GPIO pins of the microcontroller (we also connect VCC and GND but left them out to keep the illustration simple). We send ultrasonic waves by setting the Trig pin HIGH for 10 microseconds and then setting it back to LOW. This triggers the module to send 8 consecutive ultrasonic waves at a frequency of 40 kHz. It is recommended to have a minimum gap of 50ms between each trigger.
When the sensor’s waves hit an object, they bounce back to the module. As you can see in the diagram, the Echo pin changes the signal sent to the microcontroller, with the length of time the signal stays HIGH (pulse width) corresponding to the distance. In the microcontroller, we measure how long the Echo pin stays HIGH; Then, we can use this time duration to calculate the distance to the object.
Pulse width and the distance:
The pulse width (amount of time it stays high) produced by the Echo pin will range from about 150µs to 25,000µs(25ms); this is only if it hits an object. If there is no object, it will produce a pulse width of around 38ms.
Wiring the HC-SR04 to the Pico 2 Using a Voltage Divider
If you are using the regular HC-SR04 like I am, you will need to create a voltage divider for the Echo pin. In this section we will look at how to set up the circuit. However, if you are lucky and you bought the HC-SR04 Plus, you can skip to the next page. The circuit becomes much simpler because you can power the sensor with 3.3 V instead of 5 V.
Common resistor combination
Below are some resistor pairs you can use to bring the HC-SR04 Echo signal down to about 3.3 V. R1 is the resistor connected to the Echo pin, and R2 is the resistor connected to ground.
| R1 (With Echo) | R2 (With Gnd) | Output Voltage |
|---|---|---|
| 330 Ω | 470 Ω | 2.94 V |
| 330 Ω | 680 Ω | 3.37 V |
| 470 Ω | 680 Ω | 2.96 V |
| 680 Ω | 1 kΩ | 2.98 V |
| 1 kΩ | 1.8 kΩ | 3.21 V |
| 1 kΩ | 2 kΩ | 3.33 V |
| 1 kΩ | 2.2 kΩ | 3.44 V |
| 1.5 kΩ | 2.2 kΩ | 2.97 V |
| 2.2 kΩ | 3.3 kΩ | 3.00 V |
| 3.3 kΩ | 4.7 kΩ | 2.94 V |
| 4.7 kΩ | 6.8 kΩ | 2.96 V |
| 6.8 kΩ | 10 kΩ | 2.98 V |
| 22 kΩ | 33 kΩ | 3.00 V |
| 33 kΩ | 47 kΩ | 2.94 V |
| 47 kΩ | 68 kΩ | 2.96 V |
You can choose any resistor pair from the table because all of them bring the 5 V Echo signal down to a safe level near 3.3 V. In practice it is best to use the values you already have in your kit.
Connection for the Raspberry Pi Pico 2 and Ultrasonic Sensor
| Pico 2 Pin | Wire | HC-SR04 Pin |
|---|---|---|
| VBUS (Pin 40) |
|
VCC |
| GPIO 17 |
|
Trig |
| GPIO 16 (via Voltage Divider) |
|
Echo (through 1kΩ/2.2kΩ divider) |
| GND |
|
GND |
- VCC: Connect the VCC pin on the HC-SR04 to VBUS (Pin 40) on the Pico 2. The HC-SR04 requires 5V power, and VBUS provides 5V from the USB connection.
- Trig: Connect to GPIO 17 on the Pico 2 to trigger the ultrasonic sound pulses.
- Echo: Connect to GPIO 16 on the Pico 2 through a voltage divider (1kΩ resistor from Echo pin, 2kΩ or 2.2kΩ resistor to ground). The junction between the resistors connects to GPIO 16. This divider steps down the 5V Echo signal to ~3.4V, protecting the Pico’s 3.3V GPIO pins.
- GND: Connect to any ground pin on the Pico 2.
Connection for the Pico 2 and LED
| Pico 2 Pin | Wire | Component |
|---|---|---|
| GPIO 3 |
|
Resistor (220Ω-330Ω) |
| Resistor |
|
Anode (long leg) of LED |
| GND |
|
Cathode (short leg) of LED |
Circuit for HC-SR04+
Skip this step if you are using the 5V-only variant of the HC-SR04.
Connection for the Pico and Ultrasonic:
| Pico Pin | Wire | HC-SR04+ Pin |
|---|---|---|
| 3.3V |
|
VCC |
| GPIO 17 |
|
Trig |
| GPIO 16 |
|
Echo |
| GND |
|
GND |
- VCC: Connect the VCC pin on the HC-SR04+ to the 3.3V pin on the Pico.
- Trig: Connect to GPIO 17 on the Pico to start the ultrasonic sound pulses.
- Echo: Connect to GPIO 16 on the Pico; this pin sends a pulse when it detects the reflected signal, and the pulse length shows how long the signal took to return.
- GND: Connect to the ground pin on the Pico.
- LED: Connect the anode (long leg) of the LED to GPIO 3.
Connection for the Pico and LED:
| Pico Pin | Wire | Component |
|---|---|---|
| GPIO 3 |
|
Resistor |
| Resistor |
|
Anode (long leg) of LED |
| GND |
|
Cathode (short leg) of LED |
Rust Tutorial: Using the HC-SR04 Sensor with the Pico 2
We will start by creating a new project using the Embassy framework. After that, we wll build the same project again using rp-hal. As usual, generate the project from the template with cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name like “bat-beacon” and choose “embassy” as the HAL. Enable defmt logging, if you have a debug probe so you can view logs also.
Additional Imports
In addition to the usual boilerplate imports, you’ll need to add these specific imports to your project. Your code editor should provide auto-import suggestions for most of these, with the exception of the SetDutyCycle trait which you’ll need to add manually.
#![allow(unused)]
fn main() {
// For GPIO
use embassy_rp::gpio::{Input, Level, Output, Pull};
// For PWM
use embassy_rp::pwm::{Pwm, SetDutyCycle};
// For time calculation
use embassy_time::Instant;
}
We need GPIO types to control our trigger and echo pins, PWM to control the LED brightness, and timing utilities to measure the ultrasonic pulse duration.
Mapping GPIO Pins
By now, you should be familiar with PWM from the Dimming LED section. We will create a similar dimming effect here. But there’s a key difference. In the Dimming LED chapter, we made the LED fade in and out repeatedly using conditions. Here, we will increase the LED brightness only when an object gets closer to the sensor.
#![allow(unused)]
fn main() {
// For Onboard LED
// let mut led = Pwm::new_output_b(p.PWM_SLICE4, p.PIN_25, Default::default());
// For external LED connected on GPIO 3
let mut led = Pwm::new_output_b(p.PWM_SLICE1, p.PIN_3, Default::default());
}
You can use either the onboard LED or an external LED. I prefer using the external LED. You can see the gradual brightness changes much better.
Next, let’s initialize the LED to be off and get its maximum duty cycle value:
#![allow(unused)]
fn main() {
led.set_duty_cycle(0)
.expect("duty cycle is within valid range");
let max_duty = led.max_duty_cycle();
// defmt::info!("Max duty cycle {}", max_duty);
}
The duty cycle determines LED brightness; 0 is completely off, and max_duty is fully on.
Configuring Trigger and Echo Pins
As you know, we have to send a signal to the trigger pin from the Pico, so we’ll configure GPIO pin 17 (connected to the trigger pin) as an Output with an initial Low state. The sensor indicates distance through pulses on the echo pin, meaning it sends signals to the Pico (input to the Pico). So we’ll configure GPIO pin 16 (connected to the echo pin) as an Input.
#![allow(unused)]
fn main() {
let mut trigger = Output::new(p.PIN_17, Level::Low);
let echo = Input::new(p.PIN_16, Pull::Down);
}
Converting Distance to LED Brightness
We need a function that converts distance measurements into appropriate duty cycle values. The closer an object is, the higher the duty cycle (brighter the LED):
#![allow(unused)]
fn main() {
const MAX_DISTANCE_CM: f64 = 30.0;
fn calculate_duty_cycle(distance: f64, max_duty: u16) -> u16 {
if distance < MAX_DISTANCE_CM && distance >= 2.0 {
let normalized = (MAX_DISTANCE_CM - distance) / MAX_DISTANCE_CM;
// defmt::info!("duty cycle :{}", (normalized * max_duty as f64) as u16);
(normalized * max_duty as f64) as u16
} else {
0
}
}
}
This function takes the measured distance and the maximum duty cycle value. If the distance is between 2cm (the sensor’s minimum range) and 30cm, we normalize it to a 0-1 range and multiply by the maximum duty cycle. Objects closer than 2cm or farther than 30cm result in the LED turning off (duty cycle of 0).
Measuring Distance with the Sensor
We’ll measure distance by sending an ultrasonic pulse and timing how long it takes to return:
#![allow(unused)]
fn main() {
const ECHO_TIMEOUT: Duration = Duration::from_millis(100);
async fn measure_distance(trigger: &mut Output<'_>, echo: &Input<'_>) -> Option<f64> {
// Send trigger pulse
trigger.set_low();
Timer::after_micros(2).await;
trigger.set_high();
Timer::after_micros(10).await;
trigger.set_low();
// Wait for echo HIGH (sensor responding)
let timeout = Instant::now();
while echo.is_low() {
if timeout.elapsed() > ECHO_TIMEOUT {
defmt::warn!("Timeout waiting for HIGH");
return None; // Return early on timeout
}
}
let start = Instant::now();
// Wait for echo LOW (pulse complete)
let timeout = Instant::now();
while echo.is_high() {
if timeout.elapsed() > ECHO_TIMEOUT {
defmt::warn!("Timeout waiting for LOW");
return None; // Return early on timeout
}
}
let end = Instant::now();
// Calculate distance
let time_elapsed = end.checked_duration_since(start)?.as_micros();
let distance = time_elapsed as f64 * 0.0343 / 2.0;
Some(distance)
}
}
We begin by setting the trigger pin low for a brief moment, then raising it high for 10 microseconds. This creates the trigger pulse that instructs the sensor to emit an ultrasonic burst. After that, we wait for the Echo pin to rise. The time the Echo pin stays high represents the round-trip travel time of the sound wave. Using this duration, we compute the final distance value and return it.
We have also added a timeout while waiting for the echo pin to change state so the code does not get stuck indefinitely. When the pin fails to respond within the allowed time, we treat the attempt as a failed reading and return None, which lets the rest of the program continue running normally.
The main loop
Finally, let’s create our main loop that continuously reads the sensor and updates the LED:
#![allow(unused)]
fn main() {
loop {
Timer::after_millis(10).await;
let distance = match measure_distance(&mut trigger, &echo).await {
Some(d) => d,
None => {
Timer::after_secs(5).await;
continue; // Skip to next iteration
}
};
let duty_cycle = calculate_duty_cycle(distance, max_duty);
led.set_duty_cycle(duty_cycle)
.expect("duty cycle is within valid range");
}
}
Every 10 milliseconds, we measure the distance. If the measurement succeeds, we calculate the appropriate LED brightness and apply it. If it fails (due to timeout or sensor issues), we wait 5 seconds before trying again.
The Full code
Here’s everything put together:
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::{Duration, Timer};
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// For GPIO
use embassy_rp::gpio::{Input, Level, Output, Pull};
// For PWM
use embassy_rp::pwm::{Pwm, SetDutyCycle};
// For time calculation
use embassy_time::Instant;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// For Onboard LED
// let mut led = Pwm::new_output_b(p.PWM_SLICE4, p.PIN_25, Default::default());
// For external LED connected on GPIO 3
let mut led = Pwm::new_output_b(p.PWM_SLICE1, p.PIN_3, Default::default());
let mut trigger = Output::new(p.PIN_17, Level::Low);
let echo = Input::new(p.PIN_16, Pull::None);
led.set_duty_cycle(0)
.expect("duty cycle is within valid range");
let max_duty = led.max_duty_cycle();
// defmt::info!("Max duty cycle {}", max_duty);
loop {
Timer::after_millis(10).await;
let distance = match measure_distance(&mut trigger, &echo).await {
Some(d) => d,
None => {
Timer::after_secs(5).await;
continue; // Skip to next iteration
}
};
let duty_cycle = calculate_duty_cycle(distance, max_duty);
led.set_duty_cycle(duty_cycle)
.expect("duty cycle is within valid range");
}
}
const ECHO_TIMEOUT: Duration = Duration::from_millis(100);
async fn measure_distance(trigger: &mut Output<'_>, echo: &Input<'_>) -> Option<f64> {
// Send trigger pulse
trigger.set_low();
Timer::after_micros(2).await;
trigger.set_high();
Timer::after_micros(10).await;
trigger.set_low();
// Wait for echo HIGH (sensor responding)
let timeout = Instant::now();
while echo.is_low() {
if timeout.elapsed() > ECHO_TIMEOUT {
defmt::warn!("Timeout waiting for HIGH");
return None; // Return early on timeout
}
}
let start = Instant::now();
// Wait for echo LOW (pulse complete)
let timeout = Instant::now();
while echo.is_high() {
if timeout.elapsed() > ECHO_TIMEOUT {
defmt::warn!("Timeout waiting for LOW");
return None; // Return early on timeout
}
}
let end = Instant::now();
// Calculate distance
let time_elapsed = end.checked_duration_since(start)?.as_micros();
let distance = time_elapsed as f64 * 0.0343 / 2.0;
Some(distance)
}
const MAX_DISTANCE_CM: f64 = 30.0;
fn calculate_duty_cycle(distance: f64, max_duty: u16) -> u16 {
if distance < MAX_DISTANCE_CM && distance >= 2.0 {
let normalized = (MAX_DISTANCE_CM - distance) / MAX_DISTANCE_CM;
// defmt::info!("duty cycle :{}", (normalized * max_duty as f64) as u16);
(normalized * max_duty as f64) as u16
} else {
0
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"ultrasonic"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone (or refer) project I created and navigate to the ultrasonic folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/ultrasonic
Writing Rust Code Use HC-SR04 Ultrasonic Sensor with Pico 2
We’ll start by generating the project using the template, then modify the code to fit the current project’s requirements.
Generating From template
Refer to the Template section for details and instructions.
To generate the project, run:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, choose a name for your project-let’s go with “bat-beacon”. Don’t forget to select rp-hal as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "bat-beacon":
# cd bat-beacon
Setup the LED Pin
You should understand this code by now. If not, please complete the Blink LED section first.
Quick recap: Here, we’re configuring the PWM for the LED, which allows us to control the brightness by adjusting the duty cycle.
#![allow(unused)]
fn main() {
let pwm = &mut pwm_slices.pwm1; // Access PWM slice 1
pwm.set_ph_correct(); // Set phase-correct mode for smoother transitions
pwm.enable(); // Enable the PWM slice
let led = &mut pwm.channel_b; // Select PWM channel B
led.output_to(pins.gpio3); // Set GPIO 3 as the PWM output pin
}
Setup the Trigger Pin
The Trigger pin on the ultrasonic sensor is used to start the ultrasonic pulse. It needs to be set as an output so we can control it to send the pulse.
#![allow(unused)]
fn main() {
let mut trigger = pins.gpio17.into_push_pull_output();
}
Setup the Echo Pin
The Echo pin on the ultrasonic sensor receives the returning signal, which allows us to measure the time it took for the pulse to travel to an object and back. It’s set as an input to detect the returning pulse.
#![allow(unused)]
fn main() {
let mut echo = pins.gpio16.into_pull_down_input();
}
🦇 Light it Up
Step 1: Send the Trigger Pulse
First, we need to send a short pulse to the trigger pin to start the ultrasonic measurement.
#![allow(unused)]
fn main() {
// Ensure the Trigger pin is low before starting
trigger.set_low().ok().unwrap();
timer.delay_us(2);
// Send a 10-microsecond high pulse
trigger.set_high().ok().unwrap();
timer.delay_us(10);
trigger.set_low().ok().unwrap();
}
Step 2: Measure the Echo Time
Next, we will use two loops. The first loop will run as long as the echo pin state is LOW. Once it goes HIGH, we will record the current time in a variable. Then, we start the second loop, which will continue as long as the echo pin remains HIGH. When it returns to LOW, we will record the current time in another variable. The difference between these two times gives us the pulse width.
#![allow(unused)]
fn main() {
let mut time_low = 0;
let mut time_high = 0;
// Wait for the Echo pin to go high and note down the time
while echo.is_low().ok().unwrap() {
time_low = timer.get_counter().ticks();
}
// Wait for the Echo pin to go low and note down the time
while echo.is_high().ok().unwrap() {
time_high = timer.get_counter().ticks();
}
// Calculate the time taken for the signal to return
let time_passed = time_high - time_low;
}
Step 3: Calculate Distance
To calculate the distance, we need to use the pulse width. The pulse width tells us how long it took for the ultrasonic waves to travel to an obstacle and return. Since the pulse represents the round-trip time, we divide it by 2 to account for the journey to the obstacle and back.
The speed of sound in air is approximately 0.0343 cm per microsecond. By multiplying the time (in microseconds) by this value and dividing by 2, we obtain the distance to the obstacle in centimeters.
#![allow(unused)]
fn main() {
let distance = time_passed as f64 * 0.0343 / 2.0;
}
Step 4: PWM Duty cycle for LED
Finally, we adjust the LED brightness based on the measured distance.
The duty cycle percentage is calculated using our own logic, you can modify it to suit your needs. When the object is closer than 30 cm, the LED brightness will increase. The closer the object is to the ultrasonic module, the higher the calculated ratio will be, which in turn adjusts the duty cycle. This results in the LED brightness gradually increasing as the object approaches the sensor.
#![allow(unused)]
fn main() {
let duty_cycle = if distance < 30.0 {
let step = 30.0 - distance;
(step * 1500.) as u16 + 1000
} else {
0
};
// Change the LED brightness
led.set_duty_cycle(duty_cycle).unwrap();
}
Complete Logic of the loop
Note: This code snippet highlights the loop section and does not include the entire code.
#![allow(unused)]
fn main() {
loop {
timer.delay_ms(5);
trigger.set_low().ok().unwrap();
timer.delay_us(2);
trigger.set_high().ok().unwrap();
timer.delay_us(10);
trigger.set_low().ok().unwrap();
let mut time_low = 0;
let mut time_high = 0;
while echo.is_low().ok().unwrap() {
time_low = timer.get_counter().ticks();
}
while echo.is_high().ok().unwrap() {
time_high = timer.get_counter().ticks();
}
let time_passed = time_high - time_low;
let distance = time_passed as f64 * 0.0343 / 2.0;
let duty_cycle = if distance < 30.0 {
let step = 30.0 - distance;
(step * 1500.) as u16 + 1000
} else {
0
};
led.set_duty_cycle(duty_cycle).unwrap();
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the ultrasonic folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/ultrasonic
Your Challenge
- Use Embassy framework instead of rp-hal
- Use the onboard LED instead
Interrupts
Just give me a minute, my partner is calling.
Yes honey. Sure, I will do.
Ok, I am back. So, where was I?
When an interrupt occurs, the processor pauses its current execution.
Just a moment, someone is ringing the doorbell.
Nice, the Pico W arrived.
Anyway, let me get back to the explanation.
It continues from the exact instruction where it was interrupted.
That phone call and the doorbell were interrupts.
I (acting as the processor) paused my explanation, handled those interrupts, and then continued.
That was a simple attempt to explain interrupts using an analogy. I hope you get the idea. An interrupt is a signal that causes the processor to pause normal execution so an event can be handled. The idea is inspired by a great explanation by Patrick on YouTube. The original video is even more fun and very educational. It is worth watching.
Why We Need Interrupts
In a simple program, the processor executes instructions one after another in a straight line. This works fine if your program is simple. But embedded systems often need to respond to external events: a button press, data from a sensor, a timer expiring.
Without interrupts, the only way to detect these events is through polling: continuously checking a status register or input pin in a loop to see if something has happened. It’s like repeatedly asking:
“Did a button change?”
“Did the timer expire?”
“Is new data available?”
Most of the time, the answer is no. The processor wastes CPU time checking again and again, even when nothing is happening. This makes the system inefficient and less responsive.
Instead, peripherals can raise an interrupt to get the processor’s attention. When an interrupt occurs, the processor temporarily pauses the current code, jumps to a specific piece of code called an interrupt handler, handles the event, and then resumes execution from the exact place where it was interrupted.
Tip
Think of it like the difference between standing in front of the washing machine checking every minute if it’s done versus doing other things while it runs and having it beep when the cycle finishes.
With interrupts, the processor runs its main code freely and only stops when something actually needs attention.
How the processor remembers what it was doing
When an interrupt happens, the processor must be able to resume execution later without losing its place.
To do this, the processor saves its current state. This includes information such as the program counter and important registers.
On most microcontrollers, this state is pushed onto the stack automatically by the hardware. The interrupt handler then runs. When the handler finishes, the saved state is restored from the stack and execution continues as if nothing happened.
Interrupt Service Routines
The code that runs in response to an interrupt is called an Interrupt Service Routine (ISR).
An ISR should be short and fast. While an ISR is running, normal program execution is paused. Long or blocking operations inside an ISR can cause missed events and timing problems.
The Interrupt Vector Table
When an interrupt occurs, how does the processor know which interrupt service routine (ISR) to run? The answer is the interrupt vector table.
The interrupt vector table is a table stored in memory that contains the addresses of interrupt handler functions. Each interrupt source is assigned a fixed position in this table, called a vector number. When an interrupt fires, the processor uses that number to look up the corresponding entry in the table and jumps to the handler address stored there.
The vector table is not limited to peripheral interrupts. It also contains entries for system exceptions such as reset, hardware faults, and system timers. From the processor point of view, these events are handled in the same way as interrupts, by jumping to the address listed in the vector table.
On ARM Cortex-M processors such as the RP2350, the vector table is typically located at the start of flash memory at boot. The first entry contains the initial stack pointer, and the second entry contains the reset handler, which is the first code executed when the processor starts.
Interrupt priority levels
Microcontrollers allow interrupts to have priority levels. A higher-priority interrupt can preempt a lower-priority one. This ensures that time-critical events are handled first.
The NVIC: Interrupt Controller
The Nested Vectored Interrupt Controller (NVIC) is the hardware component in ARM Cortex-M processors that manages interrupts.
The NVIC is responsible for enabling and disabling individual interrupts, enforcing priority levels, and handling situations where multiple interrupts occur at once. When a higher-priority interrupt arrives, the NVIC can pause a lower-priority handler to deal with the more urgent event first.
Priority numbers in ARM Cortex-M work in reverse order: lower numbers mean higher priority. Among configurable interrupts, Priority 0 is the most urgent, while higher numbers like Priority 15 are less urgent. This means a Priority 0 interrupt can preempt a Priority 2 handler, but not the other way around.
Critical Sections
A critical section is a small sequence of code that must not be interrupted, in order to preserve the consistency of data or hardware state.
Consider a situation where the main code is updating a shared variable or configuring a peripheral using multiple steps. If an interrupt occurs in the middle of that sequence, and the interrupt handler accesses the same data or hardware, the system can end up in an inconsistent state.
In embedded systems, this is usually handled by temporarily disabling interrupts before entering the critical section and re-enabling them immediately after.
The goal is not to block interrupts for long periods of time, but to protect very small and sensitive pieces of code where consistency matters.
In the embedded Rust ecosystem, the critical-section crate provides a universal, portable API for entering critical sections across many platforms and environments. It defines functions like acquire, release, and with that libraries and applications can use to run code with interrupts disabled or otherwise protected.
Example:
#![allow(unused)]
fn main() {
use core::cell::Cell;
use critical_section::Mutex;
static MY_VALUE: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));
critical_section::with(|cs| {
// This code runs within a critical section.
// `cs` is a token that you can use to "prove" that to some API,
// for example to a `Mutex`:
MY_VALUE.borrow(cs).set(42);
});
}
Types of interrupts
In microcontrollers, interrupts usually come from a few common sources.
External interrupts
These are triggered by external signals, such as a button press or a change on a GPIO pin. They are often used for user input or reacting to external hardware events.
Timer interrupts
Timers can generate interrupts at fixed intervals. These are widely used for delays, scheduling tasks, blinking LEDs, or keeping time. Yup, we have actually been using timer interrupts already. Whenever we call Timer::after_millis(100).await in Embassy, that’s exactly what happens behind the scenes. The timer peripheral is configured to fire an interrupt after 100 milliseconds. Our task goes to sleep, and when the timer interrupt fires, it wakes the task back up. The CPU doesn’t sit there counting, it’s free to do other things or sleep while waiting.
Peripheral interrupts
Many peripherals can generate interrupts. For example:
- SPI and I2C peripherals can raise interrupts to signal transfer completion or error conditions.
- ADC peripherals can generate interrupts when a conversion finishes.
System exceptions
Some interrupts are generated by the processor itself, such as faults or system timers. These are usually reserved for system-level tasks.
Interrupts in the RP2350
In the previous chapter, we looked at what interrupts are and the role of the NVIC. Now, lets look at which interrupts are actually available on the RP2350.
Interrupts fall into two groups: system exceptions and external interrupts.
System exceptions are defined by the CPU architecture itself. These include reset, fault handlers, and the system timer. They behave the same way across most Cortex-M chips.
External interrupts come from peripherals on the RP2350. Each peripheral that can generate an interrupt has an IRQ number and a vector name. These are the names you will see in code.
The table below shows the external interrupts on the RP2350, numbered from 0 to 51. They cover common peripherals such as timers, GPIO, DMA, and communication interfaces like I2C, SPI, and UART.
You do not need to memorize this table. Its purpose is to help you recognize where names like I2C0_IRQ or UART0_IRQ come from when you see them in examples or documentation.
The full details are in the RP2350 datasheet, section 3.2 on page 82.
In the next chapter, we will see how Embassy uses these interrupts without requiring you to write interrupt handlers manually.
RP2350 External Interrupts
Important
Some interrupt descriptions are simplified here for a beginner-friendly overview. For more accurate and detailed information, refer to the RP2350 datasheet.
Timers
Timer alarms used for delays and scheduling.
| IRQ | Vector | Description |
|---|---|---|
| 0 | TIMER0_IRQ_0 | Timer 0 alarm interrupt |
| 1 | TIMER0_IRQ_1 | Timer 0 alarm interrupt |
| 2 | TIMER0_IRQ_2 | Timer 0 alarm interrupt |
| 3 | TIMER0_IRQ_3 | Timer 0 alarm interrupt |
| 4 | TIMER1_IRQ_0 | Timer 1 alarm interrupt |
| 5 | TIMER1_IRQ_1 | Timer 1 alarm interrupt |
| 6 | TIMER1_IRQ_2 | Timer 1 alarm interrupt |
| 7 | TIMER1_IRQ_3 | Timer 1 alarm interrupt |
PWM
PWM counter wrap events.
| IRQ | Vector | Description |
|---|---|---|
| 8 | PWM_IRQ_WRAP_0 | PWM wrap interrupt |
| 9 | PWM_IRQ_WRAP_1 | PWM wrap interrupt |
DMA
DMA transfer events.
| IRQ | Vector | Description |
|---|---|---|
| 10 | DMA_IRQ_0 | DMA transfer interrupt |
| 11 | DMA_IRQ_1 | DMA transfer interrupt |
| 12 | DMA_IRQ_2 | DMA transfer interrupt |
| 13 | DMA_IRQ_3 | DMA transfer interrupt |
USB
USB controller events.
| IRQ | Vector | Description |
|---|---|---|
| 14 | USBCTRL_IRQ | USB controller interrupt |
PIO
PIO state machine events.
| IRQ | Vector | Description |
|---|---|---|
| 15 | PIO0_IRQ_0 | PIO 0 interrupt |
| 16 | PIO0_IRQ_1 | PIO 0 interrupt |
| 17 | PIO1_IRQ_0 | PIO 1 interrupt |
| 18 | PIO1_IRQ_1 | PIO 1 interrupt |
| 19 | PIO2_IRQ_0 | PIO 2 interrupt |
| 20 | PIO2_IRQ_1 | PIO 2 interrupt |
GPIO and Core I/O
GPIO and core signaling events.
| IRQ | Vector | Description |
|---|---|---|
| 21 | IO_IRQ_BANK0 | GPIO interrupt |
| 22 | IO_IRQ_BANK0_NS | GPIO interrupt |
| 23 | IO_IRQ_QSPI | QSPI GPIO interrupt |
| 24 | IO_IRQ_QSPI_NS | QSPI GPIO interrupt |
| 25 | SIO_IRQ_FIFO | Inter-core FIFO interrupt |
| 26 | SIO_IRQ_BELL | Inter-core doorbell interrupt |
| 27 | SIO_IRQ_FIFO_NS | Inter-core FIFO interrupt |
| 28 | SIO_IRQ_BELL_NS | Inter-core doorbell interrupt |
| 29 | SIO_IRQ_MTIMECMP | System timer interrupt |
Communication Peripherals
Communication interface events.
| IRQ | Vector | Description |
|---|---|---|
| 30 | CLOCKS_IRQ | Clock system interrupt |
| 31 | SPI0_IRQ | SPI interrupt |
| 32 | SPI1_IRQ | SPI interrupt |
| 33 | UART0_IRQ | UART interrupt |
| 34 | UART1_IRQ | UART interrupt |
| 35 | ADC_IRQ_FIFO | ADC FIFO interrupt |
| 36 | I2C0_IRQ | I2C interrupt |
| 37 | I2C1_IRQ | I2C interrupt |
System and Power
System and power management events.
| IRQ | Vector | Description |
|---|---|---|
| 38 | OTP_IRQ | OTP interrupt |
| 39 | TRNG_IRQ | Random number generator interrupt |
| 40 | Reserved | Reserved |
| 41 | Reserved | Reserved |
| 42 | PLL_SYS_IRQ | System PLL interrupt |
| 43 | PLL_USB_IRQ | USB PLL interrupt |
| 44 | POWMAN_IRQ_POW | Power manager interrupt |
| 45 | POWMAN_IRQ_TIMER | Power manager timer interrupt |
Software IRQs
Interrupts that can be triggered by software.
| IRQ | Vector | Description |
|---|---|---|
| 46 | SPAREIRQ_IRQ_0 | Software interrupt |
| 47 | SPAREIRQ_IRQ_1 | Software interrupt |
| 48 | SPAREIRQ_IRQ_2 | Software interrupt |
| 49 | SPAREIRQ_IRQ_3 | Software interrupt |
| 50 | SPAREIRQ_IRQ_4 | Software interrupt |
| 51 | SPAREIRQ_IRQ_5 | Software interrupt |
Using Interrupts with Embassy
In the previous chapter, we looked at what interrupts are and how the NVIC fits into the picture. Now lets see how interrupts are actually used in Embassy.
In Embassy, you normally do not write interrupt handlers yourself. Async drivers use interrupts internally to wait for hardware events and to wake tasks when those events happen. Your code just awaits an operation and continues when it is ready.
For some peripherals, Embassy needs a small amount of setup so it knows which hardware interrupt belongs to which driver. This is where bind_interrupts! comes in.
Why bind_interrupts! Is Needed
Async peripherals like I2C, SPI do not finish their work in one step. While an operation is in progress, the task goes to sleep and the hardware generates interrupts as things move forward.
Embassy already provides the interrupt handlers for these peripherals. What it needs from you is the connection between the hardware interrupt and the handler it should use. The bind_interrupts! macro is how you make that connection.
You are not writing an interrupt handler here. You are just wiring things up so the async driver can work.
Binding an Interrupt for I2C
Here is a simple example for I2C:
#![allow(unused)]
fn main() {
use embassy_rp::{bind_interrupts, i2c};
use embassy_rp::peripherals::I2C0;
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
This tells Embassy that the I2C0_IRQ interrupt should be handled by the I2C driver for I2C0. Once this is in place, async I2C operations can sleep and wake correctly.
Using Async I2C
After the interrupt is bound, using async I2C looks normal:
#![allow(unused)]
fn main() {
use embassy_rp::i2c::{I2c, Config as I2cConfig};
use embassy_rp::peripherals::I2C0;
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c = I2c::new_async(
p.I2C0,
scl,
sda,
Irqs,
I2cConfig::default(),
);
}
When you later call an async operation like:
#![allow(unused)]
fn main() {
i2c.write(0x3C, &[0x00]).await;
}
your task pauses and lets other code run. Meanwhile, the I2C hardware does its work. When the hardware finishes, an interrupt fires and Embassy wakes your task back up. The interrupt happens behind the scenes, you just see your code continue after the .await.
Inter-Integrated Circuit (I2C)
So far, we’ve been toggling output pins between High and Low states to control an LED and reading the same two levels from a button. But working with interesting devices like display modules, RFID readers, and SD card readers requires something more. Simple pin toggling won’t work here. We need a proper communication mechanism, and that’s where communication protocols come in. The most common ones are I2C, SPI, and UART. Each one has its own advantages and disadvantages.
Since we will be using an OLED display in the next chapter, and it communicates over I2C, this is the first protocol we are going to explore. OLED displays are one of the modules I enjoy the most. I’ve used them to make small games and a bunch of fun personal projects.
What Is I2C?
I2C stands for Inter-Integrated Circuit, also written as I²C. It’s one of the popular communication methods used by microcontrollers to talk to sensors, displays (like OLEDs), and other chips. It is a serial, half-duplex, and synchronous interface. Let’s break down what that means.
-
Serial means data is transferred one bit at a time, in sequence. Each bit is sent individually rather than sending multiple bits at once. These bits are carried over a data signal during communication.
-
Half duplex means data travels in only one direction at a time. A device either sends data or receives data, but not both at the same time. The direction switches as needed during communication.
-
Synchronous means both devices rely on a shared clock signal. This clock defines exactly when each bit of data is sent and read, keeping both devices aligned during communication.
Controller and Target
I2C uses a controller-target model. The controller (formerly known as master) is the device that initiates communication and provides the clock signal. The target (formerly known as slave) responds to the controller’s commands.
Figure: Single Controller and Single Target
In typical embedded projects, the microcontroller(e.g: Pico) acts as the controller, and connected devices like displays(eg: OLED) or sensors act as targets.
I2C makes it easy to connect many devices on the same two wires. You can connect multiple targets to a single controller, which is the most common setup. I2C also supports multiple controllers on the same bus, so more than one controller can talk to one or more targets.
I2C Bus
The I2C bus uses just two lines, which are shared by all connected devices:
-
SCL (Serial Clock Line): Carries the clock signal from the controller. Sometimes devices label them as SCK.
-
SDA (Serial Data Line): Transfers the data in both directions. Sometimes devices label them as SDI.
Figure: Single Controller and Multiple Target
All connected devices share the same two wires. The controller selects which target to communicate with by sending that device’s unique address.
I2C Addresses
Each I2C target device has a 7-bit or 10-bit address. The most common is 7-bit, which allows for up to 128 possible addresses.
Many devices have a fixed address defined by the manufacturer, but others allow configuring the lower bits of the address using pins or jumpers. For example, a sensor might use pins labeled A0 and A1 to change its address, allowing you to use multiple copies of the same chip on the same bus.
When the controller wants to talk to a target, it starts by sending a START condition, followed by the device address and a read/write bit. The matching device responds with an ACK (acknowledge) signal, and communication continues.
Speed Modes
I2C supports different speed modes depending on how fast data needs to be transferred. Standard mode goes up to 100 kbps, fast mode reaches 400 kbps, and Fast Mode Plus allows up to 1 Mbps. For even faster communication, High-Speed mode supports up to 3.4 Mbps. There is also an Ultra-Fast mode (5 Mbps). The speed you can use depends on what speed modes are supported by both the microcontroller’s I2C interface and the connected target devices.
Why I2C?
I2C is ideal when you want to connect several devices using just two wires. It is well-suited for applications where speed is not critical but wiring simplicity is important.
The good news is that in Embedded Rust, you don’t need to implement the I2C protocol yourself. The embedded-hal crate defines common I2C traits, and the HAL for your chip takes care of the low-level details. In the next section, we will see more on it.
Resources
- Basics of the I2C Communication Protocol: Refer this if you want in-depth understanding how the controller communincates with target.
Raspberry Pi Pico 2(RP2350)’s I2C
Now that you understand the basics of the I2C protocol, let us look at how it works on the Raspberry Pi Pico 2. The RP2350 has two separate I2C controllers, named I2C0 and I2C1. Think of these as two independent communication channels that can operate simultaneously. This helps when two devices share the same I2C address, because you can place them on separate controllers.
Available I2C Pins
Both I2C controllers support multiple pin options for SDA and SCL. You only choose one pair for each controller.
| I2C Controller | GPIO Pins |
|---|---|
| I2C0 - SDA | GP0, GP4, GP8, GP12, GP16, GP20 |
| I2C0 - SCL | GP1, GP5, GP9, GP13, GP17, GP21 |
| I2C1 - SDA | GP2, GP6, GP10, GP14, GP18, GP26 |
| I2C1 - SCL | GP3, GP7, GP11, GP15, GP19, GP27 |
On the Pico 2 board layout, pins that support I2C functionality are labeled with SDA and SCL, and are also highlighted in blue to make them easy to identify.
Speed Options
The RP2350’s I2C controllers support three different speed modes, allowing you to match the capabilities of whatever devices you’re connecting:
- Standard mode: Up to 100 kb/s (kilobits per second) - the slowest but most universally compatible
- Fast mode: Up to 400 kb/s - a good balance for most sensors and displays
- Fast mode plus: Up to 1000 kb/s - for when you need quicker data transfer
It’s worth noting that the RP2350 doesn’t support the ultra-high-speed modes (High-speed at 3.4 Mb/s or Ultra-Fast at 5 Mb/s) that some specialized devices use. However, most common sensors, displays, and peripherals work perfectly fine within the supported speed ranges.
Controller or Target mode
The RP2350 can only be a Controller (master) or a Target (slave) at any given time—not both simultaneously on the same controller. For typical projects where the Pico 2 is controlling sensors and displays, you’ll always use controller mode.
For the complete technical specifications, you can refer to page 983 of the RP2350 Datasheet.
Using I2C with the Embedded Rust Ecosystem
In the previous section, we learned the basics of I2C communication and how the controller-target (master-slave) model works. Now, let’s see how these concepts apply in the Embedded Rust ecosystem, where modular and reusable design is a key principle.
The Role of embedded-hal
The embedded-hal crate defines a standard set of traits for embedded hardware abstraction, including I2C. These traits allow driver code (like for displays or sensors) to be written generically so that it can run on many different microcontrollers without needing platform-specific changes.
The core I2C trait looks like this:
#![allow(unused)]
fn main() {
pub trait I2c<A: AddressMode = SevenBitAddress>: ErrorType {
// This method must be implemented by HAL authors
fn transaction(...);
// These are default methods built on top of `transaction`
fn read(...);
fn write(...);
fn write_read(...);
}
}
The only method that the HAL is required to implement is transaction. The trait provides default implementations of read, write, and write_read using this method.
The generic type parameter A specifies the address mode and has a default type parameter of SevenBitAddress. So, in most cases you don’t need to specify it manually. For 10-bit addressing, you can use TenBitAddress instead.
Microcontroller-specific HAL crates (like esp-hal, stm32-hal, or nrf-hal) implement this trait for their I2C peripherals. For example, the esp-hal crate implements I2C. If you are curious, you can look at the implementation here.
In addition to the regular embedded-hal crate, there is an async version called embedded-hal-async. It defines similar traits, but they are designed to work with async code, which is useful when writing non-blocking drivers or tasks in embedded systems.
Platform-Independent Drivers
Imagine you are writing a driver for a sensor or a display that communicates over I2C. You don’t want to tie your code to a specific microcontroller like the Raspberry Pi Pico or ESP32. Instead, you can write the driver in a generic way using the embedded-hal trait.
As long as your driver only depends on the I2C trait, it can run on any platform that provides an implementation of this trait-such as STM32, nRF, or ESP32.
Sharing the I2C Bus
Many embedded projects connect multiple I2C devices (like an OLED display, an LCD, and various sensors) to the same SDA and SCL lines. However, only one device can control the bus at a time.
Figure: Microcontroller(Pico) and Multiple Devices
If you give exclusive access to one driver, other devices cannot communicate. This is where the embedded-hal-bus crate helps.
It provides wrapper types like AtomicDevice, CriticalSectionDevice, and RefCellDevice that allow multiple drivers to safely share access to the same I2C bus. These wrappers themselves implement the I2c trait, so drivers can use them as if they were the original bus.
You can use I2C in two ways:
-
Without sharing: If your application only talks to one I2C device, you can pass the I2C bus instance provided by the HAL (which implements the I2c trait) directly to the driver.
-
With sharing: If your application needs to communicate with multiple I2C devices on the same bus, you can wrap the I2C bus instance (provided by the HAL) using one of the sharing types from the embedded-hal-bus crate, such as AtomicDevice or CriticalSectionDevice. This allows safe, coordinated access across multiple drivers.
Resources
- embedded-hal docs on I2C: This documentation provides in-depth details on how I2C traits are structured and how they are intended to be used across different platforms.
I2C in Embassy RP
Let’s see how to initialize and use I2C with Embassy on the Raspberry Pi Pico 2.
Blocking mode
Embassy provides a simple way to set up I2C in blocking mode:
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
info!("set up i2c ");
let mut i2c = i2c::I2c::new_blocking(p.I2C0, scl, sda, Config::default());
}
We use the new_blocking method to create an I2C instance that waits for each operation to finish before continuing. First we choose which I2C peripheral we want to work with, either I2C0 or I2C1. Once we select the peripheral, we must pair it with the correct GPIO pins for SCL and SDA.
For the configuration, the default implementation gives us standard 100 kHz communication and also enables internal pullups.
Customizing Config
The Config struct lets us control how the I2C bus behaves. We can adjust the communication speed and whether the internal pullups on the SDA and SCL lines are enabled.
If we want to increase the bus speed, we can change the frequency field:
#![allow(unused)]
fn main() {
let mut config = Config::default();
config.frequency = 400_000;
}
If our circuit already includes external pullup resistors, we can disable the internal ones:
#![allow(unused)]
fn main() {
let mut config = Config::default();
config.sda_pullup = false;
config.scl_pullup = false;
}
Sending Data
Many I2C devices require us to send commands or configuration bytes. For example, imagine we are configuring a sensor and need to write two bytes to it:
#![allow(unused)]
fn main() {
const SENSOR_ADDR: u8 = 0x68;
let config_data = [0x6B, 0x00];
i2c.write(SENSOR_ADDR, &config_data)?;
}
Here, we’re sending two bytes to the device at address 0x68. The first byte 0x6B typically tells the device which register we’re writing to, and 0x00 is the value we want to write. Different devices use this pattern differently, so you’ll need to check your device’s datasheet to know what bytes to send.
Reading from a Register
Most I2C devices store their data in registers. To read a specific register, we use write_read. Let’s say we want to read the temperature from a sensor:
#![allow(unused)]
fn main() {
const TEMP_REGISTER: u8 = 0x41;
let mut buffer = [0u8; 2];
i2c.write_read(SENSOR_ADDR, &[TEMP_REGISTER], &mut buffer)?;
}
We first tell the device “I want to read from register 0x41” (the write part), then the device sends us back 2 bytes of temperature data (the read part). The write_read method does both operations in a single I2C transaction. After this, our buffer will contain the raw temperature bytes that we can then convert to an actual temperature value.
Reading Continuously
Some devices automatically advance their internal pointer and keep producing data. For these cases we can use a simple read:
#![allow(unused)]
fn main() {
let mut buffer = [0u8; 5];
i2c.read(SENSOR_ADDR, &mut buffer)?;
}
This reads bytes starting from the device’s current internal position. It is less common than write_read, but useful for sensors that stream data continuously.
Using Async Mode
If we’re building a more complex application that needs to handle multiple things at once, we can use async mode. This lets our program do other work while waiting for I2C operations to complete:
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
let mut i2c = I2c::new_async(
p.I2C0,
scl,
sda,
Irqs,
I2cConfig::default(),
);
let mut buffer = [0u8; 2];
i2c.write_read(SENSOR_ADDR, &[TEMP_REGISTER], &mut buffer).await?;
}
Some of the details here, like interrupts, may not be familiar yet. We will introduce interrupts later in the book, so do not worry if this part feels unfamiliar for now.
Target (Slave) mode
The Pico can also act as an I2C target device (also known as a slave device), where it responds to requests from another controller. However, for most of our projects in this book, we’ll be using the Pico as the controller that talks to sensors and other peripherals, so we won’t cover target mode here.
OLED Display
OLED Display
In this section, we’ll learn how to connect an OLED display module to the Raspberry Pi Pico 2. OLED displays are one of the most fun components to work with because they open up so many creative possibilities. You can build games, create dashboards, or display sensor readings in a visual way.
To give you an idea of what is possible, I have built a few games using an OLED display. One of them is Pico Rex, a tiny dinosaur jumping game inspired by Chrome’s offline dino. You can check it out here.
I have also made a small flappy-style game and a shooter game, which you can find along with other examples here.
As you learn how to use the display, feel free to experiment and build your own ideas. Even simple animations or text updates can be surprisingly fun to create.
In next few chapters, we’ll create simple projects like displaying text and an image (display Ferris 🦀 image) on the OLED. We’ll use the I2C protocol to connect the OLED display to the Pico.
Meet the Hardware
OLED, short for Organic Light-Emitting Diode, is a popular display module. These displays come in various sizes and can support different colors. They communicate using either the I²C or SPI protocol.
For this exercise, we’ll use a 0.96-inch OLED monochrome module with a resolution of 128 x 64. It operates at 3.3V. We can communicate using I2C communication protocol.
Tip
Most of the time, OLED displays come with pin headers included but not soldered. Soldering is a valuable skill to learn, but it requires care and preparation. Before attempting it, watch plenty of tutorials and do your research. It may feel challenging at first, but with practice, it gets easier. If you’re not comfortable soldering yet, consider looking for a pre-soldered version of the display, though it may cost slightly more.
SSD1306
The SSD1306 is the integrated controller chip that powers many small OLED displays including the module we are going to use(0.96-inch 128x64 module). This controller handles the communication between the Pico and the OLED panel, enabling the display to show text, graphics, and more.
DataSheet: You can find the datasheet for SSD1306 here.
How OLED module works?
We won’t dive into the details of how OLED technology works; instead, we’ll focus on what’s relevant for our exercises. The module has a resolution of 128x64, giving it a total of 128 × 64 = 8192 pixels. Each pixel can be turned on or off independently.
Don’t worry if these concepts are unclear for now - you can always research them later. These details are more relevant if you plan to write your own driver for the SSD1306 or work on more advanced tasks. For now, we already have a good crates that handles these aspects and simplifies the process.
In the datasheet, the 128 columns are referred to as segments, while the 64 rows are called commons (be careful not to confuse “commons” with “columns” due to their similar spelling).
Memory
The OLED display’s pixels are arranged in a page structure within GDDRAM (Graphics Display DRAM). GDDRAM is divided into 8 pages (From Page 0 to Page 7), each consisting of 128 columns (segments) and 8 rows(commons).
(This image is taken from the datasheet)
A segment is 8 bits of data (one byte), with each bit representing a single pixel. When writing data, you will write an entire segment, meaning the entire byte is written at once.
(This image is taken from the datasheet)
We can re-map both segments and commons through software for mechanical flexibility. You can find more details on page 25 of the ssd1306 datasheet.
Pages and Segments
I created an image to show how 128x64 pixels are divided into 8 pages. I then focused on a single page, which contains 128 segments (columns) and 8 rows. Finally, I zoomed in on a single segment to demonstrate how it represents 8 vertically stacked pixels, with each pixel corresponding to one bit.
![]()
Circuit
The OLED display requires four connections to the Raspberry Pi Pico. This example uses I2C0 with GPIO 16 and 17, but you can use any valid I2C pin pair on your Pico.
| Pico Pin | Wire | OLED Pin |
|---|---|---|
| GPIO 16 |
|
SDA |
| GPIO 17 |
|
SCL |
| 3.3V |
|
VCC |
| GND |
|
GND |
Crates You Will Use
Now that you understand what the SSD1306 is and how I2C communication works, let’s explore how we actually draw graphics on the display. You might wonder if you need to send raw commands for every pixel. The good news is that you do not. The Rust ecosystem provides us with great tools that make this much easier.
Drawing on the Display
When working with the SSD1306 display in Rust, you’ll use two main crates that work together:
- embedded-graphics - A drawing library that lets you create shapes, text, and images
- ssd1306 - A driver that controls the actual hardware
What is embedded-graphics?
embedded graphics is a lightweight 2D drawing library made for memory limited embedded systems. Instead of setting individual pixels yourself, you use high level drawing commands. For example:
#![allow(unused)]
fn main() {
Circle::new(Point::new(20, 20), 30)
.into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1))
.draw(&mut display)?; // display implements DrawTarget
}
This draws a circle without you needing to calculate any pixel positions or understand how the display stores its data internally.
What embedded-graphics provides:
- Drawing primitives such as circles, rectangles, lines, and triangles
- Text rendering - display text with different fonts
- Image and icon support through compatible image crates
- Style options for color, stroke width, and fill
The Key Design Principle
embedded-graphics is completely display-independent. It doesn’t know anything about your specific hardware (whether it’s an SSD1306, ST7789, or any other display). It simply knows how to describe what should be drawn. The actual display driver then handles the hardware-specific details of turning those instructions into real pixels.
This design means you can write drawing code once and use it with many different displays, just by changing the driver.
You can explore more in the official documentation: https://docs.rs/embedded-graphics/latest/embedded_graphics/
What is the ssd1306 Crate?
The ssd1306 crate is a hardware driver for displays that use the SSD1306 controller chip. It handles all the low-level work needed to communicate with your display. This includes initializing the screen, sending the correct I2C or SPI commands, managing an internal buffer when required, and updating the pixels on the hardware.
Graphics Mode
The ssd1306 driver supports multiple modes, but for drawing graphics, you’ll use BufferedGraphicsMode. You enter this mode by calling:
#![allow(unused)]
fn main() {
let display = ssd1306::Ssd1306::new(i2c, size, rotation)
.into_buffered_graphics_mode();
}
In this mode, the driver maintains an internal buffer in RAM and implements the DrawTarget trait from embedded-graphics-core. This is what allows the two crates to work together.
How They Work Together: The DrawTarget Trait
If you are here, you probably already understand Rust traits. A trait basically describes what something can do, and the implementation decides how it actually does it.
DrawTarget is the trait that allows embedded graphics to send drawing commands to a display driver. When the ssd1306 driver implements this trait, it is essentially saying:
“I can accept the pixels that embedded graphics produces, and I know how to put those pixels onto an SSD1306 screen.”
Here is what actually happens when you draw something:
- You create shapes, text, or images using embedded-graphics primitives.
- You call .draw(&mut display) to render them.
- embedded graphics generates the pixels that need to be drawn.
- The ssd1306 driver takes those pixels and stores them in its internal buffer.
- When you call display.flush(), the driver sends the updated pixels to the OLED hardware.
Here’s a simple example:
#![allow(unused)]
fn main() {
// Create a circle using embedded-graphics
let circle = Circle::new(Point::new(64, 32), 20)
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On));
// Draw it onto the display driver
circle.draw(&mut display)?; // Write pixel data into the driver's buffer
display.flush()?; // Send buffer to the actual screen
}
Async mode
The ssd1306 crate also supports async operation when you enable the async feature. This is useful if you’re using Embassy.
To use async mode, add the feature to your Cargo.toml:
ssd1306 = { version = "0.10.0", features = ["async"] }
When the async feature is enabled, the driver uses embedded-hal-async traits instead of the regular blocking embedded-hal traits. This allows the I2C/SPI communication to happen asynchronously, which is helpful when you want to do other tasks while waiting for display updates.
The main difference is that methods like .init() and .flush() become async and need to be .awaited:
#![allow(unused)]
fn main() {
// Async version
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display.init().await?; // Note the .await
// Drawing still works the same way
circle.draw(&mut display)?;
display.flush().await?; // This is now async too
}
Hello OLED
We are going to keep things simple. We will just display “Hello, Rust!” on the OLED display. We will first use Embassy, then we will do the same using rp-hal.
Create Project
As usual, generate the project from the template with cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name like “hello-oled” and choose “embassy” as the HAL. Enable defmt logging, if you have a debug probe so you can view logs also.
Update Dependencies
Add the following lines to your Cargo.toml under dependencies:
embedded-graphics = "0.8.1"
ssd1306 = { version = "0.10.0", features = ["async"] }
We will enable the async feature so the ssd1306 driver can be used with Embassy async I2C. You can also use it without this feature and use Embassy I2C in blocking mode.
Additional imports
Add these imports at the top of your main.rs:
#![allow(unused)]
fn main() {
// Interrupt Binding
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
text::{Baseline, Text},
};
}
Bind I2C Interrupt
We discussed this in detail in the interrupts section, so you should already be familiar with what it does. This binds the I2C0_IRQ interrupt to the Embassy I2C interrupt handler for I2C0.
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
Initialize I2C
First, we need to set up the I2C bus to communicate with the display.
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; //400kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
}
We have connected the OLED’s SDA line to Pin 16 and the SCL line to Pin 17. Throughout this chapter we will keep using these same pins. If you have connected your display to a different valid I2C pair, adjust the code to match your wiring.
We are using the new_async method to create an I2C instance in async mode. This allows I2C transfers to await instead of blocking the CPU. We use a 400 kHz bus speed, which is commonly supported by SSD1306 displays.
Initialize Display
Now we create the display interface and initialize it:
#![allow(unused)]
fn main() {
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
}
I2CDisplayInterface::new(i2c_bus) wraps the async I2C bus so it can be used by the SSD1306 driver. It uses the default I2C address 0x3C, which is standard for most SSD1306 modules.
We create the display instance by specifying a 128x64 display and the default orientation. We also enable buffered graphics mode so we can draw into a RAM buffer using embedded-graphics.
#![allow(unused)]
fn main() {
display
.init()
.await
.expect("failed to initialize the display");
}
Finally, display.init() sends initialization commands to the display hardware. This wakes up the display and configures it properly.
Writing Text
Before we can draw text, we need to define how the text should look:
#![allow(unused)]
fn main() {
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
}
This creates a text style using FONT_6X10, a built-in monospaced font that’s 6 pixels wide and 10 pixels tall. We set BinaryColor::On to display white pixels on our black background since the OLED is monochrome.
Now let’s draw the text to the display’s buffer:
#![allow(unused)]
fn main() {
defmt::info!("sending text to display");
Text::with_baseline("Hello, Rust!", Point::new(0, 16), text_style, Baseline::Top)
.draw(&mut display)
.expect("failed to draw text to display");
}
We’re rendering “Hello, Rust!” at position (0, 16), which is 16 pixels down from the top of the screen. We use the text style we defined earlier and align the text using its top edge with Baseline::Top.
The .draw(&mut display) call renders the text into the display’s internal buffer. At this point, the text exists in RAM but is not yet visible on the physical screen.
Displaying Text
Finally, we send the buffer contents to the actual OLED hardware:
#![allow(unused)]
fn main() {
display
.flush()
.await
.expect("failed to flush data to display");
}
This is when the I2C communication happens. The driver sends the bytes from RAM to the display controller, and you’ll see “Hello, Rust!” appear on your OLED screen!
Complete Code
Here’s everything put together:
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// Interrupt Binding
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
mono_font::{MonoTextStyleBuilder, ascii::FONT_6X10},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
text::{Baseline, Text},
};
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; //400kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
defmt::info!("sending text to display");
Text::with_baseline("Hello, Rust!", Point::new(0, 16), text_style, Baseline::Top)
.draw(&mut display)
.expect("failed to draw text to display");
display
.flush()
.await
.expect("failed to flush data to display");
loop {
Timer::after_millis(100).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"hello-oled"),
embassy_rp::binary_info::rp_program_description!(c"Hello OLED"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone (or refer) project I created and navigate to the hello-oled folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/oled/hello-oled
Hello Rust on OLED
The same hello world we will do with rp-hal also.
Generating From template
To generate the project, run:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, choose a name for your project-let’s go with “oh-led”. Don’t forget to select rp-hal as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "oh-led":
# cd oh-led
Add Additional Dependencies
Since we are using the SSD1306 OLED display, we need to include the SSD1306 driver. To add this dependency, use the following Cargo command:
cargo add ssd1306@0.10.0
We will use the embedded_graphics crate to handle graphical rendering on the OLED display, to draw images, shapes, and text.
cargo add embedded-graphics@0.8.1
Additional imports
In addition to the imports from the template, you’ll need the following additional dependencies for this task.
#![allow(unused)]
fn main() {
// Embedded Graphics
use embedded_graphics::mono_font::MonoTextStyleBuilder;
use embedded_graphics::mono_font::ascii::FONT_6X10;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::text::{Baseline, Text};
// For setting the Frequency
use hal::fugit::RateExtU32;
use hal::gpio::{FunctionI2C, Pin};
// SSD1306 Display
use ssd1306::{I2CDisplayInterface, Ssd1306, prelude::*};
}
Pin Configuration
We start by configuring the GPIO pins for the I2C communication. In this case, GPIO18 is set as the SDA pin, and GPIO19 is set as the SCL pin. We then configure the I2C peripheral to work in controller mode.
#![allow(unused)]
fn main() {
// Configure two pins as being I²C, not GPIO
let sda_pin: Pin<_, FunctionI2C, _> = pins.gpio16.reconfigure();
let scl_pin: Pin<_, FunctionI2C, _> = pins.gpio17.reconfigure();
// Create the I²C drive, using the two pre-configured pins. This will fail
// at compile time if the pins are in the wrong mode, or if this I²C
// peripheral isn't available on these pins!
let i2c = hal::I2C::i2c0(
pac.I2C0,
sda_pin,
scl_pin,
400.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);
}
Prepare Display
We create an interface for the OLED display using the I2C.
#![allow(unused)]
fn main() {
//helper struct is provided by the ssd1306 crate
let interface = I2CDisplayInterface::new(i2c);
// initialize the display
let mut display = Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display.init().expect("failed to initialize the display");
}
Set Text Style and Draw
Next, we define the text style and use it to display “Hello Rust” on the screen:
#![allow(unused)]
fn main() {
// Embedded graphics
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
Text::with_baseline(
"Hello, Rusty!",
Point::new(0, 16),
text_style,
Baseline::Top,
)
.draw(&mut display)
.expect("failed to draw text to display");
}
Here, we are writing the message at coordinates (x=0, y=16).
Write out data to a display
#![allow(unused)]
fn main() {
display.flush().expect("failed to flush data to display");
}
Complete code
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// Embedded Graphics
use embedded_graphics::mono_font::MonoTextStyleBuilder;
use embedded_graphics::mono_font::ascii::FONT_6X10;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::text::{Baseline, Text};
// For setting the Frequency
use hal::fugit::RateExtU32;
use hal::gpio::{FunctionI2C, Pin};
// SSD1306 Display
use ssd1306::{I2CDisplayInterface, Ssd1306, prelude::*};
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// External high-speed crystal on the Raspberry Pi Pico 2 board is 12 MHz.
/// Adjust if your board has a different frequency
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
// Grab our singleton objects
let mut pac = hal::pac::Peripherals::take().unwrap();
// Set up the watchdog driver - needed by the clock setup code
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Configure the clocks
//
// The default is to generate a 125 MHz system clock
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// The single-cycle I/O block controls our GPIO pins
let sio = hal::Sio::new(pac.SIO);
// Set the pins up according to their function on this particular board
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
// Configure two pins as being I²C, not GPIO
let sda_pin: Pin<_, FunctionI2C, _> = pins.gpio18.reconfigure();
let scl_pin: Pin<_, FunctionI2C, _> = pins.gpio19.reconfigure();
// Create the I²C drive, using the two pre-configured pins. This will fail
// at compile time if the pins are in the wrong mode, or if this I²C
// peripheral isn't available on these pins!
let i2c = hal::I2C::i2c1(
pac.I2C1,
sda_pin,
scl_pin,
400.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);
//helper struct is provided by the ssd1306 crate
let interface = I2CDisplayInterface::new(i2c);
// initialize the display
let mut display = Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display.init().expect("failed to initialize the display");
// Embedded graphics
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
Text::with_baseline(
"Hello, Rusty!",
Point::new(0, 16),
text_style,
Baseline::Top,
)
.draw(&mut display)
.expect("failed to draw text to display");
display.flush().expect("failed to flush data to display");
loop {
timer.delay_ms(100);
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone (or refer) project I created and navigate to the hello-oled folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/hello-oled
Draw Raw Image on OLED Display with Raspberry Pi Pico
In this exercise, we will draw a raw image using only byte arrays. We will create the Ohm (Ω) symbol in a 1BPP (1 Bit Per Pixel) format.
1BPP Image
The 1BPP (1 bit per pixel) format uses a single bit for each pixel. It can represent only two colors, typically black and white. If the bit value is 0, it will typically be full black. If the bit value is 1, it will typically be full white.
We will create the ohm symbol using an 8x5 pixel grid in 1bpp format. I have highlighted the 1’s in the byte array to show how they turn on the pixels to form the ohm symbol.
I chose 8 as the width to keep the example simple. This makes it easy to represent the 8 pixels width using a single byte (8 bits). But if you increase the width, it won’t fit in one byte anymore, so it will need to be spread across multiple elements in the byte array. I will explain this in later chapters. For now, let’s keep it simple.
Ohm symbol on the OLED Display (128x64)
Let me show you how it looks when the Ohm symbol is positioned on the OLED display (128x64 resolution) at position zero(x is 0 and y is also 0).

This is an enlarged illustration. When you see the symbol on the actual display module, it will be small.
Reference
- Embedded Graphics’ ImageRaw Documentation
- Image2Bytes: Convert image to Hex byte array
Using Single Byte
Drawing a Single Byte Image in Embedded Rust using embedded-graphics
By now, i hope you understand how the image is represented in the byte array. Now, let’s move on to the coding part.
Create Project
As usual, generate the project from the template with cargo-generate:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name like “byte-oled” and choose “embassy” as the HAL. Enable defmt logging, if you have a debug probe so you can view logs also.
Update Dependencies
Add the following lines to your Cargo.toml under dependencies:
embedded-graphics = "0.8.1"
ssd1306 = { version = "0.10.0", features = ["async"] }
Additional imports
Add these imports at the top of your main.rs:
#![allow(unused)]
fn main() {
// Interrupt Binding
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
image::{Image, ImageRaw},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
};
}
Boilerplate codes
We have already explained this part in the previous chapter.
Bind I2C Interrupt
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
Initialize I2C and Display instance
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; // 400kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
}
Create Your Image
We store our image as a byte array. Each byte represents one row of pixels.
#![allow(unused)]
fn main() {
// 8x5 pixels
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
0b00111000,
0b01000100,
0b01000100,
0b00101000,
0b11101110,
];
}
This creates an Ohm symbol (Ω) that’s 8 pixels wide and 5 pixels tall. Each 0b means we’re writing in binary using 1s and 0s. A 1 means the pixel is ON (white), and a 0 means the pixel is OFF (black). Each line represents one row of the image from top to bottom.
Draw the Image
Now let’s put the image on the display:
#![allow(unused)]
fn main() {
let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 8);
let image = Image::new(&raw_image, Point::zero());
}
The first line creates a raw image from our byte data. We tell it the image is 8 pixels wide, and it figures out the height by itself. The second line places the image at position (0, 0), which is the top-left corner of the screen.
Display the Image
Just like in the previous chapter, we need to draw the image to the display buffer and then send it to the screen:
#![allow(unused)]
fn main() {
image.draw(&mut display).expect("failed to draw text to display");
display.flush().await.expect("failed to flush data to display");
}
Clone the existing project
You can also clone (or refer) project I created and navigate to the byte-oled folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/oled/byte-oled
The Complete Code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// Interrupt Binding
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
image::{Image, ImageRaw},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
};
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
// 8x5 pixels
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
0b00111000,
0b01000100,
0b01000100,
0b00101000,
0b11101110,
];
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; // 400kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 8);
let image = Image::new(&raw_image, Point::zero());
image
.draw(&mut display)
.expect("failed to draw text to display");
display
.flush()
.await
.expect("failed to flush data to display");
loop {
Timer::after_millis(100).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"byte-oled"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Multi Byte
Using Multiple Bytes to Represent Wider Pixel Widths
In the previous example, we kept it simple by using an 8-pixel wide image. This made things easy because each row fit perfectly into a single byte. However, real images often need more pixels. So how do we represent them when one byte isn’t enough? The answer is simple: we use multiple bytes. But this creates a problem. If we’re using multiple bytes, how does the system know where one row ends and the next one begins?
This is exactly why we need to tell the embedded graphics crate the exact width of our image. When we specify the width, the system knows how many bytes to use for each row. Once it knows the width and the image format, it can figure out the height automatically.
Understanding the Math
Let’s look at an example with an image that’s 31 pixels wide and 7 pixels tall. The width is 31 pixels, and each pixel takes up 1 bit of space. To figure out how many bytes we need for each row, we do some simple math. Since a byte holds 8 bits, we divide 31 by 8. This gives us 3 complete bytes, which covers 24 pixels. But we still have 7 pixels left over, so we need one more byte to hold them. In total, we need 4 bytes to represent each row of 31 pixels. If the image has 7 rows then the total data length is 4 times 7, which is 28 bytes.
How the System Calculates Height
The embedded graphics crate uses code like this internally to calculate the height. You don’t need to add this to your own code. I’m showing it here just so you can see how it works behind the scenes:
#![allow(unused)]
fn main() {
let height = data.len() / bytes_per_row(width, C::Raw::BITS_PER_PIXEL);
//...
//...
const fn bytes_per_row(width: u32, bits_per_pixel: usize) -> usize {
(width as usize * bits_per_pixel + 7) / 8
}
}
In our example, the data array has 28 entries, each pixel uses 1 bit, and the image width is 31. When you run this calculation, you get 4 bytes per row and a height of 7 pixels.
Try It Yourself
You can run this code right here or in the Rust Playground to see how the calculation works:
// 31x7 pixel
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
// 1st row
0b00000001,0b11111111,0b11111111,0b00000000,
// 2nd row
0b00000001,0b11111111,0b11111111,0b00000000,
//3rd row
0b00000001,0b10000000,0b00000011,0b00000000,
//4th row
0b11111111,0b10000000,0b00000011,0b11111110,
//5th row
0b00000001,0b10000000,0b00000011,0b00000000,
//6th row
0b00000001,0b11111111,0b11111111,0b00000000,
//7th row
0b00000001,0b11111111,0b11111111,0b00000000,
];
const fn bytes_per_row(width: u32, bits_per_pixel: usize) -> usize {
(width as usize * bits_per_pixel + 7) / 8
}
fn main(){
const BITS_PER_PIXEL: usize = 1;
let width = 31;
let data = IMG_DATA;
println!("Bytes Per Row:{}", bytes_per_row(width,BITS_PER_PIXEL));
let height = data.len() / bytes_per_row(width, BITS_PER_PIXEL);
println!("Height: {}", height);
}
You dont need to manually create these byte array, you can use an online tool like imag2bytes to generate the byte array for you.
Using Multi Byte
Drawing a Multi-Byte Image in Embedded Rust using embedded-graphics
Now let’s write the code to display a wider image on our OLED screen. The main changes from the previous example are the image data and the width value. This time, we’ll display a resistor symbol in the IEC-60617 style.
Project base
We will copy the byte-oled project and work on top of that.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cp -r pico2-embassy-projects/oled/byte-oled ~/YOUR_PROJECT_FOLDER/oled-rawimg
or you can simply create a fresh project from the template and follow the same steps we used earlier.
Image Data
Here’s the byte array for the resistor symbol. Notice how each row needs multiple bytes because the image is 31 pixels wide.
#![allow(unused)]
fn main() {
// 31x7 pixel
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
// 1st row
0b00000001,0b11111111,0b11111111,0b00000000,
// 2nd row
0b00000001,0b11111111,0b11111111,0b00000000,
//3rd row
0b00000001,0b10000000,0b00000011,0b00000000,
//4th row
0b11111111,0b10000000,0b00000011,0b11111110,
//5th row
0b00000001,0b10000000,0b00000011,0b00000000,
//6th row
0b00000001,0b11111111,0b11111111,0b00000000,
//7th row
0b00000001,0b11111111,0b11111111,0b00000000,
];
}
Creating and Positioning the Image
We need to set the width to 31 pixels. We’ll draw the image at position (x=35, y=35). There’s no special reason for these coordinates. I just wanted to show you that you can place images anywhere on the screen, not just at point zero. Feel free to try different position values and see what happens.
#![allow(unused)]
fn main() {
let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 31);
let image = Image::new(&raw_image, Point::new(35, 35));
}
Clone the existing project
You can also clone (or refer) project I created and navigate to the oled-rawimg folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/oled/oled-rawimg
The full code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// Interrupt Binding
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{
image::{Image, ImageRaw},
pixelcolor::BinaryColor,
prelude::Point,
prelude::*,
};
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
// 31x7 pixel
#[rustfmt::skip]
const IMG_DATA: &[u8] = &[
// 1st row
0b00000001,0b11111111,0b11111111,0b00000000,
// 2nd row
0b00000001,0b11111111,0b11111111,0b00000000,
//3rd row
0b00000001,0b10000000,0b00000011,0b00000000,
//4th row
0b11111111,0b10000000,0b00000011,0b11111110,
//5th row
0b00000001,0b10000000,0b00000011,0b00000000,
//6th row
0b00000001,0b11111111,0b11111111,0b00000000,
//7th row
0b00000001,0b11111111,0b11111111,0b00000000,
];
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; // 400kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let raw_image = ImageRaw::<BinaryColor>::new(IMG_DATA, 31);
let image = Image::new(&raw_image, Point::new(35, 35));
image
.draw(&mut display)
.expect("failed to draw text to display");
display
.flush()
.await
.expect("failed to flush data to display");
loop {
Timer::after_millis(100).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"oled-rawimg"),
embassy_rp::binary_info::rp_program_description!(c"Multi Byte Image on OLED"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Using Bitmap Image file
You can use BMP (.bmp) files directly instead of raw image data by utilizing the tinybmp crate. tinybmp is a lightweight BMP parser designed for embedded environments. While it is mainly intended for drawing BMP images to embedded_graphics DrawTargets, it can also be used to parse BMP files for other applications.
BMP file
The crate requires the image to be in BMP format. If your image is in another format, you will need to convert it to BMP. For example, you can use the following command on Linux to convert a PNG image to a monochrome BMP:
convert ferris.png -monochrome ferris.bmp
I have created the Ferris BMP file, which you can use for this exercise. Download it from here.
Project base
We will copy the oled-rawimg project and work on top of that.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cp -r pico2-embassy-projects/oled/oled-rawimg ~/YOUR_PROJECT_FOLDER/oled-bmp
or you can simply create a fresh project from the template and follow the same steps we used earlier.
Update Cargo.toml
We need one more crate called “tinybmp” to load the bmp image.
tinybmp = "0.6.0"
Using the BMP File
Place the “ferris.bmp” file inside the src folder. The code is pretty straightforward: load the image as bytes and pass it to the from_slice function of the Bmp. Then, you can use it with the Image.
#![allow(unused)]
fn main() {
// the usual boilerplate code goes here...
// Include the BMP file data.
let bmp_data = include_bytes!("../ferris.bmp");
// Parse the BMP file.
let bmp = Bmp::from_slice(bmp_data).unwrap();
// usual code:
let image = Image::new(&bmp, Point::new(32, 0));
image
.draw(&mut display)
.expect("failed to draw text to display");
defmt::info!("Displaying image");
display.flush().await.expect("failed to flush data to display");
}
Clone the existing project
You can also clone (or refer) project I created and navigate to the oled-bmp folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/oled/oled-bmp
Full code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// Interrupt Binding
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// OLED
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// Embedded Graphics
use embedded_graphics::{image::Image, prelude::Point, prelude::*};
use tinybmp::Bmp;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; // 400kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
// Include the BMP file data.
let bmp_data = include_bytes!("../ferris.bmp");
// Parse the BMP file.
let bmp = Bmp::from_slice(bmp_data).unwrap();
// usual code:
let image = Image::new(&bmp, Point::new(32, 0));
image
.draw(&mut display)
.expect("failed to draw text to display");
defmt::info!("Displaying image");
display
.flush()
.await
.expect("failed to flush data to display");
loop {
Timer::after_millis(100).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"oled-rawimg"),
embassy_rp::binary_info::rp_program_description!(c"Multi Byte Image on OLED"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
LCD Display
In this section, we will work with Hitachi HD44780 compatible LCD (Liquid Crystal Display) modules. These character LCDs are extremely common and have been used for decades in everyday devices such as printers, digital clocks, microwaves, washing machines, air conditioners, and other home appliances. You will also find them in office equipment like copiers, fax machines, and network routers.
These displays are designed to show ASCII characters, and they also support up to 8 custom characters that you can define yourself.
Variants
HD44780-compatible LCDs come in different physical formats. The most common ones are 16x2 displays, which have 16 columns and 2 rows, and 20x4 displays, which have 20 columns and 4 rows. They also differ in backlight color, such as blue, yellow, or green.
LCD Interfaces
HD44780-based LCDs are typically used in one of two ways. The difference is not in the display itself, but in how control signals reach the controller.
At its core, the HD44780 controller uses a parallel interface. This is the native and most direct way to communicate with the LCD. Many modules expose this interface directly through their 16-pin header.
To simplify wiring, some modules include an I2C adapter board. This adapter sits between the microcontroller and the LCD and converts I2C commands into the same parallel signals expected by the HD44780 controller.
Parallel Interface
When we use the parallel interface, we connect multiple GPIO pins from the microcontroller directly to the LCD. These pins carry control signals and data lines, along with power and contrast control.
This approach requires more wiring and uses many GPIO pins, but it closely reflects how the HD44780 controller operates internally. It is useful for understanding the timing, commands, and low-level behavior of the display.
I2C Interface
There are variants that include an I2C adapter mounted on the back, and you can also add one later if needed.
With an I2C adapter, communication happens over just two signal lines. This reduces the number of required connections and makes wiring much simpler. Most I2C adapters include an inbuilt potentiometer for contrast control. Because of this, we do not need an external potentiometer or resistors when using the I2C variant.
The I2C variant is slightly more expensive (obviously) than the parallel version, but it remains affordable and widely available.
In This Book
In this book, we will be using the I2C version. Originally, I was using the parallel interface because I did not know what to buy. However, the wiring quickly became difficult. I had to connect around 12 wires, whereas the I2C version requires only 4 wires.
There is also additional overhead when using the parallel interface, such as setting up a potentiometer or a voltage divider circuit to control the contrast. With the I2C version, this extra setup is not required.
Hardware Requirements
We will need an LCD1602 display. A 16x2 module with an I2C adapter is recommended so you can follow along without adjustments, although other sizes behave the same way.
Level Shifter
Pico GPIO pins are 3.3 V tolerant, which means they are not safe to use with 5 V signals. Applying a higher voltage, such as 5 V, to these pins can damage the board. Many LCD1602 displays with an I2C adapter are designed to operate at 5 V, which creates a voltage mismatch when connecting them directly to the Pico.
To connect the Pico and the LCD safely, we need to handle this voltage difference. This is where a level shifter is used. A bidirectional I2C logic level shifter allows 3.3 V and 5 V devices to communicate safely and protects the Pico GPIO pins. These modules are inexpensive and are commonly sold as “4 Channel (I2C) 3.3V-5V Bi-Directional Logic Level Converter”.
Alternatively, you can power the LCD with 3.3 V. This avoids the voltage issue, but the display backlight and contrast will be noticeably dimmer.
Datasheet
- You can access the datasheet for the HD44780 from Sparkfun or MIT site
- LCD Driver Data Book
- LCD Module 1602A Datasheet
How it works?
A Liquid Crystal Display (LCD) works by using liquid crystals to control how light passes through the screen. When we apply electricity, the liquid crystals change their orientation. This change either allows light to pass through or blocks it. By controlling which areas allow light and which block it, the LCD can display characters and symbols.
The screen itself does not emit light. Instead, a backlight behind the display provides illumination. The liquid crystals selectively block this backlight to create dark areas, which form the visible characters on the screen.
16x2 LCD Display and 5x8 Pixel Matrix
A 16x2 LCD has 2 rows and 16 columns, so it can display a total of 32 characters at once. Each character on the screen is built from a 5x8 pixel matrix. That means every character is formed using 5 vertical columns and 8 horizontal rows of tiny dots.
These dots turn on and off to form letters, numbers, and symbols.
Displaying Text and Custom Characters on 16x2 LCD
We do not need to draw individual pixels when displaying normal text. This is handled automatically by the HD44780 controller. When we send an ASCII character, the controller looks up the corresponding 5x8 pattern and displays it on the screen.
If we want to display custom symbols, such as icons or special characters, we can define our own 5x8 pixel patterns. These patterns are stored in the LCD memory, and once defined, we can display them like regular characters. One important limitation is that the LCD can store only 8 custom characters at a time.
Data Transfer Mode
The HD44780 controller supports two data transfer modes: 8-bit mode and 4-bit mode.
When using the parallel interface, 8-bit mode sends a full byte at once using all data pins. This is faster, but it requires many GPIO pins. In 4-bit mode, the same data is sent in two steps using only four data pins. This reduces wiring at the cost of a small performance penalty.
When using an I2C adapter, the adapter board drives the LCD using the 4-bit parallel interface internally. We do not need to configure this ourselves, because the adapter handles it automatically.
To keep wiring simple and practical, we will use 4-bit mode.
Adjust the contrast
When we power on the LCD, we should see the dot matrix on the screen. If the text is not clearly visible after running the program, the contrast needs adjustment.
When using an I2C LCD module, we can adjust the small potentiometer on the I2C adapter board to set the contrast.
Turning this potentiometer slowly will make the characters clearer or darker until they are easy to read.
Pin Layout
Pin Layout
When using the parallel interface, the LCD exposes a total of 16 pins. These pins provide power, contrast control, control signals, data lines, and backlight connections.
In the I2C interface, these signals are simplified and exposed through fewer pins. We will first look at the I2C variant, followed by the parallel interface.
I2C Pin Layout
The I2C adapter simplifies the connection by converting I2C commands into parallel signals internally. From the microcontroller side, we only need power and the two I2C lines.
| Pin | Label | Description |
|---|---|---|
| 1 | VCC | Power supply (typically 5V) |
| 2 | GND | Ground |
| 3 | SDA | Serial Data Line for I2C communication |
| 4 | SCL | Serial Clock Line for I2C communication |
Parallel Interface Pin Layout
In the parallel interface, the microcontroller talks directly to the HD44780 controller. This gives more control but requires more wiring and careful timing.
| Pin Position | LCD Pin | Details |
|---|---|---|
| 1 | VSS | Ground (GND). |
| 2 | VDD | Power supply for the LCD logic, typically 5V. |
| 3 | Vo |
Contrast control pin. - This pin expects an analog voltage between GND and VDD. - Recommended: Use a 10k potentiometer as a voltage divider, with the wiper connected to Vo and the other two pins to VDD and GND. - Alternative: Use fixed resistors as a voltage divider between VDD and GND, with the midpoint connected to Vo. |
| 4 | RS |
Register Select: - LOW (RS = 0): Instruction or command register. - HIGH (RS = 1): Data register. |
| 5 | RW |
Read or Write control: - LOW (RW = 0): Write to LCD. - HIGH (RW = 1): Read from LCD. - Commonly tied to GND for write-only operation. |
| 6 | E | Enable pin. Data or commands are latched on the HIGH to LOW transition of this pin. |
| 7–10 | D0–D3 | Lower data bits. Used only in 8-bit mode. Leave unconnected when using 4-bit mode. |
| 11–14 | D4–D7 | Higher data bits. Used for data transfer in both 4-bit and 8-bit modes. In 4-bit mode, all data is sent using only these pins. |
| 15 | A | Backlight anode. Often connected to 5V. Some modules include an onboard current-limiting resistor. |
| 16 | K | Backlight cathode. Connect to GND. |
Contrast Adjustment
The Vo pin controls the contrast of the LCD by setting the voltage difference between VDD and Vo.
Lower Vo values increase contrast, while higher values reduce it.
The recommended approach is to use a potentiometer connected between VDD and GND, with the wiper connected to Vo. This allows easy adjustment while the LCD is powered.
If a potentiometer is not available, fixed resistors can be used as a voltage divider between VDD and GND, with the midpoint connected to Vo.
Register Select Pin (RS)
The RS pin selects whether the LCD interprets incoming values as commands or as character data.
- RS = LOW: command mode
- RS = HIGH: data mode
Enable Pin (E)
The Enable pin controls when data is latched into the LCD.
To send data or a command, place the value on the data pins, set RS appropriately, then pulse E HIGH and bring it back LOW. The LCD reads the data on the HIGH to LOW transition.
Connecting LCD Display (LCD1602) to the Raspberry Pi Pico
We are going to connect an LCD1602 character display fitted with an I2C adapter to the Raspberry Pi Pico. From a wiring point of view, the setup looks simple because only four connections are required: power, ground, SDA, and SCL. However, even though the wiring count is small, there is an important voltage detail that we must handle correctly before making any connections.
Voltage compatibility problem
The Raspberry Pi Pico’s GPIO pins are 3.3V tolerant. Anything significantly above 3.3 V on SDA or SCL can damage the Pico, either immediately or gradually over time.
Most LCD1602 modules with an I2C backpack are designed to run at 5 V. The I2C backpack usually has pull-up resistors connected to its supply voltage. When powered at 5 V, this means SDA and SCL idle at 5 V.
If SDA and SCL from such a module are connected directly to the Pico, the Pico GPIO pins will be exposed to 5 V. This is the core problem we must address.
A commonly suggested shortcut you will see online
Many online tutorials suggest powering the LCD1602 and its I2C backpack from 5 V and connecting SDA and SCL directly to the Pico. I have tested this setup myself, and it does work. However, it is electrically unsafe, and long-term use can damage the Pico GPIO pins.
For this reason, even though it functions, this wiring method should not be considered safe or recommended.
The lazy but reasonably safe approach: power everything at 3.3 V
For demos, experiments, and learning projects, the simplest and safest approach is to power the LCD1602 I2C module from the Pico 3.3 V rail instead of 5 V.
When the LCD backpack is powered at 3.3 V, its I2C pull-up resistors pull SDA and SCL to 3.3 V instead of 5 V. This immediately removes the voltage compatibility problem, and SDA and SCL can be connected directly to the Pico.
The trade-off is that the LCD contrast and backlight brightness will be reduced. In most indoor environments, the display remains readable and is usually more than adequate for demonstrations and testing.
This approach avoids extra components, and avoids stressing the Pico GPIO pins.
| LCD Pin | Wire | Pico Pin | Notes |
|---|---|---|---|
| GND |
|
GND | Common ground |
| VCC |
|
3.3V | 3.3 power supply for the LCD |
| SCL |
|
GPIO 17 | I2C clock line (I2C0 SCL) |
| SDA |
|
GPIO 16 | I2C data line (I2C0 SDA) |
Best approach: Using a level shifter
If you need full backlight brightness, or if your LCD module does not work reliably at 3.3 V, then you must power it at 5 V. But this means you need to protect your Pico from the 5V signals.
The solution is a bidirectional logic level converter (also called a level shifter). This small module converts signals between 3.3 volts and 5 volts in both directions. You can find these as “2-Channel” or “4-Channel I2C/SPI Logic Level Converter” modules.
The Pico connects to the 3.3V side of the level shifter, the LCD connects to the 5V side. The level shifter makes sure the Pico only ever sees 3.3V signals, while the LCD gets the 5V signals it needs.
This is the electrically correct and safe method, but it adds a few extra wires and requires an additional module.
The circuit diagram may look a little confusing at first glance, but the idea is simple once you break it down.
Power Connections (Connect These First)
First, connect the Pico 3.3 V output to the pin marked LV(Low Voltage) on the level shifter. Connect the Pico VBUS (5 V) to the pin marked HV(High Voltage) on the level shifter. The LCD VCC pin is also powered directly from the Pico VBUS pin. Ground must be common, so connect Pico GND, the level shifter GND, and the LCD GND together.
| From | Pin | Wire | To | Pin | Purpose |
|---|---|---|---|---|---|
| Raspberry Pi Pico | GND |
|
Level Shifter | GND | Common ground |
| Level Shifter | GND |
|
LCD Display | GND | Common ground |
| Raspberry Pi Pico | 3.3V |
|
Level Shifter | LV | Low voltage power (3.3V side) |
| Raspberry Pi Pico | VBUS (5V) |
|
Level Shifter | HV | High voltage power (5V side) |
| Raspberry Pi Pico | VBUS (5V) |
|
LCD Display | VCC | 5V power supply for the LCD |
Data Connections (I2C Communication)
Now coming to the I2C lines. The Pico pins must always connect to the pins on the level shifter marked LVx, and the LCD pins must always connect to the corresponding pins marked HVx. Pico GPIO 16 (SDA) connects to LV1, and the LCD SDA pin connects to HV1. Pico GPIO 17 (SCL) connects to LV2, and the LCD SCL pin connects to HV2.
| From | Pin | Wire | To | Pin | Purpose |
|---|---|---|---|---|---|
| Raspberry Pi Pico | GPIO 16 |
|
Level Shifter | LV1 | SDA (Data) - Pico side |
| Level Shifter | HV1 |
|
LCD Display | SDA | SDA (Data) - LCD side |
| Raspberry Pi Pico | GPIO 17 |
|
Level Shifter | LV2 | SCL (Clock) - Pico side |
| Level Shifter | HV2 |
|
LCD Display | SCL | SCL (Clock) - LCD side |
How it works?
In simple terms, when the Pico communicates over an I2C line, the LVx side operates at 3.3 V, and the level shifter presents a 5 V signal on the matching HVx side for the LCD. When the LCD communicates at 5 V, the level shifter translates that signal back so the Pico only ever sees 3.3 V on the LVx side. This way, both devices operate at their required voltages without stressing the Pico GPIO pins.
“Hello, Rust!” in LCD Display
We will create a simple program that prints “Hello, Rust!” on the LCD screen. This helps us quickly check that the wiring, I2C setup, and LCD configuration are correct before moving on to the next exercise.
HD44780 Drivers
You can find driver crates by searching for the hardware controller name HD44780. Sometimes searching by the display module name, such as lcd1602, also works.
While looking around, I came across several Rust crates that can control this LCD. Some of them even support async. You could also write your own driver by referring to the datasheet, but that is beyond the scope of this chapter.
Tip
If you want to learn how to write your own embedded Rust drivers, you can refer to the Rust Embedded Drivers (RED) book here: [https://red.implrust.com/]
For now, we will use one of the existing crates. You are free to try other crates later. Just read the crate documentation and adapt the code if needed.
In this exercise, we will use this crate: hd44780-driver (https://red.implrust.com/)
Project from template
We will start by creating a new project using the template.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name, like “hello-lcd” and select embassy as the HAL.
Additional Crates required
Add the following dependency to Cargo.toml along with the existing ones:
#![allow(unused)]
fn main() {
hd44780-driver = "0.4.0"
}
Additional imports
Add the imports required for I2C and the LCD driver.
#![allow(unused)]
fn main() {
// I2C
use embassy_rp::i2c::Config as I2cConfig;
use embassy_rp::i2c::{self}; // for convenience, importing as alias
// LCD Driver
use hd44780_driver::HD44780;
use embassy_time::Delay;
}
I2C Address
LCD1602 I2C adapters typically use address 0x27, though some modules use 0x3F instead depending on the adapter. Check your module’s datasheet or try both addresses if you’re unsure.
#![allow(unused)]
fn main() {
const LCD_I2C_ADDRESS: u8 = 0x27;
}
I2C Setup
We’ll configure the I2C interface using GPIO 16 for SDA and GPIO 17 for SCL, with a frequency of 100 kHz.
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 100_000; //100kHz
let i2c = i2c::I2c::new_blocking(p.I2C0, scl, sda, i2c_config);
}
LCD Initialization
Now let’s create the LCD driver instance with our I2C interface:
#![allow(unused)]
fn main() {
// LCD Init
let mut lcd =
HD44780::new_i2c(i2c, LCD_I2C_ADDRESS, &mut Delay).expect("failed to initialize lcd");
}
Clear the Display
Before we write anything, we’ll reset and clear the screen:
#![allow(unused)]
fn main() {
// Clear the screen
lcd.reset(&mut Delay).expect("failed to reset lcd screen");
lcd.clear(&mut Delay).expect("failed to clear the screen");
}
Write Text to the LCD
Finally, let’s write our message to the LCD:
#![allow(unused)]
fn main() {
// Write to the top line
lcd.write_str("Hello, Rust!", &mut Delay)
.expect("failed to write text to LCD");
}
Clone the existing project
You can clone (or refer) project I created and navigate to the hello-lcd folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/lcd/hello-lcd/
rp-hal version
You can clone (or refer) project I created and navigate to the hello-lcd folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/lcd/hello-lcd/
Supported Characters
Supported Characters
When referring to the HD44780 datasheet, you’ll find two character set tables corresponding to two different ROM versions(A00 and A02). To determine which ROM your display uses, try unique characters from both tables. The one that displays correctly indicates the ROM version. Once identified, you only need to refer to the relevant table.
In my case, the LCD module I’m using is based on ROM version A00. I’ll present the A00 table and explain how to interpret it, though the interpretation logic is the same for both versions.
It’s an 8-bit character, where the upper 4 bits come first, followed by the lower 4 bits, to form the complete character byte. In the reference table, the upper 4 bits correspond to the columns, while the lower 4 bits correspond to the rows.
For example, to get the binary representation of the character “#,” the upper 4 bits are 0010, and the lower 4 bits are 0011. Combining them gives the full binary value 00100011. In Rust, you can represent this value either in binary (0b00100011) or as a hexadecimal (0x23).
hd44780-driver crate
In the hd44780-driver crate we are using, we can write characters directly as a single byte or a sequence of bytes.
Write single byte
#![allow(unused)]
fn main() {
lcd.write_byte(0x23, &mut timer).unwrap();
lcd.write_byte(0b00100011, &mut timer).unwrap();
}
Write multiple bytes
#![allow(unused)]
fn main() {
lcd.write_bytes(&[0x23, 0x24], &mut timer).unwrap();
}
Custom Glyphs
Besides the supported characters, you can create your own custom characters(glyphs), like smileys or heart symbols. The module includes 64 bytes of Character Generator RAM (CGRAM), allowing up to 8 custom glyphs.
The controller provides 64 bytes of Character Generator RAM (CGRAM). Each custom glyph occupies 8 bytes, so you can store up to 8 custom glyphs at a time.
Each glyph is an 8x8 grid, where each row is represented by a single 8-bit value (u8). This makes it 8 bytes per glyph (8 rows × 1 byte per row). That is why, with a total of 64 bytes, you can only store up to 8 custom glyphs (8 glyphs × 8 bytes = 64 bytes).
Note: If you recall, in our LCD module, each character is represented as a 5x8 grid. But wait, didn’t we say we need an 8x8 grid for the characters? Yes, that’s correct-we need 8 x 8 (8 bytes) memory, but we only use 5 bits in each row. The 3 high-order bits in each row are left as zeros.
Generator
LCD Custom Character Generator (5x8 Grid)
Select the grids to create a symbol or character. As you select the grid, the corresponding bits in the byte array will be updated.
Generated Array
Display on LCD
Ferris on LCD Display
In this section, we will draw Ferris on a character LCD. This is my attempt at making it look like a crab. If you come up with a better design, feel free to send a pull request.
Although a single custom character is limited to one 5x8 cell, we are not restricted to just one cell. By combining 4 or even 6 adjacent grids, we can display a larger symbol. How far you take this is entirely up to your creativity.
We will use the custom glyph generator from the previous page to design Ferris. The generator produces the byte array that we can directly use in our code.
liquid_crystal crate
The hd44780-driver crate that we used earlier does not support defining custom glyphs. To work with custom glyphs stored in CGRAM, we will use the liquid_crystal crate.
This crate supports custom glyphs and also provides an async API, which we will use in this chapter.
Update Cargo.toml
Enable the async feature when adding the dependency:
liquid_crystal = { version = "0.2.0", features = ["async"] }
Additional imports
Add these imports at the top of your main.rs:
#![allow(unused)]
fn main() {
// Interrupt Binding
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// LCD Driver
use liquid_crystal::I2C;
use liquid_crystal::LiquidCrystal;
use liquid_crystal::prelude::*;
use embassy_time::Delay;
}
Bind I2C Interrupt
Bind the I2C0_IRQ interrupt to the Embassy I2C interrupt handler for I2C0:
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
Initialize I2C
First, set up the I2C bus to communicate with the display:
#![allow(unused)]
fn main() {
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 100_000; // 100kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
}
Initialize the LCD interface
Once the I2C interface is set up, we initialize the LCD.
#![allow(unused)]
fn main() {
// LCD Init
let mut i2c_interface = I2C::new(i2c_bus, 0x27);
let mut lcd = LiquidCrystal::new(&mut i2c_interface, Bus4Bits, LCD16X2);
lcd.begin(&mut Delay);
}
Generated byte array for the custom glyph
#![allow(unused)]
fn main() {
const FERRIS: [u8; 8] = [
0b01010, 0b10001, 0b10001, 0b01110, 0b01110, 0b01110, 0b11111, 0b10001,
];
// Define the character
lcd.custom_char(&mut Delay, &FERRIS, 0);
}
Displaying
Displaying the character is straightforward. You just need to use the CustomChar enum and pass the index of the custom character. We’ve defined only one custom character, which is at position 0.
#![allow(unused)]
fn main() {
lcd.write(&mut Delay, CustomChar(0));
lcd.write(&mut Delay, Text(" implRust!"));
}
Clone the existing project
You can clone (or refer) project I created and navigate to the custom-glyph folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/lcd/custom-glyph/
rp-hal version
You can clone (or refer) project I created and navigate to the custom-glyph folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/lcd/custom-glyph/
The Full code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// Interrupt Binding
use embassy_rp::peripherals::I2C0;
use embassy_rp::{bind_interrupts, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// LCD Driver
use liquid_crystal::I2C;
use liquid_crystal::LiquidCrystal;
use liquid_crystal::prelude::*;
use embassy_time::Delay;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
// const LCD_I2C_ADDRESS: u8 = 0x27;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 100_000; //100kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
// LCD Init
let mut i2c_interface = I2C::new(i2c_bus, 0x27);
let mut lcd = LiquidCrystal::new(&mut i2c_interface, Bus4Bits, LCD16X2);
lcd.begin(&mut Delay);
const FERRIS: [u8; 8] = [
0b01010, 0b10001, 0b10001, 0b01110, 0b01110, 0b01110, 0b11111, 0b10001,
];
// Define the character
lcd.custom_char(&mut Delay, &FERRIS, 0);
lcd.write(&mut Delay, CustomChar(0));
// normal text
lcd.write(&mut Delay, Text(" implRust!"));
loop {
Timer::after_millis(100).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"custom-chars"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Multi Generator
Multi-Cell Custom Glyph Generator
This is used when you want to combine multiple grids to create a symbol. You can utilize adjacent grids on the 16x2 LCD display to design a custom symbol or character. You can view the example symbol created with this generator and how to use in Rust in the next page.
Generated Array
Multi Custom
Multi-Cell Custom Glyph
In this section, we create Ferris using six adjacent grids with the generator from the previous page. Below is the Rust code that uses the generated byte arrays to render the glyph on the LCD.
We will focus only on the glyph composition here. Project setup and LCD initialization remain the same as before and are not repeated in this section.
Generated Byte array for the characters
#![allow(unused)]
fn main() {
const SYMBOL1: [u8; 8] = [
0b00110, 0b01000, 0b01110, 0b01000, 0b00100, 0b00011, 0b00100, 0b01000,
];
const SYMBOL2: [u8; 8] = [
0b00000, 0b00000, 0b00000, 0b10001, 0b10001, 0b11111, 0b00000, 0b00000,
];
const SYMBOL3: [u8; 8] = [
0b01100, 0b00010, 0b01110, 0b00010, 0b00100, 0b11000, 0b00100, 0b00010,
];
const SYMBOL4: [u8; 8] = [
0b01000, 0b01000, 0b00100, 0b00011, 0b00001, 0b00010, 0b00101, 0b01000,
];
const SYMBOL5: [u8; 8] = [
0b00000, 0b00000, 0b00000, 0b11111, 0b01010, 0b10001, 0b00000, 0b00000,
];
const SYMBOL6: [u8; 8] = [
0b00010, 0b00010, 0b00100, 0b11000, 0b10000, 0b01000, 0b10100, 0b00010,
];
}
Declare them as character
Each glyph is stored in a separate CGRAM slot. We use slots 0 through 5 for this example.
#![allow(unused)]
fn main() {
lcd.custom_char(&mut Delay, &SYMBOL1, 0);
lcd.custom_char(&mut Delay, &SYMBOL2, 1);
lcd.custom_char(&mut Delay, &SYMBOL3, 2);
lcd.custom_char(&mut Delay, &SYMBOL4, 3);
lcd.custom_char(&mut Delay, &SYMBOL5, 4);
lcd.custom_char(&mut Delay, &SYMBOL6, 5);
}
Display
We write the first three glyphs on the first row, followed by the remaining three glyphs on the second row, aligning them to form a single composite symbol.
#![allow(unused)]
fn main() {
lcd.set_cursor(&mut Delay, 0, 4)
.write(&mut Delay, CustomChar(0))
.write(&mut Delay, CustomChar(1))
.write(&mut Delay, CustomChar(2));
lcd.set_cursor(&mut Delay, 1, 4)
.write(&mut Delay, CustomChar(3))
.write(&mut Delay, CustomChar(4))
.write(&mut Delay, CustomChar(5));
}
Clone the existing project
You can clone (or refer) project I created and navigate to the mutli-glyph folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/lcd/mutli-glyph/
rp-hal version
A version using rp-hal is also available:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/lcd/mutli-glyph/
Symbols Index
Here is a list of custom symbols with their corresponding byte arrays. If you’ve designed an interesting symbol and want to add to this list, feel free to submit a pull request. Please use the custom character generator provided here to ensure consistency.
| Title | Preview | Byte Array |
|---|---|---|
| Heart | ![]() |
[ 0b00000, 0b01010, 0b11111, 0b11111, 0b01110, 0b00100, 0b00000, 0b00000,] |
| Lock | ![]() |
[ 0b01110, 0b10001, 0b10001, 0b11111, 0b11011, 0b11011, 0b11011, 0b11111, ] |
| Hollow Heart | ![]() |
[ 0b00000, 0b01010, 0b10101, 0b10001, 0b10001, 0b01010, 0b00100, 0b00000, ] |
| Battery | ![]() |
[ 0b01110, 0b11011, 0b10001, 0b10001, 0b10001, 0b11111, 0b11111, 0b11111, ] |
| Bus | ![]() |
[ 0b01110, 0b11111, 0b10001, 0b10001, 0b11111, 0b10101, 0b11111, 0b01010, ] |
| Bell | ![]() |
[ 0b00100, 0b01110, 0b01110, 0b01110, 0b11111, 0b00000, 0b00100, 0b00000, ] |
| Hour Glass | ![]() |
[ 0b00000, 0b11111, 0b10001, 0b01010, 0b00100, 0b01010, 0b10101, 0b11111, ] |
| Charger | ![]() |
[ 0b01010, 0b01010, 0b11111, 0b10001, 0b10001, 0b01110, 0b00100, 0b00100, ] |
| Tick Mark | ![]() |
[ 0b00000, 0b00000, 0b00001, 0b00011, 0b10110, 0b11100, 0b01000, 0b00000, ] |
| Music Note | ![]() |
[ 0b00011, 0b00010, 0b00010, 0b00010, 0b00010, 0b01110, 0b11110, 0b01110, ] |
ADC (Analog to Digital Converter)
I hope you are already familiar with the idea of analog and digital signals. The world around us is mostly analog. Temperature rises and falls smoothly, light intensity changes gradually, sound moves through the air as waves, and sensors produce voltages that change continuously.
Microcontrollers, however, only understand discrete values. Internally, everything is represented as binary numbers, ones and zeros, HIGH and LOW. This creates a basic gap between the real world and digital logic. How does a digital system understand something that changes smoothly?
This is where the Analog to Digital Converter(ADC) comes in. An Analog to Digital Converter (ADC) measures an analog voltage and converts it into a digital number that our microcontrollers can process, store, and act upon.
You may recall from the earlier chapter on PWM that we explored how a digital system can create an analog-like output by rapidly switching a pin on and off. ADC does the opposite. It allows the microcontroller to read and understand analog inputs from the real world. Together, peripherals like PWM and ADC allow embedded systems to both sense their environment and control it.
Consider a simple example: a temperature monitoring system. A temperature sensor outputs a voltage that changes with temperature, for example 0 V at 0°C and 3.3 V at 100°C. Without an ADC, this smooth voltage signal would be meaningless to the microcontroller. The ADC samples the voltage at regular intervals and converts each sample into a digital value. Your code can then read that value, interpret it as a temperature, and respond accordingly.
ADC Resolution
One of the most important characteristics of an ADC is its resolution, which determines how finely it can divide the analog input range into discrete digital values. Resolution is typically expressed in bits, such as 8-bit, 10-bit, or 12-bit. The number of bits decides how many different values the ADC can produce.
An 8-bit ADC divides its input voltage range into 256 distinct levels(2^8). A 10-bit ADC provides 1,024 levels(2^10), and a 12-bit ADC offers 4,096 levels(2^12). As you can see, each additional bit doubles the number of levels, significantly improving precision. The more bits your ADC has, the smaller the voltage differences it can distinguish.
Example
Let’s make this concrete with an example. Suppose you are using a 10-bit ADC with a reference voltage of 3.3V. The ADC will represent voltages from 0V to 3.3V as digital values from 0 to 1,023.
The smallest voltage change the ADC can detect (called the step size) is calculated as:
\[ \text{Step Size} = \frac{\text{Vref}}{2^{\text{bits}}} \]
Step Size = 3.3V / 1,024 = 3.22 mV
This means the ADC cannot tell the difference between two voltages that are closer than about 3.22 mV. For example, if a sensor output changes from 1.500 V to 1.502 V, the ADC will likely report the same digital value for both.
Compare this to a 12-bit ADC with the same 3.3V reference:
Step Size = 3.3V / 4,096 = 0.81 mV
The 12-bit converter offers significantly finer precision, which can be critical in applications requiring accurate measurements.
ADC Formula
Conceptually, an ADC converts an input voltage into a digital number using the following relationship:
\[ ADC = \frac{V_{in}}{V_{ref}} \times 2^{\text{bits}} \]
Where:
- \(V_{in}\) is the input voltage
- \(V_{ref}\) is the reference voltage
Converting ADC Values to Voltage
When you read from the ADC in your code, it returns a raw digital value between 0 and 4095 (for a 12-bit ADC). This number represents where the measured voltage falls within the 0V to 3.3V range. To convert this raw value back into an actual voltage, you need a simple calculation.
The conversion formula is:
\[ \text{Voltage} = \frac{\text{ADC Reading} \times \text{Reference Voltage}}{2^{\text{bits}}} \]
For the RP2350 with its 12-bit ADC and 3.3V reference voltage:
\[ \text{Voltage} = \frac{\text{ADC Reading} \times 3.3}{4096} \]
Example
Let’s say we get an ADC reading of 1000.
Voltage = (1000 × 3.3) / 4096 = 0.806V
Reference
In RP2350
The RP2350 microcontroller features a built-in 12-bit ADC with multiple input channels, giving you 4,096 distinct levels to represent voltages from 0V to 3.3V. With the 3.3V reference voltage (ADC_AVDD), this produces a step size of approximately 0.806 mV. You can refer to page 1066 of the datasheet for more details: RP2350 datasheet
On the RP2350 chip, the ADC uses a pin called ADC_AVDD. This pin powers the ADC and also acts as the reference voltage for conversions. The value on this pin sets the maximum voltage the ADC can measure. In practice, ADC_AVDD is usually connected to 3.3 V. Because the ADC is sensitive to small voltage changes, this supply should be clean and stable to get reliable readings.
On the Raspberry Pi Pico 2 board, this is already taken care of for you. The board connects the 3.3 V supply to the RP2350 ADC_AVDD pin through a small filter made from a resistor and a capacitor. This helps reduce noise and improves ADC behavior.
The Pico 2 also exposes this filtered reference on a pin labeled ADC_VREF (physical pin 35). By default, you do not need to touch this pin. You can simply use the ADC as is. For more advanced use cases, this pin allows you to supply an external precision reference voltage if you need higher accuracy, but this is optional and not required for beginner projects
For now, it is enough to remember that the RP2350 ADC is 12-bit, measures voltages up to its reference voltage, and that the Pico 2 board already provides a safe and stable default setup.
ADC Channels and GPIO Mapping on Raspberry Pi Pico 2
The RP2350 ADC has multiple internal channels. On the Raspberry Pi Pico 2, four of these channels are connected to GPIO pins that can be used as analog inputs.
| GPIO Pin | ADC Channel | Function |
|---|---|---|
| GPIO26 | ADC0 | Can be used to read voltage from peripherals. |
| GPIO27 | ADC1 | Can be used to read voltage from peripherals. |
| GPIO28 | ADC2 | Can be used to read voltage from peripherals. |
| GPIO29 | ADC3 | Measures the VSYS supply voltage on the board. |
Voltage Divider with ADC
Sometimes you need to measure voltages higher than the ADC’s reference voltage (3.3V). For example, measuring a 5V power supply, a 9V battery, or a 12V system. Since the RP2350 ADC can only safely measure up to 3.3V, you need to scale down the voltage using a voltage divider.
We have already seen how the voltage divider works and how two resistors can be used to scale a voltage. If you need a refresher on how voltage dividers work, refer back to the voltage divider section. Here, we will focus on how a voltage divider interacts with an ADC and how the divider output turns into an ADC reading.
Tip
Voltage dividers are not just for scaling voltages. Sensors like LDRs and thermistors are resistors whose values change with light or temperature. When used in a voltage divider, these resistance changes turn into voltage changes that the ADC can read.
Using Voltage Dividers with ADC
When using a voltage divider with an ADC, the goal is usually to scale a voltage so that it stays within the ADC input range.Design the voltage divider so that the maximum expected input voltage produces an output voltage below the ADC reference voltage.
For a 3.3 V system, it is good practice to aim for 3.0 V or less to allow some margin for supply variation and noise.
Example: Measuring a 3.3V Supply
Consider a simple example where the input voltage is 3.3V and we use resistor values of R1 = 10 kΩ and R2 = 1 kΩ.
You should already be familiar with the voltage divider formula:
\[ V_{out} = V_{in} \times \frac{R_2}{R_1 + R_2} \]
Using the voltage divider equation:
\[ V_{out} = 3.3V \times \frac{1k}{1k + 10k} = 3.3V \times \frac{1k}{11k} = 0.3V \]
This output voltage is well within the ADC input range. The divider output voltage V_out is connected to an ADC input pin.
Light Dependent Resistors (LDRs)
Light Dependent Resistors (LDRs), also known as photoresistors, are among the simplest sensors you can use with a microcontroller. Their resistance changes based on the amount of light falling on them. More light results in lower resistance, while less light results in higher resistance.
Because of this behavior, LDRs are commonly used in projects such as automatic street lights, screen brightness control, light meters, and basic day night detection.
How an LDR Works
An LDR is made from a photosensitive semiconductor material. When light hits its surface, photons transfer energy to the material and free up charge carriers. As a result, the resistance of the LDR decreases.
Tip
Think of the LDR as Dracula. In sunlight, he gets weaker, just like the resistance gets lower. In darkness, he gets stronger, just like the resistance gets higher.
The resistance change is not linear, and the exact values vary between different LDRs. For most embedded applications, this is not a problem. We usually care about relative light levels rather than precise measurements.
We will not go into the details of the semiconductor materials used in LDRs or the physics behind them. If you are curious and want to explore this further, you can read this article and do additional research: https://www.elprocus.com/ldr-light-dependent-resistor-circuit-and-working/
Components Needed:
You will need the following components:
- LDR (Light Dependent Resistor)
- Resistor (typically 10kΩ); needed to create voltage divider
- Jumper wires (as usual)
Prerequisite
Before working with an LDR, you should already understand what a voltage divider is and how it works, as well as what an ADC is and how it converts an analog voltage into a digital value.
If you need a quick refresher, you can refer to these sections:
Simulation of LDR in Voltage Divider
To understand how an LDR behaves in a voltage divider, I created a simple simulation using Falstad. In the circuit, the LDR is shown as a resistor symbol with arrows pointing toward it, indicating incident light.
You can import the circuit file I created, voltage-divider-ldr.circuitjs.txt, import into the Falstad site and play around. I have also embedded a small simulator at the bottom of this page, so you can use either option.
In this configuration, the LDR is placed on the top of the voltage divider (i.e as R1), with a fixed resistor(as R2) at the bottom. This arrangement is commonly used because increasing light results in an increasing output voltage, which is intuitive when reading the value using an ADC.
Once the circuit is loaded, adjust the brightness slider and observe how the resistance of the LDR changes. At the same time, you can see how the output voltage \( V_{out} \) responds to those changes.
Important
Swapping the LDR and the fixed resistor will invert the behavior of the output voltage. If you place the LDR as R2 (the bottom resistor) in the voltage divider, the relationship between light level and Vout is inverted because the equation changes.
Example output for full brightness
When the LDR is exposed to strong light, its resistance is low. This causes a larger portion of the supply voltage to appear at the output, resulting in a higher
Example output for low light
As the light level decreases, the resistance of the LDR increases. This reduces the output voltage.
Example output for full darkness
In darkness, the LDR resistance becomes very high, causing the output voltage
LDR Voltage Divider Simulator
Turn on LED in low Light with Raspberry Pi Pico
In this exercise, we will build a small but practical project using an LDR. The Pico will automatically turn on an LED when the ambient light level drops below a certain point, meaning the LED turns on as it gets darker. You can extend this idea to control a real lamp, but that is outside the scope of this exercise and requires proper safety precautions.
You can try this in a closed room by switching the room light on and off. When the light is turned off and the room becomes dark enough, the LED should turn on, and it should turn off again when the room light is switched back on. Alternatively, you can adjust the sensitivity threshold or cover the LDR with your hand or another object to simulate different light levels.
Tip
You may need to adjust the ADC threshold based on your room’s lighting conditions and the specific LDR you are using.
Hardware Requirements
You are going to need the following components for this exercise:
- LED
- LDR
- Resistors
- 330 ohm: This is used with the LED to limit the current and protect it. You may need to choose a different value depending on the LED you are using.
- 10 k ohm: This is used with the LDR to form the voltage divider. You may need to adjust this value depending on the characteristics of your LDR.
- Jumper wires: These are used to connect everything together on a breadboard or directly to the microcontroller.
Circuit to Connect LED and LDR with Pico
LDR (ADC) Connection
We are going to connect the LDR as a voltage divider and feed the divider output into an ADC pin on the Pico. Here, the LDR acts as R1 and is connected to the 3.3 V supply, which means the ADC value decreases as the light level drops.
| Pico Pin | Wire | Component |
|---|---|---|
| 3.3 V |
|
One end of the LDR |
| GPIO 28 (ADC2) |
|
Junction between LDR and 10 kΩ resistor |
| 10 kΩ resistor |
|
Other end of the LDR |
| GND |
|
Other end of the 10 kΩ resistor |
LED Connection
The LED will be connected to a GPIO pin 15 and will turn on automatically when the light level drops.
| Pico Pin | Wire | Component |
|---|---|---|
| GPIO 15 |
|
330 Ω resistor |
| 330 Ω resistor |
|
Anode (long leg) of LED |
| GND |
|
Cathode (short leg) of LED |
Embedded Rust Code to Control an LED Based on Light Level
With the circuit assembled on your breadboard, let’s write the code.
Project from template
As usual, we are going to start by generating a new project from the template.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name, like “ldr-dracula” and select embassy as the HAL.
Additional Imports
#![allow(unused)]
fn main() {
// For Interrupt Binding
use embassy_rp::adc::InterruptHandler;
use embassy_rp::bind_interrupts;
// For ADC
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
// For LED
use embassy_rp::gpio::{Level, Output, Pull};
}
Interrupt Handler for ADC
The RP2350’s ADC has a FIFO (First-In-First-Out) buffer that stores conversion results (i.e, the measured voltage converted into a discrete ADC value). When this FIFO receives data, the hardware generates an interrupt signal called ADC_IRQ_FIFO.
In the Embassy library, the ADC interrupt handling logic is already implemented. We just need to bind this hardware interrupt so the ADC driver can use it.
Before we start reading values from the ADC, we need to set up this interrupt binding.
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => InterruptHandler;
});
}
ADC Threshold
Before we write the main logic, we need to decide what counts as low light. We do this by defining a threshold value for the ADC reading.
#![allow(unused)]
fn main() {
const LDR_THRESHOLD: u16 = 200;
}
This threshold represents the light level at which the LED should turn on. When the ADC reading drops below this value, we treat the environment as dark. When the reading is above this value, we treat it as bright.
You may need to adjust this threshold depending on your room lighting, the LDR you are using, and the resistor values in the voltage divider.
Creating the ADC Instance
Let’s create the ADC instance.
#![allow(unused)]
fn main() {
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
}
Here, we pass three things to the ADC constructor. We pass the ADC peripheral itself, the interrupt bindings we defined earlier, and a default configuration.
Note
Interesting fact: the HAL does not actually do anything with
Irqsat runtime when you pass it to the ADC constructor. It is only there at compile time to make sure you have declared the ADC interrupt binding. If you follow the new method, you will notice the parameter is named_irq, which makes it clear that it is not used.
Configuring the ADC Pin
Next, we select which GPIO pin will be used as the ADC input.
#![allow(unused)]
fn main() {
let mut adc_pin = Channel::new_pin(p.PIN_28, Pull::None);
}
Configuring the LED Output
Now we configure the GPIO pin that drives the LED.
#![allow(unused)]
fn main() {
let mut led = Output::new(p.PIN_15, Level::Low);
}
Main loop
The logic is straightforward: read the ADC value, and if it’s lesser than threshold we defined earlier, we turn on the LED; otherwise, turn it off.
#![allow(unused)]
fn main() {
loop {
let adc_reading = adc
.read(&mut adc_pin)
.await
.expect("Unable to read the adc value");
defmt::info!("ADC value: {}", adc_reading);
if adc_reading < LDR_THRESHOLD {
led.set_high();
} else {
led.set_low();
}
Timer::after_secs(1).await;
}
}
The full code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
// Interrupt Binding
use embassy_rp::adc::InterruptHandler;
use embassy_rp::bind_interrupts;
// ADC
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
// For LED
use embassy_rp::gpio::{Level, Output, Pull};
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => InterruptHandler;
});
const LDR_THRESHOLD: u16 = 200;
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
let mut adc_pin = Channel::new_pin(p.PIN_28, Pull::None);
let mut led = Output::new(p.PIN_15, Level::Low);
loop {
let adc_reading = adc
.read(&mut adc_pin)
.await
.expect("Unable to read the adc value");
defmt::info!("ADC value: {}", adc_reading);
if adc_reading < LDR_THRESHOLD {
led.set_high();
} else {
led.set_low();
}
Timer::after_secs(1).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"ldr-dracula"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone (or refer) project I created and navigate to the ldr-dracula folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/ldr-dracula/
Thermistor
A thermistor is a resistor whose value changes with temperature. As temperature goes up or down, the resistance changes in a predictable way. The name “thermistor” is derived from a combination of the words “thermal” and “resistor.”
From the microcontroller point of view, a thermistor is just another resistor connected to an ADC pin. The ADC does not know anything about temperature. It only measures voltage. We do the rest in the code.
NTC vs PTC
There are two common types of thermistors.
An NTC thermistor has lower resistance at higher temperatures. This is the most common type used for temperature measurement in embedded systems.
A PTC thermistor has higher resistance at higher temperatures. These are usually used for protection or current limiting rather than precise sensing.
In this chapter, we are going to work with an NTC thermistor.
Reference
NTC and Voltage Divider
To understand how an NTC thermistor behaves in a voltage divider, I created a simple simulation using Falstad. In the circuit, the thermistor is shown as a resistor whose value changes with temperature.
You can import the circuit file I created, voltage-divider-thermistor.circuitjs.txt, into the Falstad website and experiment with it yourself. I have also embedded a small simulator at the bottom of this page, so you can use either option.
In this configuration, the NTC thermistor is placed on the top of the voltage divider (R1), with a fixed resistor at the bottom (R2). As the temperature changes, the resistance of the thermistor changes, and that directly affects the output voltage of the voltage divider.
Important
Swapping the thermistor and the fixed resistor will invert the behavior of the output voltage. If you place the thermistor as R2 instead of R1, increase in temperature will cause \( V_{out} \) to decrease instead.
Thermistor at 25°C
The thermistor has a resistance of 10kΩ at 25°C, resulting in an output voltage (\( V_{out} \)) of 1.65V.
Thermistor at 10°C
The thermistor’s resistance increases, resulting in a lower output voltage (\( V_{out} \)).
Thermistor at 100°C
The thermistor’s resistance decreases due to its negative temperature coefficient. This results in increase of output voltage.
NTC Thermistor Voltage Divider Simulator
ADC to Resistance
When we connect a thermistor to the Pico, we do not read the voltage directly. What we get from the ADC is just a digital value that represents that voltage (refer to the ADC chapter).
But to calculate temperature, we actually need the resistance of the thermistor at that moment, not the ADC value itself.
In the next chapter, we are going to look at the temperature equations. Before we can use those formulas, we first need to convert the ADC reading back into the thermistor resistance. That is exactly what this chapter focuses on.
Converting ADC Reading to Thermistor Resistance
We now derive the thermistor resistance directly from the ADC reading using the voltage divider equation and the ADC conversion equation.
Step 1: Voltage at the divider midpoint
This is the standard voltage divider formula we have already seen. Since the thermistor is connected as R1, we label it explicitly as the thermistor resistance.
\[ V_{in} = \frac{R_2}{R_{thermistor} + R_2} \times V_{ref} \]
Step 2: ADC conversion equation
The ADC converts the input voltage into a digital value based on the ratio of the input voltage to the reference voltage.
\[ ADC = \frac{V_{in}}{V_{ref}} \times 2^{bits} \]
Step 3: Substitute the divider equation into the ADC equation
Substituting the expression for \( V_{in} \) into the ADC equation:
\[ ADC = \frac{\frac{R_2}{R_{thermistor} + R_2} \times V_{ref}}{V_{ref}} \times 2^{bits} \]
The \( V_{ref} \) terms cancel out, leaving:
\[ ADC = \frac{R_2}{R_{thermistor} + R_2} \times 2^{bits} \]
Step 4: Solve for thermistor resistance
Rearranging and solving for \( R_{thermistor} \):
\[ R_{thermistor} = \left( \frac{2^{bits}}{ADC} - 1 \right) \times R_2 \]
For the Pico, since it uses a 12-bit ADC, the equation can be simplified to:
\[ R_{thermistor} = \left( \frac{4096}{ADC} - 1 \right) \times R_2 \]
At this point, the equation contains only the ADC result, which is measured, and \( R_2 \), which is a known resistor value. Because \( R_2 \) directly affects the calculated thermistor resistance, it should be a temperature-stable resistor. Any variation in \( R_2 \) with temperature will directly affect the accuracy of the measurement.
In the next chapter, we will use this resistance value to calculate the temperature.
Rust Function
A simple helper function that converts an ADC reading into the corresponding thermistor resistance.
const ADC_LEVELS: f64 = 4096.0;
const R2_RES: f64 = 10_000.0; // R2
fn adc_to_resistance(adc_value: u16, r2_res:f64) -> f64 {
let adc = adc_value as f64;
((ADC_LEVELS / adc) - 1.0) * r2_res
}
fn main() {
let adc_value = 2000; // Our example ADC value;
let r2 = adc_to_resistance(adc_value, R2_RES);
println!("Calculated Resistance (R2): {} Ω", r2);
}
Thermistor Non-Linearity
We learned how to use a thermistor in a voltage divider and how to calculate the thermistor resistance at a given moment. The next step is to determine the temperature corresponding to that resistance.
This step is not simple. Thermistors are non-linear devices, which means resistance does not change in a straight line with temperature. Because of this, resistance cannot be converted to temperature using a single linear formula.
The non-linear curve problem
A thermistor does not change resistance evenly as temperature changes. The relationship between temperature and resistance follows a curve.
At lower temperatures, a small change in temperature produces a large change in resistance. At higher temperatures, the same temperature change produces a much smaller resistance change. This behavior is a fundamental property of thermistors.
Because of this curve, there is no simple rule such as:
a fixed number of ohms equals one degree
Using known reference points
Thermistor manufacturers are aware of this behavior and provide detailed datasheets. These datasheets list resistance values at specific temperatures.
Each entry in the datasheet represents a known and measured point on the thermistor curve. Together, these points describe how resistance changes across the temperature range.
Common approaches used in practice
There are three widely used methods to convert thermistor resistance into temperature:
Beta equation (B parameter method):
This method uses a single material constant called the Beta value. The value is provided in the thermistor datasheet. This method assumes the curve can be approximated using a logarithmic relationship between resistance and temperature.
The Steinhart-Hart equation:
The Steinhart-Hart equation is a more accurate model of the thermistor curve. Instead of a single constant, it uses three coefficients that better fit the non-linear behavior.
These coefficients are either provided by the manufacturer or derived from calibration data. Steinhart-Hart offers high accuracy across a wide temperature range. The cost is increased computation and more complex implementation.
Lookup tables:
A lookup table stores known temperature and resistance pairs. These values are taken directly from the thermistor datasheet or from calibration measurements. When a resistance is measured, it is compared against the table to find the closest matching temperature. If the resistance lies between two entries, interpolation can be used to estimate the temperature between them.
In the next chapters, we will see in detail how to use B equation and Steinhart-Hart equation to determine the temperature.
References
- The B parameter vs. Steinhart-Hart equation
- Characterising Thermistors – A Quick Primer, Beta Value & Steinhart-Hart Coefficients
B Equation
The B equation, also called the Beta parameter method, is the simplest way to convert thermistor resistance into temperature. It uses a single material constant provided by the manufacturer to approximate the thermistor’s behavior.
The B equation formula
\[ \frac{1}{T} = \frac{1}{T_0} + \frac{1}{B} \ln \left( \frac{R}{R_0} \right) \]
In this equation, T is the temperature in Kelvin that we want to find. R is the measured resistance at the unknown temperature T.
\( T_0 \) is the reference temperature, usually 298.15K (25°C), where the thermistor’s resistance is known. \( R_0 \) is the resistance at the reference temperature \( T_0 \), often 10kΩ for common thermistors.
B is the B-value of the thermistor, a material constant. And, the ln represents the natural logarithm function.
Understanding the parameters
R₀ (Reference resistance):
This is the thermistor’s resistance at a known temperature, usually 25°C. For example, a “10kΩ thermistor” has R₀ = 10,000 ohms at 25°C. This value should be specified in the datasheet.
B (Beta value):
The B value is a constant usually provided by the manufacturers. The value describes how quickly resistance changes with temperature. Common values range from 3000 to 4000 K.
The datasheet often specifies B over a temperature range, such as B₂₅/₈₅ = 3950, meaning the Beta value is 3950 between 25°C and 85°C.
Temperature in Kelvin:
The temperature in the B equation must be in Kelvin (Kelvin = Celsius + 273.15), not Celsius. Convert to Kelvin before using the equation, and subtract 273.15 from the result to get Celsius again.
Example Calculation
Let us say the measured resistance of the thermistor is 10,475 Ω, which corresponds to R in the equation. To calculate the temperature, we substitute this value along with the reference temperature \( T_0 = 298.15K \) (25°C), the reference resistance \( R_0 = 10k\Omega \), and the thermistor B-value of 3950 into the B equation.
Step 1: Calculate the resistance ratio \[ \frac{R}{R_0} = \frac{10475}{10000} = 1.0475 \]
Step 2: Calculate the natural logarithm of the ratio \[ \ln(1.0475) = 0.04641 \]
Step 3: Apply the B equation \[ \frac{1}{T} = \frac{1}{298.15} + \frac{1}{3950} \times 0.04641 \]
\[ \frac{1}{T} = 0.003354 + 0.00001175 = 0.003366 \]
Step 4: Calculate T by taking the reciprocal \[ T = \frac{1}{0.003366} = 297.1K \]
Step 5: Convert to Celsius \[ T = 297.1 - 273.15 = 23.95°C \]
The measured resistance of 10,475Ω corresponds to a temperature of approximately 24°C.
Rust function
fn calculate_temperature(current_res: f64, ref_res: f64, ref_temp: f64, b_val: f64) -> f64 {
let ln_value = (current_res/ref_res).ln();
// let ln_value = libm::log(current_res / ref_res); // use this crate for no_std
let inv_t = (1.0 / ref_temp) + ((1.0 / b_val) * ln_value);
1.0 / inv_t
}
fn kelvin_to_celsius(kelvin: f64) -> f64 {
kelvin - 273.15
}
fn celsius_to_kelvin(celsius: f64) -> f64 {
celsius + 273.15
}
const B_VALUE: f64 = 3950.0;
const V_IN: f64 = 3.3; // Input voltage
const REF_RES: f64 = 10_000.0; // Reference resistance in ohms (10kΩ)
const REF_TEMP: f64 = 25.0; // Reference temperature 25°C
fn main() {
let t0 = celsius_to_kelvin(REF_TEMP);
let r = 10475.0; // Measured resistance in ohms
let temperature_kelvin = calculate_temperature(r, REF_RES, t0, B_VALUE);
let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
println!("Temperature: {:.2} °C", temperature_celsius);
}
Steinhart Hart equation
The Steinhart-Hart equation is a more accurate method for converting thermistor resistance into temperature compared to the simpler B equation. It uses three coefficients to model the thermistor’s non-linear behavior across a wider temperature range with higher precision.
Tip
We won’t be using this formula in our program. We will keep it simple and use the B equation.
The Steinhart-Hart formula
\[ \frac{1}{T} = A + B \ln R + C (\ln R)^3 \]
In this equation, T is the temperature in Kelvin that we want to find. R is the measured resistance in ohms. A, B, and C are the Steinhart-Hart coefficients, which are specific to each thermistor. The ln represents the natural logarithm function.
Calibration
To determine the accurate values for A, B, and C, place the thermistor in three known temperature conditions, typically ice water (cold), room temperature, and hot or boiling water.
For each condition, measure the thermistor’s resistance using the ADC value and record the actual temperature using a reliable thermometer. Make sure all temperatures are converted to Kelvin before further calculations.
Using these three resistance and temperature pairs together, the Steinhart-Hart coefficients A, B, and C are calculated by solving a system of equations. The coefficients are not assigned individually to cold, room, or hot temperatures. Instead, all three calibration points collectively define a curve that fits the thermistor’s non-linear behavior across the measured range.
Once the coefficients are obtained, the same A, B, and C values can be used to calculate temperature for any resistance value within that range.
Calculating Steinhart-Hart Coefficients
With three resistance and temperature data points, we can calculate the Steinhart-Hart coefficients A, B, and C.
$$ \begin{bmatrix} 1 & \ln R_1 & \ln^3 R_1 \\ 1 & \ln R_2 & \ln^3 R_2 \\ 1 & \ln R_3 & \ln^3 R_3 \end{bmatrix}\begin{bmatrix} A \\ B \\ C \end{bmatrix} = \begin{bmatrix} \frac{1}{T_1} \\ \frac{1}{T_2} \\ \frac{1}{T_3} \end{bmatrix} $$
Where:
- \( R_1, R_2, R_3 \) are the resistance values at temperatures \( T_1, T_2, T_3 \).
Let’s calculate the coefficients
Compute the natural logarithms of resistances: $$ L_1 = \ln R_1, \quad L_2 = \ln R_2, \quad L_3 = \ln R_3 $$
Intermediate calculations: $$ Y_1 = \frac{1}{T_1}, \quad Y_2 = \frac{1}{T_2}, \quad Y_3 = \frac{1}{T_3} $$
$$ \gamma_2 = \frac{Y_2 - Y_1}{L_2 - L_1}, \quad \gamma_3 = \frac{Y_3 - Y_1}{L_3 - L_1} $$
So, finally: $$ C = \left( \frac{ \gamma_3 - \gamma_2 }{ L_3 - L_2} \right) \left(L_1 + L_2 + L_3\right)^{-1} \ $$ $$ B = \gamma_2 - C \left(L_1^2 + L_1 L_2 + L_2^2\right) \ $$ $$ A = Y_1 - \left(B + L_1^2 C\right) L_1 $$
Good news, Everyone! You don’t need to calculate the coefficients manually. Simply provide the resistance and temperature values for cold, room, and hot environments, and use the form below to determine A, B and C
ADC value and Resistance Calculation
Note: if you already have the temperature and corresponding resistance, you can directly use the second table to input those values.
If you have the ADC value and want to calculate the resistance, use this table to find the corresponding resistance at different temperatures. As you enter the ADC value for each temperature, the calculated resistance will be automatically updated in the second table.
To perform this calculation, you’ll need the base resistance of the thermistor, which is essential for determining the resistance at a given temperature based on the ADC value.
Please note that the ADC bits may need to be adjusted if you’re using a different microcontroller. In our case, for the the Raspberry Pi Pico, the ADC resolution is 12 bits.
Coefficients Finder
Adjust the temperature by entering a value in either Fahrenheit or Celsius; the form will automatically convert it to the other format. Provide the resistance corresponding to each temperature, and then click the “Calculate Coefficients” button.
Calculate Temperature from Resistance
Now, with these coefficients, you can calculate the temperature for any given resistance:
Rust function
fn steinhart_temp_calc(
resistance: f64, // Resistance in Ohms
a: f64, // Coefficient A
b: f64, // Coefficient B
c: f64, // Coefficient C
) -> Result<(f64, f64), String> {
if resistance <= 0.0 {
return Err("Resistance must be a positive number.".to_string());
}
// Calculate temperature in Kelvin using Steinhart-Hart equation:
// 1/T = A + B*ln(R) + C*(ln(R))^3
let ln_r = resistance.ln();
let inverse_temperature = a + b * ln_r + c * ln_r.powi(3);
if inverse_temperature == 0.0 {
return Err("Invalid coefficients or resistance leading to division by zero.".to_string());
}
let temperature_kelvin = 1.0 / inverse_temperature;
let temperature_celsius = temperature_kelvin - 273.15;
let temperature_fahrenheit = (temperature_celsius * 9.0 / 5.0) + 32.0;
Ok((temperature_celsius, temperature_fahrenheit))
}
fn main() {
// Example inputs
let a = 2.10850817e-3;
let b = 7.97920473e-5;
let c = 6.53507631e-7;
let resistance = 10000.0;
match steinhart_temp_calc(resistance, a, b, c) {
Ok((celsius, fahrenheit)) => {
println!("Temperature in Celsius: {:.2}", celsius);
println!("Temperature in Fahrenheit: {:.2}", fahrenheit);
}
Err(e) => println!("Error: {}", e),
}
}
References
- Thermistor Calculator
- Thermistor Steinhart-Hart Coefficients for Calculating Motor Temperature
- Calibrate Steinhart-Hart Coefficients for Thermistors
- Cooking Thermometer With Steinhart-Hart Correction
Displaying Temperature on an OLED Using Embedded Rust on Raspberry Pi Pico
In this chapter, we are going to read temperature from a thermistor and display it on an OLED screen.
By now, you should already be familiar with using an OLED display with the Raspberry Pi Pico. Instead of printing values to a console, we will make it fun by displaying on the hardware display.
Hardware Requirments
- NTC 103 Thermistor: 10K OHM, 5mm epoxy coated disc
- An OLED display: (0.96 Inch I2C/IIC 4-Pin, 128x64 resolution, SSD1306 chip)
- 10kΩ Resistor: Used with the thermistor to form a voltage divider
- Jumper wires
Circuit to connect OLED, Thermistor with Raspberry Pi Pico
Thermistor Connection
We are going to connect the thermistor as a voltage divider and feed the divider output into an ADC pin on the Pico. Here, the thermistor acts as R1 and is connected to the 3.3 V supply, which means the ADC value decreases as the temperature increases (NTC behavior).
| Pico Pin | Wire | Component |
|---|---|---|
| 3.3 V |
|
One end of the thermistor |
| GPIO 28 (ADC2) |
|
Junction between thermistor and 10 kΩ resistor |
| 10 kΩ resistor |
|
Other end of the thermistor |
| GND |
|
Other end of the 10 kΩ resistor |
OLED (I2C) Connection
The OLED display is connected using I2C. SDA is connected to GPIO 16 and SCL is connected to GPIO 17.
| Pico Pin | Wire | OLED Pin |
|---|---|---|
| GND |
|
GND |
| 3.3 V |
|
VCC |
| GPIO 16 |
|
SDA |
| GPIO 17 |
|
SCL |
Write Embedded Rust code to Display Temperature on OLED Display
In this section, we move to the coding part. We write the code that reads the thermistor value using the ADC, converts it into temperature using the B equation, and displays the result on the OLED over I2C.
Project from Template
As usual, we are going to start by generating a new project from the template.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
When prompted, give your project a name, like “temperature-oled” and select embassy as the HAL.
Additional Crates required
We need a few additional crates to support the OLED display, format text, and mathematical operations. Add the following entries to Cargo.toml along with the existing dependencies.
#![allow(unused)]
fn main() {
ssd1306 = { version = "0.10.0", features = ["async"] }
heapless = "0.9.2"
libm = "0.2.15"
embedded-graphics = "0.8"
}
-
ssd1306: Driver crate for controlling SSD1306-based OLED displays. -
heapless: In a no_std environment, Rust’s standard String type is not available because it requires heap allocation. This crate provides stack-allocated, fixed-size data structures. We use it to store formatted text such as ADC values, resistance, and temperature before sending them to the OLED. -
libm: Provides mathematical functions for no_std environments. This is required to compute the natural logarithm when using the B equation. -
embedded-graphics:
The SSD1306 driver supports different ways of writing content to the display.When you use
into_buffered_graphics_mode, the display is treated like a pixel buffer. Text and shapes are first drawn into an in-memory framebuffer using the embedded-graphics API, and then the whole buffer is sent to the OLED. This mode requires the embedded-graphics crate.When you use
into_terminal_mode, the driver provides a simple text-based interface. You write characters directly to the display without drawing pixels or shapes yourself. In this case, embedded-graphics is not required.
Additional imports
#![allow(unused)]
fn main() {
// Text formatting without heap allocation
use core::fmt::Write;
use heapless::String;
// For OLED display
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// For ADC
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
use embassy_rp::gpio::Pull;
// Interrupt Binding
use embassy_rp::bind_interrupts;
use embassy_rp::peripherals::I2C0;
use embassy_rp::{adc, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// Embedded Graphics
use embedded_graphics::{
mono_font::{MonoTextStyle, iso_8859_13::FONT_7X13_BOLD},
pixelcolor::BinaryColor,
prelude::*,
text::Text,
};
}
Interrupt Handler
In this project, we use both the ADC and I2C peripherals. Each of these peripherals generate interrupts, and Embassy requires that those interrupts are explicitly bound at compile time.
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
}
ADC_IRQ_FIFO is the interrupt generated by the ADC when data is available in its FIFO. This interrupt is required for ADC operation in Embassy. I2C0_IRQ is the interrupt used by the I2C0 peripheral. This interrupt is required for asynchronous I2C communication with the OLED display.
Thermistor Constants
We define a few constants that describe the thermistor and ADC behavior.
#![allow(unused)]
fn main() {
const ADC_LEVELS: f64 = 4096.0;
const B_VALUE: f64 = 3950.0;
const REF_RES: f64 = 10_000.0; // Reference resistance in ohms (10kΩ)
const REF_TEMP: f64 = 25.0; // Reference temperature 25°C
}
The thermistor we used has a resistance of 10 kΩ at 25°C and a B value of 3950. The pico has a 12-bit ADC resolution, so 4096 possible ADC levels.
Helper functions
We will define few helper functions.
This function that converts ADC values into resistance uses the voltage divider equation. We have already covered this formula earlier, so here we simply reuse it.
#![allow(unused)]
fn main() {
// We have already covered about this formula in ADC chapter
fn adc_to_resistance(adc_value: u16, r2_res: f64) -> f64 {
let adc = adc_value as f64;
((ADC_LEVELS / adc) - 1.0) * r2_res
}
}
This function that converts resistance into temperature applies the B equation. Because we are in a no_std environment, we use the libm crate to compute the natural logarithm.
#![allow(unused)]
fn main() {
// B Equation to convert resistance to temperature
fn calculate_temperature(current_res: f64, ref_res: f64, ref_temp: f64, b_val: f64) -> f64 {
let ln_value = libm::log(current_res / ref_res); // Use libm for `no_std`
let inv_t = (1.0 / ref_temp) + ((1.0 / b_val) * ln_value);
1.0 / inv_t
}
}
We also define small helper functions to convert between Kelvin and Celsius.
#![allow(unused)]
fn main() {
fn kelvin_to_celsius(kelvin: f64) -> f64 {
kelvin - 273.15
}
fn celsius_to_kelvin(celsius: f64) -> f64 {
celsius + 273.15
}
}
Display Setup
We configure the OLED to use I2C with SDA on GPIO 16 and SCL on GPIO 17. We set the I2C frequency to 400 kHz.
We initialize the SSD1306 display in buffered graphics mode. In this mode, all drawing operations happen in memory first. The content is sent to the OLED only when we flush the buffer. We also define the text style, you can adjust the font size.
#![allow(unused)]
fn main() {
// Display Setup
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; //400kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let text_style = MonoTextStyle::new(&FONT_7X13_BOLD, BinaryColor::On);
}
ADC Setup
We configure the ADC channel connected to the thermistor pin. We then initialize the ADC peripheral using the interrupt bindings defined earlier.
#![allow(unused)]
fn main() {
// ADC Setup for thermistor
let mut adc_pin = Channel::new_pin(p.PIN_28, Pull::None);
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
}
Heapless String
We create a heapless string with a fixed capacity of 64 characters. This string lives on the stack and is reused on every iteration of the loop. We use it to store formatted values such as temperature, ADC reading, and resistance before drawing them on the OLED.
#![allow(unused)]
fn main() {
let mut buff: String<64> = String::new();
}
Convert the Reference Temperature to Kelvin
We define the reference temperature for the thermistor as 25°C. Since the B equation requires temperature in Kelvin, we convert this value once during initialization. This avoids repeating the conversion inside the loop.
#![allow(unused)]
fn main() {
let ref_temp = celsius_to_kelvin(REF_TEMP);
}
Main Loop
In each iteration of the loop, we read the thermistor value using the ADC, convert the reading into temperature, format the result as text, and update the OLED display.
We first clear both the string buffer and the display buffer.
#![allow(unused)]
fn main() {
buff.clear();
display
.clear(BinaryColor::Off)
.expect("failed to clear the display");
}
Read ADC
We then read the ADC value from the thermistor pin.
#![allow(unused)]
fn main() {
let adc_value = adc
.read(&mut adc_pin)
.await
.expect("failed to read adc value");
}
Convert ADC Value to Temperature
We convert the ADC reading into resistance using the voltage divider equation. We then convert the resistance into temperature using the B equation, which gives the temperature in Kelvin. Finally, we convert the value to Celsius.
#![allow(unused)]
fn main() {
let current_res = adc_to_resistance(adc_value, REF_RES);
let temperature_kelvin = calculate_temperature(current_res, REF_RES, ref_temp, B_VALUE);
let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
}
Format Output Text
We format the temperature, ADC value, and resistance into the heapless string.
#![allow(unused)]
fn main() {
writeln!(buff, "Temp: {:.2} °C", temperature_celsius)
.expect("failed to format temperature");
writeln!(buff, "ADC: {}", adc_value).expect("failed to format ADC value");
writeln!(buff, "R: {:.2}", current_res).expect("failed to format Resistance");
}
Update OLED Display
We draw the formatted text to the display buffer, flush the buffer to update the OLED, and wait before the next iteration.
#![allow(unused)]
fn main() {
Text::new(&buff, Point::new(5, 20), text_style)
.draw(&mut display)
.expect("Failed to write the text");
display.flush().await.expect("failed to send to display");
Timer::after_secs(2).await;
}
Flash
Once you flash the firmware, you should see the temperature along with the resistance and ADC values. You can move the setup to a different room, observe how the readings change between day and night, or take it outdoors using an external power supply to see how the temperature responds in different conditions.
The full code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp::block::ImageDef;
use embassy_rp::{self as hal};
use embassy_time::Timer;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
// Text formatting without heap allocation
use core::fmt::Write;
use heapless::String;
// For OLED display
use ssd1306::{I2CDisplayInterface, Ssd1306Async, prelude::*};
// For ADC
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
use embassy_rp::gpio::Pull;
// Interrupt Binding
use embassy_rp::bind_interrupts;
use embassy_rp::peripherals::I2C0;
use embassy_rp::{adc, i2c};
// I2C
use embassy_rp::i2c::{Config as I2cConfig, I2c};
// Embedded Graphics
use embedded_graphics::{
mono_font::{MonoTextStyle, iso_8859_13::FONT_7X13_BOLD},
pixelcolor::BinaryColor,
prelude::*,
text::Text,
};
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
I2C0_IRQ => i2c::InterruptHandler<I2C0>;
});
const ADC_LEVELS: f64 = 4096.0;
const B_VALUE: f64 = 3950.0;
const REF_RES: f64 = 10_000.0; // Reference resistance in ohms (10kΩ)
const REF_TEMP: f64 = 25.0; // Reference temperature 25°C
// We have already covered about this formula in ADC chpater
fn adc_to_resistance(adc_value: u16, r2_res: f64) -> f64 {
let adc = adc_value as f64;
((ADC_LEVELS / adc) - 1.0) * r2_res
}
// B Equation to convert resistance to temperature
fn calculate_temperature(current_res: f64, ref_res: f64, ref_temp: f64, b_val: f64) -> f64 {
let ln_value = libm::log(current_res / ref_res); // Use libm for `no_std`
let inv_t = (1.0 / ref_temp) + ((1.0 / b_val) * ln_value);
1.0 / inv_t
}
fn kelvin_to_celsius(kelvin: f64) -> f64 {
kelvin - 273.15
}
fn celsius_to_kelvin(celsius: f64) -> f64 {
celsius + 273.15
}
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// Display Setup
let sda = p.PIN_16;
let scl = p.PIN_17;
let mut i2c_config = I2cConfig::default();
i2c_config.frequency = 400_000; //400kHz
let i2c_bus = I2c::new_async(p.I2C0, scl, sda, Irqs, i2c_config);
let i2c_interface = I2CDisplayInterface::new(i2c_bus);
let mut display = Ssd1306Async::new(i2c_interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display
.init()
.await
.expect("failed to initialize the display");
let text_style = MonoTextStyle::new(&FONT_7X13_BOLD, BinaryColor::On);
// ADC Setup for thermistor
let mut adc_pin = Channel::new_pin(p.PIN_28, Pull::None);
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
let mut buff: String<64> = String::new();
let ref_temp = celsius_to_kelvin(REF_TEMP);
loop {
buff.clear();
display
.clear(BinaryColor::Off)
.expect("failed to clear the display");
let adc_value = adc
.read(&mut adc_pin)
.await
.expect("failed to read adc value");
let current_res = adc_to_resistance(adc_value, REF_RES);
let temperature_kelvin = calculate_temperature(current_res, REF_RES, ref_temp, B_VALUE);
let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
writeln!(buff, "Temp: {:.2} °C", temperature_celsius)
.expect("failed to format temperature");
writeln!(buff, "ADC: {}", adc_value).expect("failed to format ADC value");
writeln!(buff, "R: {:.2}", current_res).expect("failed to format Resistance");
Text::new(&buff, Point::new(5, 20), text_style)
.draw(&mut display)
.expect("Failed to write the text");
display.flush().await.expect("failed to send to display");
Timer::after_secs(2).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recommended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"temperature-oled"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone (or refer) project I created and navigate to the temperature-oled folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/temperature-oled/
USB Serial Communication
In this section, we are going to set up communication between our device (Pico) and a computer running Linux. We will send a simple string from the Pico to the computer, and we will also send input from the computer back to the Pico.
This is especially useful when you do not have a debug probe and want to print sensor readings or status messages to the system console to quickly see what is going on. I have personally used this approach many times before I actually bought a debug probe.
Tip
If you are using a debug probe, you can skip this chapter for now. You can always come back to it later when you want to exchange data with a computer using USB, such as sending messages, reading input, or building simple command based interfaces.
CDC ACM
When you plug a USB device into a computer, the computer needs to know what kind of device it is and how to talk to it. USB solves this by defining standard device types, called classes.
CDC stands for Communication Device Class. It is a USB class meant for devices that communicate by sending and receiving data, similar to serial communication. One very common CDC type is called ACM, which stands for Abstract Control Model.
CDC ACM makes a USB device look like a simple serial port to the computer.
So even though the data is traveling over USB, the operating system treats it like a normal serial connection. On Linux, this is why the device shows up as something like /dev/ttyACM0.
If you have ever used UART with a USB to serial adapter, this feels almost exactly the same from the software side.
Tools for Linux
When you flash the code in this exercise, the device will appear as /dev/ttyACM0 on your computer. To interact with the USB serial port on Linux, you can use tools like minicom, tio (or cat) to read and send data to and from the device
- minicom: Minicom is a text-based serial port communications program. It is used to talk to external RS-232 devices such as mobile phones, routers, and serial console ports.
- tio: tio is a serial device tool which features a straightforward command-line and configuration file interface to easily connect to serial TTY devices for basic I/O operations.
Rust Crates
We will be using the example taken from the RP-HAL repository. It uses two crates: usb-device, an USB stack for embedded devices in Rust, and usbd-serial, which implements the USB CDC-ACM serial port class. The SerialPort class in usbd-serial implements a stream-like buffered serial port and can be used in a similar way to UART.
References
- CDC: Communication Device Class (ACM)
- USB Device CDC ACM Class
- What is the difference between /dev/ttyUSB and /dev/ttyACM?
- Defined Class Codes
USB Serial Logging with Embassy on Raspberry Pi Pico
In this chapter, we will look at a simple way to log messages from the Raspberry Pi Pico to the system console using USB serial. We will use the USB interface along with the embassy-usb-logger crate to send log output to the host.
We will keep the example intentionally small. The Pico maintains a counter that increments once every second. Each time the value changes, we print it to the system console over USB. When the counter reaches its limit, it wraps back to zero and continues. The goal here is not the counter itself, but to show how logging works when USB serial is used as the output.
This approach is useful when you want log output to appear as a regular serial device on your computer. Once this is set up, you can use the same logging method to print sensor values, state changes, or debug messages from your application.
Project from template
We will start by generating a new project using the template.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
When prompted, give your project a name, for example “cdc-logger”, and select embassy as the HAL. You do not need to enable defmt for this setup.
Additional crates required
Update your Cargo.toml to include the USB logger crate.
embassy-usb-logger = "0.5.1"
log = "0.4"
The embassy-usb-logger crate provides a ready to use logger implementation that sends log messages over USB. The log crate provides the logging macros used throughout the program.
Additional imports
Add the following imports to your main.rs file.
#![allow(unused)]
fn main() {
// For USB
use embassy_rp::{peripherals::USB, usb};
}
USB interrupt binding
The USB peripheral requires an interrupt handler. Embassy provides the handler, and we only need to bind it.
#![allow(unused)]
fn main() {
embassy_rp::bind_interrupts!(struct Irqs {
USBCTRL_IRQ => usb::InterruptHandler<USB>;
});
}
USB logger task
USB handling runs in its own task. This task creates the USB driver and starts the USB logger.
#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn logger_task(usb: embassy_rp::Peri<'static, embassy_rp::peripherals::USB>) {
let driver = embassy_rp::usb::Driver::new(usb, Irqs);
embassy_usb_logger::run!(1024, log::LevelFilter::Info, driver);
}
}
The logger task runs continuously and handles all USB communication in the background.
Main function
In the main function, we spawn the USB logger task.
#![allow(unused)]
fn main() {
spawner.must_spawn(logger_task(p.USB));
}
Main loop
We keep a simple counter and increase it once every second. The counter uses wrapping arithmetic, so after reaching its maximum value it rolls back to zero. On each iteration, we log the current value using log::info!, which sends the message over USB serial.
#![allow(unused)]
fn main() {
let mut i: u8 = 0;
loop {
i = i.wrapping_add(1);
log::info!("USB says: {}", i);
Timer::after_secs(1).await;
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the cdc-logger folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/usb-serial/cdc-logger/
How to Run ?
The method to flash (run the code) on the Pico is the same as usual. However, we need to set up tio to interact with the Pico through the serial port (/dev/ttyACM0). This allows us to read data from the Pico or send data to it.
tio
Make sure you have tio installed on your system. If not, you can install it using:
apt install tio
Connecting to the Serial Port
Run the following command to connect to the Pico’s serial port:
tio /dev/ttyACM0
This will open a terminal session for communicating with the Pico.
Flashing and Running the Code
Open another terminal, navigate to the project folder, and flash the code onto the Pico as usual:
cargo run --release
Once the program starts running, you should see the counter value printed once every second in the tio terminal.
USB Serial Example Using rp-hal
We are going to use an example provided in the RP HAL repository and make a few small changes to it so we can clearly see two way communication working.
The original example sends a simple “Hello, World!” message from the Pico to the computer once the internal timer reaches a certain count. It also polls for incoming data from the computer and sends data back.
We’ll slightly modify the code to make it more fun. Instead of sending “Hello, World!”, we’ll send “Hello, Rust!” to the computer. Wait, I know that’s not the fun part. Here it comes: if you type ‘r’ in the terminal connected via USB serial, the onboard LED will turn on. Type anything else, and the LED will turn off.
Project from template
To set up the project, generate it from the provided template:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
When prompted, give your project a name, like “usb-serial-led” and select RP-HAL as the HAL.
Additional Crates required
Update your Cargo.toml to add the following crates along with the existing dependencies:
#![allow(unused)]
fn main() {
usbd-serial = "0.2.2"
usb-device = "0.3.2"
}
Additional imports
#![allow(unused)]
fn main() {
// USB Device support
use usb_device::{class_prelude::*, prelude::*};
// USB Communications Class Device support
use usbd_serial::SerialPort;
// For the LED
use embedded_hal::digital::OutputPin;
}
LED Setup
We will configure the onboard LED.
#![allow(unused)]
fn main() {
let mut led = pins.gpio25.into_push_pull_output();
}
Set up the USB driver
We first create the USB bus using the RP2350 USB peripheral and its DPRAM:
#![allow(unused)]
fn main() {
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USB,
pac.USB_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
}
Creating the CDC ACM Serial Interface
Next, we create a CDC ACM serial port on top of the USB bus:
#![allow(unused)]
fn main() {
let mut serial = SerialPort::new(&usb_bus);
}
This serial object behaves very similarly to a UART style serial port.
Creating the USB Device
Now we describe the USB device itself so the host knows how to enumerate it:
#![allow(unused)]
fn main() {
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x27dd))
.strings(&[StringDescriptors::default()
.manufacturer("implRust")
.product("Ferris")
.serial_number("TEST")])
.unwrap()
.device_class(2) // 2 for the CDC, from: https://www.usb.org/defined-class-codes
.build();
}
We use a fake VID and PID for development, provide basic string descriptors, and set the device class to CDC so Linux treats it as a serial device.
Tracking the Greeting Message
We declare a flag to track whether the greeting message has already been sent. This flag is checked inside the main loop to ensure the message is sent only once.
#![allow(unused)]
fn main() {
let mut said_hello = false;
}
Main Loop
Sending Message to PC
Once the program is running, we check the timer. When the counter reaches 2,000,000 ticks, we send a message “Hello, Rust!” to the computer and make sure it is sent only once:
#![allow(unused)]
fn main() {
if !said_hello && timer.get_counter().ticks() >= 2_000_000 {
said_hello = true;
// Writes bytes from `data` into the port and returns the number of bytes written.
let _ = serial.write(b"Hello, Rust!\r\n");
}
}
Polling for data
At the same time, we continuously poll the USB device for incoming data. When data arrives, we read it and inspect each received byte. If the character is ‘r’, the onboard LED is turned on. Any other character turns the LED off:
#![allow(unused)]
fn main() {
if usb_dev.poll(&mut [&mut serial]) {
let mut buf = [0u8; 64];
if let Ok(count) = serial.read(&mut buf) {
for &byte in &buf[..count] {
if byte == b'r' {
led.set_high().expect("unable to turn on LED");
} else {
led.set_low().expect("unable to turn off LED");
}
}
}
}
}
The Full code
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use rp235x_hal as hal;
//Panic Handler
use panic_probe as _;
// USB Device support
use usb_device::{class_prelude::*, prelude::*};
// USB Communications Class Device support
use usbd_serial::SerialPort;
// For the LED
use embedded_hal::digital::OutputPin;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
/// External high-speed crystal on the Raspberry Pi Pico 2 board is 12 MHz.
/// Adjust if your board has a different frequency
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
// Grab our singleton objects
let mut pac = hal::pac::Peripherals::take().unwrap();
// Set up the watchdog driver - needed by the clock setup code
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
// Configure the clocks
//
// The default is to generate a 125 MHz system clock
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
// The single-cycle I/O block controls our GPIO pins
let sio = hal::Sio::new(pac.SIO);
// Set the pins up according to their function on this particular board
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
let timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
let mut led = pins.gpio25.into_push_pull_output();
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USB,
pac.USB_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut serial = SerialPort::new(&usb_bus);
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x27dd))
.strings(&[StringDescriptors::default()
.manufacturer("implRust")
.product("Ferris")
.serial_number("TEST")])
.unwrap()
.device_class(2) // 2 for the CDC, from: https://www.usb.org/defined-class-codes
.build();
let mut said_hello = false;
loop {
if !said_hello && timer.get_counter().ticks() >= 2_000_000 {
said_hello = true;
// Writes bytes from `data` into the port and returns the number of bytes written.
let _ = serial.write(b"Hello, Rust!\r\n");
}
if usb_dev.poll(&mut [&mut serial]) {
let mut buf = [0u8; 64];
if let Ok(count) = serial.read(&mut buf) {
for &byte in &buf[..count] {
if byte == b'r' {
led.set_high().expect("unable to turn on LED");
} else {
led.set_low().expect("unable to turn off LED");
}
}
}
}
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recommended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"your program description"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone (or refer) project I created and navigate to the usb-serial-led folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/usb-serial-led/
How to Run ?
The method to flash (run the code) on the Pico is the same as usual. However, we need to set up tio to interact with the Pico through the serial port (/dev/ttyACM0). This allows us to read data from the Pico or send data to it.
tio
Make sure you have tio installed on your system. If not, you can install it using:
apt install tio
Connecting to the Serial Port
Run the following command to connect to the Pico’s serial port:
tio /dev/ttyACM0
This will open a terminal session for communicating with the Pico.
Flashing and Running the Code
Open another terminal, navigate to the project folder, and flash the code onto the Pico as usual:
cargo run
If everything is set up correctly, you should see a “Connected” message in the tio terminal, followed by the “Hello, Rust!” message sent from the Pico.
Send data to Pico
In the terminal where tio is running, you type that will be sent to the Pico. You won’t see what you type (since we’re not echoing back the input).
If you press the letter ‘r’, the onboard LED will be turned on. If you press any other character, the LED will be turned off.
Embassy version
You can also refer to this project, which demonstrates using USB Serial with the Embassy framework.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/usb-serial/
Serial Peripheral Interface (SPI)
In this section, we will learn what SPI is and how to use the SPI communication buses of the Raspberry Pi Pico.
What is SPI?
SPI stands for Serial Peripheral Interface. It is one of the most common ways for microcontrollers to communicate with devices like displays, sensors, and SD Cards. Technically, it is called as a serial, full duplex, and synchronous interface. But what do those terms mean?
-
Serial means data is sent one bit at a time, one after another, over a single data line. This is different from parallel communication, where multiple bits are sent at the same time using multiple data lines.
-
Full duplex means two devices can send and receive data at the same time. Both devices can transmit to each other simultaneously without waiting for the other to finish. This is different from half duplex, where devices must take turns: one sends while the other receives.
-
Synchronous means both devices use a shared clock signal. This clock defines exactly when each bit of data is sent and read, so both sides stay aligned during communication.
Controller and Peripheral
SPI uses a controller-peripheral model (previously called master-slave). The controller is the device that starts the communication and provides the clock signal. The peripheral is the device that responds to the controller.
In our case, the Raspberry Pi Pico acts as the controller, and devices like displays, sensors, or SD Cards are peripherals.
Figure: Single SPI bus with a controller and a single peripheral
The connection typically uses four lines:
-
The SCK (Serial Clock) line carries the clock signal generated by the controller. This signal keeps both the controller and the device in sync during data transfer.
-
The MOSI (Master Out, Slave In) line is used to send data from the controller to the device. In some datasheets, this line may be labeled as SDO (Serial Data Out).
-
The MISO (Master In, Slave Out) line carries data from the device to the controller. It may also be referred to as SDI (Serial Data In) depending on the device.
-
Finally, the CS (Chip Select) line is used by the controller to choose which device it wants to communicate with. Each connected device usually has its own dedicated CS line. When the controller pulls a device’s CS line low(active), that device becomes active and ready to communicate. In some older documentation, this line may be referred to as SS (Slave Select).
Figure: Single SPI bus with a controller and multiple peripherals
SPI Modes
SPI supports four modes, numbered from 0 to 3. These decide when data is read and written depending on the clock’s idle level and edge.
Don’t worry too much about the details right now. Mode 0 is the most common and works with most devices.
Why SPI?
SPI is a great choice when you want fast and reliable communication between your microcontroller and a peripheral. It’s much faster than I²C or UART, simple to use, and allows data to be sent and received at the same time (full-duplex). This makes it ideal for high-speed devices like displays or SD cards.
As long as you have enough GPIO pins and don’t need to connect a large number of devices, SPI is usually the best tool for the job.
Resources
For more in-depth technical details, refer these
Using SPI with Embedded Rust Ecosystem
In the previous section, we learned what SPI is and how the controller-peripheral model works. Now, let’s see how these concepts apply within the Embedded Rust ecosystem.
Rust’s embedded ecosystem is designed to be modular and reusable. This means you can write code for one microcontroller and reuse it on another with minimal changes. One key to this flexibility is the use of traits defined by the embedded-hal crate.
SPI in embedded-hal
The embedded-hal crate defines standard traits for working with SPI, so that drivers and libraries can be written generically. Two important traits for SPI are:
-
SpiBus: Represents full control over the SPI bus, including the SCK, MOSI, and MISO lines. This must be implemented by the microcontroller’s HAL crate. For example, the esp-hal crate implements SpiBus. If you are curious, you can look at the implementation here.
-
SpiDevice: Represents access to a single SPI device that may share the bus with others. It takes control of the chip select (CS) pin and ensures the device is properly selected before communication and released afterward.
Platform-Independent Drivers
Imagine you are writing a driver for a sensor or a display that communicates over SPI. You don’t want to tie your code to a specific microcontroller like the Raspberry Pi Pico or ESP32. Instead, you can write the driver in a generic way using the embedded-hal traits.
As long as your driver only depends on the SpiDevice or SpiBus traits, it can run on any platform that provides an implementation of these traits-such as STM32, nRF, or ESP32.
Sharing the SPI Bus
In many projects, multiple SPI devices share the same SPI bus. For example, you might have a display, an SD card, and a temperature sensor all connected to the same MOSI, MISO, and SCK lines. The only thing that separates them is their chip select (CS) pin.
Figure: Single SPI bus with a controller and multiple peripherals
If you give full control of the SPI bus to just one driver (using SpiBus), the others can’t use it. Instead, we need a way to share the SPI bus safely across multiple devices.
That’s where SpiDevice comes in - it allows each driver to use only its own CS pin, while still sharing the underlying bus.
In practice, this means we need to pass a struct that implements SpiDevice to each driver, rather than giving them the full bus.
How do we get a SpiDevice?
We said that each driver should be given a SpiDevice implementation instead of the full SpiBus. But are we supposed to write this SpiDevice struct ourselves?
Not really. While it’s possible to write your own implementation, it’s usually unnecessary-and can be tricky to get right, especially when multiple devices need to coordinate bus access.
That’s where the embedded-hal-bus crate comes in. It provides ready-to-use wrappers that implement the SpiDevice trait for you. These wrappers handle bus access, chip select control, and optional synchronization between devices.
-
If your project only uses one SPI device and doesn’t need sharing, you can use the
ExclusiveDevicestruct - it gives exclusive access to the bus for one device. -
But if your project has multiple SPI devices sharing the same bus, you can choose one of the shared access implementations such as
AtomicDeviceorCriticalSectionDevice. These manage access to the bus so that each device gets a turn without interfering with the others.
These structs allow you to focus on using or building drivers without worrying about low-level coordination or writing boilerplate code.
Resources
- embedded-hal docs on SPI: This documentation provides in-depth details on how SPI traits are structured and how they are intended to be used across different platforms.
RP2350 SPI Peripherals
The RP2350 includes two SPI controllers, named SPI0 and SPI1. Both are general purpose peripherals and are available for application use. These controllers provide flexible serial communication capabilities for connecting external devices like sensors, displays, SD cards, and other SPI peripherals.
Each SPI peripheral supports standard SPI operation, including master and slave modes, full-duplex transfers, and the usual SPI signals: SCLK, MOSI, MISO, and chip select.
You can refer to the 1046th page on the RP2350 datasheet for more detailed and accurate information: https://pip-assets.raspberrypi.com/categories/1214-rp2350/documents/RP-008373-DS-2-rp2350-datasheet.pdf?disposition=inline#page=1047
GPIO Pins
The Raspberry Pi Pico 2 exposes two SPI buses on its GPIO header: SPI0 and SPI1. Each SPI bus uses four signals: clock (SCK), transmit (MOSI), receive (MISO), and chip select (CS).

In the above diagram, the SPI pins are highlighted in pink.
SPI0 pin options
| MISO (RX) | CS | SCK | MOSI (TX) |
|---|---|---|---|
| GPIO 0 | GPIO 1 | GPIO 2 | GPIO 3 |
| GPIO 4 | GPIO 5 | GPIO 6 | GPIO 7 |
| GPIO 16 | GPIO 17 | GPIO 18 | GPIO 19 |
SPI1 pin options
| MISO (RX) | CS | SCK | MOSI (TX) |
|---|---|---|---|
| GPIO 8 | GPIO 9 | GPIO 10 | GPIO 11 |
| GPIO 12 | GPIO 13 | GPIO 14 | GPIO 15 |
Using SPI in embassy
Embassy provides two ways to use SPI. One is async, where transfers can be awaited without blocking the executor. The other is blocking, which behaves more like traditional HALs and is often simpler for small examples or one-off transfers.
Async mode
In async mode, SPI uses DMA under the hood. You must provide DMA channels, and all transfers are performed using .await. This is useful when SPI transfers are part of a larger async application and you do not want to block other tasks.
#![allow(unused)]
fn main() {
let miso = p.PIN_12;
let mosi = p.PIN_11;
let clk = p.PIN_10;
let mut spi_bus = Spi::new(p.SPI1, clk, mosi, miso, p.DMA_CH0, p.DMA_CH1, spi::Config::default());
let tx_buf = [1_u8, 2, 3, 4, 5, 6];
let mut rx_buf = [0_u8; 6];
spi_bus.transfer(&mut rx_buf, &tx_buf).await.unwrap();
info!("{:?}", rx_buf);
}
Here, data is written from tx_buf while data received from the peripheral is stored in rx_buf. The call to transfer only returns once the SPI transaction is complete.
Blocking mode
Blocking SPI is simpler, calls return only after the transfer is finished, and no DMA or async context is required.
#![allow(unused)]
fn main() {
let miso = p.PIN_12;
let mosi = p.PIN_11;
let clk = p.PIN_10;
let mut config = spi::Config::default();
let mut spi_bus = Spi::new_blocking(p.SPI1, clk, mosi, miso, config);
spi_bus.blocking_transfer_in_place(&mut buf).unwrap();
}
Using SPI in rp-hal
In the rp-hal SPI, you need to first configure the pins into SPI function mode, then the SPI peripheral is created and initialized with the desired clock speed and SPI mode.
#![allow(unused)]
fn main() {
// These are implicitly used by the spi driver if they are in the correct mode
let spi_mosi = pins.gpio7.into_function::<hal::gpio::FunctionSpi>();
let spi_miso = pins.gpio4.into_function::<hal::gpio::FunctionSpi>();
let spi_sclk = pins.gpio6.into_function::<hal::gpio::FunctionSpi>();
let spi_bus = hal::spi::Spi::<_, _, _, 8>::new(pac.SPI0, (spi_mosi, spi_miso, spi_sclk));
// Exchange the uninitialised SPI driver for an initialised one
let mut spi_bus = spi_bus.init(
&mut pac.RESETS,
clocks.peripheral_clock.freq(),
16.MHz(),
embedded_hal::spi::MODE_0,
);
// Write out 0, ignore return value
if spi_bus.write(&[0]).is_ok() {
// SPI write was successful
};
}
Example Driver Usage
Usually, you will not talk to SPI directly. Instead, you pass an SPI device to a driver. This usually means creating an SPI bus first, then wrapping it using ExclusiveDevice provided by the embedded-hal-bus crate.
#![allow(unused)]
fn main() {
let clk = p.PIN_14;
let mosi = p.PIN_15;
let spi_bus = Spi::new_blocking_txonly(p.SPI1, clk, mosi, SpiConfig::default());
let spi_dev = ExclusiveDevice::new_no_delay(spi_bus, cs_pin).expect("Failed to get exclusive device");
// Create a display instance for a single 8x8 LED matrix (not daisy-chained)
let mut display = SingleMatrix::from_spi(spi_dev).expect("display count 1 should not panic");
}
MAX7219 8X8 Dot LED Matrix Display Module
In this chapter, we are going to work with one of the interesting display modules. It is an 8×8 Dot LED Matrix. An LED Dot Matrix is basically a grid of LEDs arranged in rows and columns that lets you display patterns, numbers, text, and simple graphics. The module we are working with is an 8×8 matrix, which gives us 64 individual LEDs to control.
Dot matrix displays are commonly used in everyday places. You can see them in buses and trains for route numbers and arrival messages, in elevators for floor numbers and direction arrows, and in digital clocks for time and basic symbols. They are also used on industrial machines and control panels to show operating status, warnings, or error messages, and in mall and arcade games.
Meet the Hardware
Now we are going to look at the hardware itself. The module is built around a chip called the MAX7219. This chip is responsible for driving all 64 LEDs in the 8x8 matrix.
Without this chip, you would need many GPIO pins because each row and column of the LED matrix would have to be connected and controlled directly by the microcontroller. The MAX7219 takes care of that internally, so the microcontroller only needs to send high level commands instead of managing every LED.
Tip
In this book, we will not look at the internal working of the MAX7219. We will use an existing driver and focus on controlling the display. If you want to learn how to build a MAX7219 driver from scratch, including implementing the embedded-graphics trait, you can find that in the RED (Rust Embedded Drivers) book.
Daisy Chaining
One useful feature of the MAX7219 is that modules can be daisy chained. This means you can connect multiple identical modules together to form a larger display. For example, you can connect 4 or 8 modules side by side to create a wider dot matrix.
Some modules come with headers already soldered to make chaining easier. When using multiple modules, you need to be careful with wiring and power, since more LEDs means higher current draw. We will start with a single module and keep things simple.
7-Segment Displays
The same MAX7219 chip is also commonly used with 7-segment LED displays. The internal working and communication method are the same. In this chapter, we will focus only on the 8x8 LED matrix. Once you understand this, working with 7-segment displays will feel very similar.
Communication
The MAX7219 uses a serial interface that is similar to SPI. It needs three control signals. DIN is the data line used to send information to the chip. CLK is the clock signal that tells the chip when to read each bit. CS, sometimes labeled LOAD, is used to latch the data into the chip.
On the microcontroller side, we use the SPI peripheral in a write only manner to send data to the MAX7219. The chip does not send any data back. It only receives commands and updates the display based on what we send.
Circuit
The MAX7219 display requires five connections to the Raspberry Pi Pico 2. This example uses SPI1 with GPIO 13, 14, and 15, but you can use any valid SPI pin set on your Pico.
| Pico Pin | Wire | MAX7219 Pin |
|---|---|---|
| GPIO 13 (SPI1 CS) |
|
CS / LOAD |
| GPIO 14 (SPI1 SCK) |
|
CLK |
| GPIO 15 (SPI1 MOSI) |
|
DIN |
| VBUS (5V) |
|
VCC |
| GND |
|
GND |
Embedded Rust Code to Control MAX7219 LED Matrix on Raspberry Pi Pico 2
Let us move on to the coding part. In this program, we will draw a square on the 8x8 LED matrix using embedded-graphics.
Tip
Drawing a square is just an example, but once you learn the basics, you can do much more with it. You can scroll text, display icons or smilies, and even animate objects. You can check out the examples here: https://github.com/ImplFerris/max7219-examples. You can also try building a simple clock using daisy-chained matrices.
As usual, create a new project using the provided template.
#![allow(unused)]
fn main() {
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.1
}
When prompted, give your project a name, like “hello-max7219” and select embassy as the HAL.
Additional crates
We will add the following crates along with existing dependencies
max7219-display = { version = "0.1.5", features = ["led-matrix", "graphics"] }
embedded-graphics = "0.8.0"
embedded-hal-bus = "0.3.0"
The max7219-display crate provides the driver used in this program. The driver supports different kinds of display modules, such as 7-segment displays and LED matrices. In our case, we are using an 8x8 LED matrix, so we enable the led-matrix feature. Since we want to draw shapes instead of manually controlling individual LEDs, we also enable the graphics feature, which implements the embedded-graphics traits for the driver.
The embedded-graphics crate is used to create shapes and text in a structured way. It provides primitives like rectangles, points, and styles, which makes drawing on the LED matrix much easier.
embedded-hal-bus
The embedded-hal crate defines common traits for peripherals like SPI, enabling drivers to work across different microcontrollers. To understand why embedded-hal-bus is needed, it helps to look at how these traits are organized.
Embedded HAL separates SPI into two distinct concepts: the SPI bus (representing the shared clock and data lines) and the SPI device (representing a single peripheral with its own chip select line). Microcontroller HALs typically provide an SPI bus implementation, but device drivers are written to work with SPI devices.
The embedded-hal-bus crate bridges this gap. It provides adapters that wrap an SPI bus and manage the chip select pin, creating the SPI device interface that drivers expect. In this chapter, we use ExclusiveDevice, the simplest adapter. It’s designed for scenarios where only one device uses the SPI bus, it automatically asserts the chip select at the start of each transaction and deasserts it when complete.
Additional Imports
We now add the imports needed for SPI, the MAX7219 display driver, and drawing with embedded-graphics.
#![allow(unused)]
fn main() {
// For MAX7219
use embedded_hal_bus::spi::ExclusiveDevice;
use max7219_display::led_matrix::display::SingleMatrix;
// For Drawing shapes
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{PrimitiveStyleBuilder, Rectangle};
// For SPI
use embassy_rp::spi::{Config as SpiConfig, Spi};
// For CS Pin
use embassy_rp::gpio::{Level, Output};
}
SPI setup and chip select
Let’s start by creating the chip select pin. The MAX7219 uses an active-low chip select line, so we initialize the pin in the high state. This keeps the device inactive until an SPI transfer begins.
#![allow(unused)]
fn main() {
let cs_pin = Output::new(p.PIN_13, Level::High);
}
Next, we assign the SPI clock and MOSI pins. Since the MAX7219 is a write-only device, only these two signals are required. There is no MISO pin involved in this setup.
#![allow(unused)]
fn main() {
let clk = p.PIN_14;
let mosi = p.PIN_15;
}
With the pins selected, we create the SPI peripheral. We use a transmit-only blocking SPI instance since all communication goes from the Pico to the display.
#![allow(unused)]
fn main() {
let spi_bus = Spi::new_blocking_txonly(p.SPI1, clk, mosi, SpiConfig::default());
}
At this point, we have an SPI bus, but it does not yet manage chip select. To handle that, we wrap the bus using ExclusiveDevice. This turns the SPI bus into an SPI device by automatically controlling the chip select pin for each transfer.
#![allow(unused)]
fn main() {
let spi_dev =
ExclusiveDevice::new_no_delay(spi_bus, cs_pin).expect("Failed to get exclusive device");
}
After this step, the SPI device is ready to be used by the MAX7219 display driver.
Creating the display instance
With the SPI device ready, we now create the LED matrix display instance. Here we are working with a single 8x8 LED matrix, not a daisy-chained setup. The display driver takes ownership of the SPI device and uses it to send commands and pixel data to the MAX7219.
#![allow(unused)]
fn main() {
// Create a display instance for a single 8x8 LED matrix (not daisy-chained)
let mut display = SingleMatrix::from_spi(spi_dev).expect("display count 1 should not panic");
}
Setting the display brightness
After creating the display instance, we set the brightness level. The MAX7219 supports multiple intensity levels, and here we configure it for the only device at index 0. A low intensity value is usually sufficient and avoids excessive brightness.
#![allow(unused)]
fn main() {
// Set brightness (intensity level) of the only device at index 0
display
.driver()
.set_intensity(0, 1)
.expect("failed to set intensity");
}
Drawing a rectangle
Now we draw a simple shape using embedded-graphics. Instead of filling the entire area, we draw a hollow rectangle so only the border pixels are turned on. I first tried a filled rectangle, but it did not look good, so I have commented out that version.
First, we define a drawing style. This style turns pixels on and sets the stroke width to one pixel, which works well for an 8x8 display.
#![allow(unused)]
fn main() {
// ---- Draw Rectangle ----
// let rect = Rectangle::new(Point::new(1, 1), Size::new(6, 6)).into_styled(
// embedded_graphics::primitives::PrimitiveStyle::with_fill(BinaryColor::On),
// );
let hollow_rect_style = PrimitiveStyleBuilder::new()
.stroke_color(BinaryColor::On) // Only draw the border
.stroke_width(1) // Border thickness of 1 pixel
.build();
}
Next, we define the rectangle. The top-left corner is placed at x = 1 and y = 1, and the rectangle spans 6 pixels in both width and height. This leaves a one-pixel margin around the edges, so the border does not touch the outer LEDs.
#![allow(unused)]
fn main() {
let rect = Rectangle::new(Point::new(1, 1), Size::new(6, 6)).into_styled(hollow_rect_style);
}
When we draw the rectangle, it is written into the display buffer. At this stage, nothing has been sent to the hardware yet.
#![allow(unused)]
fn main() {
rect.draw(&mut display).expect("failed to draw the shape");
}
To actually update the LED matrix, we flush the framebuffer. This sends the pixel data over SPI to the MAX7219.
#![allow(unused)]
fn main() {
display.flush().expect("failed to send to the device");
}
The full code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
// For MAX7219
use embedded_hal_bus::spi::ExclusiveDevice;
use max7219_display::led_matrix::display::SingleMatrix;
// For Drawing shapes
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::{PrimitiveStyleBuilder, Rectangle};
// For SPI
use embassy_rp::spi::{Config as SpiConfig, Spi};
// For CS Pin
use embassy_rp::gpio::{Level, Output};
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let cs_pin = Output::new(p.PIN_13, Level::High);
let clk = p.PIN_14;
let mosi = p.PIN_15;
let spi_bus = Spi::new_blocking_txonly(p.SPI1, clk, mosi, SpiConfig::default());
let spi_dev =
ExclusiveDevice::new_no_delay(spi_bus, cs_pin).expect("Failed to get exclusive device");
// Create a display instance for a single 8x8 LED matrix (not daisy-chained)
let mut display = SingleMatrix::from_spi(spi_dev).expect("display count 1 should not panic");
// Set brightness (intensity level) of the only device at index 0
display
.driver()
.set_intensity(0, 1)
.expect("failed to set intensity");
// ---- Draw Rectangle ----
// let rect = Rectangle::new(Point::new(1, 1), Size::new(6, 6)).into_styled(
// embedded_graphics::primitives::PrimitiveStyle::with_fill(BinaryColor::On),
// );
let hollow_rect_style = PrimitiveStyleBuilder::new()
.stroke_color(BinaryColor::On) // Only draw the border
.stroke_width(1) // Border thickness of 1 pixel
.build();
let rect = Rectangle::new(Point::new(1, 1), Size::new(6, 6)).into_styled(hollow_rect_style);
rect.draw(&mut display).expect("failed to draw the shape");
display.flush().expect("failed to send to the device");
loop {
Timer::after_millis(100).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"hello-max7219"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone (or refer to) the project I created and navigate to the hello-max7219 folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/max7219/hello-max7219
RFID
We will use the RFID Card Reader (RC522) module to read data from RFID tags and key fobs.
RFID is commonly used in systems like apartment access keys, office entry cards, smart parking setups, hotel keycards, toll passes, and contactless credit cards. These systems all work in a similar way. You bring a card or tag close to a reader, and the reader identifies it wirelessly.
We will do a similar thing using the Raspberry Pi Pico, but instead of just reading data, we will also learn how to write data to RFID tags and understand how the system works.
What is RFID?
RFID stands for Radio Frequency Identification. It is a wireless technology used to identify objects using radio waves.
An RFID system has two main parts. One is the reader, and the other is the tag. The reader generates a radio field. When a tag comes close to this field, the tag is powered by it and sends data back to the reader. Most basic RFID tags do not have a battery.
The data stored on a tag can be as simple as a unique ID, or it can include small blocks of memory that can be read from and written to. The reader handles all the radio communication, and your microcontroller talks to the reader using a standard interface like SPI.
In this book, we will focus on short range RFID, where the tag needs to be very close to the reader. This is the type commonly used for access cards, key fobs, and similar systems.
Categories By Range
RFID systems can be categorized by their operating frequency, which directly affects communication range, data rate, and typical use cases. The three main categories are Low Frequency (LF), High Frequency (HF), and Ultra High Frequency (UHF).
-
Low Frequency (LF)
LF RFID operates around 125 kHz. These systems have a very short read range, typically up to about 10 cm. Data transfer is slow, but LF RFID is relatively tolerant to interference from metal and liquids. Because of this, it is commonly used in simple access control systems and livestock tracking. -
High Frequency (HF)
HF RFID operates at 13.56 MHz and typically offers a read range from about 10 cm up to 1 m, depending on antenna size and power. HF systems provide moderate data rates and support more complex protocols, allowing both read and write operations. This category is widely used in access control systems for offices, apartments, and hotels, as well as in ticketing, contactless payments, and short range data transfer.We are going to use this category in this book, specifically the RC522 module, which operates at 13.56 MHz.
-
Ultra High Frequency (UHF)
UHF RFID operates in the 860 to 960 MHz range and supports much longer read distances, often up to 12 m or more. These systems offer higher data rates and can read multiple tags simultaneously. UHF RFID is commonly used in retail inventory management, logistics, supply chain tracking, and anti counterfeiting applications.
Categories By Power source
RFID tags can be categorized based on how they are powered. The two main types are active and passive tags.
-
Active RFID Tags
Active tags include an internal battery, which allows them to transmit signals on their own. This enables much longer communication ranges compared to passive tags. Active RFID tags are commonly used for tracking large assets such as rail cars, shipping containers, and equipment that must be monitored over long distances. -
Passive RFID Tags
Passive tags do not have a battery. Instead, they draw power from the electromagnetic field generated by the RFID reader. Once powered, the tag sends its data back to the reader using radio waves. Passive RFID tags are the most widely used type and are found in access cards, key fobs, and contactless payment systems.
The RC522 module used in this book works with passive RFID tags.
RFID System Components
The RFID reader is technically referred to as the PCD (Proximity Coupling Device). In passive RFID systems, the reader generates an electromagnetic field that powers the tag and enables communication.
The tag itself is called an RFID tag, or in technical terms, a PICC (Proximity Integrated Circuit Card). It is good to know these technical terms as well. They will come in handy if you want to refer to datasheets and other documents.
RFID readers typically include internal components such as FIFO buffers and non volatile memory like EEPROM. Many readers also include cryptographic features to support secure communication with tags, allowing only authenticated readers to interact with protected data. For example, RFID readers from NXP Semiconductors use the Crypto-1 cipher for authentication.
Each RFID tag has a hardcoded UID (Unique Identifier). Depending on the tag type, this UID can be 4, 7, or 10 bytes in size and is used to uniquely identify the tag.
References
Meet the module
We will be using the RC522 RFID Card Reader Module, which is built on the MFRC522 IC (designed by NXP), operates at 13.56 MHz . This module is widely available online at an affordable price and typically comes with an RFID tag (MIFARE Classic 1K) and key fob, each containing 1KB of memory. MFRC522 Datasheet can be found here.
The microcontroller can communicate with the reader using SPI, UART, I2C. It also has an IRQ (Interrupt Request) pin that can trigger interrupts, so the microcontroller(pico) knows when the tag is nearby, instead of constantly asking the reader (kind of like “Are we there yet?”).
Unfortunately, the library we’re going to use doesn’t support this feature yet, so we won’t be using it for now. We’ll update this section once support is added. So, are we there yet?
Additional Information about the Module:
- Supported Standards: ISO/IEC 14443 A / MIFARE
- Card Reading Distance: 0~50 mm
- Idle Current: 10–13 mA
- Operating Current: 13–26 mA
- Operating Voltage: DC 3.3V (⚠️ Do not use 5V or higher, it will cause damage).
MIFARE
MIFARE is a series of integrated circuit (IC) chips used in contactless smart cards and proximity cards, developed by NXP Semiconductors. MIFARE cards follow ISO/IEC 14443A standards and use encryption methods such as Crypto-1 algorithm. The most common family is MIFARE Classic, with a subtype called MIFARE Classic EV1.
Memory Layout
The MIFARE Classic 1K card is divided into 16 sectors, with each sector containing 4 blocks. Each block can hold up to 16 bytes, resulting in a total memory capacity of 1KB.
16 sectors × 4 blocks/sector × 16 bytes/block = 1024 bytes = 1KB
This diagram is based on the memory layout shown in the datasheet. I have added color coding and separator lines to make the structure easier to read. Each sector is separated using a yellow horizontal line. The sector trailer block is highlighted in dark red. All regular data blocks are shown in green. The manufacturer block is highlighted separately to distinguish it from user writable data.
Sector Trailer
The last block of each sector, known as the “trailer” holds two secret keys and programmable access conditions for the blocks within that sector. Each sector has its own pair of keys (KeyA and KeyB), enabling support for multiple applications with a key hierarchy.
Note
Default Keys: The MIFARE Classic 1K card is pre-configured with the default key FF FF FF FF FF FF for both KeyA and KeyB. When reading the trailer block, KeyA values are returned as all zeros (00 00 00 00 00 00), while KeyB returned as it is.
By default, the access bytes (6, 7, and 8 of the trailer) are set to FF 07 80h. You can refer the 10th page for the datasheet for more information. And the 9th byte can be used for storing data.
| Byte Number | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | |
| Description | KEY A | Access Bits | USER Data | KEY B | ||||||||||||
| Default Data | FF | FF | FF | FF | FF | FF | FF | 07 | 80 | 69 | FF | FF | FF | FF | FF | FF |
Manufacturer Block
The first block (block 0) of the first sector(sector 0) contains IC manufacturer’s data including the UID. This block is write-protected.
Data Block
Since each sector has a trailer block, so only 3 blocks can be used for data storage in each sector. However, the first sector only has 2 usable blocks because the first block stores manufacturer data.
To read or write the data, you first need to authenticate with either Key A or Key B of that sector.
The data blocks can be further classified into two categories based on the access bits(we will explain about it later).
- read/write block: These are standard data blocks that allow basic operations such as reading and writing data.
- value block: These blocks are ideal for applications like electronic purses, where they are commonly used to store numeric values, such as account balances. So, you can perform incrementing (e.g., adding $10 to a balance) or decrementing (e.g., deducting $5 for a transaction).
Reference
- Datasheet: MIFARE Classic EV1 1K - Mainstream contactless smart card IC for fast and easy solution development
Flow
When you bring a tag near the reader, the tag is powered by the reader RF field and is ready to respond to polling commands.
To check if any tag is nearby, we send a polling command. Normally this is REQA. If a tag was previously put into the HALT state, we use WUPA instead. If a tag is present and allowed to respond, it replies with ATQA (Answer To Request).
Important
Note: Once the card is in the HALT state, only the WUPA (Wake Up) command can wake it up and let us do more operations. It always reminds me of Chandler from Friends saying “WOOPAAH”. REQA works only on idle cards.
After receiving ATQA, we run the anticollision and select procedure. During this step, we handle collisions if multiple tags are present and retrieve the UID of one tag. Once this procedure completes, the card is selected and active.
After the card is selected, we authenticate the specific sector we want to read from or write to. Authentication must be performed again when accessing a different sector.
Once authentication succeeds, we can perform operations such as read, write, increment, decrement, or restore.
When all operations are done, we send the HLTA command to put the card into the HALT state. While the RF field remains on, a halted card will not respond to REQA and can only be reactivated using WUPA. If the card leaves the RF field and comes back, it starts again from the beginning.
Connecting RC522 with Raspberry Pi Pico
We will see how to connect the RFID Reader to Pico 2. Before that, we will have a quick look at the pinout of the RC522 module.
Pinout diagram of RC522
The RC522 RFID module exposes 8 pins. Some pins have different functions depending on whether the module is used with SPI, I2C, or UART. The diagram below shows all available functions for each pin. In our setup, we will later use SPI, but it is useful to understand the full pinout first.
| Pin | SPI Function | I²C Function | UART Function | Description |
|---|---|---|---|---|
| 3.3V | Power | Power | Power | Power supply (3.3V). |
| GND | Ground | Ground | Ground | Ground connection. |
| RST | Reset | Reset | Reset | Reset the module. |
| IRQ | Interrupt (optional) | Interrupt (optional) | Interrupt (optional) | Interrupt Request (IRQ) informs the microcontroller when an RFID tag is detected. Without using IRQ, the microcontroller would need to constantly poll the module. |
| MISO | Master-In-Slave-Out | SCL | TX | In SPI mode, it acts as Master-In-Slave-Out (MISO). In I²C mode, it functions as the clock line (SCL). In UART mode, it acts as the transmit pin (TX). |
| MOSI | Master-Out-Slave-In | - | - | In SPI mode, it acts as Master-Out-Slave-In (MOSI). |
| SCK | Serial Clock | - | - | In SPI mode, it acts as the clock line that synchronizes data transfer. |
| SDA | Chip Select (CS) | SDA | RX | In SPI mode, it acts as the Chip select (CS/SS, also referred as Slave Select). In I²C mode, it serves as the data line (SDA). In UART mode, it acts as the receive pin (RX). |
Connecting the RFID Reader to the Raspberry Pi Pico
To establish communication between the Raspberry Pi Pico and the RFID reader, we will use the SPI protocol. In this setup, we will use SPI0 on the Pico. For now, we are not using the RST and IRQ pins.
The table below shows how to connect the RC522 module to the Pico using SPI.
| Pico Pin | Wire | RFID Reader Pin |
|---|---|---|
| 3.3V |
|
3.3V |
| GND |
|
GND |
| GPIO 0 |
|
MISO |
| GPIO 1 |
|
SDA (CS) |
| GPIO 2 |
|
SCK |
| GPIO 3 |
|
MOSI |
How to Read an RFID Card UID with Raspberry Pi Pico in Embedded Rust
Now that the wiring is complete, we will move on to the coding part. Let us keep things simple. We will first detect an RFID card and read its UID. We will not deal with authentication or data access yet.
In this section, we will print the UID using a debug probe. Logging is done with defmt, and the output appears in the host console through RTT. If you have a debug probe, this is the simplest way to print and inspect the output.
Important
In the next section, we will read the same UID but print it using USB serial instead. This is useful when you do not have a debug probe and want to print data to the system console. You are not limited to these two approaches. You may also display the UID on an OLED screen.
mfrc522 Driver
We will use the mfrc522 crate to communicate with the RC522 RFID reader. The crate provides the core functionality required to detect cards and read their UIDs. While it is still under development, it is sufficient for the features we need in this chapter.
Project from template
We will start by creating a new project using the template.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
When prompted, enter a project name, for example “print-uid”, and select “Embassy” as the HAL. Ensure to enable “defmt” for logging.
Additional Crates required
Update your Cargo.toml to add the required crates along with the existing dependencies. The set of crates depends on how the output is printed.
mfrc522 = "0.8.0"
embedded-hal-bus = "0.3.0"
The mfrc522 crate provides the driver for communicating with the RC522 RFID reader and handles card detection and UID reading. The embedded-hal-bus crate enables SPI bus sharing through the ExclusiveDevice wrapper, which the mfrc522 driver requires.
Additional imports
Add these imports to your main.rs file:
#![allow(unused)]
fn main() {
// For SPI
use embassy_rp::spi::Spi;
use embassy_rp::spi;
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
// For CS Pin
use embassy_rp::gpio::{Level, Output};
// Driver for the MFRC522
use mfrc522::{Mfrc522, comm::blocking::spi::SpiInterface};
}
Setting Up SPI for the RFID Reader
Let’s set up the SPI bus and the pins connected to the reader. This example uses SPI0:
#![allow(unused)]
fn main() {
let miso = p.PIN_0;
let cs_pin = Output::new(p.PIN_1, Level::High);
let clk = p.PIN_2;
let mosi = p.PIN_3;
let mut config = spi::Config::default();
config.frequency = 1000_000;
let spi_bus = Spi::new_blocking(p.SPI0, clk, mosi, miso, config);
}
We configure the SPI bus to run at 1 MHz, which provides reliable communication with the RFID reader. The chip select pin starts high, which is the idle state for SPI devices.
Getting the SpiDevice from SPI Bus
The mfrc522 driver expects an SpiDevice rather than a raw SPI bus. We use the embedded-hal-bus crate to create this device:
#![allow(unused)]
fn main() {
let spi = ExclusiveDevice::new(spi_bus, cs_pin, Delay).expect("Failed to get exclusive device");
}
We use ExclusiveDevice since our setup has just one device on the SPI bus. This wrapper holds the SPI bus and CS pin together, automatically controlling the chip select line during each read or write operation. The delay parameter provides timing control.
Initialize the mfrc522
With the SPI device ready, we can now initialize the RFID reader.
#![allow(unused)]
fn main() {
let itf = SpiInterface::new(spi);
let mut rfid = Mfrc522::new(itf)
.init()
.expect("failed to initialize the RFID reader");
}
Read the UID and Print
The main loop continuously checks for nearby RFID cards. When a card is detected, we read its UID and display it:
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
defmt::info!("UID: {:02x}", uid.as_bytes());
Timer::after_millis(500).await;
}
}
}
}
The reqa method sends a Request command to detect cards in proximity. When a card responds with its ATQA (Answer To Request), we call select to perform the anti-collision protocol and retrieve the UID. The UID bytes are formatted as hexadecimal and sent through defmt to the RTT console.
Clone the existing project
You can clone (or refer) project I created and navigate to the print-uid folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/print-uid/
How to Run ?
Don’t forget to use the cargo embed --release command to flash instead of cargo flash --release when you are using a debug probe. I already configured in the template to open up the RTT when flashed, you should see the defmt output.
Now, bring the RFID tag near the reader. You should see the UID bytes displayed in hex format in the RTT console.
Read RFID Card UID Using USB Serial
Before we begin, make sure to check out the USB serial tutorial for setting up USB serial on the Pico. We will not go over the USB setup again here to keep things simple.
In this section, we will read the UID of an RFID card and print it using USB serial. This approach is useful when you do not have a debug probe and want the Pico to appear as a serial device on your computer.
The RFID logic remains the same as the previous section. The only difference is how we print the UID.
mfrc522 Driver
We will continue using the mfrc522 crate to communicate with the RC522 RFID reader. The driver handles card detection and UID reading. No changes are required on the RFID side.
Project from template
We will start by creating a new project using the template.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
When prompted, enter a project name, for example “uid-over-usb”, and select “Embassy” as the HAL. You do not need to enable defmt for this setup.
Additional Crates required
Update your Cargo.toml and add the following crates along with the existing dependencies:
mfrc522 = "0.8.0"
embedded-hal-bus = "0.3.0"
embassy-usb-logger = "0.5.1"
log = "0.4"
heapless = "0.9.2"
The embassy-usb-logger crate provides logging support over USB. The log crate is used for logging macros such as log::info!. The heapless crate is used to format the UID without heap allocation.
Additional imports
#![allow(unused)]
fn main() {
// For USB
use embassy_rp::{peripherals::USB, usb};
// For SPI
use embassy_rp::spi;
use embassy_rp::spi::Spi;
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
// For CS Pin
use embassy_rp::gpio::{Level, Output};
// Driver for the MFRC522
use mfrc522::{Mfrc522, comm::blocking::spi::SpiInterface};
// to prepare buffer with data before writing into USB serial
use core::fmt::Write;
use heapless::String;
}
USB Logger Setup
First, we need to bind the USB interrupt handler. This tells Embassy which interrupt handler to use for USB communication:
#![allow(unused)]
fn main() {
embassy_rp::bind_interrupts!(struct Irqs {
USBCTRL_IRQ => usb::InterruptHandler<USB>;
});
}
Next, we create an async task that will handle the USB logging. This task runs in the background and manages all USB serial communication:
#![allow(unused)]
fn main() {
#[embassy_executor::task]
async fn logger_task(usb: embassy_rp::Peri<'static, embassy_rp::peripherals::USB>) {
let driver = embassy_rp::usb::Driver::new(usb, Irqs);
embassy_usb_logger::run!(1024, log::LevelFilter::Info, driver);
}
}
The #[embassy_executor::task] attribute marks this function as an async task that can be spawned by the Embassy executor. The task accepts the USB peripheral as a parameter and creates a USB driver with our interrupt configuration. The embassy_usb_logger::run! macro sets up the USB logger with a 1024-byte buffer and Info log level filtering.
Helper Function to Print UID in Hex
We’ll use this helper function to convert the u8 byte array (in this case UID) into a printable hex string. You could also just use raw bytes and enable hex mode in tio (requires latest version) or minicom, but I find this approach easier. In hex mode, it prints everything in hex, including normal text.
#![allow(unused)]
fn main() {
fn print_hex_to_serial(data: &[u8]) {
let mut buff: String<64> = String::new();
for &d in data.iter() {
write!(buff, "{:02x} ", d).expect("failed to write byte into buffer");
}
log::info!("UID: {}", buff);
}
}
This function creates a fixed-size string buffer on the stack (no heap allocation required) and formats each byte as a two-digit hexadecimal number. The formatted string is then sent through the logging system.
Spawn the Logger Task
In the main function, we need to spawn the logger task and give it time to initialize:
#![allow(unused)]
fn main() {
spawner.must_spawn(logger_task(p.USB));
Timer::after_secs(3).await;
}
The spawner.must_spawn() method starts the logger task inside the Embassy executor. This task runs alongside our main code and handles USB serial logging. We then wait for 3 seconds to give the host computer time to recognize the Pico as a USB serial device and for the logger to be ready. This delay is important because the USB connection is set up gradually, and log messages sent too early may not show up on the host.
Setting Up SPI for the RFID Reader
SPI setup is the same as before.
#![allow(unused)]
fn main() {
let miso = p.PIN_0;
let cs_pin = Output::new(p.PIN_1, Level::High);
let clk = p.PIN_2;
let mosi = p.PIN_3;
let mut config = spi::Config::default();
config.frequency = 1000_000;
let spi_bus = Spi::new_blocking(p.SPI0, clk, mosi, miso, config);
}
This configures the SPI bus with a frequency of 1 MHz, which is suitable for the RC522 module. The chip select (CS) pin is initialized high (inactive state), as SPI chip select is active low.
Getting the SpiDevice from SPI Bus
The mfrc522 driver expects an SpiDevice rather than a raw SPI bus. We use the embedded-hal-bus crate to create this device:
#![allow(unused)]
fn main() {
let spi = ExclusiveDevice::new(spi_bus, cs_pin, Delay).expect("Failed to get exclusive device");
}
We use ExclusiveDevice since our setup has just one device on the SPI bus. This wrapper holds the SPI bus and CS pin together, automatically controlling the chip select line during each read or write operation. The delay parameter provides timing control between operations.
Initialize the mfrc522
With the SPI device ready, we can now initialize the RFID reader.
#![allow(unused)]
fn main() {
let itf = SpiInterface::new(spi);
log::info!("Initializing MFRC522...");
let mut rfid = match Mfrc522::new(itf).init() {
Ok(rfid) => {
log::info!("MFRC522 initialized successfully");
rfid
}
Err(e) => {
log::error!("Failed to initialize MFRC522: {:?}", e);
loop {
Timer::after_secs(1).await;
}
}
};
}
The SpiInterface wraps our SPI device for use with the mfrc522 driver. We then attempt to initialize the RFID reader. If initialization succeeds, we log a success message and continue. If it fails, we log the error and enter an infinite loop, as we cannot proceed without a working RFID reader.
Read the UID and Print
The main loop continuously checks for nearby RFID cards. When a card is detected, we read its UID and display it:
#![allow(unused)]
fn main() {
log::info!("Waiting for RFID");
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
print_hex_to_serial(uid.as_bytes());
Timer::after_millis(500).await;
}
}
Timer::after_millis(100).await;
}
}
The reqa method sends a Request command to detect cards in proximity. When a card responds with its ATQA (Answer To Request), we call select to perform the anti-collision protocol and retrieve the UID. The UID bytes are formatted as hexadecimal and sent through the log system to the USB serial console.
The 500ms delay after a successful read prevents flooding the serial output with repeated reads of the same card. The 100ms delay in the main loop provides a reasonable polling interval without consuming excessive CPU cycles.
Clone the existing project
You can clone (or refer) project I created and navigate to the uid-over-usb folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/uid-over-usb/
How to Run ?
The method to flash (run the code) on the Pico is the same as usual. However, we need to set up tio to interact with the Pico through the serial port (/dev/ttyACM0). This allows us to read data from the Pico or send data to it.
tio
Make sure you have tio installed on your system. If not, you can install it using:
apt install tio
Connecting to the Serial Port
Run the following command to connect to the Pico’s serial port:
tio /dev/ttyACM0
This will open a terminal session for communicating with the Pico. The /dev/ttyACM0 device appears when the Pico is recognized by the host as a USB CDC ACM device.
Flashing and Running the Code
Open another terminal, navigate to the project folder, and flash the code onto the Pico as usual:
cargo run --release
If everything is set up correctly, you should see a “Connected” message in the tio terminal.
Reading the UID
Now, bring the RFID tag near the reader. You should see the UID bytes displayed in hex format on the USB serial terminal. Each byte is shown as a two-digit hexadecimal number.
rp-hal version
If you are interested in an rp-hal based version of this example, you can find it in the repository below. Clone the repository and navigate to the print-uid project:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/rfid/print-uid/
Turn on LED When RFID UID Matches
In this section, we’ll use the UID obtained in the previous chapter and hardcode it into our program. The LED will turn on only when the matching RFID tag is nearby; otherwise, it will remain off. When you bring the RFID tag close, the LED will light up. If you bring a different tag, like a key fob or any other RFID tag, the LED will turn off.
This should give you a basic authorized vs unauthorized feel. Simply checking the UID is not enough for real security, and we will go deeper into reading and authentication in the next chapter. For now, this is a simple and fun way to get started. You can also extend this example by adding an OLED display and showing Authorized or Unauthorized messages instead of using an LED.
Getting Your Card’s UID
Before writing the LED control logic, you need to know the UID of your RFID card.
Run the code from the previous chapter that prints the UID. When you bring your card near the reader, you will see output like this:
UID: 13 37 73 31
These are the four bytes you’ll hardcode into the program as [0x13, 0x37, 0x73, 0x31].
Logic
The logic used here is straightforward. The LED is kept off by default. The program continuously checks for an RFID tag. When a tag is detected, its UID is read and compared with the hardcoded UID. If they match, the LED is turned on. If they do not match, or if no tag is present, the LED remains off.
#![allow(unused)]
fn main() {
// Replace the UID Bytes with your tag UID
const TAG_UID: [u8; 4] = [0x13, 0x37, 0x73, 0x31];
let mut led = Output::new(p.PIN_25, Level::Low);
loop {
led.set_low();
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
if *uid.as_bytes() == TAG_UID {
led.set_high();
Timer::after_millis(500).await;
}
}
}
Timer::after_millis(100).await;
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the rfid-led folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/rfid-led/
Light it Up
Flash the program onto the Pico as you normally would:
# if you are using debug probe
# cargo flash --release
cargo embed --release
# if you are using BOOTSEL approach
cargo run --release
Once the program is running, bring the matching RFID tag near the reader and observe the onboard LED turning on. Move the tag away and the LED turns off. Try a different card or key fob and you will see that the LED does not turn on for non matching UIDs.
rp-hal version
If you are interested in an rp-hal based version of this example, you can find it in the repository below. Clone the repository and navigate to the rfid-led project:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/rfid/rfid-led/
Reading RFID Tag Data on Raspberry Pi Pico with Embedded Rust
In this chapter, we move beyond just reading the UID and start reading actual data stored on the RFID tag. We will read all the blocks from the first sector, which is sector 0.
As mentioned earlier, reading or writing data on an RFID tag is not possible directly. Before accessing a block, we must first authenticate with the sector that contains that block. Once authentication succeeds, the reader is allowed to read or write the blocks in that sector.
Select Your Output Method
In this chapter, the instructions and code differ slightly depending on how you are printing the output. Select the appropriate tab based on whether you are using a debug probe or USB serial.
In this setup, we use defmt over RTT to print the block data.
Project from Template
We will start by creating a new project using the template.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
When prompted, enter a project name, for example “read-blocks”, and select “Embassy” as the HAL. Ensure to enable “defmt” for logging.
Additional Crates Required
Update your Cargo.toml to add the required crates along with the existing dependencies.
mfrc522 = "0.8.0"
embedded-hal-bus = "0.3.0"
heapless = { version = "0.9.2", features = ["defmt"] }
Additional Imports
#![allow(unused)]
fn main() {
// For SPI
use embassy_rp::spi::Spi;
use embassy_rp::{self as hal, spi};
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
// For CS Pin
use embassy_rp::gpio::{Level, Output};
// Driver for the MFRC522
use mfrc522::{Mfrc522, comm::blocking::spi::SpiInterface};
// to prepare buffer with data before logging
use core::fmt::Write;
use heapless::String;
}
Reusing the existing setup
The remaining SPI and RFID reader initialization is the same as in the previous chapter. You can reuse that setup here without any changes. In this chapter, we only add a few helper functions and modify the main loop to read blocks and print their contents.
Authentication
Most MIFARE Classic tags ship with a default sector key. The most common default key is six bytes of 0xFF. If authentication fails, you may need to check the tag documentation or try a different key.
For authentication, we need three things:
- The tag UID, which we already obtain using the REQA and select steps
- The absolute block number we want to authenticate against
- The key used for authentication, which is hardcoded in this example
Helper function to print bytes as hex dump
This helper converts raw bytes into a hexadecimal format and prints them.
#![allow(unused)]
fn main() {
fn print_hex(data: &[u8]) {
let mut buff: String<64> = String::new();
for &d in data.iter() {
write!(buff, "{:02x} ", d).expect("failed to write byte into buffer");
}
defmt::println!("{}", buff);
}
}
Read the block
Once authentication with a sector succeeds, the RFID reader is allowed to access the blocks that belong to that sector. From this point on, the reader can issue read commands for those blocks.
Each block on a MIFARE Classic tag stores exactly 16 bytes of data. The mf_read function provided by the mfrc522 crate reads one block at a time and returns these 16 bytes when the operation succeeds. If the read fails, an error is returned.
In this chapter, we focus on sector 0. Sector 0 contains four blocks. Authentication is performed once for the sector, and all four blocks are read in sequence. As long as we stay within the same sector, authentication does not need to be repeated.
#![allow(unused)]
fn main() {
fn read_sector<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
const AUTH_KEY: [u8; 6] = [0xFF; 6];
let block_offset = sector * 4;
rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
.map_err(|_| "Auth failed")?;
for abs_block in block_offset..block_offset + 4 {
let data = rfid.mf_read(abs_block).map_err(|_| "Read failed")?;
print_hex(&data);
}
Ok(())
}
}
When accessing memory on the tag, we have to refer to blocks using absolute block numbers. The tag treats all blocks as part of one continuous sequence rather than using sector local numbering. For example, sector 0 uses blocks 0 to 3, sector 1 uses blocks 4 to 7, and sector 2 uses blocks 8 to 11.
To convert a sector number into the correct block number, we calculate an offset. Since each sector contains four blocks, multiplying the sector number by 4 gives the first block of that sector. This value is stored in block_offset and is then used as the starting point to read all blocks that belong to the selected sector.
For the first sector, which is sector 0, the calculation is straightforward. The offset is 0 * 4, which gives block 0. This means sector 0 starts at block 0 and includes blocks 0, 1, 2, and 3. These are the blocks we read when we authenticate and access the first sector.
The main loop
The main loop operates similarly to what we covered in the previous chapter. After selecting a tag, we proceed to read its blocks. Once the block data is read, the loop sends the HLTA and stop_crypto1 commands to put the card in HALT state.
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
if let Err(e) = read_sector(&uid, 0, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
let _ = rfid.hlta();
let _ = rfid.stop_crypto1();
Timer::after_millis(100).await;
}
}
Timer::after_millis(100).await;
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the read-blocks folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/read-blocks/
How to Run?
Use cargo embed to flash and run the program, since the template already configures RTT and defmt and the output appears in the terminal after flashing completes.
cargo embed --release
Reading the Block Data
Bring the RFID tag close to the reader, and the system console will display the data bytes read from all blocks in the first sector (sector 0).
rp-hal version
If you are interested in an rp-hal based version of this example, you can find it in the repository below. Clone the repository and navigate to the read-blocks project:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects//rfid/read-blocks/
Dump Memory
Dump Entire Memory
So far, you have learned how to authenticate a sector and read the blocks inside it. In the previous chapter, we limited ourselves to sector 0. In this chapter, we extend that approach to dump the entire memory of the tag.
We will loop through all sectors on the tag, authenticate each sector, and read every block inside it. Since authentication is valid only for a single sector, we must authenticate again whenever we move to the next sector.
For each sector, we will read all four blocks and print their 16 byte contents. To make the output easier to understand, we will add labels that show:
- The sector number
- The absolute block number
- The block number relative to the sector
- Whether the block is manufacturer data, a sector trailer, or normal data
To keep this chapter focused, we will not repeat the project creation or dependency setup steps. By now, you should already be comfortable with the required crates and project structure.
Select Your Output Method
As before, the core logic is the same for both setups. Only the logging output differs slightly.
Select the tab that matches how you are printing the output.
In this setup, we use defmt over RTT to print the block data.
Loop through the sector
We start by creating a helper function that iterates through all sectors on the tag. For a MIFARE Classic 1K tag, this means sectors 0 through 15.
For each sector, we print a header and then call read_sector to authenticate and read its blocks.
#![allow(unused)]
fn main() {
fn dump_memory<E, COMM>(
uid: &mfrc522::Uid,
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
let mut buff: String<64> = String::new();
for sector in 0..16 {
// Printing the Sector number
write!(buff, "-----------SECTOR {}-----------", sector)
.expect("failed to write into heapless buff");
defmt::println!("{}", buff);
buff.clear();
read_sector(uid, sector, rfid)?;
}
Ok(())
}
}
Identify Block Type
Next, we add a small helper function that determines the type of a block based on its position within a sector.
#![allow(unused)]
fn main() {
fn get_block_type(sector: u8, rel_block: u8) -> &'static str {
match rel_block {
0 if sector == 0 => "MFD",
3 => "TRAILER",
_ => "DATA",
}
}
}
Reading a Sector with Labels
The read_sector function is similar to what you used earlier, but now it adds more context to the output.
#![allow(unused)]
fn main() {
fn read_sector<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
const AUTH_KEY: [u8; 6] = [0xFF; 6];
let mut buff: String<64> = String::new();
let block_offset = sector * 4;
rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
.map_err(|_| "Auth failed")?;
for abs_block in block_offset..block_offset + 4 {
let rel_block = abs_block - block_offset;
let data = rfid.mf_read(abs_block).map_err(|_| "Read failed")?;
// Printing the block data
for &d in data.iter() {
write!(buff, "{:02x} ", d).expect("failed to write byte into buffer");
}
// Printing block type
let block_type = get_block_type(sector, rel_block);
defmt::println!(
"BLOCK {} (REL: {}) | {} | {}",
abs_block,
rel_block,
buff,
block_type
);
buff.clear();
}
defmt::println!("");
Ok(())
}
}
The main loop
The main loop stays mostly the same. The only difference is that we now call dump_memory instead of reading a single sector.
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
if let Err(e) = dump_memory(&uid, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
let _ = rfid.hlta();
let _ = rfid.stop_crypto1();
Timer::after_millis(500).await;
}
}
Timer::after_millis(200).await;
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the memory-dump folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/memory-dump/
Dump
When you run the program and bring your tag or key fob close, you should see output like this. If you notice the 0x40..0x43 bytes in the block 18 (the block 2 of the sector 4) and wonder why it’s there; good catch! That’s the custom data I wrote to the tag.
Write Data
In the previous chapters, we focused on reading data from the tag. In this chapter, we move one step further and write data to the tag memory.
We will write data to block 2 of sector 4. To clearly see the effect of the write operation, we first read and print the block contents before writing, then read the same sector again after the write completes.
Writing is done using the mf_write function provided by the mfrc522 crate. This function writes exactly 16 bytes, which matches the size of a single block on a MIFARE Classic tag.
Caution
Writing to the wrong block can permanently change authentication keys or access bits. Avoid writing to sector trailer blocks unless you fully understand their structure.
Writing a Block
We begin by defining a helper function that performs the write operation. The function receives the tag UID, the sector number, the block number relative to the sector, a 16 byte data buffer, and a mutable reference to the RFID reader. Inside the function, the absolute block number is calculated and the sector is authenticated before the write is performed.
#![allow(unused)]
fn main() {
fn write_block<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
rel_block: u8,
data: [u8; 16],
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
const AUTH_KEY: [u8; 6] = [0xFF; 6];
let block_offset = sector * 4;
let abs_block = block_offset + rel_block;
rfid.mf_authenticate(uid, block_offset, &AUTH_KEY)
.map_err(|_| "Auth failed")?;
rfid.mf_write(abs_block, data).map_err(|_| "Write failed")?;
Ok(())
}
}
Preparing the Data
Each block stores exactly 16 bytes. When writing text or smaller values, the remaining bytes still need to be provided. In this example, we write the string implRust and pad the remaining bytes with 0x00.
#![allow(unused)]
fn main() {
let target_sector = 4;
let rel_block = 2;
const DATA: [u8; 16] = [
b'i', b'm', b'p', b'l', b'R', b'u', b's', b't', // "implRust"
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Remaining bytes as 0x00
];
}
Main Loop
The main loop follows a simple sequence. After detecting and selecting a tag, the current contents of the target sector are read and printed. The new data is then written to the selected block, and the sector is read again to confirm the change. Finally, the tag is placed into the HALT state and encryption is stopped.
If you are using USB serial instead of a debug probe, replace defmt::info! and defmt::error! with log::info! and log::error!.
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
defmt::info!("\r\n----Before Write----\r\n");
if let Err(e) = read_sector(&uid, target_sector, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
if let Err(e) = write_block(&uid, target_sector, rel_block, DATA, &mut rfid) {
defmt::error!("Error writing data: {:?}", e);
}
defmt::info!("\r\n----After Write----\r\n");
if let Err(e) = read_sector(&uid, target_sector, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
let _ = rfid.hlta();
let _ = rfid.stop_crypto1();
Timer::after_millis(500).await;
}
}
Timer::after_millis(100).await;
}
}
Clone the existing project
You can clone the example project and navigate to the write example directory.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/rfid/write-data/
Output
When the program runs successfully, the output shows the block contents before and after the write. You should see the ASCII bytes for implRust appear in block 2, confirming that the write operation worked as expected.
rp-hal version
If you are interested in an rp-hal based version of this example, you can find it in the repository below. Clone the repository and navigate to the write-data project:
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-rp-projects/rfid/write-data/
Change Auth Key
Changing the Authentication Key
In this chapter, we change the authentication key for a sector. Specifically, we update Key A of sector 1. By default, both Key A and Key B on a MIFARE Classic 1K card are set to FF FF FF FF FF FF. We will replace Key A with the value 52 75 73 74 65 64, which is the hex representation of the ASCII string “Rusted”.
To change the key, we must write to the sector trailer, which is block 3 of the sector. The rest of the sector data must remain untouched. Writing to the trailer block is a sensitive operation, so it is important to be careful.
Before making any changes, it is strongly recommended to inspect the current contents of the sector trailer. You can do this by running the Dump Memory or Read Data program from the earlier chapters.
Note
Default Keys: On a MIFARE Classic 1K card, both Key A and Key B are initially set to FF FF FF FF FF FF. When reading a trailer block, Key A is not returned and appears as 00 00 00 00 00 00, while Key B is returned as stored.
In this example, we also update Key B so that it is easy to confirm that the write operation succeeded. Key B is set to the hex bytes for the ASCII string “Ferris”, which is 46 65 72 72 69 73.
The program prints the contents of the sector before and after writing. After the key is changed, attempting to authenticate again with the old key will fail. This is expected behavior and confirms that the new key is now active.
Key and Trailer Data
The data written to the trailer block contains three parts. The first six bytes are the new Key A value, followed by the access bits and trailer byte, and finally the six bytes for Key B.
The current authentication key is set to the default value. The new key is extracted from the first six bytes of the data array so that it can be reused when reading the sector after the update.
#![allow(unused)]
fn main() {
let target_sector = 1;
let rel_block = 3;
const DATA: [u8; 16] = [
0x52, 0x75, 0x73, 0x74, 0x65, 0x64, // Key A: "Rusted"
0xFF, 0x07, 0x80, 0x69, // Access bits and trailer byte
0x46, 0x65, 0x72, 0x72, 0x69, 0x73, // Key B: "Ferris"
];
let current_key = &[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
let new_key: &[u8; 6] = &DATA[..6].try_into().expect("have enough data");
}
Writing the Trailer Block
The write function is slightly modified to accept the authentication key as an argument. This allows us to authenticate with the old key before writing and then authenticate with the new key afterward.
#![allow(unused)]
fn main() {
fn write_block<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
rel_block: u8,
data: [u8; 16],
key: &[u8; 6],
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
let block_offset = sector * 4;
let abs_block = block_offset + rel_block;
rfid.mf_authenticate(uid, block_offset, key)
.map_err(|_| "Auth failed")?;
rfid.mf_write(abs_block, data).map_err(|_| "Write failed")?;
Ok(())
}
}
Reading a Sector with a Custom Key
The read function is updated in the same way. It now accepts the key as an argument and uses it during authentication.
#![allow(unused)]
fn main() {
fn read_sector<E, COMM>(
uid: &mfrc522::Uid,
sector: u8,
key: &[u8; 6],
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str>
where
COMM: mfrc522::comm::Interface<Error = E>,
{
let block_offset = sector * 4;
rfid.mf_authenticate(uid, block_offset, key)
.map_err(|_| "Auth failed")?;
for abs_block in block_offset..block_offset + 4 {
let data = rfid.mf_read(abs_block).map_err(|_| "Read failed")?;
print_hex(&data);
}
Ok(())
}
}
The main loop
The main loop follows the same structure as earlier chapters. First, the sector is read using the current key. Then the trailer block is written with the new key. Finally, the sector is read again using the new key to confirm the change.
If you are using USB serial instead of a debug probe, replace defmt::println! and defmt::error! with log::info! and log::error!.
#![allow(unused)]
fn main() {
loop {
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
defmt::println!("\r\n----Before Write----\r\n");
if let Err(e) = read_sector(&uid, target_sector, current_key, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
Timer::after_millis(200).await;
if let Err(e) =
write_block(&uid, target_sector, rel_block, DATA, current_key, &mut rfid)
{
defmt::error!("Error writing block: {:?}", e);
}
Timer::after_millis(200).await;
defmt::println!("\r\n----After Write----\r\n");
if let Err(e) = read_sector(&uid, target_sector, new_key, &mut rfid) {
defmt::error!("Error reading sector: {:?}", e);
}
let _ = rfid.hlta();
let _ = rfid.stop_crypto1();
Timer::after_millis(500).await;
}
}
Timer::after_millis(200).await;
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the change-key folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/change-key/
Output
When the program runs, the sector contents are printed before and after the write. After the key is changed, bringing the tag back to the reader will result in an authentication failure when using the old key. This confirms that the new key 52 75 73 74 65 64 (Rusted) is now active.
Note
After the key update, if you bring the tag close to the reader again, authentication will fail because the program will be still using the old hardcoded current_key.
You can further verify this by updating the read-data program to use the new key and running it again.
Access Control
The tag includes access bits that enable access control for the data stored in the tag. This chapter will explore how these access bits function. This section might feel a bit overwhelming, so I’ll try to make it as simple and easy to understand as possible.
Caution
Modifying Access Bits: Be careful when writing the access bits, as incorrect values can make the sector unusable.
Permissions
These are the fundamental permissions that will be used to define access conditions. The table explains each permission operation and specifies the blocks to which it is applicable: normal data blocks (read/write), value blocks, or sector trailers.
| Operation | Description | Applicable for Block Type |
|---|---|---|
| Read | Reads one memory block | Read/Write, Value, Sector Trailer |
| Write | Writes one memory block | Read/Write, Value, Sector Trailer |
| Increment | Increments the contents of a block and stores the result in the internal Transfer Buffer | Value |
| Decrement | Decrements the contents of a block and stores the result in the internal Transfer Buffer | Value |
| Restore | Reads the contents of a block into the internal Transfer Buffer | Value |
| Transfer | Writes the contents of the internal Transfer Buffer to a block | Value, Read/Write |
Access conditions
Let’s address the elephant in the room: The access conditions. During my research, I found that many people struggled to make sense of the access condition section in the datasheet. Here is my attempt to explain it for easy to understand 🤞.
You can use just 3 bit-combinations per block to control its permissions. In the official datasheet, this is represented using a notation like CXY (C1₀, C1₂… C3₃) for the access bits. The first number (X) in this notation refers to the access bit number, which ranges from 1 to 3, each corresponding to a specific permission type. However, the meaning of these permissions varies depending on whether the block is a data block or a trailer block. The second number (Y) in the subscript denotes the relative block number, which ranges from 0 to 3.
Table 1: Access conditions for the sector trailer
In the original datasheet, the subscript number is not specified in the table. I have added the subscript “3”, as the sector trailer is located at Block 3.
Important
Readable Key: If you can read the key, it cannot be used as an authentication key. Therefore, in this table, whenever Key B is readable, it cannot serve as the authentication key. If you’ve noticed, yes, the Key A can never be read.
| Access Bits | Access Condition for | Remark | |||||||
|---|---|---|---|---|---|---|---|---|---|
| Key A | Access Bits | Key B | |||||||
| C13 | C23 | C33 | Read | Write | Read | Write | Read | Write | |
| 0 | 0 | 0 | never | key A | key A | never | key A | key A | Key B may be read |
| 0 | 1 | 0 | never | never | key A | never | key A | never | Key B may be read |
| 1 | 0 | 0 | never | key B | key A|B | never | never | key B | |
| 1 | 1 | 0 | never | never | key A|B | never | never | never | |
| 0 | 0 | 1 | never | key A | key A | key A | key A | key A | Key B may be read; Default configuration |
| 0 | 1 | 1 | never | key B | key A|B | key B | never | key B | |
| 1 | 0 | 1 | never | never | key A|B | key B | never | never | |
| 1 | 1 | 1 | never | never | key A|B | never | never | never | |
How to make sense out of this table?
It is a simple table showing the correlation between bit combinations and permissions.
For example: Let’s say you select “1 0 0” (3rd row in the table), then you can’t read KeyA, KeyB. However, you can modify the KeyA as well as KeyB value with KeyB. You can Read Access Bits with either KeyA or KeyB. But, you can never modify the Access Bits.
Now, where should these bits be stored? We will place them in the 6th, 7th, and 8th bytes at a specific location, which will be explained shortly.
Table 2: Access conditions for data blocks
This applies to all data blocks. The original datasheet does not include the subscript “Y”, I have added it for context. Here, “Y” represents the block number (ranging from 0 to 2).
The default config here indicates that both Key A and Key B can perform all operations. However, as seen in the previous table, Key B is readable (in default config), making it unusable for authentication. Therefore, only Key A can be used.
| Access Bits | Access Condition for | Application | |||||
|---|---|---|---|---|---|---|---|
| C1Y | C2Y | C3Y | Read | Write | Increment | Decrement,Transfer/Restore | |
| 0 | 0 | 0 | key A|B | key A|B | key A|B | key A|B | Default configuration |
| 0 | 1 | 0 | key A|B | never | never | never | read/write block |
| 1 | 0 | 0 | key A|B | key B | never | never | read/write block |
| 1 | 1 | 0 | key A|B | key B | key B | key A|B | value block |
| 0 | 0 | 1 | key A|B | never | never | key A|B | value block |
| 0 | 1 | 1 | key B | key B | never | never | read/write block |
| 1 | 0 | 1 | key B | never | never | never | read/write block |
| 1 | 1 | 1 | never | never | never | never | read/write block |
How to make sense out of this table?
It’s similar to the previous one; it shows the relationship between bit combinations and permissions.
For example: If you select “0 1 0” (2nd row in the table) and use this permission for block 1, you can use either KeyA or KeyB to read block 1. However, no other operations can be performed on block 1.
The notation for this is as follows: the block number is written as a subscript to the bit labels (e.g., C11, C21, C31). Here, the subscript “1” represents block 1. For the selected combination “0 1 0”, this means:
- C11 = 0
- C21 = 1
- C31 = 0
These bits will also be placed in the 6th, 7th, and 8th bytes at a specific location, which will be explained shortly.
Table 3: Access conditions table
Let’s colorize the original table to better visualize what each bit represents. The 7th and 3rd bits in each byte are related to the sector trailer. The 6th and 2nd bits correspond to Block 2. The 5th and 1st bits are associated with Block 1. The 4th and 0th bits are related to Block 0.
The overline on the notation indicates inverted values. This means that if the CXy value is 0, then CXy becomes 1.
| Byte | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| Byte 6 | C23 | C22 | C21 | C20 | C13 | C12 | C11 | C10 |
| Byte 7 | C13 | C12 | C11 | C10 | C33 | C32 | C31 | C30 |
| Byte 8 | C33 | C32 | C31 | C30 | C23 | C22 | C21 | C20 |
The default access bit “FF 07 80”. Let’s try to understand what it means.
| Byte | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|
| Byte 6 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
| Byte 7 | 0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 |
| Byte 8 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
We can derive the CXY values from the table above. Notice that only C33 is set to 1, while all other values are 0. Now, refer to Table 1 and Table 2 to understand which permission this corresponds to.
| Block | C1Y | C2Y | C3Y | Access |
|---|---|---|---|---|
| Block 0 | 0 | 0 | 0 | All permissions with Key A |
| Block 1 | 0 | 0 | 0 | All permissions with Key A |
| Block 2 | 0 | 0 | 0 | All permissions with Key A |
| Block 3 (Trailer) | 0 | 0 | 1 | You can write Key A using Key A. Access Bits and Key B can only be read and written using Key A. |
Since Key B is readable, you cannot use it for authentication.
Calculator on next page
Still confused? Use the calculator on the next page to experiment with different combinations. Adjust the permissions for each block and observe how the Access Bits values change accordingly.
Reference
MIFARE Classic 1K Access Bits Calculator
Decode: You can modify the “Access bits” and the Data Block and Sector Trailer tables will automatically update.
Encode: Click the “Edit” button in each row of the table to select your preferred access conditions. This will update the Access Bits.
Caution
Writing an incorrect value to the access condition bits can make the sector inaccessible.
Access Bits
Data Block Access Conditions:
| Block | C1Y | C2Y | C3Y | Read | Write | Increment | Decrement/Transfer/Restore | Remarks | Action |
|---|---|---|---|---|---|---|---|---|---|
| Block 0 | 0 | 0 | 0 | key A|B | key A|B | key A|B | key A|B | Default configuration | |
| Block 1 | 0 | 0 | 0 | key A|B | key A|B | key A|B | key A|B | Default configuration | |
| Block 2 | 0 | 0 | 0 | key A|B | key A|B | key A|B | key A|B | Default configuration |
| C1Y | C2Y | C3Y | Read | Write | Increment | Decrement/Transfer/Restore | Remarks |
|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | key A|B | key A|B | key A|B | key A|B | Default configuration |
| 0 | 1 | 0 | key A|B | never | never | never | read/write block |
| 1 | 0 | 0 | key A|B | key B | never | never | read/write block |
| 1 | 1 | 0 | key A|B | key B | key B | key A|B | value block |
| 0 | 0 | 1 | key A|B | never | never | key A|B | value block |
| 0 | 1 | 1 | key B | key B | never | never | read/write block |
| 1 | 0 | 1 | key B | never | never | never | read/write block |
| 1 | 1 | 1 | never | never | never | never | read/write block |
Sector Trailer (Block 3) Access Conditions:
| C13 | C23 | C33 | Read Key A | Write Key A | Read Access Bits | Write Access Bits | Read Key B | Write Key B | Remarks | Action |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 1 | never | key A | key A | key A | key A | key A | Key B may be read; Default configuration |
| C13 | C23 | C33 | Read Key A | Write Key A | Read Access Bits | Write Access Bits | Read Key B | Write Key B | Remarks |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | never | key A | key A | never | key A | key A | Key B may be read |
| 0 | 1 | 0 | never | never | key A | never | key A | never | Key B may be read |
| 1 | 0 | 0 | never | key B | key A|B | never | never | key B | |
| 1 | 1 | 0 | never | never | key A|B | never | never | never | |
| 0 | 0 | 1 | never | key A | key A | key A | key A | key A | Key B may be read; Default configuration |
| 0 | 1 | 1 | never | key B | key A|B | key B | never | key B | |
| 1 | 0 | 1 | never | never | key A|B | key B | never | never | |
| 1 | 1 | 1 | never | never | key A|B | never | never | never |
References
- This UI is inspired from this calculator: Mifarecalc
- MIFARE-Classic-1K-Access-Bits-Calculator
SD Card (SDC/MMC)
Sooner or later, you may want to store data that does not fit in flash memory. This could be sensor logs, configuration files, game assets, or anything else that needs to survive a power cycle. One of the most practical ways to do this is by using an SD card. In this section, we are going to learn how to use an SD card module with the Raspberry Pi Pico.
MMC (MultiMediaCard)
The MultiMediaCard (MMC) was introduced as an early type of flash memory storage, preceding the SD Card. It was commonly used in devices such as camcorders, digital cameras, and portable music players.
Like modern flash storage, MMC stores data as electrical charge in flash memory cells. This is very different from optical media such as CDs or DVDs, which store data as physical marks read by a laser.
Although MMC cards themselves are mostly obsolete today, they are still relevant because SD cards inherited many ideas from MMC.
SD (Secure Digital) Card
The Secure Digital Card, usually called an SD card, built on the ideas of MMC and expanded them. SD cards became extremely popular and are now used in cameras, embedded systems, and single board computers. There is a smaller variant called the microSD card that is typically used in microcontroller projects.
Image credit: Based on SD card by Tkgd2007, licensed under the GFDL and CC BY-SA 3.0, 2.5, 2.0, 1.0.
Internally, SD cards read and write data in fixed size blocks, typically 512 bytes; Because of this, data is accessed in blocks rather than as individual bytes. Filesystems such as FAT sit on top of these blocks and manage how files are stored and retrieved.
Hardware Requirements
We’ll be using the Micro SD Card adapter module. You can search for either “Micro SD Card Reader Module” or “Micro SD Card Adapter” to find them.
And of course, you’ll need a microSD card. The SD card should be formatted with FAT32; Depending on your computer, you may need a separate SD or USB adapter to format the card, since not all laptops have a microSD slot.
References:
- I highly recommend watching Jonathan Pallant’s talk at Euro Rust 2024 on writing an SD card driver in Rust. He wrote the driver we are going to use (originally he created it to run MS-DOS on ARM). It is not intended for production systems.
- If you want to understand how it works under the hood in SPI mode, you can refer to this article: How to Use MMC/SDC
Connecting Micro SD Card Reader with Raspberry Pi Pico
In this section, we are going to wire a microSD card reader module to the Raspberry Pi Pico using SPI mode.
microSD Card Pin Mapping for SPI Mode
We will focus only on the microSD card itself, since that is what we are using. A microSD card has 8 physical pins, but in SPI mode only 6 of them are actually required.
You may have noticed that most microSD card reader modules also expose only 6 pins. That is because those modules are already wired internally for SPI operation, and the unused pins are simply not brought out.
The table below shows how the microSD card pins map to SPI signals.
| microSD Card Pin | SPI Function |
|---|---|
| 1 | - |
| 2 | Chip Select (CS); also referred as Card Select |
| 3 | Data Input (DI) - corresponds to MOSI. To receive data from the microcontroller. |
| 4 | VDD - Power supply (3.3V) |
| 5 | Serial Clock (SCK) |
| 6 | Ground (GND) |
| 7 | Data Output (DO) - corresponds to MISO. To send data from the microSD card to the microcontroller. |
| 8 | - |
Connecting the Raspberry Pi Pico to the SD Card Reader
The Raspberry Pi Pico’s GPIO pins are 3.3V tolerant and can be permanently damaged by 5V signals. Micro SD cards also operate at 3.3V and can be damaged if higher voltages are applied.
SD card reader modules come in different configurations:
3.3V-only modules: These modules are simple microSD breakout boards designed to run directly at 3.3V. Because both the Raspberry Pi Pico and the SD card use the same voltage, the module can be connected without any extra circuitry. Power it from the Pico’s 3V3(OUT) pin and connect the SPI data lines directly to the Pico’s GPIO pins. This is the simplest and safest option when working with the Raspberry Pi Pico.
5V modules with voltage regulation: Some SD card modules are designed to be powered from 5V. These modules include extra components so the SD card itself still runs at 3.3V. Some higher-quality modules also make sure that all signal lines stay at safe 3.3V levels.
If a 5V module is designed correctly, it can be used with the Raspberry Pi Pico. However, not all 5V modules handle the signal levels properly. Because of this, you should always check the module description or documentation before connecting it to the Pico.
The Module Used in This Guide
The SD card module used in this guide is designed to be powered from 5V and outputs 3.3V signals, making it safe to use with the Raspberry Pi Pico.
Power the module from the Pico’s VBUS pin, which provides 5V when the Pico is powered through USB. Do not power this type of module from the Pico’s 3.3V pin, as it may not work reliably.
Caution
Always verify your SD card module outputs 3.3V signals before connecting it to the Pico. If uncertain, use a 3.3V-only module to avoid damaging your Pico.
Wiring Diagram
| Pico Pin | Wire | SD Card Pin |
|---|---|---|
| GPIO 4 (RX) |
|
MISO |
| GPIO 5 |
|
CS |
| GPIO 6 |
|
SCK |
| GPIO 7 (TX) |
|
MOSI |
| VBUS |
|
VCC |
| GND |
|
GND |
Read SD Card with Raspberry Pi Pico
In this chapter, we are going to read a file from a microSD card and print its contents. Before continuing, you will need an SD card that is already formatted as FAT32, and it must contain a file named RUST.TXT. For this example, you can put the text Ferris inside.
Select Your Output Method
In this section, I have given two output methods. Which one you use depends on whether you have a debug probe or not.
There is no major difference in terms of logic or behavior. The program works the same way in both cases. The main difference is how the output is printed.
If you are using a debug probe, we print messages using defmt over RTT. If you are using USB serial, we print messages using the log crate.
The USB serial setup is the same as what we used in the earlier USB Serial chapter. You may need to follow the same steps to set up the USB logger. I will not be repeating those steps here.
From a code point of view, the difference mostly comes down to this:
- Since we use defmt with a debug probe, logging is done using defmt::info!
- On the other hand, when using USB serial, we use the log crate, so logging is done with log::info!.
Select the tab below based on your setup:
In this setup, we use defmt over RTT to print the file contents.
Project from template
We will start by creating a new project using the template.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
When prompted, enter a project name, for example “read-sdcard”, and select “Embassy” as the HAL. Ensure to enable “defmt” for logging.
Additional Crates required
Update your Cargo.toml to add these additional crate along with the existing dependencies.
# To convert Spi bus to SpiDevice
embedded-hal-bus = "0.3.0"
# sd card driver
embedded-sdmmc = "0.9.0"
The embedded-sdmmc crate is a driver for reading and writing files on FAT-formatted SD cards.
Additional imports
#![allow(unused)]
fn main() {
// For SPI
use embassy_rp::spi;
use embassy_rp::spi::Spi;
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
// For CS Pin
use embassy_rp::gpio::{Level, Output};
// For SdCard
use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeIdx, VolumeManager};
}
Dummy TimeSource
The SD card filesystem code requires a TimeSource implementation to supply timestamps for file metadata. Even though we are not creating or modifying files in this example, the trait still needs to be implemented.
Since timestamps are not important here, we use a dummy implementation that always returns zeroed values.
#![allow(unused)]
fn main() {
/// Code from https://github.com/rp-rs/rp-hal-boards/blob/main/boards/rp-pico/examples/pico_spi_sd_card.rs
/// A dummy timesource, which is mostly important for creating files.
#[derive(Default)]
pub struct DummyTimesource();
impl TimeSource for DummyTimesource {
// In theory you could use the RTC of the rp2040 here, if you had
// any external time synchronizing device.
fn get_timestamp(&self) -> Timestamp {
Timestamp {
year_since_1970: 0,
zero_indexed_month: 0,
zero_indexed_day: 0,
hours: 0,
minutes: 0,
seconds: 0,
}
}
}
}
Setting up SPI for the SD card reader
Now we configure the SPI peripheral and the GPIO pins used to communicate with the SD card reader.
#![allow(unused)]
fn main() {
let miso = p.PIN_4;
let cs_pin = Output::new(p.PIN_5, Level::High);
let clk = p.PIN_6;
let mosi = p.PIN_7;
let mut config = spi::Config::default();
config.frequency = 400_000;
let spi_bus = Spi::new_blocking(p.SPI0, clk, mosi, miso, config);
}
Creating an SpiDevice from the SPI bus
The SD card driver expects an SpiDevice, not a raw SPI bus.
#![allow(unused)]
fn main() {
let spi_device =
ExclusiveDevice::new(spi_bus, cs_pin, Delay).expect("Failed to get exclusive device");
}
Setting up the SD card driver
With the SPI device ready, we can now create the SD card driver instance.
#![allow(unused)]
fn main() {
let sdcard = SdCard::new(spi_device, Delay);
}
Reading the SD card size
Before opening any files, we read the total size of the card to confirm that initialization succeeded.
#![allow(unused)]
fn main() {
defmt::info!("Init SD card controller and retrieve card size...");
let sd_size = sdcard.num_bytes().expect("failed to get sdcard size");
defmt::info!("card size is {} bytes", sd_size);
}
Opening the volume and root directory
Next, we create a volume manager and open the first volume on the card.
#![allow(unused)]
fn main() {
let volume_mgr = VolumeManager::new(sdcard, DummyTimesource::default());
let volume0 = volume_mgr
.open_volume(VolumeIdx(0))
.expect("failed to open volume");
let root_dir = volume0.open_root_dir().expect("failed to open root dir");
}
Opening the file
With the root directory available, we open the file we want to read.
#![allow(unused)]
fn main() {
let my_file = root_dir
.open_file_in_dir("RUST.TXT", embedded_sdmmc::Mode::ReadOnly)
.expect("failed to open RUST.TXT file");
}
Reading the file content
Finally, we read the file in small chunks and print its contents.
#![allow(unused)]
fn main() {
while !my_file.is_eof() {
let mut buffer = [0u8; 32];
if let Ok(n) = my_file.read(&mut buffer) {
if let Ok(s) = core::str::from_utf8(&buffer[..n]) {
defmt::info!("{}", s);
} else {
defmt::info!("{:02x}", &buffer[..n]);
}
}
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the read-sdcard folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/sdcard/read-sdcard/
Once the program is running, the output will show the contents of RUST.TXT file.
Write Data to an SD Card Using Embedded Rust on Raspberry Pi Pico
In this chapter, we create a file on a microSD card and write data into it. The SD card wiring, SPI configuration, logging setup, and card initialization remain unchanged. We only change how the file is opened and how data is written to it.
If the file already exists on the card, it will be overwritten. After the program runs, you can remove the card and verify the contents on your computer.
Project Setup
You can either reuse the project from the read example and modify it, or create a new project from the template and follow the same setup steps up to creating the sdcard instance.
From that point onward, only the file handling code is different.
Opening a file for writing
To write data, the file must be opened in a mode that allows creation and modification. Here we use ReadWriteCreateOrTruncate, which creates the file if it does not exist and truncates it if it already exists. This ensures that the file starts empty each time the program runs.
#![allow(unused)]
fn main() {
let my_file = root_dir
.open_file_in_dir(
"FERRIS.TXT",
embedded_sdmmc::Mode::ReadWriteCreateOrTruncate,
)
.expect("failed to create FERRIS.TXT file");
}
Writing data to the file
To keep the example simple, we write a single line of text into the file. If the write succeeds, the file is flushed to ensure that the data is committed to the SD card.
#![allow(unused)]
fn main() {
let line = "Hello, Ferris!";
if let Ok(()) = my_file.write(line.as_bytes()) {
info!("Written Data");
if let Err(_) = my_file.flush() {
info!("Failed to flush");
}
} else {
error!("Unable to write the data");
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the write-sdcard folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/sdcard/write-sdcard/
Verifying the result
After the program finishes running, power off the board and remove the microSD card. When the card is inserted into a computer, a file named FERRIS.TXT should be present in the root directory.
Tip
In this example, we are using a dummy time source, so the file timestamp will appear around 1980. If you want correct timestamps, you will need to set up an RTC and provide a custom TimeSource implementation that returns the current time.
Opening the file should show:
Hello, Ferris!
Joystick
In this section, we’ll explore how to use the Joystick Module. It is similar to the joysticks found on PS2 (PlayStation 2) controllers. They are commonly used in gaming, as well as for controlling drones, remote-controlled cars, robots, and other devices to adjust position or direction.
Meet the hardware - Joystick module
You can move the joystick knob vertically and horizontally, sending its position (X and Y axes) to the MCU (e.g., Pico). Additionally, the knob can be pressed down like a button. The joystick typically operates at 5V, but it can also be connected to 3.3V.
How it works?
The joystick module has two 10K potentiometers: one for the X-axis and another for the Y-axis. It also includes a push button, which is visible.
When you move the joystick from right to left or left to right(X axis), you can observe one of the potentiometers moving accordingly. Similarly, when you move it up and down(Y-axis), you can observe the other potentiometer moving along.
You can also observe the push-button being pressed when you press down on the knob.
Movement and ADC
Joystick Movement and Corresponding ADC Values
When you move the joystick along the X or Y axis, it produces an analog signal with a voltage that varies between 0 and 3.3V(or 5V if we connect it to 5V supply). When the joystick is in its center (rest) position, the output voltage is approximately 1.65V, which is half of the VCC(VCC is 3.3V in our case).
Note
The reason it is 1.65V in the center position is that the potentiometer acts as a voltage divider. When the potentiometer is moved, its resistance changes, causing the voltage divider to output a different voltage accordingly. Refer the voltate divider section.
The joystick has a total of 5 pins, and we will shortly discuss what each of them represents. Out of these, two pins are dedicated to sending the X and Y axis positions, which should be connected to the ADC pins of the microcontroller.
As you may already know, the Raspberry Pi Pico has a 12-bit SAR-type ADC, which converts analog signals (voltage differences) into digital values. Since it is a 12-bit ADC, the analog values will be represented as digital values ranging from 0 to 4095. If you’re not familiar with ADC, refer to the ADC section that we covered earlier.
Note:
The ADC values in the image are just approximations to give you an idea and won’t be exact. For example, I got around 1850 for X and Y at the center position. When I moved the knob toward the pinout side, X went to 0, and when I moved it to the opposite side, it went to 4095. The same applies to the Y axis.So, You might need to calibrate your joystick.
Pin layout
The joystick has a total of 5 pins: power supply, ground, X-axis output, Y-axis output, and switch output pin.
| Joystick Pin | Details |
|---|---|
| GND | Ground pin. Should be connected to the Ground of the circuit. |
| VCC | Power supply pin (typically 5V or 3.3V ). |
| VRX | The X-axis analog output pin varies its voltage based on the joystick's horizontal position, ranging from 0V to VCC as the joystick is moved left and right. |
| VRY | The Y-axis analog output pin varies its voltage based on the joystick's vertical position, ranging from 0V to VCC as the joystick is moved up and down. |
| SW | Switch pin. When the joystick knob is pressed, this pin is typically pulled LOW (to GND). |
Connecting the Joystick to the Raspberry Pi Pico
Let’s connect the joystick to the Raspberry Pi Pico. We need to connect the VRX and VRY pins to the ADC pins of the Pico. The joystick will be powered with 3.3V instead of 5V because the Pico’s GPIO pins are only 3.3V tolerant. Connecting it to 5V could damage the Pico’s pins. Thankfully, the joystick can operate at 3.3V as well.
| Pico Pin | Wire | Joystick Pin |
|---|---|---|
| GND |
|
GND |
| 3.3V |
|
VCC |
| GPIO 27 (ADC1) |
|
VRX |
| GPIO 26 (ADC0) |
|
VRY |
| GPIO 15 |
|
SW |
Understanding Joystick Positions Using ADC Values on Raspberry Pi Pico
In this chapter, we are going to print the ADC values from the joystick whenever they change. We will read two analog signals, vrx and vry, which are connected to the joystick axes, and we will also print the values when the joystick button is pressed.
The goal here is simple. We want to see how the physical movement of the joystick translates into numeric ADC values. Once you see how the values change as you move it around, you can extend the idea and use it in projects such as controlling robots, RC vehicles, drones, pan-tilt camera mounts and more.
Project from template
We will start by generating a new project using the template.
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.3.2
When prompted, give your project a name, for example joystick-adc, and select embassy as the HAL.
If you have a debug probe, you can enable defmt and logging will be straightforward. If you do not have a debug probe, disable defmt and use USB serial output, just like we did in the previous chapter. We will not repeat those steps here so that we can stay focused on the joystick and ADC setup.
Additional imports
Add the following imports to your main.rs file. These are needed for ADC access, GPIO input, and interrupt handling.
#![allow(unused)]
fn main() {
// For ADC
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
use embassy_rp::gpio::{Input, Pull};
// Interrupt Binding
use embassy_rp::adc;
use embassy_rp::bind_interrupts;
}
ADC Interrupt Binding
Next, we bind the ADC FIFO interrupt.
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
});
}
ADC and Joystick Setup
Now we initialize the ADC peripheral and set up the joystick pins. The vrx and vry signals are connected to GPIO pins that support ADC. And we also set up the joystick button as a regular input.
#![allow(unused)]
fn main() {
// ADC Setup
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
let mut vrx_pin = Channel::new_pin(p.PIN_27, Pull::None);
let mut vry_pin = Channel::new_pin(p.PIN_26, Pull::None);
let button = Input::new(p.PIN_15, Pull::Up);
}
Tracking Previous Values
Before we enter the main loop, we keep a few variables to track previous readings. This lets us detect when something actually changes instead of printing values continuously.
#![allow(unused)]
fn main() {
let mut prev_vrx: u16 = 0;
let mut prev_vry: u16 = 0;
let mut print_vals = true;
let mut prev_btn_state = false;
}
We store the previous X and Y readings, keep a small flag to control when values should be printed, and also remember the previous button state so we can detect a button press event.
Main loop
Inside the main loop, we continuously read the ADC values from the two joystick pins. If an ADC read fails, we skip that iteration and try again.
#![allow(unused)]
fn main() {
loop {
let Ok(vry) = adc.read(&mut vry_pin).await else {
continue;
};
let Ok(vrx) = adc.read(&mut vrx_pin).await else {
continue;
};
}
Detecting Meaningful Changes
Analog joysticks are not perfectly stable. Even when untouched, the readings can fluctuate slightly. To keep the output readable, we only react when the change is large enough to matter.
#![allow(unused)]
fn main() {
if vrx.abs_diff(prev_vrx) > 100 {
prev_vrx = vrx;
print_vals = true;
}
if vry.abs_diff(prev_vry) > 100 {
prev_vry = vry;
print_vals = true;
}
}
When either axis changes beyond the threshold, we update the stored value and mark that the readings should be printed.
Printing ADC Values
If the print_vals flag is set, we print the current joystick position and then clear the flag.
#![allow(unused)]
fn main() {
if print_vals {
print_vals = false;
info!("X: {} Y: {}", vrx, vry);
}
}
Handling the Button
Next, we read the joystick button. Because it uses a pull-up resistor, a pressed button reads as low.
#![allow(unused)]
fn main() {
let btn_state = button.is_low();
if btn_state && !prev_btn_state {
info!("Button Pressed");
print_vals = true;
}
prev_btn_state = btn_state;
}
When the button is pressed, we mark the ADC values to be printed. This lets us see the exact joystick position at the moment of the press.
Finally, we add a short delay at the end of the loop.
#![allow(unused)]
fn main() {
Timer::after_millis(100).await;
}
}
The full code
#![no_std]
#![no_main]
use defmt::info;
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_time::Timer;
// For ADC
use embassy_rp::adc::{Adc, Channel, Config as AdcConfig};
use embassy_rp::gpio::{Input, Pull};
// Interrupt Binding
use embassy_rp::adc;
use embassy_rp::bind_interrupts;
//Panic Handler
use panic_probe as _;
// Defmt Logging
use defmt_rtt as _;
/// Tell the Boot ROM about our application
#[unsafe(link_section = ".start_block")]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
});
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
// ADC Setup
let mut adc = Adc::new(p.ADC, Irqs, AdcConfig::default());
let mut vrx_pin = Channel::new_pin(p.PIN_27, Pull::None);
let mut vry_pin = Channel::new_pin(p.PIN_26, Pull::None);
let button = Input::new(p.PIN_15, Pull::Up);
let mut prev_vrx: u16 = 0;
let mut prev_vry: u16 = 0;
let mut print_vals = true;
let mut prev_btn_state = false;
loop {
let Ok(vry) = adc.read(&mut vry_pin).await else {
continue;
};
let Ok(vrx) = adc.read(&mut vrx_pin).await else {
continue;
};
if vrx.abs_diff(prev_vrx) > 100 {
prev_vrx = vrx;
print_vals = true;
}
if vry.abs_diff(prev_vry) > 100 {
prev_vry = vry;
print_vals = true;
}
if print_vals {
print_vals = false;
info!("X: {} Y: {}", vrx, vry);
}
let btn_state = button.is_low();
if btn_state && !prev_btn_state {
info!("Button Pressed");
print_vals = true;
}
prev_btn_state = btn_state;
Timer::after_millis(100).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recommended to have these minimal entries.
#[unsafe(link_section = ".bi_entries")]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"joystick-adc"),
embassy_rp::binary_info::rp_program_description!(c"your program description"),
embassy_rp::binary_info::rp_cargo_version!(),
embassy_rp::binary_info::rp_program_build_attribute!(),
];
// End of file
Clone the existing project
You can clone (or refer) project I created and navigate to the joystick-adc folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/joystick/joystick-adc
Sending Joystick ADC Values to USB Serial (RP-HAL)
In the previous chapter, we used Embassy to read joystick ADC values and print them using logging. In the first version of this book, I was using RP-HAL while exploring and learning the ecosystem. I do not want to throw away that material, as it may still be useful for understanding older code or different approaches.
This chapter shows the same joystick ADC example implemented using RP-HAL and USB serial. If you are not interested in the RP-HAL version, you can skip this chapter.
Project from template
To set up the project, run:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.1.0
When prompted, give your project a name, like “joystick-usb” and select RP-HAL as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "joystick-usb":
# cd joystick-usb
Additional Crates required
Update your Cargo.toml to add these additional crate along with the existing dependencies.
#![allow(unused)]
fn main() {
usb-device = "0.3.2"
usbd-serial = "0.2.2"
heapless = "0.8.0"
embedded_hal_0_2 = { package = "embedded-hal", version = "0.2.5", features = [
"unproven",
] }
}
The first three should be familiar by now; they set up USB serial communication so we can send data between the Pico and the computer. heapless is a helper function for buffers.
embedded_hal_0_2 is the new crate. You might already have embedded-hal with version “1.0.0” in your Cargo.toml. So, you may wonder why we need this version. The reason is that Embedded HAL 1.0.0 doesn’t include an ADC trait to read ADC values, and the RP-HAL uses the one from version 0.2. (Don’t remove the existing embedded-hal 1.0.0; just add this one along with it.)
Additional imports
#![allow(unused)]
fn main() {
/// This trait is the interface to an ADC that is configured to read a specific channel at the time
/// of the request (in contrast to continuous asynchronous sampling).
use embedded_hal_0_2::adc::OneShot;
// for USB Serial
use usb_device::{class_prelude::*, prelude::*};
use usbd_serial::SerialPort;
use heapless::String;
}
USB Serial
Make sure you’ve completed the USB serial section and added the boilerplate code from there into your project.
#![allow(unused)]
fn main() {
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USB,
pac.USB_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut serial = SerialPort::new(&usb_bus);
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x27dd))
.strings(&[StringDescriptors::default()
.manufacturer("implRust")
.product("Ferris")
.serial_number("12345678")])
.unwrap()
.device_class(2) // 2 for the CDC, from: https://www.usb.org/defined-class-codes
.build();
let mut buff: String<64> = String::new();
}
ADC and Pin Setup
The joystick wiring is the same as before. GPIO 27 and GPIO 26 are connected to the VRX and VRY pins of the joystick and are configured as ADC inputs. GPIO 15 is used for the joystick button with an internal pull-up.
#![allow(unused)]
fn main() {
let mut adc = hal::Adc::new(pac.ADC, &mut pac.RESETS);
//VRX Pin
let mut adc_pin_1 = hal::adc::AdcPin::new(pins.gpio27).unwrap();
// VRY pin
let mut adc_pin_0 = hal::adc::AdcPin::new(pins.gpio26).unwrap();
let mut btn = pins.gpio15.into_pull_up_input();
}
Printing Co-ordinates
Just like in the Embassy version, we avoid printing ADC values continuously. We track the previous readings and only print when the change crosses a threshold. The same approach is used for the button. We detect a press by checking for a state transition, so the message is printed only once per press.
#![allow(unused)]
fn main() {
let mut prev_vrx: u16 = 0;
let mut prev_vry: u16 = 0;
let mut print_vals = true;
}
Reading ADC Values:
First, read the ADC values for vrx and vry. If there’s an error during the read operation, we ignore it and continue the loop:
#![allow(unused)]
fn main() {
let Ok(vry): Result<u16, _> = adc.read(&mut adc_pin_0) else {
continue;
};
let Ok(vrx): Result<u16, _> = adc.read(&mut adc_pin_1) else {
continue;
};
}
Checking for Threshold Changes:
Next, we check if the absolute difference between the current and previous values of vrx or vry exceeds a threshold (e.g., 100). If so, we update the previous values and set the print_vals flag to true:
#![allow(unused)]
fn main() {
if vrx.abs_diff(prev_vrx) > 100 {
prev_vrx = vrx;
print_vals = true;
}
if vry.abs_diff(prev_vry) > 100 {
prev_vry = vry;
print_vals = true;
}
}
Using a threshold filters out small ADC fluctuations, avoids unnecessary prints, and ensures updates only for significant changes.
Printing the Coordinates
If print_vals is true, we reset it to false and print the X and Y coordinates via the USB serial:
#![allow(unused)]
fn main() {
if print_vals {
print_vals = false;
buff.clear();
write!(buff, "X: {} Y: {}\r\n", vrx, vry).unwrap();
let _ = serial.write(buff.as_bytes());
}
}
Button Press Detection with State Transition
The button is normally in a high state. When you press the knob button, it switches from high to low. However, since the program runs in a loop, simply checking if the button is low could lead to multiple detections of the press. To avoid this, we only register the press once by detecting a high-to-low transition, which indicates that the button has been pressed.
To achieve this, we track the previous state of the button and compare it with the current state before printing the “button pressed” message. If the button is currently in a low state (pressed) and the previous state was high (not pressed), we recognize it as a new press and print the message. Then, we update the previous state to the current state, ensuring the correct detection of future transitions.
#![allow(unused)]
fn main() {
let btn_state = btn.is_low().unwrap();
if btn_state && !prev_btn_state {
let _ = serial.write("Button Pressed\r\n".as_bytes());
print_vals = true;
}
prev_btn_state = btn_state;
}
The Full code
#![no_std]
#![no_main]
use core::fmt::Write;
use embedded_hal::{delay::DelayNs, digital::InputPin};
use embedded_hal_0_2::adc::OneShot;
use hal::block::ImageDef;
use heapless::String;
use panic_halt as _;
use rp235x_hal as hal;
use usb_device::{class_prelude::*, prelude::*};
use usbd_serial::SerialPort;
#[link_section = ".start_block"]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
#[hal::entry]
fn main() -> ! {
let mut pac = hal::pac::Peripherals::take().unwrap();
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
let sio = hal::Sio::new(pac.SIO);
let pins = hal::gpio::Pins::new(
pac.IO_BANK0,
pac.PADS_BANK0,
sio.gpio_bank0,
&mut pac.RESETS,
);
// let mut led = pins.gpio25.into_push_pull_output();
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USB,
pac.USB_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut serial = SerialPort::new(&usb_bus);
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x27dd))
.strings(&[StringDescriptors::default()
.manufacturer("implRust")
.product("Ferris")
.serial_number("12345678")])
.unwrap()
.device_class(2) // 2 for the CDC, from: https://www.usb.org/defined-class-codes
.build();
let mut btn = pins.gpio15.into_pull_up_input();
let mut adc = hal::Adc::new(pac.ADC, &mut pac.RESETS);
//VRX Pin
let mut adc_pin_1 = hal::adc::AdcPin::new(pins.gpio27).unwrap();
// VRY pin
let mut adc_pin_0 = hal::adc::AdcPin::new(pins.gpio26).unwrap();
let mut prev_vrx: u16 = 0;
let mut prev_vry: u16 = 0;
let mut prev_btn_state = false;
let mut buff: String<64> = String::new();
let mut print_vals = true;
loop {
let _ = usb_dev.poll(&mut [&mut serial]);
let Ok(vry): Result<u16, _> = adc.read(&mut adc_pin_0) else {
continue;
};
let Ok(vrx): Result<u16, _> = adc.read(&mut adc_pin_1) else {
continue;
};
if vrx.abs_diff(prev_vrx) > 100 {
prev_vrx = vrx;
print_vals = true;
}
if vry.abs_diff(prev_vry) > 100 {
prev_vry = vry;
print_vals = true;
}
let btn_state = btn.is_low().unwrap();
if btn_state && !prev_btn_state {
let _ = serial.write("Button Pressed\r\n".as_bytes());
print_vals = true;
}
prev_btn_state = btn_state;
if print_vals {
print_vals = false;
buff.clear();
write!(buff, "X: {} Y: {}\r\n", vrx, vry).unwrap();
let _ = serial.write(buff.as_bytes());
}
timer.delay_ms(50);
}
}
#[link_section = ".bi_entries"]
#[used]
pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 5] = [
hal::binary_info::rp_cargo_bin_name!(),
hal::binary_info::rp_cargo_version!(),
hal::binary_info::rp_program_description!(c"JoyStick USB"),
hal::binary_info::rp_cargo_homepage_url!(),
hal::binary_info::rp_program_build_attribute!(),
];
Clone the existing project
You can clone (or refer) project I created and navigate to the joystick-usb folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/joystick-usb/
How to Run ?
The method to flash (run the code) on the Pico is the same as usual. However, we need to set up tio to interact with the Pico through the serial port (/dev/ttyACM0). This allows us to read data from the Pico or send data to it.
tio
Make sure you have tio installed on your system. If not, you can install it using:
apt install tio
Connecting to the Serial Port
Run the following command to connect to the Pico’s serial port:
tio /dev/ttyACM0
This will open a terminal session for communicating with the Pico.
Flashing and Running the Code
Open another terminal, navigate to the project folder, and flash the code onto the Pico as usual:
cargo run
If everything is set up correctly, you should see a “Connected” message in the tio terminal. As you move the joystick, the coordinates will be printed. Pressing the knob downwards will also display a “Button pressed” message.
Debugging
Debugging Embedded Rust on Raspberry Pi Pico 2 with GDB
In this chapter, we will look at how to debug Embedded Rust programs on the Raspberry Pi Pico 2 (RP2350) using GDB. You will need a Debug Probe hardware and you must connect it to your Raspberry Pi Pico 2. Make sure you have read this chapter before continuing.
What a Debug Probe Gives You
In the Debug Probe introduction chapter, we saw that it helps you avoid pressing the BOOTSEL button every time you want to flash your program. But the Debug Probe offers much more than that. It allows you to use GDB directly from your computer, so you can debug your program while it is running on the Pico 2.
What is GDB?
If you have never used GDB, here is a simple explanation: GDB is a command line debugger that lets you pause your program, inspect what is happening inside it, read memory, and step through the code to find problems.
For debugging the Pico 2, you need a version of GDB that supports ARM targets. You can install it with:
sudo apt install gdb-multiarch
Enable GDB in Embed.toml
Earlier, we used probe-rs through the cargo embed command. The same tool can also start a GDB server, which lets you connect GDB to the Pico 2 through the Debug Probe.
For this, we need to edit the Embed.toml file in the root of your project. This file is the configuration file used by the cargo embed command. You should add the following section to enable the GDB server:
[default.gdb]
# Whether or not a GDB server should be opened after flashing.
enabled = true
Example Project
For this exercise, I have created a simple LED blink program using rp-hal. It does not use Embassy to keep things simple. The Embed.toml file is already set up, so you can clone the project and start working right away:
git clone https://github.com/ImplFerris/pico-debug
cd pico-debug
If you run the cargo embed command now, the GDB server will start automatically and listen on port 1337 (the default port used by probe-rs).
Connecting GDB to the Remote Server
To connect GDB to the running probe-rs GDB server, open a new terminal and start GDB with the our project binary file:
Note: There is an issue with probe-rs version 0.30. When I try to connect to the GDB server, the connection closes immediately. I downgraded to version 0.28 as suggested in this issue discussion. After downgrading, run cargo embed again.
gdb-multiarch ./target/thumbv8m.main-none-eabihf/debug/pico-debug
Then connect to the server on port 1337:
(gdb) target remote :1337
At this point, GDB is connected to the Pico 2 through the Debug Probe, and you can start using breakpoints, stepping, memory inspection, and other debugging commands.
Resetting to the Start of the Program
When you connect GDB to the running GDB server, the CPU may not be stopped at the start of your program. It might be sitting somewhere deep inside the code.
To ensure you start debugging from a clean state, run:
(gdb) monitor reset halt
This command tells the Debug Probe to reset the Pico 2 and immediately halt the CPU. This puts the program back at the very beginning, right where the processor starts running after a reset.
Finding the Reset Handler and Tracing the Call to main
When the Pico 2 resets, the CPU starts executing from the Reset Handler. To understand how our program starts, we will locate the Reset Handler, disassemble it, and follow the call chain until we reach our actual Rust main.
When the Pico 2 starts up, the CPU does not jump straight into our Rust main function. Instead, it follows a small chain of functions provided by the Cortex-M runtime.
In this section, we will:
-
Find where the chip starts executing after reset
-
See which function that Reset Handler calls
-
Follow the chain until we reach our real Rust main
Read the Reset Vector Entry
The Cortex-M processor starts execution by reading a table at the beginning of flash memory called the vector table.
The first two entries are:
- Word 0 (offset 0x00): Initial stack pointer value
- Word 1 (offset 0x04): Reset handler address
On Pico 2, flash starts at address 0x10000000 so:
- The initial stack pointer value is stored at 0x10000000
- Reset handler address is at 0x10000004
What is the Reset Handler?
The reset handler is the first function that runs when the processor powers on or resets. It performs initialization and eventually calls our main function.
Read it in GDB:
(gdb) x/wx 0x10000004
Example output:
0x10000004 <__RESET_VECTOR>: 0x1000010d
This value is the address the CPU jumps to after reset. The last bit (the “Thumb bit”) is always 1, so the actual address is 0x1000010c. But you can use either one of them (0x1000010d or 0x1000010c), GDB can handle it.
Alternatively, you can also use the readelf program to find the entrypoint address:
arm-none-eabi-readelf -h ./target/thumbv8m.main-none-eabihf/debug/pico-debug
Disassemble the Reset Handler
Now Let’s ask GDB to show the instructions at that address:
(gdb) disas 0x1000010d
# or
(gdb) disas 0x1000010c
You will see assembly instructions for the reset handler. Look for a bl (Branch with Link) instruction that calls another function:
...
0x10000140 <+52>: isb sy
0x10000144 <+56>: bl 0x1000031c <main>
0x10000148 <+60>: udf #0
The Reset Handler calls a function located at 0x1000031c, which GDB shows as main. But this is not our Rust main yet.
What is this “main”?
The main at 0x1000031c is not our program’s main function. It is a small wrapper created by the cortex-m-rt crate. This wrapper is often called the trampoline because it jumps to the real entry point later.
Its demangled name is usually:
#![allow(unused)]
fn main() {
// NOTE: here, pico_debug prefix is our project's name
pico_debug::__cortex_m_rt_main_trampoline
}
Let’s disassemble it.
Disassemble that trampoline
(gdb) disas 0x1000031c
Output:
Dump of assembler code for function main:
0x1000031c <+0>: push {r7, lr}
0x1000031e <+2>: mov r7, sp
0x10000320 <+4>: bl 0x10000164 <_ZN10pico_debug18__cortex_m_rt_main17he0b4d19700c84ad2E>
End of assembler dump.
This is very small. All it does is call the real Rust entrypoint, which is named:
#![allow(unused)]
fn main() {
pico_debug::__cortex_m_rt_main
}
Enable Demangled Names
Rust function names are mangled by default and look unreadable.
Enable demangling:
set print asm-demangle on
Now try:
(gdb) disas 0x1000031c
#or
(gdb) disas pico_debug::__cortex_m_rt_main_trampoline
You should now see readable Rust names.
Dump of assembler code for function pico_debug::__cortex_m_rt_main_trampoline:
0x1000031c <+0>: push {r7, lr}
0x1000031e <+2>: mov r7, sp
0x10000320 <+4>: bl 0x10000164 <pico_debug::__cortex_m_rt_main>
End of assembler dump.
Disassemble the Actual Rust main
Now let’s inspect our main function:
disas pico_debug::__cortex_m_rt_main
You will see the program’s logic, starting with the initial setup code followed by the loop that toggles the LED Pin.
#![allow(unused)]
fn main() {
...
0x100002dc <+376>: bl 0x100079a4 <rp235x_hal::timer::Timer<rp235x_hal::timer::CopyableTimer0>::new_timer0>
0x100002e0 <+380>: bl 0x10000b30 <rp235x_hal::gpio::Pin<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::func::FunctionNull, rp235x_hal::gpio::pull::PullDown>::into_push_pull_output<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::func::FunctionNull, rp235x_hal::gpio::pull::PullDown>>
...
0x100002f8 <+404>: bl 0x10000c48 <rp235x_hal::gpio::eh1::{impl#1}::set_high<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown>>
...
0x10000306 <+418>: bl 0x100006b8 <rp235x_hal::timer::{impl#7}::delay_ms<rp235x_hal::timer::CopyableTimer0>>
...
0x1000030c <+424>: bl 0x10000c38 <rp235x_hal::gpio::eh1::{impl#1}::set_low<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown>>
...
}
Breakpoints
Now that we’ve traced the execution path from reset to our main function, let’s set breakpoints in the LED loop and observe how the GPIO registers change when we toggle the LED.
Understanding the LED Loop
Let me show you the disassembled code from the __cortex_m_rt_main function again. We need to look for the bl instructions. The bl stands for “branch and link” - these are instructions that call other functions. Specifically, we’re looking for the calls to set_high and set_low functions.
#![allow(unused)]
fn main() {
...
// This is the set_high() call
0x100002f8 <+404>: bl 0x10000c48 <rp235x_hal::gpio::eh1::{impl#1}::set_high<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown>>
...
// This is the delay_ms() call
0x10000306 <+418>: bl 0x100006b8 <rp235x_hal::timer::{impl#7}::delay_ms<rp235x_hal::timer::CopyableTimer0>>
...
// This is the set_low() call
0x1000030c <+424>: bl 0x10000c38 <rp235x_hal::gpio::eh1::{impl#1}::set_low<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown>>
...
// This is the delay_ms() call
0x10000314 <+432>: bl 0x100006b8 <rp235x_hal::timer::{impl#7}::delay_ms<rp235x_hal::timer::CopyableTimer0>>
...
}
Look at those addresses on the left - 0x100002f8 and 0x1000030c. These are memory addresses where the LED control happens. The first address is where set_high gets called, and the second is where set_low gets called. We’re going to put breakpoints at these addresses so our program pauses right before running these instructions.
Setting Breakpoints in the Loop
Let’s set up the first breakpoint. Type this in GDB:
(gdb) break *0x100002f8
You’ll see: Breakpoint 1 at 0x100002f8: file src/main.rs, line 63.
This means GDB created a breakpoint at that address, and it corresponds to line 63 in our main.rs file.
(gdb) break *0x1000030c
You’ll see: Breakpoint 2 at 0x1000030c: file src/main.rs, line 65.
Now let’s reset everything to start fresh:
monitor reset halt
This resets the microcontroller and stops it at the beginning, so we have a clean starting point.
GPIO Register Overview
Before we continue, I need to explain what we’re going to look at. When you call set_high or set_low in your Rust code, what actually happens is that specific memory locations get changed. These memory locations are called registers, and they directly control the hardware.
On the RP2350 chip, there’s a register called GPIO_OUT that controls all the GPIO pins. You can find this in the RP2350 datasheet (chapter 3.1.11, page 55) under the SIO (Single-cycle IO) section.
Here’s where this register lives in memory:
- The SIO peripheral starts at base address 0xd0000000
- The GPIO_OUT register is at offset 0x010 from that base
- So the full address is: 0xd0000000 + 0x010 = 0xd0000010
Think of GPIO_OUT as a 32-bit number where each bit controls one GPIO pin. Bit 0 controls GPIO0, bit 1 controls GPIO1, and so on. Bit 25 controls GPIO25 - that’s where the onboard LED is connected. When bit 25 is 0, the LED is off. When bit 25 is 1, the LED is on.
Running to the First Breakpoint
Let’s run the program until it hits our first breakpoint:
(gdb) continue
When the breakpoint is hit, GDB will show something like:
Continuing.
Thread 1 hit Breakpoint 1, 0x100002f8 in pico_debug::__cortex_m_rt_main () at src/main.rs:63
63 led_pin.set_high().unwrap();
The program stopped right before calling set_high. This is the perfect moment to check what the register looks like before we turn the LED on.
Checking GPIO Registers Before set_high
Let’s look at what’s currently in the GPIO_OUT register:
(gdb) x/x 0xd0000010
The x/x command means “examine this memory address and show me the value in hexadecimal format.”
You’ll probably get an error message “Cannot access memory at address 0xd0000010”. This happens because GDB doesn’t automatically know about peripheral registers. We need to tell GDB that it’s allowed to read from this memory region.
Making SIO Peripheral Accessible in GDB
To fix this, we need to tell GDB about the peripheral memory region. According to the RP2350 datasheet, the SIO region actually extends from 0xd0000000 to 0xdfffffff. However, we don’t need to map the entire SIO region - we only need enough to cover the registers we want to access.
So we can type:
(gdb) mem 0xD0000000 0xD0001000 rw nocache
Here, we’re mapping about 4KB of the SIO region (from 0xD0000000 to 0xD0001000), which is more than enough to cover GPIO_OUT and the other SIO registers we’ll be looking at during debugging.
If you want to map even less and be more precise, you can use:
(gdb) mem 0xD0000000 0xD0000100 rw nocache
This gives us just 256 bytes, which covers all the basic SIO registers we need, including GPIO_OUT at 0xD0000010. The key point is that we map enough memory to include the registers we want to read, without needing to map the entire SIO region.
Now try reading GPIO_OUT again:
(gdb) x/x 0xd0000010
0xd0000010: 0x00000000
We get the value 0x00000000. This means all 32 bits are zero, so all GPIO pins are currently off. Our LED is off.
Continue to the Second Breakpoint
Now let’s continue running and see what happens after set_high executes:
(gdb) continue
Continuing.
Thread 1 received signal SIGINT, Interrupt.
rp235x_hal::gpio::eh1::{impl#1}::set_high<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown> (self=0x2007ffbd)
at /home/implrust/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rp235x-hal-0.3.1/src/gpio/mod.rs:1549
1549 fn set_high(&mut self) -> Result<(), Self::Error> {
We got interrupted inside the set_high function. Let’s continue again:
(gdb) continue
Continuing.
Thread 1 hit Breakpoint 2, pico_debug::__cortex_m_rt_main () at src/main.rs:65
65 led_pin.set_low().unwrap();
Now the program has run through set_high and the delay, and stopped at our second breakpoint on line 65, right before calling set_low. Let’s check GPIO_OUT again:
(gdb) x/x 0xd0000010
0xd0000010: 0x02000000
The value changed from 0x00000000 to 0x02000000. You should also see the LED turned on by this time.
Let me explain what 0x02000000 means. In binary, this is 00000010 00000000 00000000 00000000. If you count from the right starting at 0, bit 25 is now set to 1. That’s exactly what set_high did - it turned on bit 25 of the GPIO_OUT register, which turned on GPIO25, which lit up the LED.
Continue to See set_low in Action
Now let’s continue one more time to see what happens when set_low executes. But first, let’s note that the LED is currently on and GPIO_OUT shows 0x02000000 with bit 25 set to 1.
Let’s continue:
(gdb) continue
Continuing.
Thread 1 received signal SIGINT, Interrupt.
rp235x_hal::gpio::eh1::{impl#1}::set_low<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown> (self=0x2007ffbd)
at /home/implrust/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rp235x-hal-0.3.1/src/gpio/mod.rs:1544
1544 fn set_low(&mut self) -> Result<(), Self::Error> {
We got interrupted inside the set_low function. Let’s continue again:
(gdb) continue
Continuing.
Thread 1 hit Breakpoint 1, 0x100002f8 in pico_debug::__cortex_m_rt_main () at src/main.rs:63
63 led_pin.set_high().unwrap();
The program ran through set_low and the delay, and looped back to our first breakpoint on line 63. Let’s check GPIO_OUT again:
(gdb) x/x 0xd0000010
0xd0000010: 0x00000000
The value is back to 0x00000000. Bit 25 is now 0, which means GPIO25 is off and the LED is off. You should see the LED turned off on your board.
What We Learned
From what we observed:
- When we call
led_pin.set_high(), bit 25 of GPIO_OUT changes from 0 to 1 (0x00000000→0x02000000) - When we call
led_pin.set_low(), bit 25 changes from 1 to 0 (0x02000000→0x00000000)
Atomic GPIO Register
Earlier, we looked only at the GPIO_OUT register. That register holds the full 32-bit output value for all GPIO pins. But in practice, the rp-hal library does not write to GPIO_OUT directly. Instead, it uses the atomic helper registers: GPIO_OUT_SET, GPIO_OUT_CLR, and GPIO_OUT_XOR.
These atomic registers are write-only registers within the SIO block that don’t hold values themselves. When you write to them, the bits you set are used to modify the underlying GPIO_OUT register:
- GPIO_OUT_SET changes specified bits to 1. This register is at address 0xd0000018, as per the datasheet.
- GPIO_OUT_CLR changes specified bits to 0. This register is at address 0xd0000020, as per the datasheet.
- GPIO_OUT_XOR toggles specified bits
Only the bits that we write as 1 are changed. All other bits stay untouched. This makes it safer and prevents accidental changes to other pins.
For example, if we want to control GPIO25:
-
To set GPIO25 high, we write a 1 to bit 25 of GPIO_OUT_SET. So the GPIO_OUT_SET value will be 0b00000010_00000000_00000000_00000000 (or in hex 0x02000000).
-
To set GPIO25 low, we write a 1 to bit 25 of GPIO_OUT_CLR. So the GPIO_OUT_CLR value will be 0b00000010_00000000_00000000_00000000 (or in hex 0x02000000).
These operations modify only bit 25 in GPIO_OUT, leaving all other bits intact.
Inside rp-hal: Setting a Pin High or Low
If we follow what set_high() and set_low() do inside rp-hal, we can see that they never write to GPIO_OUT directly. Instead, they write to the atomic registers GPIO_OUT_SET and GPIO_OUT_CLR.
The code inside rp-hal looks like this:
#![allow(unused)]
fn main() {
#[inline]
pub(crate) fn _set_low(&mut self) {
let mask = self.id.mask();
self.id.sio_out_clr().write(|w| unsafe { w.bits(mask) });
}
#[inline]
pub(crate) fn _set_high(&mut self) {
let mask = self.id.mask();
self.id.sio_out_set().write(|w| unsafe { w.bits(mask) });
}
}
When these write() functions run, they eventually call core::ptr::write_volatile(). write_volatile does some pre-checks, and then the compiler’s intrinsic intrinsics::volatile_store performs the final store to the MMIO address. That volatile store is the moment the actual hardware register changes.
Now let’s check how this looks when we step through it in GDB.
Breakpoint at write_volatile
There are many ways to reach write_volatile. One way is to step through set_low() or set_high() using stepi and nexti in GDB. But we will take a shorter path. We will set a breakpoint directly on core::ptr::write_volatile.
There is one thing to keep in mind. If you set this breakpoint right after reset (for example, right after monitor reset halt), GDB will stop many times. This is because write_volatile is used in a lot of places during startup. So we will not set it at the beginning.
Instead, follow the steps from the previous chapter. When the program stops at the first breakpoint in your code, like this:
Continuing.
Thread 1 hit Breakpoint 1, 0x100002f8 in pico_debug::__cortex_m_rt_main () at src/main.rs:63
63 led_pin.set_high().unwrap();
Tip
You can check your breakpoints with
info break. You can delete the breakpoint withdelete <number>.
Now that we’re past the startup code, let’s set our breakpoint on write_volatile:
(gdb) break core::ptr::write_volatile
Then continue execution:
(gdb) continue
You should see output similar to this:
Thread 1 received signal SIGINT, Interrupt.
rp235x_hal::gpio::eh1::{impl#1}::set_high<rp235x_hal::gpio::pin::bank0::Gpio25, rp235x_hal::gpio::pull::PullDown> (self=0x2007ffbd)
at /home/implrust/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/rp235x-hal-0.3.1/src/gpio/mod.rs:1549
1549 fn set_high(&mut self) -> Result<(), Self::Error> {
Continue again:
(gdb) continue
Now we’ve stopped inside the write_volatile function:
Thread 1 hit Breakpoint 3, core::ptr::write_volatile<u32> (dst=0xd0000018, src=33554432)
at /home/implrust/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ub_checks.rs:76
76 if ::core::ub_checks::$kind() {
Did you notice the function arguments here? The destination dst is 0xd0000018, which is the address of the GPIO_OUT_SET register. The source value src is 33554432. If we convert that to hexadecimal, we get 0x02000000. In binary, that’s 0b00000010_00000000_00000000_00000000. This is the exact bit mask for GPIO25.
Let’s disassemble the function to see what’s happening at the assembly level:
(gdb) disas
Dump of assembler code for function _ZN4core3ptr14write_volatile17hc4948e781ca030f6E:
0x10008084 <+0>: push {r7, lr}
0x10008086 <+2>: mov r7, sp
0x10008088 <+4>: sub sp, #24
0x1000808a <+6>: str r2, [sp, #4]
0x1000808c <+8>: str r1, [sp, #8]
0x1000808e <+10>: str r0, [sp, #12]
0x10008090 <+12>: str r0, [sp, #16]
0x10008092 <+14>: str r1, [sp, #20]
=> 0x10008094 <+16>: b.n 0x10008096 <_ZN4core3ptr14write_volatile17hc4948e781ca030f6E+18>
0x10008096 <+18>: ldr r2, [sp, #4]
0x10008098 <+20>: ldr r0, [sp, #12]
0x1000809a <+22>: movs r1, #4
0x1000809c <+24>: bl 0x100080ac <_ZN4core3ptr14write_volatile18precondition_check17h8beabfccc7ba3236E>
0x100080a0 <+28>: b.n 0x100080a2 <_ZN4core3ptr14write_volatile17hc4948e781ca030f6E+30>
0x100080a2 <+30>: ldr r0, [sp, #8]
0x100080a4 <+32>: ldr r1, [sp, #12]
0x100080a6 <+34>: str r0, [r1, #0]
0x100080a8 <+36>: add sp, #24
0x100080aa <+38>: pop {r7, pc}
End of assembler dump.
The key instruction is at address 0x100080a6. This is the line that actually writes to the hardware register. At this point, r1 will contain the GPIO_OUT_SET address and r0 will contain the value that is going to be written.
Let’s take a closer look. We set another breakpoint right on that instruction:
(gdb) break *0x100080a6
Then continue:
(gdb) continue
If you get interrupted, continue again
Thread 1 received signal SIGINT, Interrupt.
core::ptr::write_volatile<u32> (dst=0xd0000018, src=33554432)
at /home/implrust/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ub_checks.rs:77
77 precondition_check($($arg,)*);
Continue again:
(gdb) c
Continuing.
Thread 1 hit Breakpoint 4, 0x100080a6 in core::ptr::write_volatile<u32> (dst=0xd0000018, src=33554432)
at /home/implrust/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:2201
2201 intrinsics::volatile_store(dst, src);
GDB will stop exactly at the store instruction. If you run disas again, you’ll see the arrow pointing to that line:
...
0x100080a4 <+32>: ldr r1, [sp, #12]
=> 0x100080a6 <+34>: str r0, [r1, #0]
0x100080a8 <+36>: add sp, #24
Before we execute this write instruction, let’s check what values are in registers r0 and r1:
(gdb) i r $r0
r0 0x2000000 33554432
(gdb) i r $r1
r1 0xd0000018 3489660952
Let’s also examine the current value in the GPIO_OUT register:
(gdb) x/x 0xd0000010
0xd0000010: 0x00000000
Right now it shows all zeros. At this stage, the LED is still off because we haven’t executed the store instruction yet.
Now let’s step forward by one instruction:
(gdb) nexti
#or
(gdb) ni
After executing this command, you should see the LED turn on. Now let’s examine the GPIO_OUT register again:
(gdb) x/x 0xd0000010
0xd0000010: 0x02000000
The register now shows 0x02000000, which is exactly the bit mask for GPIO25. This confirms that our write operation successfully set the LED pin high.
Your Turn: Try It Yourself
Now it’s time to practice what you’ve learned. Let the program continue running until it hits the set_low breakpoint. Then continue execution again until you reach the write_volatile function.
This time, things will be a bit different. The destination address will be 0xd0000020, which is the GPIO_OUT_CLR register. As the name suggests, this register is used to clear GPIO pins rather than set them.
Step through the code just like before. When you execute the str instruction, the LED will turn off. If you examine the GPIO_OUT register afterwards, you’ll see it contains all zeros again. This confirms that the bit for GPIO25 has been cleared, turning off the LED.
Watchdog
This book was originally written using rp-hal. Later, I revised it to primarily use Embassy. When working with rp-hal, there is a step where we explicitly configure the watchdog. To explain why that line exists and what it actually does, this chapter introduces the concept of a watchdog.
In January 1994, the Clementine spacecraft successfully mapped the Moon. While it was traveling toward the asteroid Geographos, a floating point exception occurred on May 7, 1994, in the Honeywell 1750 processor. This processor handled telemetry and several other critical spacecraft functions.
The Honeywell 1750 included a built-in watchdog timer, but it was not used. After the failure, the software team publicly regretted this decision. They also noted that even a standard watchdog might not have been robust enough to detect that specific failure mode.
So what exactly is a watchdog, and why do we use it?
You may already have a rough idea.
What is watchdog?
A watchdog timer (WDT) is a hardware component commonly found in embedded systems. Its primary job is to detect software failures and automatically reset the processor when something goes wrong. This allows the system to recover without human intervention.
Watchdogs are especially important in systems that must run unattended for long periods of time.
How It Works?
A watchdog timer behaves like a countdown timer. It starts counting down from a configured value toward zero. The software must periodically reset this timer before it reaches zero.
This action is commonly called “feeding the watchdog”. You may also see it referred to as “kicking the dog”, although that term is widely used and I personally avoid it.
If the software fails to reset the timer in time, for example due to an infinite loop, a deadlock, or a system hang, the watchdog assumes the system is no longer healthy and triggers a processor reset. After the reset, the system can start again in a known good state.
Feeding the dog:
You can think of the watchdog timer like a dog that needs to be fed at regular intervals. As time passes, the dog gets hungrier. If it is not fed in time, it reacts. In embedded systems, that reaction is a hardware reset.
To keep the system running normally, the software must regularly feed the watchdog by resetting its counter.
Code
In the following snippet, we set up the watchdog driver. This is required because the clock initialization code depends on the watchdog being available.
#![allow(unused)]
fn main() {
// Set up the watchdog driver - needed by the clock setup code
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
}
References
- Great Watchdog Timers For Embedded Systems, by Jack Ganssle
- Born to fail
- A Guide to Watchdog Timers for Embedded Systems
- Proper Watchdog Timer Use
Curated List of Projects Written in Rust for Raspberry Pi Pico 2
Here is a curated list of projects I found online that are interesting and related to Pico 2 and Rust. If you have some interesting projects to showcase, please send a PR :)
- Pico Rex: Dinosaur Game written in Rust for the Raspberry Pi Pico 2 (RP2350) with an OLED display, using the Embassy framework.
- GB-RP2350 A Game Boy emulator for the Pi Pico 2 written in Rust: You can also find the reddit post by the OP here.
- simple-robot: A very simple robot with HC-SR04 distance sensor and autonomous as well as remote controlled movement written in Rust(Embassy)
Useful resources
This section will include a list of resources I find helpful along the way.
Blog Posts
Tutorials
Other resources
- Curated list of resources for Embedded Rust
- Writing an OS in Rust : many useful concepts explained here
- Embassy Book
















