Introduction

Content of this material

The goal of this book is to provide a getting-started guide on using the Rust programming language with Espressif SoCs and modules using no_std (bare metal) approach. To better understand this approach, see Developing on Bare Metal (no_std) chapter of The Rust on ESP Book.

The introductory trail will introduce you to the basics of embedded development and how to make the embedded board interact with the outside world by reacting to a button press, and lighting an LED.

Note that there are several examples covering the use of specific peripherals under the examples folder of esp-hal. See esp-hal/examples

If you would like to learn about std development, see Using the Standard Library (std) chapter of The Rust on ESP Book and, Embedded Rust on Espressif training.

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

The board

Examples shown here usually apply to ESP32-C3 using the ESP32-C3-DevKit-RUST-1 board.

You can use any SoC supported by no_std but smaller code and configuration changes might be needed.

Rust knowledge

  • Basic Rust like The Rust Book Chapters 1 - 6, Chapter 4 Ownership, does not need to be fully understood.
  • The Rust on ESP Book is not 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.

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, details that require special attention.
  • 🔎 Knowledge that dives deeper into a subject but which you are not 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.

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 to 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 Installation 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 --target riscv32imc-unknown-none-elf

🔎 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.

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

✅ Install them with the following command:

cargo install cargo-espflash espflash

⚠️ 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

macOS

When using the Homebrew package manager, which we recommend:

brew install llvm

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 is not 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 hello-world project:
    web-flash --chip esp32c3 target/riscv32imc-unknown-none-elf/release/hello-world
    

✅ 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:

docker run --mount type=bind,source="$(pwd)",target=/workspace,consistency=cached -it rust-std-training /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 & Devcontainer

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/no_std-training.

✅ Clone and change into the workshop repository:

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

❗ Windows users may have problems with long path names. Follow these steps to substitute the path:

git clone https://github.com/esp-rs/no_std-training.git
subst r: no_std-trainings
cd r:\

Repository contents

  • book/: markdown sources of this book
  • intro/: code examples and exercises for the introduction course

Hello World

The hello-world example is basically a project generated with esp-template. Templates are already covered in The Rust on ESP Book, see Generating Projects from Templates chapter for more details on how to generate a project from the esp-template, and Understanding esp-template for detail on what is inside the template project.

Since we already have the code for this example, let's use it to do a consistency check!

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

cd intro/hello-world

✅ Build, flash, and monitor the project:

$ cargo run
(...)
Finished release [optimized] target(s) in 1.78s
(...)
Chip type:         esp32c3 (revision v0.3)
Crystal frequency: 40MHz
Flash size:        4MB
Features:          WiFi, BLE
MAC address:       60:55:f9:c0:39:7c
App/part. size:    210,608/4,128,768 bytes, 5.10%
[00:00:00] [========================================]      13/13      0x0
[00:00:00] [========================================]       1/1       0x8000
[00:00:01] [========================================]      67/67      0x10000
[00:00:01] [========================================]      67/67      0x10000
[2023-07-07T08:16:32Z INFO ] Flashing has completed!
Commands:
    CTRL+R    Reset chip
    CTRL+C    Exit
(...)
(...)
Hello world!

🔎 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-unknown-none-elf/release/hello_world This modification is applied to all the projects in the training for convenience.

💡 By default espflash will use a baud-rate of 115200 which is quite conservative. An easy way to increase the baud-rate is setting the environment variable ESPFLASH_BAUD to e.g. 921600

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/hello-world/wokwi.toml.
    2. Build your project.
    3. Press F1 again and select Wokwi: Start Simulator.

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. The content includes:

  • A panic example
  • A blinky example
  • A button example
  • A button with interrupt example

Preparations

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

Panic!

When something goes terribly wrong in Rust there might occur a panic.

Setup

✅ Go to intro/panic directory.

✅ Open the prepared project skeleton in intro/panic.

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

cargo doc --open

intro/panic/examples/panic.rs contains the solution. You can run it with the following command:

cargo run --example panic

Exercise

✅ In main.rs add a panic! somewhere, e.g. after our println

✅ Run the code

cargo run

