Introduction

Content of This Material

This is Ferrous Systems' Embedded Rust on Espressif training material. It is divided into two workshops: introductory and advanced. The introductory trail will introduce you to the basics of embedded development and how to make the embedded board interact with the outside world - reacting to commands and sending sensor data.

The advanced course takes it from there to dive deeper into topics like interrupt handling, low-level peripheral access and writing your own drivers.

You can join the esp-rs community on Matrix for all technical questions and issues! The community is open to everyone.

Translations

This book has been translated by generous volunteers. If you would like your translation listed here, please open a PR to add it.

The Board

A Rust ESP Board is mandatory1 for working with this book - emulators like QEMU aren't supported.

The board design and images, pin layout and schematics can also be found in this repository.

If you are subscribed to one of the trainings, a board will be provided to you directly by Espressif.

Our focus lies primarily on the ESP32-C3 platform, a RISC-V-based microcontroller with strong IoT capabilities, facilitated by integrated Wi-Fi and Bluetooth 5 (LE) functionality as well as large RAM + flash size for sophisticated applications. A substantial amount of this course is also applicable for Xtensa, the other architecture Espressif uses, in particular the ESP32-S3. For low-level access, the general principles apply as well, but actual hardware access will differ in various ways - refer to the technical reference manuals (C3, S3) or other available technical documents as needed.

Rust Knowledge

  • Basic Rust like The Rust Book Chapters 1 - 6, Chapter 4 Ownership doesn't need to be fully understood.
  • The Rust on ESP Book isn't required, but it is highly recommended, as it can help you understand the Rust on ESP ecosystem and many of the concepts that will be discussed during the training.
1

It is possible to follow the intro part with ESP32-C3-DevKitC-02 but, we don't recommend it. It is inherently easier to follow the training when using the same hardware.

Preparations

This chapter contains information about the course material, the required hardware and an installation guide.

Icons and Formatting We Use

We use Icons to mark different kinds of information in the book:

  • ✅ Call for action.
  • ⚠️ Warnings, and details that require special attention.
  • 🔎 Knowledge that dives deeper into a subject but which you aren't required to understand, proceeding.
  • 💡 Hints that might help you during the exercises

Example note: Notes like this one contain helpful information

Code Annotations

In some Rust files, you can find some anchor comments:

// ANCHOR: test
let foo = 1;
...
// ANCHOR_END: test

Anchor comments can be ignored, they are only used to introduce those parts of code in this book. See mdBook documentation

Required Hardware

  • Rust ESP Board: available on Mouser, Aliexpress. Full list of vendors.
  • USB-C cable suitable to connect the board to your development computer.
  • Wi-Fi access point connected to the Internet.

No additional debugger/probe hardware is required.

Simulating Projects

Certain projects can be simulated with Wokwi. Look for indications in the book to identify projects available for simulation. Simulation can be accomplished through two methods:

  • Using wokwi.com: Conduct the build process and code editing directly through the browser.
  • Using Wokwi VS Code extension: Leverage VS Code to edit projects and perform builds. Utilize the Wokwi VS Code extension to simulate the resulting binaries.

Ensuring a Working Setup

⚠️ If you are participating in a training led by Ferrous Systems, we urge you to do prepare for the workshop by following the instructions in this chapter, at least, one business day in advance to verify you're ready to go by the time it starts. Please, contact us should you encounter any issues or require any kind of support.

⚠️ If you are using a ESP32-C3-DevKitC-02 a few pins and slave addresses are different, since the board is similar but not the same. This is relevant for the solutions in advanced/i2c-sensor-reading/ and advanced/i2c-driver/, where the pins and slave addresses for the ESP32-C3-DevKitC-02 are commented out.

Companion Material

Checking the Hardware

Connect the Espressif Rust Board to your computer. Verify, a tiny red control LED lights up.

The device should also expose its UART serial port over USB:

Windows: a USB Serial Device (COM port) in the Device Manager under the Ports section.

Linux: a USB device under lsusb. The device will have a VID (Vendor ID) of 303a and a PID (Product ID) of 1001 -- the 0x prefix will be omitted in the output of lsusb:

$ lsusb | grep USB
Bus 006 Device 035: ID 303a:1001 Espressif USB JTAG/serial debug unit

Another way to see the device is to see which permissions and port is associated with the device is to check the /by-id folder:

$ ls -l /dev/serial/by-id
lrwxrwxrwx 1 root root .... usb-Espressif_USB_JTAG_serial_debug_unit_60:55:F9:C0:27:18-if00 -> ../../ttyACM0

If you are using a ESP32-C3-DevKitC-02 the command is $ ls /dev/ttyUSB*

macOS: The device will show up as part of the USB tree in system_profiler:

$ system_profiler SPUSBDataType | grep -A 11 "USB JTAG"

USB JTAG/serial debug unit:

  Product ID: 0x1001
  Vendor ID: 0x303a
  (...)

The device will also show up in the /dev directory as a tty.usbmodem device:

$ ls /dev/tty.usbmodem*
/dev/tty.usbmodem0

Software

Follow the steps below for a default installation of the ESP32-C3 platform tooling.

🔎 Should you desire a customized installation (e.g. building parts from source, or adding support for Xtensa targets), instructions for doing so can be found in the Rust on ESP targets chapter of the Rust on ESP Book.

Rust Toolchain

✅ If you haven't got Rust on your computer, obtain it via https://rustup.rs/

Furthermore, for ESP32-C3, a nightly version of the Rust toolchain is currently required, for this training we will use nightly-2023-11-14 version.

✅ Install nightly Rust and add support for the target architecture using the following command:

rustup toolchain install nightly-2023-11-14 --component rust-src

🔎 Rust is capable of cross-compiling to any supported target (see rustup target list). By default, only the native architecture of your system is installed. To build for the Xtensa architecture (not part of this material), a fork of the Rust compiler is required as of January 2022.

Espressif Toolchain

Several tools are required:

  • cargo-espflash - upload firmware to the microcontroller and open serial monitor with Cargo integration
  • espflash - upload firmware to the microcontroller and open serial monitor
  • ldproxy - Espressif build toolchain dependency

✅ Install them with the following command:

cargo install cargo-espflash espflash ldproxy

⚠️ The espflash and cargo-espflash commands listed in the book assume version is >= 2

Toolchain Dependencies

Debian/Ubuntu

sudo apt install llvm-dev libclang-dev clang libuv-dev libuv1-dev pkgconf python3-venv python-is-python3

macOS

When using the Homebrew package manager, which we recommend:

brew install llvm libuv

Troubleshooting

  • Python 3 is a required dependency. It comes preinstalled on stock macOS and typically on desktop Linux distributions. An existing Python 2 installation with the virtualenv add-on pointing to it is known to potentially cause build problems.

  • Error failed to run custom build command for libudev-sys vX.X.X or esp-idf-sys vX.X.X:

    At time of writing, this can be solved by:

    1. Running this line:

    apt-get update \ && apt-get install -y vim nano git curl gcc ninja-build cmake libudev-dev python3 python3-pip libusb-1.0-0 libssl-dev \ pkg-config libtinfo5

    1. Restarting the terminal.

    2. If this isn't working, try cargo clean, remove the ~/.espressif folder (%USERPROFILE%\.espressif in Windows) and rebuild your project.

    3. On Ubuntu, you might need to change your kernel to 5.19. Run uname -r to obtain your kernel version.

Docker

An alternative environment, is to use Docker. The repository contains a Dockerfile with instructions to install the Rust toolchain, and all required packages. This virtualized environment is designed to compile the binaries for the Espressif target. Flashing binaries from containers isn't possible, hence there are two options:

  • Execute flashing commands, e.g., cargo-espflash, on the host system. If proceeding with this option, it's recommended to keep two terminals open:
    • In the container: compile the project
    • On the host: use the cargo-espflash sub-command to flash the program onto the embedded hardware
  • Use web-flash crate to flash the resulting binaries from the container. The container already includes web-flash. Here is how you would flash the build output of hardware-check project:
    web-flash --chip esp32c3 target/riscv32imc-esp-espidf/debug/hardware-check
    

✅ Install Docker for your operating system.

✅ Get the Docker image: There are 2 ways of getting the Docker image:

  • Build the Docker image from the Dockerfile:
    docker image build --tag rust-std-training --file .devcontainer/Dockerfile .
    
    Building the image takes a while depending on the OS & hardware (20-30 minutes).
  • Download it from Dockerhub:
    docker pull espressif/rust-std-training
    

