Skip to content
Lesson 13.5Physical Memory, Machines, and Hardware

Firmware and Bare-Metal Rust

Follow a microcontroller from reset to Rust, build a working firmware image, and understand what changes when you program a physical board.

Lesson 13.5
Read or listenPrint chapter or book
About 28 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 13.5Key ideas, reference and questions

A one-page summary of “Firmware and Bare-Metal Rust” you can keep beside your notes. Plain Markdown.

Lesson 13.5 — Firmware and Bare-Metal Rust: cheatsheet

  • Remember: The compilation target specifies the processor environment; the board configuration supplies its particular memory layout and peripherals.
  • Also useful: The vector table is a startup directory of addresses: the processor reads where the stack and reset handler begin, rather than executing the entries as instructions.

Key terms

  • firmware: That software is firmware.
  • bare-metal programming: The latter arrangement is called bare-metal programming, and it is the arrangement used by this lesson.
  • QEMU: QEMU is an emulator: a program that reproduces a machine's behaviour so its software can run on another computer.
  • SRAM: A KiB is 1,024 bytes; SRAM is the chip's working RAM, whose contents are not retained without power.
  • boot ROM: A boot ROM is startup software stored in read-only memory on a chip.
  • rising edge: The logical change false → true is a rising edge.
  • thumbv7m-none-eabi: The Rust target is thumbv7m-none-eabi.
  • LENGTH: ORIGIN is a region's starting address; LENGTH is its capacity.
  • panic_handler: A panic_handler defines what this no_std application does if it panics.
  • watchdog: A watchdog is a timer that can reset a stalled device unless firmware services it correctly; its configuration and recovery policy matter on real hardware, rather than being a cure for an unexplained bug.

Review

  • Q: A firmware image uses the right Cortex-M CPU target but a memory.x file for a different board. Is matching the CPU enough to make the image correct? A: No: the target picks instructions, but the linker layout must match the board's addresses and sizes
  • Q: The input samples are false, true, true, true. How many times does this lesson's controller toggle the lamp? A: Once, when false first becomes true
Download cheatsheet (.md)

A controller can recognise a button press before your game opens. An SSD can manage its stored blocks before a Windows driver asks for one. A console can check its boot software before its main operating system starts. In each case, some software belongs to the device itself. That software is firmware.

Writing your own firmware means taking responsibility for work that a desktop application normally delegates: starting the processor correctly, choosing a memory layout, configuring hardware, and deciding what happens when something fails. We will make those responsibilities concrete with a small button-and-lamp controller, build its Rust code into a firmware image, and run that image on an emulated microcontroller.

Firmware is software that operates a device, usually stored in nonvolatile memory: memory that keeps its contents without power. Flash is a common example and can be erased and programmed again. The word does not mean that the code is permanently unchangeable, nor that it must be written in assembly.

Consider a USB controller plugged into a PC:

Software Where it runs What it does
Game application The PC’s processor Turns a recognised button action into a game action
Device driver The PC’s processor, as part of its OS Exchanges requests and reports with the controller
Controller firmware The controller’s own processor Samples its buttons and prepares the reports

The driver and firmware cooperate across a device boundary. Updating the game does not necessarily update either one. A firmware update replaces code in the device; its format and programming procedure belong to that particular device.

Firmware can include a real-time operating system, or RTOS, which schedules tasks and provides services suited to embedded devices. It can also use no OS at all. The latter arrangement is called bare-metal programming, and it is the arrangement used by this lesson. An embedded Linux application is another possibility; being physically inside a device does not make every program bare-metal firmware.

A microcontroller combines a processor core, memory, and peripheral devices on one chip. Peripherals include timers, serial interfaces, and GPIO (general-purpose input/output) pins. A GPIO pin can sense a button’s electrical level or drive an output, with the appropriate circuit and configuration.

Our target is QEMU’s model of the LM3S6965EVB evaluation board. QEMU is an emulator: a program that reproduces a machine’s behaviour so its software can run on another computer. This model has a Cortex-M3 core, 256 KiB of flash, and 64 KiB of SRAM. A KiB is 1,024 bytes; SRAM is the chip’s working RAM, whose contents are not retained without power. These capacities are listed in the QEMU board documentation. The address map is shown in the Cortex-M memory-layout walkthrough.

