ramblings of a chaotic mind

Rust on ESP32

on 2024-01-04 by Gertjan Assies reading time 10 min, viewed 135 times, read 1 times, liked 0 times

Rust on ESP32

This is a series of articles about using Rust to program an ESP32 Microcontroller by building a minimal EV Charger.

For the last couple of months, I’ve been learning myself how to program in rust

The main reason was I was intrigued by Rust’s ownership / borrow model, which allows for functional programming, without the need of making everything immutable.

Immutable comes with copying values (instead of passing references) all over the place, which can become an issue when you want to sqeeze every bit of performance out of your application.

As rust is considered more of a systems language, being close to the hardware, using it to write applications for microcontrollers makes sense.

Luckily the makers of the ESP 32 chip espressif also created and maintain a development environment for these chips. What they also did was create a bunch of rust crates that allows access to the chip hardware and features.

There are two modes to write your code, no_std and std

With the no_std, you cannot use Rust its standard library, which makes your application smaller, but you have to do a lot yourself, there are peripheral access and hardware abstraction crates and crates to setup wifi, logging, storage, etc, these are very much device specific. but you have to do all the wiring up.

Therefore I will focus on the higher level std approach which allows the use of all the goodies the standard library brings, plus more device independent abstractions which makes the code much more portable to other ESP32 devices.

Application model

The application model for the std mode look like this:

Application Model

The following libraries are made available

Library Description
esp-idf-svc Services like Wifi, Mqtt, Http
embedded-svc device independent api and abstractions for the services
esp-idf-hal safe wrappers around esp-idf-sys
embedded-hal device independent api and common traits for the hardware
esp-idf-sys unsafe bindings to the ESP hardware and features

A filtered out dependency tree from cargo tree shows how the libraries depend on each other

├── esp-idf-hal v0.42.5
│   ├── embedded-hal v0.2.7
│   ├── esp-idf-sys v0.33.7
├── esp-idf-svc v0.47.3
│   ├── embedded-svc v0.26.4
│   ├── esp-idf-hal v0.42.5
|   ├── esp-idf-sys v0.33.7

Use case

As my daily work is all about allowing EV Drivers to charge their cars, I’ve decided to make a small EV charger

For this I need to control GPIO (General Purpose Input Output) Pins to enable charging (relay), lock the cable (solenoid), display the charger state (multicolor led) and communicate to a backend (MQTT / IoT)

Application Model


I decided to use a M5 Stamp C3U that I had lying around which is based on the ESP-32 C3U chip, a 1 core 32 bits RISC-V cpu running on 160Mhz

It has 400Kb RAM and 4Mb Flash and has Wifi and Bluetooth capabilities

There are 14 GPIO Pins, that can be setup als digital or analog input/ouput, and some of them use serial protocols like UART, I2C, I2S and SPI so plenty to play around with


To get started I used the cookiecutter template Espressif has created: https://github.com/esp-rs/esp-idf-template

This should also be your starting point, if you want to play with it yourselves.

After installing the prerequisites a simple

cargo generate esp-rs/esp-idf-template cargo

will after answering a few questions generate a project for you.

to build and flash it hook up your device to an usb port and do

> cargo build
   Compiling rust-esp-charger v0.1.0 (/Users/gertjan/Projects/rust-esp-charger)
    Finished dev [optimized + debuginfo] target(s) in 1.96s

and then:

> espflash flash target/riscv32imc-esp-espidf/debug/rust-esp-charger
[2024-01-04T12:53:07Z INFO ] Detected 4 serial ports
[2024-01-04T12:53:07Z INFO ] Ports which match a known common dev board are highlighted
[2024-01-04T12:53:07Z INFO ] Please select a port
❯ /dev/cu.usbmodem1101 - USB JTAG_serial debug unit
  /dev/tty.usbmodem1101 - USB JTAG_serial debug unit
[2024-01-04T12:54:07Z INFO ] Serial port: '/dev/cu.usbmodem1101'
[2024-01-04T12:54:07Z INFO ] Connecting...
[2024-01-04T12:54:07Z INFO ] Using flash stub
Chip type:         esp32c3 (revision v0.3)
Crystal frequency: 40MHz
Flash size:        4MB
Features:          WiFi, BLE
MAC address:       84:f7:03:27:96:ec
App/part. size:    559,520/4,128,768 bytes, 13.55%
[00:00:04] [========================================]     271/271
[2024-01-04T12:54:13Z INFO ] Flashing has completed!

The target might differ depending on the type of your ESP32 , but the rest should be the same.

If you do

> espflash monitor

It will show you all the messages logged to the console, you can also add --monitor to the flash command to do it in one go

The Code

My first thoughts were that I would have to create multiple running tasks, that would handle controlling the hardware and communicating with the backend. so first I had to find out how to do that in rust and how I can communicate changes to those tasks.

