FE/26
MENU

2026-07-25 · 15 min read

Two Kilobytes and No Operating System

RustEmbeddedSystemsSafety

Rust on Bare Metal, part one. The series builds up a set of labs on an Arduino Uno R3 — blink, a millis() counter, interrupts, serial — and uses each one to make a safety property concrete rather than asserted. This first part builds no lab at all. It establishes the machine, because every claim the later parts make about safety is a claim about this machine, and the arguments do not survive being separated from it.

I have spent most of my career on systems with an operating system underneath — which means most of my safety intuitions are really intuitions about what the OS was quietly doing on my behalf. A process that overruns its stack gets a segfault. A pointer into freed memory usually faults before it corrupts something interesting. Memory I allocate is memory somebody else does not have.

None of that is true on an ATmega328P.

That is the entire reason this series exists. The chip is small enough, and bare enough, that the mechanisms are visible instead of inferred. When something goes wrong there is nothing between you and the wreckage — no scheduler, no virtual memory, no fault handler, no log. You get the wrong answer, silently, and the only way to have prevented it was to have known.

What an Arduino Uno actually is

Your laptop and an Arduino Uno are both computers, and they differ by roughly six orders of magnitude. Every design decision in this series follows from that gap.

Laptop (typical)Arduino Uno R3
Working memory (RAM)16 GB2 KB — about 8 million times less
Program storage512 GB SSD32 KB flash
Clock speed~3 GHz, multiple cores16 MHz, one core
Data width64 bits at a time8 bits at a time
Operating systemmacOS / Linux / Windowsnone

The last row is the one that matters. There is no operating system on the Uno. Nothing manages memory, nothing schedules tasks, nothing catches your mistakes, and there is no screen, keyboard, or filesystem. When you power the board it begins executing your code directly, and your code is the only thing running. Forever — there is nothing to exit to.

The chip doing this is an ATmega328P, made by Microchip. "AVR" is the name of its instruction set, in the same way "x86-64" names your laptop's — a different and much simpler vocabulary of machine operations, which is why you cannot simply run a normal program on it. The board around it is the chip plus supporting parts: a 16 MHz crystal for timing, a voltage regulator, pin headers, an LED wired to pin 13, and a second small chip whose only job is translating USB into the serial protocol the main chip speaks.

Why two kilobytes is the recurring villain

2048 bytes holds about 2000 characters of text — this section, roughly. Everything your program needs at runtime lives there: variables, the call stack, and any text you print. There is no warning when you run out. The program simply begins corrupting its own data and behaves bizarrely.

SRAM · 2048 BYTES · TO SCALE .data .bss free · and nothing guarding it stack "..." grows this way, into your variables 200 chars = 10% no MMU · no guard page · no fault · the collision is silent
One error message costs a tenth of the machine. The stack grows downward into whatever is below it, and nothing in hardware notices the moment they meet.

That bar is the shape of most of the unusual choices in this series. Keeping RAM usage small is ordinary embedded discipline; keeping it visible is the part that turns out to matter, because the failure mode is not a crash. It is a program that worked until you added one more function call.

Compiling is not one step — it is four

People say "compiling" for the whole process, but four distinct programs run, and knowing which is which cuts debugging time enormously, because each produces a different style of error.

First, cross-compiling — the reason this is not the usual arrangement. A compiler turns source you can read into machine code a specific chip can execute, and normally it produces code for the machine it is running on. Here you compile on your Mac to produce AVR machine code. The Mac cannot run the result at all. That single fact explains most of the friction: you cannot just run your program to see whether it works, and you cannot use ordinary debugging tools. The compiler's name for "what kind of machine am I producing code for" is a target, and ours is avr-none — AVR architecture, no operating system.

ONE COMMAND, FOUR PROGRAMS rustc avr-gcc crt1.o avrdude object files,with holes fills the holes,assigns addresses runs before main,zeroes .bss copies it over USBinto flash LLVM half GNU half · installed by brew install avr-gcc ICEs and wrong codegen come from the left · "undefined reference" and "relocation truncated" from the middle
The toolchain is a hybrid, and that is not a detail. Every error message you will ever see here comes from one half or the other, and they are distinguishable on sight.

The four, in order. The compiler (rustc) translates each source file into machine code, producing object files — machine code with holes in it, because a file that calls blink() does not yet know what address blink() will live at. The linker (here avr-gcc, acting as one) takes the object files, fills the holes, and decides the final address of every function and variable; its errors sound like "undefined reference" or "relocation truncated" and are always about connecting things, never about your syntax. Startup code gets linked in alongside — your main is not the first thing that runs. And the flasher (avrdude, driven by ravedude) copies the finished program over USB into the chip's flash, then lets it run.

