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

This book

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

The book aims to be self-contained, but brief reading material will be suggested.

The goals are to show:

  1. The basics of the board and hardware components,
  2. How to start and configure new projects,
  3. What we can do with a board.

so that you can do projects independently afterwards.

Prior Knowledge

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

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.

Source

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

git clone https://github.com/micrors/esp32-c6 micrors_esp
cd micrors_esp

Repository contents:

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

Embedded Basics

The board (or DevKit), as shown earlier, is:

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

  • The printed circuit board (PCB): the flat fiberglass sheet supporting all electronic components.
  • The components: items laid on top of the PCB. They interconnect through traces which are thin copper wires. Examples of components: module, USB ports, LEDs, GPIO-pin headers.

So we have the flat piece with the components laid on top. Before zooming further, this is the hierarchy we will be exploring in the definitions (the outer levels contain the inner levels)

  • Board
    • Module
      • SoC (or MCU)
        • CPU, ROM, SRAM,
        • Advanced Peripherals: Wi-Fi, RF (Bluetooth).
        • Peripherals: GPIO, UART, I2C, ADC
      • Other Items
    • Components: USB, LED, GPIO pin-headers

The description below aren't technical, they are just an overview.

Module

The module is the big square labelled ESP32-C6-WROOM (or some variant). It takes almost half the board.

Within it, a module has a few more items, the key one being the SoC, short for System on a Chip. We can't see the SoC unless we take the module's lid off, but here is how it looks within:

SoC

The SoC includes CPU, ROM, SRAM, advanced peripherals and peripherals. It's an all-in-one integrated circuit or chip.

The advanced peripherals are those making SoC an advanced MCU, like Bluetooth and Wi-Fi.

The peripherals are parts of the SoC placed on the periphery of the CPU, extending its capabilities. For example: GPIO, UART, I2C, SPI, DAC, ADC. We can't see them directly.

The GPIO peripheral is "broken out" into the pin headers at each side of the board. See the [user-guide] schematics and tables for the board.

SoC or MCU?

In this tutorial we consider System on a Chip (SoC) and Micro controller unit (MCU) synonyms. Wikipedia defines MCU and disambiguates SoC or MCU very well (I modified it slightly):

A microcontroller (MC, uC, or μC) or microcontroller unit (MCU) is a small computer on a single integrated circuit. A microcontroller contains one or more processor cores along with memory and programmable input/output peripherals.

In modern terminology, a microcontroller is similar to, but less sophisticated than, a system on a chip (SoC). A SoC may include a microcontroller as one of its components but usually integrates it with advanced peripherals like a graphics processing unit (GPU), a Wi-Fi module, or one or more coprocessors.

In very simple terms, a module contains the SoC which is just an advanced MCU.

Putting it all together

An explanation of how it all comes together is in the micro:bit v2 book, a modified version is provided below:

Our MCU (or SoC) has tiny metal pins sitting right underneath it. These pins and the board components are wired via copper traces, the little "roads" on the board.

The MCU can be programmed to change the electrical state of the pins. The pins change the electrical state of the traces, ultimately controlling the components. So the MCU can control the components.

For example, 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.

Suggested reading

  • esp32-c6 user-guide. It describes the board components, peripherals, and pinouts.
  • The SoC datasheet, especially the product page, and the features (first 3 or 4 pages).
  • implrust's page on the distinction between DevKit (or Board), Module and SoC.

Set Up

Now it's time to connect the host computer to 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.)
esp32-c6 usb port connected to usb port in laptop.

Board-Laptop USB-link (white cable).

Note

The extra USB port is UART or ch343-tagged. It is used to communicate with the UART peripheral within the MCU. Both ports can be used for flashing and powering, but only esp32c6 can be used for debugging.

Check connection

To ensure our OS can detect the board, list the devices with one of these approaches:

  • With ls

    ls /dev/tty*usb* # MacOS
    
    ls /dev/ttyACM*  # Linux
    
  • With lsusb

    lsusb | grep JTAG
    # Bus (...) USB JTAG/serial debug unit (...)
    

Install Software

Now that the board is seen by our OS, let's install all needed software.

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 processor

    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

rustc has compilation backends which do part of the compilation of our code. llvm is one such possible backend (the most common one.)

  • Debian/Ubuntu.

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

    brew install llvm
    

Suggested reading

Additional Software

  • It's recommended to install Rust Analyzer, following their guide.

Boilerplate