As the C code IDF Framwork is based on the FreeRTOS Kernel, I knew about things like xTaskPinnedToCore() which runs a task in parallel to the main thread, and the xQueue* API to send receive messages between tasks, unfortunately xTaskPinnedToCore() only allows C functions as a parameter, so no Rust goodies in there (or at least not to my knowledge, what’s the signature of a rust function in C?)

But as I can use the standard library, std::thread::spawn is available to which I can pass a closure, but how to communicate changes?

When you access a variable in the closure that is defined outside of the closure the compiler will make you use the move keyword to explicitly move ownership to the closure.

So for instance: if you have a simple Charger struct that has a field state, which holds the state of the charger (Available, Occupied, Charging, Error) and you want to access that from within the thread, the compiler will complain about the last line that the charger’s ownership has moved to the thread. as seen in the snippet below

let mut charger = Charger{id: "1", state: "Available"}

thread:spawn(move || { 
    println!("Charger from thread: {:?}", charger);
    loop {
        charger.state = "Charging";
        // sleep a bit

println!("Charger from main: {:?}", charger);

To solve this we need to use two concepts from rust synchronisation utilities.

  • std::sync::Arc<T> Arc stands for Atomically Reference Counted, This will keep a reference for each time you clone the object, and makes sure the original object is updated with any changes.
  • std::sync::Mutex<T> A Mutex will lock the object so only one thread can update the object at a time

So to get the above working:

let org_charger: Arc<Mutex<Charger>> = Arc::new(Mutex::new(Charger{id: "1", state: "Available"}));

let charger = org_charger.clone();
thread:spawn(move || { 
    println!("Charger from thread: {:?}", charger);
    loop {
        charger.state = "Charging";
        // sleep a bit

let charger = org_charger.clone();
loop {
    println!("Charger from main: {:?}", charger);
    // sleep a bit

Note that because the charger moves into the thread, I can reuse the charger var as the first one is no longer in scope, also note that the object does not need to be mutable anymore.

Now how to control the leds, relays, buttons that are needed to make this charger functional?

That’s done through the “General Purpose Input Output” or GPIO, working with GPIO is pretty straightforward:

Here we turn a led on when a button is pressed.

use esp_idf_hal::delay::FreeRtos;
use esp_idf_hal::gpio::*;
use esp_idf_hal::peripherals::Peripherals;

let peripherals = Peripherals::take()?;
let mut led = PinDriver::output(peripherals.pins.gpio4)?;
let mut button = PinDriver::input(peripherals.pins.gpio9)?;


loop {
    // we are using thread::sleep here to make sure the watchdog isn't triggered

    if button.is_high() {
    } else {

Doing a button in a more async ‘wait until pressed then do something’ is a bit more involved:

First set up the button and an interrupt:

use esp_idf_hal::peripherals::Peripherals;
use esp_idf_svc::hal::gpio::{InterruptType, PinDriver, Pull};
use esp_idf_svc::hal::task::notification::Notification;

let mut button = PinDriver::input(peripherals.pins.gpio9)?; // pin 9 is the onboard button on the M5 Stamp C3U

let notification = Notification::new();
let notifier = notification.notifier();

unsafe {
        .subscribe(move || {

Here we have created an interrupt that will notify when the button is pressed in this case on a Positive Edge (when the button is released).

Now we can enable the interrupt and block until it is interrupt by someone pressing the button.

loop {
    notification.wait(esp_idf_svc::hal::delay::BLOCK); // code blocks until button interrupts
    println!("Button was pressed and released");

We do this in a separate thread so it won’t block the rest

The M5 Stamp that i’m using has an onboard multicolor led (SK6812 chip) attached to a gpio port, there is a rust library for that and other multicolor led, or ledstrips call smart_leds

use smart_leds_trait::{SmartLedsWrite, White};
use ws2812_esp32_rmt_driver::driver::color::LedPixelColorGrbw32;
use ws2812_esp32_rmt_driver::{LedPixelEsp32Rmt, RGBW8};

fn main() -> Result {
    let driver = LedPixelEsp32Rmt::<RGBW8, LedPixelColorGrbw32>::new(0, 2)?; //Onboard led is attached to pin2
    let red = RGBW8::from((255, 0, 0, White(0)));
    let color = std::iter::repeat(red).take(1);

In the code linked at the bottom, I combined all the above in an application that runs on the ESP32-C3U and will cycle through all the charger states when the onboard button is pressed. showing the correct color on the onboard led and output the charger state in the console.

Proving that you can run Rust on an ESP32, access the hardware and run code concurrently while being able to access shared objects.

Thanks for reading, the next articles will most likely expand on this and be about:

  • Controlling a minimal hardware setup for a charger (Relay, Led, Button) based on the charger’s state
  • Setup Wifi and MQTT to communicate with a backend, using the OCPP protocol
  • Optionally: Control Pilot (CP) communications between the charger and the car over a Type2 Mennekes connector


Opinions expressed here are my own and not the views of my employer or anyone else, (re)use is free, but quoting the source is appreciated.
This blog is licensed under a Creative Commons Attribution 4.0 International License. © 2023 by Gertjan Assies