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:

1

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:

xxxxxxxxxx
1
1

You should see output similar to this:

x
1
2
3
4
5
6

If you already has a Rust installation, run the following command to update Rust:

xxxxxxxxxx
1
1

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:

xxxxxxxxxx
3
1
2
3

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

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

You should see something like this:

xxxxxxxxxx
6
1
2
3
4
5
6

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
10
1
2
3
4
5
6
7
8
9
10

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

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

xxxxxxxxxx
1
1

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

xxxxxxxxxx
1
1

Try verifying it:

xxxxxxxxxx
1
1

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:

xxxxxxxxxx
1
1

This compiles with --release, enabling optimizations (-O3 under the hood). Check the result:

xxxxxxxxxx
1
1

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:

xxxxxxxxxx
2
1
2

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

xxxxxxxxxx
1
1

Example output:

xxxxxxxxxx
2
1
2

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

xxxxxxxxxx
1
1

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:

xxxxxxxxxx
1
1

This displays readable disassembly with demangled Rust names; No more puzzling prefixes. You’ll see output like:

xxxxxxxxxx
4
1
2
3
4

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:

xxxxxxxxxx
1
1

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:

xxxxxxxxxx
6
1
2
3
4
5
6

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

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

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:

xxxxxxxxxx
3
1
2
3

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:

xxxxxxxxxx
2
1
2

⚠️ Note: the address 0x08000000 is the flash memory base on most STM32 devices.

Alternatively, for more flexibility and debugging support, use OpenOCD:

xxxxxxxxxx
5
1
2
3
4
5

Once done, your MCU will reset and start running the freshly flashed firmware.

  • Open a serial terminal:

xxxxxxxxxx
1
1

You should see:

xxxxxxxxxx
2
1
2

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

xxxxxxxxxx
4
1
2
3
4

Step 1: Start OpenOCD (GDB Server)

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

xxxxxxxxxx
3
1
2
3

The Directory 2 contains a GDB/OpenOCD configuration file named openocd.gdb with the following commands:

xxxxxxxxxx
27
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

Step 2: Drive GDB Manually

From another terminal, launch GDB pointing at your ELF:

xxxxxxxxxx
1
1

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

 


 

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:

xxxxxxxxxx
9
1
2
3
4
5
6
7
8
9

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
3
1
2
3

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

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:

  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.

xxxxxxxxxx
3
1
2
3

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

xxxxxxxxxx
1
1

Step 2: From your project directory invoke Cargo:

xxxxxxxxxx
1
1

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

xxxxxxxxxx
1
1

Step 2: Generate a new STM32 project

We’ll use a community STM32 template that asks a few guided questions (chip, HAL, etc.):

xxxxxxxxxx
1
1

You’ll be prompted for values such as:

xxxxxxxxxx
6
1
2
3
4
5
6

This produces a ready-to-go directory (e.g., app/) looking like this:

xxxxxxxxxx
12
1
2
3
4
5
6
7
8
9
10
11
12
  • 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.

xxxxxxxxxx
1
1

Step 2: Add RTT Crate

xxxxxxxxxx
1
1

Step 3: Enable RTT inside the Embed.toml file

xxxxxxxxxx
2
1
2

Step 4: Code RTT + Timer + LED Toggle

Replace the existing code inside src/main.rs with this instead:

xxxxxxxxxx
44
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

Step 5: Check the new runner's configuration

  • Open the .cargo/Config.toml and note the new runner configuration, set to use probe-rs

xxxxxxxxxx
2
1
2

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:

xxxxxxxxxx
1
1
  • Probe-RS will build, flash, reset, and attach RTT.

  • You should see RTT messages like:

xxxxxxxxxx
1
1
  • 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 Dr. David Cabanis, Principal Member Technical Staff at Doulos. Version 1.1

This article is Copyright © 2025 by Doulos. All rights are reserved.