A few lines frequently repeat, like a mantra througout the examples. It helps a lot to analyse them.

no_std, no_main

no_std, no_main, no_std, no_main, ...

The entry files start with

#![no_std]
#![no_main]
// ...
#[esp_hal::main]
fn main() -> ! { }
  • no_std disables loading the std crate. It only loads core by default.
  • no_main tells Rust not to search for a main function.
    • This is handled by esp_hal::main macro.

Imports

A few imports also repeat througout:

use esp_hal::{main, Config};
use esp_backtrace as _;
use esp_println::println;
  • esp_hal: provides main entry plus useful modules for dealing with peripherals, time, and so forth.

  • esp_backtrace: Out of bounds indexing and our own panic! calls have to be handled by a panic-handler (otherwise provided by std).

    • This crate provides a replacement through the panic-handler feature.
    • In summary, it handles the panic! calls, and prints the backtrace (call stack up to that point.).
  • esp_println: The macro println! comes from std. We don't use std so esp-println provides the macro for us.

    • Logging backends (log, defmt) can also be used:
    use esp_println::{logger,println};
    +use log::{info, trace};
    
    • The log import isn't needed if we don't use these macros.

App Descriptor

The line is:

esp_bootloader_esp_idf::esp_app_desc!();
  • The board has 2 bootloaders.
    1. First stage bootloader: written in ROM and can't be changed. It's read first. It loads the second stage bootloader.
    2. The second stage bootloader needs an application descriptor, which esp_bootloader_esp_idf::esp_app_desc!(); creates for us. This bootloader loads our application.

      Note

      Each time we flash a binary, espflash includes a pre-compiled second stage bootloader with default settings, alongside our binary-code.

init

The other line is within fn main() { } namely:

#[main]
fn main() -> ! {
    let _peripherals = esp_hal::init(Config::default());
}

This line initialises our MCU with default configuration. In their words, for esp_hal::init

Initialize the system.

This function sets up the CPU clock and watchdog, then, returns the peripherals and clocks.

Notice the "returns the peripherals". That's important. The Peripherals struct provides access to all of the hardware peripherals on the chip!

And for esp_hal::Config:

System configuration.

This struct is marked with #[non_exhaustive] and can't be instantiated directly. This is done to prevent breaking changes when new fields are added to the struct. Instead, use the [Config::default()] method to create a new instance.

Recap

So we have described the macro attributes (like #[main]), the module imports, the app descriptor and board initialisation.

Tip

All the snippets in this page are so ubiquitous that it is useful to memorise them and their role.

Suggested Reading

Hello World

There is a lot to learn, so let's start simple. Our "hello world" is more of a logging practice.

  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:

    ..
    Commands:
        CTRL+R    Reset chip
        CTRL+C    Exit
    ..
    loop..!
    ..
    

    Tip

    Try pressing CTRL+R to reset, then press CTRL+C to exit.

    The aim here is just run the binary.

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.

This builds and then flashes the binary. Finally, it monitors for logs. Removing --monitor it won't wait for logs.

Exercise

We already know our code can print information with println! macro. For prettier logs we can use log or defmt. Let's try adding them:

  1. Take some time to read the code. Do you recognise the boilerplate discussed earlier?
  2. Try commenting out the line:
    use esp_backtrace as _;
    So that we can see what it provides. Then uncomment the line.
  3. Uncomment the "log lines" in src/main.rs,
  4. Add the log dependency to Cargo.toml.
  5. 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, to test how it works.

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

The examples/hello_world.rs contains a solution. You can run it with cargo run --example hello_world.

Important

Running the solution requires fixing the log-lines at the bottom of Cargo.toml.

Suggested Reading

Panic

If something goes very wrong, a program will panic!.

esp-backtrace provides a panic! implementation through the panic-handler feature.

"Handling panic!" here means to print what was executed up to that point (the backtrace), and probably stops code execution as well.

We could use our own panic-handler instead, and no backtrace at all. For example, this one:

use core::panic::PanicInfo;
use core::sync::atomic::{self, Ordering};

#[inline(never)]
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {
        atomic::compiler_fence(Ordering::SeqCst);
    }
}

Warning

We need to use esp-backtrace as _; so that this handler is included in the final binary.

In the exercise below, we configure a profile. Profiles are configurations cargo uses to control compilation. When unspecified, cargo sets sensible defaults for us.

