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

By demiplus (@demiplus.com)
Published:

日本語

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

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

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

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:

[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

[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

<!-- Reference: official sample https://github.com/embassy-rs/embassy/blob/main/examples/stm32f4/src/bin/blinky.rs -->

#![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

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 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:

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:

Recovery Mode

If the board becomes unresponsive with JtagNoDeviceConnected for any reason, use the same BOOT0 procedure above:

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


References