Introduction

It’s been well over a year (perhaps almost two even) since I implemented this, so unfortunately I’ve already forgotton most of the story around it. However, I remember it was a huge struggle so I’ve been meaning to write it up for a while in the hope that it might help others in the future. It’s also just a nice reference for my future self :)

Anyway, we’ve recently starting shipping a product powered by embedded Rust and Embassy! Embassy includes a great bootloader, but it was not easy to find a way to read the bootloader version from the application, so in this post I’ll briefly document how that was achieved.

It was obvious from the beginning that it should be possible to embed the version string at a specific address inside the bootloader so that I could simply read that address from the app, but figuring out the exact details was tricky…

The Big Picture

On the STM32F4 powering this product, flash is memory-mapped. That means a byte sitting at address 0x0800FC00 in flash can be read by the CPU just by dereferencing a pointer to that address. The bootloader and the app just need to agree on what lives at that address and how to interpret it.

Here’s the relevant slice of the flash layout:

0x0800_0000  +-----------------------+
             | Bootloader code       |  63 KiB
0x0800_FC00  +-----------------------+
             | Bootloader version    |   1 KiB  <-- the slot
0x0801_0000  +-----------------------+
             | Boot state (DFU etc.) |  64 KiB
0x0802_0000  +-----------------------+
             | Application           | 896 KiB
0x0810_0000  +-----------------------+
             | DFU download slot     |   1 MiB
             +-----------------------+

The version slot is a 1 KiB region sitting right between the bootloader code and the boot state area. The bootloader’s linker script places a static there at compile time. The app’s linker script maps the same physical address and exposes a symbol the app can read.

I can’t remember the exact reason I chose the slot to be 1 KiB, but this might have been driven by a hardware constraint. It was either that or I just felt like 1 KiB was a good enough size to be “future proof”. That and there was probably plenty of space left in the 64 KiB bootloader block. The reason why the entire bootloader image spans a 64 KiB block is related to constraints regarding programming of the flash memory.

Building the Version String

In the bootloader’s build.rs, I construct the version string from Cargo.toml:

let version = format!("psx8-bootloader-v{}", env!("CARGO_PKG_VERSION"));
println!("cargo:rustc-env=VERSION={version}");

This makes env!("VERSION") available as a compile-time string literal in the bootloader source.

I needed a well-defined binary layout so the app can interpret the bytes without any ambiguity, so I defined a 1024-byte struct — exactly the size of the version slot:

#[repr(C)]
struct VersionString {
    len: usize,        // 4 bytes on 32-bit ARM
    data: [u8; 1020],  // 1020 bytes
}
// Total: 1024 bytes = 1 KiB

const _: () = assert!(size_of::<VersionString>() == 1024);
const _: () = assert!(align_of::<VersionString>() == 4);

#[repr(C)] guarantees the layout is exactly {len, data} with no padding surprises. The compile-time assertions catch any drift.

Then a const fn builds the struct at compile time. I used the byte-strings crate’s const_as_bytes! macro to convert the string literal into a byte slice inside a const context:

const fn build_version_string() -> VersionString {
    let mut data = [0; 1020];
    let ver = const_as_bytes!(env!("VERSION"));
    let mut i = 0;
    while i < ver.len() {
        data[i] = ver[i];
        i += 1;
    }
    VersionString { len: ver.len(), data }
}

This is a plain while loop since const fn doesn’t allow iterators or copy_from_slice, but it works fine for copying bytes one at a time.

Placing It in Flash

Now I needed this struct to land at the right address. For this, I used two attributes:

#[unsafe(link_section = ".bootloader_version")]
#[used]
static VERSION: VersionString = build_version_string();

#[link_section] tells the compiler to put this static into the .bootloader_version ELF input section instead of the default .rodata. #[used] prevents the linker from garbage-collecting the symbol. Without it, the linker would see that no code references VERSION and silently drop it (I learned this the hard way).

The bootloader’s linker script (memory.x) defines the memory regions and places the section:

MEMORY
{
  FLASH               : ORIGIN = 0x08000000, LENGTH = 63K
  BOOTLOADER_VERSION  : ORIGIN = 0x0800FC00, LENGTH = 1K
  ...
}