We see where the panic occurred, and we even see a backtrace!

While in this example things are obvious, this will come in handy in more complex code.

✅ Now try running the code compiled with release profile.

cargo run --release

Now things are less pretty:

!! A panic occured in 'examples\panic.rs', at line 15, column 5:
This is a panic

Backtrace:

0x42000100
0x42000100 - _start_rust
    at ??:??

We still see where the panic occurred, but the backtrace is less helpful now.

That is because the compiler omitted debug information and optimized the code, you might have noticed the difference in the size of the flashed binary.

Generally you want to use release always. To get a more helpful backtrace when using the release profile you can add this to your .cargo/config.toml

[profile.release]
debug = true

This will include debug information in the ELF file - but that won't get flashed to the target so it's something you can and should always use.

If you are reusing this project for other exercises, be sure to remove the line causing the explicit panic.

Simulation

This project is available for simulation through two methods:

  • Wokwi projects:
    • Exercise: Currently not available
    • Solution
  • 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/panic/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

Blinky

Let's see how to create the iconic Blinky.

Setup

✅ Go to intro/blinky directory.

✅ Open the prepared project skeleton in intro/blinky.

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

cargo doc --open

intro/blinky/examples/blinky.rs contains the solution. You can run it with the following command:

cargo run --release --example blinky

Exercise

On ESP32-C3-DevKit-RUST-1 there is a regular LED connected to GPIO 7. If you use another board consult the data-sheet.

Note that most of the development boards from Espressif today use an addressable LED which works differently and is beyond the scope of this book. In that case, you can also connect a regular LED to some of the free pins (and don't forget to add a resistor).

✅ Initiate the IO peripheral, and create a led variable from GPIO connected to the LED, using the into_push_pull_output function.

Here we see that we can drive the pin high, low, or toggle it.

We also see that the HAL offers a way to delay execution.

✅ Initialize a Delay instance.

✅ Using the toogle() and delay_ms() methods, make the LED blink every 500 ms.

Simulation

This project is available for simulation through two methods:

  • Wokwi projects:
    • Exercise: Currently not available
    • Solution
  • 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/blinky/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

Detect a button press

We are now going to make the LED ligth only when we press a button, we will create a project that reads the state of the button GPIO and reacts to its state.

Setup

✅ Go to intro/button directory.

✅ Open the prepared project skeleton in intro/button.

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

cargo doc --open

intro/button/examples/button.rs contains the solution. You can run it with the following command:

cargo run --release --example button

Exercise

Most of the dev-boards have a button, in our case, we will use the one labeled BOOT on GPIO9.

✅ Initiate the IO peripheral, and create variable for the LED and button, the LED can be created using the into_push_pull_output function as before while the button can be obtained using into_pull_up_input function.

Similarly to turning a GPIO into an output we can turn it into an input. Then we can get the current state of the input pin with is_high and similar functions.

✅ In the loop, add some logic so if the button is not pressed, the LED is lit. If the button is pressed, the LED is off.

Simulation

This project is available for simulation through two methods:

  • Wokwi projects:
    • Exercise: Currently not available
    • Solution
  • 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/button/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

Detect a button press with interrupt

Now, instead of polling the button pin, we will use interrupts. Interrupts offer a mechanism by which the processor handles asynchronous events and fatal errors.

Setup

✅ Go to intro/button-interrupt directory.

✅ Open the prepared project skeleton in intro/button-interrupt.

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

cargo doc --open

intro/button-interrupt/examples/button-interrupt.rs contains the solution. You can run it with the following command:

cargo run --release --example button-interrupt

Exercise

Inspecting the code, the first thing we notice is the static BUTTON. We need it since in the interrupt handler we have to clear the pending interrupt on the button and we somehow need to pass the button from main to the interrupt handler.

Since an interrupt handler can't have arguments we need a static to get the button into the interrupt handler.

We need the Mutex to make access to the button safe.

Please note that this is not the Mutex you might know from libstd but it's the Mutex from critical-section (and that's why we need to add it as a dependency).

✅ We need to set the interrupt handler for the GPIO interrupts.