✅ Start the new Docker container:

  • For local Docker image:
    docker run --mount type=bind,source="$(pwd)",target=/workspace,consistency=cached -it rust-std-training /bin/bash
    
  • From the Docker Hub:
    docker run --mount type=bind,source="$(pwd)",target=/workspace,consistency=cached -it espressif/rust-std-training:latest /bin/bash
    

This starts an interactive shell in the Docker container. It also mounts the local repository to a folder named /workspace inside the container. Changes to the project on the host system are reflected inside the container & vice versa.

Additional Software

VS Code

One editor with good Rust support is VS Code, which is available for most platforms. When using VS Code, we recommend the following extensions to help during the development.

  • Rust Analyzer to provide code completion & navigation
  • Even Better TOML for editing TOML based configuration files

There are a few more useful extensions for advanced usage

  • lldb a native debugger extension based on LLDB
  • crates to help manage Rust dependencies

VS Code & Dev Containers

One extension for VS Code that might be helpful to develop inside a Docker container is Remote Containers. It uses the same Dockerfile as the Docker setup, but builds the image and connects to it from within VS Code. Once the extension is installed, VS Code recognizes the configuration in the .devcontainer folder. Use the Remote Containers - Reopen in Container command to connect VS Code to the container.

Workshop Repository

The entire material can be found at https://github.com/esp-rs/std-training.

✅ Clone and change into the workshop repository:

git clone "https://github.com/esp-rs/std-training.git"
cd std-training

❗ Windows users may have problems with long path names.

Repository Contents

  • advanced/ - code examples and exercises for the advanced course
  • book/ - markdown sources of this book
  • common/ - code shared between both courses
  • common/lib/ - support crates
  • intro/ - code examples and exercises for the introduction course

A Word on Configuration

We use toml-cfg throughout this workshop as a more convenient and secure alternative to putting credentials or other sensitive information directly in source code. The settings are stored in a file called cfg.toml in the respective package root instead.

This configuration contains exactly one section header which has the same name as your package (name = "your-package" in Cargo.toml), and the concrete settings will differ between projects:

[your-package]
user = "example"
password = "h4ckm3"

❗ If you copy a cfg.toml to a new project, remember to change the header to [name-of-new-package].

Hello, Board!

You're now ready to do a consistency check!

✅ Connect the USB-C port of the board to your computer and enter the hardware-check directory in the workshop repository:

cd intro/hardware-check

To test Wi-Fi connectivity, you will have to provide your network name (SSID) and password (PSK). These credentials are stored in a dedicated cfg.toml file (which is .gitignored) to prevent accidental disclosure by sharing source code or doing pull requests. An example is provided.

✅ Copy cfg.toml.example to cfg.toml (in the same directory) and edit it to reflect your actual credentials:

⚠️The 5 GHz band isn't supported in ESP32-C3, you need to ensure you are using a Wi-Fi with active 2.4 GHz band.

$ cp cfg.toml.example cfg.toml
$ $EDITOR cfg.toml
$ cat cfg.toml

[hardware-check]
wifi_ssid = "Your Wifi name"
wifi_psk = "Your Wifi password"

✅ Build, flash and monitor the project:

$ cargo run

Serial port: /dev/SERIAL_DEVICE
Connecting...

Chip type:         ESP32-C3 (revision 3)
(...)
Compiling hardware-check v0.1.0
Finished release [optimized] target(s) in 1.78s

[00:00:45] ########################################     418/418     segment 0x10000

Flashing has completed!
(...)
rst:0x1 (POWERON),boot:0xc (SPI_FAST_FLASH_BOOT)
(...)
(...)
(...)
I (4427) wifi::wifi: Wifi connected!

🔎 If cargo run has been successful, you can exit with ctrl+C.

🔎 cargo run is configured to use espflash as custom runner. The same output can be achieved via:

  • Using cargo-espflash: cargo espflash flash --release --monitor
  • Building your project and flashing it with espflash: cargo build --release && espflash target/riscv32imc-esp-espidf/release/hardware-check This modification is applied to all the projects in the training for convenience.

The board LED should turn yellow on startup, and then, depending on whether a Wi-Fi connection could be established, either turn red (error) or blink, alternating green and blue, in case of succeeding. In case of a Wi-Fi error, a diagnostic message will also show up at the bottom, e.g.:

Error: could not connect to Wi-Fi network: ESP_ERR_TIMEOUT

⚠️ You will get an ESP_ERR_TIMEOUT error also in case your network name or password are incorrect, so double-check those.

Extra Information About Building, Flashing and Monitoring

If you want to try to build without flashing, you can run:

cargo build

You can also monitor the device without flashing it with the following command:

espflash monitor

Simulation

This project is available for simulation through two methods:

  • Wokwi project
  • Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File, and choose intro/hardware-check/wokwi.toml.
    2. Build your project.
    3. Press F1 again and select Wokwi: Start Simulator.

Troubleshooting

Build Errors

error[E0463]: can't find crate for `core`
= note: the `riscv32imc-esp-espidf` target may not be installed

You're trying to build with a stable Rust - you need to use nightly. this error message is slightly misleading - this target cannot be installed. It needs to be built from source, using build-std, which is a feature available on nightly only.


error: cannot find macro `llvm_asm` in this scope

You're using an incompatible version of nightly - configure a suitable one using rust-toolchain.toml or cargo override.


CMake Error at .../Modules/CMakeDetermineSystem.cmake:129 (message):

Your Espressif toolchain installation might be damaged. Delete it and rerun the build to trigger a fresh download:

rm -rf ~/.espressif

On Windows, delete %USERPROFILE%\.espressif folder.


Serial port: /dev/tty.usbserial-110
Connecting...

Unable to connect, retrying with extra delay...
Unable to connect, retrying with default delay...
Unable to connect, retrying with extra delay...
Error: espflash::connection_failed

× Error while connecting to device
╰─▶ Failed to connect to the device
help: Ensure that the device is connected and the reset and boot pins are not being held down

The board isn't accessible with a USB-C cable. A typical connection error looks like this:

Workarounds:

  1. Press and hold boot button on the board, start flash command, release boot button after flashing process starts
  2. Use a hub.

Source.

Intro Workshop

This workshop guides you through the basics of embedded development. At the end of the workshop, we will be able to interact with the outside world, including sending and receiving commands to/from the board sensors. The content includes:

  • Overview of a project
  • Setting up a project with cargo-generate.
  • Writing an HTTP client.
  • Writing an HTTP server.
  • Writing an MQTT client that:
    • Publishes sensor data
    • Receives commands via a subscribed topic.

Preparations

Please go through the preparations chapter to prepare for this workshop.

Reference

If you're new to embedded programming, read our reference, where we explain some terms in a basic manner.

Project Organization

The esp-rs Crates

Unlike most other embedded platforms, Espressif supports the Rust standard library. Most notably, this means you'll have arbitrary-sized collections like Vec or HashMap at your disposal, as well as generic heap storage using Box. You're also free to spawn new threads, and use synchronization primitives like Arc and Mutex to safely share data between them. Still, memory is a scarce resource on embedded systems, and so you need to take care not to run out of it - threads in particular can become rather expensive.

Services like Wi-Fi, HTTP client/server, MQTT, OTA updates, logging etc. are exposed via Espressif's open source IoT Development Framework, ESP-IDF. It is mostly written in C and as such is exposed to Rust in the canonical split crate style:

  • a sys crate to provide the actual unsafe bindings (esp-idf-sys)
  • a higher level crate offering safe and comfortable Rust abstractions (esp-idf-svc)

The final piece of the puzzle is low-level hardware access, which is again provided in a split fashion:

  • esp-idf-hal implements the hardware-independent embedded-hal traits like analog/digital conversion, digital I/O pins, or SPI communication - as the name suggests, it also uses ESP-IDF as a foundation
  • if direct register manipulation is required, esp32c3 provides the peripheral access crate generated by svd2rust.

More information is available in the ecosystem chapter of The Rust on ESP Book.

Build Toolchain

🔎 As part of a project build, esp-idf-sys will download ESP-IDF, the C-based Espressif toolchain. The download destination is configurable. To save disk space and download time, all examples/exercises in the workshop repository are set to use one single global toolchain, installed in ~/.espressif (%USERPROFILE%\.espressif in Windows). See the ESP_IDF_TOOLS_INSTALL_DIR parameter in esp-idf-sys's README for other options.

Package Layout

On top of the usual contents of a Rust project created with cargo new, a few additional files and parameters are required. The examples/exercises in this workshop are already fully configured, and for creating new projects it is recommended to use the cargo-generate wizard based approach.

