Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

About this tutorial

This is an embedded-rust tutorial using the esp32-c6 board.

Rust knowledge is assumed, but no embedded experience is required. The content was written for Unix (Linux, MacOS).

The book aims to be self-contained, but short reading material will be suggested. If the license allows for, we will copy useful content here, and provide the source.

Required Hardware

The esp32-c6 board is available on Mouser, Aliexpress and other retailers. It should look similar to:

Other required hardware:

  • USB-C cable to link your computer and the board.

    • The cable has to support data transfers and not be power-only.
  • One resistor, 2 jumper wires, one LED, one breadboard. These are needed for Blinky and Handle Input chapters.

Workshop repository

The source for the book and code-exercises is at https://github.com/iampi31415/iampi31415.github.io. To get started, clone repo and then change directory:

git clone https://github.com/iampi31415/iampi31415.github.io
cd iampi31415.github.io

Repository contents. The directory contains:

  • book/: markdown sources of this book
  • exercises/: code examples and exercises

Suggested reading

  • esp32-c6 user guide: you can find the labelled components, list of peripherals, and schematics.

Definitions

Board, components and peripherals

The board, as shown earlier, is:

The board then, is the whole item. Its main parts are:

  • The printed circuit board (PCB): the flat fiberglass sheet underneath it all.
  • The components: modules on top of the PCB. They interconnect with traces which are thin copper wires. Examples of components: MCU, USB ports LEDs, Pins.
    • The microcontroller or microcontroller unit (MCU): the big square labelled ESP32-C6-WROOM (or some variant). It takes almost half the board. The MCU includes peripherals, CPU and RAM.
      • The peripherals are parts of the MCU including: GPIO, UART, I2C, SPI, WiFi, Bluetooth and others.

A nice explanation of how it all comes together is in the micro:bit v2 book:

Our MCU1 has 73 tiny metal pins sitting right underneath it (it’s a so called aQFN73 chip). These pins are connected to traces, the little “roads” that act as the wires connecting components together on the board. The MCU can dynamically alter the electrical properties of the pins. This works similarly to a light switch, altering how electrical current flows through a circuit. By enabling or disabling electrical current to flow through a specific pin, an LED attached to that pin (via the traces) can be turned on and off.

What is no_std?

This tutorial is “no_std”, but what does it mean? std can refer to:

  • std: the crate
  • std: the component, including the crates std, core, alloc among others.

This book distinguishes them precisely by adding the words crate and component, respectively.

The microcontroller has no Operative System, but std crate relies on one. Hence, #[no_std] is used in main.rs to indicate that std crate should be excluded.

To be precise alloc is excluded by default but can be added. A comparison is provided by this the table below, copied from the Rust Embedded Guide:

featureno_stdstd
heap (dynamic memory)*
collections (Vec, BTreeMap, etc)**
stack overflow protection
runs init code before main
libstd available
libcore available
writing firmware, kernel, or bootloader code

* Only if you use the alloc crate and use a suitable allocator like esp-alloc.

** Only if you use the collections crate and configure a global default allocator.

** HashMap and HashSet are not available due to a lack of a secure random number generator.


  1. Refers to a different MCU than ours but the concept is the same.

Hardware

Now it’s time to connect the host computer with the development board.

  1. Plug one end of the USB to the computer’s USB port.
  2. Plug the other end to the board’s esp32c6-tagged USB port (or you may see a USB tag.)
  3. A tiny red control LED may light up (if the board is brand new.)

Note

The esp32-c6 also has an extra USB port, which is UART or ch343-tagged. ch343 is used to communicate with the UART peripheral inside the MCU. Both ports can be used for flashing and both can be monitored.

List the USB ports with lsusb | grep usb. If lsusb is unavailable with ls /dev/tty*usb*. Examples:

  • With ls

    ls /dev/tty*usb*
    # /dev/tty.usbmodem101
    
  • With lsusb

    lsusb | grep JTAG
    # Bus (...) USB JTAG/serial debug unit (...)
    
esp32-c6 usb port connected to usb port in laptop.

Linking board and laptop through USB-C