✅ Let's add a critical-section, using the with() method and enable an interrupt for falling edges:

    critical_section::with(|cs| {
        button.listen(Event::FallingEdge);
        BUTTON.borrow_ref_mut(cs).replace(button)
    });

In this line we move our button into the static BUTTON for the interrupt handler to get hold of it.

The code running inside the critical_section::with closure runs within a critical section, cs is a token that you can use to "prove" that to some API.

The interrupt handler is defined via the #[handler] macro. Here, the name of the function must match the interrupt.

Simulation

This project is available for simulation through two methods:

  • Wokwi projects:
    • Exercise: Currently not available
    • Solution
  • 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/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

Direct Memory Access (DMA)

The DMA peripheral is used to perform memory transfers in parallel to the work of the processor (the execution of the main program).

In this chapter you will learn how to use DMA with esp-hal. For the example we are going to use the SPI peripheral.

Setup

✅ Go to intro/dma directory.

✅ Open the prepared project skeleton in intro/dma.

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

cargo doc --open

intro/dma/examples/dma.rs contains the solution. You can run it with the following command:

cargo run --release --example dma

Exercise

The project skeleton contains code to transfer a small amount of data via SPI. To make it easy to explore the example you can connect GPIO4 and GPIO2 - this way the data we send is also the data we receive.

The blocking SPI transfer looks like this

        // To transfer much larger amounts of data we can use DMA and
        // the CPU can even do other things while the transfer is in progress
        let mut data = [0x01u8, 0x02, 0x03, 0x04];
        spi.transfer(&mut data).unwrap();

The data array in this case serves as the data to transmit as well as the buffer to receive data.

✅ First thing we need to use DMA is initializing the DMA peripheral driver and getting a channel.

We also need to create a buffer for data we want to send as well as a separate buffer for the data we will receive.

    // we need to create the DMA driver and get a channel
    let dma = Dma::new(peripherals.DMA);
    let dma_channel = dma.channel0;

    // DMA transfers need descriptors and buffers
    let (mut tx_buffer, mut tx_descriptors, mut rx_buffer, mut rx_descriptors) = dma_buffers!(3200);

There are also descriptors needed. That is because internally the DMA peripheral uses a linked list for the transfer and that is what the descriptors are needed for. For convenience we use the dma_buffers! macro to create the buffers and descriptors.

🔎 You could use cargo expand or Rust Analyzer's Expand macro recursively command to see what code the macro expands to

✅ Next we need to configure the SPI peripheral driver to use DMA

We need to call .with_dma passing a configured DMA channel. To configure a DMA channel we call configure to enable burst-mode, pass the descriptors and set the priority.

    // we can call `.with_dma` on the SPI driver to make it use DMA
    let mut spi = Spi::new(peripherals.SPI2, 100u32.kHz(), SpiMode::Mode0, &clocks)
        .with_pins(Some(sclk), Some(mosi), Some(miso), Some(cs))
        .with_dma(dma_channel.configure(
            false,
            &mut tx_descriptors,
            &mut rx_descriptors,
            DmaPriority::Priority0,
        ));

✅ Now we are ready to start a DMA enabled SPI transfer

Now we need to pass the buffers to transmit and receive individually. Please note that we now get a DMA transfer from calling transmit.

        // `dma_transfer` will move the driver and the buffers into the
        // returned transfer.
        let transfer = spi.dma_transfer(&mut tx_buffer, &mut rx_buffer).unwrap();

What happens here is that the buffers and the SPI driver are moved into the Transfer we get. This way the buffers and the driver are inaccessible during the transfer.

Now we are free to let the CPU do other things while the SPI transfer is in progress.

✅ Wait for the transfer to complete and get back the buffers and the driver instance

As mentioned before the buffers and the driver are moved into the Transfer.

        // the buffers and spi are moved into the transfer and
        // we can get it back via `wait`
        // if the transfer isn't completed this will block
        transfer.wait().unwrap();

We call wait on the Transfer. It will block until the transfer is done and we get our SPI driver and the buffers.

While using DMA needs more effort than letting the CPU do all the work it's not too complex once the general idea is understood.

⚠️ You might assume that it's always preferable to use DMA.