🔎 The rest of this page is optional knowledge that can come in handy should you wish to change some aspects of a project.

Some build dependencies must be set:

[build-dependencies]
embuild = "=0.31.2"
anyhow = "=1.0.71"

Additional Configuration Files

  • build.rs - Cargo build script. Here: sets environment variables required for building.
  • .cargo/config.toml - sets the target architecture, a custom runner to flash and monitor the device, and controls build details. This is the place to override ESP_IDF_TOOLS_INSTALL_DIR if you wish to do so.
  • sdkconfig.defaults - overrides ESP-IDF specific parameters such as stack size, log level, etc.

Generating New Projects

We're now going to use cargo-generate (a generic project wizard) to set up our first application.

More information on generating projects can be found in the Writing Your Own Application chapter of The Rust on ESP Book.

Most other exercises in this workshop already provide a project skeleton and don't require using cargo-generate.

✅ Install cargo-generate:

cargo install cargo-generate

✅ Change to the intro directory and run cargo generate with the esp-idf template:

cd intro
cargo generate esp-rs/esp-idf-template cargo

You'll be prompted for details regarding your new project. When given a choice between several options, navigate using cursor up/down and select with the Return key.

The first message you see will be: ⚠️Unable to load config file: /home/$USER/.cargo/cargo-generate.toml. You see this error because you don't have a favorite config file, but you don't need one and you can ignore this warning.

🔎 You can create a favorite config file that will be placed in $CARGO_HOME/cargo-generate, and override it with -c, --config <config-file>.

If you make a mistake, hit Ctrl+C and start anew.

✅ Configure your project:

(These items may appear in a different order)

  • Project Name: hello-world
  • MCU: esp32c3
  • Configure advanced template options?: false

🔎 .cargo/config.toml contains local settings (list of all settings) for your package. Cargo.toml contains dependencies and Cargo.lock will import all your dependencies.

Optional, but recommended: To save disk space and download time, set the toolchain directory to global. Otherwise, each new project/workspace will have its own instance of the toolchain installed on your computer:

✅ Open hello-world/.cargo/config.toml and add the following line to the bottom of the [env] section. Leave everything else unchanged.

[env]
# ...
ESP_IDF_TOOLS_INSTALL_DIR = { value = "global" } # add this line

✅ Open hello-world/rust-toolchain.toml and change the file to look like this:

[toolchain]
channel = "nightly-2023-11-14" # change this line

✅ Run your project by using the following command out of the hello-world directory.

cd hello-world
cargo run

✅ The last lines of your output should look like this:

(...)
I (268) cpu_start: Starting scheduler.
Hello, world!

Extra Tasks

  • If your main function exits, you have to reset the microcontroller to start it again. What happens when you put an infinite loop at the end instead? Test your theory by flashing a looping program.
  • Can you think of a way to prevent what you're now seeing? (click for hint:1)

Troubleshooting

  • If cargo run is stuck on Connecting..., you might have another monitor process still running (e.g. from the initial hardware-check test). Try finding and terminating it. If this doesn't help, disconnect and reconnect the board's USB cable.
  • ⛔ Git Error: authentication required: your git configuration is probably set to override https GitHub URLs to ssh. Check your global ~/.git/config for insteadOf sections and disable them.
1

yield control back to the underlying operating system by sleeping in a loop instead of busy waiting. (use use std::thread::sleep)

HTTP and HTTPS Client

In this exercise, we'll write a small client that retrieves data over an HTTP connection to the internet. Then we will upgrade it to an HTTPS client.

HTTP Client

The goal of this exercise is to write a small HTTP client that connects to a website.

Setup

✅ Go to intro/http-client directory.

✅ Open the prepared project skeleton in intro/http-client.

✅ Add your network credentials to the cfg.toml as in the hardware test.

✅ Open the docs for this project with the following command:

cargo doc --open

intro/http-client/examples/http_client.rs contains the solution. You can run it with the following command:

cargo run --example http_client

Making a Connection

By default, only unencrypted HTTP is available, which rather limits our options of hosts to connect to. We're going to use http://neverssl.com/.

In ESP-IDF, HTTP client connections are managed by http::client::EspHttpClient in the esp-idf-svc crate. It implements the http::client::Client trait from embedded-svc, which defines functions for HTTP request methods like GET or POST. This is a good time to have a look at the documentation you opened with cargo doc --open for esp_idf_svc::http::client::EspHttpConnection and embedded_svc::http::client::Client. See instantiation methods at your disposal.

✅ Create a new EspHttpConnection with default configuration. Look for a suitable constructor in the documentation.

✅ Get a client from the connection you just made.

Calling HTTP functions (e.g. get(url)) on this client returns an embedded_svc::http::client::Request, which must be submitted to reflect the client's option to send some data alongside its request.

The get function uses as_ref(). This means that instead of being restricted to one specific type like just String or just &str, the function can accept anything that implements the AsRef<str> trait. That is any type where a call to .as_ref() will produce a &str. This works for String and &str, but also the Cow<str> enum type which contains either of the previous two.

#![allow(unused)]
fn main() {
    let request = client.request(Method::Get, url.as_ref(), &headers)?;
}

A successful response has a status code in the 2xx range. Followed by the raw html of the website.

✅ Verify the connection was successful.

✅ Return an Error if the status isn't in the 2xx range.

#![allow(unused)]
fn main() {
match status {
        200..=299 => {
        }
        _ => bail!("Unexpected response code: {}", status),
    }
}

The status error can be returned with the Anyhow, crate which contains various functionality to simplify application-level error handling. It supplies a universal anyhow::Result<T>, wrapping the success (Ok) case in T and removing the need to specify the Err type, as long as every error you return implements std::error::Error.

✅ Read the received data chunk by chunk into an u8 buffer using Read::read(&mut reader,&mut buf). Read::read returns the number of bytes read - you're done when this value is 0.

✅ Report the total number of bytes read.

✅ Log the received data to the console. 💡 The response in the buffer is in bytes, so you might need a method to convert from bytes to &str.

Extra Tasks

✅ Handle 3xx, 4xx and 5xx status codes each in a separate match arm

✅ Write a custom Error enum to represent these errors. Implement the std::error::Error trait for your error.

Simulation

This project is available for simulation through two methods:

  • Wokwi projects:
  • Wokwi files are also present in the project folder to simulate it with Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File and choose intro/http-client/wokwi.toml
      • Edit the wokwi.toml file to select between exercise and solution simulation
    2. Build you project
    3. Press F1 again and select Wokwi: Start Simulator

Troubleshooting

  • missing WiFi name/password: ensure that you've configured cfg.toml according to cfg.toml.example - a common problem is that the package name and config section name don't match.
# Cargo.toml
#...
[package]
name = "http-client"
#...

# cfg.toml
[http-client]
wifi_ssid = "..."
wifi_psk = "..."
  • Guru Meditation Error: Core 0 panic'ed (Load access fault). Exception was unhandled. This may be caused by an .unwrap() in your code. Try replacing those with question marks.

HTTPS Client

You will now make changes to your HTTP client files so that it also works for encrypted connections.

intro/http-client/examples/http_client.rs contains the solution. You can run it with the following command:

cargo run --example https_client

Create a custom client configuration to use an esp_idf_svc::http::client::EspHttpConnection which enables the use of these certificates and uses default values for everything else:

#![allow(unused)]
fn main() {
    let connection = EspHttpConnection::new(&Configuration {
        use_global_ca_store: true,
        crt_bundle_attach: Some(esp_idf_svc::sys::esp_crt_bundle_attach),
        ..Default::default()
    })?;
}

✅ Initialize your HTTP client with this new configuration and verify HTTPS works by downloading from an https resource, e.g. https://espressif.com/. the download will show as raw HTML in the terminal output.

Troubleshooting (Repeated from Previous Section)

  • missing WiFi name/password: ensure that you've configured cfg.toml according to cfg.toml.example - a common problem is that the package name and config section name don't match.
# Cargo.toml
#...
[package]
name = "http-client"
#...

# cfg.toml
[http-client]
wifi_ssid = "..."
wifi_psk = "..."

A Simple HTTP Server

We're now turning our board into a tiny web server that upon receiving a GET request serves data from the internal temperature sensor.

Setup

You can find a prepared project skeleton in intro/http-server/. It includes establishing a Wi-Fi connection, but you must configure it to use your network's credentials in cfg.toml.

intro/http-server/examples/https-server.rs contains a solution. You can run it with the following command:

cargo run --example http_server

Serving Requests

To connect to your board with your browser, you need to know the board's IP address.

✅ Run the skeleton code in intro/http-server. The output should yield the board's IP address like this:

I (3862) esp_netif_handlers: sta ip: 192.168.178.54, mask: ...
...
Server awaiting connection

The sta ip is the "station", the Wi-Fi term for an interface connected to an access point. This is the address you'll put in your browser (or other HTTP client like curl).

🔎 ESP-IDF tries to register the hostname espressif in your local network, so often http://espressif/ instead of http://<sta ip>/ will also work.

You can change the hostname by setting CONFIG_LWIP_LOCAL_HOSTNAME in sdkconfig.defaults, e.g.: CONFIG_LWIP_LOCAL_HOSTNAME="esp32c3"

Sending HTTP data to a client involves:

  • Creating an instance of EspHttpServer
  • Looping in the main function, so it doesn't terminate - termination would result in the server going out of scope and subsequently shutting down
  • Setting a separate request handler function for each requested path you want to serve content. Any unconfigured path will result in a 404 error. These handler functions are realized inline as Rust closures via:
#![allow(unused)]
fn main() {
server.fn_handler(path, Method::Get, |request| {
    // ...
    // construct a response
    let mut response = request.into_ok_response()?;
    // now you can write your desired data
    response.write_all(&some_buf)?;
    // once you're done the handler expects a `Completion` as result,
    // this is achieved via:
    Ok(())
});

}

✅ Create a EspHttpServer instance using a default esp_idf_svc::http::server::Configuration. The default configuration will cause it to listen on port 80 automatically.

✅ Verify that a connection to http://<sta ip>/ yields a 404 (not found) error stating This URI does not exist.

✅ Write a request handler for requests to the root path ("/"). The request handler sends a greeting message at http://<sta ip>/, using the provided index_html() function to generate the HTML String.

Dynamic Data

We can also report dynamic information to a client. The skeleton includes a configured temp_sensor that measures the board's internal temperature.

✅ Write a second handler that reports the chip temperature at http://<sta ip>/temperature, using the provided temperature(val: f32) function to generate the HTML String. 💡 If you want to send a response string, it needs to be converted into a &[u8] slice via a_string.as_bytes() 💡 The temperature sensor needs exclusive (mutable) access. Passing it as owned value into the handler will not work (since it would get dropped after the first invocation) - you can fix this by making the handler a move || closure, wrapping the sensor in an Arc<Mutex<_>>, keeping one clone() of this Arc in your main function and moving the other into the closure.

Troubleshooting

  • httpd_txrx: httpd_resp_send_err can be solved by restarting, or cargo clean if nothing happens.
  • Make sure your computer and the Rust ESP Board are using the same Wi-Fi network.

IoT Using MQTT

In this exercise, we will learn how MQTT works and write an application that can send and receive data to the Internet using MQTT.

How Does MQTT Work

⚠️ This exercise requires an MQTT server. If you're participating in a Ferrous Systems training, login credentials for a server operated by Espressif will be made available in the workshop, otherwise you can use one listed at https://test.mosquitto.org/ or install one locally.

To conclude the introductory course, let's add some IoT functionality to the board. Our goal here is to have our board send out real-time updates of sensor values without having to poll repeatedly, like we would with an HTTP server, and also receive commands to change the board LED color.

This can be modeled using a publish-subscribe architecture, where multiple clients publish messages in certain channels/topics, and can also subscribe to these topics to receive messages sent by others. Dispatching of these messages is coordinated by a message broker - in our case, this is done in an MQTT server.

MQTT Messages

An MQTT message consists of two parts - topic and payload.

The topic serves the same purpose as an email subject or a label on a filing cabinet, whereas the payload contains the actual data. The payload data format isn't specified, although JSON is common.

🔎 The most recent version of the MQTT standard (MQTT 5) supports content type metadata. When sending an MQTT message, a Quality of Service (QoS) parameter needs to be defined, indicating delivery guarantees:

  • At most once.
  • At least once.
  • Exactly once.

For the purpose of this exercise, it doesn't matter which quality you choose.

MQTT Topics

MQTT topics are UTF-8 strings representing a hierarchy, with individual levels separated by a / slash character. A leading slash is supported, but not recommended. Some example topics are:

home/garage/temperature
beacons/bicycle/position
home/alarm/enable
home/front door/lock

Here a sensor would periodically publish the garage temperature which then gets broadcast to every subscriber, just as the bicycle beacon publishes its GPS coordinates. The alarm and lock topics serve as a command sink for specific devices. However, nothing prevents additional subscribers from listening in on these commands, which might provide useful for auditing purposes.

🔎 Topics starting with $ are reserved for statistics internal to the broker. Typically, the topic will begin with $SYS. Clients can't publish on these topics.

⚠️ Since all workshop participants will be sharing the same MQTT server, some measures are required to prevent crosstalk between different projects. The exercise skeleton will generate a unique, random ID (in the UUID v4 format) for each repository checkout. You can also manually generate your own online. Your UUID should be used as leading part of the message topics sent between the computer and board, roughly resembling this pattern:

6188eec9-6d3a-4eac-996f-ac4ab13f312d/sensor_data/temperature
6188eec9-6d3a-4eac-996f-ac4ab13f312d/command/board_led

Subscribing to Topics

A client sends several subscribe messages to indicate they're interested in receiving certain topics. Wildcards are optionally supported, both for a single hierarchy level and as a catch-all:

  • home/garage/temperature - subscribes only to this specific topic
  • home/# - the hash character is used as multi-level wildcard and thus subscribes to every topic starting with home/ - home/garage/temperature, home/front door/lock and home/alarm/enable would all match, but beacons/bicycle/position won't. The multi-level wildcard must be placed at the end of a subscription string.
  • home/+/temperature - the plus character serves as a single-level wildcard to subscribe to home/garage/temperature, home/cellar/temperature, etc.

MQTT Exercise: Sending Messages

Setup

✅ Go to intro/mqtt/exercise directory.

✅ Open the prepared project skeleton in intro/mqtt/exercise.

✅ In intro/mqtt/host_client you can find a host run program that mimics the behavior of a second client. Run it in a separate terminal using the cargo run command. Find more information about the host client below.

The client also generates random RGB colors and publishes them in a topic. This is only relevant for the second part of this exercise.

⚠️ Similar to the HTTP exercises, you need to configure your connection credentials in cfg.toml for both programs. Besides Wi-Fi credentials, you'll also need to add MQTT server details. Check each cfg.toml.example for required settings. Remember, the name between brackets in the cfg.toml file is the name of the package in Cargo.toml.

The structure of the exercises is as below. In this part, we will focus on the Temperature topic.

example_client_broker_board

intro/mqtt/exercise/solution/solution_publ.rs contains a solution. You can run it with the following command:

cargo run --example solution_publ

Tasks

✅ Create an EspMqttClient with a default configuration and an empty handler closure.

✅ Send an empty message under the hello_topic to the broker. Use the hello_topic(uuid) utility function to generate a properly scoped topic.

✅ Verify a successful publish by having a client connected that logs these messages. The host_client implements this behavior. The host_client should be running in another terminal before you run your program in the ESP Rust Board. host_client should print something like this:

Setting new color: rgb(1,196,156)
Setting new color: rgb(182,190,128)
Board says hi!

✅ In the loop at the end of your main function, publish the board temperature on temperature_data_topic(uuid) every second. Verify this, using host_client too:

Setting new color: rgb(218,157,124)
Board temperature: 33.29°C
Setting new color: rgb(45,88,22)
Board temperature: 33.32°C

Establishing a Connection

Connections are managed by an instance of esp_idf_svc::mqtt::client::EspMqttClient. It is constructed using

  • a broker URL which in turn contains credentials, if necessary
  • a configuration of the type esp_idf_svc::mqtt::client::MqttClientConfiguration
  • a handler closure similar to the HTTP server exercise
#![allow(unused)]

fn main() {
let mut client = EspMqttClient::new(broker_url,
    &mqtt_config,
    move |message_event| {
        // ... your handler code here - leave this empty for now
        // we'll add functionality later in this chapter
    })?;

}

Support Tools & Crates

To log the sensor values sent by the board, a helper client is provided under intro/mqtt/host_client. It subscribes to the temperature topic.

The mqtt_messages crate (located in common/lib) supports handling messages, subscriptions, and topics:

Functions to Generate Topic Strings

  • color_topic(uuid) - creates a topic to send colors that will be published to the board.
  • hello_topic(uuid) - test topic for initially verifying a successful connection
  • temperature_data_topic(uuid) - creates a whole "temperature" topic string

Encoding and Decoding Message Payloads

The board temperature f32 float is converted to four "big-endian" bytes using temp.to_be_bytes().

#![allow(unused)]
fn main() {
// temperature
let temperature_data = &temp.to_be_bytes() as &[u8]; // board
let decoded_temperature = f32::from_be_bytes(temperature_data); // workstation
}

Publish & Subscribe

EspMqttClient is also responsible for publishing messages under a given topic. The publish function includes a retain parameter indicating whether this message should also be delivered to clients that connect after it has been published.

#![allow(unused)]
fn main() {
let publish_topic = /* ... */;
let payload: &[u8] = /* ... */ ;
client.publish(publish_topic, QoS::AtLeastOnce, false, payload)?;
}

Troubleshooting

  • error: expected expression, found . when building example client: update your stable Rust installation to 1.58 or newer
  • MQTT messages not showing up? make sure all clients (board and workstation) use the same UUID (you can see it in the log output)
  • Make sure the cfg.toml file is configured properly. The example-client has a dbg!() output at the start of the program, that shows mqtt configuration. It should output the content of your cfg.toml file.
  • error: expected expression, found . while running the host-client can be solved with rustup update

MQTT Exercise: Receiving LED Commands

✅ Subscribe to color_topic(uuid)

✅ Run host_client in parallel in its own terminal. The host_client publishes board LED color roughly every second.

✅ Verify your subscription is working by logging the information received through the topic.

✅ React to the LED commands by setting the newly received color to the board with led.set_pixel(/* received color here */).

intro/mqtt/exercise/solution/solution_publ_rcv.rs contains a solution. You can run it with the following command:

cargo run --example solution_publ_rcv

Encoding and Decoding Message Payloads

The board LED commands are made of three bytes indicating red, green, and blue.

  • enum ColorData contains a topic color_topic(uuid) and the BoardLed
  • It can convert the data() field of an EspMqttMessage by using try_from(). The message needs first to be coerced into a slice, using let message_data: &[u8] = &message.data();
#![allow(unused)]
fn main() {
// RGB LED command

if let Ok(ColorData::BoardLed(color)) = ColorData::try_from(message_data) { /* set new color here */ }
}

Publish & Subscribe

EspMqttClient isn't only responsible for publishing but also for subscribing to topics.

#![allow(unused)]
fn main() {
let subscribe_topic = /* ... */;
client.subscribe(subscribe_topic, QoS::AtLeastOnce)
}

Handling Incoming Messages

The message_event parameter in the handler closure is of type Result<Event<EspMqttMessage>. Since we're only interested in processing successfully received messages, we can make use of deep pattern matching into the closure:

#![allow(unused)]
fn main() {
let mut client =
    EspMqttClient::new(
        broker_url,
        &mqtt_config,
        move |message_event| match message_event {
            Ok(Received(msg)) => process_message(msg, &mut led),
            _ => warn!("Received from MQTT: {:?}", message_event),
        },
    )?;
}

In the processing function, you will handle Complete messages.

💡 Use Rust Analyzer to generate the missing match arms or match any other type of response by logging an info!().

#![allow(unused)]
fn main() {
match message.details() {
    // All messages in this exercise will be of type `Complete`
    // The other variants of the `Details` enum are for larger message payloads
    Complete => {

        // Cow<&[u8]> can be coerced into a slice &[u8] or a Vec<u8>
        // You can coerce it into a slice to be sent to try_from()
        let message_data: &[u8] = &message.data();
        if let Ok(ColorData::BoardLed(color)) = ColorData::try_from(message_data) {
            // Set the LED to the newly received color

        }
    }
    // Use Rust Analyzer to generate the missing match arms or match an incomplete message with a log message.
}
}

💡 Use a logger to see what you are receiving, for example, info!("{}", color); or dbg!(color).

Extra Tasks

Implement MQTT with Hierarchical Topics

✅ Work on this if you have finished everything else. We don't provide a full solution for this, as this is to test how far you get on your own.

Check common/lib/mqtt-messages:

✅ Implement the same procedure, but by using an MQTT hierarchy. Subscribe by subscribing to all "command" messages, combining cmd_topic_fragment(uuid) with a trailing # wildcard.

✅ Use enum Command instead of enum ColorData. enum Command represents all possible commands (here: just BoardLed).

RawCommandData stores the last part of a message topic (e.g. board_led in a-uuid/command/board_led). It can be converted into a Command using try_from.

#![allow(unused)]
fn main() {
// RGB LED command
let raw = RawCommandData {
    path: command,
    data: message.data(),
};

}

Check the host-client:

✅ you will need to replace color with command. For example, with this:

#![allow(unused)]
fn main() {
let command = Command::BoardLed(color)
}

✅ in the process_message() function, you will need to parse the topic.

#![allow(unused)]
fn main() {
match message.details() {
    Complete => {
        // All messages in this exercise will be of type `Complete`
        // the other variants of the `Details` enum
        // are for larger message payloads

        // Cow<str> behaves a lot like other Rust strings (&str, String)
        let topic: Cow<str> = message.topic(token);

        // Determine if we're interested in this topic and
        // Dispatch based on its content
        let is_command_topic: bool = /* ... */;
        if is_command_topic {
            let raw = RawCommandData { /* ... */ };
            if let Ok(Command::BoardLed(color)) = Command::try_from(raw) {
                // Set the LED to the newly received color
            }

        },
        _ => {}
    }
}
}

💡 Since you will be iterating over a MQTT topic, you will need to split() on a string returns an iterator. You can access a specific item from an iterator using nth(). 💡 The solution implementing hierarchy can be run with cargo run --example solution2, while the solution without can be run with cargo run or cargo run --example solution1

Other Tasks

✅ Leverage serde_json to encode/decode your message data as JSON.

✅ Send some messages with a large payload from the host client and process them on the microcontroller. Large messages will be delivered in parts instead of Details::Complete:

#![allow(unused)]
fn main() {
InitialChunk(chunk_info) => { /* first chunk */},
SubsequentChunk(chunk_data) => { /* all subsequent chunks */ }
}

💡 You don't need to differentiate incoming chunks based on message ID, since at most one message will be in flight at any given time.

Troubleshooting

  • error: expected expression, found . When building host client: update your stable Rust installation to 1.58 or newer
  • MQTT messages not showing up? make sure all clients (board and workstation) use the same UUID (you can see it in the log output)

Advanced Workshop

In this course, we're going to dive deeper into topics that are embedded-only and/or close to the hardware, especially focussing on lower level I/O. Unlike in the first part, we'll not just use the higher level abstractions where for example something like pin configurations are hidden away. Instead, you'll learn how to configure them yourself. You're also going to learn how to write directly into registers and how to find out which register is needed in the first place. We'll talk about ownership issues and memory safety issues in the context of exercises.

This part consists of three exercises:

In the first one, you'll learn how to handle a button interrupt, in the second you'll read sensor values from sensors via the I²C bus. Once you have used the drivers we have prepared, you'll learn how to get started writing your own. This is a necessary skill as Rust drivers are usually not provided by manufacturers.

Preparations

Please go through the preparations chapter to prepare for this workshop.

Reference

If you're new to embedded programming, read our reference where we explain some terms in a basic manner.

Lower Level I/O: How to Manipulate Registers

There are two ways to write firmware for the ESP32-C3:

  • One is the bare-metal using only [no_std] Rust.
  • The other using [std] Rust and C-Bindings to ESP-IDF.

[no_std] Rust refers to Rust not using the standard library, only the core library, which is a subset of the standard library that doesn't depend on the existence of an operating system.

What do the Ecosystems Look Like?

[std] Rust and the ESP-IDF

This way relies on using C bindings to ESP-IDF. We can use Rust's standard library when going this route, as we can use an operating system: ESP-IDF, which is based on FreeRTOS. Being able to use the standard library comes with benefits: We can use all types, no matter if they are stack or heap allocated. We can use threads, Mutexes and other synchronization primitives.

The ESP-IDF is mostly written in C and as such is exposed to Rust in the canonical split crate style:

  • A sys crate to provide the actual unsafe bindings (esp-idf-sys)
  • A higher level crate offering safe and comfortable Rust abstractions (esp-idf-svc)

The final piece of the puzzle is low-level hardware access, which is again provided in a split fashion:

  • esp-idf-hal implements the hardware-independent embedded-hal traits like analog/digital conversion, digital I/O pins, or SPI communication - as the name suggests, it also uses ESP-IDF as a foundation

