Chapter 21: Blinky
Learning Objectives
- Control GPIO pins to drive an LED
- Understand digital output (high/low)
- Implement a busy-wait delay loop
- Build the classic “Hello World” of embedded systems
The Classic Blinky
Blinking an LED is the embedded equivalent of “Hello World” — it proves you can control hardware. Most ESP32-C3 development boards have a built-in LED connected to GPIO8.
Starting Point
We start from the same esp-generate project structure as Chapter 20. The only dependency we need is esp-hal — no esp-println required.
[dependencies]
esp-hal = { version = "1.0.0", features = ["esp32c3"] }
esp-bootloader-esp-idf = { version = "0.4.0", features = ["esp32c3"] }
critical-section = "1.2.0"
The Code
#![no_std] #![no_main] use esp_hal::clock::CpuClock; use esp_hal::gpio::{Level, Output, OutputConfig}; use esp_hal::main; use esp_hal::time::{Duration, Instant}; #[panic_handler] fn panic(_: &core::panic::PanicInfo) -> ! { loop {} } esp_bootloader_esp_idf::esp_app_desc!(); #[main] fn main() -> ! { let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max()); let peripherals = esp_hal::init(config); // Configure GPIO8 as a digital output, starting LOW (LED off) let mut led = Output::new(peripherals.GPIO8, Level::Low, OutputConfig::default()); loop { led.set_high(); // LED on let delay_start = Instant::now(); while delay_start.elapsed() < Duration::from_millis(500) {} led.set_low(); // LED off let delay_start = Instant::now(); while delay_start.elapsed() < Duration::from_millis(500) {} } }
How GPIO Works
GPIO stands for General Purpose Input/Output. Each pin can be configured as either input or output:
ESP32-C3
┌───────────┐
│ │
GPIO8 ──────── │ Output │ ──── LED
│ Register │
│ │
└───────────┘
set_high() → pin outputs 3.3V → LED turns on
set_low() → pin outputs 0V → LED turns off
Output::new()
#![allow(unused)] fn main() { let mut led = Output::new( peripherals.GPIO8, // Which pin Level::Low, // Initial state OutputConfig::default(), // Default drive strength, no pull-up/down ); }
The three arguments:
- Pin:
peripherals.GPIO8— Rust’s ownership system ensures only one part of your code controls this pin - Initial level:
Level::Low— start with LED off - Config: Drive strength and other electrical settings (defaults are fine)
Ownership
Notice that peripherals.GPIO8 is moved into Output::new(). This is Rust’s ownership system at work — you can’t accidentally configure the same pin twice from different parts of your code. This is a compile-time guarantee that prevents a whole class of hardware bugs.
The Busy-Wait Delay
#![allow(unused)] fn main() { let delay_start = Instant::now(); while delay_start.elapsed() < Duration::from_millis(500) {} }
This is a busy-wait (also called spin-loop): the CPU continuously checks the clock until 500ms have passed. It’s simple but wasteful — the CPU can’t do anything else during the wait.
In Chapter 22 we’ll see how Embassy’s async runtime solves this —
Timer::after(Duration::from_millis(500)).awaitlets the CPU sleep or do other work while waiting.
Build & Flash
cargo run --release
You should see the LED blinking at 1 Hz (on for 500ms, off for 500ms).
Exercise
- Change the blink rate to 200ms on, 800ms off (asymmetric blink)
- Add
esp-printlnand print “ON” / “OFF” each time the LED changes state - Create a pattern: 3 fast blinks (100ms), then a 1-second pause, repeat