Region in this model Address range Purpose
Flash, 256 KiB 0x00000000–0x0003FFFF Startup information, instructions, and stored constants
SRAM, 64 KiB 0x20000000–0x2000FFFF Writable state and the call stack
Peripheral registers Device-specific addresses Configure hardware and exchange input/output

These are addresses in the emulated device, not addresses in the PC process running QEMU. The model can load an image into its flash region, but that does not reproduce the electrical procedure for programming a physical flash chip.

For the lamp controller, the instructions survive a power cycle in flash. The current lamp state is working state: the program starts it again at each boot. Retaining the last setting across power loss would require an explicit storage design. Writing flash every time the main loop runs would be a poor substitute: flash has erase/program constraints and finite endurance that RAM does not.

On this Cortex-M system, reset does not mean “call whichever function happens to be named main.” The core obtains startup values from a vector table. Its first entry contains the initial stack pointer; its second contains the reset-handler address. Later entries describe exception and interrupt handlers. The table contains values and addresses, rather than a sequence of application instructions. The Cortex-M memory-layout walkthrough shows how these entries fit into an image.

0x00000000initial SP: 0x20010000
0x00000004reset-handler address
0x00000008next handler address
The beginning of this image's Cortex-M vector table. The initial stack pointer is just above the 64 KiB RAM region; the stack grows toward lower addresses. The reset address is supplied by the runtime, rather than guessed in application code.

The reset handler runs startup code. Among its jobs is preparing writable static data so Rust can safely use it. The linker groups program contents into sections. Placement in the image happens before execution; reset-time initialization then prepares particular writable ranges:

Section What belongs there Who arranges it
.vector_table Initial stack value and handler addresses The linker places it; the loader or programmer supplies the image
.text Machine instructions The linker places executable code in flash for this image
.rodata Read-only data, such as message strings The linker places stored constants in flash
.data Writable statics with stored initial values The linker describes both ranges; reset startup copies initial contents from flash to RAM
.bss Writable statics initially filled with zero The linker reserves the range; reset startup fills it with zero

For example, a writable counter that must start at 7 needs both a working location in RAM and an initial value in the image. Startup copies the 7 before the application uses the counter. A counter starting at zero needs a reserved RAM range, but the image need not carry a long run of zero bytes: startup can write the zeros. The linker and runtime agree on where these ranges begin and end. Local variables are separate; a compiler can keep them in registers or on the stack as the program executes.

What startup does before Rust uses its staticsOn reset the RAM is not initialised. Startup copies .data from flash and zero-fills .bss.
  1. 1 Startup copies .data from flash

    Flash, 256 KiB
    0x000000000x0003FFFF
    code and vectors
    .data initial value: 7
    SRAM, 64 KiB
    0x200000000x2000FFFF
    .data counter 7
    .bss counter ??

    After reset SRAM holds unknown bytes (??). Startup first copies the initial value 7 from flash into the .data counter.

  2. 2 Startup zero-fills .bss: now Rust may use both

    SRAM, 64 KiB
    0x200000000x2000FFFF
    .data counter 7
    .bss counter 0

    The .bss counter is filled with zero, so both statics hold their starting values before main runs.

On reset the RAM is not initialised. Startup copies .data from flash and zero-fills .bss.

The cortex-m-rt runtime provides the Cortex-M startup and vector arrangement used here. Our application supplies its memory map and Rust entry function. The runtime prepares static data, then calls that function. These responsibilities are why a source file containing fn main() alone is not a complete firmware image.

Startup data and button edges have different jobs. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

startup copies flash data into RAM
startup zeroes BSS
lamp = off; previous = released
for each sample: pressed = held and not previous
toggle on pressed; save previous
report initialized memory and final lamp

Result: RAM data 7, BSS zero, 2 presses and lamp off. Holding a button does not create repeated edges.