More information is available in the ecosystem chapter of The Rust on ESP Book.

This is the way that currently allows the most possibilities on Espressif chips if you want to use Rust. Everything in this course is based on this approach.

We're going to look at how to write values into Registers in this ecosystem in the context of the Interrupts exercise.

Bare Metal Rust with [no_std]

As the name bare metal implies, we don't use an operating system. Because of this, we can't use language features that rely on one. The core library is a subset of the standard library that excludes features like heap allocated types and threads. Code that uses only the core library is labelled with #[no_std]. #[no_std] code can always run in a std environment, but the reverse isn't true. In Rust, the mapping from Registers to Code works like this:

Registers and their fields on a device are described in System View Description (SVD) files. svd2rust is used to generate Peripheral Access Crates (PACs) from these SVD files. The PACs provide a thin wrapper over the various memory-mapped registers defined for the particular model of microcontroller you are using.

Whilst it is possible to write firmware with a PAC alone, some of it would prove unsafe or otherwise inconvenient as it only provides the most basic access to the peripherals of the microcontroller. So there is another layer, the Hardware Abstraction Layer (HAL). HALs provide a more user-friendly API for the chip, and often implement common traits defined in the generic embedded-hal.

Microcontrollers are usually soldered to some Printed Circuit Board (or just Board), which defines the connections that are made to each pin. A Board Support Crate (BSC, also known as a Board Support Package or BSP) may be written for a given board. This provides yet another layer of abstraction and might, for example, provide an API to the various sensors and LEDs on that board - without the user necessarily needing to know which pins on your microcontroller are connected to those sensors or LEDs.

We will write a partial sensor driver in this approach, as driver's should be platform-agnostic.

I²C

Introduction

The Inter-Integrated Circuit, usually shortened to I²C or I2C, is a serial protocol that allows multiple peripheral (or slave) chips to communicate with one or more controller (or master) chips. Many devices can be connected to the same I²C bus, and messages can be sent to a particular device by specifying its I²C address. The protocol requires two signal wires and can only be used for short-distance communications within one device.

One of the signal lines is for data (SDA) and the other is for the clock signal (SCL). The lines are pulled high by default, with some resistors fitted somewhere on the bus. Any device on the bus (or even multiple devices simultaneously) can 'pull' either or both of the signal lines low. This means that no damage occurs if two devices try to talk on the bus at the same time - the messages are merely corrupted (and this is detectable).

An I²C transaction consists of one or more messages. Each message consists of a start symbol, some words, and finally a stop symbol (or another start symbol if there is a follow-on message). Each word is eight bits, followed by an ACK (0) or NACK (1) bit which is sent by the recipient to indicate whether the word was received and understood correctly. The first word indicates both the 7-bit address of the device the message is intended for, plus a bit to indicate if the device is being read from or being written to. If there is no device of that address on the bus, the first word will naturally have a NACK after it, because there is no device driving the SDA line low to generate an ACK bit, and so you know there is no device present.

The clock frequency of the SCL line is usually 400 kHz but slower and faster speeds are supported (standard speeds are 100 kHz-400 kHz-1 MHz).In our exercise, the configuration will be 400 kHz ( <MasterConfig as Default>::default().baudrate(400.kHz().into())).

To read three bytes from an EEPROM device, the sequence will be something like:

StepController SendsPeripheral Sends
1.START
2.Device Address + W
3.ACK
4.High EEPROM Address byte
5.ACK
6.Low EEPROM Address byte
7.ACK
8.START
9.Device Address + R
10.ACK
11.Data Byte from EEPROM Address
12.ACK
13.Data Byte from EEPROM Address + 1
14.ACK
15.Data Byte from EEPROM Address + 2
16.NAK (i.e. end-of-read)
17.STOP

I²C Signal Image

A sequence diagram of data transfer on the I²C bus:

  • S - Start condition
  • P - Stop condition
  • B1 to BN - Transferring of one bit
  • SDA changes are allowed when SCL is low (blue), otherwise, there will be a start or stop condition generated.

Source & more details: Wikipedia.

I²C Sensor Reading Exercise

In this exercise, you will learn how to read out sensors on the I²C bus.

The Rust ESP Board has two sensors that can be read via the I²C bus:

PeripheralPart numberReferenceCrateAddress
IMUICM-42670-PDatasheetLink0x68
Temperature and HumiditySHTC3DatasheetLink0x70

The task is to use an existing driver from crates.io to read out the temperature and humidity sensor over I²C. After that, a second sensor will be read out over the same I²C bus using shared-bus.

Part 1: Reading Temperature & Humidity

Create an instance of the Humidity sensor SHTC3 and read and print the values for humidity and temperature every 600 ms.

i2c-sensor-reading/examples/part_1.rs contains a working solution of Part 1. To run the solution for Part 1:

cargo run --example part_1

i2c-sensor-reading/src/main.rs contains skeleton code, that already contains necessary imports for this part.

Steps:

✅ Go to the i2c-sensor-reading/ folder and open the relevant documentation with the following command:

cargo doc --open

✅ Define two pins, one as SDA and one as SCL.

SignalGPIO
SDAGPIO10
SCLGPIO8

✅ Create an instance of the I²C peripheral with the help of the documentation you generated. Use 400 kHz as frequency.

✅ Use the shtcx driver crate, make an instance of the SHTC3 sensor, passing the I²C instance into them. Check the documentation for guidance.

✅ To check if the sensor is addressed correctly, read its device ID and print the value.

Expected Output:

Device ID SHTC3: 71

✅ Make a measurement, read the sensor values and print them. Check the documentation for guidance on sensor methods.

Expected Output:

TEMP: [local temperature] °C
HUM: [local humidity] %

❗ Some sensors need some time to pass between measurement and reading value. ❗ Watch out for the expected units!

💡 There are methods that turn the sensor values into the desired unit.

Part 2: Reading Accelerometer Data

Using a bus manager, implement the second sensor. Read out its values and print the values from both sensors.

Continue with your own solution from part one. Alternatively, you can start with the provided partial solution of Part 1: i2c-sensor-reading/examples/part_1.rs.

i2c-sensor-reading/examples/part_2.rs contains a working solution of Part 2. You can consult it if you need help, by running:

cargo run --example part_2

Steps

✅ Import the driver crate for the ICM42670p.

#![allow(unused)]
fn main() {
use icm42670::{Address, Icm42670, PowerMode as imuPowerMode};
}

✅ Create an instance of the sensor.

✅ Why does passing the same I²C instance to two sensors not work, despite both being on the same I²C bus?

Answer

This is an ownership issue. Every place in memory needs to be owned by something. If we pass the I²C bus to the SHTC3, the sensor owns the I²C bus. It can't be owned by another sensor. Borrowing is also not possible, because the I²C bus needs to be mutable. Both sensors need to be able to change it. We solve this problem by introducing a bus manager, that creates a number of proxies of the I²C bus. These proxies can then be owned by the respective sensors.

✅ Import the bus manager crate.

#![allow(unused)]
fn main() {
use shared_bus::BusManagerSimple;
}

✅ Create an instance of a simple bus manager. Make two proxies and use them instead of the original I²C instance to pass to the sensors.

✅ Read & print the device ID from both sensors.

Expected Output:

Device ID SHTC3: 71
Device ID ICM42670p: 96

✅ Start the ICM42670p in low noise mode.

✅ Read the gyroscope sensor values and print them with 2 decimal places alongside the temperature and humidity values.

Expected Output:

GYRO: X: 0.00 Y: 0.00 Z: 0:00
TEMP: [local temperature] °C
HUM: [local humidity] %

Simulation

This project is available for simulation through two methods:

  • Wokwi projects
  • Wokwi files are also present in the project folder to simulate it with Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File and choose advanced/i2c-sensor-reading/wokwi.toml
      • Edit the wokwi.toml file to select between exercise and solutions simulation
    2. Build you project
    3. Press F1 again and select Wokwi: Start Simulator

When simulating this project, expect the following hardcoded values: TEMP: 24.61 °C | HUM: 36.65 % | GYRO: X= 0.00 Y= 0.00 Z= 0.00

I²C Driver Exercise - Easy Version

We're not going to write an entire driver, merely the first step: the hello world of driver writing: reading the device ID of the sensor. This version is labelled easy, because we explain the code fragments, and you only have to copy and paste the fragments into the right place. Use this version if you have very little previous experience with Rust, if these workshops are your first in the embedded domain, or if you found the hard version too hard. You can work in the same file with either version.

