STM32F4 + Embassy(Rust) + Docker: Building an Embedded Rust Dev Environment

@demiplus.bsky.social

日本語

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.

STM32F411 Black Pill running


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

2.2 Software Components

ComponentVersionPurpose
Ubuntu24.04Host OS
Rust1.92Programming language
embassy-executor0.9Async task executor
embassy-time0.5Time management
embassy-stm320.5HAL for STM32
probe-rs-tools0.30Flash/debug tooling
Docker24+Development container
defmt1.0Embedded 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

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 ./projects into the container at /projects
    • Persist Cargo cache in a named volume (without this, builds are slow every time)
  • USB device (debugger) passthrough:
    • /dev/bus/usb in volumes (bind mount): required for libusb to traverse the device tree upward
    • /dev/bus/usb in devices: declares the device node to Docker
    • Both are needed. With only devices, probe-rs fails with "Could not determine a suitable packet size"
  • privileged: true is 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 runner
  • embassy-time: Time management (Timer::after_millis, etc.)
  • embassy-stm32: HAL for STM32 (target chip specified via features)
  • defmt / defmt-rtt: Lightweight logging over RTT
  • panic-probe: defmt output on panic
  • cortex-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 overflow
    • link.x / defmt.x: Linker scripts for cortex-m-rt / defmt
  • Set log level via the DEFMT_LOG environment 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:

ItemOfficial blinky.rsThis main.rsReason
Initializationinit(Default::default())Separate Config::default() variable passed to init(config)Easier to add comments
LED pinPB7PC13Black Pill onboard LED is on PC13
Toggle methodAlternating set_high() / set_low()toggle()More concise
Blink interval300ms × 2 (High/Low separately)1000msEasier to see at 1-second intervals
LogSeparate "high" / "low" outputSingle "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) environments
  • embassy_stm32::init(config) initializes peripherals and manages pin ownership
  • enable_debug_during_sleep is 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 time
  • info!("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.

picoprobe - Black Pill wiring diagram

Pico GPIOPico PinBlack PillDescription
GP2Pin 4PA14 (SWCLK)SWD clock
GP3Pin 5PA13 (SWDIO)SWD data
GNDPin 38 etc.GNDGround
3V3Pin 363V33.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 --release succeeds
  • 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:

  1. Hold the BOOT0 button on the Black Pill
  2. Press and release the RESET button
  3. Release BOOT0
  4. 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 printf debugging

References

demiplus.bsky.social
demiplus

@demiplus.bsky.social

Robot, Art, Games
white wind -> https://whtwnd.com/demiplus.bsky.social

Artist account -> @demimanga.bsky.social

Post reaction in Bluesky

*To be shown as a reaction, include article link in the post or add link card

Reactions from everyone (0)