Everything you stood on is gone
For this whole course there has been a quiet floor beneath your feet. When you ran "$ ./a.out", an operating system received your request, the loader mapped your executable into memory, C runtime startup code set up the stack and zeroed your globals, and only then did your main() begin. Every malloc(), every open(), every system call crossed a wall into the kernel, which managed the hardware on your behalf. Bare-metal programming is what happens when you take all of that away. There is no OS. Your code is the only code. When the chip powers on, it runs your program directly, with nothing underneath.
This is not a thought experiment — it is how most of the computers on Earth actually work. The chip in a thermostat, a drone's motor controller, a pacemaker, a car's airbag sensor, a USB charger: these are microcontrollers, and the overwhelming majority run bare-metal or with a tiny scheduler, never a full OS. A typical microcontroller (MCU) might have 64 KiB of RAM and 256 KiB of flash — a thousand times less memory than a laptop. There is no room for Linux and no need for it. The MCU's whole job is to run one program, forever, the instant it powers up. You are about to learn to write that program.
One flat address space, and hardware lives in it
On a laptop you met virtual memory: every process saw its own private address space, an illusion the MMU maintained, so virtual addresses were not real physical ones. Throw that away too. A bare-metal MCU usually has no MMU. There is one flat, physical address space, and an address like 0x20000000 means exactly one real location in silicon — always, for every part of your program. This is the memory-as-byte-array picture from the very early rungs, made literal: the whole machine is one numbered array of bytes, and you reach any of them with a pointer.
Now the part that feels strange the first time. In this single address space, not every address is memory. The chip's designers wired the peripherals — the timer, the serial port, the pins you can toggle — to respond to specific addresses. Writing to address 0x40020014 might not store a value anywhere; it might set the voltage on a physical pin to high. This is the MCU memory map: a fixed layout where some address ranges are RAM, some are flash holding your code, and large stretches are not memory at all but control panels for hardware. The next guide makes this map precise; for now, hold the headline — on bare metal, a pointer can point at a wire.
Talking to the hardware: registers and volatile
Those hardware control panels are made of peripheral registers — fixed memory-mapped locations where each bit means something physical. To turn on an LED, you do not call a library; you find the address of the right register in the chip's datasheet, make a pointer to it, and write a bit. The whole conversation with the hardware is reading and writing numbers at known addresses, using exactly the bitwise operators and bit masks you learned long ago. A line like "GPIOA->ODR |= (1 << 5);" really means "set bit 5 in the output register of port A," which really means "drive pin A5 high." There is a deep satisfaction the first time abstract C makes a real light come on.
But here lurks a trap that bites almost every beginner, and it is worth slowing down for. Suppose you write a loop that reads a status register over and over, waiting for a hardware flag to flip from 0 to 1. Your compiler, applying the as-if rule, reasons: "this address is never written inside the loop, so its value can't change — I'll read it once and reuse that." The compiler is correct by the rules of ordinary C, and your loop spins forever, because the compiler does not know an outside wire changes that location. The fix is the volatile qualifier: declaring the pointer as a pointer to volatile data tells the compiler "this can change behind your back — re-read it from the actual address every single time, and never cache, reorder, or eliminate the access."
/* WRONG: compiler may read READY once and spin forever */
unsigned int *status = (unsigned int *)0x40004404;
while ((*status & 0x1) == 0) { /* wait for READY bit */ }
/* RIGHT: volatile forces a real read of the address each pass */
volatile unsigned int *status = (volatile unsigned int *)0x40004404;
while ((*status & 0x1) == 0) { /* now actually polls the hardware */ }Who runs main()? The story of the first instruction
On a hosted system you never wondered how your program starts — the OS handled it. Bare-metal forces the question into the open: when the chip powers on, what is the very first instruction the CPU executes? On a small ARM Cortex-M MCU, the hardware does something beautifully simple. At reset it reads two words from a fixed spot at the bottom of flash: the first word is the initial value to load into the stack pointer, and the second word is the reset vector — the address of the first function to run. The CPU loads the stack pointer, jumps to that address, and your code is now in charge of the entire machine. No loader, no OS, no ceremony.
But the reset vector does not jump straight to main() — it cannot, because main() expects a world that does not exist yet. The C language assumes global variables are initialized and uninitialized ones are zero. Who does that? On a hosted system the runtime did; here you must. So the reset vector points at startup code (historically called crt0), which copies the initialized globals from flash into RAM, zeroes the .bss section of uninitialized globals, sets up the heap if you have one, and only then calls main(). The reset vector and the startup code together are what stands in for the entire OS-and-loader machinery you used to take for granted — and they are the subject of the next two guides.
When the program has no end: super-loops, interrupts, and the road ahead
One more habit of mind has to change. A hosted program runs, does its job, and returns from main() — it ends, and the OS reclaims its resources. A bare-metal program must never end. There is nothing to return to; if main() returned, the CPU would fall off the edge into undefined territory. So an embedded program's main() almost always finishes with an infinite loop, the super-loop: read the sensors, decide, drive the outputs, repeat, forever. This is the simplest possible scheduler — one task, run end to end, again and again — and for a huge number of devices it is genuinely all you need.
But a pure super-loop has a weakness: it can only check things when it gets around to them. If a button is pressed while the loop is busy elsewhere, it might be missed. The answer is interrupts: the hardware itself can pause your loop the instant something happens, jump to a small handler, deal with it, and resume exactly where you were. The CPU finds the right handler through an interrupt vector table — a list of handler addresses, one per event source — and each handler is an interrupt service routine (ISR). This is the same interrupt idea from the OS rungs, but now there is no kernel between you and the hardware: you write the ISRs, and they run with your full attention or none.
That is the whole rung in one breath. We start from this bare-metal mindset; the next guide draws the precise memory map and the reset/startup/linker-script trio that bring a chip to life; then the vector table, ISRs, and interrupt priority that let it react; then memory-mapped registers and volatile in full rigor; and finally, when one super-loop is no longer enough, a real-time operating system with tasks, real-time scheduling, and the hard guarantees of determinism. Every later guide assumes the picture you now hold: a chip, a flat address space with hardware living in it, your code as the only code, starting from the first instruction and running forever. Welcome to the metal.