i2c-driver/src/icm42670p.rs is a gap text of a very basic I²C IMU sensor driver. The task is to complete the file, so that running main.rs will log the device ID of the driver.

i2c-driver/src/icm42670p_solution.rs provides the solution to this exercise. If you want to run it, the imports need to be changed in main.rs and lib.rs. The imports are already there, you only need to comment the current imports out and uncomment the solutions as marked in the line comments.

Driver API

Instance of the Sensor

To use a peripheral sensor first you must get an instance of it. The sensor is represented as a struct that contains both its device address, and an object representing the I²C bus itself. This is done using traits defined in the embedded-hal crate. The struct is public as it needs to be accessible from outside this crate, but its fields are private.

#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct ICM42670P<I2C> {
    // The concrete I²C device implementation.
    i2c: I2C,

    // Device address
    address: DeviceAddr,
}
}

We add an impl block that will contain all the methods that can be used on the sensor instance. It also defines the Error Handling. In this block, we also implement an instantiating method. Methods can also be public or private. This method needs to be accessible from outside, so it's labelled pub. Note that written this way, the sensor instance takes ownership of the I²C bus.

#![allow(unused)]
fn main() {
impl<I2C, E> ICM42670P<I2C>
where
    I2C: i2c::WriteRead<Error = E> + i2c::Write<Error = E>,
{
    /// Creates a new instance of the sensor, taking ownership of the i2c peripheral.
    pub fn new(i2c: I2C, address: DeviceAddr) -> Result<Self, E> {
        Ok(Self { i2c, address })
    }
// ...
}

Device Address

  • The device's addresses are available in the code:
#![allow(unused)]
fn main() {
pub enum DeviceAddr {
    /// 0x68
    AD0 = 0b110_1000,
    /// 0x69
    AD1 = 0b110_1001,
}
}
  • This I²C device has two possible addresses - 0x68 and 0x69. We tell the device which one we want it to use by applying either 0V or 3.3V to the AP_AD0 pin on the device. If we apply 0V, it listens to address 0x68. If we apply 3.3V it listens to address 0x69. You can therefore think of pin AD_AD0 as being a one-bit input which sets the least-significant bit of the device address. More information is available in the datasheet, section 9.3

Representation of Registers

The sensor's registers are represented as enums. Each variant has the register's address as value. The type Register implements a method that exposes the variant's address.

#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
pub enum Register {
    WhoAmI = 0x75,
}

impl Register {
    fn address(&self) -> u8 {
        *self as u8
    }
}

}

read_register() and write_register()

We define a read and a write method, based on methods provided by the embedded-hal crate. They serve as helpers for more specific methods and as an abstraction that is adapted to a sensor with 8-bit registers. Note how the read_register() method is based on a write_read() method. The reason for this lies in the characteristics of the I²C protocol: We first need to write a command over the I²C bus to specify which register we want to read from. Helper methods can remain private as they don't need to be accessible from outside this crate.