That's not the case: Setting up a DMA transfer consumes more CPU cycles than setting up a blocking transfer. Especially if the amount of data to transfer is small this might hurt performance a lot. Also, if there is nothing to do for the CPU other than waiting for the transfer to finish it's preferable to use a blocking transfer.

However, if the amount of data to transfer is bigger, then the time needed to setup the transfer is negligible compared to the time the CPU could use to do useful things in parallel.

HTTP Client

Next, we'll write a small client that retrieves data over an HTTP connection to the internet.

For demonstration purposes we implement the http client ourselves. Usually you want to use e.g. reqwless or edge-net

Before jumping to the exercise, let's explore how Wi-Fi works in no_std Rust for Espressif devices.

Wi-Fi Ecosystem

Wi-Fi support comes in the esp-wifi crate. The esp-wifi is home to the Wi-Fi, Bluetooth and ESP-NOW driver implementations for no_std Rust. Check the repository README for current support, limitations and usage details.

There are some other relevant crates, on which esp-wifi depends on:

  • smol-tcp: Event-driven TCP/IP stack implementation.
    • It does not require heap allocation (which is a requirement for some no_std projects)
    • For more information about the crate, see the official documentation

Additionally when using async, embassy-net is relevant.

Setup

✅ Go to intro/http-client directory.

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

✅ Add your network credentials: Set the SSID and PASSWORD environment variables.

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

cargo run --release --example http-client

✅ Read the Optimization Level section of the esp-wifi README.

Exercise

✅ Bump the clock frequency at which the target operates to its maximum. Consider using ClockControl::configure or ClockControl::max

✅ Create a timer and initialize the Wi-Fi

    let timer = SystemTimer::new(peripherals.SYSTIMER).alarm0;
    let init = initialize(
        EspWifiInitFor::Wifi,
        timer,
        Rng::new(peripherals.RNG),
        system.radio_clock_control,
        &clocks,
    )
    .unwrap();

✅ Configure Wi-Fi using Station Mode

    let wifi = peripherals.WIFI;
    let mut socket_set_entries: [SocketStorage; 3] = Default::default();
    let (iface, device, mut controller, sockets) =
        create_network_interface(&init, wifi, WifiStaDevice, &mut socket_set_entries).unwrap();

✅ Create a Client with your Wi-Fi credentials and default configuration. Look for a suitable constructor in the documentation.

    let client_config = Configuration::Client(ClientConfiguration {
    ....
    });

    let res = controller.set_configuration(&client_config);
    println!("Wi-Fi set_configuration returned {:?}", res);

✅ Start the Wi-Fi controller, scan the available networks, and try to connect to the one we set.

    controller.start().unwrap();
    println!("Is wifi started: {:?}", controller.is_started());

    println!("Start Wifi Scan");
    let res: Result<(heapless::Vec<AccessPointInfo, 10>, usize), WifiError> = controller.scan_n();
    if let Ok((res, _count)) = res {
        for ap in res {
            println!("{:?}", ap);
        }
    }

    println!("{:?}", controller.get_capabilities());
    println!("Wi-Fi connect: {:?}", controller.connect());

    // Wait to get connected
    println!("Wait to get connected");
    loop {
        let res = controller.is_connected();
        match res {
            Ok(connected) => {
                if connected {
                    break;
                }
            }
            Err(err) => {
                println!("{:?}", err);
                loop {}
            }
        }
    }
    println!("{:?}", controller.is_connected());

✅ Then we obtain the assigned IP

    // Wait for getting an ip address
    let wifi_stack = WifiStack::new(iface, device, sockets, current_millis);
    println!("Wait to get an ip address");
    loop {
        wifi_stack.work();

        if wifi_stack.is_iface_up() {
            println!("got ip {:?}", wifi_stack.get_ip_info());
            break;
        }
    }

If the connection succeeds, we proceed with the last part, making the HTTP request.

By default, only unencrypted HTTP is available, which limits our options of hosts to connect to. We're going to use www.mobile-j.de/.

To make an HTTP request, we first need to open a socket, and write to it the GET request,

✅ Open a socket with the following IPv4 address 142.250.185.115 and port 80. See IpAddress::Ipv4 documentation.