Final values: data = 7; samples = two; ram_data = 7; bss = 0; lamp = false; presses = 2; held = false; pressed = false.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.

    data = 7; samples = two.

  2. Step 2, line 2. The startup routine copies initialized data, then clears zero-initialized storage.

    data = 7; samples = two; ram_data = 7; bss = 0.

  3. Step 3, line 3. Rust starts after these memory regions are initialized.

    data = 7; samples = two; ram_data = 7; bss = 0; lamp = false; presses = 0.

  4. Step 4, line 5. Held false gives fresh press false.

    data = 7; samples = two; ram_data = 7; bss = 0; lamp = false; presses = 0; held = false; pressed = false.

  5. Step 5, line 5. Held true gives fresh press true.

    data = 7; samples = two; ram_data = 7; bss = 0; lamp = true; presses = 1; held = true; pressed = true.

  6. Step 6, line 5. Held true gives fresh press false.

    data = 7; samples = two; ram_data = 7; bss = 0; lamp = true; presses = 1; held = true; pressed = false.

  7. Step 7, line 5. Held false gives fresh press false.

    data = 7; samples = two; ram_data = 7; bss = 0; lamp = true; presses = 1; held = false; pressed = false.

  8. Step 8, line 5. Held true gives fresh press true.

    data = 7; samples = two; ram_data = 7; bss = 0; lamp = false; presses = 2; held = true; pressed = true.

  9. Step 9, line 5. Held false gives fresh press false.

    data = 7; samples = two; ram_data = 7; bss = 0; lamp = false; presses = 2; held = false; pressed = false.

  10. Step 10, line 6. Result: RAM data 7, BSS zero, 2 presses and lamp off. Holding a button does not create repeated edges.

    data = 7; samples = two; ram_data = 7; bss = 0; lamp = false; presses = 2; held = false; pressed = false.

Watch it happenInitialize memory, then detect new button presses

Reset copies the initial stack pointer 0x20010000 and follows the reset handler. Startup copies counter 7 into .data and zeroes .bss before entering Rust. Six button samples are false, true, true, false, true, false. Only new presses toggle the lamp, producing Off, On, On, On, Off, Off, which is reported to QEMU.

Read the steps
  1. Reset reads the vector table. Copy its initial stack pointer into SP, then follow the reset-handler address supplied by the runtime. The stack starts just above the 64 KiB RAM region.
  2. Startup copies the stored counter value 7 from flash into the .data working range. It writes zeros into .bss. The image needs stored bytes for 7, but no stored run of zero bytes.
  3. Only after initialization does the runtime call the Rust entry function. The lamp controller begins with lamp_on false and was_pressed false.
  4. Sample 1 is released, so the lamp stays off. Sample 2 changes false to true: a new press toggles the lamp on. Remember that the button is now pressed.
  5. Sample 3 is still pressed. Since the previous sample was already true, it is not a new edge. Sample 4 releases the button. Neither sample toggles the lamp, which stays on.
  6. Sample 5 is a second false-to-true edge, so it toggles the lamp off. Sample 6 releases the button and leaves it off. Holding and releasing are different from a new press.
  7. The application reports Off, On, On, On, Off, Off through semihosting to QEMU, then requests an emulator exit. This reporting can happen only after startup and application execution.

The six inputs and outputs are the lesson’s trace. The button is a logical stable press; physical switch bounce needs a separate filter.

A boot ROM is startup software stored in read-only memory on a chip. A bootloader is software whose job is to select, check, or load the application that will run next. It may support updates and recovery. A vendor boot ROM can start a bootloader; a bootloader can then start an application image.

Those stages are not mandatory in every design. Our simple image starts through its Cortex-M reset vectors and runtime without a separate bootloader. A console uses a more elaborate chain, including signature checks, as console architecture explains next. A bootloader’s work and the Rust runtime’s initialization work remain distinct, even if a particular system coordinates them closely.

Write the control rule separately from the hardware

Section titled “Write the control rule separately from the hardware”

Our rule is: a new button press toggles the lamp once. Keeping the button held must not toggle it on every sample. The input is a logical pressed value; the output is whether the lamp should now be on.

The logical change false → true is a rising edge. We detect it by remembering the previous sample:

if pressed now AND not pressed previously:
toggle the lamp
remember the current pressed state
report the lamp state

Here is that complete control rule in Rust. It requires neither an OS nor a hardware register address:

pub struct LampController {
lamp_on: bool, // Output state that survives between samples.
was_pressed: bool, // The previous sample, needed to detect a new press.
}
impl LampController {
pub const fn new() -> Self {
Self { lamp_on: false, was_pressed: false }
}
pub fn sample(&mut self, pressed: bool) -> bool {
// Holding the button is not a new edge.
if pressed && !self.was_pressed {
self.lamp_on = !self.lamp_on;
}
self.was_pressed = pressed;
self.lamp_on
}
}