#![allow(unused)]
fn main() {
impl<I2C, E> ICM42670P<I2C>
where
    I2C: i2c::WriteRead<Error = E> + i2c::Write<Error = E>,
{
    /// Creates a new instance of the sensor, taking ownership of the i2c peripheral.
    pub fn new(i2c: I2C, address: DeviceAddr) -> Result<Self, E> {
        Ok(Self { i2c, address })
    }
    // ...
    /// Writes into a register
    // This method is not public as it is only needed inside this file.
    #[allow(unused)]
    fn write_register(&mut self, register: Register, value: u8) -> Result<(), E> {
        let byte = value;
        self.i2c
            .write(self.address as u8, &[register.address(), byte])
    }

    /// Reads a register using a `write_read` method.
    // This method is not public as it is only needed inside this file.
    fn read_register(&mut self, register: Register) -> Result<u8, E> {
        let mut data = [0];
        self.i2c
            .write_read(self.address as u8, &[register.address()], &mut data)?;
        Ok(u8::from_le_bytes(data))
    }
}

✅ Implement a public method that reads the WhoAmI register with the address 0x75. Make use of the above read_register() method.

✅ Optional: Implement further methods that add features to the driver. Check the documentation for the respective registers and their addresses. 💡 Some ideas:

  • Switching the gyroscope sensor or the accelerometer on
  • Starting measurements
  • Reading measurements

🔎 General Info About Peripheral Registers

Registers can have different meanings, in essence, they are a location that can store a value.

In this specific context, we are using an external device (since it is a sensor, even if it is on the same PCB). It is addressable by I2C, and we are reading and writing to its register addresses. The addresses each identify a unique location that contains some information. In this case, we want the address for the location that contains the current temperature, as read by the sensor.

You can find the register map of the ICM-42670 in section 14 should you want to try to get other interesting data from this sensor.

Simulation

This project is available for simulation through two methods:

  • Wokwi projects
  • Wokwi files are also present in the project folder to simulate it with Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File and choose advanced/i2c-driver/wokwi.toml
    2. Build you project
    3. Press F1 again and select Wokwi: Start Simulator

Writing an I²C Driver - Hard Version

We're not going to write an entire driver, merely the first step: the hello world of driver writing: reading the device ID of the sensor. This version is labelled hard, because you have to come up with the content of the methods and research information in the embedded-hal and datasheets yourself. You can work in the same file with either version.

i2c-driver/src/icm42670p.rs is a gap text of a very basic I²C IMU sensor driver. The task is to complete the file, so that running main.rs will log the device ID of the driver.

i2c-driver/src/icm42670p_solution.rs provides the solution to this exercise. If you want to run it, the imports need to be changed in main.rs and lib.rs. The imports are already there, you only need to comment the current imports out and uncomment the solutions as marked in the line comments.

Driver API

Instance of the Sensor

✅ Create a struct that represents the sensor. It has two fields, one that represents the sensor's device address and one that represents the I²C bus itself. This is done using traits defined in the embedded-hal crate. The struct is public as it needs to be accessible from outside this crate, but its fields are private.

✅ Implement an instantiating method in the impl block. This method needs to be accessible from outside, so it's labelled pub. The method takes ownership of the I²C bus and creates an instance of the struct you defined earlier.

Device Address

✅ This I²C device has two possible addresses, find them in the datasheet, section 9.3.

🔎 We tell the device which one we want it to use by applying either 0V or 3.3V to the AP_AD0 pin on the device. If we apply 0V, it listens to address 0x68. If we apply 3.3V it listens to address 0x69. You can therefore think of pin AD_AD0 as being a one-bit input which sets the least-significant bit of the device address.

✅ Create an enum that represents both address variants. The values of the variants need to be in binary representation.

Representation of Registers

✅ Create an enum that represents the sensor's registers. Each variant has the register's address as value. For now, you only need the WhoAmI register. Find its address in the datasheet.

✅ Implement a method that exposes the variant's address as u8.

read_register() and write_register()

✅ Check out the write and write_read function in the embedded-hal. Why is it write_read and not just read?

Answer The reason for this lies in the characteristics of the I²C protocol. We first need to write a command over the I²C bus to specify which register we want to read from.

✅ Define a read_register and a write_register method for the sensor instance. Use methods provided by the embedded-hal crate. They serve as helpers for more specific methods and as an abstraction that is adapted to a sensor with 8-bit registers. This means that the data that is written, as well as the data that is read is an unsigned 8-bit integer. Helper methods can remain private as they don't need to be accessible from outside this crate.

✅ Implement a public method that reads the WhoAmI register with the address 0x75. Make use of the above read_register() method.

✅ Optional: Implement further methods that add features to the driver. Check the documentation for the respective registers and their addresses. 💡 Some ideas:

  • Switching the gyroscope sensor or the accelerometer on
  • Starting measurements
  • Reading measurements

🔎 General Info About Peripheral Registers

  • Registers are small amounts of storage, immediately accessible by the processor. The registers on the sensor are 8 bits.
  • They can be accessed by their address
  • You can find register maps in the section 14.
  • Returning a value with MSB and LSB (most significant byte and least significant byte) is done by shifting MSB values, and OR LSB values.
#![allow(unused)]
fn main() {
let GYRO_DATA_X: i16 = ((GYRO_DATA_X1 as i16) << 8) | GYRO_DATA_X0 as i16;
}

Interrupts

An interrupt is a request for the processor to interrupt currently executing so that the event can be processed timely. If the request is accepted, the processor will suspend its current activities, save its state, and execute a function called an interrupt handler to deal with the event. Interrupts are commonly used by hardware devices to indicate electronic or physical state changes that require time-sensitive attention, for example, pushing a button.

The fact that interrupt handlers can be called at any time provides a challenge in embedded Rust: It requires the existence of statically allocated mutable memory that both the interrupt handler and the main code can refer to, and it also requires that this memory is always accessible.

unsafe {} Blocks

This code contains a lot of unsafe {} blocks. As a general rule, unsafe doesn't mean that the contained code isn't memory safe. It means, that Rust can't make safety guarantees in this place and that it is the responsibility of the programmer to ensure memory safety. For example, Calling C Bindings is per se unsafe, as Rust can't make any safety guarantees for the underlying C Code.

Building the Interrupt Handler

The goal of this exercise is to handle the interrupt that fires if the BOOT button is pushed.

You can find a skeleton code for this exercise in advanced/button-interrupt/src/main.rs.

You can find the solution for this exercise in advanced/button-interrupt/examples/solution.rs. You can run it with the following command:

cargo run --example solution

✅ Tasks

  1. Configure the BOOT button (GPIO9), using the PinDriver struct with the following settings:
    • Input mode
    • Pull up
    • Interrupt on positive edge
  2. Instantiate a new notification and notifier
    • See hal::task::notification documentation
  3. In an unsafe block, create a subscription and its callback function.
    • See PinDriver::subscribe and task::notify_and_yield
    • The reasons for being unsafe are:
      • The callback function will run in the ISR (Interrupt Service Routine), so we should avoid calling any blocking functions on it, this includes STD, libc or FreeRTOS APIs (except for a few allowed ones).
      • Callback closure is capturing its environment and you can use static variables inserted onto it. Captured variables need to outlive the subscription. You can also, use non-static variables, but that requires extra caution, see esp_idf_hal::gpio::PinDriver::subscribe_nonstatic documentation for more details.
  4. In the loop, enable the interrupt, and wait for the notification
    • The interruption should be enabled after each received notification, from a non-ISR context
    • esp_idf_svc::hal::delay::BLOCK can be used for waiting
  5. Run the program, push the BOOT button, and see how it works!

🔎 In this exercise we are using notifications, which only give the latest value, so if the interrupt is triggered multiple times before the value of the notification is read, you will only be able to read the latest one. Queues, on the other hand, allow receiving multiple values. See esp_idf_hal::task::queue::Queue for more details.

Simulation

This project is available for simulation through two methods:

  • Wokwi projects
  • Wokwi files are also present in the project folder to simulate it with Wokwi VS Code extension:
    1. Press F1, select Wokwi: Select Config File and choose advanced/button-interrupt/wokwi.toml
      • Edit the wokwi.toml file to select between exercise and solution simulation
    2. Build you project
    3. Press F1 again and select Wokwi: Start Simulator

Random LED Color on Pushing a Button

✅ Modify the code so the RGB LED light changes to a different random color upon each button press. The LED shouldn't go out or change color if the button isn't pressed for some time.

Continue by adding to your previous solution or the code from advanced/button-interrupt/src/main.rs.

You can find the solution for this exercise in advanced/button-interrupt/examples/solution.rs. You can run it with the following command:

cargo run --example solution_led

💡 Solving Help

  • The necessary imports are already made, if you enter cargo --doc --open you will get helpful documentation regarding the LED.
  • The LED's part number is WS2812RMT.
  • It's a programmable RGB LED. This means there aren't single pins to set for red, green and blue, but we need to instantiate it to be able to send RGB8 type values to it with a method.
  • The board has a hardware random number generator. It can be called with esp_random().
  • Calling functions from the esp-idf-svc::sys is unsafe in Rust terms and requires an unsafe() block. You can assume that these functions are safe to use, so no other measures are required.

Step by Step Guide to the Solution

  1. Initialize the LED peripheral and switch the LED on with an arbitrary value just to see that it works.

    #![allow(unused)]
    fn main() {
     let mut led = WS2812RMT::new(peripherals.pins.gpio2, peripherals.rmt.channel0)?;
    
     led.set_pixel(RGB8::new(20, 0, 20)).unwrap(); // Remove this line after you tried it once
    }
  2. Light up the LED only when the button is pressed. You can do this for now by adding the following line after the button pressed message:

    #![allow(unused)]
    fn main() {
    led.set_pixel(arbitrary_color)?;
    }
  3. Create random RGB values by calling esp_random().

    • This function is unsafe.
    • It yields u32, so it needs to be cast as u8.
    #![allow(unused)]
    fn main() {
    unsafe {
    //...
    1 => {
        let r = esp_random() as u8;
        let g = esp_random() as u8;
        let b = esp_random() as u8;
    
        let color = RGB8::new(r, g, b);
        led.set_pixel(color)?;
    
        },
    _ => {},
    }
  4. Optional: If you intend to reuse this code in another place, it makes sense to put it into its own function. This lets us explore, in detail, which parts of the code need to be in unsafe blocks.

#![allow(unused)]
fn main() {
// ...
    loop {
        // Enable interrupt and wait for new notificaton
        button.enable_interrupt()?;
        notification.wait(esp_idf_svc::hal::delay::BLOCK);
        println!("Button pressed!");
        // Generates random rgb values and sets them in the led.
        random_light(&mut led);
    }

// ...
fn random_light(led: &mut WS2812RMT) {
    let mut color = RGB8::new(0, 0, 0);
    unsafe {
        let r = esp_random() as u8;
        let g = esp_random() as u8;
        let b = esp_random() as u8;

        color = RGB8::new(r, g, b);
    }

    led.set_pixel(color).unwrap();
}

}

Reference

GPIO

GPIO is short for General Purpose Input Output. GPIOs are digital (or sometimes analog) signal pins that can be used as interfaces to other systems or devices. Each pin can be in various states, but they will have a default state on power-up or after a system reset (usually a harmless one, like being a digital input). We can then write software to change them into the appropriate state for our needs.

We'll introduce a couple of concepts related to GPIOs:

Pin Configurations

GPIOs can be configured in one of several ways. The options available can vary depending on the design of the chip, but will usually include:

Floating: A floating pin is neither connected to VCC nor Ground. It just floats around at whatever voltage is applied. Note though, that your circuit should externally pull the pin either low or high, as CMOS silicon devices (such as microcontrollers) can fail to work correctly if you leave a pin higher than the 'low voltage threshold' or Vtl, but lower than the 'high voltage threshold' or Vth for more than a few microseconds.

Push-Pull-Output: A pin that is configured as push–pull output can then be set to either drive a high voltage onto the pin (i.e. connect it to VCC), or a low voltage onto the pin (i.e. connect it to Ground). This is useful for LEDs, buzzers or other devices that use small amounts of power.

Open-Drain-Output: Open Drain outputs switch between "disconnected" and "connected to ground." It is expected that some external resistor will weakly pull the line up to VCC. This type of output is designed to allow multiple devices to be connected together - the line is 'low' if any of the devices connected to the line drive it low. If two or more devices drive it low at the same time, no damage occurs (connecting Ground to Ground is safe). If none of them drive it low, the resistor will pull it high by default.

Floating-Input: A pin where the external voltage applied can be read, in software, as either a 1 (usually if the voltage is above some threshold voltage) or a 0 (if it isn't). The same warnings apply per the 'Floating' state.

Pull-Up-Input: Like a Floating-Input, except an internal 'pull-up' resistor weakly pulls the line up to VCC when nothing external is driving it down to Ground. Useful for reading buttons and other switches, as it saves you from needing an external resistor.

Active High/Low

A digital signal can be in two states: high and low. This is usually represented by the voltage difference between the signal and ground. It is arbitrary which of these voltage levels represents which logic states: So both high and low can be defined as an active state.

For example, an active high pin has voltage when the logic level is active, and, an active low pin has voltage when the logic level is set to inactive.

In embedded Rust, abstractions show the logic level and not the voltage level. So if you have an active low pin connected to an LED, you need to set it to inactive for the LED to light up.

Chip Select

Chip Select is a binary signal to another device that can switch that device on or off, either partially or entirely. It is frequently a signal line connected to a GPIO, and commonly used to allow multiple devices to be connected to the same SPI bus - each device only listens when its Chip Select line is active.

Bit Banging

For protocols such as I2C or SPI, we usually use peripherals within the MCU to convert the data we want to transmit into signals. Sometimes, for example, if the MCU doesn't support the protocol or if a non-standard form of the protocol is used, you need to write a program that turns the data into signals manually. This is called bit-banging.