Now that the board is wired to our laptop, and registered by our OS, let’s set up the needed software.

Software

Let’s install all we need.

Rust Tools

  1. Install the Rust toolchain following the steps in https://rustup.rs/

    • The Rust Toolchain is rustup plus many components cargo, rustc, the compiled std component and so on.
  2. Add the compiled std-component for our target microcontroller

    rustup target add riscv32imac-unknown-none-elf
    
  3. Add development components:

    rustup component add rust-analyzer rust-src 
    

    rust-src is the std-component’s source code, used by rust-analyzer.

Espressif tools

espflash is used for flashing our ELF binary (our program) into the board.

Install espflash with: cargo install espflash

Build dependencies

  • Debian/Ubuntu.

    sudo apt install llvm-dev libclang-dev clang
    
  • MacOS. When using the Homebrew package manager, which we recommend:

    brew install llvm
    

Additional Software

  • Editor: Neovim, Zed, VSCode, Helix.
  • Rust Analyzer LSP, for code completion, formatting.
  • Even Better TOML LSP, for editing TOML based configuration files

Hello World

There is a lot to learn, so let’s start simple.

  1. Connect the board to your computer and access the first project:

    cd exercises/hello_world
    
  2. Build, flash, and monitor the project with cargo run. You should see:

    ..
    0x4080082e - handle_interrupts
    at ??:??
    ..
    Commands:
        CTRL+R    Reset chip
        CTRL+C    Exit
    ..
    loop..!
    ..
    

    We will talk about the at ??:?? in the next chapter about panic!.

    The loop..! logs come from esp-println. They provide us with dbg!, print! and println!.

Build, flash and monitor?

The .cargo/config.toml includes this config:

[target.riscv32imac-unknown-none-elf] # our processor arch.
runner = "espflash flash --monitor"   # for `cargo run`.

Our cargo run is replaced by cargo build && espflash flash <elf_path> --monitor.

Note

This builds and flashes the binary. Then monitors for any logs. Without the --monitor it flashes and exits instead of waiting and printing logs.

Exercise

esp-println supports backends log and defmt which provide info!, warn! other macros. Let’s try adding them:

  1. Uncomment the lines in src/main.rs and in Cargo.toml to enable log.
  2. The log crate logging level is controlled with ESP_LOG under the [env] section in .cargo/config.toml.
    • Change the ESP_LOG variable to turn off all logs. Re-run cargo run --release, to test how it works.

    • Try with other levels, for example, with trace.

The exercises/hello_world/examples/hello_world.rs contains a solution.

You can run it with the following command cargo run --release --example hello_world. You should first fix the lines at the bottom of Cargo.toml.

Suggested Reading

  • esp-println
  • log this one you can just peek at to have a general idea.

Panic

If something goes wrong, the program will panic!. In embedded, we will always need to add a panic-handler.

esp-backtrace provides a panic-handler feature that handles panic!. This is why we need to use esp-backtrace as _.

Let’s access the project at exercises/panic, and modify the code to test panic!.

We will also take a look at compilation profiles. We don’t need deep knowledge about profiles, just the very basics.

cargo uses profile-settings to control compilation using defaults when unspecified. We are interested in controlling a few aspects:

  • Should it be as fast as possible? As short as possible?
  • Should it include information useful for debugging?

Exercise: part 1

  1. In main.rs use the esp-backtrace crate.

  2. Then add a panic! somewhere, e.g. after our println.

  3. Run the code with cargo run; this uses the development profile.

    • It outputs debug information into the compiled binary.
  4. Then run with release profile cargo run --release.

    • This profile will not output all debug information in the binary. We should find:

      Hello world!
      ====================== PANIC ======================
      panicked at examples/panic.rs:24:5:
      This is a panic
      Backtrace:
      0x4200252a
      main
          at ??:??
      

      The default --release behaviour excludes debug information and minimises the binary size; the backtrace shows the missing debug information with ??.

Exercise: part 2

We nearly always use --release (we want small binary size). If we want debug information we need to configure the release profile in .cargo/config.toml:

+[profile.release]
+debug = true