&mut self lets the method update this controller’s remembered state. ! negates a boolean: it switches false to true or true to false. The last expression returns the new lamp state. No heap allocation is needed; the controller stores just its two boolean fields.

The input deliberately means a recognised, stable press. A physical switch can bounce, briefly making and breaking contact several times. Rising-edge detection alone does not remove that bounce. Physical firmware would first use a suitable debounce policy, often based on several stable samples over time, then pass the resulting logical state to this controller.

Sample Input pressed? New press? Lamp after the sample
1 false No Off
2 true Yes On
3 true No: still held On
4 false No: released On
5 true Yes Off
6 false No Off

This separation lets us test the rule on the PC and then reuse it inside a firmware image. It also keeps a logic bug distinguishable from a pin-wiring or clock-configuration bug.

The firmware-labs project combines this rule with a target, memory map, runtime, and entry function. The full image follows; the build reference keeps its exact files and reproduction steps.

The target and the layout answer different questions

Section titled “The target and the layout answer different questions”

The thumbv7m-none-eabi target selects Armv7-M instructions, a bare-metal environment, and a calling convention for Cortex-M3. It does not select board pins or a memory map. memory.x declares the regions for the runtime’s link.x: ORIGIN gives the starting address; LENGTH gives the capacity.

/* K means 1024 bytes in this linker script. */
MEMORY
{
FLASH : ORIGIN = 0x00000000, LENGTH = 256K
RAM : ORIGIN = 0x20000000, LENGTH = 64K
}

#![no_std] uses Rust’s core without the OS-oriented standard library. Arithmetic, structs, borrowing, arrays, slices, Option, and Result still work; OS files, threads, and terminal printing do not appear automatically. Vec needs allocation support; this controller does not.

#![no_main] removes the normal application entry arrangement. The runtime’s #[entry] marks the function called after startup. -> ! means it never returns: there is no desktop caller waiting.

Semihosting asks a supporting debugger or emulator to perform host-side operations. Here cortex-m-semihosting sends text to QEMU’s terminal, without a guest OS or emulated UART. This is the full src/main.rs:

main.rs

firmware-labs/src/ · Rust, 45 lines

Show the codeHide the code
#![no_std] // Use core without an operating system's standard library.
#![no_main] // Let cortex-m-rt supply the reset/startup entry arrangement.

use core::{fmt, fmt::Write, panic::PanicInfo};
use cortex_m_rt::entry;
use cortex_m_semihosting::{debug, hio};
use gha_firmware_labs::LampController;

// These samples stand in for a button; they are not GPIO reads.
fn run_trace() -> Result<(), fmt::Error> {
    let mut output = hio::hstdout().map_err(|_| fmt::Error)?;
    let mut lamp = LampController::new();

    for (index, pressed) in [false, true, true, false, true, false]
        .into_iter()
        .enumerate()
    {
        let on = lamp.sample(pressed);
        writeln!(output, "sample {}: pressed={pressed}, lamp={on}", index + 1)?;
    }
    Ok(())
}

#[entry]
fn main() -> ! {
    // Tell QEMU whether the trace completed; no guest OS exits this program.
    let status = match run_trace() {
        Ok(()) => debug::EXIT_SUCCESS,
        Err(_) => debug::EXIT_FAILURE,
    };
    debug::exit(status); // QEMU-only: replace this in physical-board firmware.

    // The entry point must never return, even if the exit request returns.
    loop {
        core::hint::spin_loop();
    }
}

