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.
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.
Raspberry Pi Pico 2 Pinout Diagram
Note: 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.
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.
Setup
Picotool
picotool is a tool for working with RP2040/RP2350 binaries, and interacting with RP2040/RP2350 devices when they are in BOOTSEL mode.
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.
NOTE: 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.1.0
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.toml file, defined as:runner = "picotool load -u -v -x -t elf". This means that when you execute cargo run, it actually invokes the picotool with the load subcommand 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.
Note: This book focuses on ARM examples. 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.
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"
Note: 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");
}
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.
Note: 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.
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.
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.2.0
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.2.0
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?
NOTE: 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
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’s a technique that uses a digital signal switching rapidly between HIGH and LOW to simulate an analog output.
Instead of providing a steady voltage like 1.5V as a true analog signal would, PWM quickly toggles between full voltage and 0V. By controlling how long the signal stays HIGH versus LOW, you can control the average power or voltage delivered to the device.
How PWM Works
PWM works by sending a square wave signal that switches between HIGH and LOW at a fixed frequency. The key parameter is the “duty cycle”, which is the percentage of time the signal is HIGH during one complete cycle.
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.3 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).
The switching happens extremely fast, typically hundreds or thousands of times per second. This is so fast that devices like LEDs and motors respond to the average voltage rather than seeing individual pulses.
Example Usage: 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.
Example Usage: 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
In PWM, period and frequency work together to determine how fast the signal switches between HIGH and LOW states, which directly affects how smooth and effective your control is.
Period is the total time for one on-off cycle to complete.
The frequency is the number of cycles it completes in one second, measured in Hertz (Hz). Frequency is the inverse of the period. So, a higher frequency means a shorter period, resulting in faster switching between HIGH and LOW states.
\[ \text{Frequency (Hz)} = \frac{1}{\text{Period (s)}} \]
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} \]
Calculating Cycle count from Frequency per second
The Formula to calculate cycle count:
\[
\text{Cycle Count} = \text{Frequency (Hz)} \times \text{Total Time (seconds)}
\]
If a PWM signal has a frequency of 50Hz, it means it completes 50 cycles in one second.
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 in Depth
Timer Operation
The timer plays a key role in PWM generation. It counts from zero to a specified maximum value (stored in a register), then resets and starts the cycle over. This counting process determines the duration of one complete cycle, called the period.
Compare Value
The timer’s hardware continuously compares its current count with a compare value (stored in a register). When the count is less than the compare value, the PWM signal stays HIGH; when the count exceeds the compare value, the signal goes LOW. By changing this compare value, you directly control the duty cycle.
PWM Resolution
Resolution refers to how precisely the duty cycle can be controlled. This is determined by the range of values the timer counts through in one complete cycle.
The timer counts from 0 to a maximum value based on the resolution. The higher the resolution, the more finely the duty cycle can be adjusted.
For a system with n bits of resolution, the timer counts from 0 to \(2^n - 1\), which gives \(2^n\) possible levels for the duty cycle.
For example:
- 8-bit resolution allows the timer to count from 0 to 255, providing 256 possible duty cycle levels.
- 10-bit resolution allows the timer to count from 0 to 1023, providing 1024 possible duty cycle levels.
Higher resolution gives more precise control over the duty cycle but requires the timer to count through more values within the same period. This creates a trade-off: to maintain the same frequency with higher resolution, you need a faster timer clock, or you must accept a lower frequency.
Simulation
You can modify the PWM resolution bits and duty cycle in this simulation. Adjusting the PWM resolution bits increases the maximum count but remains within the time period (it does not affect the duty cycle). Changing the duty cycle adjusts the on and off states accordingly, but it also stays within the period.
Relationship Between Duty Cycle, Frequency, and Resolution
This diagram shows how duty cycle, frequency, period, pulse width, and resolution work together in PWM generation. While it may seem a bit complex at first glance, breaking it down helps to clarify these concepts.
In this example, the timer resolution is 4 bits, meaning the timer counts from 0 to 15 (16 possible values). When the timer reaches its maximum value(i.e 15), an overflow interrupt is triggered (indicated by the blue arrow), and the counter resets to 0. The time it takes for the timer to count from 0 to its maximum value is called as “period”.
The duty cycle is configured to 50%, meaning the signal remains high for half the period. At each step in the counting process, the timer compares its current count with the duty cycle’s compare value. When the timer count exceeds this compare value (marked by the yellow arrow), the signal transitions from high to low. This triggers the compare interrupt, signaling the state change.
The time during which the signal is high is referred to as the pulse width.
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 1076 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;
}
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.2.0
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
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.
Ultrasonic
In this guide, we’ll learn how to use the HC-SR04 ultrasonic sensor with the Raspberry Pi Pico 2. Ultrasonic sensors measure distances by emitting ultrasonic sound waves and calculating the time taken for them to return after bouncing off an object.
These kind of sensors you can normally find in the car parking assistance; When you reverse the car for parking, the sensor measures the distance between objects and alert you as you get close to it.
We will build a simple project that gradually increases the LED brightness using PWM, when the ultrasonic sensor detects an object distance of less than 30 cm - You can adjust this value as per your needs.
Prerequisites
Before starting, get familiar with yourself on these topics
🛠 Hardware Requirements
To complete this project, you will need:
- 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)
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.
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 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 input 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.
Circuit
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.
- Although the HC-SR04 generally operates at 5V, using 3.3V helps protect the Pico since the Pico GPIO pins are only 3.3V tolreant.
- Other considerations: Alternatively, you can use HCSR04+ (which can operate at both 3.3v and 5V) or use voltage divider.
- 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, as in the External LED setup.
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 |
Code
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.1.0
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-projects/ultrasonic
Your Challenge
- Use Embassy framework instead of rp-hal
- Use the onboard LED instead
I2C
I²C, also known as I2C or IIC (Inter-Integrated Circuit), is a communication protocol widely used to link microcontrollers with devices like sensors, displays, and other integrated circuits. This protocol is also called the Two-Wire Interface (TWI).
-
Two Wires: Only 2 lines, SDA (Serial Data) and SCL (Serial Clock), are used to transfer data.
- The SDA line is used by both the master and slave to send and receive data
- The SCL line carries the clock signal
-
Multi-Device Support: I2C allows multiple slave devices to be connected to a single master, and it also supports multiple masters.
-
The master is responsible for generating the clock and controlling the transfer of data, while the slave responds by either transmitting or receiving data from the master.
RP2350’s I2C
The RP2350 has 2 identical I2C controllers, each connected to specific GPIO pins on the Raspberry Pi Pico.
| 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 |
Resources
OLED Display
OLED Display
In this section, we’ll learn how to connect an OLED display module to the Raspberry Pi Pico 2.
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.
Hardware Requirements
For this project, you’ll need:
- An OLED display (0.96 Inch I2C/IIC 4-Pin, 128x64 resolution, SSD1306 chip)
- A breadboard
- Jumper wires
Setup
| Pico Pin | Wire | OLED Pin |
|---|---|---|
| GPIO 18 |
|
SDA |
| GPIO 19 |
|
SCL |
| 3.3V |
|
VCC |
| GND |
|
GND |
We will connect the SDA to GPIO 18 and the SCL to GPIO 19. Attach VCC to 3.3V for power, and GND to GND. This setup allows the OLED display to communicate with the microcontroller using I2C.
New crates
In addition to the usual crate like rp-hal, we will be using these new crates necessary for the project.
ssd1306: a driver for the SSD1306 OLED display, supporting both I2C and 4-wire SPI.embedded-graphics: a 2D graphics library tailored for memory-constrained embedded devices, enabling text and graphic rendering.tinybmp: a lightweight BMP parser for embedded, no-std environments. We’ll use this to directly load.bmpimages with theembedded-graphicscrate, avoiding the need for raw image data.
Prerequisite
Resources
Hello Rust on OLED
We will create a simple program to display “Hello, Rust” in the OLED display.
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.1.0
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.9.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
Code
Additional imports
In addition to the imports from the template, you’ll need the following additional dependencies for this task.
#![allow(unused)]
fn main() {
use hal::fugit::RateExtU32;
use hal::gpio::{FunctionI2C, Pin};
use ssd1306::{prelude::*, I2CDisplayInterface, Ssd1306};
use embedded_graphics::prelude::*;
use embedded_graphics::mono_font::ascii::FONT_6X10;
use embedded_graphics::mono_font::MonoTextStyleBuilder;
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::text::{Baseline, Text};
}
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 master mode.
#![allow(unused)]
fn main() {
// 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();
let i2c = hal::I2C::i2c1(
pac.I2C1,
sda_pin,i2c1
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().unwrap();
}
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, Rust!", Point::new(0, 16), text_style, Baseline::Top)
.draw(&mut display)
.unwrap();
}
Here, we are writing the message at coordinates (x=0, y=16).
Write out data to a display
#![allow(unused)]
fn main() {
display.flush().unwrap();
}
Full logic
#![allow(unused)]
fn main() {
let sda_pin: Pin<_, FunctionI2C, _> = pins.gpio18.reconfigure();
let scl_pin: Pin<_, FunctionI2C, _> = pins.gpio19.reconfigure();
let i2c = hal::I2C::i2c1(
pac.I2C1,
sda_pin,
scl_pin,
400.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
display.init().unwrap();
let text_style = MonoTextStyleBuilder::new()
.font(&FONT_6X10)
.text_color(BinaryColor::On)
.build();
Text::with_baseline("Hello, Rust!", Point::new(0, 16), text_style, Baseline::Top)
.draw(&mut display)
.unwrap();
display.flush().unwrap();
loop {
timer.delay_ms(500);
}
}
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
Ferris on OLED
In this task, we will display the ferris.bmp(Download and put it in the project folder) file on the OLED screen.
Follow the same instructions as in the “Hello Rust” program, with just a few modifications.
First, we need to add the tinybmp crate to handle BMP file loading. Use the following Cargo command to include it in your project:
cargo add tinybmp@0.6.0
Additional imports
#![allow(unused)]
fn main() {
use embedded_graphics::image::Image;
use tinybmp::Bmp;
}
Difference
After initializing the display, we will load the ferris.bmp file using the tinybmp crate, and then we will draw the image.
#![allow(unused)]
fn main() {
let bmp = Bmp::from_slice(include_bytes!("../ferris.bmp")).unwrap();
let im = Image::new(&bmp, Point::new(32, 0));
im.draw(&mut display).unwrap();
}
Clone the existing project
You can clone (or refer) project I created and navigate to the ferris-oled folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/ferris-oled
Useful tools
- Bitmap to Rust array Converter: Image2Bytes Generator
Servo Motor and PWM
In this section, we’ll connect an SG90 Micro Servo Motor to the Pico 2 and control its rotation using PWM. The servo will move in a loop, rotating from 0 degrees to 90 degrees, and then to 180 degrees.
Before moving forward, make sure you’ve read the PWM introduction in the Blink LED section.
Hardware Requirements
- 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).
Connection Overview
- 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 (or 3.3V if required by your setup) power pin.
- Signal (PWM): Connect the servo’s control (signal) pin to GPIO9 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 9 |
|
Signal (Orange Wire) | Receives PWM signal to control the servo's position. |
Intro
Introduction to Servo Motors
A servo motor controls movement by adjusting its position using a feedback system. It is guided by a signal, usually Pulse Width Modulation (PWM), to reach and maintain the desired position.
They are widely used in applications requiring precise motion, such as robotics, RC vehicles, and camera systems, as well as in various projects. Hobby servos, which are often used in RC toys like cars, airplanes, are also popular for building robots.
In our exercise, we’ll be using the hobby server (Micro Servo SG90)
How does it work?
A servo motor is controlled by sending a series of pulses through its signal line. The signal has a frequency of 50Hz, with a pulse every 20 milliseconds. The width of the pulse determines the servo’s position. Typically, a servo can rotate 180 degrees.
Controlling the position
The position of a servo motor is controlled by sending a pulse with a specific duration. The length of the pulse determines the angle of the motor. For most servos, a 1ms pulse moves the motor to 0 degrees, a 1.5ms pulse moves it to 90 degrees (neutral position), and a 2ms pulse moves it to 180 degrees.
However, from my experiment, I found that not all servos follow these exact timings. For example, with my servo, the pulse duration for 0 degrees was 0.5ms, 1.5ms for 90 degrees, and approximately 2.4ms for 180 degrees. I had to experiment and adjust to get it right. If you’re unsure, you can use tools like an oscilloscope to fine-tune it, or simply test different values to find what works best for your specific servo.
The example I’ll provide in this exercise is based on my servo’s configuration, you might need to adjust the values depending on the servo you’re using.
Reference
More on PWM
More on PWM
The servo motor we’re using operates at a 50Hz frequency, which means that a pulse is sent every 20 milliseconds (ms).
Let’s break this down further:
- 50Hz Frequency: Frequency refers to how many times an event happens in a given time period. A 50Hz frequency means that the servo expects a pulse to occur 50 times per second. In other words, the servo receives a pulse every 1/50th of a second, which is 20 milliseconds.
- 20ms Time Interval: This 20ms is the time between each pulse. It means that every 20 milliseconds, the servo expects a new pulse to adjust its position. Within this 20ms period, the width of the pulse (how long it stays “high”) determines the angle at which the servo will move.
So, when we say the servo operates at 50Hz, it means that the motor is constantly receiving pulses every 20ms to keep it in motion or adjust its position based on the width of each pulse.
Pulse Width and Duty Cycle
Let’s dive deeper into how different pulse widths like 0.5ms, 1.5ms, and 2.4ms affect the servo’s position.
1. 0.5ms Pulse (Position: 0 degrees)
-
What Happens: 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.
-
Duty Cycle: The duty cycle refers to the percentage of time the signal is “high” in one complete cycle. For a 0.5ms pulse: \[ \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.
2. 1.5ms Pulse (Position: 90 degrees)
- What Happens: 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).
- Duty Cycle: For a 1.5ms pulse: \[ \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).
3. 2.4ms Pulse (Position: 180 degrees)
- What Happens: 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).
- Duty Cycle: For a 2.4ms pulse: \[ \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.
Servo and Pico
Servo and Pico
To control a servo with the Raspberry Pi Pico, we need to set a 50Hz PWM frequency. Currently, RP-HAL doesn’t allow directly setting the frequency, so we achieve this by adjusting the top and div_int values.
Refer the 1073th page of the RP2350 Datasheet to understand how top and div_int works.
Formula from datasheet
The following formula from the datasheet is used to calculate the period and determine the output frequency based on the system clock frequency.
-
Period calculation: \[ \text{period} = (\text{TOP} + 1) \times (\text{CSR_PH_CORRECT} + 1) \times \left( \text{DIV_INT} + \frac{\text{DIV_FRAC}}{16} \right) \]
-
PWM output frequency calculation:
\[ 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.
Let’s calculate top
We want the PWM frequency (f_pwm) to be 50 Hz. In order to achieve that, we are going to adjust the top and div_int values.
The top value must be within the range of 0 to 65535 (since it’s a 16-bit unsigned integer). To make sure the top value fits within this range, I chose values for the divisor (div_int) in powers of 2 (such as 8, 16, 32, 64), though this isn’t strictly necessary (it’s just a preference). In this case, we chose div_int = 64 to calculate a top value that fits within the u16 range.
With the chosen div_int and system parameters, we can calculate the top using the following formula: \[ \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 \]
After performing the calculation, we find that the top value is 46,874.
You can experiment with different div_int and corresponding top values. Just ensure that div_int stays within the u8 range, top fits within the u16 range, and the formula yields a 50Hz frequency.
Note:
- In case you are wondering, we are not setting the
div_fracwhich is 0 by default. That’s why it is not included in the calculation. - We are not going to enable the phase correct for this exercise, so it also can be excluded from the calculation (since it is just multiplying by 1); if you enable phase correct, then the calculation will differ since you have to multiply by 2 (1+1)
Position calculation based on top
To calculate the duty cycle that corresponds to specific positions (0, 90, and 180 degrees), we use the following formula based on the top value:
#![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;
}
We multiply the TOP value by a duty cycle percentage to determine the appropriate pulse width for each position of the servo. You might need to adjust the percentage based on your servo.
Action
Action
Setting Up the PWM and Servo Control
#![allow(unused)]
fn main() {
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = 46_874;
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;
}
1. Set Up the PWM Slice and Channel
First, initialize the PWM slice and channel. You should have already done similar in the previous blinky section.
#![allow(unused)]
fn main() {
let mut pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS);
let pwm = &mut pwm_slices.pwm4;
}
2. Adjust for 50HZ frequency
Now, set the divisor and the top value to achieve a PWM frequency of 50Hz.
#![allow(unused)]
fn main() {
pwm.set_div_int(PWM_DIV_INT);
pwm.set_div_frac(0);
pwm.set_top(PWM_TOP);
pwm.enable();
}
3. Set Output Pin
Next, specify the GPIO pin where the PWM signal will be sent. We will use GPIO pin 9.
#![allow(unused)]
fn main() {
let servo = &mut pwm.channel_b;
servo.output_to(pins.gpio9);
}
4. Set Servo Position in a Loop
Finally, in the loop, we adjust the duty cycle which will control the servo’s position. We will move the servo to different positions (0°, 90°, and 180°) using the MIN_DUTY, HALF_DUTY, and MAX_DUTY values calculated earlier.
#![allow(unused)]
fn main() {
loop {
servo.set_duty_cycle(MIN_DUTY).unwrap(); // 0 degrees
timer.delay_ms(1000);
servo.set_duty_cycle(HALF_DUTY).unwrap(); // 90 degrees
timer.delay_ms(1000);
servo.set_duty_cycle(MAX_DUTY).unwrap(); // 180 degrees
timer.delay_ms(1000);
}
}
Full Code snippet
const PWM_DIV_INT: u8 = 64;
const PWM_TOP: u16 = 46_874;
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;
#[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,
);
// The delay object lets us wait for specified amounts of time (in
// milliseconds)
let mut timer = hal::Timer::new_timer0(pac.TIMER0, &mut pac.RESETS, &clocks);
// 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_div_int(PWM_DIV_INT);
pwm.set_div_frac(0);
pwm.set_top(PWM_TOP);
pwm.enable();
let servo = &mut pwm.channel_b;
servo.output_to(pins.gpio9);
loop {
servo.set_duty_cycle(MIN_DUTY).unwrap();
timer.delay_ms(1000);
servo.set_duty_cycle(HALF_DUTY).unwrap();
timer.delay_ms(1000);
servo.set_duty_cycle(MAX_DUTY).unwrap();
timer.delay_ms(1000);
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the servo folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/servo
Watchdog
In January 1994, the Clementine spacecraft successfully mapped the moon. While traveling toward the asteroid Geographos, a floating point exception occurred on May 7, 1994, in the Honeywell 1750 processor, which was responsible for telemetry and various spacecraft functions.
The 1750 had a built-in watchdog timer but it was not utilized. The software team later regretted this decision and noted that a standard watchdog might not have been robust enough to detect the failure mode.
So, What exactly is a watchdog?
You might have already figured out its purpose.
What is watchdog?
A watchdog timer (WDT) is a hardware component used in embedded systems, its primary purpose is to detect software anomalies and automatically reset the processor if a malfunction occurs, ensuring that the system can recover without human intervention.
How It Works?
The watchdog timer functions like a counter that counts down from a preset value to zero. The embedded software is responsible for periodically “feeding the dog” (also known as “kicking the dog,” a term I don’t like) by resetting the counter before it reaches zero. If the software fails to reset the counter (perhaps due to an infinite loop or a system hang), the watchdog timer assumes there’s a problem and triggers a reset of the processor. This allows the system to restart and return to normal operation.
Feeding the dog:
Think of a watchdog timer like a dog that needs regular feeding to stay healthy and active. Just as you must feed your dog at scheduled intervals, the watchdog timer requires periodic resets to ensure that the embedded system is operating correctly. Imagine the dog’s energy levels decreasing over time. If it runs out of energy, it will bark to alert you (just like the watchdog timer triggers an alert if it reaches zero). To keep the dog happy and active, you need to feed it regularly (or reset the timer) before it runs out of energy!
By implementing a watchdog timer, embedded systems can be made self-reliant, essential for devices that may be unreachable by human operators, such as space probes or other remote applications.
Code
In this code snippet, we were setting up the watchdog driver, which is essential for the clock setup process.
#![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
Buzzinga
In this section, we’ll explore some fun activities using the buzzer. I chose the title “Buzzinga” just for fun (a nod to Sheldon’s “Bazinga” in The Big Bang Theory); it’s not a technical term.
- Passive Buzzer
- Jumper Wires:
- Female-to-Male jumper wires for connecting the Pico 2 to the buzzer pins (Positive and Ground).
The buzzer has two pins: Positive(Signal), Ground; The positive side of the buzzer is typically marked with a + symbol and is the longer pin, while the negative side (ground) is the shorter pin, similar to an LED. However, some passive buzzers may allow for either pin to be connected to ground or signal, depending on the specific model.
By the way, I used an active buzzer in my experiment. A passive buzzer is recommended if you plan to play different sounds, as it provides a better tone.
Connection Overview
| Pico Pin | Wire | Buzzer Pin | Notes |
|---|---|---|---|
| GPIO 15 |
|
Positive Pin | Receives PWM signals to produce sound. |
| GND |
|
Ground Pin | Connects to ground. |
Before moving forward, make sure you’ve read the following sections and understood the concepts.
- PWM introduction in the Blink LED section
- More on PWM in the servo section
- Calculating top in the servo section
Reference
Intro to Buzzer
Introduction to Buzzer
A buzzer is an electronic device used to generate sound, beeps, or even melodies, and is commonly found in alarm systems, timers, computers, and for confirming user inputs, such as mouse clicks or keystrokes. Buzzers serve as audio signaling devices, providing audible feedback for various actions.
Active Buzzer vs Passive Buzzer
Active Buzzer:
-
Built-in Oscillator: An active buzzer has an internal oscillator that generates the tone automatically when power is applied. You can identify whether you have active buzzer or not by connecting the buzzer directly to the battery and it will make a sound.
-
Simpler Usage: No need to worry about generating specific frequencies since the buzzer does it internally.
-
Tone: Typically produces a single tone or a fixed frequency.

-
How to identify: Usually has a white covering on top and a black smooth finish at the bottom. It produces sound when connected directly to a battery.
Passive Buzzer:
- External Signal Required: A passive buzzer requires an external signal (usually a square wave) to generate sound. It does not have an internal oscillator, so it relies on a microcontroller to provide a frequency.
- Flexible Tones: You can control the frequency and create different tones, melodies, or alarms based on the input signal.
- How to identify: Typically has no covering on the top and looks like a PCB-style blue or green covering at the bottom.
Which one ?
Choose Active Buzzer if:
- You need a simple, fixed tone or beep. It’s ideal for basic alerts, alarms, or user input confirmation.
Choose Passive Buzzer if:
- You want to generate different tones, melodies, or sound patterns.
It is recommended to use a passive buzzer for our exercises. However, if you only have an active buzzer, don’t worry; you can still use it. In fact, I personally used an active buzzer for this.
Beep
In this exercise, we will generate a beeping sound with a 50% duty cycle, followed by a 0% duty cycle, creating a looping pattern of sound and silence. We will use the A4 note (440Hz frequency) for this. (If you’re not familiar with the A4 note, please look up more information on musical notes.)
Get Top function
In the previous exercise (servo motor), we manually calculated and hardcoded the top value. In this exercise, we create a small function to calculate the top value based on the target frequency and div_int:
#![allow(unused)]
fn main() {
const fn get_top(freq: f64, div_int: u8) -> u16 {
let result = 150_000_000. / (freq * div_int as f64);
result as u16 - 1
}
}
div_int value
We will be using 64 as div_int.
#![allow(unused)]
fn main() {
const PWM_DIV_INT: u8 = 64;
}
Configure the GPIO 15 pin
Next, we need to configure the GPIO pin (GPIO 15) to output the PWM signal.
#![allow(unused)]
fn main() {
let pwm = &mut pwm_slices.pwm7;
pwm.enable();
pwm.set_div_int(PWM_DIV_INT);
pwm.channel_b.output_to(pins.gpio15);
}
To Set a frequency 440Hz(A4 Note)
Now we calculate the top value required to generate the 440Hz frequency (A4 note) and set it for the PWM:
#![allow(unused)]
fn main() {
let top = get_top(440., PWM_DIV_INT);
pwm.set_top(top);
}
Loop
Finally, we create a loop to alternate between a 50% duty cycle (beep) and a 0% duty cycle (silence). The loop repeats with a delay of 500 milliseconds between each change:
#![allow(unused)]
fn main() {
loop {
pwm.channel_b.set_duty_cycle_percent(50).unwrap();
timer.delay_ms(500);
pwm.channel_b.set_duty_cycle(0).unwrap();
timer.delay_ms(500);
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the beep folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/beep
GOT Buzzer?
We are going to play the Game of Thrones (GOT) background music (BGM) on the buzzer, thanks to the awesome arduino-songs repository. It also has other bgms.
If you’re unsure about musical notes and sheet music, feel free to check out the quick theory I’ve provideded here.
I’ve splitted the code into rust module(you can do it in single file as we have done so far): music, got.
Reference
Musical 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
In the music module, we define constants for common notes and their corresponding frequency values.
#![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
}
Next, we create small helper struct to to represent a musical Song and provide some functions to calculate note durations based on tempo.
This struct has a single field whole_note, which will store the duration of a whole note in milliseconds. The reason we store the duration in milliseconds is that musical timing is often based on tempo (beats per minute, BPM), and we need to calculate how long each note lasts in terms of time.
#![allow(unused)]
fn main() {
pub struct Song {
whole_note: u32,
}
}
The formula (60_000 * 4) / tempo as u32 calculates the duration of a whole note in milliseconds. We use 60_000 because there are 60,000 milliseconds in a minute, and we multiply by 4 because a whole note is typically equivalent to four beats.
#![allow(unused)]
fn main() {
impl Song {
pub fn new(tempo: u16) -> Self {
let whole_note = (60_000 * 4) / tempo as u32;
Self { whole_note }
}
}
calc_note_duration
The calc_note_duration function calculates the duration of a musical note based on its division relative to a whole note. It takes in a divider parameter, which can be positive or negative, and returns the duration of the note in milliseconds.
#![allow(unused)]
fn main() {
pub fn calc_note_duration(&self, divider: i16) -> u32 {
if divider > 0 {
self.whole_note / divider as u32
} else {
let duration = self.whole_note / divider.unsigned_abs() as u32;
(duration as f64 * 1.5) as u32
}
}
}
}
Logic:
-
When
divider > 0:- If the
divideris positive, the function calculates the note’s duration by dividing the duration of a whole note by thedivider. - For example, if
divider = 4, the function calculates the duration of a quarter note, which is 1/4 of a whole note.
- If the
-
When
divider <= 0:- If the
divideris negative, the function first converts thedividerto a positive value usingunsigned_abs(). - It divides the whole note’s duration by this absolute value, then multiplies the result by 1.5 to account for dotted notes (e.g., dotted quarter note, dotted eighth note), which last 1.5 times the duration of a regular note.
- If the
This positive and negative logic is a custom approach (based on an Arduino example I referred to) to differentiate dotted notes. It is not related to standard musical logic.
Melody Example: Game of Thrones Theme
These section contains code snippets for the rust module got.
Tempo
we declare the tempo for the song(you can also change and observe the result).
#![allow(unused)]
fn main() {
pub const TEMPO: u16 = 85;
}
Melody Array
We define the melody of the Game of Thrones theme using the notes and durations in an array. The melody consists of tuple of note frequencies and their corresponding durations. The duration of each note is represented by an integer, where positive values represent normal notes and 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),
];
}
Play
Playing the Game of Thrones Melody
This section demonstrates how to play the Game of Thrones melody using PWM (Pulse Width Modulation) for generating the tones and timing the note durations. The code calculates the duration of each note, sets the PWM duty cycle, and controls the timing to ensure proper pauses between notes.
Song object
This creates a new Song object using the tempo value from got::TEMPO, which is set to 85 BPM. The Song object will manage the note durations based on the tempo.
#![allow(unused)]
fn main() {
let song = Song::new(got::TEMPO);
}
Playing the notes
This loop iterates over the muisc-notes array. Each item in the melody is a tuple containing a note and its duration type (e.g., quarter note, eighth note).
#![allow(unused)]
fn main() {
for (note, duration_type) in got::MELODY {
let top = get_top(note, PWM_DIV_INT);
pwm.set_top(top);
let note_duration = song.calc_note_duration(duration_type);
let pause_duration = note_duration / 10; // 10% of note_duration
pwm.channel_b.set_duty_cycle_percent(50).unwrap(); // Set duty cycle to 50% to play the note
timer.delay_ms(note_duration - pause_duration); // Play 90%
pwm.channel_b.set_duty_cycle(0).unwrap(); // Stop tone
timer.delay_ms(pause_duration); // Pause for 10%
}
}
First, we calculate the top value based on the note frequency. This sets the PWM frequency to match the target note.
Next, the calc_note_duration function is used to determine how long each note should be played. We also calculate the pause duration as 10% of the note duration. The 90% duration ensures that the note is played clearly, while the 10% pause creates a small gap between notes, resulting in a cleaner and more distinct melody.
Keeping the Program Running
This loop keeps the program running indefinitely, as required by the main function’s signature. The main function has a -> ! return type, meaning it doesn’t return.
#![allow(unused)]
fn main() {
loop {
// Keep the program running
timer.delay_ms(500);
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the got-buzzer folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/got-buzzer
Wokwi Project
An Arduino version of this exercise is available on the Wokwi site with the Pico board. Unfortunately, the site currently does not support coding in Rust for the Pico. However, you can refer to this project to understand how it works.
Active Beep
Beeping with an Active Buzzer
Since you already know that an active buzzer is simple to use, you can make it beep just by powering it. In this exercise, we’ll make it beep with just a little code.
Hardware Requirements
- Active Buzzer
- Female-to-Male or Male-to-Male (depending on your setup)
We’ll use the Embassy HAL for this project.
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 “active-beep” and select embassy as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "active-beep":
# cd active-beep
All you need to do is change the output pin from 25 to 15 in the template code.
// Active Buzzer
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let mut buzzer = Output::new(p.PIN_15, Level::Low); // Changed PIN number to 15
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 an input voltage \( V_{in} \) to a lower output voltage \( V_{out} \) using two series resistors. The resistor connected to the input voltage \( V_{in} \) is called \( R_{1} \), and the other resistor is \( R_{2} \). The output voltage \( V_{out} \) is taken from the junction between \( R_{1} \) and \( R_{2} \), producing a fraction of \( 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.
Simulator
I used the website https://www.falstad.com/circuit/e-voltdivide.html to create this 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.
LDR
LDR (Light Dependent Resistor)
In this section, we will use an LDR (Light Dependent Resistor) with the Raspberry Pi Pico 2. An LDR changes its resistance based on the amount of light falling on it. The brighter the light, the lower the resistance, and the dimmer the light, the higher the resistance. This makes it ideal for applications like light sensing, automatic lighting, or monitoring ambient light levels.
Components Needed:
- LDR (Light Dependent Resistor)
- Resistor (typically 10kΩ); needed to create voltage divider
- Jumper wires (as usual)
Prerequisite
To work with this, you should get familiar with what a voltage divider is and how it works. You also need to understand what ADC is and how it functions.
What is LDR
How LDR works?
We have already given an introduction to what an LDR is. Let me repeat it again: an LDR changes its resistance based on the amount of light falling on it. The brighter the light, the lower the resistance, and the dimmer the light, the higher the resistance.
Dracula: Think of the LDR as Dracula. In sunlight, he gets weaker (just like the resistance gets lower). But in the dark, he gets stronger (just like the resistance gets higher).
We will not cover what kind of semiconductor materials are used to make an LDR, nor why it behaves this way in depth. I recommend you read this article and do further research if you are interested.
Simulation of LDR in Voltage Divider
I have created a voltage divider circuit with an LDR(a resistor symbol with arrows, kind of indicating light shining on it) in Falstad . You can import the circuit file I created, voltage-divider-ldr.circuitjs.txt, import into the Falstad site and play around.
You can adjust the brightness value and observe how the resistance of R2 (which is the LDR) changes. Also, you can watch how the \( V_{out} \) voltage changes as you increase or decrease the brightness.
Example output for full brightness
The resistance of the LDR is low when exposed to full brightness, causing the output voltage(\( V_{out} \)) to be significantly lower.
Example output for low light
With less light, the resistance of the LDR increases and the output voltage increase.
Example output for full darkness
In darkness, the LDR’s resistance is high, resulting in a higher output voltage (\( V_{out} \)).
LDR and LED
Turn on LED(or Lamp) in low Light with Pico
In this exercise, we’ll control an LED based on ambient light levels. The goal is to automatically turn on the LED in low light conditions.
You can try this in a closed room by turning the room light on and off. When you turn off the room-light, the LED should turn on, given that the room is dark enough, and turn off again when the room-light is switched back on. Alternatively, you can adjust the sensitivity threshold or cover the light sensor (LDR) with your hand or some object to simulate different light levels.
Note: You may need to adjust the ADC threshold based on your room’s lighting conditions and the specific LDR you are using.
Setup
Hardware Requirements
- LED – Any standard LED (choose your preferred color).
- LDR (Light Dependent Resistor) – Used to detect light intensity.
- Resistors
- 330Ω – For the LED to limit current and prevent damage. (You might have to choose based on your LED)
- 10kΩ – For the LDR, forming a voltage divider in the circuit. (You might have to choose based on your LDR)
- Jumper Wires – For connecting components on a breadboard or microcontroller.
Circuit to connect LED, LDR with Pico
- One side of the LDR is connected to AGND (Analog Ground).
- The other side of the LDR is connected to GPIO26 (ADC0), which is the analog input pin of the pico2
- A resistor is connected in series with the LDR to create a voltage divider between the LDR and ADC_VREF (the reference voltage for the ADC).
- From the datasheet: “ADC_VREF is the ADC power supply (and reference) voltage, and is generated on Pico 2 by filtering the 3.3V supply. This pin can be used with an external reference if better ADC performance is required”
Action
We’ll use the Embassy HAL for this exercise.
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 “dracula-ldr” and select embassy as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "dracula-ldr":
# cd dracula-ldr
Interrupt Handler
Let’s set up interrupt handling for the ADC.
#![allow(unused)]
fn main() {
use embassy_rp::adc::InterruptHandler;
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => InterruptHandler;
});
}
In simple terms, when the ADC completes a conversion and the result is ready, it triggers an interrupt. This tells the pico that the new data is available, so it can process the ADC value. The interrupt ensures that the pico doesn’t need to constantly check the ADC, allowing it to respond only when new data is ready.
Read more about RP2350 interreupts in the datasheet (82th page).
Initialize the Embassy HAL
#![allow(unused)]
fn main() {
let p = embassy_rp::init(Default::default());
}
Initialize the ADC
#![allow(unused)]
fn main() {
let mut adc = Adc::new(p.ADC, Irqs, Config::default());
}
Configuring the ADC Pin and LED
We set up the ADC input pin (PIN_26) for reading an analog signal. Then we set up an output pin (PIN_15) to control an LED. The LED starts in the low state (Level::Low), meaning it will be off initially.
#![allow(unused)]
fn main() {
let mut p26 = Channel::new_pin(p.PIN_26, Pull::None);
let mut led = Output::new(p.PIN_15, Level::Low);
}
Main loop
The logic is straightforward: read the ADC value, and if it’s greater than 3800, turn on the LED; otherwise, turn it off.
#![allow(unused)]
fn main() {
loop {
let level = adc.read(&mut p26).await.unwrap();
if level > 3800 {
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::adc::{Adc, Channel, Config, InterruptHandler};
use embassy_rp::bind_interrupts;
use embassy_rp::block::ImageDef;
use embassy_rp::gpio::{Level, Output, Pull};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};
#[link_section = ".start_block"]
#[used]
pub static IMAGE_DEF: ImageDef = ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => InterruptHandler;
});
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_rp::init(Default::default());
let mut adc = Adc::new(p.ADC, Irqs, Config::default());
let mut p26 = Channel::new_pin(p.PIN_26, Pull::None);
let mut led = Output::new(p.PIN_15, Level::Low);
loop {
let level = adc.read(&mut p26).await.unwrap();
if level > 3800 {
led.set_high();
} else {
led.set_low();
}
Timer::after_secs(1).await;
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the dracula-ldr folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/dracula-ldr/
ADC (Analog to Digital Converter)
An Analog-to-Digital Converter (ADC) is a device used to convert analog signals (continuous signals like sound, light, or temperature) into digital signals (discrete values, typically represented as 1s and 0s). This conversion is necessary for digital systems like microcontrollers (e.g., Raspberry Pi, Arduino) to interact with the real world. For example, sensors that measure temperature or sound produce analog signals, which need to be converted into digital format for processing by digital devices.
ADC Resolution
The resolution of an ADC refers to how precisely the ADC can measure an analog signal. It is expressed in bits, and the higher the resolution, the more precise the measurements.
- 8-bit ADC produces digital values between 0 and 255.
- 10-bit ADC produces digital values between 0 and 1023.
- 12-bit ADC produces digital values between 0 and 4095.
The resolution of the ADC can be expressed as the following formula: \[ \text{Resolution} = \frac{\text{Vref}}{2^{\text{bits}} - 1} \]
Pico
Based on the Pico datasheet, Raspberry Pi Pico has 12-bit 500ksps Analogue to Digital Converter (ADC). So, it provides values ranging from 0 to 4095 (4096 possible values)
\[ \text{Resolution} = \frac{3.3V}{2^{12} - 1} = \frac{3.3V}{4095} \approx 0.000805 \text{V} \approx 0.8 \text{mV} \]
Pins
The Raspberry Pi Pico has four accessible ADC channels on the following GPIOs:
| 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. |
In pico, ADC operates with a reference voltage set by the supply voltage, which can be measured on pin 35 (ADC_VREF).
ADC Value and LDR Resistance in a Voltage Divider
In a voltage divider with an LDR (Light-Dependent Resistor, core component of a light/brightness sensor) and a fixed resistor, the output voltage \( V_{\text{out}} \) is given by:
\[ V_{\text{out}} = V_{\text{in}} \times \frac{R_{\text{LDR}}}{R_{\text{LDR}} + R_{\text{fixed}}} \]
It is same formula as explained in the previous chapter, just replaced the \({R_2}\) with \({R_{\text{LDR}}}\) and \({R_1}\) with \({R_{\text{fixed}}}\)
- Bright light (low LDR resistance): \( V_{\text{out}} \) decreases, resulting in a lower ADC value.
- Dim light (high LDR resistance): \( V_{\text{out}} \) increases, leading to a higher ADC value.
Example ADC value calculation:
Bright light:
Let’s say the Resistence value of LDR is \(1k\Omega\) in the bright light (and we have \(10k\Omega\) fixed resistor).
\[ V_{\text{out}} = 3.3V \times \frac{1k\Omega}{1k\Omega + 10k\Omega} \approx 0.3V \]
The ADC value is calculated as: \[ \text{ADC value} = \left( \frac{V_{\text{out}}}{V_{\text{ref}}} \right) \times (2^{12} - 1) \approx \left( \frac{0.3}{3.3} \right) \times 4095 \approx 372 \]
Darkness:
Let’s say the Resistence value of LDR is \(140k\Omega \) in very low light.
\[ V_{\text{out}} = 3.3V \times \frac{140k\Omega}{140k\Omega + 10k\Omega} \approx 3.08V \]
The ADC value is calculated as: \[ \text{ADC value} = \left( \frac{V_{\text{out}}}{V_{\text{ref}}} \right) \times (2^{12} - 1) \approx \left( \frac{3.08}{3.3} \right) \times 4095 = 3822 \]
Converting ADC value back to voltage:
Now, if we want to convert the ADC value back to the input voltage, we can multiply the ADC value by the resolution (0.8mV).
For example, let’s take an ADC value of 3822:
\[ \text{Voltage} = 3822 \times 0.8mV = 3057.6mV \approx 3.06V \]
Reference
Thermistor
In this section, we’ll be using a thermistor with the Raspberry Pi Pico. A thermistor is a variable resistor that changes its resistance based on the temperature. The amount of change in resistance depends on its composition. The term comes from combining “thermal” and “resistor.”.
Thermistors are categorized into two types:
-
NTC (Negative Temperature Coefficient):
- Resistance decreases as temperature increases.
- They are primarily used for temperature sensing and inrush current limiting.
- We’ll be using the NTC thermistor to measure temperature in our exercise.

-
PTC (Positive Temperature Coefficient):
- Resistance increases as temperature rises.
- They primarily protect against overcurrent and overtemperature conditions as resettable fuses and are commonly used in air conditioners, medical devices, battery chargers, and welding equipment.
Reference
NTC and Voltage Divider
NTC and Voltage Divider
I have created a circuit on the Falstad website, and you can download the voltage-divider-thermistor.circuitjs.txt ile to import and experiment with. This setup is similar to what we covered in the voltage divider chapter of the LDR section. If you haven’t gone through that section, I highly recommend completing the theory there before continuing.
This circuit includes a 10kΩ thermistor with a resistance of 10kΩ at 25°C. The input voltage \( V_{in} \) is set to 3.3V.
Themistor 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 38°C
The thermistor’s resistance decreases due to its negative temperature coefficient, altering the voltage divider’s output.
Thermistor at 10°C
The thermistor’s resistance increases, resulting in a higher output voltage (\( V_{out} \)).

ADC
ADC
When setting up the thermistor with the Pico, we don’t get the voltage directly. Instead, we receive an ADC value (refer to the ADC explanation in the LDR section). In the LDR exercise, we didn’t calculate the resistance corresponding to the ADC value since we only needed to check whether the ADC value increased. However, in this exercise, to determine the temperature, we must convert the ADC value into resistence.
ADC to Resistance
We need resistance value from the adc value for the thermistor temperature calculation(that will be discussed in the next chapters).
We will use this formula to calculate the resistance value from the ADC reading. If you need how it is derived, refer the Deriving Resistance from ADC Value.
\[ R_2 = \frac{R_1}{\left( \frac{\text{ADC_MAX}}{\text{adc_value}} - 1 \right)} \]
Note: If you connected the thermistor to power supply instead of GND. You will need opposite. since thermistor becomes R1.
\[ R_1 = {R_2} \times \left(\frac{\text{ADC_MAX}}{\text{adc_value}} - 1\right) \]
Where:
- R2: The resistance based on the ADC value.
- R1: Reference resistor value (typically 10kΩ)
- ADC_MAX: The maximum ADC value is 4095 (\( 2^{12}\) -1 ) for a 12-bit ADC
- adc_value: ADC reading (a value between 0 and ADC_MAX).
Rust Function
const ADC_MAX: u16 = 4095;
const REF_RES: f64 = 10_000.0;
fn adc_to_resistance(adc_value: u16, ref_res:f64) -> f64 {
let x: f64 = (ADC_MAX as f64/adc_value as f64) - 1.0;
// ref_res * x // If you connected thermistor to power supply
ref_res / x
}
fn main() {
let adc_value = 2000; // Our example ADC value;
let r2 = adc_to_resistance(adc_value, REF_RES);
println!("Calculated Resistance (R2): {} Ω", r2);
}
Maths
Derivations
You can skip this section if you’d like. It simply explains the math behind deriving the resistance from the ADC value.
ADC to Voltage
The formula to convert an ADC value to voltage is:
\[ V_{\text{out}} = {{V_{in}}} \times \frac{\text{adc_value}}{\text{adc_max}} \]
Where:
- adc_value: The value read from the ADC.
- v_in: The reference input voltage (3.3V for the Pico).
- adc_max: The maximum ADC value is 4095 (\( 2^{12}\) -1 ) for a 12-bit ADC.
Deriving Resistance from ADC Value
We combine the voltage divider formula with ADC Resolution formula to find the Resistance(R2).
Note: It is assumed here that one side of the thermistor is connected to Ground (GND). I noticed that some online articles do the opposite, connecting one side of the thermistor to the power supply instead, which initially caused me some confusion.
Votlage Divider Formula \[ V_{out} = V_{in} \times \frac{R_2}{R_1 + R_2} \]
Step 1:
We can substitue the Vout and make derive it like this
\[ {V_{in}} \times \frac{\text{adc_value}}{\text{adc_max}} = V_{in} \times \frac{R_2}{R_1 + R_2} \]
\[ \require{cancel} \cancel{V_{in}} \times \frac{\text{adc_value}}{\text{adc_max}} = \cancel {V_{in}} \times \frac{R_2}{R_1 + R_2} \]
Step 2:
Lets temperoarily assign the adc_value/adc_max to x for ease of derivation and finally subsitue
\[ x = \frac{\text{adc_value}}{\text{adc_max}} \]
Substituting x into the equation:
\[ x = \frac{R_2}{R_1 + R_2} \]
Rearrange to Solve \( R_2 \)
\[ R_2 = x \times (R_1 + R_2) \]
Expand the right-hand side:
\[ R_2 = x \times R_1 + x \times R_2 \]
Rearrange to isolate \( R_2 \) terms:
\[ R_2 - x \times R_2 = R_1 \times x \]
\[ R_2 \times (1 - x) = R_1 \times x \]
\[ R_2 = R_1 \times \frac{{x}}{{1-x}} \]
\[ R_2 = R_1 \times \frac{1}{\left( \frac{1}{x} - 1 \right)} \]
Step 3
Let’s subsitute the x value back. We need 1/x, lets convert it. \[ \frac{1}{x} = \frac{\text{adc_max}}{\text{adc_value}} \]
Final Formula
\[ R_2 = R_1 \times \frac{1}{\left( \frac{\text{adc_max}}{\text{adc_value}} - 1 \right)} \]
Non-Linear
Non-Linear
Thermistors have a non-linear relationship between resistance and temperature, meaning that as the temperature changes, the resistance doesn’t change in a straight-line pattern. The behavior of thermistors can be described using the Steinhart-Hart equation or the B equation.
The B equation is simple to calculate using the B value, which you can easily find online. On the other hand, the Steinhart equation uses A, B, and C coefficients. Some manufacturers provide these coefficients, but you’ll still need to calibrate and find them yourself since the whole reason for using the Steinhart equation is to get accurate temperature readings.
In the next chapters, we will see in detail how to use B equation and Steinhart-Hart equation to determine the temperature.
Referemce
- The B parameter vs. Steinhart-Hart equation
- Characterising Thermistors – A Quick Primer, Beta Value & Steinhart-Hart Coefficients
B Equation
B Equation
The B equation is simpler but less precise. \[ \frac{1}{T} = \frac{1}{T_0} + \frac{1}{B} \ln \left( \frac{R}{R_0} \right) \]
Where:
- T is the temperature in Kelvin.
- \( T_0 \) is the reference temperature (usually 298.15K or 25°C), where the thermistor’s resistance is known (typically 10kΩ).
- R is the resistance at temperature T.
- \( R_0 \) is the resistance at the reference temperature \( T_0 \) (often 10kΩ).
- B is the B-value of the thermistor.
The B value is a constant usually provided by the manufacturers, changes based on the material of a thermistor. It describes the gradient of the resistive curve over a specific temperature range between two points(i.e \( T_0 \) vs \( R_0 \) and T vs R). You can even rewrite the above formula to get B value yourself by calibrating the resistance at two temperatures.
Example Calculation:
Given:
- Reference temperature \( T_0 = 298.15K \) (i.e., 25°C + 273.15 to convert to Kelvin)
- Reference resistance \( R_0 = 10k\Omega \)
- B-value B = 3950 (typical for many thermistors)
- Measured resistance at temperature T: 10475Ω
Step 1: Apply the B-parameter equation
Substitute the given values:
\[ \frac{1}{T} = \frac{1}{298.15} + \frac{1}{3950} \ln \left( \frac{10,475}{10,000} \right) \]
\[ \frac{1}{T} = 0.003354016 + \frac{1}{3950} \ln(1.0475) \]
\[ \frac{1}{T} = 0.003354016 + (0.000011748) \]
\[ \frac{1}{T} = 0.003365764 \]
Step 2: Calculate the temperature (T)
\[ T = \frac{1}{0.003365764} = 297.10936358 (Kelvin) \]
Convert to Celsius:
\[ T_{Celsius} = 297.10936358 - 273.15 \approx 23.95936358°C \]
Result:
The temperature corresponding to a resistance of 10475Ω is approximately 23.96°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 = 9546.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 Equation
Steinhart Hart equation
The Steinhart-Hart equation provides a more accurate temperature-resistance relationship over a wide temperature range. \[ \frac{1}{T} = A + B \ln R + C (\ln R)^3 \]
Where:
- T is the temperature in Kelvins. (Formula to calculate kelvin from degree Celsius, K = °C + 273.15)
- R is the resistance at temperature T in Ohms.
- A, B, and C are constants specific to the thermistor’s material, often provided by the manufacturer. For better accuracy, you may need to calibrate and determine these values yourself. Some datasheets provide resistance values at various temperatures, which can also be used to calculate this.
Calibration
To determine the accurate values for A, B, and C, place the thermistor in three temperature conditions: room temperature, ice water, and boiling water. For each condition, measure the thermistor’s resistance using the ADC value and use a reliable thermometer to record the actual temperature. Using the resistance values and corresponding temperatures, calculate the coefficients:
- Assign A to the ice water temperature,
- B to the room temperature, and
- C to the boiling water temperature.
Calculating Steinhart-Hart Coefficients
With three resistance and temperature data points, we can find the 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),
}
}
Referemce
- Thermistor Calculator
- Thermistor Steinhart-Hart Coefficients for Calculating Motor Temperature
- Calibrate Steinhart-Hart Coefficients for Thermistors
- Cooking Thermometer With Steinhart-Hart Correction
Temperature on OLED
In this section, we will measure the temperature in your room and display it on the OLED screen.
Hardware Requirments
- An OLED display: (0.96 Inch I2C/IIC 4-Pin, 128x64 resolution, SSD1306 chip)
- Jumper wires
- NTC 103 Thermistor: 10K OHM, 5mm epoxy coated disc
- 10kΩ Resistor: Used with the thermistor to form a voltage divider
Circuit to connect OLED, Thermistor with Raspberry Pi Pico
- One side of the Thermistor is connected to AGND (Analog Ground).
- The other side of the Thermistor is connected to GPIO26 (ADC0), which is the analog input pin of the pico2
- A resistor is connected in series with the Thermistor to create a voltage divider between the Thermistor and ADC_VREF (the reference voltage for the ADC).
Note:Here, one side of the thermistor is connected to ground, as shown. If you’ve connected it to the power supply instead, you’ll need to use the alternate formula mentioned earlier.
The Flow
- We read the ADC value
- Get resisance value from ADC value
- Calculate temperature using B parameter equation
- Display the ADC, Resistance, Temperature(in Celsius) in the OLED
Action
We’ll use the Embassy HAL for this exercise.
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 “thermistor” and select embassy as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "thermistor":
# cd thermistor
Additional Crates required
Update your Cargo.toml to add these additional crate along with the existing dependencies.
#![allow(unused)]
fn main() {
ssd1306 = "0.9.0"
heapless = "0.8.0"
libm = "0.2.11"
}
ssd1306: Driver for controlling SSD1306 OLED display.heapless: In ano_stdenvironment, Rust’s standardStringtype (which requires heap allocation) is unavailable. This provides stack-allocated, fixed-size data structures. We will be using to store dynamic text, such as ADC, resistance, and temperature values, for display on the OLED screenlibm: Provides essential mathematical functions for embedded environments. We need this to calculate natural logarithm.
Additional imports
#![allow(unused)]
fn main() {
use heapless::String;
use ssd1306::mode::DisplayConfig;
use ssd1306::prelude::DisplayRotation;
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306};
use embassy_rp::adc::{Adc, Channel};
use embassy_rp::peripherals::I2C1;
use embassy_rp::{adc, bind_interrupts, i2c};
use embassy_rp::gpio::Pull;
use core::fmt::Write;
}
Interrupt Handler
We have set up only the ADC interrupt handler for the LDR exercises so far. For this exercise, we also need to set up an interrupt handler for I2C to enable communication with the OLED display.
#![allow(unused)]
fn main() {
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
I2C1_IRQ => i2c::InterruptHandler<I2C1>;
});
}
ADC related functions
We can hardcode 4095 for the Pico, but here’s a simple function to calculate ADC_MAX based on ADC bits:
#![allow(unused)]
fn main() {
const fn calculate_adc_max(adc_bits: u8) -> u16 {
(1 << adc_bits) - 1
}
const ADC_BITS: u8 = 12; // 12-bit ADC in Pico
const ADC_MAX: u16 = calculate_adc_max(ADC_BITS); // 4095 for 12-bit ADC
}
Thermistor specific values
The thermistor I’m using has a 10kΩ resistance at 25°C and a B value of 3950.
#![allow(unused)]
fn main() {
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
}
Helper functions
#![allow(unused)]
fn main() {
// We have already covered about this formula in ADC chpater
fn adc_to_resistance(adc_value: u16, ref_res: f64) -> f64 {
let x: f64 = (ADC_MAX as f64 / adc_value as f64) - 1.0;
// ref_res * x // If you connected thermistor to power supply
ref_res / x
}
// 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
}
}
Base setups
First, we set up the Embassy HAL, configure the ADC on GPIO 26, and prepare the I2C interface for communication with the OLED display
#![allow(unused)]
fn main() {
let p = embassy_rp::init(Default::default());
// ADC to read the Vout value
let mut adc = Adc::new(p.ADC, Irqs, adc::Config::default());
let mut p26 = Channel::new_pin(p.PIN_26, Pull::None);
// Setting up I2C send text to OLED display
let sda = p.PIN_18;
let scl = p.PIN_19;
let i2c = i2c::I2c::new_async(p.I2C1, scl, sda, Irqs, i2c::Config::default());
let interface = I2CDisplayInterface::new(i2c);
}
Setting Up an SSD1306 OLED Display in Terminal Mode
Next, create a display instance, specifying the display size and orientation. And enable terminal mode.
#![allow(unused)]
fn main() {
let mut display =
Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0).into_terminal_mode();
display.init().unwrap();
}
Heapless String
This is a heapless string set up with a capacity of 64 characters. The string is allocated on the stack, allowing it to hold up to 64 characters. We use this variable to display the temperature, ADC, and resistance values on the screen.
#![allow(unused)]
fn main() {
let mut buff: String<64> = String::new();
}
Convert the Reference Temperature to Kelvin
We defined the reference temperature as 25°C for the thermistor. However, for the equation, we need the temperature in Kelvin. To handle this, we use a helper function to perform the conversion. Alternatively, you could directly hardcode the Kelvin value (298.15 K, which is 273.15 + 25°C) to skip using the function.
#![allow(unused)]
fn main() {
let ref_temp = celsius_to_kelvin(REF_TEMP);
}
Loop
In a loop that runs every 1 second(adjust as you require), we read the ADC value, calculate the resistance from ADC, then derive the temperature from resistance, and display the results on the OLED.
Read ADC
We read the ADC value; we also put into the buffer.
#![allow(unused)]
fn main() {
let adc_value = adc.read(&mut p26).await.unwrap();
writeln!(buff, "ADC: {}", adc_value).unwrap();
}
ADC To Resistance
We convert the ADC To resistance; we put this also into the buffer.
#![allow(unused)]
fn main() {
let current_res = adc_to_resistance(adc_value, REF_RES);
writeln!(buff, "R: {:.2}", current_res).unwrap();
}
Calculate Temperature from Resistance
We use the measured resistance to calculate the temperature in Kelvin using the B-parameter equation.Afterward, we convert the temperature from Kelvin to Celsius.
#![allow(unused)]
fn main() {
let temperature_kelvin = calculate_temperature(current_res, REF_RES, ref_temp, B_VALUE);
let temperature_celsius = kelvin_to_celsius(temperature_kelvin);
}
Write the Buffer to Display
#![allow(unused)]
fn main() {
writeln!(buff, "Temp: {:.2} °C", temperature_celsius).unwrap();
display.write_str(&buff).unwrap();
Timer::after_secs(1).await;
}
Clear the Buffer and Screen
#![allow(unused)]
fn main() {
buff.clear();
display.clear().unwrap();
}
Final code
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_rp as hal;
use embassy_rp::block::ImageDef;
use embassy_rp::gpio::Pull;
use embassy_time::Timer;
use heapless::String;
use ssd1306::mode::DisplayConfig;
use ssd1306::prelude::DisplayRotation;
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306};
use {defmt_rtt as _, panic_probe as _};
use embassy_rp::adc::{Adc, Channel};
use embassy_rp::peripherals::I2C1;
use embassy_rp::{adc, bind_interrupts, i2c};
use core::fmt::Write;
/// Tell the Boot ROM about our application
#[link_section = ".start_block"]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
bind_interrupts!(struct Irqs {
ADC_IRQ_FIFO => adc::InterruptHandler;
I2C1_IRQ => i2c::InterruptHandler<I2C1>;
});
const fn calculate_adc_max(adc_bits: u8) -> u16 {
(1 << adc_bits) - 1
}
const ADC_BITS: u8 = 12; // 12-bit ADC in Pico
const ADC_MAX: u16 = calculate_adc_max(ADC_BITS); // 4095 for 12-bit ADC
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, ref_res: f64) -> f64 {
let x: f64 = (ADC_MAX as f64 / adc_value as f64) - 1.0;
// ref_res * x // If you connected thermistor to power supply
ref_res / x
}
// 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());
// ADC to read the Vout value
let mut adc = Adc::new(p.ADC, Irqs, adc::Config::default());
let mut p26 = Channel::new_pin(p.PIN_26, Pull::None);
// Setting up I2C send text to OLED display
let sda = p.PIN_18;
let scl = p.PIN_19;
let i2c = i2c::I2c::new_async(p.I2C1, scl, sda, Irqs, i2c::Config::default());
let interface = I2CDisplayInterface::new(i2c);
let mut display =
Ssd1306::new(interface, DisplaySize128x64, DisplayRotation::Rotate0).into_terminal_mode();
display.init().unwrap();
let mut buff: String<64> = String::new();
let ref_temp = celsius_to_kelvin(REF_TEMP);
loop {
buff.clear();
display.clear().unwrap();
let adc_value = adc.read(&mut p26).await.unwrap();
writeln!(buff, "ADC: {}", adc_value).unwrap();
let current_res = adc_to_resistance(adc_value, REF_RES);
writeln!(buff, "R: {:.2}", current_res).unwrap();
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).unwrap();
display.write_str(&buff).unwrap();
Timer::after_secs(1).await;
}
}
// Program metadata for `picotool info`.
// This isn't needed, but it's recomended to have these minimal entries.
#[link_section = ".bi_entries"]
#[used]
pub static PICOTOOL_ENTRIES: [embassy_rp::binary_info::EntryAddr; 4] = [
embassy_rp::binary_info::rp_program_name!(c"Blinky Example"),
embassy_rp::binary_info::rp_program_description!(
c"This example tests the RP Pico on board LED, connected to gpio 25"
),
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 thermistor folder.
git clone https://github.com/ImplFerris/pico2-embassy-projects
cd pico2-embassy-projects/thermistor/
USB Serial Communication
In this section, we’ll explore how to establish communication between our device (Pico) and a computer(Linux). We’ll demonstrate how to send a simple string from the device(Pico) to the computer, as well as how to send input from the computer to the device.
CDC ACM
The Communication Device Class (CDC) is a standard USB device class defined by the USB Implementers Forum (USB-IF). The Abstract Control Model (ACM) in CDC allows a device to act like a traditional serial port (like old COM ports). It’s commonly used for applications that previously relied on serial COM or UART communication.
Tools for Linux
When you flash the code in this exercise, the device will appear as /dev/ttyACM0 in 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 use 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
Pico to PC
The example provided in the RP-HAL repository sends a simple “Hello, World!” message from the Pico to the computer once the timer ticks reach 2,000,000. To ensure the message is only sent once, we add a check that sends it only on the first occurrence. Also, it polls for any incoming data to the device (Pico). If data is received, it converts it to uppercase and send it back(This is just show communication is working, not just echoing).
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, run:
cargo generate --git https://github.com/ImplFerris/pico2-template.git --tag v0.1.0
When prompted, give your project a name, like “usb-fun” and select RP-HAL as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "usb-fun":
# cd usb-fun
Additional Crates required
Update your Cargo.toml to add these additional crate 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;
}
Set up the USB driver
#![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,
));
}
Set up the USB Communications Class Device driver
#![allow(unused)]
fn main() {
let mut serial = SerialPort::new(&usb_bus);
}
Create a USB device with a fake VID and PID
#![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();
}
Sending Message to PC
This part sends “Hello, Rust!” to the PC when the timer count exceeds 2,000,000 by writing the text to the serial port. We ensure the message 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
Here is the fun part. When you type characters on your computer, they are sent to the Pico via USB serial. On the Pico, we check if the received character matches the letter ‘r’. If it matches, the onboard LED turns on. For any other character, the LED turns 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().unwrap();
} else {
led.set_low().unwrap();
}
}
}
}
}
The Full code
#![no_std]
#![no_main]
use embedded_hal::digital::OutputPin;
use hal::block::ImageDef;
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 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("TEST")])
.unwrap()
.device_class(2) // 2 for the CDC, from: https://www.usb.org/defined-class-codes
.build();
let mut said_hello = false;
loop {
// Send data to the PC
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");
}
// Read data from PC
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().unwrap();
} else {
led.set_low().unwrap();
}
}
}
}
}
}
#[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"USB Fun"),
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 usb-fun folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/usb-fun/
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/
RFID
In this section, we will use the RFID Card Reader (RC522) module to read data from RFID tags and key fob tags.
What is RFID?
You’ve probably used them without even realizing it; on your apartment key, at the office, in parking lots, or with a contactless credit card. If you’ve got a toll pass in your car or used a hotel keycard, then yep, you’ve already seen them in action.
RFID (Radio Frequency Identification) is a technology that uses radio waves to identify and track objects, animals. It wirelessly transmits the stored data from a tag (containing a chip and antenna) to a reader when in range.
Categories By Range
RFID systems can be categorized by their operating frequency. The three main types are:
-
Low Frequency (LF): Operates at ~125 kHz with a short range (up to 10cm). It’s slower and commonly used in access control and livestock tracking.
-
High Frequency (HF): Operates at 13.56 MHz with a range of 10cm to 1m. It offers moderate speed and is widely used in access control systems, such as office spaces, apartments, hotel keycards, as well as in ticketing, payments, and data transfer. We are going to use this one (RC522 module which operates at 13.56MHz)
-
Ultra-High Frequency (UHF): Operates at 860–960 MHz with a range of up to 12m. It’s faster and commonly used in retail inventory management, anti-counterfeiting, and logistics.
Categories By Power source
RFID tags can either be active or passive, depending on how they are powered.
- Active tags: They have their own battery and can send signals on their own. These are typically used on large objects like rail cars, big reusable containers, and assets that need to be tracked over long distances.
- Passive tags: Unlike active tags, passive tags don’t have a battery. They rely on the electromagnetic fields emitted by the RFID reader to power up. Once energized, they transmit data using radio waves. These are the most common type of RFID tags and are likely the ones you’ve encountered in everyday life. If you guessed it correctly, yes the RC522 is the passive tags.
Components:
RFID systems consist of an RFID Reader, technically referred to as the PCD (Proximity Coupling Device). In passive RFID tags, the reader powers the tag using an electromagnetic field. The tags themselves are called RFID Tags or, in technical terms, PICCs (Proximity Integrated Circuit Cards). It is good to know its technical terms also, it will come in handy if you want to refer the datasheet and other documents.
Reader typically include memory components like FIFO buffers and EEPROM. They also incorporate cryptographic features to ensure secure communication with Tags, allowing only authenticated RFID readers to interact with them. For example, RFID readers from NXP Semiconductors use the Crypto-1 cipher for authentication.
Each RFID tag has a hardcoded UID (Unique Identifier), which can be 4, 7, or 10 bytes in size.
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
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.
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
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 the tag near the reader, it goes into a state where it waits for either a REQA (Request) or WUPA (Wake Up) command.
To check if any tag is nearby, we send the REQA command in a loop. If the tag is nearby, it responds with an ATQA (Answer to Request).
Once we get the response, we select the card, and it sends back its UID (we won’t dive into the full technical details involved in this process). After that, we authenticate the sector we want to read or write from. Once we’re done with our operation, we send a HLTA command to put the card in the HALT state.
Note: Once the card is in the HALT state, only the WUPA command (reminds me of Chandler from Friends saying “WOOPAAH”) can wake it up and let us do more operations.
Circuit
Circuit
The introduction has become quite lengthy, so we will move the circuit diagram for connecting the Pico to the RFID reader to a separate page. Additionally, there are more pins that involved in this than any of the previous components we’ve used so far.
Pinout diagram of RC522
There are 8 pins in the RC522 RFID module.

