Your LED is on. Now what? Most bare-metal "hello world" guides stop at the blink,
leaving you with a flat main loop, printf-over-UART, and no clear path to a
second task. This post builds the real starting line: an LED blinks
asynchronously while RTT streams live structured logs to your computer — both
running concurrently without a single blocking delay.
What you'll learn
- Why
#![no_std]and#![no_main]are mandatory on bare metal, and what you lose (and don't lose) by droppingstd - How the Embassy executor schedules cooperative tasks on a single thread with zero heap allocation
- How to configure the STM32H7 PLL chain to hit 480 MHz SYSCLK through Embassy's
typed
Configstruct - How
defmt+ RTT gives you timestamped, structured logs over your existing debug probe — no UART, no extra wires - How
cargo runbecomes flash-and-stream in one command with the probe-rs runner - The
rustflagsplacement that silently breaks every first-time setup
Setting up the project
Create a new binary crate and wire the dependencies in Cargo.toml:
[package]
name = "h7-blink"
version = "0.1.0"
edition = "2021"
[dependencies]
embassy-executor = { version = "0.6", features = [
"arch-cortex-m",
"executor-thread",
"defmt",
] }
embassy-stm32 = { version = "0.1", features = [
"stm32h743zi",
"time-driver-any",
"defmt",
"memory-x",
] }
embassy-time = { version = "0.3", features = [
"defmt",
"defmt-timestamp-uptime",
] }
defmt = "0.3"
defmt-rtt = "0.4"
cortex-m = { version = "0.7", features = ["inline-asm"] }
cortex-m-rt = "0.7"
panic-probe = { version = "0.3", features = ["print-defmt"] }
[profile.release]
opt-level = "z"
lto = "fat"
The memory-x feature in embassy-stm32 generates the linker memory map for
your specific chip — no hand-written memory.x required.
Wiring the probe-rs runner
Create .cargo/config.toml. This file is responsible for two of the most common
first-time mistakes:
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32H743ZITx"
rustflags = [
"-C",
"link-arg=-Tlink.x",
"-C",
"link-arg=-Tdefmt.x",
]
[build]
target = "thumbv7em-none-eabihf"
⚠️ Common gotcha:
rustflags(line 3) must live under[target.thumbv7em-none-eabihf](line 1), not under[build](line 10). Under[build], those linker flags apply to every build target including host-side proc-macros and build scripts, producing deeply confusing linker errors. The[target.*]section scopes them to cross-compilation only.
With this in place, cargo run --release compiles, flashes the chip via the
probe, and immediately begins streaming RTT output to your terminal. There is no
separate flash step.
No OS, no allocator: what #![no_std] actually removes
Every Embassy firmware file starts with two attributes:
#![no_std]
#![no_main]
ℹ️ What this means — for Rust developers new to embedded: Rust's standard library (
std) is built on OS services: heap allocation, file I/O, threads, environment variables, panic unwinding with a stack trace. None of those exist on bare metal.#![no_std]tells the compiler to link againstcoreonly — Rust's OS-independent subset, which includes iterators, option/result, traits, and arithmetic. You loseVec,String, andHashMap; you keep almost everything else.#![no_main]removes the default C-runtime entry point so the Embassy runtime can define the correct bare-metal startup sequence.
For engineers coming from C/C++: this is the Rust equivalent of your startup
file skipping the CRT and jumping directly into your application, with you
responsible for __bss__ zeroing and stack pointer initialisation — except
cortex-m-rt handles all of that for you.
The Embassy executor: one thread, many tasks
Embassy's executor runs cooperative tasks on a single thread. Each task is an
async fn that yields at .await points. When a task is waiting — for a timer,
a peripheral, a channel message — the executor drives the next ready task. On a
Cortex-M7 without an RTOS, this produces deterministic, interrupt-driven
scheduling with none of the context-switch overhead of a preemptive kernel.
The entry point uses the #[embassy_executor::main] macro:
#![no_std]
#![no_main]
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_time::Timer;
use defmt::info;
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
let mut led = Output::new(p.PB14, Level::High, Speed::Low);
loop {
info!("tick");
led.toggle();
Timer::after_millis(500).await;
}
}
Line 18, Timer::after_millis(500).await, suspends the current task and returns
control to the executor. The CPU is not spinning; it enters WFI (wait-for-interrupt)
until the hardware timer fires. If a second task were spawned — a sensor read, a
display refresh — it would run during those 500 ms windows without any change to
this code.
ℹ️ What this means — for Rust developers new to embedded:
.awaithere does not park an OS thread. There are no threads. The compiler transforms theasync fnbody into a state machine; at each.awaitit emits a state transition that the executor drives forward when the awaited resource is ready. The result is zero heap allocation and zero scheduler overhead compared to a preemptive RTOS task.
Configuring the STM32H7 clock tree
Running at 480 MHz requires an explicit PLL configuration. Embassy exposes the
full PLL chain through a typed Config struct; no register writes, no
bit-field offsets, no generated CubeMX boilerplate.
use embassy_stm32::rcc::{
AHBPrescaler, APBPrescaler, Hse, HseMode,
Pll, PllDiv, PllMul, PllPreDiv, PllSource, Sysclk,
};
use embassy_stm32::time::Hertz;
use embassy_stm32::Config;
let mut config = Config::default();
{
let rcc = &mut config.rcc;
// 8 MHz crystal on Nucleo-H743ZI2
rcc.hse = Some(Hse {
freq: Hertz(8_000_000),
mode: HseMode::Oscillator,
});
// HSE ÷ 2 → 4 MHz VCO input; × 240 → 960 MHz VCO; ÷ 2 → 480 MHz SYSCLK
rcc.pll1 = Some(Pll {
source: PllSource::Hse,
prediv: PllPreDiv::DIV2,
mul: PllMul::MUL240,
divp: Some(PllDiv::DIV2), // 480 MHz → Cortex-M7
divq: Some(PllDiv::DIV4), // 240 MHz → SPI / SDMMC
divr: None,
});
rcc.sys = Sysclk::Pll1P;
rcc.ahb_pre = AHBPrescaler::DIV2; // 240 MHz AHB bus
rcc.apb1_pre = APBPrescaler::DIV2; // 120 MHz APB1
rcc.apb2_pre = APBPrescaler::DIV2; // 120 MHz APB2
rcc.apb3_pre = APBPrescaler::DIV2;
rcc.apb4_pre = APBPrescaler::DIV2;
}
let p = embassy_stm32::init(config);
ℹ️ What this means — for Rust beginners: The
Pllstruct encodes the divider chain as typed enum variants. Supplying an out-of-range multiplier or an invalid input frequency is a compile error, not a silent misconfiguration that produces the wrong clock at runtime. The embedded C equivalent — a hundred generated register-write lines from CubeMX — offers no such guarantee.
The diagram below shows the active clock paths for this configuration:
%%{init: {'theme': 'base', 'themeVariables': {
'primaryColor': '#edb059',
'primaryTextColor': '#222222',
'primaryBorderColor': '#c8951f',
'lineColor': '#5a5450',
'secondaryColor': '#ede9e4',
'secondaryTextColor': '#2d2a26',
'tertiaryColor': '#222222',
'tertiaryTextColor': '#edb059',
'background': '#faf8f5',
'mainBkg': '#faf8f5',
'noteBkgColor': '#f2c880',
'noteTextColor': '#222222',
'clusterBkg': '#faf8f5',
'clusterBorder': '#ede9e4',
'titleColor': '#2d2a26'
}}}%%
flowchart LR
HSE["HSE\n8 MHz"]:::src
HSI["HSI\n64 MHz"]:::src
HSI48["HSI48\n48 MHz"]:::src
HSE --> PLL1["PLL1\n÷2 × 240"]:::pll
HSI --> PLL2["PLL2"]:::pll
PLL1 -- "÷2" --> SYSCLK["SYSCLK\n480 MHz\nCortex-M7"]:::out
PLL1 -- "÷4" --> SPI_CLK["SPI / SDMMC\n240 MHz"]:::out
PLL2 -- "÷N" --> FDCAN_CLK["FDCAN kernel"]:::out
HSI48 --> USB_CLK["USB 48 MHz\n(+ CRS trim)"]:::out
classDef src fill:#5a5450,color:#faf8f5,stroke:#5a5450
classDef pll fill:#222222,color:#edb059,stroke:#edb059
classDef out fill:#edb059,color:#222222,stroke:#c8951f
STM32H7 clock paths for a typical Embassy bring-up: HSE feeds PLL1 for SYSCLK at 480 MHz; PLL2 is available for FDCAN; HSI48 feeds USB directly.
defmt + RTT: structured logs without UART
defmt is a deferred-format logging framework: format strings stay on the host
as symbol table entries; the target writes only argument bytes over the wire.
RTT (Real-Time Transfer) is a memory-mapped ring buffer that probe-rs reads over
the existing SWD/JTAG connection. No UART pins, no USB-CDC, no extra hardware.
The use {defmt_rtt as _, panic_probe as _} in main.rs is the linker hook
that activates both. After that, logging is:
use defmt::{info, warn, debug};
info!("SYSCLK running at {} MHz", 480_u32);
debug!("tick #{}", tick_count);
warn!("supply voltage low: {} mV", vcc_mv);
The macros are type-safe — format specifiers are resolved at compile time.
info!("{}", some_enum) works if the enum derives defmt::Format; a
type mismatch is a compile error, not a runtime format panic.
Running cargo run --release now produces:
0.000000 INFO SYSCLK running at 480 MHz
0.000512 DEBUG tick #0
0.500563 DEBUG tick #1
1.000598 DEBUG tick #2
Timestamps come from the Embassy time driver, accurate to the tick resolution
set in Cargo.toml (tick-hz-32_768 gives ~30 µs resolution).
Key takeaways
#![no_std]removes the OS-dependent half of Rust's standard library; the language — traits, iterators, option/result, arithmetic — is fully available.- The Embassy executor is a cooperative, single-threaded state machine.
.awaityields the task back to the runtime; nothing spins, nothing blocks. - Embassy's typed
Configstruct expresses the full PLL chain at compile time; misconfigured dividers are errors, not silent runtime surprises. defmt+ RTT delivers timestamped, structured logs over the debug probe connection with minimal flash overhead and no extra wiring.rustflagsin.cargo/config.tomlmust be under[target.<arch>], not[build], or linker flags leak into host-side build steps.