The gap between cargo build and blinking LEDs
You have written a Rust program for a microcontroller. It compiles without errors. The terminal prints Finished release [optimized] target(s). Now you need to get those bytes onto the actual silicon. The distance between a successful cargo build and a blinking LED is where most embedded beginners get stuck. You are not just copying a file. You are translating a high-level binary into a format the chip understands, routing it through a USB bridge, and writing it into non-volatile memory that survives power cycles.
This pipeline has three moving parts. The compiler produces a portable executable. A hardware debug probe acts as a translator between your computer and the chip. A flashing tool reads the executable, talks to the probe, and programs the flash memory. Getting the target architecture right, choosing the right tool, and understanding what the tool actually does will save you hours of staring at unresponsive boards.
How the flashing pipeline actually works
Cross-compilation is the first step. Your laptop runs on x86 or ARM64. Your microcontroller runs on a completely different architecture, usually ARM Cortex-M or RISC-V. The Rust compiler does not run your code. It translates your code into machine instructions for the target CPU. The result is an ELF file. ELF stands for Executable and Linkable Format. It is a standardized container that holds machine code, symbol tables, and metadata about where each function lives in memory.
The debug probe is the physical bridge. It plugs into your computer via USB and connects to the microcontroller through a SWD or JTAG header. The probe does not run your code. It provides a low-level channel to read and write the chip's memory, halt the CPU, and step through instructions. Think of it as a specialized delivery truck that knows exactly how to navigate the chip's internal roads.
The flashing tool is the driver. It reads the ELF file, extracts the relevant sections, calculates the correct memory addresses, and sends programming commands to the probe. The probe then writes those bytes into the microcontroller's flash memory. Flash memory is different from RAM. It retains data without power, but it requires specific voltage sequences to erase and write. The flashing tool handles those sequences automatically.
Your first flash with probe-rs
probe-rs is the modern standard for Rust embedded development. It is written in Rust, understands Rust's ELF output natively, and includes a built-in database of chip memory layouts. You do not need to write configuration files for most common microcontrollers.
// This is a conceptual representation of the build and flash pipeline.
// In practice, you run these commands in your terminal.
// The comments explain why each step exists.
// 1. Cross-compile for the exact target architecture.
// The triple tells rustc which CPU, OS, and ABI to generate.
// cargo build --release --target thumbv7em-none-eabi
// 2. Locate the resulting ELF binary.
// Cargo places it in target/<triple>/release/<binary_name>.elf
// The .elf extension signals an unlinked or linked executable format.
// 3. Instruct probe-rs to read the ELF and write to flash.
// The --chip flag loads the correct memory map from probe-rs' database.
// probe-rs download --chip STM32F401CCUx target/thumbv7em-none-eabi/release/firmware.elf
The command above does three things automatically. It connects to the first available debug probe on your USB bus. It reads the ELF file and maps each section to the correct flash address. It erases the necessary flash sectors, writes the new data, verifies the write by reading it back, and resets the chip so your code starts running immediately.
What happens under the hood
When you run the download command, probe-rs opens a USB connection to the probe. The probe negotiates a communication protocol, usually CMSIS-DAP or J-Link. probe-rs then parses the ELF file. It ignores sections marked as NOLOAD or DISCARD. It keeps sections like .text, .rodata, and .data. Each section has a virtual memory address baked into the ELF header by your linker script.
The tool sends a halt command to the microcontroller. The CPU stops executing whatever it was doing. probe-rs calculates which flash sectors overlap with your new binary. Flash memory can only be written to empty cells, so the tool issues an erase command for those sectors. Erasing takes milliseconds per sector. The tool then streams the binary data in small packets, usually 256 or 1024 bytes at a time. The probe writes each packet to the flash controller.
After writing, the tool reads the flash memory back and compares it byte-for-byte with the ELF file. This verification step catches USB dropouts, probe firmware bugs, and flash wear issues. If the verification passes, the tool sends a reset command. The microcontroller restarts, the vector table points to your new code, and your main function runs.
A realistic embedded workflow
Real projects rarely use bare commands. You will chain the build and flash steps, handle multiple targets, and occasionally fall back to OpenOCD for legacy hardware or complex probe setups.
# Cargo.toml configuration for embedded targets
# The [build] section is not used for targets.
# Instead, you set the default target in rust-toolchain.toml or via environment variables.
# Convention: keep your target triple consistent across CI and local machines.
# Build the release binary for an STM32 board
cargo build --release --target thumbv7m-none-eabi
# Flash using probe-rs with explicit interface and speed
# The --interface flag selects SWD or JTAG. SWD is standard for ARM Cortex-M.
# The --speed flag sets the clock frequency in kHz. Higher is faster, but may fail on long wires.
probe-rs download --chip STM32F072CBUx --interface swd --speed 1000 target/thumbv7m-none-eabi/release/led_blinky.elf
# Alternative: Flash using OpenOCD when probe-rs lacks chip support
# OpenOCD requires a configuration file that defines the probe, transport, and chip memory map.
openocd -f interface/stlink.cfg -f target/stm32f0x.cfg -c "program target/thumbv7m-none-eabi/release/led_blinky.elf verify reset exit"
OpenOCD stands for Open On-Chip Debugger. It has been around longer than probe-rs and supports a wider range of legacy probes and obscure microcontrollers. It uses a text-based configuration system. You chain interface files and target files to describe your hardware. The program command tells it to load the ELF, verify it, reset the chip, and exit. The community convention for OpenOCD is to keep configuration files in a scripts/ or openocd/ directory at the project root. This keeps your repository clean and makes CI pipelines reproducible.
Common pitfalls and how to fix them
The wrong target triple is the most frequent blocker. If you compile for thumbv7em-none-eabi but your chip is a Cortex-M0, the CPU will fault immediately on reset. The error will not show up in the terminal. Your LED will not blink. Check the datasheet for the exact core revision and match it to the thumbv or riscv32 triple in the Rust target list.
Missing probe permissions on Linux will stop you before the tool even starts. The operating system blocks USB access by default. You need a udev rule that grants your user group read/write access to the probe's vendor and product IDs. The probe-rs documentation provides a ready-to-use rule file. Install it, reload the daemon, and unplug then replug your probe. The tool will connect without sudo.
OpenOCD configuration mismatches cause silent failures. If your interface file specifies a different transport than your target file expects, the connection drops. If the flash size in the target file is smaller than your binary, the programmer aborts with a Cannot write to address error. Verify that your configuration matches the exact chip variant, not just the family.
Forgetting to set the reset behavior leaves your chip in a halted state. probe-rs resets by default. OpenOCD requires the reset exit suffix in the program command. Without it, the tool finishes writing, leaves the CPU paused, and returns to the shell. Your code never runs. Add reset exit to your command or configure it in your openocd.cfg.
Choosing your flashing tool
Use probe-rs when you are working with modern ARM Cortex-M or RISC-V chips and want zero configuration. Use probe-rs when you want built-in verification, automatic sector erasure, and native ELF parsing without writing config files. Use probe-rs when your team prefers a single Rust-native toolchain that integrates with cargo and IDE extensions.
Use OpenOCD when you are supporting legacy probes that lack CMSIS-DAP or J-Link drivers. Use OpenOCD when you need to flash obscure microcontroller families that probe-rs has not yet added to its chip database. Use OpenOCD when you require advanced debugging features like hardware breakpoints, trace analysis, or custom Tcl scripting for complex board bring-up.
Use cargo embed when you want a single command that builds, flashes, and attaches a GDB session automatically. Use cargo embed when your workflow relies on continuous integration pipelines that need a declarative Embed.toml configuration instead of shell scripts.
Keep your flashing commands in a Makefile or a shell script. Do not hardcode them in documentation. Treat the flashing step as part of your build pipeline, not an afterthought.