Overview
I set up an embedded development environment on Docker using Embassy (an async embedded Rust framework) for the STM32F4 microcontroller, and documented the steps from environment setup to LED blinking.
Tested on Ubuntu 24.04.
1. About Embassy
My starting point was learning that you can do embedded programming in Rust and thinking it would be interesting to apply the philosophy of "catch potential bugs at compile time and write safe code" to systems that control hardware.
FreeRTOS (C/C++) is a well-known OS for hardware control with MCUs. Embassy, however, does not use a preemptive scheduler like FreeRTOS — it uses cooperative scheduling, where tasks yield control at .await points. This means "other tasks won't run unless a task voluntarily yields."
For example, in a mobile robot control loop, sensor reading, motor control, and command waiting would all be structured to periodically .await via Timer::after_millis() and similar. I had a preconception that non-preemptive tasks would be problematic for mobile robot control software, but I concluded that as long as each task is designed to yield appropriately, Embassy can handle control loops just fine.
Note
Embassy also supports interrupt-based multi-priority executors, which allow preemption at .await points between different priority levels. This article uses a single-priority configuration.
2. Environment Used
2.1 Hardware
-
Target board: STM32F411CEU6 (Black Pill)
- Specs: 100MHz Cortex-M4F, 512KB Flash, 128KB RAM
-
Debugger: Raspberry Pi Pico (picoprobe firmware) — CMSIS-DAP compatible probe
- USB ID:
2e8a:000c - Also tested ST-Link V2 clone, but picoprobe is recommended due to issues described later
- USB ID:
2.2 Software Components
| Component | Version | Purpose |
|---|---|---|
| Ubuntu | 24.04 | Host OS |
| Rust | 1.92 | Programming language |
| embassy-executor | 0.9 | Async task executor |
| embassy-time | 0.5 | Time management |
| embassy-stm32 | 0.5 | HAL for STM32 |
| probe-rs-tools | 0.30 | Flash/debug tooling |
| Docker | 24+ | Development container |
| defmt | 1.0 | Embedded logging |
3. Preparing the Host Environment
3.1 Host Environment
- Docker / Docker Compose already installed
- Ubuntu 24.04
3.2 Setting Up udev Rules
To use the debugger without root privileges, configure udev rules. Register both picoprobe and ST-Link V2. Reference: Probe Setup | probe-rs
[/etc/udev/rules.d/99-stlink.rules]
# ST-Link V2
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="3748", MODE="0666", GROUP="plugdev"
# ST-Link V2-1
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374b", MODE="0666", GROUP="plugdev"
# ST-Link V3
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374e", MODE="0666", GROUP="plugdev"
ATTRS{idVendor}=="0483", ATTRS{idProduct}=="374f", MODE="0666", GROUP="plugdev"
# Raspberry Pi Debugprobe / picoprobe (CMSIS-DAP)
ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="000c", MODE="0666", GROUP="plugdev"
# Reload udev rules
sudo udevadm control --reload-rules
sudo udevadm trigger
3.3 Adding to the plugdev Group
# Check if current user belongs to plugdev
groups | grep plugdev
# If not, add the user (requires logout/login)
sudo usermod -aG plugdev $USER
4. Building the Docker Development Environment
4.1 Directory Structure
The project is organized as follows:
stm32-embassy/
├── Dockerfile
├── docker-compose.yml
└── projects/
└── led-blink/
├── Cargo.toml
├── rust-toolchain.toml
├── .cargo/
│ └── config.toml
└── src/
└── main.rs
4.2 Dockerfile Highlights
- Base image:
rust:1.92-bookworm - Install build dependencies (pkg-config, libusb, etc.)
- Reference: Installation | probe-rs
- Add Rust target
thumbv7em-none-eabihf- Reference: Hardware - The Embedded Rust Book
- Install
probe-rs-toolsandflip-link - Run as a non-root user
FROM rust:1.92-bookworm
ENV DEBIAN_FRONTEND=noninteractive
# Build tools and dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
libusb-1.0-0-dev \
libudev-dev \
gdb-multiarch \
openocd \
&& rm -rf /var/lib/apt/lists/*
# Rust target for Cortex-M4F (STM32F4)
RUN rustup target add thumbv7em-none-eabihf
RUN rustup component add rust-src rust-analyzer
# Embedded Rust tools
RUN cargo install probe-rs-tools --locked
RUN cargo install flip-link --locked
# Create developer user
ARG UID=1000
ARG GID=1000
RUN groupadd -g ${GID} developer && \
useradd -m -u ${UID} -g ${GID} -G dialout,plugdev developer
# Copy cargo and rustup to developer's home
RUN cp -r /usr/local/cargo /home/developer/.cargo && \
cp -r /usr/local/rustup /home/developer/.rustup && \
chown -R developer:developer /home/developer/.cargo /home/developer/.rustup
USER developer
ENV CARGO_HOME=/home/developer/.cargo
ENV RUSTUP_HOME=/home/developer/.rustup
ENV PATH="${CARGO_HOME}/bin:${PATH}"
WORKDIR /projects
CMD ["/bin/bash"]
4.3 docker-compose.yml Highlights
- Volume mount design:
- Mount
./projectsinto the container at/projects - Persist Cargo cache in a named volume (without this, builds are slow every time)
- Mount
- USB device (debugger) passthrough:
/dev/bus/usbinvolumes(bind mount): required for libusb to traverse the device tree upward/dev/bus/usbindevices: declares the device node to Docker- Both are needed. With only
devices, probe-rs fails with "Could not determine a suitable packet size"
privileged: trueis required
services:
embassy-dev:
build:
context: .
args:
UID: ${UID:-1000}
GID: ${GID:-1000}
image: stm32f4-embassy-dev
container_name: stm32-embassy-dev
volumes:
- ./projects:/projects
- cargo-cache:/home/developer/.cargo/registry
- cargo-git:/home/developer/.cargo/git
- rustup-toolchains:/home/developer/.rustup
- /dev/bus/usb:/dev/bus/usb # required for libusb device tree traversal
devices:
- /dev/bus/usb:/dev/bus/usb
privileged: true
tty: true
stdin_open: true
working_dir: /projects
environment:
- TERM=xterm-256color
volumes:
cargo-cache:
cargo-git:
rustup-toolchains:
4.4 Building the Image
# Basic build command
docker compose build
# Customize UID/GID if needed
UID=$(id -u) GID=$(id -g) docker compose build
The first build takes 5–10 minutes. Subsequent builds are faster due to caching.
5. Project Structure
5.1 Cargo.toml
Role of each dependency crate:
embassy-executor: Async task runnerembassy-time: Time management (Timer::after_millis, etc.)embassy-stm32: HAL for STM32 (target chip specified viafeatures)defmt/defmt-rtt: Lightweight logging over RTTpanic-probe: defmt output on paniccortex-m/cortex-m-rt: Cortex-M runtime
[package]
name = "led-blink"
version = "0.1.0"
edition = "2021"
[dependencies]
embassy-executor = { version = "0.9", features = ["arch-cortex-m", "executor-thread"] }
embassy-time = { version = "0.5", features = ["tick-hz-32_768"] }
embassy-stm32 = { version = "0.5", features = [
"stm32f411ce",
"time-driver-any",
"memory-x",
]}
defmt = "1.0"
defmt-rtt = "1.0"
panic-probe = { version = "1.0", features = ["print-defmt"] }
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7"
[profile.dev]
opt-level = 1
[profile.release]
debug = 2
lto = true
opt-level = "s"
codegen-units = 1 # single unit + LTO for maximum optimization
5.2 .cargo/config.toml
- Specify the build target
- Configure the probe-rs runner (picoprobe preferred)
- Linker settings:
flip-link: Stack overflow detection linker wrapper. Places static variables at the start of RAM and grows the stack upward, causing an immediate HardFault on overflowlink.x/defmt.x: Linker scripts for cortex-m-rt / defmt
- Set log level via the
DEFMT_LOGenvironment variable
[target.thumbv7em-none-eabihf]
# picoprobe (CMSIS-DAP) - no BOOT0 manipulation needed
runner = "probe-rs run --chip STM32F411CEUx --probe 2e8a:000c"
# ST-Link V2 - requires BOOT0+RESET on every flash
# runner = "probe-rs run --chip STM32F411CEUx --connect-under-reset"
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
rustflags = [
"-C", "linker=flip-link",
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
]
[build]
target = "thumbv7em-none-eabihf"
[env]
DEFMT_LOG = "debug"
5.3 rust-toolchain.toml
Pinning the toolchain keeps the environment consistent.
[toolchain]
channel = "1.92"
components = ["rust-src", "rust-analyzer"]
targets = ["thumbv7em-none-eabihf"]
6. LED Blink Sample (led-blink)
6.1 Full Code
#![no_std]
#![no_main]
use defmt::*;
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
let config = embassy_stm32::Config::default();
let p = embassy_stm32::init(config);
info!("Embassy STM32F4 LED Blink started!");
// PC13: onboard LED (active low)
let mut led = Output::new(p.PC13, Level::High, Speed::Low);
loop {
led.toggle();
info!("LED toggled");
Timer::after_millis(1000).await;
}
}
6.2 Differences from the Official Sample
Based on the official blinky.rs (STM32F4), adapted for the Black Pill:
| Item | Official blinky.rs | This main.rs | Reason |
|---|---|---|---|
| Initialization | init(Default::default()) | Separate Config::default() variable passed to init(config) | Easier to add comments |
| LED pin | PB7 | PC13 | Black Pill onboard LED is on PC13 |
| Toggle method | Alternating set_high() / set_low() | toggle() | More concise |
| Blink interval | 300ms × 2 (High/Low separately) | 1000ms | Easier to see at 1-second intervals |
| Log | Separate "high" / "low" output | Single "LED toggled" | Simplified |
The structure (#![no_std], #![no_main], imports, macros) is identical to the official sample — only hardware-specific differences and minor readability tweaks.
6.3 Code Highlights
#![no_std]/#![no_main]: Required for bare-metal (no OS) environmentsembassy_stm32::init(config)initializes peripherals and manages pin ownershipenable_debug_during_sleepis not needed (no issue with picoprobe)- Even with ST-Link V2 clone, this setting has no effect (see the Troubleshooting section)
Output::new(p.PC13, Level::High, Speed::Low): Active Low, so initial state is High (LED off)loop { ... Timer::after_millis(1000).await; }: Async sleep — CPU sleeps in WFE during this timeinfo!("LED toggled"): defmt log output over RTT
6.4 picoprobe Wiring
picoprobe operates with just 3 SWD wires. If the Black Pill is powered via USB, power lines are not needed.
| Pico GPIO | Pico Pin | Black Pill | Description |
|---|---|---|---|
| GP2 | Pin 4 | PA14 (SWCLK) | SWD clock |
| GP3 | Pin 5 | PA13 (SWDIO) | SWD data |
| GND | Pin 38 etc. | GND | Ground |
| 3V3 | Pin 36 | 3V3 | 3.3V power |
6.5 Build and Run
# Start the container
docker compose run --rm embassy-dev
# Navigate to the led-blink project directory and build
cd led-blink
cargo build --release
# Flash & run (completes in one step if picoprobe is connected)
cargo run --release
After running, the LED blinks at 1-second intervals and defmt logs appear in the terminal:
INFO Embassy STM32F4 LED Blink started!
INFO LED toggled
INFO LED toggled
...
7. Debugging and Logging
7.1 Log Output with defmt
defmt is a lightweight logging system that transfers logs to the host via RTT (Real-Time Transfer).
While cargo run is executing, probe-rs automatically forwards RTT output to the terminal.
use defmt::*;
info!("Value: {}", some_value); // INFO level
warn!("Something happened"); // WARN level
error!("Error: {}", err_code); // ERROR level
Log level is configured via DEFMT_LOG in .cargo/config.toml:
[env]
DEFMT_LOG = "debug" # trace / debug / info / warn / error
7.2 Basic probe-rs Operations
# List connected probes
probe-rs list
# Check version (-v is verbose flag, use --version instead)
probe-rs --version
# Verify connection to target
probe-rs info --chip STM32F411CEUx --probe 2e8a:000c
8. Troubleshooting
ST-Link V2 Clone + embassy 0.9: JtagNoDeviceConnected
I initially planned to use an ST-Link V2 clone as the programmer. However, flashing failed from the second attempt onward.
Symptom:
- First
cargo run --releasesucceeds - Second and subsequent attempts (after entering WFE sleep) fail with
JtagNoDeviceConnected
Root Cause:
In embassy-executor 0.9, the idle implementation changed from WFI to WFE. On the STM32F411, during WFE sleep, the AHB bus matrix is disabled, which drops the SWD connection.
Setting enable_debug_during_sleep = true (DBGMCU_CR = 0x7) has no effect, and there appears to be no fundamental workaround for ST-Link V2 clones.
Solution (Recommended): Switch to picoprobe
Use a Raspberry Pi Pico flashed with the "picoprobe" firmware as the programmer.
With picoprobe (CMSIS-DAP), probe-rs directly controls SWD at the bit level and can re-initialize the DP via an SWD line reset sequence even during WFE sleep.
Simply specify --probe 2e8a:000c in the runner in .cargo/config.toml.
Workaround: BOOT0 Button (when using ST-Link V2)
Required before each flash:
- Hold the BOOT0 button on the Black Pill
- Press and release the RESET button
- Release BOOT0
- Run
cargo run --release
Recovery Mode
If the board becomes unresponsive with JtagNoDeviceConnected for any reason, use the same BOOT0 procedure above:
- Set BOOT0 pin HIGH and reset → boots into system memory bootloader
- The probe can then write to Flash in this state
Docker: "Could not determine a suitable packet size"
This occurs when /dev/bus/usb:/dev/bus/usb is removed from volumes in docker-compose.yml.
Symptom:
Error: Failed to open probe: Failed to open the debug probe.
Caused by:
Could not determine a suitable packet size for this probe.
Root Cause:
libusb (used internally by probe-rs) traverses the USB device tree upward when opening a device file.
The devices entry only passes the device node itself — not the full tree — so libusb cannot access the device hierarchy.
Solution:
Specify /dev/bus/usb:/dev/bus/usb in both volumes and devices (see the configuration in section 4.3).
Note: Couldn't get parent device shown during probe-rs list or probe-rs info is a non-fatal libusb warning and does not affect connectivity.
Reconnecting picoprobe USB Fixes It
If cargo run --release suddenly starts failing, picoprobe may be in an unstable state.
Unplugging and replugging the picoprobe USB cable on the host side usually resolves it.
9. Summary
- Successfully built a reproducible embedded Rust development environment combining Embassy and Docker
- Confirmed the basics of async programming (async/await) with an LED blink example
- Recommend picoprobe as the debugger: ST-Link V2 clones are incompatible with the WFE sleep used by embassy 0.9 and later
- defmt RTT logging is extremely convenient and works like
printfdebugging
References
- Embassy Book
- Embassy Official Documentation
- embassy/examples/stm32f4/src/bin/blinky.rs (Official STM32F4 sample)
- Getting Started · embassy-rs/embassy Wiki
- The Embedded Rust Book
- Hardware - The Embedded Rust Book (target triple explanation)
- Installation | probe-rs (pkg-config / libusb dependencies)
- Probe Setup | probe-rs (udev rule setup)
- probe-rs
- defmt
- STM32F411 Reference Manual
- Jeff McBride: Errors using RTT and WFI (2025) — Explanation of the bus matrix disable mechanism