Blog

Blink an LED on an ESP32, without installing anything

tutorial


Getting the first LED to blink on a new microcontroller usually costs an afternoon. Not because blinking an LED is hard, but because everything around it is: a Rust toolchain with the right target, espflash, USB drivers, a linker script, and a .cargo/config.toml you copied from somewhere and do not yet understand. None of that teaches you anything about the chip.

This walks through the same sketch with none of that. You will read a working no_std Rust program, compile it on our build farm, write it to a board plugged into your own machine, and watch it print over serial — from a browser tab.

What you need#

A supported ESP32 devkit and a USB cable that carries data. Some charging cables do not; if the board never appears in the port list, that is the first thing to suspect.

Chrome or Edge on a desktop or laptop. Flashing uses Web Serial, which Firefox and Safari do not implement — this is a browser capability, not a policy choice, so there is no flag to turn on. Everything except flashing works in any browser.

No drivers, on any current OS. Boards with a native USB-Serial-JTAG peripheral (most C3, C6 and S3 devkits) enumerate as a standard device. Older boards with a CP2102 or CH340 bridge are handled by the OS on macOS 11+, Windows 10+ and Linux.

Open the sketch#

Each board has its own version, because the LED sits on a different pin on each one. Pick yours:

BoardLED pinPlayground
ESP32GPIO2Open →
ESP32-C3GPIO8Open →
ESP32-C6GPIO8Open →
ESP32-H2GPIO8Open →
ESP32-S2GPIO15Open →
ESP32-S3GPIO48Open →

Those pins are the common devkit wiring, not a property of the chip. Plenty of third-party boards put the LED somewhere else, or fit an addressable RGB LED that ignores a plain high/low entirely. If nothing lights up but the serial log looks right, check your board's schematic and change the pin number — that is a one-word edit and it will rebuild.

The code#

Here is the whole program, minus the pin-monitoring helper:

#![no_std]
#![no_main]

use esp_backtrace as _;
use esp_hal::{
    delay::Delay,
    gpio::{Level, Output, OutputConfig},
    main,
};
use esp_println::println;

esp_bootloader_esp_idf::esp_app_desc!();

#[main]
fn main() -> ! {
    let peripherals = esp_hal::init(esp_hal::Config::default());
    let mut led = Output::new(peripherals.GPIO8, Level::Low, OutputConfig::default());
    let delay = Delay::new();

    let mut on = false;
    loop {
        led.toggle();
        on = !on;
        println!("blinky: LED {}", if on { "on" } else { "off" });
        delay.delay_millis(500);
    }
}

#![no_std] says there is no operating system underneath, so the standard library is out; you get core, which has everything except heap allocation and OS services. #![no_main] says the usual Rust startup sequence is out too. The #[main] attribute from esp-hal puts the real entry point where the chip's reset vector expects it.

main returns ! — it never returns. There is nothing to return to: this function is the whole program. If it ever did fall off the end, the chip would reset and start it again.

esp_hal::init takes ownership of every peripheral and hands back a struct with one field per peripheral. That struct is how the type system stops you using GPIO8 twice: Output::new consumes peripherals.GPIO8, so a second attempt does not compile. Conflicting pin ownership is a classic embedded bug, and here it is simply not expressible.

The line most likely to cost you an evening on your own is this one:

esp_bootloader_esp_idf::esp_app_desc!();

It emits a small descriptor into a .flash.appdesc section. The ESP-IDF second stage bootloader reads that structure to validate the image before jumping into it, so a binary without it is rejected — espflash will tell you the app descriptor is missing. The macro has to come from esp-bootloader-esp-idf 0.5.x, because that is the version whose section name matches what esp-hal 1.1.1's linker script keeps. Pair it with an older release and the descriptor lands in a section that gets discarded, which produces the same error with none of the explanation.

Delay here is a busy-wait. It blocks the core for 500 ms and burns power doing nothing, which is fine for a first sketch and wrong for anything on a battery. Async with an executor is the answer, and the ESP-NOW tutorial uses it.

Build it#

Press Build, or ⌘B / Ctrl+B. Real cargo output streams into the drawer as it compiles, on a machine with the toolchain already warm. The first build of a sketch takes a few seconds; the last line reports the image size.

Press Build again without changing anything and you get Cache hit — identical sources already built. Builds are keyed on a hash of the sources, the target and the exact toolchain, so identical input never compiles twice. Type a single character and Flash goes dim: the built image no longer matches what is on screen, and offering to flash a stale binary would be a worse answer than disabling the button.

Connect the board#

Plug the board in and press Connect Device. The browser shows its own port chooser — the page cannot see your serial ports until you pick one, and the grant covers exactly that device. Nothing is auto-detected behind your back.

Once connected, the device panel names the USB identity, something like Espressif USB-JTAG · 0x1001. It deliberately never shows a /dev/… path, because Web Serial does not expose one.

Flash it#

Press Flash, or ⌘⏎ / Ctrl+Enter. Progress runs 0 to 100% with real byte counts.

Before writing anything, the flasher reads the chip ID off the board and compares it against the target the image was built for. If they disagree it stops there:

chip mismatch: connected ESP32-C3, built for esp32-c6

That check happens before the first byte, so picking the wrong board in the dropdown costs you a message rather than a corrupted flash.

When the write finishes, the board hard-resets and the drawer switches to the serial monitor at 115200 baud. You should see the ROM boot banner, then:

blinky: LED on
blinky: LED off
blinky: LED on

twice a second, with the LED on the board keeping time. Open the Pins tab and GPIO8 alternates between driven high and driven low in the table and the diagram — that readout comes from the gpio_monitor module in the sketch, which prints the GPIO bank registers for the IDE to parse.

When it doesn't work#

The board never appears in the port chooser. Try another cable first; this is the most common cause by a wide margin. If the board has a UART bridge rather than native USB, check that your OS enumerated it at all.

Flashing fails to start on a board with a CP2102 or CH340. Those bridges need DTR/RTS toggled to enter the download mode, which the flasher drives automatically. A few board designs leave the auto-reset circuit off. Hold BOOT, tap RESET, release BOOT, then press Flash.

The flash succeeds but the monitor stays silent. Native USB-Serial-JTAG boards disconnect and re-enumerate when the chip resets, so the port briefly disappears underneath us. We retry for about a second and a half; if that is not enough, press Connect again.

Could not reach the build farm — is the API running? The build service is unreachable. Nothing is wrong with your board or your code.

Build servers are offline — try again in a few minutes. The farm is up but has no builder to give the job to, so it refuses immediately rather than leaving you watching a spinner.

The serial log is right but the LED is dark. The sketch is running and the pin is wrong for your board. See the note under the table above.

You are worried about bricking it. You cannot, not from here. The first stage bootloader lives in mask ROM and cannot be overwritten by any amount of flashing. A bad image boot-loops; flashing a good one recovers it.

Next#

Change something. delay_millis(500) to 100 is the obvious one — build, flash, watch the log speed up. Or add a second Output on another pin and alternate them.

When you want the board to talk to something other than your laptop, ESP-NOW is the shortest path: two boards, no router, no pairing, about fifteen lines of setup.


Ready to try it? Open the playground — it compiles on the build farm and flashes over Web Serial, with nothing to install.