Flash Microcontrollers from Rust with cargo-embed

cargo-embed lets you flash and debug embedded Rust applications directly from your terminal, bypassing IDEs and complex toolchains.

Let’s flash an ESP32-C3 with a simple "Hello, World!" to the serial port.

First, ensure you have cargo-embed installed:

cargo install cargo-embed

Next, create a new Rust project for your embedded target. For an ESP32-C3, you’ll need to configure your Cargo.toml and .cargo/config.toml.

Cargo.toml:

[package]
name = "esp32c3-hello"
version = "0.1.0"
edition = "2021"

[dependencies]
esp32c3-hal = "0.11.0"
embedded-hal = "0.2.7"
nb = "1.0.0"
embedded-io = "0.5.0"
esp-println = { version = "0.1.0", features = ["esp32c3"] }

[profile.dev]
# Optimize for debuggability and speed
opt-level = "z"
debug = true
lto = true
codegen-units = 1

[profile.release]
opt-level = "z"
debug = false
lto = true
codegen-units = 1

.cargo/config.toml:

[build]
target = "riscv32imac-unknown-none-elf"

[target.riscv32imac-unknown-none-elf]
runner = "espflash --release --monitor"
rustflags = [
  "-C", "link-arg=-Tlink.x",
  "-C", "link-arg=-Tdefmt.x",
]

[unstable]
build-std = ["core", "alloc"]

You’ll also need a link.x file for the linker script. A minimal one for ESP32-C3 looks like this:

link.x:

ENTRY(entry)

MEMORY
{
    FLASH : ORIGIN = 0x40000000, LENGTH = 128M
    RAM : ORIGIN = 0x3FC00000, LENGTH = 320K
}

SECTIONS
{
    .text : ALIGN(4)
    {
        *(.text*)
    } > FLASH

    .rodata : ALIGN(4)
    {
        *(.rodata*)
    } > FLASH

    .data : ALIGN(4)
    {
        *(.data*)
    } > RAM

    .bss : ALIGN(4)
    {
        *(.bss*)
    } > RAM
}

And defmt.x for defmt if you plan to use it (which esp-println uses implicitly):

defmt.x:

OUTPUT_ARCH(riscv)
ENTRY(_start)

MEMORY
{
    FLASH (rx) : ORIGIN = 0x40000000, LENGTH = 128M
    RAM (xrw) : ORIGIN = 0x3FC00000, LENGTH = 320K
}

SECTIONS
{
    .text : ALIGN(4)
    {
        KEEP(*(.text._start))
        *(.text*)
    } > FLASH

    .rodata : ALIGN(4)
    {
        *(.rodata*)
    } > FLASH

    .data : ALIGN(4)
    {
        _sdata = .;
        *(.data*)
        . = ALIGN(4);
        _edata = .;
    } > RAM AT > FLASH

    .bss : ALIGN(4)
    {
        _sbss = .;
        *(.bss*)
        . = ALIGN(4);
        _ebss = .;
    } > RAM

    .comment :
    {
        *(.comment)
    } > FLASH
}

Now, the main application code in src/main.rs:

#![no_std]
#![no_main]

use esp32c3_hal::{
    clock::ClockControl,
    peripherals::Peripherals,
    prelude::*,
    timer::TimerGroup,
    Rtc,
    IO,
};
use esp_println::println;
use nb::block;

#[entry]
fn main() -> ! {
    let peripherals = Peripherals::take();
    let mut system = peripherals.SYSTEM.split();
    let clocks = ClockControl::boot_defaults(peripherals.IO).freeze();

    let mut rtc = Rtc::new(peripherals.RTC_CNTL);
    let timer_group0 = TimerGroup::new(peripherals.TIMG0, &clocks);
    let mut wdt0 = timer_group0.wdt0;
    let timer_group1 = TimerGroup::new(peripherals.TIMG1, &clocks);
    let mut wdt1 = timer_group1.wdt1;

    // Disable the RTC watchdogs
    rtc.swd.disable();
    wdt0.disable();
    wdt1.disable();

    let io = IO::new(peripherals.GPIO, peripherals.IO_MUX);
    // Configure the UART pins for serial output
    let pins = io.split_pins();

    // Initialize UART and print "Hello, world!"
    esp_println::init();
    println!("Hello, world!");

    loop {
        // Keep the program running
    }
}

With your hardware connected (USB-to-serial adapter for the ESP32-C3’s UART0 TX/RX pins, or just the USB port if it’s a dev board with built-in USB-serial), you can now flash the microcontroller using cargo embed:

cargo embed --chip esp32c3

This command will compile your Rust code, link it with the correct memory layout, and then use espflash (configured in .cargo/config.toml) to upload the binary to the ESP32-C3. If successful, you should see "Hello, world!" printed in your serial terminal.

The key to cargo-embed is its integration with the Rust build system and its ability to call out to flashing and debugging tools. By defining the runner in .cargo/config.toml, you tell cargo what command to execute after a successful build. espflash is a popular tool for flashing Espressif devices, and cargo-embed orchestrates its execution. The --chip esp32c3 flag tells cargo-embed which target architecture it’s dealing with, allowing it to pick the appropriate toolchain and settings.

The rustflags in .cargo/config.toml are crucial for embedded development. -C link-arg=-Tlink.x tells the linker to use your custom link.x script, defining memory regions and section placement. -C link-arg=-Tdefmt.x similarly guides the defmt formatter.

The profile.dev and profile.release sections in Cargo.toml are optimized for embedded. opt-level = "z" prioritizes code size, which is critical for limited microcontroller flash memory. lto = true (Link Time Optimization) further helps reduce binary size by optimizing across the entire project at link time. debug = true in the dev profile keeps debugging symbols.

When you run cargo embed, it first performs a cargo build using the specified target and profiles. If the build succeeds, it then invokes the configured runner. In this case, espflash --release --monitor is executed. espflash handles the low-level details of putting the ESP32-C3 into bootloader mode, transferring the compiled firmware, and then resetting the chip to run the new code. The --monitor flag automatically starts a serial terminal to view the output.

The esp-println crate provides a convenient way to use println! macros that are redirected to the device’s serial port, making debugging and output straightforward. The #[entry] attribute from esp32c3-hal marks the main function as the program’s entry point.

The surprising thing about embedded Rust is how much of the standard library you can leverage, even without an operating system. alloc and core are available via build-std, and crates like esp32c3-hal provide hardware abstractions that feel very idiomatic to Rust, using traits and generics.

The next step is often integrating peripherals like GPIOs, I2C, or SPI, which involves exploring the capabilities of the esp32c3-hal crate further.

Want structured learning?

Take the full Cargo course →