Embedded Systems · Bare-Metal Programming
Getting Initialised Data and BSS Ready Before main Runs
Why a C static variable occupies two addresses, how the startup copy moves initialised data from flash into RAM, and why zeroed memory has to be zeroed by software.
A C program with static storage describes values that must exist in RAM and remain valid for the life of the program. RAM loses its contents when power is removed, and the program image lives in non-volatile memory. Reconciling those two facts is the job of the code that runs between reset and the first call to main, and it is the part of a firmware image that has no equivalent in the language it is written in.
One variable, two addresses
Consider a file-scope object with an initialiser, such as a lookup table or a configuration structure with non-zero defaults. At run time it must be in RAM, because the program can write to it. At load time, its initial value must be somewhere that survives power loss. The value therefore exists twice: once as bytes in flash, once as a region in RAM that must be filled with those bytes before the first read.
ELF formalises this with two addresses per section. The load address is where the bytes are stored in the image; the virtual address is where the section is expected to reside when the program runs. Object files produced by a C compiler put writable initialised objects in a section conventionally named .data and unwritten objects in a section named .bss. A linker script expresses the split with a region assignment such as > RAM AT> FLASH, which says that the section’s virtual address lies in the RAM region while its load address lies in the flash region. Everything downstream follows from that single line: the symbol values, the copy loop, and the size of the image.
Objects with no initialiser are a different case, and the difference is a language requirement rather than an implementation choice. The C standard states that objects with static storage duration and no explicit initialiser are initialised to zero before program startup. A freestanding implementation is still obliged to make that true, and the only moment at which it can be made true is startup. Zeroed memory is therefore not free storage that the image avoids paying for; it is storage whose contents are a contract between the startup code and every later file that declares a static counter.
The copy loop, driven by linker-defined symbols
The linker script publishes the extents of the initialised and zeroed regions as symbols. They have no storage of their own; their addresses are the boundaries.
#include <stdint.h>
/* Declared, not defined: these are linker symbols, so their ADDRESSES are the
values that matter. Dereferencing the symbol instead of writing &symbol
reads whatever happens to sit at that address. */
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss;
int main(void);
void reset_handler(void)
{
/* .data: copy the load image from flash to its run-time address. */
uint32_t *src = &_sidata;
for (uint32_t *dst = &_sdata; dst < &_edata; ) {
*dst++ = *src++;
}
/* .bss: no initial values exist anywhere, so write zeros. */
for (uint32_t *dst = &_sbss; dst < &_ebss; ) {
*dst++ = 0;
}
(void)main(); /* does not return in a bare-metal image */
for (;;) { }
}
Two properties of this function are easy to get wrong. It must not rely on static storage itself: if the compiler allocates a static temporary or reads a global constant that lives in .data, the value read is garbage, because the copy has not finished. Writing the reset handler in C is practical only while it uses local variables, which live on a stack that the hardware already installed from the first word of the image.
The second property is the shape of the region. The loop assumes that all initialised objects form one contiguous run in RAM and that their initial bytes form one contiguous run in flash. The linker script creates that property by listing the input sections in order and by grouping them, which is why a script that places .data and .rodata interleaved in flash produces an image whose copy loop fills RAM with the wrong bytes.
A large firmware usually has more than one such pair: sections that must be copied to a fast internal RAM, sections that must not be initialised at all because they carry state across a warm reset, and sections that belong to a different memory with different access rules. The two options are to enumerate a copy table (an array of source address, destination address, and length triples, generated at link time) or to give each pair its own loop. The table costs roughly twelve bytes per region and makes the startup code independent of the script’s section list; the explicit loops cost nothing but must be edited whenever a region is added.
Why skipping the zeroing appears to work
Memory that has never been written often reads as zero on hardware that has just come out of reset, and a .bss that is never cleared looks correct on the first run after power-up. It stops looking correct after a warm reset, a watchdog reboot, or a jump from a bootloader that used the same RAM. The failure is then a static variable holding a stale value from an earlier execution, which is a class of bug that resists reproduction because it depends on the previous run.
The same reasoning applies to memory that must be preserved. A boot-reason flag written by firmware before a self-reset must live in a region excluded from both the copy and the zeroing loop, and the linker script has to place it outside the extents it publishes. Placing such an object in ordinary .bss guarantees it is cleared at the exact moment the next execution wants to read it.
From the two loops to main
Zeroed and initialised sections are necessary but not sufficient for calling into compiled C. Objects with dynamic initialisers, meaning constructs that require a function call before first use rather than a constant byte pattern, are recorded in arrays that the linker collects, conventionally .init_array and .preinit_array, and the startup code walks them and calls each entry before main. A toolchain’s own startup file does this; a hand-written one that omits it produces a program in which those objects are uninitialised when main first touches them.
The point at which main becomes callable is therefore after the copy, the zeroing, and the initialiser walk. Whether calling main is even the right interface depends on the build: a hosted C environment expects the implementation to call main and to define argc and argv; a freestanding build has no such obligation, and firmware may call any entry function it likes. The arrangement in which the linker script and the startup file agree on an entry symbol is a convention, not a language rule.
What the copy costs
The image pays for initialised data twice: once for the bytes in flash and once for the RAM they occupy. A program with 4 KB of initialised tables therefore consumes at least 8 KB of the two memories combined, before any code. The alternative is to leave constants in flash and mark the objects read-only, which is why algorithm tables that never change belong in a const section rather than in .data. The startup cost is proportional to the number of bytes copied, and the loop runs before any clock configuration above the reset default, so the cycles spent are the slowest cycles in the life of the firmware.
The loops are written over 32-bit words rather than bytes for a reason that is visible in the bus rather than in the language: a word copy is one read and one write per four bytes of section, while a byte copy is four times as many transactions, and on a core with a 32-bit data path the byte form has no compensating advantage. The regions the linker defines are word-aligned for the same reason, and a section whose size is not a multiple of four is padded so that the loop can treat it as one. Where a region must be copied between memories with different native widths, the copy is the place that difference shows up as a measurable cost, since every transaction is bounded by the slower of the two interfaces.
There is a cheap way to check the script without running the firmware. A listing of section headers shows two addresses per section, the virtual address and the load address, and the sizes. If the load address of the initialised data region does not fall inside the flash region, or its virtual address does not fall inside RAM, the copy loop is wrong before it is ever executed. A gap between the end of the load image and the start of the zeroed region means the script has placed something between them; a virtual address for .data that precedes the end of the code occupying RAM indicates the regions overlap.
The order of operations around this code is itself a constraint. A region that lives in external memory cannot be initialised before that memory is usable, and the code performing the copy cannot live in the memory it is writing. Those ordering problems are the subject of initialisation ordering at reset; the copy loop here is only the part of the sequence that makes static objects in the language behave the way the language says they do.
References
- ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
- Tool Interface Standard (TIS) Executable and Linking Format (ELF) Specification, Version 1.2 — TIS Committee
- GNU ld documentation: Linker Scripts — GNU Binutils
- The Definitive Guide to Arm Cortex-M3 and Cortex-M4 Processors — Joseph Yiu, Newnes