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

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 against core only — Rust's OS-independent subset, which includes iterators, option/result, traits, and arithmetic. You lose Vec, String, and HashMap; 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: .await here does not park an OS thread. There are no threads. The compiler transforms the async fn body into a state machine; at each .await it 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 Pll struct 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