| 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 | Slave Select (SS) | SDA | RX | In SPI mode, it acts as the Slave select (SS, also referred as Chip 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 (Serial Peripheral Interface) protocol. The SPI interface can handle data speed up to 10 Mbit/s. We wont be utilizing the following Pins: RST, IRQ at the moment.
| Pico Pin | Wire | RFID Reader Pin |
|---|---|---|
| 3.3V |
|
3.3V |
| GND |
|
GND |
| GPIO 4 |
|
MISO |
| GPIO 5 |
|
SDA |
| GPIO 6 |
|
SCK |
| GPIO 7 |
|
MOSI |
Read UID
Alright, let’s get to the fun part and dive into some action! We’ll start by writing a simple program to read the UID of the RFID tag.
mfrc522 Driver
We will be using the awesome crate “mfrc522”. It is still under development. However, it has everything what we need for purposes.
USB Serial
To display the tag data, we’ll use USB serial, which we covered in the last chapter. This will allow us to read from the RFID tag and display the UID on the computer.
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 “rfid-uid” and select RP-HAL as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "rfid-uid":
# cd rfid-uid
Additional Crates required
Update your Cargo.toml to add these additional crate along with the existing dependencies.
#![allow(unused)]
fn main() {
usbd-serial = "0.2.2"
usb-device = "0.3.2"
heapless = "0.8.0"
mfrc522 = "0.8.0"
embedded-hal-bus = "0.2.0"
}
We have added embedded-hal-bus, which provides the necessary traits for SPI and I2C buses. This is required for interfacing the Pico with the RFID reader.
Additional imports
#![allow(unused)]
fn main() {
use hal::fugit::RateExtU32;
use core::fmt::Write;
// to prepare buffer with data before writing into USB serial
use heapless::String;
// for setting up USB Serial
use usb_device::{class_prelude::*, prelude::*};
use usbd_serial::SerialPort;
// Driver for the MFRC522
use mfrc522::{comm::blocking::spi::SpiInterface, Mfrc522};
use embedded_hal_bus::spi::ExclusiveDevice;
}
Make sure to check out the USB serial tutorial for setting up the USB serial. We won’t go over the setup here to keep it simple.
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<B: UsbBus>(data: &[u8], serial: &mut SerialPort<B>) {
let mut buff: String<64> = String::new();
for &d in data.iter() {
write!(buff, "{:02x} ", d).unwrap();
}
serial.write(buff.as_bytes()).unwrap();
}
}
Setting Up the SPI for the RFID Reader
Now, let’s configure the SPI bus and the necessary pins to communicate with the RFID reader.
#![allow(unused)]
fn main() {
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));
let spi_cs = pins.gpio5.into_push_pull_output();
let spi = spi_bus.init(
&mut pac.RESETS,
clocks.peripheral_clock.freq(),
1_000.kHz(),
embedded_hal::spi::MODE_0,
);
}
Getting the SpiDevice from SPI Bus
To work with the mfrc522 crate, we need an SpiDevice. Since we only have the SPI bus from RP-HAL, we’ll use the embedded_hal_bus crate to get the SpiDevice from the SPI bus.
#![allow(unused)]
fn main() {
let spi = ExclusiveDevice::new(spi, spi_cs, timer).unwrap();
}
Initialize the mfrc522
#![allow(unused)]
fn main() {
let itf = SpiInterface::new(spi);
let mut rfid = Mfrc522::new(itf).init().unwrap();
}
Read the UID and Print
The main logic for reading the UID is simple. We continuously send the REQA command. If a tag is present, it send us the ATQA response. We then use this response to select the tag and retrieve the UID.
Once we have the UID, we use our helper function to print the UID bytes in hex format via USB serial.
#![allow(unused)]
fn main() {
loop {
// to estabilish USB serial
let _ = usb_dev.poll(&mut [&mut serial]);
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
serial.write("\r\nUID: \r\n".as_bytes()).unwrap();
print_hex_to_serial(uid.as_bytes(), &mut serial);
timer.delay_ms(500);
}
}
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the rfid-uid folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/rfid-uid/
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.
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.
LED on UID Match
Turn on LED on UID Match
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.
Logic
It is very simple straightforward logic.
#![allow(unused)]
fn main() {
let mut led = pins.gpio25.into_push_pull_output();
// Replace the UID Bytes with your tag UID
const TAG_UID: [u8; 4] = [0x13, 0x37, 0x73, 0x31];
loop {
led.set_low().unwrap();
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
if *uid.as_bytes() == TAG_UID {
led.set_high().unwrap();
timer.delay_ms(500);
}
}
}
}
}
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-rp-projects
cd pico2-projects/rfid-led/
Light it Up
Lets flash the pico with our program.
cargo run
Now bring the RFID tag near the RFID reader, the onboard LED on the Pico should turn on. Next, try bringing the key fob closer to the reader, and the LED will turn off. Alternatively, you can first read the key fob UID and hardcode it into the program to see the opposite behavior.
Read the data
In this section, we’ll read all the blocks from the first sector (sector 0). As we mentioned earlier, to read or write to a specific block on the RFID tag, we first need to authenticate with the corresponding sector.
Authentication
Most tags come with a default key, typically 0xFF repeated six times. You may need to check the documentation to find the default key or try other common keys. For the RFID reader we are using, the default key is 0xFF repeated six times.
For authentication, we need:
- The tag’s UID (obtained using the REQA and Select commands).
- The block number within the sector.
- The key (hardcoded in this case).
Read the block
After successful authentication, we can read data from each block using the mf_read function from the mfrc522 crate. If the read operation succeeds, the function returns 16 bytes of data from the block. This data will then be converted into a hex string and sent to the USB serial output.
The first sector (sector 0) consists of 4 blocks, with absolute block numbers ranging from 0 to 3. For higher sectors, the absolute block numbers increase accordingly (e.g., for sector 1, the blocks are 4, 5, 6, 7).
#![allow(unused)]
fn main() {
fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>, B: UsbBus>(
uid: &mfrc522::Uid,
sector: u8,
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
serial: &mut SerialPort<B>,
) -> Result<(), &'static str> {
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_to_serial(&data, serial);
serial
.write("\r\n".as_bytes())
.map_err(|_| "Write failed")?;
}
Ok(())
}
}
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 {
let _ = usb_dev.poll(&mut [&mut serial]);
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
if let Err(e) = read_sector(&uid, 0, &mut rfid, &mut serial) {
serial.write(e.as_bytes()).unwrap();
}
rfid.hlta().unwrap();
rfid.stop_crypto1().unwrap();
}
}
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the rfid-read folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/rfid-read/
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.
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.
Reading the UID
Bring the RFID tag close to the reader, and the USB serial terminal will display the data bytes read from the blocks of the first sector (sector 0).
Dump Memory
Dump Entire Memory
You’ve learned how to read the data from each block of the first sector(sector 0) by authenticating into it. Now, we will loop through each sector. Re-Authentication is required every time we move to a new sector. For each sector, we will display the 16-byte data from every 4 blocks.
To make it clearer, we’ll add some formatting and labels, indicating which sector and block we’re referring to (both absolute and relative block numbers to the sector), as well as whether the block is a sector trailer or a data block.
Loop through the sector
We will create a separate function to loop through all 16 sectors (sectors 0 to 15), read all the blocks within each sector, and print their data.
#![allow(unused)]
fn main() {
fn dump_memory<E, COMM: mfrc522::comm::Interface<Error = E>, B: UsbBus>(
uid: &mfrc522::Uid,
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
serial: &mut SerialPort<B>,
) -> Result<(), &'static str> {
let mut buff: String<64> = String::new();
for sector in 0..16 {
// Printing the Sector number
write!(buff, "\r\n-----------SECTOR {}-----------\r\n", sector).unwrap();
serial.write(buff.as_bytes()).unwrap();
buff.clear();
read_sector(uid, sector, rfid, serial)?;
}
Ok(())
}
}
Labels
The read_sector function follows the same logic as before, but with added formatting and labels. It now prints the absolute block number, the block number relative to the sector, and labels for the manufacturer data (MFD) block and sector trailer blocks.
#![allow(unused)]
fn main() {
fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>, B: UsbBus>(
uid: &mfrc522::Uid,
sector: u8,
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
serial: &mut SerialPort<B>,
) -> Result<(), &'static str> {
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")?;
// Prining the Block absolute and relative numbers
write!(buff, "\r\nBLOCK {} (REL: {}) | ", abs_block, rel_block).unwrap();
serial.write(buff.as_bytes()).unwrap();
buff.clear();
// Printing the block data
print_hex_to_serial(&data, serial);
// Printing block type
let block_type = get_block_type(sector, rel_block);
write!(buff, "| {} ", block_type).unwrap();
serial.write(buff.as_bytes()).unwrap();
buff.clear();
}
serial
.write("\r\n".as_bytes())
.map_err(|_| "Write failed")?;
Ok(())
}
}
We will create a small helper function to determine the block type based on the sector and its relative block number.
#![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",
}
}
}
The main loop
There isn’t much change in the main loop. We just call the dump_memory function instead of read_sector.
#![allow(unused)]
fn main() {
loop {
let _ = usb_dev.poll(&mut [&mut serial]);
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
if let Err(e) = dump_memory(&uid, &mut rfid, &mut serial) {
serial.write(e.as_bytes()).unwrap();
}
rfid.hlta().unwrap();
rfid.stop_crypto1().unwrap();
}
}
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the rfid-dump folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/rfid-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
We will write data into block 2 of sector 4. First, we will print the data in the block before writing to it, and then again after writing. To perform the write operation, we will use the mf_write function from the mfrc522 crate.
Accidentally writing to the wrong block and overwriting the trailer block may alter the authentication key or access bits, which could make the sector unusable.
Write function
We will use this function to write data to the block. The mf_write function requires the absolute block number, which we will calculate using the sector number and its relative block number.
#![allow(unused)]
fn main() {
fn write_block<E, COMM: mfrc522::comm::Interface<Error = E>>(
uid: &mfrc522::Uid,
sector: u8,
rel_block: u8,
data: [u8; 16],
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str> {
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(())
}
}
The main loop
The main loop begins by reading and printing the current content of a specified block before writing new data to it. The write_block function is used to write the constant DATA, which must fill the entire 16-byte block. Any unused bytes are padded with null bytes (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
];
loop {
let _ = usb_dev.poll(&mut [&mut serial]);
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
serial
.write("\r\n----Before Write----\r\n".as_bytes())
.unwrap();
if let Err(e) = read_sector(&uid, target_sector, &mut rfid, &mut serial) {
serial.write(e.as_bytes()).unwrap();
}
if let Err(e) = write_block(&uid, target_sector, rel_block, DATA, &mut rfid) {
serial.write(e.as_bytes()).unwrap();
}
serial
.write("\r\n----After Write----\r\n".as_bytes())
.unwrap();
if let Err(e) = read_sector(&uid, target_sector, &mut rfid, &mut serial) {
serial.write(e.as_bytes()).unwrap();
}
rfid.hlta().unwrap();
rfid.stop_crypto1().unwrap();
}
}
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the rfid-write folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/rfid-write/
Output
When you run the program, the output will display the hex representation of “implRust” visible in the third row.