The collective name for that set of programs is a toolchain. When the setup step says brew install avr-gcc, it is installing an entire AVR toolchain: a linker, the standard startup code, and a library of helper routines. Rust cannot do without it, and the reason is worth stating plainly rather than treating as a packaging accident — rustc is a compiler. It is not a linker, and it does not ship a C runtime. On a hosted target that is invisible because the system C compiler is already installed and rustc quietly uses it. Here nothing is pre-installed, and one of the missing pieces is the program's beginning.

Flash, RAM, and registers

Three kinds of memory, easy to conflate, and the distinction matters constantly.

That second meaning is how you control anything. There is no turnOnLED() instruction. Specific memory addresses are wired to physical behaviour: writing the value 32 to address 0x25 — named PORTB — sets pin 13 high and lights the LED. Reading address 0x23 tells you the voltage on some pins. Controlling hardware is writing numbers to particular addresses. That is the whole game, and every abstraction layer in the next section exists to stop you playing it by hand with magic numbers.

The Harvard split

On your laptop, code and data share one address space; a memory address is a memory address, and code is just bytes you could in principle read as data. On AVR they are separately numbered and reached by different machine instructions.

TWO ADDRESS SPACES, ONE NUMBERING FLASH 32 KB · word-addressed · your program LPM SRAM 2 KB · byte-addressed · everything else LD / ST 0x100 in both · unrelated places · no pointer type can span them consequence: Rust has no stable syntax for "this constant lives in flash", so every static — including every string literal — is copied into the 2 KB bar above.
LLVM models the distinction as address spaces. Rust has no stable way to request the flash one, which is why "my string literals ate my RAM" is the classic failure of this stack.

This is the most consequential fact about the chip, and it has an awkward consequence specifically for Rust. Text like "hello" in your source could perfectly well live in the 32 KB of flash. It ends up copied into your precious 2 KB of RAM instead, because Rust has no standard way to say "this constant stays in flash". C solved this decades ago with a non-standard GCC extension — which is exactly what Arduino's F("...") macro wraps — and Rust's stricter type system makes the equivalent harder to bolt on. It is a real, current disadvantage of this stack, and I would rather write that down here than discover it in part four.

The first milliseconds after reset

Worth knowing before writing anything, because several later parts read the evidence of it out of the compiled file.

POWER-ON TO STEADY STATE reset vector table __init main PC = 0 entry 0 is reset,so jump to startup set stack pointer,copy .data, zero .bss -> ! · never returns loop {} the vector table costs 104 bytes of flash whether or not you use interrupts
Your main is the fourth thing to run, not the first. The three before it are supplied by avr-libc, which is why removing avr-gcc does not merely break linking — it removes the program's beginning.

At flash address 0 sits the interrupt vector table — a list of jump addresses, one per kind of hardware event. Entry 0 is "reset", so the CPU's first act is a jump into the startup code, which sets the stack pointer to the top of RAM, copies initial values of variables from flash into RAM, zeroes the rest, and calls main. Your main then runs and must never return: on a laptop returning from main hands control back to the OS, and here there is no OS, so it is declared -> ! and ends in an infinite loop {}.

Interrupts, mentioned above, are the mechanism where hardware pauses your program, runs a small designated function, and resumes exactly where it left off. It is how you react to events without constantly checking for them, it is the subject of a later part of this series, and it is also where the first genuinely interesting safety argument lives — a function that can begin between any two instructions of another function is a concurrency problem on a single-core chip with no threads.

The layers between your code and the hardware

You could write directly to address 0x25. Nobody does, because it is unreadable and unsafe. So there is a stack of libraries, each translating the layer below into something more human. In Rust a library is called a crate.

FIVE LAYERS, ZERO RUNTIME COST led.toggle() arduino-hal avr-hal avr-device the chip board crate · "pin 13 on an Uno", 16 MHz HAL · generic "set an output pin" PAC · names every register: PORTB, DDRB address 0x25 inlined away entirely 1 instruction: sbi 0 bytes of RAM — but only with optimisation on, which makes an optimisation setting a correctness setting
The whole blink program is 96 bytes of machine code. The abstraction is free at runtime, and that guarantee is the reason this stack is worth the setup cost at all.

The jargon you will meet for these: a PAC (Peripheral Access Crate) is the bottom layer, avr-device — nothing but precise names and types for every hardware register, generated automatically from Microchip's own machine-readable description of the chip. A HAL (Hardware Abstraction Layer), avr-hal, turns registers into concepts: pins, delays, serial ports. A board crate, arduino-hal, fills in the specifics of this board.

