For Applications & Industries
Free Online Training Events
Free Technical Resources
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:
How to set up the Rust toolchain for embedded development
How to cross-compile and inspect binaries
How to flash and debug code on a Cortex-M MCU
Three progressively engaging demos:
The files required for this tutorial can be acquired by executing the following command:
git clone https://github.com/Doulos/embedded_rust_toolchain_webseminar.gitWith 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 manager
cargo – build and dependency manager
rustc – the compiler itself
llvm-tools and cargo-binutils – for exploring binaries
qemu-system-arm and st-flash – for emulation and flashing
If you already have Rust installed on your machine, great! Otherwise, install it via the official Rust website.
Check your installation and active toolchains:
rustup showYou 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:
rustup updateIf 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 |
| Cortex-M0 / M0+ |
ARMv7-M |
| Cortex-M3 |
ARMv7E-M |
| Cortex-M4 / M7 with FPU |
Let’s add a few to be on the safe side:
rustup target add thumbv6m-none-eabi
rustup target add thumbv7m-none-eabi
rustup target add thumbv7em-none-eabihfThis ensures you can build code for various Cortex-M families (not all). At this point to can check again what are the available target on your system with:
rustup showThis 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.
Let’s start by inspecting our project directory:
Navigate to the directory 1
Look at the project's structure with the command:
tree - .You should see something like this:
.
├── Cargo.lock
├── Cargo.toml
├── memory.x
└── src
└── main.rsFrom 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:
[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:
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):
cargo build --target thumbv7m-none-eabiIf your project compiles successfully, Cargo will place the output in:
target/thumbv7m-none-eabi/debug/Try verifying it:
ls target/thumbv7m-none-eabi/debugYou should see an ELF executable, typically named after your project — e.g. app.
When you’re ready for optimized, production-ready binaries, use:
cargo build --release --target thumbv7m-none-eabiThis compiles with --release, enabling optimizations -O3 under the hood).
Check the result:
ls target/thumbv7m-none-eabi/releaseYou’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:
cargo install cargo-binutils
rustup component add llvm-toolsNow you can access a suite of sub-commands:
Command | Description |
|---|---|
| Shows the size of sections |
| Lists symbols and labels in your binary |
| Disassembles your code — view actual ARM instructions |
Let’s try them out.
cargo size --release --target thumbv7m-none-eabiExample output:
text data bss dec hex filename
596 0 0 604 25c appHere:
.text → your code
.data → initialized global variables
.bss → uninitialized globals
Small and tidy, just what we need for an embedded application.
cargo nm --release --target thumbv7m-none-eabiThis 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:
cargo objdump --release --target thumbv7m-none-eabi --bin app -- -d --no-show-raw-insn --demangleThis displays readable disassembly with demangled Rust names; No more puzzling prefixes. You’ll see output like:
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:
qemu-system-arm -M helpThis 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:
qemu-system-arm \
-cpu cortex-m3 \
-machine stm32vldiscovery \
-nographic \
-kernel target/thumbv7m-none-eabi/debug/app \
-semihosting-config enable=on,target=nativeHere’s what these flags mean:
Option | Description |
|---|---|
| Select the emulated CPU |
| Specify board model |
| Run without a GUI (console only) |
| Path to your ELF binary |
| 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.
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.

Navigate to the Directory 2
Build the application with:
cargo buildOnce 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:
arm-none-eabi-objcopy -O binary \
target/thumbv7m-none-eabi/debug/app \
app.binYou 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:
st-flash erase
st-flash write app.bin 0x08000000⚠️ Note: the address 0x08000000 is the flash memory base on most STM32 devices.
Alternatively, for more flexibility and debugging support, use OpenOCD:
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:
picocom -b 115200 -d 8 -p 1 /dev/ttyUSB0You should see:
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.
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):
openocd \
-f interface/stlink.cfg \
-f target/stm32f1x.cfgThe Directory 2 contains a GDB/OpenOCD configuration file named openocd.gdb with the following commands:
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
stepiFrom another terminal, launch GDB pointing at your ELF:
gdb-multiarch -q target/thumbv7m-none-eabi/debug/app -x opencd.gdbYou can now interact with the application via the GDB console.
Handy GDB commands:
Command | Meaning | Typical Use |
|---|---|---|
| Set breakpoint | Stop program at a given location |
| Resume execution | Run until next breakpoint or crash |
| Step into / over lines | Fine-grained source debugging |
| Run until current function returns | Escape from deep function calls |
| Show CPU register contents | Inspect processor state |
| Disassemble 10 instructions at PC | View current code around PC |
| Show 16 words at address | Inspect memory or registers |
| Reset and halt target system | Restart nd 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:
[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:
cargo build
# equivalent to:
cargo build --target thumbv7m-none-eabiThe [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:
cargo runBecause in embedded environments, there’s no OS to “execute” binaries, Cargo instead calls this runner command.
The command is composed of:
Element | Meaning |
|---|---|
| Cross-architecture GDB capable of debugging ARM binaries Quiet mode (less startup output) |
| Quiet mode (less startup output) |
| Path to the ELF file built by Cargo (you can replace |
| GDB command script executed at startup (usually contains |
Consequently, Running cargo run will:
openocd.gdb)The rustflags are additional compiler flags passed directly to rustc during compilation.
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:
Memory regions (FLASH, RAM)
Section placement .text, .data, .bss)
Stack and vector table locations
Now, the updated work-flow is:
openocd -f interface/stlink.cfg -f target/stm32f1x.cfgcargo runCargo 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-generatecargo install cargo-generateWe’ll use a community STM32 template that asks a few guided questions (chip, HAL, etc.):
cargo generate --git https://github.com/burrbull/stm32-template/You’ll be prompted for values such as:
project: app
device: STM32F103C8T6
HAL: latest
RTIC: no
dfmt: no
SVD: noThis produces a ready-to-go directory (e.g., app/) looking like this:
.
└── app
├── build.rs
├── Cargo.lock
├── Cargo.toml
├── Embed.toml
├── LICENSE-APACHE
├── LICENSE-MIT
├── memory.x
├── README.md
└── src
└── main.rs The Cargo.toml is already preconfigured for your MCU
The linker script / memory layout is also predefined with the required base addresses and regions' size corresponding to the STM32F103C8T6 microcontroller
We can also find a basic code example inside src/main.rs tailored to the MCU
We now have a fully functional project ready to be used and customised with our own code. For the sake of this tutorial we will now create a simple application that makes use of a new logging mechanism called Real-Time Trace.
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.
cargo binstall probe-rs-toolscargo add rtt-targetEmbed.toml file[default.rtt]
enabled = trueReplace the existing code inside src/main.rs with this instead:
#![deny(unsafe_code)]
#![no_std]
#![no_main]
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};
#[entry]
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
[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:
cargo runProbe-RS will build, flash, reset, and attach RTT.
You should see RTT messages like:
Blinky application startingThis is followed by a blinking LED on the board at one second intervals.
You just built a complete Embedded Rust workflow:
Semihosting for early boot/debug
UART for robust, fast serial logging
RTT for high-speed logs over SWD (no UART)
This article was written by David Cabanis, Principle Member Technical Staff at Doulos. Version 1.1
This article is Copyright © 2025 by Doulos. All rights are reserved.