Now it will emit debug information in the ELF binary file; yet debug info isn’t flashed into the target, it is just used to display the backtrace.

  • Re-run the program with --release and confirm ??:?? is now filled in.

exercises/panic/examples/panic.rs contains a solution. It can be run with: cargo run --example panic --release.

Recap

  • esp-backtrace handles panics (by us or by any library).
  • We can manually use panic! to exit the program.
  • Configure compilation so that panics print a backtrace with all debug information.

Suggested Reading

Blinky

We will now create the iconic blinky.

Let’s access the project with cd exercises/blinky. We will need to edit the file main.rs.

On esp32-c6 board there is no regular LED connected, instead there is an addressable LED which works differently and is beyond the scope of this book.

Instead, we will use a regular LED and a resistor, and build a circuit controlled with the GPIO pin headers.

Wire up a circuit with an LED and a resistor connected to the board.

esp32-c6, wiring the LED

Wire up the board as shown on the previous image:

  1. Start wiring from GND pin header (red wire),

  2. From there to the resistor (220mΩ or otherwise, without it the LED blows up.)

  3. From the other leg of the resistor to the LED (blue wire),

  4. Finally, the LED connects to GPIO7 (the long LED-leg is on GPIO7.)

Exercise

  1. Create OutputConfig with default configuration.
    • Hint: it implements Default.
  2. Toggle the led with 3500ms delay.

The exercises/blinky/examples/blinky.rs contains a solution.

You can run it with the following command cargo run --example blinky --release.

Handle Input

Using same wiring from the blinky chapter, let’s now use external input to make the LED blink.

  • Access exercises/handle_input in order to edit main.rs.

Exercise

We will use the button labelled BOOT linked to GPIO9 to toggle the LED.

  1. Initialise the peripherals with default config (check previous exercises)
  2. Set the led variable to Output::new passing GPIO7 peripheral, Level::High and default OutputConfig.
  3. Set the btn variable to Input::new passing GPIO9 peripheral, default InputConfig but overwrite with as_pull(Pull::High).
  4. Add the logic inside loop
    • When pressing it should turn the led on, and delay it 2 seconds
    • Then it turns itself off.

The exercises/handle_input/examples/handle_input.rs contains a solution. You can run it with the following command cargo run --example handle_input --release.

defmt

defmt is an efficient logging framework. Just like log, the defmt crate enables defmt::info, defmt::warn and other levels. It also has a defmt::println! macro.

The name defmt comes from deferred formatting:

(…) instead of formatting 255u8 into "255" and sending the string, the single-byte binary data is sent to a second machine, the host, and the formatting happens there.

So the formatting is deferred to the host. The other bit improving efficiency is compression:

defmt’s string compression consists of building a table of string literals, like "Hello, world" or "The answer is {:?}", at compile time. At runtime the logging machine sends indices instead of complete strings.

Source: defmt docs.

defmt Ecosystem

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

  • espflash has support for different logging formats, one of them being defmt.

  • esp-println needs defmt-espflash feature. As their docs state:

    Using the defmt-espflash feature, esp-println will install a defmt global logger. Updated the Cargo.toml’s features.

  • esp-backtrace needs a defmt feature that uses defmt logging to print panic and exception handler messages.

Exercise

Go to exercises/defmt directory.

  1. Update the runner’s logger in .cargo/config.toml:

    [target.riscv32imac-unknown-none-elf]
    - runner = "espflash flash --monitor"
    + runner = "espflash flash --monitor -L defmt"
    

    so that espflash monitor can decode the format of the defmt messages received.

  2. Update Cargo.toml to include the needed features:

    esp-println = { version = "0.16.0", features = [
        "esp32c6",
    -     "log-04",
    +     "defmt-espflash",
    ] }
    # (...)
    esp-backtrace = { version = "0.18.0", features = [
        "esp32c6",
        "panic-handler",
    +   "defmt",
    ]}
    
  3. Due to the linking process we need to add defmt linker script to cargo/config.toml:

    rustflags = [
      # ....
    +  "-C", "link-arg=-Tdefmt.x",
    ]
    
  4. Add defmt to the dependencies.

  5. Logging level: Use the defmt::println! and some defmt macros to print a few messages.

    • When building the app, set DEFMT_LOG level as done for ESP_LOG earlier.

    • An alternative to changing .cargo/config.toml is using DEFMT_LOG=<value> cargo run --release; the same is valid for ESP_LOG.

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