#[panic_handler]
fn panic(_information: &PanicInfo) -> ! {
    debug::exit(debug::EXIT_FAILURE); // A panic makes this emulator run fail.
    loop {
        core::hint::spin_loop();
    }
}
#![no_std] // Use core without an OS standard library.
#![no_main] // The runtime supplies reset/startup.
use core::{fmt, fmt::Write, panic::PanicInfo};
use cortex_m_rt::entry;
use cortex_m_semihosting::{debug, hio};
use gha_firmware_labs::LampController;
fn run_trace() -> Result<(), fmt::Error> {
let mut output = hio::hstdout().map_err(|_| fmt::Error)?;
let mut lamp = LampController::new();
// These are logical input samples, not GPIO reads.
for (index, pressed) in [false, true, true, false, true, false]
.into_iter()
.enumerate()
{
let on = lamp.sample(pressed);
writeln!(output, "sample {}: pressed={pressed}, lamp={on}", index + 1)?;
}
Ok(())
}
#[entry]
fn main() -> ! {
let status = match run_trace() {
Ok(()) => debug::EXIT_SUCCESS,
Err(_) => debug::EXIT_FAILURE,
};
debug::exit(status); // Finish this QEMU test run.
loop { core::hint::spin_loop(); } // Never return from firmware entry.
}
#[panic_handler]
fn panic(_information: &PanicInfo) -> ! {
debug::exit(debug::EXIT_FAILURE); // QEMU reports a failed run.
loop { core::hint::spin_loop(); }
}

fmt::Write supplies output. run_trace uses Result and ? for failed opens/writes; entry reports the outcome. Formatting needs no growing string.

The panic_handler reports failure too. Both exits serve QEMU; a physical device needs safe outputs and its own halt or restart policy.

The build reference contains the exact installation and run commands. Linking produces an ELF image with sections and addresses, at target/thumbv7m-none-eabi/debug/lamp-firmware. Debug metadata can enlarge that file without occupying flash. Its output is:

sample 1: pressed=false, lamp=false
sample 2: pressed=true, lamp=true
sample 3: pressed=true, lamp=true
sample 4: pressed=false, lamp=true
sample 5: pressed=true, lamp=false
sample 6: pressed=false, lamp=false

The held press stays on. QEMU exits successfully; the computer’s OS runs the tools while the loaded firmware has no guest OS.

A HAL (hardware abstraction layer) supplies operations such as reading pins while peripheral-access code reaches hardware registers. Choose the exact board’s documented example; the physical-board reference keeps the complete configuration, polarity, and programming order.

Replace fixed inputs with debounced observations and QEMU exits with a board-appropriate failure policy. Polling repeatedly checks input; an interrupt runs a handler for a configured event. Shared controller state needs synchronization; this example has one owner. A watchdog can reset a stalled device unless firmware services it, requiring an explicit recovery policy.

The library checks the logical rule; QEMU exercises the compiled ARM image and startup. Neither proves physical wiring or electrical behavior.

Breakpoints stop the processor while external devices can continue. Timers, messages, and watchdogs may behave differently; printing also slows loops. Semihosting output does not measure normal execution speed. Compare timing with logging disabled. The troubleshooting reference keeps the observation checklist and transport tradeoffs.

Use these references to reproduce the emulated run or adapt the rule to physical hardware. The complete working firmware remains above.

Optional build reference: project files, QEMU runner, and commands
Emulator (QEMU)

The complete firmware-labs project contains these files. Keep their names and directory arrangement, including the hidden .cargo directory:

  • Directoryfirmware-labs/
    • Cargo.toml
    • Cargo.lock
    • Directory.cargo/
      • config.toml
    • memory.x
    • build.rs
    • Directorysrc/
      • lib.rs
      • main.rs

src/lib.rs contains the controller and its tests. Cargo.toml declares the project and its runtime dependencies; Cargo.lock pins the full dependency set. build.rs is a small helper that makes the memory map available to the linker. It runs on your computer during the build, so its use of std does not require an OS on the microcontroller.

lib.rs

firmware-labs/src/ · Rust, 61 lines

Show the codeHide the code
#![no_std]

/// A lamp that toggles once per new button press, not once per held sample.
/// Inputs are logical pressed/released states, already debounced by the caller.
pub struct LampController {
    lamp_on: bool,
    was_pressed: bool,
}

impl LampController {
    /// The lamp and previous button state both start off at each new boot.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            lamp_on: false,
            was_pressed: false,
        }
    }

    /// Accept one sample and return the output the lamp should now use.
    pub fn sample(&mut self, pressed: bool) -> bool {
        // A rising edge is a change from released to pressed.
        if pressed && !self.was_pressed {
            self.lamp_on = !self.lamp_on;
        }
        self.was_pressed = pressed;
        self.lamp_on
    }
}

impl Default for LampController {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::LampController;

