100% Written in Rust!
No C, no assembly (well, just a tiny bit in the bootloader) - pure, Rust from bootloader to applications!
A practical example of running multiple applications on a single STM32F411CEU6 (Blackpill) microcontroller with seamless switching between them using a custom bootloader.
rust-stm32-multiapp-bootloader/
├── Cargo.toml # Workspace configuration
├── README.md # This file
├── openocd.cfg # OpenOCD configuration for debugging
├── openocd.gdb # GDB script for OpenOCD
│
├── bootloader/ # The bootloader (16KB)
│ ├── Cargo.toml # Bootloader dependencies
│ ├── build.rs # Build script
│ ├── memory.x # Flash: 0x08000000, 16KB
│ ├── device.x # Device-specific linker script
│ └── src/
│ └── main.rs # Bootloader logic
│
├── app1/ # Application 1 (128KB)
│ ├── Cargo.toml # App1 dependencies
│ ├── build.rs # Build script
│ ├── memory.x # Flash: 0x08004000, 128KB
│ └── src/
│ └── main.rs # Slow blinker with button interrupt
│
├── app2/ # Application 2 (368KB)
│ ├── Cargo.toml # App2 dependencies
│ ├── build.rs # Build script
│ ├── memory.x # Flash: 0x08024000, 368KB
│ └── src/
│ └── main.rs # Fast blinker with button polling
│
└── target/ # Build artifacts (gitignored)
├── debug/ # Debug builds
├── release/ # Release builds
└── thumbv7em-none-eabihf/ # Target-specific builds
Imagine you want to run different programs on your microcontroller without having to reflash it every time. Maybe you want a "settings mode" and a "normal operation mode," or different diagnostic tools that you can switch between with a button press.
This project demonstrates exactly that: two independent applications living in the same chip's flash memory, with the ability to switch between them at runtime.
App1 (The Slow Blinker):
- Blinks the LED in a distinctive pattern: blink-blink-looong pause
- When you press the button, it triggers an interrupt and switches to App2
- Located at flash address
0x08004000
App2 (The Fast Blinker):
- Blinks the LED rapidly with a steady rhythm
- Continuously checks the button state (polling approach)
- When you press the button, it switches back to App1
- Located at flash address
0x08024000
Both apps can switch to each other, creating a complete bidirectional switching system!
Think of the bootloader as a tiny program that decides which application to run when the chip starts up. It sits at the very beginning of flash memory (0x08000000) where the chip always starts executing after a reset.
-
On Power-Up or Reset: The STM32 chip always starts executing code from address
0x08000000(the bootloader) -
Check the Magic Value: The bootloader reads a special location in RAM (
0x2001FFF8) looking for a "magic number"- If it finds
0xDEADBEEF→ Jump to App1 - If it finds
0xCAFEBABE→ Jump to App2 - If it finds anything else (or nothing) → Default to App1
- If it finds
-
Clear the Magic: After reading it, the bootloader clears the magic value to prevent boot loops
-
Jump to the Application: The bootloader sets up the processor to start running the chosen application
- Updates the Vector Table Offset Register (VTOR) to point to the app's interrupt vectors
- Jumps to the application's entry point using
cortex_m::asm::bootload()
Flash Memory (512KB total):
┌─────────────────────────────────┐ 0x08000000
│ Bootloader (16KB) │ <- Chip always starts here
├─────────────────────────────────┤ 0x08004000
│ App1 (128KB) │ <- Default application
├─────────────────────────────────┤ 0x08024000
│ App2 (368KB) │ <- Alternate application
└─────────────────────────────────┘ 0x0807FFFF
Each component (bootloader, App1, App2) needs its own memory.x file to tell the linker where in memory to place the code.
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 16K
RAM : ORIGIN = 0x20000000, LENGTH = 128K - 8
NOINIT_RAM : ORIGIN = 0x2001FFF8, LENGTH = 8
}Key Points:
FLASH: Starts at the very beginning (0x08000000) - only 16KB to keep it smallRAM: Normal RAM for variables and stack (slightly reduced to make room for NOINIT)NOINIT_RAM: The magic ingredient! This is a special 8-byte section at the end of RAM
Normally, when a program starts, the runtime automatically clears (zeros out) all RAM. This is good for normal programs, but we need to preserve the magic value across resets!
The .noinit section tells the linker: "Don't initialize this portion of memory!"
Here's the magic trick:
- On STM32F4, when you trigger a software reset (not a power cycle), the RAM contents are NOT cleared by the hardware
- BUT the C/Rust runtime would normally clear it during startup
- By marking it as
.noinit, we prevent the runtime from touching it - This allows the magic value written by App1 or App2 to survive the reset and be read by the bootloader
MEMORY
{
FLASH : ORIGIN = 0x08004000, LENGTH = 128K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}Key Points:
FLASH: Starts at0x08004000(right after the 16KB bootloader)- Gets 128KB of space for its code
- Uses full RAM since it doesn't need the noinit section
MEMORY
{
FLASH : ORIGIN = 0x08024000, LENGTH = 368K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}Key Points:
FLASH: Starts at0x08024000(after bootloader + App1)- Gets the remaining 368KB of flash memory
- Also uses full RAM
Let's walk through what happens when you press the button in App1:
You press the button → App1's interrupt handler fires
const MAGIC_ADDR: *mut u32 = 0x2001_FFF8 as *mut u32;
const MAGIC_APP2: u32 = 0xCAFE_BABE;
write_volatile(MAGIC_ADDR, MAGIC_APP2);App1 writes 0xCAFEBABE (magic value for App2) to the special RAM location
const SCB_AIRCR: *mut u32 = 0xE000_ED0C as *mut u32;
write_volatile(SCB_AIRCR, 0x05FA0004); // System reset commandApp1 triggers a software reset of the entire chip
The processor resets → All peripherals reset → But RAM keeps its contents!
Critical: Software reset does NOT clear RAM on STM32F4
Chip starts executing from 0x08000000 (bootloader entry point)
As always after any reset, execution begins at the bootloader
let magic = unsafe { read_volatile(&MAGIC_VALUE) }; // Reads 0xCAFEBABEThe bootloader finds 0xCAFEBABE in the noinit section
unsafe { write_volatile(&mut MAGIC_VALUE, 0); }Clears it so next power-on boots App1 by default
let app_addr = match magic {
MAGIC_APP2 => APP2_ADDR, // 0x08024000
_ => APP1_ADDR,
};
jump_to_app(app_addr);The bootloader sets VTOR and jumps to App2 at 0x08024000
App2 starts fresh → Full reset means clean initialization
LED now blinks fast → Button polling works perfectly
The same process works in reverse when App2 switches back to App1!
Want to add App3, App4, or more? Here's exactly what you need to do:
First, determine where your new app will live in flash. You need to adjust the existing apps to make room.
Example: Adding App3 (64KB)
Current layout:
- Bootloader:
0x08000000-0x08003FFF(16KB) - App1:
0x08004000-0x08023FFF(128KB) - App2:
0x08024000-0x0807FFFF(368KB)
New layout:
- Bootloader:
0x08000000-0x08003FFF(16KB) - unchanged - App1:
0x08004000-0x08023FFF(128KB) - unchanged - App2:
0x08024000-0x08043FFF(128KB) - reduced from 368KB - App3:
0x08044000-0x0807FFFF(240KB) - new!
mkdir -p app3/src
mkdir -p app3/.cargoCopy from app1 or app2 and change the package name:
[package]
name = "app3"
version = "0.1.0"
edition = "2021"
[dependencies]
# ... same dependencies as app1/app2 ...Rationale: Each app is a separate Rust binary with its own dependencies.
MEMORY
{
FLASH : ORIGIN = 0x08044000, LENGTH = 240K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}Rationale:
ORIGIN: Must match your chosen flash address (0x08044000in this example)LENGTH: The space allocated for this app (240KB remaining flash)- All apps share the same RAM space (only one app runs at a time)
[target.thumbv7em-none-eabihf]
rustflags = [
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
]Rationale: Ensures app3 uses the defmt linker script (not needed by bootloader, but needed by apps).
Copy from app1 or app2, then customize:
pub unsafe fn jump_to_other(_addr: u32) -> ! {
use core::ptr::write_volatile;
// Magic RAM location and value for your target app
const MAGIC_ADDR: *mut u32 = 0x2001_FFF8 as *mut u32;
const MAGIC_APP1: u32 = 0xDEAD_BEEF; // Or whatever app you want to jump to
write_volatile(MAGIC_ADDR, MAGIC_APP1);
cortex_m::asm::dsb();
// Trigger system reset
const SCB_AIRCR: *mut u32 = 0xE000_ED0C as *mut u32;
write_volatile(SCB_AIRCR, 0x05FA0004);
loop { cortex_m::asm::nop(); }
}
// ... rest of your app code ...Rationale: Each app needs the jump function to write the appropriate magic value for switching.
Add app3 to the workspace members:
[workspace]
members = ["bootloader", "app1", "app2", "app3"]Rationale: Makes cargo build --workspace include app3.
Since we reduced App2's size to make room for App3:
MEMORY
{
FLASH : ORIGIN = 0x08024000, LENGTH = 128K # Changed from 368K
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}Rationale: Apps can't overlap in flash - you must resize existing apps if needed.
Define a unique magic value for App3. In the bootloader's src/main.rs:
const MAGIC_APP1: u32 = 0xDEAD_BEEF;
const MAGIC_APP2: u32 = 0xCAFE_BABE;
const MAGIC_APP3: u32 = 0xBAAD_F00D; // New magic value
const APP3_ADDR: u32 = 0x0804_4000; // New app addressRationale: Each app needs a unique magic value so the bootloader knows which one to boot.
In bootloader/src/main.rs, update the match statement:
let app_addr = match magic {
MAGIC_APP2 => APP2_ADDR,
MAGIC_APP3 => APP3_ADDR, // New case
MAGIC_APP1 => APP1_ADDR,
_ => APP1_ADDR, // Default
};Rationale: The bootloader must recognize the new magic value and know where to jump.
If App1 or App2 need to jump to App3, update their jump_to_other functions:
const MAGIC_ADDR: *mut u32 = 0x2001_FFF8 as *mut u32;
const MAGIC_APP3: u32 = 0xBAAD_F00D;
write_volatile(MAGIC_ADDR, MAGIC_APP3);
// ... trigger reset ...Rationale: Apps need to know the magic values of other apps they want to switch to.
Update .vscode/tasks.json to include build and flash tasks for app3:
{
"label": "Build app3 (release)",
"type": "shell",
"command": "cargo build --release -p app3"
},
{
"label": "Flash app3",
"type": "shell",
"command": "probe-rs download target/thumbv7em-none-eabihf/release/app3 --chip STM32F411CEUx --base-address 0x08044000"
}Rationale: Makes building and flashing easier from VS Code.
cargo build --release -p bootloader
cargo build --release -p app1
cargo build --release -p app2
cargo build --release -p app3Rationale: Each component must be built separately before flashing.
# Flash bootloader first (always at 0x08000000)
probe-rs download target/thumbv7em-none-eabihf/release/bootloader \
--chip STM32F411CEUx --base-address 0x08000000
# Flash app1
probe-rs download target/thumbv7em-none-eabihf/release/app1 \
--chip STM32F411CEUx --base-address 0x08004000
# Flash app2
probe-rs download target/thumbv7em-none-eabihf/release/app2 \
--chip STM32F411CEUx --base-address 0x08024000
# Flash app3
probe-rs download target/thumbv7em-none-eabihf/release/app3 \
--chip STM32F411CEUx --base-address 0x08044000Rationale: The value for --base-address must match each app's ORIGIN in its memory.x.
- Choose a flash address and size that doesn't overlap existing apps
- Create app directory with
src/,.cargo/,Cargo.toml,memory.x - Set correct
ORIGINinmemory.xto match your chosen flash address - Add app to workspace
Cargo.tomlmembers - Choose a unique magic value (e.g.,
0xBAAD_F00D) - Update bootloader to recognize the new magic value and app address
- Update other apps' jump functions if they need to switch to the new app
- Build with
cargo build --release -p appN - Flash with correct
--base-addressmatching your flash origin - Rebuild bootloader if you changed its code
- Test by power cycling and pressing buttons
The bootloader only needs to be flashed ONCE (or whenever you change the number of apps or the flash layout).
Here's why:
- The bootloader defines the memory map - where each app lives in flash
- Once flashed, it sits at
0x08000000and orchestrates which app runs - Apps can be updated independently without touching the bootloader
- You only reflash the bootloader if you:
- Add or remove applications (changing the flash distribution)
- Modify the bootloader logic itself
- Completely erase the chip
Typical workflow after initial setup:
# Initial setup (one time):
cargo build --release -p bootloader
probe-rs download target/thumbv7em-none-eabihf/release/bootloader \
--chip STM32F411CEUx --base-address 0x08000000
# Regular development (update apps as needed):
cargo build --release -p app1
probe-rs download target/thumbv7em-none-eabihf/release/app1 \
--chip STM32F411CEUx --base-address 0x08004000
# The bootloader stays untouched!The bootloader and apps are distributed across the STM32F411's 512KB flash:
Flash Address Component Size Purpose
─────────────────────────────────────────────────────────────────
0x08000000 Bootloader 16KB App selector, runs on every boot
0x08004000 App1 128KB Your first application
0x08024000 App2 368KB Your second application
0x08080000 [End] --- Total: 512KB used
Key Points:
- Bootloader (16KB): Small and efficient, just enough to read magic values and jump
- App1 (128KB): Moderate size, suitable for most applications
- App2 (368KB): Gets the remaining space, ideal for larger/feature-rich apps
- You can adjust these sizes in each component's
memory.xfile - Just ensure they don't overlap and fit within the 512KB total flash
# Build all components
cargo build --release -p bootloader
cargo build --release -p app1
cargo build --release -p app2
# Flash in order (bootloader first, only needed once!)
probe-rs download target/thumbv7em-none-eabihf/release/bootloader \
--chip STM32F411CEUx --base-address 0x08000000
probe-rs download target/thumbv7em-none-eabihf/release/app1 \
--chip STM32F411CEUx --base-address 0x08004000
probe-rs download target/thumbv7em-none-eabihf/release/app2 \
--chip STM32F411CEUx --base-address 0x08024000- Board: STM32F411CEU6 Blackpill
- LED: PC13 (onboard LED)
- Button: PA0 (with pull-up resistor)
- Clock: 25 MHz HSE (external crystal)
- The bootloader is your app selector - it always runs first and decides what to run next
- The
.noinitsection is the secret sauce - it preserves data across software resets (you could see it as a very low level mailbox for message passing between the different apps and the bootloader in which the content of the message tells the bootloader which app should run). - Each app lives at its own flash address - they can't overlap or overwrite each other
- System reset gives a clean slate - each app starts fresh, solving interrupt initialization issues
- Magic values are the communication protocol - simple but effective way to pass information through a reset
This architecture is commonly used in production embedded systems for features like firmware updates, multiple operating modes, and diagnostic tools!
MIT License - Feel free to use this as a learning resource or starting point for your own projects.
Happy hacking!