exercises/defmt/examples/defmt.rs contains a solution. You can run it with the following command: cargo run --example defmt --release. You will need to have the settings above done correctly though!

Suggested reading

Short articles that give more context:

Set Up

Programs are debugged using:

  • dbg! or print! information to know the value of a variable.
  • assert! expected values
  • Write unit tests

To access these finer details, we can use a debugger.

Software needed

Two programs will be needed:

  • GNU Debugger (gdb): inspect and stop the program as it runs.
    • Linux has gdb installed. On MacOS, install it with brew install gdb.
  • OpenOCD (openocd): handles the communication with the board.

OpenOCD

Download the latest openocd for your laptop architecture from the openocd-esp32 github repo. The repo is a fork of openocd targetted at esp32 boards.

Important

Please, check the commands before executing them.

  • For Linux amd-64:
    # Linux amd64. See releases page for other archs. 
    cd ${HOME} # go to home so we download in a visible place.
    OPENOCD_ZIP_NAME=openocd-esp32-linux-amd64-0.12.0-esp32-20250707.tar.gz
    DATE=v0.12.0-esp32-20250707
    wget https://github.com/espressif/openocd-esp32/releases/download/${DATE}/${OPENOCD_ZIP_NAME}
    tar -xvf ${OPENOCD_ZIP_NAME}
    
  • For MacOS arm64:
    cd ${HOME}
    OPENOCD_ZIP_NAME=openocd-esp32-macos-arm64-0.12.0-esp32-20250707.tar.gz
    DATE=v0.12.0-esp32-20250707
    wget https://github.com/espressif/openocd-esp32/releases/download/${DATE}/${OPENOCD_ZIP_NAME}
    tar -xvf ${OPENOCD_ZIP_NAME}
    

Important

Below, it is assumed the output of the command above was a directory named openocd-esp32.

Modify the PATH

Let’s add a path to the PATH variable, by editing the .zshrc or .bashrc file.

Additionally, the OPENOCD_SCRIPTS variable is defined. openocd uses that variable to find the board’s configuration.

# add `openocd` to PATH
export PATH=${HOME}/openocd-esp32/bin:${PATH}
# Define openocd scripts/configs location.
export OPENOCD_SCRIPTS="${HOME}/openocd-esp32/share/openocd/scripts"

Then source the profile:

source ~/.bashrc # or ~/.zshrc

Then check it is installed with openocd --version. Now to some exercises.

GDB

GDB commands

  • next step one line over.
  • continue or c: continue executing up to next break point.
  • break fn_name: break point at function name.
    • There may be many with same name. So we need the path.
      • For example: break gdb_hello_world::__risc_v_rt__main
  • break lineno: break point at line number of currently-focused file.
    • Example: break 19, or br 19.
  • break my_file.rs:lineno: example break main.rs:17.
  • monitor reset halt: restarts and halts it.
  • layout:
    • layout src: shows source code and CLI.
    • layout asm: shows assembly source and CLI.
    • tui disable to disable the layout.
  • info break to show breakpoints
  • info locals to show variables.
  • print x to print variable x. Also print &x prints the address of x.

Exercises

  1. Access exercises/gdb_hello_world
  2. Inspect the configuration files openocd.cfg (for openocd ) and .gdbinit (for gdb).
  3. Execute cargo run, leave out --release to use the development profile.
    • Without it, cargo will remove / optimise lines.
    • We should debug with --release to ensure the best outcomes.
    • But this examples is just to get started.
  4. Run gdb in one terminal window, and openocd in another window (same directory!).
  5. The image shows what gdb should look like at this stage.
    • Should have a breakpoint in _peripherals which means gdb stopped execution there.
  6. Add a break point in main.rs where a/=2
  7. print the resulting value.
  8. Exit with Ctrl+D or Ctrl+C.