write the following message to the socket and flush it: b"GET / HTTP/1.0\r\nHost: www.mobile-j.de\r\n\r\n"

✅ Then we wait for the response and read it out.

        let wait_end = current_millis() + 20 * 1000;
        loop {
            let mut buffer = [0u8; 512];
            if let Ok(len) = socket.read(&mut buffer) {
                let to_print = unsafe { core::str::from_utf8_unchecked(&buffer[..len]) };
                print!("{}", to_print);
            } else {
                break;
            }

            if current_millis() > wait_end {
                println!("Timeout");
                break;
            }
        }
        println!();

✅ Finally, we will close the socket and wait

        socket.disconnect();

        let wait_end = current_millis() + 5 * 1000;
        while current_millis() < wait_end {
            socket.work();
        }

Simulation

This project is available for simulation through two methods:

  • Wokwi projects:
    • Exercise: Currently not available
    • Solution: Currently not available
  • 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

defmt

In this chapter, we will cover defmt, a highly efficient logging framework, and how to use it in the no_std environment.

defmt Ecosystem

esp-println, esp-backtrace and espflash/cargo-espflash provide mechanisms to use defmt:

  • espflash has support for different logging formats, one of them being defmt.
    • espflash requires framming bytes as when using defmt it also needs to print non-defmt messages, like the bootloader prints.
      • It's important to note that other defmt-enabled tools like probe-rs won't be able to parse these messages due to the extra framing bytes.
    • Uses rzcobs encoding
  • esp-println has a defmt-espflash feature, which adds framming bytes so espflash knows that is a defmt message.
  • esp-backtrace has a defmt feature that uses defmt logging to print panic and exception handler messages.

Setup

✅ Go to intro/defmt directory.

✅ Open the prepared project skeleton in intro/defmt.

intro/defmt/examples/defmt.rs contains the solution. You can run it with the following command:

cargo run --release --example defmt

Exercise

✅ Make sure the defmt-espflash feature of esp-println is enabled.

✅ Make sure the defmt feature of esp-backtrace is enabled.

✅ Update the linking process in the .cargo/config.toml.

✅ Make sure, the defmt crate is added to the dependencies.

✅ Make sure you are building esp_println and esp_backtrace

use esp_backtrace as _;
use esp_println as _;

✅ Use the defmt::println! or any of the logging defmt macros to print a message.

  • If you want to use any of the logging macros like info, debug
    • Enable the log feature of esp-println
    • When building the app, set DEFMT_LOG level.

✅ Add a panic! macro to trigger a panic with a defmt message.

Advanced Examples

In this chapter you will learn about more advanced features and concepts.

Stack Overflow Detection

Rust is well known for its memory safety. Whenever possible the compiler enforces memory safety at compile.

However, the situation is different in regards to the stack memory. It's impossible to check this at compile time and even at runtime this can be difficult.

The stack is usually placed at the top of the available memory and grows from top (high addresses) to bottom (low addresses).

On desktop operating systems there are measures to prevent overflowing the stack. Also, an RTOS might include mechanisms to check for stack overflows.

In bare-metal however there is no common way to implement stack protection.

On some platforms it's done by moving the stack to the start of the RAM so that when the stack grows above its bounding an access fault will occur. We cannot do that because on our chips there is the flash/ext-mem cache at the start of RAM which we definitely shouldn't touch.

🔎 On ESP32-C6/ESP32-H2 cache is not located in the start of RAM which means we can move the stack there. esp-hal offers the feature flip-link which will do that and you get stack-overflow protection "for free".

🔎 esp-hal also supports stack smashing protection for all targets which in our case can also double as a simple stack overflow detector. While the overhead is very small, there is some run-time cost involved.

To enable it you need a nightly compiler and add "-Z", "stack-protector=all", to rustflags in .cargo/config.toml

Some of our chips (including ESP32-C3) include the debug-assist peripheral.

This peripheral can monitor the stack-pointer and detect read and/or write access to specified memory areas.

We could just use the stack-pointer monitoring which will work well as long as we don't use esp-wifi.

