So you are familiar with Rust ownership, lifetimes, concurrency, and all these other features that make this language so revered. But what if we took it beyond the comfort of cargo run and point it at a microcontroller instead of your desktop CPU?
Welcome to Embedded Rust, where no_std, semihosting, and bare-metal debugging await. In this tutorial, we’ll go from “Hello, World!” to blinking LEDs on real silicon, in a step by step way.
We’ll explore:
The files required for this tutorial can be acquired by executing the following command:
git clone https://github.com/Doulos/embedded_rust_toolchain_webseminar.git
With this in place, let’s get started.
Before we can blink LEDs or output text messages, we need a working Rust environment that understands embedded targets.
We’ll use:
rustup – Rust’s toolchain managercargo – build and dependency managerrustc – the compiler itselfllvm-tools and cargo-binutils – for exploring binariesqemu-system-arm and st-flash – for emulation and flashingIf you already have Rust installed on your machine, great! Otherwise, install it via the official Rust website.
Check your installation and active toolchains:
xxxxxxxxxx
rustup show
You should see output similar to this:
Default host: x86_64-unknown-linux-gnu
rustup home: /home/dcabanis/.rustup
installed toolchains
--------------------
stable-x86_64-unknown-linux-gnu (default)
If you already has a Rust installation, run the following command to update Rust:
xxxxxxxxxx
rustup update
If everything looks good, we can move on to enabling embedded targets.
Rust supports multiple CPU architectures, but for our microcontroller demo, we will need Cortex-M specific targets.
The most common ones are:
| Architecture | Target Triple | Example Devices |
|---|---|---|
| ARMv6-M | thumbv6m-none-eabi |
Cortex-M0 / M0+ |
| ARMv7-M | thumbv7m-none-eabi |
Cortex-M3 |
| ARMv7E-M | thumbv7em-none-eabihf |
Cortex-M4 / M7 with FPU |
Let’s add a few to be on the safe side:
xxxxxxxxxx
rustup target add thumbv6m-none-eabi
rustup target add thumbv7m-none-eabi
rustup target add thumbv7em-none-eabihf
This ensures you can build code for various Cortex-M families (not all). At this point to can check again what are the availble target on your system with:
xxxxxxxxxx
rustup show
This will now display a few more targets that we will be able to use for cross-compiling.
At this point, your Rust toolchain is ready to cross-compile for embedded targets. Next up: we’ll use cargo to build real embedded binaries and learn how to peek inside them.
xxxxxxxxxx
tree - .
You should see something like this:
xxxxxxxxxx
.
├── Cargo.lock
├── Cargo.toml
├── memory.x
└── src
└── main.rs
From this list we can see three essential files, namely main.rs the binary create of our project, the memory.x file providing the linker with essential placement information and most importantly our dependencies' file: Cargo.toml.
If you cast your eyes on the Cargo.toml you will notice a section with the following text:
xxxxxxxxxx
[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
cortex-m-semihosting = "0.5.0"
panic-halt = "0.2.0"
[dependencies.stm32f1xx-hal]
version = "0.11.0"
features = ["stm32f100", ]
This defines the list of crates required for this project. The details of these is beyond the scope of this tutorial however you can already see the semihosting crate that will allow us to send messages to the debug console and the stm32f1xx-hal giving us access to the MCU's peripherals.
For general information, you can notice the following statement inside the main.rs file:
xxxxxxxxxx
hprintln!("Aye Aye Captain!");
This is a similiar macro to the well known println!(). However, this time the message is sent to the debug console instead of computer screen.
Now that we have our targets installed and we have a better understanding of the project's files, it is time to tell Cargo how to build for them.
cargo is both a package manager and build system. When cross-compiling, the key is the --target flag, it tells Cargo that we’re building for something other than our host machine.
Run the following command to build your code for the Cortex-M3 target (thumbv7m-none-eabi):
xxxxxxxxxx
cargo build --target thumbv7m-none-eabi
If your project compiles successfully, Cargo will place the output in:
xxxxxxxxxx
target/thumbv7m-none-eabi/debug/
Try verifying it:
xxxxxxxxxx
ls target/thumbv7m-none-eabi/debug
You should see an ELF executable, typically named after your project — e.g. app.
When you’re ready for optimized, production-ready binaries, use:
xxxxxxxxxx
cargo build --release --target thumbv7m-none-eabi
This compiles with --release, enabling optimizations (-O3 under the hood). Check the result:
xxxxxxxxxx
ls target/thumbv7m-none-eabi/release
You’ll notice the file size difference between the debug and release versions; A good hint that optimizations worked.
cargo build: Exploring Binaries with BinutilsBuilding is great, but real embedded developers peek inside their binaries. That’s where cargo-binutils and LLVM tools come into play.
Install them once:
xxxxxxxxxx
cargo install cargo-binutils
rustup component add llvm-tools
Now you can access a suite of sub-commands:
| Command | Description |
|---|---|
cargo size |
Shows the size of sections (.text, .data, .bss) |
cargo nm |
Lists symbols and labels in your binary |
cargo objdump |
Disassembles your code — view actual ARM instructions |
Let’s try them out.
xxxxxxxxxx
cargo size --release --target thumbv7m-none-eabi
Example output:
xxxxxxxxxx
text data bss dec hex filename
596 0 0 604 25c app
Here:
.text → your code.data → initialized global variables.bss → uninitialized globalsSmall and tidy, just what we need for an embedded application.
xxxxxxxxxx
cargo nm --release --target thumbv7m-none-eabi
This reveals all functions and variables linked into your binary. Great for verifying that main, interrupt handlers, and panic routines are present.
To see the generated ARM assembly:
xxxxxxxxxx
cargo objdump --release --target thumbv7m-none-eabi --bin app -- -d --no-show-raw-insn --demangle
This displays readable disassembly with demangled Rust names; No more puzzling prefixes. You’ll see output like:
xxxxxxxxxx
main:
push {r7, lr}
bl core::fmt::write
pop {r7, pc}
You can now compile Rust code for a Cortex-M microcontroller and analyze the resulting binary. Next, we’ll see how to run it without hardware using emulation tools like QEMU.
Sometimes you may want to run your embedded code before you have a real hardware board in you possession. That’s where QEMU comes in; A popular, open-source machine emulator that supports multiple CPU architectures, including ARM Cortex-M.
Start by listing the available machine configurations:
xxxxxxxxxx
qemu-system-arm -M help
This will print out a list of supported boards such as stm32vldiscovery, lm3s6965evb, mps2-an385, etc.
To emulate a simple STM32-based system, we can run:
xxxxxxxxxx
qemu-system-arm \
-cpu cortex-m3 \
-machine stm32vldiscovery \
-nographic \
-kernel target/thumbv7m-none-eabi/debug/app \
-semihosting-config enable=on,target=native
Here’s what these flags mean:
| Option | Description |
|---|---|
-cpu cortex-m3 |
Select the emulated CPU |
-machine stm32vldiscovery |
Specify board model |
-nographic |
Run without a GUI (console only) |
-kernel |
Path to your ELF binary |
-semihosting-config |
Enables communication between emulated target and host console |
If your app prints via semihosting, you’ll see the output directly in your terminal, just as if you were using a debugger on real hardware.
xxxxxxxxxx
Aye Aye Captain!
You’ve now emulated an embedded Rust binary on your host machine. We will now turn our attention to Flashing our application onto real silicon.
In the previous part of this tutorial we have used the STM32VL Discovery board for practical reasons, since it was supported by QEMU. We will now switch to a physical "blue pill" board to demonstrate the programming of a development board with a debug adapter.
For this operation you will need a Blue Pill board connected to an ST-Link v2 debug adapter via the serial wire interface.

xxxxxxxxxx
cargo build
Once your binary is built, you’ll need to convert it into a format suitable for flashing and then program your device.
Rust’s default output is an ELF file (Executable and Linkable Format), which contains sections and symbols for debugging. To flash it, we usually want a pure binary:
xxxxxxxxxx
arm-none-eabi-objcopy -O binary \
target/thumbv7m-none-eabi/debug/app \
app.bin
You should have now have a smaller, raw app.bin ready to be loaded onto the MCU.
If you’re using an STM32 board with the ST-Link programmer, you can erase and flash it using st-flash:
xxxxxxxxxx
st-flash erase
st-flash write app.bin 0x08000000
⚠️ Note: the address
0x08000000is the flash memory base on most STM32 devices.
Alternatively, for more flexibility and debugging support, use OpenOCD:
xxxxxxxxxx
openocd \
-f interface/stlink.cfg \
-f target/stm32f1x.cfg \
-c "adapter speed 4000" \
-c "program target/thumbv7m-none-eabi/debug/app verify reset exit"
Once done, your MCU will reset and start running the freshly flashed firmware.
Open a serial terminal:
xxxxxxxxxx
picocom -b 115200 -d 8 -p 1 /dev/ttyUSB0
You should see:
xxxxxxxxxx
uart demo: aye aye captain!
uart demo: aye aye captain!
Up next: we’ll go deeper into debugging, explore Cargo runners, and streamline the development process with automatic flashing and GDB sessions.
Debugging Rust with println!() is a common practice, however it has its limitation. A better alternative to debugging is interactive debug.
On microcontrollers, we typically use OpenOCD (a GDB server that speaks SWD/JTAG) and GDB (the debugger) to step through code, set breakpoints, inspect registers, and more.
xxxxxxxxxx
Computer (host) Target (MCU)
┌──────────┐ TCP :3333 ┌────────────┐
│ GDB │ ◄──────────────► │ OpenOCD │ ◄─► ST-Link / J-Link ◄─► MCU (SWD/JTAG)
└──────────┘ └────────────┘
Launch OpenOCD with your probe and target configuration (STM32F1 shown as example):
xxxxxxxxxx
openocd \
-f interface/stlink.cfg \
-f target/stm32f1x.cfg
The Directory 2 contains a GDB/OpenOCD configuration file named openocd.gdb with the following commands:
xxxxxxxxxx
file target/thumbv7m-none-eabi/debug/app
# connect to
target extended-remote :3333
# print demangled symbols
set print asm-demangle on
# set backtrace limit to not have infinite backtrace loops
set backtrace limit 32
# detect unhandled exceptions, hard faults and panics
break DefaultHandler
break HardFault
break rust_begin_unwind
# *try* to stop at the user entry point (it might be gone due to inlining)
break main
# enable semihosting in OpenOCD
monitor arm semihosting enable
# load the binary inside the device
load
# start the process but immediately halt the processor
stepi
From another terminal, launch GDB pointing at your ELF:
xxxxxxxxxx
gdb-multiarch -q target/thumbv7m-none-eabi/debug/app -x openocd.gdb
You can now interact with the application via the GDB console.
Handy GDB commands:
| Command | Meaning | Typical Use |
|---|---|---|
break <fn-or-file:line> |
Set breakpoint | Stop program at a given location |
continue / c |
Resume execution | Run until next breakpoint or crash |
step / next |
Step into / over lines | Fine-grained source debugging |
finish |
Run until current function returns | Escape from deep function calls |
info registers |
Show CPU register contents | Inspect processor state |
x/10i $pc |
Disassemble 10 instructions at PC | View current code around PC |
x/16wx <addr> |
Show 16 words at address | Inspect memory or registers |
monitor reset halt |
Reset and halt MCU | Restart and prepare for fresh debugging |
Typing the GDB command each time gets old fast. Cargo runners let you define a “runner” command that Cargo executes when you call cargo run for a given target.
Open the .cargo/config.toml this contains the following configuration:
xxxxxxxxxx
[build]
target = "thumbv7m-none-eabi"
[target.thumbv7m-none-eabi]
runner = "gdb-multiarch -q target/thumbv7m-none-eabi/debug/app -x openocd.gdb"
rustflags = [
"-C", "link-arg=-Tlink.x",
]
The [build] section defines global build settings for your project.
target = "thumbv7m-none-eabi"
This tells Cargo that every cargo build, cargo run, or cargo test should target the Arm Cortex-M3/M4 architecture by default.
"thumbv7m-none-eabi" means:
thumbv7m: ARMv7-M instruction set (Cortex-M3)none: no operating system (bare-metal)eabi: “Embedded Application Binary Interface” (uses standard ARM ABI conventions)As a result, you don’t need to specify --target thumbv7m-none-eabi each time you build:
xxxxxxxxxx
cargo build
# equivalent to:
cargo build --target thumbv7m-none-eabi
The [target.thumbv7m-none-eabi] section provides target-specific overrides for the given platform triple.
runner = "gdb-multiarch -q target/thumbv7m-none-eabi/debug/app -x openocd.gdb"
This defines what happens when you run:
xxxxxxxxxx
cargo run
Because in embedded environments, there’s no OS to “execute” binaries, Cargo instead calls this runner command.
The command is composed of:
| Element | Meaning |
|---|---|
gdb-multiarch |
Cross-architecture GDB capable of debugging ARM binaries |
-q |
Quiet mode (less startup output) |
target/thumbv7m-none-eabi/debug/app |
Path to the ELF file built by Cargo (you can replace app with your binary name) |
-x openocd.gdb |
GDB command script executed at startup (usually contains target extended-remote :3333, monitor reset halt, load, continue, etc.) |
Consequently, Running cargo run will:
openocd.gdb).The rustflags are additional compiler flags passed directly to rustc during compilation.
xxxxxxxxxx
rustflags = [
"-C", "link-arg=-Tlink.x",
]
In details, these flags will alter the linker behaviour as follows:
-C link-arg= passes arguments to the linker (ld).-Tlink.x tells the linker to use the custom linker script link.x.The linker script defines:
.text, .data, .bss)xxxxxxxxxx
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg
xxxxxxxxxx
cargo run
Cargo will build your ELF for thumbv7m-none-eabi and then invoke the runner: gdb-multiarch -q -x openocd.gdb target/thumbv7m-none-eabi/debug/app
GDB connects, loads the firmware, halts at main, and you are ready to take over the interactive simulation.
Next we will look at a pragmatic way to start and brand new embedded Rust project.
cargo-generateManually wiring linker scripts, target configs, and HAL boilerplate can be… a bore.
Thankfully we can use cargo-generate to bootstrap a ready-to-build embedded project from a template.
cargo-generatexxxxxxxxxx
cargo install cargo-generate
We’ll use a community STM32 template that asks a few guided questions (chip, HAL, etc.):
xxxxxxxxxx
cargo generate --git https://github.com/burrbull/stm32-template/
You’ll be prompted for values such as:
xxxxxxxxxx
project: app
device: STM32F103C8T6
HAL: latest
RTIC: no
dfmt: no
SVD: no
This produces a ready-to-go directory (e.g., app/) looking like this:
xxxxxxxxxx
.
└── app
├── build.rs
├── Cargo.lock
├── Cargo.toml
├── Embed.toml
├── LICENSE-APACHE
├── LICENSE-MIT
├── memory.x
├── README.md
└── src
└── main.rs
Cargo.toml is already preconfigured for your MCUsrc/main.rs tailored to the MCU
RTT (Real-Time Transfer) gives you fast, low-latency logs over the SWD debug channel without any UART required. We’ll also blink an LED at 1 Hz using a hardware timer and log via RTT.
probe-rs ToolsProbe-RS provides a convenient subcommand to build, flash, enable RTT, and stream logs — all in one.
xxxxxxxxxx
cargo binstall probe-rs-tools
xxxxxxxxxx
cargo add rtt-target
Embed.toml filexxxxxxxxxx
[default.rtt]
enabled = true
Replace the existing code inside src/main.rs with this instead:
xxxxxxxxxx
use nb::block;
use panic_halt as _;
use rtt_target::{rprintln, rtt_init_print};
use cortex_m_rt::entry;
use stm32f1xx_hal::{pac, prelude::*, timer::Timer};
fn main() -> ! {
// Initialise RTT
rtt_init_print!();
rprintln!("Blinky application starting");
// Get access to the core peripherals from the cortex-m crate
let (cp, dp) = (
cortex_m::Peripherals::take().unwrap(),
pac::Peripherals::take().unwrap(),
);
let mut rcc = dp.RCC.constrain();
// Acquire the GPIOB peripheral
let mut gpiob = dp.GPIOB.split(&mut rcc);
// Configure gpio C pin 13 as a push-pull output
let mut led = gpiob.pb9.into_push_pull_output(&mut gpiob.crh);
// Configure the syst timer to trigger an update every second
let mut timer = Timer::syst(cp.SYST, &rcc.clocks).counter_hz();
timer.start(1.Hz()).unwrap();
// Set initial LED state
led.set_high();
// Wait for the timer to trigger an update and change the state of the LED
loop {
block!(timer.wait()).unwrap();
led.toggle();
}
}
Open the .cargo/Config.toml and note the new runner configuration, set to use probe-rs
xxxxxxxxxx
[target.'cfg(all(target_arch = "arm", target_os = "none"))']
runner = "probe-rs run --chip STM32F103C8Tx"
Now that we have the Embed.toml configured to use RTT we can start the device programming process and code's execution with the command:
xxxxxxxxxx
cargo run
Probe-RS will build, flash, reset, and attach RTT.
You should see RTT messages like:
xxxxxxxxxx
Blinky application starting
This is followed by a blinking LED on the board at one second intervals.
You just built a complete Embedded Rust workflow:
This article was written by Dr. David Cabanis, Principal Member Technical Staff at Doulos. Version 1.1
This article is Copyright © 2025 by Doulos. All rights are reserved.