Global training solutions for engineers creating the world's electronics
Menu

Rust Insights: Your First Steps into Embedded Rust

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:

1. Semihosted logging – “Aye Aye, Captain!”
2. UART output – sending data over serial
3. RTT-based Blinky – real-time feedback, no PC host dependency

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.

Setting Up the Embedded Rust Environment

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.

Step 1: Verify or Install Rust

Check your installation and active toolchains:

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:

rustup update

If everything looks good, we can move on to enabling embedded targets.

Step 2: Add 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:

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 available target on your system with:

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.

Building for Your Target: Cross-Compilation with Cargo

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

[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.

Step 1: Cross-Compile for ARM

Run the following command to build your code for the Cortex-M3 target thumbv7m-none-eabi):

cargo build --target thumbv7m-none-eabi

If your project compiles successfully, Cargo will place the output in:

target/thumbv7m-none-eabi/debug/

Try verifying it:

ls target/thumbv7m-none-eabi/debug

You should see an ELF executable, typically named after your project — e.g. app.

Step 2: Release Build

When you’re ready for optimized, production-ready binaries, use:

cargo build --release --target thumbv7m-none-eabi

This compiles with --release, enabling optimizations -O3 under the hood).

Check the result:

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.

Beyond cargo build: Exploring Binaries with Binutils

Building 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-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.

1. Checking Binary Size

cargo size --release --target thumbv7m-none-eabi

Example output:

    text	   data	    bss	    dec	    hex	   filename
     596	      0	      0	    604	    25c    app

Here:

  • .text → your code

  • .data → initialized global variables

  • .bss → uninitialized globals

Small and tidy, just what we need for an embedded application.

2. Listing Symbols

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.

3. Disassembly View

To see the generated ARM assembly:

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:

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.

Running Code Without Hardware: Emulation with 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.

Step 1: Check Supported Machines

Start by listing the available machine configurations:

qemu-system-arm -M help

This will print out a list of supported boards such as stm32vldiscovery, lm3s6965evb, mps2-an385, etc.

Step 2: Emulate a Cortex-M3 Board

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=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.

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.

Flashing and Running on Real Hardware

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.

Blue Pill

Step 1: Build the new application

  • Navigate to the Directory 2

  • Build the application with:

cargo build

Once your binary is built, you’ll need to convert it into a format suitable for flashing and then program your device.

Step 2: Convert ELF to Binary

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.bin

You should have now have a smaller, raw app.bin ready to be loaded onto the MCU.

Step 3: Erase and Flash the Device

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/ttyUSB0

You 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.

Interactive Debugging with GDB

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.

GDB and OpenOCD connectivity

Computer (host)                  Target (MCU)
┌──────────┐     TCP :3333     ┌────────────┐
│  GDB     │  ◄──────────────► │  OpenOCD   │  ◄─►  ST-Link / J-Link  ◄─►  MCU (SWD/JTAG)
└──────────┘                   └────────────┘

Step 1: Start OpenOCD (GDB Server)

Launch OpenOCD with your probe and target configuration (STM32F1 shown as example):

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:

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

Step 2: Drive GDB Manually

From another terminal, launch GDB pointing at your ELF:

gdb-multiarch -q target/thumbv7m-none-eabi/debug/app -x opencd.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 target system

Restart nd prepare for fresh debugging

Cargo Runners: One-Command Flash & Debug

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

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 Quiet mode (less startup output)

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

1. Launch GDB with your ELF file.
2. Connect to OpenOCD (via commands in openocd.gdb)
3. Flash and run the firmware on your target board.

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:

Step 1: Start OpenOCD in one terminal

openocd -f interface/stlink.cfg -f target/stm32f1x.cfg

Step 2: From your project directory invoke Cargo

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.

Project Creation the Easy Way — cargo-generate

Manually 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.

Step 1: Install cargo-generate

cargo install cargo-generate

Step 2: Generate a new STM32 project

We’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: no

This 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.

Real-Time Logs + Blinky

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.

Step 1: Install probe-rs Tools

Probe-RS provides a convenient subcommand to build, flash, enable RTT, and stream logs — all in one.

cargo binstall probe-rs-tools

Step 2: Add RTT Crate

cargo add rtt-target

Step 3: Enable RTT inside the Embed.toml file

[default.rtt]
enabled = true

Step 4: Code RTT + Timer + LED Toggle

Replace 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();
    }
}

Step 5: Check the new runner's configuration

  • 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"

Step 6: Flash and Run

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 run
  • Probe-RS will build, flash, reset, and attach RTT.

  • You should see RTT messages like:

Blinky application starting
  • This is followed by a blinking LED on the board at one second intervals.

Wrap-up

You just built a complete Embedded Rust workflow:

1. Toolchain setup with rustup, plus ARM targets
2. Cross-compilation and binary inspection (size, symbols, disassembly)
3. Emulation with QEMU (fast feedback without hardware)
4. Flashing real devices with st-flash or OpenOCD
5. Debugging with GDB + Cargo runners for a one-command loop
6. Logging strategies:
  • Semihosting for early boot/debug

  • UART for robust, fast serial logging

  • RTT for high-speed logs over SWD (no UART)

7. Scaffolding future projects with cargo-generate templates

References & Next Steps

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.