Setup
Setup
1. Rust
Go to https://www.rust-lang.org/tools/install
💡 Windows users: prefer to install Rust natively, rather than using WSL.
2. Toolchain
To compile Rust code for out ESP32-C6 microcontroller, we need to install the appropriate toolchain.
rustup toolchain install stable --component rust-src --target riscv32imac-unknown-none-elf
3. Tools
Additionally, we will want tools to help us generate projects and program the firmware.
cargo install cargo-espflash espflash esp-generate
Hardware information
Board pinout

Documents
Links
Hello World
Create the project
Let's create our first embedded Rust project. For this, we will be using esp-generate tool installed earlier.
To begin with, run the following command:
esp-generate --headless --chip=esp32c6 -o log -o unstable-hal hello_world
This will create a new project in hello_world directory. Go ahead and open it with: code hello_world.
💡 You can run
esp-generate --chip=esp32c6to open the tool in interactive mode and explore the available options.
Run
With the ESP board plugged into your computer, you should be able to run the firmware with:
cargo run
You may have to select the appropriate device. If this is the case, the tool will prompt for it.
If everything goes well, your device will boot and start printing the "hello world" message:
INFO - Hello world!
INFO - Hello world!
INFO - Hello world!
INFO - Hello world!
INFO - Hello world!
Experiment
Experiment with this example a little bit.
- Change the message to print something else.
- Change the log level (e.g. to
warn). - Change the printing frequency by adjusting the sleep duration.
Bonus
The way the delay is currently written is a bit verbose.
We can simplify the code by using Delay from the esp_hal crate.
- Add the following line next to the existing
usedeclarations:#![allow(unused)] fn main() { use esp_hal::delay::Delay; } - Create a delay variable:
#![allow(unused)] fn main() { let delay = Delay::new(); } - Replace the two lines using
delay_startwith the following one line:#![allow(unused)] fn main() { delay.delay_millis(1000); }
Blinky
We have created a typical "hello world" application, but in embedded systems, the equivalent of "hello world" is blinky: simply blinking an LED.
We will continue working in the hello_world project.
Change the program
In order to blink an LED, we need to define an output pin and make a couple of other changes.
Firstly, let's rename the _peripherals variable to peripherals.
💡 In Rust, a variable can be prefixed with an underscore (
_) to indicate that it is unused. Since we'll be using it, let's remove the leading underscore.
Next, we will define our LED:
#![allow(unused)] fn main() { let mut led = Output::new( peripherals.GPIO19, Level::Low, OutputConfig::default().with_drive_mode(DriveMode::OpenDrain), ); }
The code above will not compile as is, since the compiler won't know where Output and other types are coming from. To fix that, import the required types like so:
#![allow(unused)] fn main() { use esp_hal::gpio::{DriveMode, Level, Output, OutputConfig}; }
💡 Whenever a you have a missing declaration error, you can use
rust-analyzerto help you fix it.Place the cursor on the missing type name and press
Ctrl+.on the keyboard. In the context menu, selectImport <Type>orQualify <Type>."Import" will add the required
usedeclaration, while "Qualify" will prefix the module path in-place.
Finally, add the following line to the body of the loop to toggle the LED every cycle:
#![allow(unused)] fn main() { led.toggle(); }
Experiment
Output has other methods than toggle. Try using set_high and set_low methods to change the LED blinking pattern.
Exercise: working with inputs
We have worked through an example of driving a GPIO output. Now it's time for you to try and extend the program to read the GPIO input state.
- Use the
esp_halcrate documentation to create and initialize a GPIOInput. - The button is connected to
GPIO9. - Write a program that turns the LED on only while the button is pressed.
Solution
- Add new imports:
#![allow(unused)] fn main() { use esp_hal::gpio::{Input, InputConfig}; } - Declare the button:
#![allow(unused)] fn main() { let button = Input::new(peripherals.GPIO9, InputConfig::default()); } - Modify the loop code:
#![allow(unused)] fn main() { if button.is_high() { led.set_high(); } else { led.set_low(); } }
Explanation
You may be tempted to think that high means ON and low means off, however, the logic of both the button and the LED
is inverted.
Button has a pull-up resistor and connects the pin to ground when pressed.
The LED has its anode connected to the 3.3 V supply and the cathode is connected to the open-drain output. When the output is set to low, it is connected to ground, allowing the current to flow and thereby turning the LED on.
Interrupts
In the last exercise, we learnt how to read a GPIO pin state. However, it's not always desirable to poll the pin state in software. This is where interrupts come it. We can ask hardware to notify us whenever the pin state changes.
Background
Because interrupts can happen at any time during the execution of our program, we can think of them as if they're running on a separate thread. As such, when working with interrupts, we apply the same principles as when working in a multi-threaded environment.
Additionally, because the interrupt routine is called by hardware, there is no way to pass parameters or return values from the interrupt handler.
One common way to pass data between the application and interrupt contexts is using global state. This is mostly straightforward in C, as you can just read and write any global variable at any time. Whether you like it or not, this "feature" opens up questions about what happens when the variable is modified while some other code is reading it?
Consider this code:
volatile int counter = 0;
int main() {
while (true) {
app_task();
++counter; // Same as: counter = counter + 1
}
}
void timer_interrupt() {
interrupt_task(counter);
counter = 0;
}
In Rust, however, using a mutable global state is explicitly unsafe. While it's possible to bypass
the compiler rules, the language strongly encourages developers to write correct code.
Atomics & Mutexes
When working with primitive types we can use one of the types provided in core::sync::atomic.
Atomic types are the most fundamental way to ensure exclusive access -- these opperations are provided by the CPU.
For more complex types, we have to resort to higher level synchronization primitives, such as Mutexes and
other types of locks. Note that Mutex is only available in the std library, as is requires operating system support.
No worries though, for embedded systems we can use Mutex provided by the critical_section crate.
GPIO Interrupt
Let's see how we can use interrupts on ESP32.
Declaring global mutable objects
At least in case of esp_hal, interrupt status is not cleared automatically, so we'll need to do that ourselves.
For that, we will need to access the button object inside the interrupt handler, so we have to make it global.
Additionally, since we will be modifying this global object, we have to ensure it's done in a safe way, without causing race conditions.
We'll need to wrap our Input with three different types, so this will be very confusing at first. The purpose of each
type will be explained at the end of this section.
Start by importing the necessary types:
#![allow(unused)] fn main() { use core::cell::RefCell; use critical_section::Mutex; }
Now, we can declare our global button:
#![allow(unused)] fn main() { static BUTTON: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None)); }
As you can see, our Input is wrapped in an Option, which is wrapped in a RefCell which in turn is wrapped
in Mutex.
Let's look into each one starting with the outermost.
Mutex
Mutex, when combined with the critical section, allows us to get an exclusive access to the stored type.
Unlike the Mutex in Rust's std library, critical_section::Mutex does not provide ability to mutate the
contained value. For this, we have to rely on the next type.
RefCell
In short, RefCell is a run-time borrow checker. When we call borrow or borrow_mut methods, the type will check
that the value is not already borrowed. If it is, we will get a panic. If we successfully get the mutable reference,
we can safely mutate the stored value, because we know that no-one else has any reference to our object.
Option
Option is required because we cannot create Input at the initialization time. We need to initialize the chip,
configure peripherals etc. Option allows us to initialize an "empty" object first and fill it later when we're ready.
Handler function
Next, let's look how we can define an interrupt handler function.
Import the handler macro:
#![allow(unused)] fn main() { use esp_hal::handler; }
Then define the handler function at the bottom of the file, like this:
#![allow(unused)] fn main() { #[handler] fn button_handler() { info!("GPIO interrupt!"); critical_section::with(|cs| { BUTTON .borrow(cs) // Borrow the value in Mutex .borrow_mut() // Mutably borrow value in RefCell .as_mut() // Get mutable reference to the value in Option .unwrap() // Unwrap the Option<&mut T> .clear_interrupt(); // Clear the interrupt flag }); } }
💡 To make things a bit more concise,
Mutexprovides aborrow_ref_mutmethod that combines theMutexborrow andRefCellborrow into one function. We will use that function from here on.
💡 Note how our handler function is marked with the
#[handler]attribute macro. We can usecargo-expandtool to expand the macros and see what code is being produced. Our handler function gets expanded to the code below:#![allow(unused)] fn main() { extern "C" fn __esp_hal_internal_button_handler() { // Function body goes here } #[allow(non_upper_case_globals)] const button_handler: esp_hal::interrupt::InterruptHandler = esp_hal::interrupt::InterruptHandler::new( __esp_hal_internal_button_handler, esp_hal::interrupt::Priority::min(), ); }
Setting up interrupts
Next, we can register our handler to handle the GPIO interrupts. Add this to the setup part of your main function:
#![allow(unused)] fn main() { let mut io = esp_hal::gpio::Io::new(peripherals.IO_MUX); io.set_interrupt_handler(button_handler); }
Finally, we start to listen to the button events and move the button object into our global state. We will use a "rising edge" event, which means that the interrupt will be triggered on button release.
Import Event:
#![allow(unused)] fn main() { use esp_hal::gpio::Event; }
Add this code right after the existing declaration of button.
#![allow(unused)] fn main() { critical_section::with(|cs| { button.listen(Event::RisingEdge); BUTTON.borrow_ref_mut(cs).replace(button); }); }
Experiment
Try changing the event type to falling edge. Do you observe any difference?
Exercise: toggle LED with interrupt
Now that we have set up the GPIO interrupt, try using it to toggle the LED. There are many ways to achieve this, so let's set some specific requirements:
-
Use
AtomicBoolWe have already seen how to use a Mutex, so for this exercise, let's try something new.
-
Toggle the LED in the main loop
In general, we want to avoid doing any work in the interrupt handler, so use the
AtomicBoolto signal to the main loop that the LED should be toggled. Remove any other code from the loop for now. -
Use
FallingEdgeinterrupt type -
Take care of debouncing
You will encounter switch bounce - several interrupt events occurring from a single button press. Try to come up with a solution for this problem so that the LED is only toggled once per button press. There are a multitude of ways to implement this so any solution that works is fine.
Solution
First, we'll need to import atomic and then declare our static object object.
#![allow(unused)] fn main() { use core::sync::atomic; static BUTTON_EVENT: atomic::AtomicBool = atomic::AtomicBool::new(false); }
Next, set the BUTTON_EVENT in the button_handler():
#![allow(unused)] fn main() { BUTTON_EVENT.store(true, atomic::Ordering::Relaxed); }
Finally, in the main loop, let's check the BUTTON_EVENT and if it's true, toggle the LED.
#![allow(unused)] fn main() { loop { if BUTTON_EVENT.load(atomic::Ordering::Relaxed) { led.toggle(); // Delay before resetting the BUTTON_EVENT acts as debouncing logic. delay.delay_millis(150); BUTTON_EVENT.store(false, atomic::Ordering::Relaxed); } } }
PWM
In this chapter, we will learn how to digitally control the brightness of the LED using Pulse-width modulation.
We will continue using the same hello_world project from the previous workshops.
esp-hal crate provides a PWM driver in mcpwm module. Let's start by importing the necessary modules and types:
#![allow(unused)] fn main() { use esp_hal::mcpwm::operator::PwmPinConfig; use esp_hal::mcpwm::timer::PwmWorkingMode; use esp_hal::mcpwm::{McPwm, PeripheralClockConfig}; use esp_hal::time::Rate; }
Next, let set up the PWM peripheral:
#![allow(unused)] fn main() { let clock_cfg = PeripheralClockConfig::with_frequency(Rate::from_mhz(40)).unwrap(); let mut mcpwm = McPwm::new(peripherals.MCPWM0, clock_cfg); let timer_clock_cfg = clock_cfg .timer_clock_with_frequency(255, PwmWorkingMode::Increase, Rate::from_khz(20)) .expect("could not determine parameters for the requested frequency"); mcpwm.operator0.set_timer(&mcpwm.timer0); mcpwm.timer0.start(timer_clock_cfg); }
💡 Note the use of
expect.expectperforms the same function asunwrap, but allows us to write a helpful error message. In general, use ofunwrapis discouraged outside of "example" code. Ideally, the errors should be handled gracefully or at least, provide an explanation of what has happened.
We'll change the LED pin to GPIO16, so we can have active-high configuration:
#![allow(unused)] fn main() { let led = Output::new( peripherals.GPIO16, esp_hal::gpio::Level::Low, OutputConfig::default(), ); }
Then, we will transfer GPIO to the PWM instance:
#![allow(unused)] fn main() { let mut led = mcpwm.operator0.with_pin_a(led, PwmPinConfig::UP_ACTIVE_HIGH); }
💡 Note how we're re-using the name
led. This is called rebinding and allows us to write cleaner code.
Finally, let's modify our application to cycle through different brightness levels in a loop:
#![allow(unused)] fn main() { let mut sequence = [32, 128, 255].iter().cycle(); loop { led.set_timestamp(*sequence.next().unwrap()); delay.delay_millis(1000); } }
Exercise: LED breathing effect
We learnt how to control the LED brightness and make the brightness ramp-up. For this exercise, you have to implement a commonly-used breathing effect.
The sequence is roughly as follows:
- LED brightness should increase gradually until it reaches the maximum.
- LED brightness should decrease gradually until it reaches zero.
- LED stays off for a couple of seconds.
- The sequence repeats.
Solution
The requirements of this exercise a suited perfectly for a state machine
and Rust's enums work great for state machines.
Let's define our enum. We can achieve our goals by using two variants (states): one for increasing brightness and one for decreasing. The enum variants will hold the actual brightness value.
#![allow(unused)] fn main() { enum LedState { Up(u16), Down(u16), } }
Let's create the instance of our LedState. Declare this variable just above the main loop:
#![allow(unused)] fn main() { let mut led_state = LedState::Up(0); }
We will start at zero brightness and increasing direction.
Finally, let's implement our state transitions:
#![allow(unused)] fn main() { loop { match led_state { // We have reached maximum, change state to ramp down. LedState::Up(255) => { led.set_timestamp(255); led_state = LedState::Down(255); } // Ramping up: increment the brightness. LedState::Up(brightness) => { led.set_timestamp(brightness); led_state = LedState::Up(brightness + 1); } // We have reached minimum, wait two seconds then change state to ramp up. LedState::Down(0) => { led.set_timestamp(0); delay.delay_millis(2000); led_state = LedState::Up(0); } // Ramping down: decrement the brightness. LedState::Down(brightness) => { led.set_timestamp(brightness); led_state = LedState::Down(brightness - 1); } } delay.delay_millis(10); } }