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.
Cheatsheet for Lesson 13.5
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 → trueis a rising edge. thumbv7m-none-eabi: The Rust target isthumbv7m-none-eabi.LENGTH:ORIGINis a region's starting address;LENGTHis its capacity.panic_handler: Apanic_handlerdefines what thisno_stdapplication 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
falsefirst becomestrue
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 a role, not a file extension
Section titled “Firmware is a role, not a file extension”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.
One chip, several kinds of memory
Section titled “One chip, several kinds of memory”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.
Reset comes before the Rust entry point
Section titled “Reset comes before the Rust entry point”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.
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.
1 Startup copies .data from flash
Flash, 256 KiB0x000000000x0003FFFFcode and vectors.data initial value: 7copy from flash
SRAM, 64 KiB0x200000000x2000FFFF.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 Startup zero-fills .bss: now Rust may use both
fill with zero
SRAM, 64 KiB0x200000000x2000FFFF.data counter 7.bss counter 0The .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 lampResult: 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
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
data = 7; samples = two.
Step 2, line 2. The startup routine copies initialized data, then clears zero-initialized storage.
data = 7; samples = two; ram_data = 7; bss = 0.
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.
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.
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.
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.
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.
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.
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.
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.
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
- 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.
- 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.
- Only after initialization does the runtime call the Rust entry function. The lamp controller begins with lamp_on false and was_pressed false.
- 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.
- 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.
- 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.
- 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 bootloader is an additional stage
Section titled “A bootloader is an additional stage”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 lampremember the current pressed statereport the lamp stateHere 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.
Build a complete firmware project
Section titled “Build a complete firmware project”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 keeps Rust without assuming an OS
Section titled “no_std keeps Rust without assuming an OS”#![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.
Report results through the emulator
Section titled “Report results through the emulator”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
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.
Build and run the image
Section titled “Build and run the image”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=falsesample 2: pressed=true, lamp=truesample 3: pressed=true, lamp=truesample 4: pressed=false, lamp=truesample 5: pressed=true, lamp=falsesample 6: pressed=false, lamp=falseThe held press stays on. QEMU exits successfully; the computer’s OS runs the tools while the loaded firmware has no guest OS.
Take the same rule to a physical board
Section titled “Take the same rule to a physical board”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.
Debugging changes timing too
Section titled “Debugging changes timing too”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.
Optional build references
Section titled “Optional build references”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
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
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:
# 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-firmwareTarget, runtime, and reporting details
Section titled “Target, runtime, and reporting details”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 linked image and model output
Section titled “The linked image and model output”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:
- 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.
- 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 → truepress can therefore correspond to an electrical high-to-low transition. - 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. - 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.
- 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.
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?
The CPU target and board layout answer different questions. A board can have the same instruction set but different flash/RAM addresses, capacities, pin assignments and peripherals. Firmware must match those details, and its runtime/startup arrangement must match how that board boots.
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.
Plain Markdown, saved in this browser.
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.