Start Here25 min read
How a Program Actually Runs on a Microcontroller
When you press reset on the STM32F4 Discovery, a few hundred nanoseconds later the CPU is executing your code. No operating system loaded it, nothing allocated its memory, and nothing will clean up after it. This lesson walks through what happens in between and afterwards: how C becomes bits in flash, how the CPU fetches and executes them, why memory and peripherals live in one address space, what reset does, why main never returns, what clocks and interrupts are, and what the debugger does to the chip. Every later lesson zooms into one part of the picture you build here.
From source to bits in flash
Section titled “From source to bits in flash”Your C source goes through a pipeline of tools before it reaches the chip. Each stage has a lesson of its own later; here is the overview so you know the names.
main.c --preprocess--> main.i (macros expanded, headers pasted in) --compile-----> main.s (Arm assembly text) --assemble----> main.o (machine code + symbols, addresses not final)
main.o + startup.o + libc.a --link (with link.ld)--> firmware.elf (final addresses, debug info) --objcopy-------------> firmware.bin (raw bytes for flash) --OpenOCD + probe-----> flash memory at 0x08000000The step that has no equivalent on a PC is the link with a linker script. The linker script tells the linker that this chip has flash at 0x08000000 and RAM at 0x20000000, and that code and constants go to flash while variables go to RAM. The result, firmware.elf, contains your machine code at its final addresses plus debug information that maps those addresses back to source lines.
The bytes that actually end up in flash are mostly instructions and constant data. The first bytes are special: a table of addresses called the vector table, which the CPU reads at reset. You will see it again below.
You will take this pipeline apart in Hello, C: the Compilation Pipeline, and build every piece of it for the STM32F407 in Blinky From Nothing.
A tiny program to follow
Section titled “A tiny program to follow”Keep this program in mind for the rest of the lesson. It blinks the green LED on PD12 using nothing but register writes:
#include <stdint.h>
#define RCC_AHB1ENR (*(volatile uint32_t *)0x40023830u)#define GPIOD_MODER (*(volatile uint32_t *)0x40020C00u)#define GPIOD_BSRR (*(volatile uint32_t *)0x40020C18u)
int main(void){ RCC_AHB1ENR |= (1u << 3); /* clock GPIOD */ GPIOD_MODER = (GPIOD_MODER & ~(3u << 24)) | (1u << 24); /* PD12 output */
for (;;) { GPIOD_BSRR = (1u << 12); /* PD12 high: LED on */ for (volatile uint32_t i = 0; i < 500000u; i++) { } GPIOD_BSRR = (1u << 28); /* PD12 low: LED off */ for (volatile uint32_t i = 0; i < 500000u; i++) { } }}Do not worry about every detail yet. Notice three things. First, the “variables” RCC_AHB1ENR and friends are not variables at all; they are fixed addresses cast to pointers and dereferenced. Writing to them changes hardware. Second, volatile tells the compiler that every access really must happen, because the hardware cares. Third, main contains a loop that never ends. All three are explained below.
The CPU: fetch, decode, execute
Section titled “The CPU: fetch, decode, execute”A CPU core does one thing, billions of times: it fetches the instruction at the address in the program counter, decodes it to work out what to do, executes it, and moves on. The Cortex-M4 has a three-stage pipeline, so while one instruction executes, the next is being decoded and the one after that is being fetched. Most simple instructions complete in one clock cycle.
An instruction is a 16-bit or 32-bit number. The Cortex-M executes only the Thumb instruction set (the Thumb-2 version, which mixes 16-bit and 32-bit encodings). For example, the 16-bit value 0x4770 decodes as bx lr: “branch to the address in the link register”, which is how a function returns.
Here is what the compiler produces for the LED-on line of the program above (abridged from -O2 output):
ldr r3, .L_bsrr @ r3 = 0x40020C18, loaded from a constant stored in flash mov r2, #4096 @ r2 = 1 << 12 str r2, [r3] @ store r2 to the address in r3: GPIOD_BSRR = 1 << 12Three instructions. The CPU cannot store a value directly to a 32-bit address written inside the instruction (the instruction is not big enough), so it first loads the address into a register, puts the value into another register, and then stores one to the other. That pattern, load an address, compute a value, store, is what most firmware boils down to.
Registers, the program counter, and the stack pointer
Section titled “Registers, the program counter, and the stack pointer”Registers are a handful of 32-bit storage slots inside the CPU core itself. They are the only place the CPU does arithmetic. To add two numbers in memory, it loads both into registers, adds, and stores the result back.
The Cortex-M4 has sixteen core registers plus a status register:
| Register | Alias | Role |
|---|---|---|
r0 to r3 | Function arguments and return values; scratch | |
r4 to r11 | Local variables that must survive function calls | |
r12 | ip | Scratch, used by the linker for long calls |
r13 | sp | Stack pointer: address of the top of the stack |
r14 | lr | Link register: return address of the current function |
r15 | pc | Program counter: address of the instruction being executed |
xPSR | Flags (zero, negative, carry, overflow), current exception number |
The program counter is what makes the CPU a sequential machine. After each instruction it advances to the next one. A branch instruction simply writes a new value into pc. A function call (bl) writes the return address into lr and the function’s address into pc.
The stack is a region of RAM used for local variables, saved registers, and return addresses that do not fit in registers. On Cortex-M it grows downwards: pushing data subtracts from sp, popping adds. The stack pointer must point at valid RAM before any C function can run, which is why it is the very first thing the CPU sets up at reset. A stack that grows too large silently runs into other data; there is no guard page. You will see the consequences in Memory: Where Everything Lives.
The full register set, including special registers like PRIMASK and CONTROL, is covered in The Cortex-M Architecture.
One address space for everything
Section titled “One address space for everything”On a microcontroller, flash, RAM, and every peripheral register share a single 32-bit address space. There is no separate “I/O space” and no special instructions for hardware. A store to 0x20000000 writes RAM; a store to 0x40020C18 toggles a pin. The CPU issues the same str instruction in both cases. Only the address differs.
Here is the STM32F407’s address space, simplified:
0xFFFFFFFF +--------------------------------+ | Cortex-M private peripherals | NVIC, SysTick, SCB, debug 0xE0000000 +--------------------------------+ (same on every Cortex-M) | (external memory, unused | | on the Discovery) | 0x60000000 +--------------------------------+ | Peripherals | GPIO, UART, timers, RCC ... | AHB1 0x40020000 | GPIOD at 0x40020C00, RCC at 0x40023800 | APB2 0x40010000 | SPI1, USART1 | APB1 0x40000000 | USART2 at 0x40004400 0x40000000 +--------------------------------+ | SRAM 128 KB 0x20000000 | variables, stack, heap 0x20000000 +--------------------------------+ | CCM RAM 64 KB 0x10000000 | fast RAM, CPU only 0x10000000 +--------------------------------+ | Flash 1 MB 0x08000000 | your code and constants 0x08000000 +--------------------------------+ | Boot alias 0x00000000 | flash mirrored here at boot 0x00000000 +--------------------------------+This is why C pointers are so central to firmware. *(volatile uint32_t *)0x40020C18u = 1u << 12; is an ordinary pointer store, and it is also how you turn on an LED. It is also why a wild pointer is dangerous: it can just as easily land in a peripheral register as in a variable.
Memory and peripherals behave very differently once you look closer, though:
| Flash | SRAM | Peripheral register | |
|---|---|---|---|
| Read | Returns what was programmed | Returns what was last written | May return live hardware state that changes on its own |
| Write | Ignored or faults (needs a special erase/program sequence) | Stores the value | Triggers hardware behaviour; some bits are read-only, some clear when you write 1 |
| Reading has side effects? | No | No | Sometimes (reading a UART data register removes the byte) |
| Survives reset? | Yes | Contents undefined after power-up | Reset to documented default values |
That last column is why volatile exists. The compiler assumes that memory behaves like SRAM: if you write a value and read it back, nothing changed in between, and writing the same value twice is pointless. Peripheral registers break all of those assumptions. Without volatile, the optimiser is entitled to delete or merge accesses that look redundant, and the hardware never sees them. You will learn exactly what volatile guarantees in Undefined Behavior, volatile, and What the Optimizer Assumes.
Buses: how the CPU reaches all of it
Section titled “Buses: how the CPU reaches all of it”The CPU core is connected to memory and peripherals by buses: sets of wires carrying an address, data, and control signals. When the core executes str r2, [r3], it places the address 0x40020C18 and the data 0x1000 on a bus. The bus matrix inside the chip looks at the address and routes the transaction to the right destination.
Cortex-M4 core I-bus D-bus S-bus | | | +---+-------+-------+-------------------------+ | AHB bus matrix |----- DMA controllers +----+----------+-----------+----------+------+ | | | | Flash SRAM AHB1 AHB/APB bridges (via ART) GPIOA..I | | RCC, DMA APB2 APB1 84 MHz max 42 MHz max SPI1, ADC USART2, I2C1, USART1 TIM2..TIM7The core has separate buses for fetching instructions (I-bus), reading constant data from flash (D-bus), and everything else (S-bus), so it can fetch the next instruction while storing to a peripheral. The DMA controllers are also masters on the matrix: they move data between peripherals and memory without the CPU.
Peripherals hang off slower buses. AHB1 runs at the full system clock, APB2 at up to 84 MHz, and APB1 at up to 42 MHz on the STM32F407. A peripheral on APB1 takes several CPU cycles to respond to a register access. More importantly, every peripheral’s clock is switched off at reset to save power. Writes to a peripheral whose clock is off are simply ignored, which is why the first line of the example program enables the GPIOD clock in the RCC_AHB1ENR register. Forgetting that line is the single most common “nothing happens” bug in firmware.
What reset does
Section titled “What reset does”Reset is the moment the chip starts from a known state, after power-up, after the reset pin is pulled low, or after software asks for it. Here is what the Cortex-M4 does, entirely in hardware, before any of your code runs:
- It reads the 32-bit word at address
0x00000000and loads it into the stack pointer. - It reads the word at
0x00000004and loads it into the program counter. This is the address of your reset handler. - It starts executing at that address.
On the STM32F407, flash is mirrored at address 0x00000000 during boot, so those two words are the first eight bytes of your firmware at 0x08000000. They are the start of the vector table:
Address Contents Meaning 0x08000000 0x20020000 initial SP = top of 128 KB SRAM 0x08000004 0x080001C5 Reset_Handler at 0x080001C4, bit 0 set 0x08000008 address of NMI_Handler 0x0800000C address of HardFault_Handler ... more handler addresses one per exception and interruptThe stack pointer value 0x20020000 is one past the end of SRAM (0x20000000 + 128 KB). Since the stack grows downwards, the first push will write just below it. The reset handler address has its lowest bit set to 1. That bit is not part of the address; it tells the CPU the code is Thumb code. Cortex-M only runs Thumb, so the bit must always be 1, and the toolchain sets it for you. (The 0x080001C4 here is just an illustration; the real address depends on your build.)
The reset handler is part of the startup code, which runs before main and does the jobs an operating system would do on a PC:
Reset_Handler: 1. copy initial values of initialised globals from flash to RAM (.data) 2. fill uninitialised globals with zero (.bss) 3. enable the FPU, optionally set up clocks 4. call main() 5. if main ever returns: loop foreverSteps 1 and 2 exist because of how C defines global variables. Consider:
#include <stdint.h>
uint32_t blink_count = 5u; /* initialised: lives in .data */uint32_t error_count; /* not initialised: lives in .bss, must start at 0 */blink_count must hold 5 when main starts, but RAM contents are random at power-up. So the linker places the value 5 in flash, reserves space for the variable in RAM, and the startup code copies one to the other. error_count gets no value in flash at all; the startup code just writes zeros over the whole .bss region. If the startup code skips either step, your globals start with garbage, and the bugs are bizarre.
After reset, the STM32F407 runs from its internal 16 MHz HSI oscillator, with almost every peripheral clock turned off and every GPIO pin configured as an input (a few debug pins excepted). Everything else is your job. You will write the startup code yourself in Blinky From Nothing and study it in depth in Startup Code and the Vector Table.
The main loop never returns
Section titled “The main loop never returns”On a PC, main returns and the operating system cleans up. On a microcontroller there is nothing to return to. If main returned, execution would go back into the startup code, which can only loop forever or reset. So firmware is structured as an initialisation phase followed by an infinite loop:
void hardware_init(void);void poll_inputs(void);void update_outputs(void);
int main(void){ hardware_init();
for (;;) { /* the superloop: runs until power is removed */ poll_inputs(); update_outputs(); }}This pattern is called the superloop. Even firmware running an RTOS has the same shape: main initialises hardware, creates tasks, and starts the scheduler, which never returns. If you see a firmware main that ends with return 0;, that line is dead code, or a bug.
Interrupts in one paragraph
Section titled “Interrupts in one paragraph”Polling every input in a loop wastes time and can miss short events. An interrupt lets hardware get the CPU’s attention: when a peripheral signals an event (a byte arrived, a timer expired, a button was pressed), the interrupt controller (the NVIC) stops the code that is running between two instructions, the core automatically saves eight registers on the stack, looks up the handler’s address in the vector table, and jumps to it. When the handler returns, the hardware restores the registers and the interrupted code continues as if nothing happened. On the Cortex-M4 this entry takes about 12 clock cycles. Interrupts are what make firmware responsive, and they are also what make it concurrent: your main loop can be interrupted between any two instructions, even in a program with no threads. Interrupts Done Right covers them in full.
Clocks, and why “168 MHz” matters
Section titled “Clocks, and why “168 MHz” matters”Every flip-flop in the chip changes state on the edge of a clock signal. The CPU’s clock frequency sets how many cycles it executes per second, and therefore how long everything takes:
| System clock | One cycle | 1,000,000 cycles |
|---|---|---|
| 16 MHz (HSI, default after reset) | 62.5 ns | 62.5 ms |
| 168 MHz (maximum for STM32F407) | about 5.95 ns | about 5.95 ms |
The “168 MHz” in the chip’s description is a maximum, not a default. After reset the chip runs at 16 MHz from the internal HSI oscillator. Reaching 168 MHz takes explicit configuration: start the external crystal, configure the PLL (a circuit that multiplies a clock), raise the flash wait states because flash cannot be read in a single 6 ns cycle, and switch the system clock over. That is covered in Clocks, Reset, and Timing.
The clock frequency matters for much more than speed:
- Timing. The busy-wait loop in the example blinks at one rate at 16 MHz and over ten times faster at 168 MHz. Any delay counted in loop iterations is wrong the moment someone changes the clock.
- Peripherals. A UART computes its baud rate by dividing its bus clock. If your code assumes APB1 runs at 42 MHz but it really runs at 16 MHz, every byte is garbled.
- Power. Dynamic power rises roughly in proportion to frequency. Running at 168 MHz when 16 MHz would do burns battery for nothing.
- Flash wait states. At 168 MHz the CPU is faster than flash, so instruction fetches stall unless a cache-like accelerator (ST calls it the ART Accelerator) hides the latency. Cycle counts on paper and measured time can differ.
What a debugger does to the chip
Section titled “What a debugger does to the chip”A debugger lets you pause the CPU, inspect registers and memory, and step through code. On the Discovery board the chain looks like this:
VS Code / gdb --TCP port 3333--> OpenOCD --USB--> ST-Link probe | SWD: 2 wires (SWDIO, SWCLK) | STM32F407 debug access port | | halt/step the core read/write any breakpoints (6) address on the bus watchpoints (4)The Cortex-M core has debug logic built into the silicon. Through the SWD (Serial Wire Debug) port, the probe can:
- Halt the core: the pipeline stops, and registers can be read and written.
- Read and write memory, including peripheral registers, often while the core keeps running.
- Set hardware breakpoints: the M4 has six comparators that halt the core when the program counter matches an address. Since code is in flash, which cannot be patched freely, these comparators are how breakpoints work. Run out of them and gdb will complain.
- Set watchpoints: four comparators that halt when a given address is read or written. They are the tool for finding “who is corrupting this variable”.
- Flash a new program, by running a small loader in RAM or driving the flash controller directly.
The debugger is not invisible. Halting the core does not halt the peripherals: timers keep counting and a UART keeps receiving, so data can be lost while you sit at a breakpoint. Reading a peripheral register through the debugger can have the same side effects as reading it from code, such as clearing a status flag. These effects are covered in Debugging and Tracing on Real Hardware. For now, remember that “it works when I step through it” is a clue, not a mystery.
The mental model to carry through the course
Section titled “The mental model to carry through the course”Put the pieces together. This is the picture of a running microcontroller you will refine in every lesson that follows:
clock tree (HSI/HSE -> PLL -> SYSCLK, AHB, APB1, APB2) | ticks everything v +----------------- Cortex-M4 core ---------------------------------------+ | pc -> fetch -> decode -> execute r0..r12, sp, lr, pc, xPSR | | NVIC (interrupt controller) | | reset: sp = [0x0], pc = [0x4] SysTick, SCB, debug logic <---- SWD probe +----------+-------------------------------------------------------------+ | load/store, instruction fetch (one 32-bit address space) v +------------------------------ bus matrix ------------------------------+ | | | | Flash SRAM Peripherals DMA 0x08000000 0x20000000 0x40000000+ (moves data vector table .data .bss each needs its clock without the code, consts heap, stack enabled in RCC first CPU) (grows down) registers = hardware | | events raise interrupt requests v NVIC -> vector table -> your handlerRead it as a story. The clock ticks. The core fetches instructions from flash, starting at the address in the vector table. It computes in registers, keeps local state on the stack in SRAM, and changes the outside world by storing to peripheral registers through the bus matrix. Peripherals work in parallel and raise interrupts when something happens; the NVIC diverts the core to a handler through the same vector table. The debug probe can freeze all of this and look inside. Everything in this course, from pointer arithmetic to RTOS context switches to bootloaders, is a detail of one of these boxes or arrows.
Common mistakes
Section titled “Common mistakes”- Forgetting to enable a peripheral’s clock in RCC: the peripheral ignores every write, so enable the clock first and read the register back to confirm.
- Accessing hardware registers through non-
volatilepointers: the optimiser removes or merges the accesses, so always usevolatilefor memory-mapped registers. - Letting
mainreturn: execution falls into the startup code’s trap loop; structure firmware as init plus an infinite loop. - Timing with empty loops: the delay changes with clock speed and optimisation level; use a hardware timer such as SysTick.
- Assuming the chip runs at its maximum clock after reset: the STM32F407 starts at 16 MHz from HSI, so configure the clock explicitly and compute peripheral settings from the real frequency.
- Expecting globals to be initialised without startup code: if
.datais not copied and.bssnot zeroed, globals hold garbage; never skip those steps. - Assuming halting at a breakpoint freezes the whole chip: peripherals keep running, so expect overruns and missed events while stopped.
Exercises
Section titled “Exercises”- Using the table in the clocks section, compute how long 500,000 loop iterations take if each iteration costs 8 cycles, at 16 MHz and at 168 MHz. You should get 250 ms and about 23.8 ms. Explain in one sentence why the LED blink in the example program would change speed if someone added PLL configuration.
- Take the example
main.cand paste it into Compiler Explorer with an ARM GCC “none” compiler and-O2 -mcpu=cortex-m4 -mthumb. Find thestrinstruction that writesGPIOD_BSRRand the register that holds the address. Then removevolatilefrom theGPIOD_BSRRmacro only and describe what changes. (The two writes inside the loop may be merged or moved; the delay loops still exist becauseiis stillvolatile.) - Write out, by hand, the first two words of a vector table for a firmware whose stack should start at the top of SRAM and whose
Reset_Handleris at0x08000188. Answer:0x20020000and0x08000189. Explain why the second word is odd. - For each declaration, say whether it lives in flash,
.datain RAM,.bssin RAM, or on the stack:const uint32_t table[4] = {1, 2, 3, 4};at file scope,uint32_t hits;at file scope,uint32_t mode = 2;at file scope, anduint32_t tmp;inside a function. (Answers: flash,.bss,.data, stack or a register.) You will verify these with a map file in Memory: Where Everything Lives. - Draw the mental model diagram from memory on paper. Then trace, arrow by arrow, what happens when a button press on PA0 triggers an interrupt that turns on PD13. Your trace should touch: GPIO, the interrupt request, the NVIC, the vector table, the handler’s instructions, the bus matrix, and the GPIOD register.
Check yourself
Section titled “Check yourself”Question 1:At reset, the Cortex-M4 reads the word at address 0x00000004. What is it, and what does the CPU do with it?
Word 0 of the vector table is the initial stack pointer and word 1 is the reset handler address. The CPU loads it into pc and starts executing the startup code, which later calls main.
Question 2:A global uint32_t speed = 100u; reads as a random value at the start of main. What is the most likely cause?
Initialised globals live in RAM but their initial values are stored in flash. Startup code must copy them; RAM contents are undefined after power-up.
Question 3:Code writes 0x1000 to GPIOD's BSRR, the instruction executes, but the LED does not light. The pin is configured correctly in your code. What should you check first?
Peripheral clocks are off after reset and a peripheral with no clock ignores writes, including the earlier writes that were supposed to configure the pin. Enabling the clock is always the first step.
Question 4:Why must hardware register pointers be volatile?
The optimiser assumes memory behaves like RAM, where repeated writes or reads can be collapsed. Peripheral registers have side effects, so volatile forces every access to be emitted.
Question 5:You stop at a breakpoint for ten seconds while a UART is receiving data at 115200 baud. When you continue, bytes are missing. Why?
The debugger freezes only the core. Peripherals keep running unless specifically configured to freeze, so a halted CPU cannot service them.
Key takeaways
Section titled “Key takeaways”- The CPU repeats fetch, decode, execute on Thumb instructions at the address in
pc; most firmware is load an address, compute, store. - Flash, RAM, and peripheral registers share one 32-bit address space; a pointer store can change hardware, which is why
volatileand careful pointers matter. - At reset the core loads
spfrom address 0 andpcfrom address 4; startup code then copies.data, zeroes.bss, and callsmain. mainnever returns in firmware; it initialises hardware and then runs a superloop or starts a scheduler.- Every peripheral needs its clock enabled before it responds, and all timing depends on the actual clock frequency, which is 16 MHz after reset on the STM32F407.
- A debugger halts the core through SWD with a limited number of hardware breakpoints and watchpoints, but peripherals keep running while it is halted.
Next: Hello, C: the Compilation Pipeline begins Part 1, where you take the source-to-binary pipeline apart stage by stage.