Embedded Systems · Bare-Metal Programming
Ordering Constraints in the First Thousand Instructions
Clock sources, flash wait states, external memory controllers, and the floating-point unit all have prerequisites, so the startup sequence is a dependency graph rather than a list.
The sequence between reset and a working system is usually written as a list of calls: configure the clock, configure the memories, initialise the peripherals, enable interrupts, call the application entry. The list is a topological sort of a dependency graph, and the edges in that graph are the interesting part. Each edge is a statement that one operation cannot precede another, and the failure that follows from violating an edge is not a warning from any tool.
Which initialisations have prerequisites
An initialisation step depends on another when it reads or writes state the other creates. The edges that appear in practice fall into four kinds.
A step that runs code from a memory region depends on that region being usable. Firmware whose text executes from internal flash depends on nothing for that, but a system with a data section in external memory depends on the controller for that memory having been configured, and the configuration code itself must execute from memory that does not need the controller. That circularity is why the controller setup lives at the very start of the sequence, often in the reset handler before the copy loop, and why its configuration data cannot live in the region it is about to make available.
A step that raises a clock depends on the slower clock’s requirements having been satisfied in advance. The classic case is flash: the array’s access time is fixed by the silicon, while the bus clock period shrinks as the clock rises, so the number of wait states the controller must insert grows with the frequency. A flash macro with an access time in the tens of nanoseconds needs no wait states at a bus frequency of a few megahertz and several of them at 100 MHz. Increasing the frequency first and the wait states afterwards means the core is fetching from an array that cannot answer in one cycle, and the failure is a fetch of undefined data or a bus fault at the instruction that changed the clock.
A step that switches a clock source depends on the destination source being stable. An oscillator has to be running before the multiplexer selects it, and a phase-locked loop has to have locked before its output is usable as the system clock. Both provide a status flag that the software polls; the flag is the only safe indication, because the time to stability is a property of the oscillator and its loading rather than of the software. Waiting a fixed number of cycles calibrated for one board is a latent failure across parts, and a delay loop calibrated before the clock change measures something different after it.
A step that enables an interrupt depends on the handler, the table, and the state it touches being ready. Enabling a peripheral’s interrupt before the vector table has been relocated, before the memory that holds the handler’s shared state has been zeroed, or before the peripheral itself has been configured produces a handler that runs against a partially built system. The general rule is that interrupts are enabled last, and that each peripheral’s interrupt is enabled after that peripheral’s configuration rather than globally at the end of startup.
A sequence that respects the graph
The skeleton that satisfies these edges has a fixed shape across most parts, even though the register names differ.
At reset the core has a stack pointer and nothing else. The first phase runs before any interrupt has been enabled at the controller, with the floating-point unit off, and at the reset clock. It establishes the external memory controller if one is needed, then performs the initialised-data copy and zeroes the zeroed region, exactly as described in getting initialised data and BSS ready. Because the copy happens at the reset clock it is the slowest the copy will ever run, which is a cost of a few thousand cycles for a few kilobytes of data.
The second phase changes the clock. It enables the oscillator, waits for its stability flag, programs the dividers, programs the flash wait states and any prefetch or cache configuration for the new frequency, programs the PLL, waits for lock, and then selects the new source. It verifies the switch by reading back the status field that reports which source is active rather than trusting the write. Recomputing any delay constant used later belongs in this phase, since every software delay written before it was calibrated against a different clock.
The third phase enables optional execution hardware. On cores with a floating-point unit, the unit is typically off at reset and access to it is controlled by a coprocessor access register; code compiled to use hardware floating-point instructions faults with a usage fault if the unit is not enabled. Raising the clock and enabling memory protection also belong here, after the memories they apply to are known to work.
The fourth phase initialises the driver layer and then enables interrupts. The vector table base is set before any interrupt is unmasked, and the interrupt controller’s priority configuration is written before the enable bits, because a priority written after an interrupt is enabled leaves a window in which the interrupt can be taken at the reset default.
Where floating point changes the constraint
The floating-point edge has two directions that are easy to conflate. The architectural one is that the unit must be enabled before any code executes a floating-point instruction, which includes instructions the compiler generated for ordinary C. The build-level one is that a compiler configured for hardware floating-point will emit those instructions in the startup code as readily as anywhere else, including in the function that is supposed to enable the unit. The way out is that the enabling code itself is written or compiled so it contains no floating-point operations — which is automatic when the sequence runs before any arithmetic, and is not automatic when the enabling function is in a translation unit full of it.
There is a second floating-point edge, in the direction of the exception model. If the unit has been used, the exception entry sequence stacks additional floating-point registers, and the implementation may defer that stacking until a handler touches floating-point state. The consequence for startup is that the enable and configuration of the unit changes the size of every interrupt frame on the stack, so any stack sizing done before that configuration is describing a different system. A configured unit that no one has used does not enlarge frames, so the effect appears after the first floating-point instruction rather than at the moment of configuration.
What goes wrong when the order is guessed
Faults from the clock edges are typically immediate and reproducible: the machine faults at the instruction that follows the misconfiguration, or fetches from flash stop making sense. Faults from the memory edge are worse, because writing a data section that lives in unconfigured external memory may silently write nothing — the write is accepted by the bus and discarded — so the program starts with a data structure that is partly zeros. Faults from the interrupt edge appear as handlers that dereference a null pointer or read a stale counter, and they cluster at boot rather than under load.
A subtler failure is a startup sequence that is correct on one part and wrong on its near relative with a faster flash array, the same code, and a different number of wait states. The wait-state field is a hardware property of the part; the sequence that writes it belongs to the part’s configuration, and a startup file copied between related devices carries the mistake silently.
Two habits reduce the whole class. The first is to make each phase wait on the hardware’s own status flag rather than on elapsed time, so the sequence is correct at any clock. The second is to assert the invariants that can be asserted in code: that the source reported by the clock status field matches the intended source before the rest of startup proceeds, that the copied region’s boundaries come from the linker rather than from a constant duplicated in two files, and that a table’s base address is aligned as the architecture requires, which is one of the failure modes covered in relocating the vector table. The order itself is not something the compiler can check, because the dependency is between a register write and the behaviour of a bus rather than between two objects in the language.
References
- ARMv7-M Architecture Reference Manual (ARM DDI 0403) — Arm
- Cortex-M4 Processor Technical Reference Manual (ARM DDI 0439) — Arm
- The Definitive Guide to Arm Cortex-M3 and Cortex-M4 Processors — Joseph Yiu, Newnes
- GNU ld documentation: Linker Scripts — GNU Binutils