Change Auth Key
Changing the Authentication Key
Let’s change the authentication key (KeyA) for sector 1. By default, it is set to FF FF FF FF FF FF. We’ll update it to 52 75 73 74 65 64 which is hex for “Rusted.” To do this, we need to modify the trailer block (block 3) of sector 1 while leaving the rest of the sector untouched.
Before proceeding, it is a good idea to verify the current contents of this block. Run the Dump Memory or Read Data program to check.
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.
We’ll also modify the KeyB contents to verify that the write was successful. We’ll set KeyB to the hex bytes of “Ferris” (46 65 72 72 69 73).
Before writing, the access bytes and KeyB values in your block should mostly match what I have, but double-checking is always better than guessing.
Here’s the plan:
- In the program, we hardcode the default key (
FF FF FF FF FF FF) into a variable namedcurrent_key. - Set the
new_keytoRusted(in hex bytes). This is necessary to print the block content after writing; otherwise, we’ll get an auth error. - The program will print the block’s contents both before and after writing.
Once the key is updated, bring the tag nearby again. You will likely see an “Auth failed” error. If you’re wondering why, congrats-you figured it out! The new key was successfully written, so the hardcoded current_key no longer works. To verify, modify the read-data program to use the new key (Rusted) and try again.
Key and Data
The DATA array contains the new KeyA (“Rusted” in hex), access bits, and KeyB (“Ferris” in hex). The current_key is set to the default FF FF FF FF FF FF, and new_key is the first 6 bytes of DATA, which is “Rusted”.
#![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; 6];
let new_key: &[u8; 6] = &DATA[..6].try_into().unwrap();
}
Write Block function
We have slighly modified the write_block function to accept key as argument.
#![allow(unused)]
fn main() {
fn write_block<E, COMM: mfrc522::comm::Interface<Error = E>>(
uid: &mfrc522::Uid,
sector: u8,
rel_block: u8,
data: [u8; 16],
key: &[u8; 6],
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
) -> Result<(), &'static str> {
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(())
}
}
Read Sector function
We have done similar modification for the read_sector function also.
#![allow(unused)]
fn main() {
fn read_sector<E, COMM: mfrc522::comm::Interface<Error = E>, B: UsbBus>(
uid: &mfrc522::Uid,
sector: u8,
key: &[u8; 6],
rfid: &mut Mfrc522<COMM, mfrc522::Initialized>,
serial: &mut SerialPort<B>,
) -> Result<(), &'static str> {
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_to_serial(&data, serial);
serial
.write("\r\n".as_bytes())
.map_err(|_| "Write failed")?;
}
Ok(())
}
}
The main loop
There’s nothing new in the main loop. All the read and write functions are ones you’ve already seen. We’re just printing the sector content before and after changing the key.
#![allow(unused)]
fn main() {
loop {
let _ = usb_dev.poll(&mut [&mut serial]);
if let Ok(atqa) = rfid.reqa() {
if let Ok(uid) = rfid.select(&atqa) {
serial
.write("\r\n----Before Write----\r\n".as_bytes())
.unwrap();
if let Err(e) = read_sector(&uid, target_sector, current_key, &mut rfid, &mut serial) {
serial.write(e.as_bytes()).unwrap();
}
if let Err(e) =
write_block(&uid, target_sector, rel_block, DATA, current_key, &mut rfid)
{
serial.write(e.as_bytes()).unwrap();
}
serial
.write("\r\n----After Write----\r\n".as_bytes())
.unwrap();
if let Err(e) = read_sector(&uid, target_sector, new_key, &mut rfid, &mut serial) {
serial.write(e.as_bytes()).unwrap();
}
rfid.hlta().unwrap();
rfid.stop_crypto1().unwrap();
}
}
}
}
Clone the existing project
You can clone (or refer) project I created and navigate to the rfid-change-key folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/rfid-change-key/
Output
As you can see in the output, when you run the program, it will display the contents of the target block before and after writing. After we change the key, bringing the tag back to the reader will result in an “auth failed” message because the current_key has been changed; The new key is 52 75 73 74 65 64 (Rusted).
You can also modify the read data program we used earlier with the new key to verify it.
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.
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.
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.
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)
In this section, we will explore how to use the SD Card reader module. Depending on your project, you can use the SD card to store collected data from sensors, save game ROMs and progress, or store other types of information.
MMC
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. MMCs store data as electrical charges in flash memory cells, unlike optical disks, which rely on laser-encoded data on reflective surfaces.
SD (Secure Digital) Card
The Secure Digital Card (SDC), commonly referred to as an SD Card, is an evolution of the MMC. SD Cards are widely used as external storage in electronic devices such as cameras, smartphones. A smaller variant, the microSD card, is commonly used in smartphones, drones, and other devices.
Image credit: Based on SD card by Tkgd2007, licensed under the GFDL and CC BY-SA 3.0, 2.5, 2.0, 1.0.
SD cards read and write data in blocks, typically 512 bytes in size, allowing them to function as block devices; this makes SD cards behave much like hard drives.
Protocol
To communicate with an SD card, we can use the SD Bus protocol, SPI protocol, or UHS-II Bus protocol. The Raspberry Pi (but not the Raspberry Pi Pico) uses the SD Bus protocol, which is more complex than SPI. The full specs of the SD Bus protocol are not accessible to the public and are only available through the SD Association. We will be using the SPI protocol, as the Rust driver we will be using is designed to work with it.
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’s hardware, you might need a separate SD card adapter (not the one mentioned above) to format the microSD card. Some laptops comes with direct microSD card support.
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
- Wikipedia
Circuit
Circuit
microSD Card Pin Mapping for SPI Mode
We’ll focus only on the microSD card since that’s what we’re using. The microSD has 8 pins, but we only need 6 for SPI mode. You may have noticed that the SD card reader module we have also has only 6 pins, with markings for the SPI functions. The table below shows the microSD card pins and their corresponding SPI functions.
| 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 microSD card operates at 3.3V, so using 5V to power it could damage the card. However, the reader module comes with an onboard voltage regulator and logic shifter, allowing it to safely be connected to the 5V power supply of the Pico.
| Pico Pin | Wire | SD Card Pin |
|---|---|---|
| GPIO 1 |
|
CS |
| GPIO 2 |
|
SCK |
| GPIO 3 |
|
MOSI |
| GPIO 4 |
|
MISO |
| 5V |
|
VCC |
| GND |
|
GND |
Read SD Card with Raspberry Pi Pico
Let’s create a simple program that reads a file from the SD card and outputs its content over USB serial. Make sure the SD card is formatted with FAT32 and contains a file to read (for example, “RUST.TXT” with the content “Ferris”).
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 “read-sdcard” and select RP-HAL as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "read-sdcard":
# cd read-sdcard
Additional Crates required
Update your Cargo.toml to add these additional crate along with the existing dependencies.
#![allow(unused)]
fn main() {
// USB serial communication
usbd-serial = "0.2.2"
usb-device = "0.3.2"
heapless = "0.8.0"
// To convert Spi bus to SpiDevice
embedded-hal-bus = "0.2.0"
// sd card driver
embedded-sdmmc = "0.8.1"
}
Except for the embedded-sdmmc crate, we have already used all these crates in previous exercises.
- The usbd-serial and usb-device crates are used for sending or receiving data to and from a computer via USB serial. The heapless crate acts as a helper, providing a buffer before printing data to USB serial.
- The embedded-hal-bus crate offers the necessary traits for SPI and I²C buses, which are essential for interfacing the Pico with the SD card reader.
- The embedded-sdmmc crate is a driver for reading and writing files on FAT-formatted SD cards.
Additional imports
#![allow(unused)]
fn main() {
use usb_device::{class_prelude::*, prelude::*};
use usbd_serial::SerialPort;
use hal::fugit::RateExtU32;
use heapless::String;
use core::fmt::Write;
use embedded_hal_bus::spi::ExclusiveDevice;
use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeIdx, VolumeManager};
}
Make sure to check out the USB serial tutorial for setting up the USB serial. We won’t go over the setup here to keep it simple.
Dummy Timesource
The TimeSource is needed to retrieve timestamps and manage file metadata. Since we won’t be using this functionality, we’ll create a DummyTimeSource that implements the TimeSource trait. This is necessary for compatibility with the embedded-sdmmc crate.
#![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 the SPI for the SD Card Reader
Now, let’s configure the SPI bus and the necessary pins to communicate with the SD Card reader.
#![allow(unused)]
fn main() {
let spi_cs = pins.gpio1.into_push_pull_output();
let spi_sck = pins.gpio2.into_function::<hal::gpio::FunctionSpi>();
let spi_mosi = pins.gpio3.into_function::<hal::gpio::FunctionSpi>();
let spi_miso = pins.gpio4.into_function::<hal::gpio::FunctionSpi>();
let spi_bus = hal::spi::Spi::<_, _, _, 8>::new(pac.SPI0, (spi_mosi, spi_miso, spi_sck));
let spi = spi_bus.init(
&mut pac.RESETS,
clocks.peripheral_clock.freq(),
400.kHz(), // card initialization happens at low baud rate
embedded_hal::spi::MODE_0,
);
}
Getting the SpiDevice from SPI Bus
To work with the embedded-sdmmc crate, we need an SpiDevice. Since we only have the SPI bus from RP-HAL, we’ll use the embedded_hal_bus crate to get the SpiDevice from the SPI bus.
#![allow(unused)]
fn main() {
let spi = ExclusiveDevice::new(spi, spi_cs, timer).unwrap();
}
Setup SD Card driver
#![allow(unused)]
fn main() {
let sdcard = SdCard::new(spi, timer);
let mut volume_mgr = VolumeManager::new(sdcard, DummyTimesource::default());
}
Print the size of the SD Card
#![allow(unused)]
fn main() {
match volume_mgr.device().num_bytes() {
Ok(size) => {
write!(buff, "card size is {} bytes\r\n", size).unwrap();
serial.write(buff.as_bytes()).unwrap();
}
Err(e) => {
write!(buff, "Error: {:?}", e).unwrap();
serial.write(buff.as_bytes()).unwrap();
}
}
}
Open the directory
Let’s open the volume with the volume manager then open the root directory.
#![allow(unused)]
fn main() {
let Ok(mut volume0) = volume_mgr.open_volume(VolumeIdx(0)) else {
let _ = serial.write("err in open_volume".as_bytes());
continue;
};
let Ok(mut root_dir) = volume0.open_root_dir() else {
serial.write("err in open_root_dir".as_bytes()).unwrap();
continue;
};
}
Open the file in read-only mode
#![allow(unused)]
fn main() {
let Ok(mut my_file) = root_dir.open_file_in_dir("RUST.TXT", embedded_sdmmc::Mode::ReadOnly) else {
serial.write("err in open_file_in_dir".as_bytes()).unwrap();
continue;
};
}
Read the file content and print
#![allow(unused)]
fn main() {
while !my_file.is_eof() {
let mut buffer = [0u8; 32];
let num_read = my_file.read(&mut buffer).unwrap();
for b in &buffer[0..num_read] {
write!(buff, "{}", *b as char).unwrap();
}
}
serial.write(buff.as_bytes()).unwrap();
}
Full code
#![no_std]
#![no_main]
use embedded_hal::delay::DelayNs;
use hal::block::ImageDef;
use panic_halt as _;
use rp235x_hal::{self as hal, Clock};
use usb_device::{class_prelude::*, prelude::*};
use usbd_serial::SerialPort;
use hal::fugit::RateExtU32;
use heapless::String;
use core::fmt::Write;
use embedded_hal_bus::spi::ExclusiveDevice;
use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeIdx, VolumeManager};
#[link_section = ".start_block"]
#[used]
pub static IMAGE_DEF: ImageDef = hal::block::ImageDef::secure_exe();
const XTAL_FREQ_HZ: u32 = 12_000_000u32;
/// 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,
}
}
}
#[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 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 spi_cs = pins.gpio1.into_push_pull_output();
let spi_sck = pins.gpio2.into_function::<hal::gpio::FunctionSpi>();
let spi_mosi = pins.gpio3.into_function::<hal::gpio::FunctionSpi>();
let spi_miso = pins.gpio4.into_function::<hal::gpio::FunctionSpi>();
let spi_bus = hal::spi::Spi::<_, _, _, 8>::new(pac.SPI0, (spi_mosi, spi_miso, spi_sck));
let spi = spi_bus.init(
&mut pac.RESETS,
clocks.peripheral_clock.freq(),
400.kHz(), // card initialization happens at low baud rate
embedded_hal::spi::MODE_0,
);
let spi = ExclusiveDevice::new(spi, spi_cs, timer).unwrap();
let sdcard = SdCard::new(spi, timer);
let mut buff: String<64> = String::new();
let mut volume_mgr = VolumeManager::new(sdcard, DummyTimesource::default());
let mut is_read = false;
loop {
let _ = usb_dev.poll(&mut [&mut serial]);
if !is_read && timer.get_counter().ticks() >= 2_000_000 {
is_read = true;
serial
.write("Init SD card controller and retrieve card size...".as_bytes())
.unwrap();
match volume_mgr.device().num_bytes() {
Ok(size) => {
write!(buff, "card size is {} bytes\r\n", size).unwrap();
serial.write(buff.as_bytes()).unwrap();
}
Err(e) => {
write!(buff, "Error: {:?}", e).unwrap();
serial.write(buff.as_bytes()).unwrap();
}
}
buff.clear();
let Ok(mut volume0) = volume_mgr.open_volume(VolumeIdx(0)) else {
let _ = serial.write("err in open_volume".as_bytes());
continue;
};
let Ok(mut root_dir) = volume0.open_root_dir() else {
serial.write("err in open_root_dir".as_bytes()).unwrap();
continue;
};
let Ok(mut my_file) =
root_dir.open_file_in_dir("RUST.TXT", embedded_sdmmc::Mode::ReadOnly)
else {
serial.write("err in open_file_in_dir".as_bytes()).unwrap();
continue;
};
while !my_file.is_eof() {
let mut buffer = [0u8; 32];
let num_read = my_file.read(&mut buffer).unwrap();
for b in &buffer[0..num_read] {
write!(buff, "{}", *b as char).unwrap();
}
}
serial.write(buff.as_bytes()).unwrap();
}
buff.clear();
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"USB Fun"),
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 read-sdcard folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/read-sdcard/
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. It will then print the card size and the content of the file once the timer’s ticks reach 2,000,000.
LCD Display
In this section, we will be using Hitachi HD44780 compatible LCD (Liquid Crystal Display) displays. You might have seen them in devices like printers, digital clocks, microwaves, washing machines, air conditioners, and other home appliances. They’re also used in equipment like copiers, fax machines, and routers.
You can display ASCII character and up to 8 custom characters.
Variants
It comes in various variants, such as 16x2 (16 columns, 2 rows) and 20x4 (20 columns, 4 rows), and also based on backlight color (blue, yellow, or green). The one I have displays white characters with a blue backlight. However, you can choose any variant as it won’t significantly affect the code. Most of these variants will have 16 pins.
I2C variants
Some variants come with an I2C interface adapter, so you can use I2C for communication. The main advantage of I2C variant is that it reduces the number of pin connections. However, we’ll be working with the parallel interface instead.
You can also buy the I2C interface adapter separately and solder it later.
Hardware Requirements
- LCD Display (LCD1602): I would recommend getting a 16x2 so you can follow along.
- Potentiometer (Optional): This is used to control the LCD contrast. I didn’t have one at the moment, so I used resistors instead.
- Resistors (Optional): Two 1K resistors. If you have a potentiometer, you can ignore this. I used these to control the contrast.
- Jump Wires: A lot! We’ll need around 15+ jump wires since we’re using a parallel interface (not I2C), which requires connecting many pins.
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) uses liquid crystals to control light. When electricity is applied, the crystals change orientation, either allowing light to pass through or blocking it, creating images or text. A backlight illuminates the screen, and colored sub-pixels (red, green, and blue) combine to form various colors. The crystals can also turn opaque in specific areas, blocking the backlight and creating dark regions to display characters.
16x2 LCD Display and 5x8 Pixel Matrix
A 16x2 LCD has 2 rows and 16 columns, allowing it to display 32 characters in total. Each character is made up of a 5x8 pixel grid, where 5 columns and 8 rows of pixels form the shape of the character. This grid is used to display text and simple symbols on the screen.
Displaying Text and Custom Characters on 16x2 LCD
We don’t have to manually draw the pixels; This is taken care of by the HD44780 IC, which automatically maps ASCII characters to the 5x8 pixel grid.
However, if you’d like to create custom characters or symbols, you will need to define the 5x8 pixel pattern yourself. This pattern is saved in the LCD’s memory, and once it’s defined, you can use the custom character. Keep in mind, only up to 8 custom characters can be stored at a time.
Data transfer mode
The LCM (Liquid Crystal Module) supports two types of data transfer modes: 8-bit and 4-bit. In 8-bit mode, data is sent as a full byte using all the data pins. In 4-bit mode, only the higher-order data bits are used, sending data in nibbles. While 8-bit mode is faster, it comes with a trade-off;using too many wires, which can quickly exhaust the GPIO pins on a microcontroller. To minimize wiring, we’ll use 4-bit mode.
Reference:
Pin Layout
Pin Layout
The LCD has a total of 16 pins for the parallel interface.
| Pin Position | LCD Pin | Details |
|---|---|---|
| 1 | VSS | Should be connected to the Ground. |
| 2 | VSS | Power supply (5V) for the logic |
| 3 | VO |
Contrast adjustment: - If you use a potentiometer (10k), connect the middle pin to adjust the contrast. Other pins of the potentiometer should be connected to 5V (or 3.3V) and GND. - I used two 1k resistors instead, which was sufficient for this exercise. |
| 4 | RS |
Register select pin: - Set LOW (RS = 0) to send commands to the LCD. - Set HIGH (RS = 1) to send data to the LCD. |
| 5 | RW |
Read/Write pin: - Set LOW (RW = 0) to write to the LCD, which is what we will mostly do. - Set HIGH (RW = 1) to read from the LCD (rarely used). - We will connect this to Ground since we’re only writing. |
| 6 | E | The Enable pin is pulsed high and then brought back to low (ground) to trigger the LCD to accept and process data. |
| 7-10 | D0 - D3 | These are the 4 lower-order data bits, used only in 8-bit mode. If you are using 4-bit mode, leave these pins unconnected. |
| 11-14 | D4 - D7 | These are the 4 higher-order data bits, used to represent the data in 4-bit mode. |
| 15 | A | Anode of the backlight. Should be connected to 5V. |
| 16 | K | Cathode of the backlight. Should be connected to Ground. |
Contrast Adjustment
The Vo pin controls the contrast of the LCD.
According to the datasheet of the LCD1602A, the Vo pin controls the contrast of the LCD by adjusting the operating voltage for the LCD, which is the difference between the power supply for the logic (VDD) and the contrast control pin (Vo). When Vo is closer to ground, the voltage difference (VDD - Vo) is larger, resulting in a higher contrast, making the text on the screen more distinct and readable. When Vo is closer to VDD, the voltage difference decreases, resulting in a lower contrast, causing the text to appear faded or less visible.
You can use the potentiometer to adjust the contrast on the fly. You have to connect the middle pin of the potentiometer to Vo, and the other two pins to VCC and Ground.
You can also use resistors to adjust the contrast, which is what I did. You need to adjust the contrast one way or another. The first time I ran the program, I couldn’t see the text clearly. I placed two 1k resistors(when I added only one 1k resistor, it didn’t look that great) between Ground and the Vo, and then the text became visible.
Register Select Pin (RS)
The Register Select (RS) pin determines whether the LCD is in command mode or data mode.
When it is in Low(RS = 0), the LCD is in command mode, where the input is interpreted as a command, such as clearing the display or setting the cursor position (e.g., sending a command to clear the display).
When it is in High(RS = 1), the LCD is in data mode, where the input is interpreted as data to be displayed on the screen (e.g., sending text to display).
Enable Pin (E)
It is used to control when data is transferred to the LCD display. The enable pin is typically kept low (E=0) but is set high (E=1) for a specific period of time to initiate a data transfer, and then returned to low.. The data is latched into the LCD on the transition from high to low.
Circuit
Connecting LCD Display (LCD1602) to the Raspberry Pi Pico
We will be using parallel interface in 4bit mode. Remaining Pins like D0 to D3 won’t be connected.
| LCD Pin | Wire | Pico Pin | Notes |
|---|---|---|---|
| VSS |
|
GND | Ground |
| VDD |
|
VBUS (5V) | Power Supply |
| VO |
|
GND or Potentiometer | You can use Potentiometer or resistors to adjust the contrast. I placed two 1K resistors in between Ground and VO |
| RS |
|
GPIO 16 | Register Select (0 = command, 1 = data) |
| RW |
|
GND | Read/Write. Set '0' to write to display. If you want to read from display, set '1' |
| EN |
|
GPIO 17 | Enable |
| D4 |
|
GPIO 18 | Data Bit 4 |
| D5 |
|
GPIO 19 | Data Bit 5 |
| D6 |
|
GPIO 20 | Data Bit 6 |
| D7 |
|
GPIO 21 | Data Bit 7 |
| A |
|
3V3(OUT) | LED Backlight + |
| K |
|
GND | LED Backlight - |
“Hello, Rust!” in LCD Display
In this program, we will just print “Hello, Rust!” text in the LCD display.
HD44780 Drivers
During my research, I came across many Rust crates for controll the LCD Display, but these two stood out as working well. In this program, we will start by using the hd44780-driver crate.
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 “lcd-hello” and select RP-HAL as the HAL.
Then, navigate into the project folder:
cd PROJECT_NAME
# For example, if you named your project "lcd-hello":
# cd lcd-hello
Additional Crates required
Update your Cargo.toml to add these additional crate along with the existing dependencies.
#![allow(unused)]
fn main() {
hd44780-driver = "0.4.0"
}
Additional imports
#![allow(unused)]
fn main() {
use hd44780_driver::HD44780;
}
Mapping Pico and LCD Pins
We connect GPIO16 to the RS pin, GPIO17 to the Enable (E) pin, and GPIO18-21 to the D4-D7 data pins of the LCD. We’re using only 4 data pins since we will be working on 4-bit mode.
#![allow(unused)]
fn main() {
// Read Select Pin
let rs = pins.gpio16.into_push_pull_output();
// Enable Pin
let en = pins.gpio17.into_push_pull_output();
// Data Pins
let d4 = pins.gpio18.into_push_pull_output();
let d5 = pins.gpio19.into_push_pull_output();
let d6 = pins.gpio20.into_push_pull_output();
let d7 = pins.gpio21.into_push_pull_output();
}
Write Text to the LCD
Here, we initialize the LCD module, clear the screen, and then write the text “Hello, Rust!”.
#![allow(unused)]
fn main() {
// LCD Init
let mut lcd = HD44780::new_4bit(rs, en, d4, d5, d6, d7, &mut timer).unwrap();
// Clear the screen
lcd.reset(&mut timer).unwrap();
lcd.clear(&mut timer).unwrap();
// Write to the top line
lcd.write_str("Hello, Rust!", &mut timer).unwrap();
}
Clone the existing project
You can clone (or refer) project I created and navigate to the lcd-hello folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/lcd-hello/
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 Characters
Custom Characters
Besides the supported characters, you can create your own custom ones, like smileys or heart symbols. The module includes 64 bytes of Character Generator RAM (CGRAM), allowing up to 8 custom characters.
Each character is an 8x8 grid, where each row is represented by a single 8-bit value (u8). This makes it 8 bytes per character (8 rows × 1 byte per row). That’s why, with a total of 64 bytes, you can only store up to 8 custom characters (8 chars × 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
Let’s create Ferris (my attempt to make it look like a crab; if you have a better design, feel free to send a pull request) using a single character. In fact, we can combine 4 or 6 adjacent grids to display a single symbol. Creativity is up to you, and you can improve it however you like.
We’ll use the custom character generator from the previous page to create this symbol. This will give us the byte array that we can use.
Note that the previous crate hd44780-driver doesn’t support custom characters. To handle this, we can use the liquid_crystal crate, which allows us to work with custom characters.
Initialize the LCD interface
#![allow(unused)]
fn main() {
let mut lcd_interface = Parallel::new(d4, d5, d6, d7, rs, en, lcd_dummy);
let mut lcd = LiquidCrystal::new(&mut lcd_interface, Bus4Bits, LCD16X2);
lcd.begin(&mut timer);
}
Our generated byte array for the custom character
#![allow(unused)]
fn main() {
const FERRIS: [u8; 8] = [
0b01010, 0b10001, 0b10001, 0b01110, 0b01110, 0b01110, 0b11111, 0b10001,
];
// Define the character
lcd.custom_char(&mut timer, &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 timer, CustomChar(0));
lcd.write(&mut timer, Text(" implRust!"));
}
Clone the existing project
You can clone (or refer) project I created and navigate to the lcd-custom folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/lcd-custom/
Multi Generator
Multi Custom Character 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 Custom character
I attempted to create the Ferris image using 6 adjacent grids with the generator from the previous page. Here’s the Rust code to utilize those byte arrays.
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
#![allow(unused)]
fn main() {
lcd.custom_char(&mut timer, &SYMBOL1, 0);
lcd.custom_char(&mut timer, &SYMBOL2, 1);
lcd.custom_char(&mut timer, &SYMBOL3, 2);
lcd.custom_char(&mut timer, &SYMBOL4, 3);
lcd.custom_char(&mut timer, &SYMBOL5, 4);
lcd.custom_char(&mut timer, &SYMBOL6, 5);
}
Display
Let’s write the first 3 grids into the first row, then the second half into the second row of the LCD display.
#![allow(unused)]
fn main() {
lcd.set_cursor(&mut timer, 0, 4)
.write(&mut timer, CustomChar(0))
.write(&mut timer, CustomChar(1))
.write(&mut timer, CustomChar(2));
lcd.set_cursor(&mut timer, 1, 4)
.write(&mut timer, CustomChar(3))
.write(&mut timer, CustomChar(4))
.write(&mut timer, CustomChar(5));
}
Clone the existing project
You can clone (or refer) project I created and navigate to the lcd-custom-multi folder.
git clone https://github.com/ImplFerris/pico2-rp-projects
cd pico2-projects/lcd-custom-multi/
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, ] |
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).
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 |
Print ADC Values
Sending Joystick Movement ADC Values to USB Serial
In this program, we’ll observe how joystick movement affects ADC values in real time. We will connect the Raspberry Pi Pico with the joystick and set up USB serial communication. If you’re not sure how to set up USB Serial, check the USB Serial section.
As you move the joystick, the corresponding ADC values will be printed in the system. You can compare these values with the previous Movement and ADC Diagram;they should approximately match the values shown. Pressing the joystick knob will print “Button Pressed” along with the current coordinates.
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();
}
Pin setup
Let’s set up the ADC and configure GPIO 27 and GPIO 26, which are mapped to the VRX and VRY pins of the joystick:
#![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();
}
We also configure GPIO15 as a pull-up input for the button:
#![allow(unused)]
fn main() {
let mut btn = pins.gpio15.into_pull_up_input();
}
Printing Co-ordinates
We want to print the coordinates only when the vrx or vry values change beyond a certain threshold. This avoids continuously printing unnecessary values.
To achieve this, we initialize variables to store the previous values and a flag to determine when to print:
#![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.0/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.0/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();
Note: 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.0/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.
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