    #[test]
    fn holding_and_releasing_do_not_create_extra_toggles() {
        let mut lamp = LampController::new();
        let samples = [false, true, true, false, true, false];
        let expected = [false, true, true, true, false, false];

        for (pressed, wanted) in samples.into_iter().zip(expected) {
            assert_eq!(lamp.sample(pressed), wanted);
        }
    }

    #[test]
    fn a_new_controller_does_not_retain_the_last_boots_state() {
        let mut first_boot = LampController::new();
        assert!(first_boot.sample(true));

        let mut second_boot = LampController::new();
        assert!(!second_boot.sample(false));
        assert!(second_boot.sample(true));
    }
}

You can obtain the source files from those links and the configuration link below, or use the authored project folder. The project uses Rust 2024 and requires Rust 1.85 or later.

The project’s .cargo/config.toml selects the target, passes -Tlink.x to the linker, and tells Cargo how to run the image:

[build]
target = "thumbv7m-none-eabi"
[target.thumbv7m-none-eabi]
rustflags = ["-C", "link-arg=-Tlink.x"]
runner = [
"qemu-system-arm",
"-cpu", "cortex-m3",
"-machine", "lm3s6965evb",
"-nographic",
"-semihosting-config", "enable=on,target=native",
"-kernel",
]

Cargo appends the built executable’s path after -kernel. QEMU creates the selected machine and loads that image. -nographic uses terminal output; the semihosting option enables the reporting mechanism described next. This configuration follows the Embedded Rust QEMU setup.

Install stable Rust with rustup and an ARM system build of QEMU. The QEMU downloads page provides platform-specific installation options. On Windows, make sure the folder containing qemu-system-arm.exe is on PATH.

Open a terminal inside firmware-labs/, so Cargo reads this project’s configuration, then run:

Terminal window
# Install Rust's libraries for the Cortex-M3 target.
rustup target add thumbv7m-none-eabi
# Compile and link the image using the supplied dependency versions.
cargo build --locked --bin lamp-firmware
# Start the same image using the QEMU runner in .cargo/config.toml.
cargo run --locked --bin lamp-firmware

The Rust target is thumbv7m-none-eabi. It describes the Armv7-M architecture, bare-metal environment, and calling convention suitable for this Cortex-M3. It does not identify which pins the board wires to an LED or where a different chip keeps its flash. The Rust target documentation describes the CPU requirements.

The memory.x mapping above supplies this board model’s regions.

ORIGIN is a region’s starting address; LENGTH is its capacity. The linker places the program into these regions using the runtime’s link.x script. A matching CPU instruction set with the wrong memory map still produces the wrong firmware for a board.

The binary begins with #![no_std] and #![no_main]. no_std uses Rust’s core library without automatically linking the OS-oriented standard library. Arithmetic, structs, borrowing, arrays, slices, Option, and Result still work. File access, OS threads, and ordinary terminal printing do not appear automatically. Dynamic collections such as Vec require additional allocation support; our controller does not need them.

no_main disables the normal application entry arrangement. The #[entry] attribute from cortex-m-rt marks the function that startup calls. Its -> ! means it never returns: there is no desktop caller waiting for a return value.

Semihosting lets target code ask a supporting debugger or emulator to perform host-side operations. Here it sends text to QEMU’s terminal. That is neither an emulated UART nor Windows providing println! to a guest process. The cortex-m-semihosting interface provides the output stream used in the complete main file above.

The complete main file above supplies these reporting steps.

fmt::Write supplies the writing operation used by writeln!. Opening the stream or writing can fail, so run_trace returns a Result and ? propagates an error. The entry function converts that result into an emulator success or failure request. Formatting uses the fixed samples and controller state; it needs no growing string buffer.

A panic_handler defines what this no_std application does if it panics. This one makes the emulator run fail. Physical firmware needs its own failure policy, such as placing outputs into a safe state and halting or restarting. Both exit calls in this file are QEMU test instrumentation, not a physical-device shutdown procedure.

The image is an ELF executable at target/thumbv7m-none-eabi/debug/lamp-firmware. ELF carries sections, addresses, and other information a loader or debugger can use. The file’s total size is not the flash usage: debug metadata can occupy space in the file without being loaded into the microcontroller’s flash.

The worked trace above shows the expected result.

