Blinking

Well… this is awkward. The board is already blinking!

But how?

Let’s look line by line to understand how this is working.

Navigate to firmware/src/main.rs. This is the main file for our firmware.

#![no_std]
#![no_main]

At the top of the file you will find ^ these directives.

#![no_std] indicates that our program will not have acces to the Rust standard library. This is because embedded systems are resource constrained environments with no operating system, so simple things like allocation are suddenly not so simple. This directive informs the Rust compiler that the standard library is unavailable, which permits us to target the microcontroller.

#![no_main] indicates that the entry-point1 to our program is non-trivial. The Rust compiler typically expects the entry-point to be defined as a top-level2 function named main, however as we will soon see, the entry-point will not be defined by us, and will be generated by Embassy to begin async operation and start the executor3.

Below this you will see:

use embassy_executor::Spawner;
use embassy_time::Timer;
use esp_backtrace as _;
use esp_hal::{
    clock::ClockControl,
    gpio::{Io, Level, Output},
    peripherals::Peripherals,
    prelude::*,
    system::SystemControl,
    timer::{timg::TimerGroup},
};
use esp_println::println;

This looks like a lot, but these are just crates we are importing to use in our program. We will see exactly which crates we use and why as we explore the rest of this file.

#[esp_hal_embassy::main]
async fn main(_spawner: Spawner) {
    // ...
}

Next you will see this ^ function.

This function is hosted by Embassy, and is what we will use to kick off our firmware.

You may notice it is async! This means the executor has already started, and we can utilize all of Rust’s concurrency features right away!

#[main] is an attribute macro4 from Embassy that transforms our async function definition into the proper structure for starting the executor and configuring the CPU/static memory.

For funzies, you can use a tool called cargo-expand to view what this macro does! Your editor of choice may also support macro expansion inline.

This function provides us with a single parameter of type Spawner. This type allows us to spawn tasks, which we don’t need for a simple blink program so we marked the variable as unused with the leading underscore.

Now let’s look at what exactly we do in this function to configure our microcontroller.

let peripherals = Peripherals::take();
let system = SystemControl::new(peripherals.SYSTEM);

let clocks = ClockControl::max(system.clock_control).freeze();

In the first line, we take ownership of all peripherals. This operation can only be done once, per RAII, we may only have one binding for one resource.

In the second line we create a binding to specifically the system peripheral, which controls clocks, interconnects, etc.

And on the third line, configure the system clocks to run at their maximum frequency.

esp_println::logger::init_logger_from_env();

Next up we enable logging via the ESP32s JTAG5 controller.

let timg0 = TimerGroup::new(peripherals.TIMG0, &clocks);

Then we initialize the 0th timer group (which we can use to provide Embassy with a means for keeping time).

esp_hal_embassy::init(&clocks, timg0.timer0);

Embassy needs a impl TimerCollection (some time that implements the trait TimerCollection) to provide monotonics6.

ℹ️
We understand that this may be uncharted territory for many of you. We implore you to do your own research, read about Rust, and mess with it on your own. The foundational principles we are leveraging are not easy to grasp initially, so know that you are not alone if you are struggling. The tutors are here to help, and don’t forget about the resources page.

The next section is fairly straight forward:

let io = Io::new(peripherals.GPIO, peripherals.IO_MUX);

let mut led = Output::new(io.pins.gpio17, Level::Low);

loop {
    println!("Hello, World!");
    led.toggle();
    Timer::after_millis(1_000).await;
}

We create a binding to the GPIO and IO_MUX (multiplexer for the GPIO) peripherals to gain control of IO.

Then we configure pin 17 to be an output pin with a default level of low.

And finally, in an infinite loop, we print Hello, World!, toggle the LED, and wait for 1 second!

You may notice as part of that “waiting”, there is a trailing .await. In async contexts, this indicates yielding control back to the executor. If we had other tasks waiting to do work, they would get to while this task waits. This simple handoff of execution is what makes async such a powerful language feature.

If you have operating system design experience, you may be itching to see some concurrency primitives in action, rest assured we will get to that. You may also be wondering how preemption can be done. The Embassy executor actually does not support preemption, and this was done intentionally. You can, however, create multiple executors of different priorities, and they will preempt each other’s execution. You can learn more at Embassy’s docs.

And that’s it! If you want you could try to get the other LED (pin 18) blinking as well!


  1. All binaries have an entry-point, which is the symbol to start execution on. ↩︎

  2. A top-level item is defined above all hierarchy of a file. Think of global variable definitions, free-standing functions or type definitions. ↩︎

  3. The executor is the “entity” provided by Embassy that fascilitates the routing of execution between tasks. ↩︎

  4. Attribute macros are procedural macros7 that attatch to structures and directly transform them. ↩︎

  5. JTAG is a proprietary debug interface from SEGGER that exists on top of SWD9↩︎

  6. Monotonic means strictly increasing. A monotonic timer is usually at the heart of time-keeping as the assumption that the absolute value of the timer is strictly increasing can be leveraged to conduct time-keeping logic. ↩︎

  7. Procedural macros are an advanced type of macro8 that are mini Rust programs written to be executed by the compiler at compile-time. As opposed to declarative macros which do a direct symbol transformation. ↩︎

  8. Macros are like functions that transform code at compile-time. ↩︎

  9. Sserial Wire Debug (SWD) is a debug protocol created by ARM for debugging ARM CPUs. This is extremely useful as we can remotely debug microcontrollers via this interface. You can use it to transfer log-style information, or via the microcontroller’s debug peripheral, you could even start a GDB (or LLDB) session like any local process. ↩︎