The remarkable property, and the reason this is worth doing, is the bracket on the right of that diagram: these layers cost nothing at runtime. The compiler inlines them all away, so led.toggle() becomes the same one or two machine instructions you would have written by hand. But that guarantee depends on optimisation being switched on — which is a claim I am going to have to measure rather than repeat, and a later part does exactly that, because a debug build where the abstraction is not free is a debug build that does not fit in 2 KB.

Why this is harder than the Arduino IDE

The IDE hides all of the above. You write digitalWrite(13, HIGH), press Upload, and it works. It can do that because it ships a runtime that pre-configures the chip for you and a HAL that looks up pin numbers at runtime.

The trade is that mistakes are silent. Writing to a pin you configured as an input compiles fine and misbehaves quietly. Exhausting RAM is easy and unannounced. And this is the point where the series' actual subject appears, because the Rust stack refuses to hide those things — and in exchange the compiler can prove whole categories of mistake impossible before the code reaches the board.

Not all of them. That is the interesting part. On this chip the safety story splits cleanly in three, and being honest about which third you are in is most of the skill:

The reason to start a safety series on a machine this small is that the first category is only impressive if you can see the second and third clearly. A type system that eliminates a bug you could not have observed is a marketing claim. On an Uno, you can observe all of them.

Vocabulary for the rest of the series

Every term the later parts assume. Skim now, return as needed.

TermMeaning
ABIThe conventions for how compiled code passes arguments and returns values. Two pieces of machine code must agree on it to interoperate.
avrdudeThe program that copies your compiled firmware into the chip over USB.
avr-gccThe GNU C compiler for AVR. Used here as the linker, and for its startup code and helper library — not to compile any C.
avr-libcThe AVR standard C library. Supplies the startup code and the linker scripts describing the chip's memory layout.
bootloaderA tiny program permanently in the top 512 bytes of flash that receives new firmware over serial and writes it. On the Uno it is Optiboot, and it is why you need no special programming hardware.
.bss / .data / .textRegions of a compiled program: .text is machine code, .data is variables with initial values, .bss is variables starting at zero. Their sizes tell you flash and RAM usage.
crateA Rust library or program. The unit of compilation and of dependency.
coreRust's standard library minus everything needing an OS. std is the full one, and is unavailable here.
ELFThe container format for compiled programs on Unix-like systems. Your .elf is firmware plus symbol and debug tables.
firmwareSoftware that runs on a device with no OS. What you are writing.
ICEInternal Compiler Error — the compiler itself crashed. Always a compiler bug, never yours.
inliningPasting a called function's body into the caller, removing call overhead. The mechanism by which the library layers become free.
interrupt / ISRHardware pauses your program to run a short Interrupt Service Routine, then resumes.
libgccRoutines for operations the chip lacks — 32-bit multiply, division, floating point. The AVR has no divide instruction, so a / b becomes a call into libgcc.
no_stdA Rust crate declaring it does not use the OS-dependent standard library. Mandatory here.
PAC / HAL / board crateThe three abstraction layers above.
panicRust's response to an unrecoverable error. On a laptop it prints and exits; here you must supply a handler, and there is nothing to exit to.
relocationA "hole" in an object file that the linker fills with a final address. "Relocation truncated to fit" means an address did not fit the space the instruction allows.
Tier 3Rust's designation for a target that is built but not tested, and for which no precompiled core is shipped. AVR is Tier 3, and part two spells out what that costs.
typestateEncoding a thing's current state in its type, so misuse is a compile error. Used for pin modes.
zero-cost abstractionA layer of convenience the optimiser removes entirely, leaving no runtime penalty.

Next

Part two takes the toolchain apart properly: why the AVR target is Tier 3, why that forces a nightly compiler and a rebuild of core from source, and why the nightly is pinned to a specific date rather than tracking latest. The short version is that a miscompile on this target is silent, which makes the pin a safety measure rather than conservatism — but the long version is the one worth reading, because it is the first time in this series that the answer is "something you rely on is genuinely, currently broken, and here is how to tell".

No code yet. There is a reason for that: every safety claim the labs make is a claim about the machine described above, and I have watched too many embedded tutorials teach a mechanism without the constraint that justifies it. The constraint comes first.

References

  1. Microchip, ATmega328P Datasheet (DS40002061), §7 "AVR Memories", §36 "Instruction Set Summary".

  2. avr-libc user manual, "Memory Sections". https://www.nongnu.org/avr-libc/user-manual/mem_sections.html

  3. Rust platform support and target tier policy. https://doc.rust-lang.org/rustc/target-tier-policy.html

  4. avr-hal — the HAL and board crates used throughout this series. https://github.com/Rahix/avr-hal