QEMU then exits successfully. The two consecutive true inputs leave the lamp on, which is the essential difference from toggling on every held sample. Some QEMU versions also print Timer with period zero, disabling; that model message is separate from the firmware trace.

The computer’s OS runs Cargo and QEMU. The emulated firmware has no guest OS. Its startup, control logic, and output requests all execute as part of the loaded image.

Optional build reference: adapt to an exact physical board

The control rule is reusable; the board configuration is specific. Start with the exact development board’s documented Rust board-support or HAL (hardware abstraction layer) example. A HAL presents operations such as reading a pin or configuring a timer while handling many register details. Underneath it, peripheral-access code ultimately reads and writes hardware registers, the mechanism introduced in device registers.

Adapt the project in this order:

  1. Identify the actual chip and board. Select their CPU target, memory map, pin assignments, and supported programming interface. Two Cortex-M boards can require different targets, flash addresses, clocks, and peripherals.
  2. Initialize the hardware. Configure clocks, an input pin with the right pull resistor, and a suitable output. A pull-up or pull-down resistor gives an unpressed input a defined electrical level instead of leaving it floating. Convert high/low into logical pressed/released meaning: active-low means a low electrical level represents a press. Its logical false → true press can therefore correspond to an electrical high-to-low transition.
  3. Replace the trace with real observations. Debounce the input, call sample, and apply its result to the output. A timer can set the sampling cadence. A real loop continues working rather than ending after six inputs.
  4. Replace emulator-only behaviour. Remove the QEMU runner and both semihosting exit calls. Choose board output and a panic policy that work without QEMU. Logging through a debugger is an optional development aid.
  5. Rebuild, program, verify, reset, and observe. Use the board’s supported bootloader or a compatible debug probe. A flashing tool erases/programs the required regions; verification checks the written image. Reset tests the startup path, and the real output tests pin configuration and behaviour.

probe-rs is one programming and debugging tool for supported chips and probes. A board can instead document a vendor tool, a USB bootloader, or OpenOCD. Use its exact target configuration; the QEMU build above is not an image for arbitrary boards.

Polling checks an input repeatedly; an interrupt runs a handler when a configured event occurs. Either design can work. If an interrupt and the main loop both touch controller state, they need an explicit synchronization policy. The simple controller above has one owner and introduces no such shared access. A watchdog is a timer that can reset a stalled device unless firmware services it correctly; its configuration and recovery policy matter on real hardware, rather than being a cure for an unexplained bug.

Optional build reference: hardware observations and logging transports

Firmware debugging has two places to inspect: the rule and its hardware integration. The library tests in the project check that holding a press does not toggle repeatedly and that a fresh controller starts with fresh state. They do not establish that a physical button is wired correctly. The QEMU run tests the compiled ARM image and its startup, but does not test a real board’s electrical behaviour.

When a real device behaves differently, record the chip, board revision, toolchain, image version, and input conditions. Compare the intended state transition with the observed one. Then check the boundary responsible for that input or output: pin polarity, debounce timing, timer rate, or register setup. Changing the control rule to compensate for a wiring mistake hides the cause.

Breakpoints stop the processor, while external devices can keep running; timers, incoming messages, and watchdogs may therefore behave differently. Printing can also slow a loop. Semihosting is convenient for this short trace but unsuitable as a measurement of normal execution speed. Other transports have different requirements:

Logging method How output leaves the device
UART (universal asynchronous receiver/transmitter) A serial peripheral sends bytes through configured pins to a compatible receiver or adapter
RTT (Real Time Transfer) The target writes a RAM buffer that a supporting debug probe reads
ITM/SWO tracing A supported Cortex-M Instrumentation Trace Macrocell emits trace events through Serial Wire Output to a compatible probe; CMSIS documents this debug path

RTT’s buffer policy matters too: a full blocking buffer can pause the program, while a nonblocking mode can discard output. Buffering or deferring text formatting reduces work on a time-sensitive path but does not make it free. Record only the events needed to distinguish the competing explanations, and compare behaviour with logging disabled when investigating a timing problem.

The image joins reset, memory, and control logic. Console architecture extends those startup responsibilities into a larger boot chain.

Knowledge check

Two parts of a working image

A firmware image uses the right Cortex-M CPU target but a memory.x file for a different board. Is matching the CPU enough to make the image correct?

Finished Lesson 13.5?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 13: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.