Exercise 1

  1. Access the project at exercises/panic.

  2. Add all the boilerplate code described earlier.

  3. Add a panic! within main.

    • But remember, it's for irrecoverable errors!
  4. Run the code with cargo run; this uses the development profile.

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

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

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

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

Exercise 2

  1. Edit the .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.
  2. Try another option:

    +[profile.dev]
    +opt-level = "s"
    

    which turns size-optimisation on, and has debug info by default. Run it with cargo run (no --release flag since we want to run the dev profile.)

Recap

  • Using esp-backtrace.
  • Using panic! to exit the program on error.
  • Profiles:
    • Tweak debug information on backtrace.
    • Optimise binary size.

Suggested Reading

Blinky

We will now create the iconic blinky.

Let's access the project with cd exercises/blinky.

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 larger, 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 pin-header 7 (the long LED-leg is on 7).

Let's reconsider our boilerplate:

#![no_std]
#![no_main]

use esp_backtrace as _;
use esp_println::println;
use esp_hal::{main, Config};

esp_bootloader_esp_idf::esp_app_desc!();

fn main() -> ! {
    let peripherals = esp_hal::init(Config::default());
}

And now we ask ourselves:

  1. Which peripheral can we use to control pin-headers' electric state?
    • We can use the peripherals.GPIOX where X is the same number than the pin-header we need.
    • In this case, it's GPIO7, labelled 7. Always check the pinouts.

      Warning

      The PCB labels are written below the headers! So GPIO7 is not mapped onto header 6 (as a quick look might suggest), but on header 7.

  2. Which driver can we use to communicate with it?
    • We can use the Input and / or Output drivers.

Exercise

  1. Open the file src/main.rs, take some time to read the code.
  2. Create OutputConfig with default configuration.
    • Hint: it implements Default.
  3. 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 src/main.rs.

Warning

This exercise is slightly more involved, in the sense you'll need to add more code. But there is always the solution if it gets too hard.

Exercise

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

  1. As per usual, we will be editing src/main.rs.

  2. We first need to fill in the boilerplate. Once that's done, this line might be of help:

    // you will also have other `esp_hal` submodules besides `gpio`.
    +use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull},
    

    You may get inspired by the previous exercises and the boilerplate page.

  3. Create an Output::new passing GPIO7 peripheral, Level::High and default OutputConfig.

    • Assign it to the led variable.
    • Hint: with peripherals initialised, GPIO7 is a field in that struct.
  4. Create an Input::new passing GPIO9 peripheral.

    • Use default InputConfig, overwrite with as_pull(Pull::High).
    • Assign it to the btn variable.
  5. Add the logic inside loop:

    • When pressing it should turn the led on, and delay it 2 seconds.
    • Then it turns itself off.

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

Logging - Part 1

What happens if we use the boilerplate, and no logging dependency?

In the src/main.rs we start with:

#![no_std]
#![no_main]
// None of these: esp_backtrace, esp_println, defmt
use esp_hal::{
    main,
    Config,
};
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    esp_hal::init(Config::default());
    panic!("Welcome.")
}

Running cargo run --release, we get:

error: #[panic_handler] function required, but not found

The panic handler is something we can define, or use a crate. For simplicity, let's use esp-backtrace with that feature, adding to Cargo.toml the following dependency:

esp-backtrace = { version = "0.18.1", features = [
    "esp32c6",
    "panic-handler",
 ]}

And to src/main.rs:

// ...
use esp_backtrace as _;
// ...

Re-run cargo run --release, and we get a new error:

error: failed to run custom build command for esp-backtrace v0.18.1 ... Exactly one of the following features must be enabled: defmt, println

So we add one:

esp-backtrace = { version = "0.18.1", features = [
    "esp32c6",
    "panic-handler",
+   "println"
]}

And yet, it errors:

error: failed to run custom build command for esp-println v0.16.1

Exactly one of the following features must be enabled: jtag-serial, uart, auto, no-op

This, it seems, would require that we add esp-println, and enable some of those features.

Since auto is a default feature, we can add:

esp-println = { version = "0.16.1", features = [
    "esp32c6",
] }

Now, it will run and panic. Yay!

Playing with these dependencies helps get a sense of what they are for. Also, to see how useful the compiler error messages can be.

So far we've not printed anything, but given esp-println is already there, we only need to use it. Then we can println!("Something") and it should work.

At this step, the code looks like:

#![no_std]
#![no_main]
use esp_hal::{
    main,
    Config,
};
use esp_backtrace as _;
use esp_println::println;
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    esp_hal::init(Config::default());
    println!("println!");
    panic!("Welcome.")
}

Using the log crate

We add a few items:

use esp_backtrace as _;
+use esp_println::{logger,println};
+use log::info;
esp_bootloader_esp_idf::esp_app_desc!();

And within main:

fn main() -> ! {
    esp_hal::init(Config::default());
    +logger::init_logger_from_env();
    +info!("SOME INFO!");
    println!("println!");

Finally, on Cargo.toml:

esp-println = { version = "0.16.1", features = [
"esp32c6",
+"log-04"
] }
# Manuallu, or use `cargo add log`
+log = "0.4.29"

So now we have learnt how to add a logger, if we need to.

Note

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

In the next logging section, we analyse using defmt instead.

Logging - Part 2

Dependencies that may log data provide support to use defmt or log. Previously, we configured a package to use log, so now it will be quicker, but other aspects can be examined.

As a recap:

  • espflash has built in support for defmt, log or none using the -L flag.
  • esp-println can use defmt-espflash or log-04 features. As their docs state:

    Using the defmt-espflash feature, esp-println will install a defmt global logger.

  • esp-backtrace can use println or defmt features, in order to print panic messages and backtraces.

Let's update the package to use defmt.

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",
    -   "println"
    +   "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, and remove log.

  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 (within .cargo/config.toml, under [env] table).
    • 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.

Note

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

Suggested reading

Short articles that give more context:

Debugger - Set Up

Programs are debugged using:

  • Printing value of a variable (e.g. println!).
  • Asserting the value of a variable (e.g assert!).
  • Writing unit tests.

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

GDB and OpenOCD

Two programs will be needed:

  • GDB: with multiarch to support riscv32 architecture.
    • Linux: sudo apt-get install gdb-multiarch.
    • MacOS: brew install gdb (it's multiarch).
  • OpenOCD: handles the communication with the board.
    • The installation is described below.

Download the latest openocd for your laptop architecture from the openocd-esp32 github repo. The repo is a fork of openocd targeted 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

Confirm installation with openocd --version. Now to some exercises.

Debugging - Exercises

Now that gdb or gdb-multiarch are set up, let's do some 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 -x gdbinit -q (or gdb-multiarch on Linux) in one terminal, and openocd in another (at the same directory!).
    • In the image, _peripherals has not yet been assigned. gdb stopped execution there. The window at the bottom half is our (gdb) prompt.
  5. Add a break point where a/=2, then print the resulting value.
  6. Exit with Ctrl+D or Ctrl+C.

Suggested Reading

  • MB2 article on Debugging.

Intro

This chapter is about communication protocols and peripherals used with them.

A communication protocol is a series of steps that the devices involved agree upon, in order to encode and decode the messages exchanged (communicate).

  1. Asynchronous serial communication with the UART peripheral.
  2. I2C serial communication protocol and peripheral.
  3. SPI serial communication protocol and peripheral.

There are many others! And sometimes devices have their own language and their datasheet must be read. But the ones listed above are very common.

Have fun

Check out any website listing sensors, and without tempting yourself to buy things, look at which communication protocols they use. Here are a few from Wikimedia Commons:

UART - Part 1

UART stands for Universal Asynchronous Receiver and Transmitter.

Asynchronous means that they don't have a dedicated clock line, since all it has is two data lines (Rx, Tx in the diagram below). This will be become more clear after learning I2C.

The diagram below shows two UARTs wired for communication; each is connected to a local data bus.

graph LR
    subgraph uart2 [UART 2]
        direction TB
        Tx2
        Rx2
    end
    subgraph uart1 [UART 1]
        direction TB
        Tx1
        Rx1
    end
    Tx1--> Rx2
    Tx2--> Rx1
    DB2[Data Bus 2] <--> uart2
    DB1[Data Bus 1] <--> uart1

A UART has two data lines: one transmits data (Tx), one receives data (Rx). Each connects to the complementary line on the other UART. For example, the Tx1 line of UART 1, to the Rx2 line of UART 2.

The Data Bus is either where the transmitter reads data from, or where the receiver writes data to.

Communication

Most communication peripherals take the name from the communication protocol, but not UART. So...which protocol does the UART peripheral use?

UART uses Asynchronous Serial Communication which is an "umbrella" or general term. There isn't a subterm / protocol name. So we say: UART uses Asynchronous Serial Communication. How it works for UART is shown in the steps a bit further down.

The "Asynchronous" part was explained in the first paragraphs. "Serial Communication" means sending one bit after another; that is, in series.

  • Example: Sending data from the board to the laptop. This is what happens at a high level:

    1. The sending-UART's Tx line uses START and END signals to mark when the communication starts and ends.
    2. A UART device reads a byte from the data bus.
    3. It serialises it, and wraps it with metadata bits. The whole item is now called a frame.
    4. Then sends the bits one-by-one to the other device (through the Tx line).

The other UART receives the series of bits (in the Rx line), uses the wrapping bits and places the data bits in the data bus.

Since there isn't a clock line, the transmission rate must be set as a parameter, called the baud rate, in bits per second.

Besides the baud rate, the data, the parity, and the stop bits must be set equal on each device. A mismatch of speed or configuration bits will cause the devices to decode information incorrectly.

As an example:

Wikipedia Asynchronous Communication image, described below

From Asynchronous Communication by Plugwash (Public Domain)

And the description of the image (slightly modified):

In this diagram, two bytes are sent, each consisting of a start bit, followed by eight data bits (bits 0-7), and one stop bit, for a 10-bit character frame.

The last data bit is sometimes used as a parity bit.

The number of data and formatting bits, the order of data bits, the presence or absence of a parity bit, the form of parity (even or odd) and the transmission speed (frequency) must be pre-agreed by the communicating parties.

The "stop bit" is actually a "stop period"; the stop period of the transmitter may be arbitrarily long. It cannot be shorter than a specified amount, usually 1 to 2 bit times. The receiver requires a shorter stop period than the transmitter.

At the end of each character, the receiver stops briefly to wait for the next start bit. It is this difference which keeps the transmitter and receiver synchronized.

Speed Calculation

With a set up like:

  • 8 data bits, 1 parity, and 1 stop bit (10 bits)
    • Note: this is called a frame.
  • baud rate 115200 bits/second

It will send 11520 frames per second. Since it includes 1 byte per frame (10 bits), it's also 11520 byte per second. Or 11520/1024 to get it in KiB (about 11 KiB/s). That's about 1 short essay per second.

Suggested Reading

  • A bit more on UART: this is a short article, only looks long due to the comments section.

UART - Part 2

Consider our boilerplate:

#![no_std]
#![no_main]
use esp_hal::{main, Config};
use esp_backtrace as _;
use esp_println as _;
esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
  let peripherals = esp_hal::init(Config::default())
  loop {}
}

How do we get to use a UART peripheral from there? How do we configure the peripheral? Which configuration options do you expect? Try getting a sense of what to do first (maybe checking esp_hal docs.)

Warning

The pinouts won't help. UART is connected to UART-USB bridge (a small IC near the USB ports). So when passing the with_rx and with_tx to the driver, we must choose the GPIOs hardwired to the UART-USB bridge. These are GPIO16, GPIO17. For sending to GPIO-header-pins (those on the sidelines) we could pass any GPIO (but not when using the bridge.)

======!Spoiler Alert!====== (click for a solution)

There are many other details in the main.rs file, but the most critical is to make sense of this snippet:

let uart_config = uart::Config::default()
    .with_baudrate(115200)
    // ... other settings

let mut uart = Uart::new(peripherals.UART0, uart_config)
    //   driver^^^^^^^^^       periph^^^^^ config^^^^^^
    .expect("Should set up the driver.")
    // Route through the UART-USB bridge
    .with_tx(peripherals.GPIO16)
    .with_rx(peripherals.GPIO17);

That is another boilerplate we can think of: init device, grab peripheral, pass it to a driver along with its config.

A list of some of the peripherals is:

# (Most of these are unstable)
GPIO0-23, GPIO27 peripheral singleton
I2C0 peripheral singleton
I2S0 peripheral singleton
LEDC peripheral singleton
RNG peripheral singleton
SPI0-2 peripheral singleton
TIMG0-1 peripheral singleton
UART0-1 peripheral singleton
USB_DEVICE peripheral singleton
WIFI peripheral singleton

Exercise

  1. Access exercises/uart/src/main.rs. The comments within will guide you through.
  2. If not installed install screen (for example sudo apt install screen).
  3. Before running it, note that we exit screen with Ctrl+a+k (then pressing y when prompted for it).
  4. After flashing the board (any USB port) connect it through ch343 (or UART) tagged port.
  5. Run screen <device_path> <baud_rate>.
    • <device_path> is probably /dev/ttyACM0 on Linux and /dev/tty.usbmodem0 MacOS.
    • To be sure, list them with ls /dev/tty* and try to figure out which one is the board.
    • <baud_rate> must match the rate in the code. For example 115200.
  6. Remember to press the RESET board button once.
  7. Try the echo server by typing in the keyboard, and send with Enter key.

Note

A solution is provided in examples/uart.rs.

Imagining a projects

The esp32-c6 board has 2 UARTs: UART0 and UART1.

Technically, we could use UART0 just as we did, and UART1 could other GPIOs connected to GPIO-header-pins. By linking this to USB-UART converter and this in turn to a laptop, we could chat.

Two laptops could chat via the board.

Suggested Reading

  • This is a good time to check esp_hal, especially their peripherals section, and anything you find interesting.

I2C

I2C (inter-integrated communication) is a protocol and also a peripheral. We are concerned with the protocol.

"inter" in the name refers to within the board, "integrated" refers to integrated-circuits. In other words: communication between multiple integrated circuits within a board (or short distances).

Yet, devices supporting I2C need hardware that understands the protocol rules. Here, we will focus on its usage and most relevant concepts.

Note

An integrated circuit (IC) is an extremely packed, microscale version of a circuit. We could build one with wires, resistors and other components (and tons of effort).

ICs are the "black squares" in the PCB, but are not always visible, for example those within the MCU, or inside a sensor.

I2C is within the Synchronous Serial Communication umbrella:

  • It is Serial because it transmits one bit at a time.
  • It uses two lines: a serial clock-line (SCL) and a serial data-line (SDA).
  • The SCL makes it a synchronous protocol.

The complete diagram is in this document (section 2.1), but a similar one from Wikipedia is shown below:

Diagram showing the two wires SDA, SCL and many devices linked to them.

By Tim Mathias - Own work, CC BY-SA 4.0, Link

  • SDA is the Serial Data line, the road data travels on (in any direction). SCL is the Serial Clock line, which times when to read-from or write-to SDA.

  • The Vdd line pulls up the voltage in the SDA and SCL lines. There is a GND line as well, omitted here.

  • All the coloured blocks are devices, 3 can be targets only, one can be controller only. µC means microcontroller, and Rp are resistors.

The last bit of terminology are the roles:

  • Controller (formerly master): initiates communication. Microcontrollers are always in the controller role.
  • Target (formerly slave): the target of the controller. Most devices –except MCUs– are targets.

And the modes: whichever the role is, a device can either be the receiver or the transmitter of data.

Communication

The high level view of the protocol is:

  1. A controller broadcasts START bit.
  2. It then broadcasts a target device address (usually 7 bits).
  3. Then sends a single bit where 0 receiver, 1 means transmitter.
  4. If the broadcasted address matches the ID of a target, the target replies with an acknowledgement and the communication starts.
  5. Now 8 bits of data are sent (either direction). The receiver always sends an acknowledgment bit.
    • This step can repeat many times.
  6. No other device will transmit data during this time. It's "locked".
  7. Finally the controller broadcasts a STOP bit, and the bus is unlocked.

Here, most bits are sent through SDA, but the SCL also helps define when communication starts and ends, and times when to read from SDA.

The speed of the communication is a bit faster than UART's deppending on the mode, in the order of 10 KBps to 100 KBps.

For curiosity

  • Search for "OLED I2C", or just any sensors. Many will support this or some of the already-learnt protocols. For I2C there will be a Vdd, GND, SDA, SCL lines, as expected.
  • Don't buy more stuff! It's just to learn. You may even have some sensor supporting I2C already.

Suggested Reading

GDB Commands

GDB basic commands, classified with respect to their purpose.

Program Flow

  • 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.
  • load: if we only use cargo build, then we can flash using load.
    • Within gdb type load. It'll flash the file to the MCU.

Layout

  • layout src: shows source code and CLI.
  • layout asm: shows assembly source and CLI.
  • tui disable: to disable the layout.

Overviews

  • info break: to show breakpoints
  • info locals: to show variables.
  • print x: to print variable x. Also print &x prints the address of x.
  • list or list main: will show our program with numbered lines.

Further reading

  • Discovery MB2 - UART a section with more detail than the one in this book.
  • TI I2C section 3 on the protocol could be of interest.

Other useful pages