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.
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.
- This approach requires some installation
- This approach assumes that the project is built in debug mode
- This approach allows debugging the project
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-2024-06-30
version.
✅ Install nightly Rust and add support for the target architecture using the following command:
rustup toolchain install nightly-2024-06-30 --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 integrationespflash
- upload firmware to the microcontroller and open serial monitorldproxy
- 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 libudev-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
oresp-idf-sys vX.X.X
:At time of writing, this can be solved by:
- 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
-
Restarting the terminal.
-
If this isn't working, try
cargo clean
, remove the~/.espressif
folder (%USERPROFILE%\.espressif
in Windows) and rebuild your project. -
On Ubuntu, you might need to change your kernel to
5.19
. Rununame -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 includesweb-flash
. Here is how you would flash the build output ofhardware-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
:
Building the image takes a while depending on the OS & hardware (20-30 minutes).docker image build --tag rust-std-training --file .devcontainer/Dockerfile .
- 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 & navigationEven Better TOML
for editing TOML based configuration files
There are a few more useful extensions for advanced usage
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 coursebook/
- markdown sources of this bookcommon/
- code shared between both coursescommon/lib/
- support cratesintro/
- 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 .gitignore
d) 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 withctrl+C
.
🔎
cargo run
is configured to useespflash
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:
- Press F1, select
Wokwi: Select Config File
, and chooseintro/hardware-check/wokwi.toml
. - Build your project.
- Press F1 again and select
Wokwi: Start Simulator
.
- Press F1, select
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:
- Press and hold boot button on the board, start flash command, release boot button after flashing process starts
- Use a hub.
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 actualunsafe
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 overrideESP_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-2024-06-30" # 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 onConnecting...
, you might have another monitor process still running (e.g. from the initialhardware-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 overridehttps
GitHub URLs tossh
. Check your global~/.git/config
forinsteadOf
sections and disable them.
yield control back to the underlying operating system by sleep
ing 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:
- Press F1, select
Wokwi: Select Config File
and chooseintro/http-client/wokwi.toml
- Edit the
wokwi.toml
file to select between exercise and solution simulation
- Edit the
- Build you project
- Press F1 again and select
Wokwi: Start Simulator
- Press F1, select
Troubleshooting
missing WiFi name/password
: ensure that you've configuredcfg.toml
according tocfg.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 configuredcfg.toml
according tocfg.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 oftenhttp://espressif/
instead ofhttp://<sta ip>/
will also work.You can change the hostname by setting
CONFIG_LWIP_LOCAL_HOSTNAME
insdkconfig.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 a404
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, orcargo 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 topichome/#
- the hash character is used as multi-level wildcard and thus subscribes to every topic starting withhome/
-home/garage/temperature
,home/front door/lock
andhome/alarm/enable
would all match, butbeacons/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 tohome/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.
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 connectiontemperature_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. Theexample-client
has adbg!()
output at the start of the program, that showsmqtt
configuration. It should output the content of yourcfg.toml
file. error: expected expression, found .
while running the host-client can be solved withrustup 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 topiccolor_topic(uuid)
and theBoardLed
- It can convert the
data()
field of anEspMqttMessage
by usingtry_from()
. The message needs first to be coerced into a slice, usinglet 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 EspMqttEvent
, which has a payload()
method to access the EventPayload
Since we're only interested in processing successfully received messages:
#![allow(unused)] fn main() { let mut client = EspMqttClient::new_cb( &broker_url, &mqtt_config, move |message_event| match message_event.payload() { Received { data, details, .. } => process_message(data, details, &mut led), Error(e) => warn!("Received error from MQTT: {:?}", e), _ => info!("Received from MQTT: {:?}", message_event.payload()), }, )?; }
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() { fn process_message(data: &[u8], details: Details, led: &mut WS2812RMT) { match details { Complete => { info!("{:?}", data); let message_data: &[u8] = data; if let Ok(ColorData::BoardLed(color)) = ColorData::try_from(message_data) { info!("{}", color); if let Err(e) = led.set_pixel(color) { error!("Could not set board LED: {:?}", e) }; } } _ => {} } } }
💡 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) }
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 actualunsafe
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-independentembedded-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:
Step | Controller Sends | Peripheral 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:
Peripheral | Part number | Reference | Crate | Address |
---|---|---|---|---|
IMU | ICM-42670-P | Datasheet | Link | 0x68 |
Temperature and Humidity | SHTC3 | Datasheet | Link | 0x70 |
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.
Signal | GPIO |
---|---|
SDA | GPIO10 |
SCL | GPIO8 |
✅ 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:
- Press F1, select
Wokwi: Select Config File
and chooseadvanced/i2c-sensor-reading/wokwi.toml
- Edit the
wokwi.toml
file to select between exercise and solutions simulation
- Edit the
- Build you project
- Press F1 again and select
Wokwi: Start Simulator
- Press F1, select
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
and0x69
. We tell the device which one we want it to use by applying either0V
or3.3V
to theAP_AD0
pin on the device. If we apply0V
, it listens to address0x68
. If we apply3.3V
it listens to address0x69
. You can therefore think of pinAD_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:
- Press F1, select
Wokwi: Select Config File
and chooseadvanced/i2c-driver/wokwi.toml
- Build you project
- Press F1 again and select
Wokwi: Start Simulator
- Press F1, select
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
- Configure the BOOT button (GPIO9), using the
PinDriver
struct with the following settings:- Input mode
- Pull up
- Interrupt on positive edge
- Instantiate a new notification and notifier
- See
hal::task::notification
documentation
- See
- In an
unsafe
block, create a subscription and its callback function.- See
PinDriver::subscribe
andtask::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.
- The callback function will run in the ISR (Interrupt Service Routine), so we should avoid calling any blocking functions on it, this includes STD,
- See
- 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
- 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
- Exercise
- Solution
- The Solution project contains solution for Random LED Color on pushinig a Button
- Wokwi files are also present in the project folder to simulate it with Wokwi VS Code extension:
- Press F1, select
Wokwi: Select Config File
and chooseadvanced/button-interrupt/wokwi.toml
- Edit the
wokwi.toml
file to select between exercise and solution simulation
- Edit the
- Build you project
- Press F1 again and select
Wokwi: Start Simulator
- Press F1, select
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 anunsafe()
block. You can assume that these functions are safe to use, so no other measures are required.
Step by Step Guide to the Solution
-
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 }
-
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)?; }
-
Create random RGB values by calling
esp_random()
.- This function is
unsafe
. - It yields
u32
, so it needs to be cast asu8
.
#![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)?; }, _ => {}, }
- This function is
-
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.