SECTIONS
{
  .bootloader_version : ALIGN(4)
  {
    . = ALIGN(4);
    KEEP(*(.bootloader_version));
    . = ALIGN(4);
  } > BOOTLOADER_VERSION
}

The BOOTLOADER_VERSION region starts at 0x0800FC00 and is 1 KiB long — matching the struct size exactly. The KEEP() directive is the linker-script equivalent of #[used]: it tells the linker not to discard the section even if it appears unreferenced. The ALIGN(4) ensures 4-byte alignment, matching the struct’s alignment requirement.

When the bootloader is compiled and flashed, the version string is burned into flash at 0x0800FC00 as part of the normal image.

Reading It from the App

The app has its own linker script (app-memory.x) that defines the same memory map from the app’s perspective:

MEMORY
{
  BOOTLOADER          : ORIGIN = 0x08000000, LENGTH = 63K
  BOOTLOADER_VERSION  : ORIGIN = 0x0800FC00, LENGTH = 1K
  ...
}

SECTIONS
{
  .bootloader_version : ALIGN(4)
  {
    . = ALIGN(4);
    __bootloader_version_start = .;
  } > BOOTLOADER_VERSION
}

Obviously, the addresses and the sizes need to match here. But instead of pulling in a section with KEEP(), the app just defines a linker symbol __bootloader_version_start pointing at the start of that region. The app doesn’t write anything there, it only reads.

This is where the Rust gets interesting. The app re-declares the same struct layout (it has to match exactly):

#[repr(C)]
struct VersionString {
    len: usize,
    data: [u8; 1020],
}

Then it declares an extern static bound to the linker symbol:

unsafe extern "C" {
    static __bootloader_version_start: VersionString;
}

This tells the linker: “there exists a symbol called __bootloader_version_start, and I want to treat whatever is at that address as a VersionString.” The linker resolves it to 0x0800FC00. No actual variable is defined on the app side, the symbol is just an address.

The read function then dereferences this address:

pub fn get_bootloader_version() -> Result<&'static str, ReadVersionError> {
    unsafe {
        let version = &__bootloader_version_start;

        if version.len < 1 || version.len > 1020 {
            return Err(ReadVersionError);
        }

        let version = from_utf8_unchecked(&version.data[..version.len]);
        Ok(version)
    }
}

The &__bootloader_version_start here is a simple raw pointer dereference since the extern static is really just an address. Taking a reference to it reads the flash at that address. Because flash is memory-mapped, this is a simple bus read with no driver needed.

I also added validation to check that len is in [1, 1020]. If the bootloader was never flashed (all zeros), or if the data is corrupted, this hopefully catches it. A fresh, unprogrammed flash page likely reads as all 0xFF or all 0x00 depending on the MCU, so len should be wildly out of range in that case.

Finally, from_utf8_unchecked avoids the runtime cost of re-validating UTF-8 on every boot. I know the data is valid UTF-8 because the bootloader built it from a Rust string literal. The lifetime is 'static because the data lives in flash and will never be deallocated or mutated.

Why This Works

The whole scheme rests on a few guarantees. Flash is memory-mapped, so reading from a flash address is as simple as reading from a pointer. The CPU’s bus handles it. #[repr(C)] on both sides ensures the bootloader and app agree on the struct layout. The compile-time size and alignment assertions make this contract explicit and machine-checked.

Both linker scripts define the same address for BOOTLOADER_VERSION. If someone changes one without the other, the app will read garbage, but at least the len validation will catch most cases. It turns out both #[used] and KEEP() are needed. #[used] keeps the symbol in the ELF object file and KEEP() keeps it through the linker’s garbage collection. Missing either one caused the version to silently disappear from the binary.

Conclusion

I found this exercise to be a good example of how understanding how an embedded system (and memory in particular) works at the lowest level is sometimes necessary to arrive at a solution. It’s also an example of how the linker script can be used in interesting ways.

Achieving this result in Rust was probably a bit more verbose and complex than it would have been in C, but thankfully it worked out in the end!