The reason we cannot use that with esp-wifi is that it runs multiple tasks by quickly switching between them which includes switching stacks. In that case the stack bounds check will trigger as soon as we switch the running task for the first time.

What we can do however is defining a protected area at the bottom of the stack and detect read and write access to it. As soon as the stack grows into this area, we will detect this.

It is important to define this area larger (ideally twice) than the largest stack allocation we expect. Otherwise, it's possible that code will start writing to memory below the stack - possibly overwriting sensitive static data or even code residing in RAM before we can detect access to the monitored memory area.

For X86 LLVM supports probestack which would allow us to use a smaller safe-area. However, this feature currently isn't available for our target platforms.

We can also test for the current stack usage by temporarily increasing the safe area until we see the stack memory protection trigger.

Setup

✅ Go to advanced/stack-overflow-detection directory.

✅ Open the prepared project skeleton in advanced/stack-overflow-detection.

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

cargo doc --open

✅ Run the code

cargo run

You will see the application crash with an Illegal instruction exception. This is because the recursive function is placed in RAM. If you change it to run from flash you won't see a crash but the application will just freeze after printing a weird counter number.

In this case it's easy to guess the cause of this behavior however in a real world application you probably won't know what exactly happened.

advanced/stack-overflow-detection/examples/stack-overflow-detection.rs contains the solution. You can run it with the following command:

cargo run --release --example stack-overflow-detection

Exercise

✅ Create a function which will set up the safe memory area and enables the appropriate interrupt

The function will take the DebugAssist peripheral driver and the size of the safe-area. It should move the DebugAssist into a static variable.

The resulting function should look like this

static DA: Mutex<RefCell<Option<DebugAssist>>> = Mutex::new(RefCell::new(None));

fn install_stack_guard(mut da: DebugAssist<'static>, safe_area_size: u32) {
    extern "C" {
        static mut _stack_end: u32;
        static mut _stack_start: u32;
    }
    let stack_low = unsafe { (&mut _stack_end as *mut _ as *mut u32) as u32 };
    let stack_high = unsafe { (&mut _stack_start as *mut _ as *mut u32) as u32 };
    println!(
        "Safe stack {} bytes",
        stack_high - stack_low - safe_area_size
    );
    da.enable_region0_monitor(stack_low, stack_low + safe_area_size, true, true);

    critical_section::with(|cs| DA.borrow_ref_mut(cs).replace(da));
}

There is quite a lot going on here but most of this is setting up the interrupt. You should recognize most of this from the interrupt exercise in the previous chapter.

The most interesting part is probably da.enable_region0_monitor(stack_low, stack_low + safe_area_size, true, true). This actually configures the region to monitor as well as setting it up to trigger on reads and writes to that region.

Another interesting part here is how we can get the top and bottom address of the stack from symbols created by the linker script.

✅ Create the interrupt handler

As you probably remember from the introduction to interrupts we can define the interrupt handler by using the #[interrupt] attribute macro. The name of the function needs to match the name of the interrupt.

#[handler(priority = esp_hal::interrupt::Priority::min())]
fn interrupt_handler() {
...

Next, we need to get access to the debug assist peripheral driver which we stored in the static variable.

We need it to get the address where the access to the monitored memory region happened. Printing this address will enable espflash to print the name of the function. Similar to how stack traces are printed.

We can also clear the pending interrupt and disable region monitoring here. It's not strictly needed since we won't return from the interrupt handler.

It is unfortunately not possible to generate a stack trace here since the stack is not in a correct state and we don't know the stack frame from which we can start generating the backtrace.

The whole function should look like this

#[handler(priority = esp_hal::interrupt::Priority::min())]
fn interrupt_handler() {

    critical_section::with(|cs| {
        println!("\n\nPossible Stack Overflow Detected");
        let mut da = DA.borrow_ref_mut(cs);
        let da = da.as_mut().unwrap();

        if da.is_region0_monitor_interrupt_set() {
            let pc = da.get_region_monitor_pc();
            println!("PC = 0x{:x}", pc);
            da.clear_region0_monitor_interrupt();
            da.disable_region0_monitor();
            loop {}
        }
    });
}