RayBench EmbeddedInteractive engineering labs
EMBEDDED C

Object Layout & Storage

Where an object lives and what it costs: storage duration and linkage, the sections a declaration lands in, struct layout and the padding that makes a struct larger than its members, allocation without a heap, and integrity checks over stored data.

Reviewed 2026-08-223,945 words

Object Layout, Sections, and the Memory Map

Every object in a firmware image ends up in a named section, and every section ends up at an address chosen by the linker script. Initialised globals live in .data, which occupies both flash (for the initial values) and RAM (where they are copied at startup). Zero-initialised globals live in .bss, which occupies RAM only and is cleared by startup code. Constants live in .rodata and usually stay in flash. Code lives in .text. Locals live on a stack whose size nobody declares and everybody assumes. Understanding this layout is not trivia: it is how you answer whether a build fits, why a variable is non-zero before main runs, and why adding one array pushed the stack into the heap.

How it is built

  • A struct's layout is its members in declaration order, with padding inserted so each member meets its own alignment requirement and the struct's size is a multiple of its strictest member's alignment. Reordering members from largest to smallest alignment often removes padding entirely, which on a part with kilobytes of RAM is worth doing deliberately rather than hoping.
  • The three data sections divide by initialisation. .data has a non-zero initialiser, so its values are stored in flash and copied to RAM by startup - it costs both. .bss is zero-initialised, costs only RAM, and is cleared by a loop in startup. .rodata is const and stays in flash. Marking a large lookup table const is often the single largest RAM saving available in a firmware project.
  • The linker script owns placement. Section attributes in source are requests; the script decides which memory region each section lands in and in what order. A buffer that must live in DMA-capable RAM, or in a region that survives reset, gets there through the script - the attribute alone does nothing.
  • The stack is not a section the linker sizes for you. It is usually placed at one end of RAM and grows toward the other, and nothing checks that it does not meet .bss coming the other way. Overflow is silent corruption of whatever it reaches, which is why measuring the high-water mark matters more than any static analysis.
  • The map file is generated evidence, not documentation. It lists every section, its address, its size, and which object file contributed it. When a build stops fitting, the map file names the object that grew; when a symbol is unexpectedly in RAM, the map file says so.
  • Startup code runs before main and is what makes any of this true: it copies .data from flash to RAM, clears .bss, sets up the stack pointer, and calls the C runtime initialisation. A global read before startup completes has whatever value flash or RAM happened to hold, which is why constructors and pre-main hooks are a common source of order-dependent bugs.

Design procedure

  1. Read the map file after every significant change, and keep the totals in the build log so growth is visible over time rather than discovered when it stops fitting.
  2. Order struct members by descending alignment to remove padding, then verify with sizeof and _Static_assert rather than assuming the compiler did what you expected.
  3. Mark every lookup table, string, and configuration constant const so it stays in flash. Check the .data and .bss totals before and after to confirm it moved.
  4. Paint the stack region with a known pattern at reset and read the high-water mark on a realistic workload with interrupts active. Estimating is not measuring.
  5. Place DMA and reset-persistent buffers through the linker script, and assert their addresses at startup so a script change cannot silently relocate them.
  6. Budget RAM as a table of regions with peaks, and compare its total against the part's RAM. The map file covers only the static half of that table.

Key terms

.text
Executable code. Lives in flash and is usually the largest section.
.rodata
Constants. Stays in flash, costs no RAM. Where const moves a lookup table.
.data
Initialised globals. Costs flash for the values AND RAM for the copy.
.bss
Zero-initialised globals. Costs RAM only, cleared by startup.
Padding
Bytes inserted so members meet their alignment. Removable by reordering.
Linker script
The file that decides which region each section lands in. Section attributes are requests to it.
Map file
Generated evidence of what went where, and which object contributed it.
High-water mark
The deepest the stack actually reached, found by painting the region at reset.

Worked example

/* Padding is real, and reordering removes it. */
struct bad  { uint8_t a; uint32_t b; uint8_t c; };   /* 12 bytes */
struct good { uint32_t b; uint8_t a; uint8_t c; };   /*  8 bytes */
_Static_assert(sizeof(struct good) == 8, "layout changed");

/* Where each of these lands: */
uint32_t counter;                 /* .bss    - RAM only, zeroed      */
uint32_t limit = 500;             /* .data   - flash AND RAM         */
const uint32_t table[256] = {0};  /* .rodata - flash only            */
static uint8_t buf[1024];         /* .bss    - 1 KB of RAM           */

# Reading a map file, in the order that matters:
#
#   .text    24 512   flash used by code
#   .rodata   3 200   flash used by constants
#   .data       412   flash for values + 412 RAM for the copy
#   .bss     12 088   RAM, cleared at startup
#            ------
#   flash    28 124 of 65 536   -> 43%
#   RAM      12 500 of 20 480   -> 61% BEFORE the stack
#
# The stack is the part the map file does not show, and the part
# that overflows.

# What startup does before main, in order:
#   1. set the stack pointer from the vector table
#   2. copy .data from flash to RAM
#   3. zero .bss
#   4. run C runtime init
#   5. call main

Common pitfalls

Image Integrity: Sections, Placement, and Boot-Time Verification

Verifying a firmware image at boot requires three things the language does not provide: a defined region to check, a place to store the expected value, and a build step that computes it after linking. The region has to be described by linker symbols so the code can find its own extent; the expected value has to live somewhere the computation does not cover, or checking it becomes circular; and the computation has to happen after the final image exists, which means a post-build step rather than anything in the C source. This chapter is about that mechanism - sections, linker symbols, placement and the boot-time check. What the check can actually promise, which corruptions a given polynomial detects, and why a valid CRC is not evidence of authorship, is the integrity chapter of Embedded DSA at /dsa/checksums.

How it is built

  • The linker can export symbols marking the start and end of a section, and those symbols are addresses rather than variables. Declaring them as extern arrays and taking their addresses is the idiom that works; declaring them as extern integers and reading their values reads whatever is at that address, which is a common and confusing mistake.
  • The checked region must exclude the slot holding the expected value, or the computation covers its own result and can never be satisfied. The usual arrangement is to place the checksum in a dedicated section at a known offset, immediately after the region it describes.
  • The value has to be computed after linking, because only then does the final image exist. A post-build step reads the binary, computes the check over the defined range, and patches it into the reserved slot - which means the build produces an image that differs from the one the linker emitted, and the tooling has to account for that.
  • A bootloader and an application are separate images with separate vector tables, and the handover is a specific sequence: verify the application, deinitialise anything the bootloader started, set the vector table offset, load the application's stack pointer from its vector table, and branch to its reset handler. Skipping the vector table relocation leaves interrupts vectoring into the bootloader.
  • Dual-bank or A/B layouts place two application slots so an update can be written to the inactive one and verified before it is ever run. The selection between them belongs in persistent storage that survives a failed update, and the fallback path must be tested rather than assumed.
  • Verifying the whole image at every boot costs boot time proportional to image size, which on a large image is significant. The alternatives are verifying only after an update, verifying a header plus a sampled region, or using hardware acceleration - each of which weakens or costs something, and the choice belongs in the product's requirements.

Design procedure

  1. Define the checked region with linker symbols, and declare them in C as extern arrays whose addresses you take rather than as variables you read.
  2. Reserve the checksum slot in its own section placed immediately after the checked region, so the region can exclude it cleanly.
  3. Compute and patch the value in a post-build step, and make that step part of the normal build rather than a manual action.
  4. Verify before jumping, and define what happens when verification fails: stay in the bootloader, fall back to the other slot, or enter a recovery mode. Silence is not an option.
  5. Perform the handover in full: deinitialise, relocate the vector table, load the stack pointer from the application's vector table, then branch.
  6. For what the check actually proves - which error classes a polynomial catches, and why it says nothing about who produced the image - see /dsa/checksums.

Key terms

Linker symbol
An address exported by the linker script. Take its address; do not read its value.
Reserved slot
A section holding the expected value, excluded from the region it describes.
Post-build step
Computes the check over the final image and patches it in. The only point where the image exists.
Vector table offset
Must be relocated before jumping to an application, or interrupts vector into the bootloader.
A/B slots
Two application regions so an update is verified before it is ever executed.
Boot-time cost
Full verification is proportional to image size, which is a real boot-time budget item.

Worked example

/* Linker script exports the extent of the checked region: */
/*   .text : { __app_start = .; *(.text*) ... __app_end = .; } > FLASH  */
/*   .appcrc : { __app_crc = .; KEEP(*(.appcrc)) } > FLASH             */

#include <stdint.h>
#include <stddef.h>

/* Declared as arrays so taking the address is natural and correct. */
extern const uint8_t __app_start[];
extern const uint8_t __app_end[];
extern const uint32_t __app_crc[];

int app_image_valid(void) {
    const size_t len = (size_t)(__app_end - __app_start);
    return crc32(__app_start, len) == __app_crc[0];
}

/* WRONG: reads the value AT the address, not the address itself. */
/* extern uint32_t __app_start;  size_t len = __app_start;  */

# The handover, in the order it has to happen:
#   1. verify the application image
#   2. deinitialise peripherals the bootloader started
#   3. disable interrupts
#   4. SCB->VTOR = APP_BASE          <- forgetting this is the classic bug
#   5. set MSP from APP_BASE[0]
#   6. branch to APP_BASE[1]

# What the check does and does not establish:
#   the image is intact                    YES
#   the flash write completed              YES
#   the image came from you                NO   <- needs a signature
#
#   the error classes it catches:  /dsa/checksums

Common pitfalls

Memory, Stack & Allocation Lab

Embedded memory management matches object lifetime, capacity, alignment, access context and failure policy to a physical RAM budget. Automatic objects normally occupy call frames; static objects occupy fixed sections; pools provide a known number of equal-size objects; arenas serve phase-based work; and a general heap serves variable sizes and lifetimes. None is universally correct. A production design states who owns each object, when storage is live, what happens at exhaustion, how long allocation can take, and which evidence proves the worst case on the shipping binary.

How it is built

  • The map file accounts for .data and .bss but not the maximum stack depth or changing allocator occupancy. A complete ledger adds fixed sections, simultaneously active task stacks, nested interrupt frames, live pools and arenas, DMA buffers, retained state and margin. The total represents bytes alive at the same time rather than an average observed during a friendly run.
  • A stack frame can hold saved registers, spilled temporaries, local objects, alignment padding and arguments that did not fit in registers. The compiler and optimization level choose its real size. Worst-case stack is the sum along the deepest reachable call path plus exception-entry frames and permitted nesting. Recursion, variable-length arrays, alloca and unresolved indirect calls make that bound dynamic or unknown and therefore demand a hard policy.
  • A general heap tracks variable-size free regions. Allocation may search for a suitable hole, split it and maintain metadata; release may create holes or merge neighbors. The heap can have enough total free bytes but no single block large enough for a request. This external fragmentation makes success and latency depend on allocation history unless the allocator and usage pattern provide a stronger bound.
  • A fixed-block pool divides storage into equal aligned blocks and links the unused blocks. Acquire pops one block and release pushes it back, so both operations can be constant time and external fragmentation is absent. Capacity is explicit, but a small request still occupies a whole block and a larger request requires another size class or mechanism.
  • An arena keeps a base, capacity and cursor. Each aligned allocation advances the cursor, and one reset releases the whole region. Allocation is fast and metadata small, but individual objects cannot be returned and every pointer must be dead before reset. It fits packet processing, parsers, inference scratch memory and other clear phases.
  • Caller-owned storage places capacity and lifetime at the API boundary. A module exposes its context or reports the size and alignment of opaque storage, then initializes inside memory supplied by the application. This avoids hidden allocation, lets the application select a memory bank or linker section, and makes shutdown and failure ownership reviewable.
  • Static and dynamic evidence answer different questions. GCC -fstack-usage reports per-function stack estimates for a particular build, while a reviewed call graph composes them into paths. A painted high-water mark or RTOS watermark shows what a test workload reached. Neither alone proves the real worst case: the static model can miss control flow, and the measurement can miss an untriggered path.
  • Allocation failure is a normal branch. The caller must leave state valid, avoid partial ownership transfer, report a diagnostic and apply a defined retry, degrade, drop, safe-state or reset policy. Tests force the first allocation, every intermediate allocation and full-capacity exhaustion to fail, because cleanup paths are where leaks and double releases hide.
  • A source-level teaching analyzer can classify straightforward declarations, expand fixed arrays, visualize struct padding and identify recognizable allocation calls before code reaches the toolchain. Its totals are estimates, not ABI evidence: macros, packing, bit-fields, unions, optimizer-created spills, library metadata and linker placement still require sizeof and _Alignof checks, the map file, compiler stack reports and target measurement.

Design procedure

  1. Classify every object by size, alignment, lifetime, owner, maximum simultaneous count and execution context. Mark whether it may be acquired or released in an ISR or hard real-time path.
  2. Build a concurrent RAM ledger from the map file and runtime regions. Include fixed sections, task stacks, nested interrupts, allocator peaks, DMA ownership, diagnostic buffers and margin; make the build fail above the approved limit.
  3. Compile the release configuration with -fstack-usage and inspect the generated .su files. Compose deepest reachable call chains, account for recursion and indirect calls, and add architecture-specific exception frames and alignment.
  4. Choose storage from lifetime shape: static or caller-owned for image lifetime, a pool for interchangeable fixed-size objects, an arena for phase lifetime, and a general heap only where variable lifetime and size justify weaker bounds.
  5. Write an exhaustion contract before the happy path. State whether data is dropped, retried, back-pressured, degraded or escalated, and preserve module invariants when acquisition returns NULL.
  6. Instrument safely: paint an inactive reserved stack before task start, add guard words or an MPU boundary, expose pool occupancy and allocation-failure counters, and retain peak values for postmortem diagnostics.
  7. Test full capacity, one-past-capacity, fragmentation histories, double-free diagnostics, invalid releases, alignment, reset during use and maximum interrupt nesting. Run the real scheduling and DMA workload on target.
  8. Review evidence after compiler, optimization, linker-script, RTOS, interrupt or major feature changes. These inputs change frame size and concurrency, so an old watermark is not proof for a new image.
  9. Paste representative source into the declaration inspector to build intuition about placement and padding, then compare its explanation with compiler-generated sizeof, _Alignof, map and -fstack-usage evidence for the actual target ABI.

Key terms

stack frame
Per-call storage chosen by the ABI and compiler for locals, spills, saved registers, padding and stacked arguments.
high-water mark
Deepest observed stack use, commonly found by scanning a safely painted inactive region after execution.
external fragmentation
Free space split into holes so a contiguous request fails although their combined size is sufficient.
fixed-block pool
A bounded set of equal aligned blocks, usually with constant-time acquire and release through a free list.
arena
A linear allocator that advances a cursor and releases an entire lifetime phase with one reset.
ownership
The unique responsibility to use, transfer or release an object at a given point in its lifetime.
exhaustion policy
The defined system response when no stack, pool, arena or heap capacity remains.

Worked example

The live lesson contains complete C11 fixed-block pool, aligned arena, caller-owned module and stack high-water examples. Each checks capacity before changing state and makes failure visible to its caller.

Common pitfalls

The mental model

Memory correctness is ownership plus lifetime plus bounds. The map tells you where bytes can live; the program must still decide who owns them, how long they remain valid, and who may modify them.

MCUs have fixed RAM, often no memory protection, and limited visibility after failure. Stack collision, fragmentation, buffer overwrite, and use-after-lifetime can corrupt unrelated state long before the crash is observed.

Core rules

Storage duration is not scope

Scope controls where a name is visible. Storage duration controls how long the object exists. A block-scope static object lives for the full program.

The stack is bounded

Every call, local array, saved register, interrupt frame, and nested ISR consumes stack. Recursion makes the upper bound harder to prove.

Heap success is temporary evidence

A successful allocation now does not prove a later allocation will succeed. Fragmentation depends on the lifetime and size pattern.

Buffers need four facts

For every buffer, identify owner, capacity, current valid length, and lifetime. Confusing length with capacity is a common overflow cause.

Measure high-water use

Map files provide static allocation. Stack painting, allocator telemetry, and pool watermarks provide runtime evidence.

Workflow

  1. Read the linker map and record flash, static RAM, heap reservation, and stack reservation.
  2. Draw object lifetimes for the longest operation.
  3. Replace unbounded allocation with static, arena, or fixed-block storage where practical.
  4. Add bounds checks and allocation-failure paths.
  5. Measure stack and pool high-water marks under worst-case interrupt and error load.

Worked example

Caller-owned buffer contract
typedef enum { READ_OK, READ_FULL, READ_BAD_ARG } read_status_t;

read_status_t read_frame(uint8_t *dst, size_t capacity,
                         size_t *length)
{
    if (dst == NULL || length == NULL) return READ_BAD_ARG;
    *length = 0u;
    while (byte_available()) {
        if (*length == capacity) return READ_FULL;
        dst[(*length)++] = read_byte();
        if (dst[*length - 1u] == '
') return READ_OK;
    }
    return READ_OK;
}

Ownership stays with the caller. Capacity enters separately from current length, every write is preceded by a bound check, and the status makes truncation visible.

Vocabulary

stack
LIFO storage normally used for call frames and automatic objects.
heap
A region managed by a dynamic allocator for variable-lifetime objects.
fragmentation
Free memory split into pieces that cannot satisfy a requested contiguous allocation.
arena
A region that supports fast sequential allocation and is reset as a group.
high-water mark
The maximum amount of a resource observed in use.

The Memory Map: Where Everything Lives and Why It Matters

A microcontroller's address space is divided into regions with entirely different properties - flash that is fast to read and slow to write, RAM that is fast both ways and volatile, and peripheral registers that are not memory at all but hardware responding to addresses. A program's variables are distributed across these by rules that are mostly invisible until something runs out, and understanding the map is what turns 'it crashed' into a specific diagnosis.

How it is built

  • Flash holds code and constants. It survives power loss, is read at close to full speed, and is written in blocks with an erase cycle - which is why a constant table costs nothing and a variable in flash is not possible.
  • RAM holds everything mutable and is divided by convention rather than by hardware: initialised data, zero-initialised data, the heap growing one way and the stack growing the other.
  • The .data section is initialised variables, whose values live in flash and are copied to RAM at startup. The .bss section is zero-initialised variables, which cost no flash at all and are cleared by the startup code.
  • The stack grows downward from the top of RAM and the heap upward from the end of .bss. They meet in the middle, and nothing checks - which is why a stack overflow silently corrupts the heap.
  • Peripheral registers occupy addresses but are not storage. Reading one can have side effects, writing one can start a transfer, and the compiler must be told with volatile that it cannot cache or reorder these accesses.
  • The linker script assigns all of this. It is not generated boilerplate but the file stating where each section goes, and reading it is how a memory question is answered definitively.

Design procedure

  1. Read the linker map file after building. It lists every section's size and address and is the direct answer to how much memory is left.
  2. Check both flash and RAM usage against the part's capacity, and remember the stack and heap are not in the reported figures - they consume what is left.
  3. Size the stack from the worst-case call depth plus interrupt frames, and verify it by filling the region with a pattern and checking how much was disturbed after a representative run.
  4. Prefer static allocation to a heap in a long-running system, so the memory footprint is known at link time and fragmentation cannot occur.
  5. Mark every hardware register access volatile, and never assume a peripheral read is free of side effects.
  6. Put large constant tables in flash explicitly, since a table declared without const is copied to RAM at startup and consumes both.

Key terms

.text
Code, in flash.
.rodata
Constants, in flash. What const buys you.
.data
Initialised variables: values in flash, copied to RAM at startup.
.bss
Zero-initialised variables. No flash cost; cleared by startup code.
Stack
Grows down from the top of RAM. Locals, return addresses, saved registers.
Heap
Grows up from the end of .bss. Meets the stack, with nothing checking.
Linker map
The build output listing every section's size and address.
Memory-mapped register
An address that is hardware, not storage. Needs volatile.

Worked example

A lookup table declared as `uint8_t table[1024] = {...}` costs 1 kB of flash for the initial values and 1 kB of RAM to hold them, and the startup code copies it. Adding `const` moves it to .rodata: the RAM cost disappears entirely and the code reads it directly from flash. On a part with 4 kB of RAM that one keyword is a quarter of the available memory, and the difference is invisible in the source until the linker map is read.

Common pitfalls

More in Embedded C

  • Interrupts, Rings & ConcurrencyThe handler is a second thread you did not declare. What is and is not atomic on a single core, the lock-free ring buffer and the conditions it requires, DMA and cache coherency, low-power modes and their wake sources, priority inversion, and debugging a race you cannot reproduce.
  • Volatile RegistersMaster volatile keyword usage for memory-mapped registers in embedded C. Interactive simulator shows compiler optimization effects on register reads.
  • User-Defined TypesDeclare your own types in embedded C: struct, union, enum, typedef, bitfields, designated initialisers, flexible array members and opaque handles.
  • Embedded C + DSA 0 → 100One self-sufficient course connecting beginner C, Embedded C, hardware-facing APIs, bounded data structures, Embedded DSA practice, compiled code, diagnostics and production capstones.
  • Bits, Fields & Fixed PointRegister fields and the mask conventions that silently disagree, Gray code and where one-bit-at-a-time matters, fixed-point arithmetic and the intermediate width a multiply needs, the undefined-behaviour traps in ordinary bit idioms, wire-format packing, and what each checksum detects.
  • Types / PromotionUnderstand integer promotion and type conversion in embedded C. Interactive lab demonstrates implicit and explicit casting with signed/unsigned types.
  • Compiler Workbench & TestingCompile real C for an embedded target and inspect what the compiler produced, then the discipline around it: where to draw the host-testable boundary, reading the generated assembly, undefined behaviour and the sanitizers, the warnings worth enabling, and measuring size and stack.
  • Functions & ContractsDesign production C functions and modules with explicit preconditions, postconditions, ownership, status codes, opaque types, reentrancy, HAL seams and failure-state behavior.
  • Arrays, Strings & BuffersArrays decay and the length does not travel; strings are a convention, not a type; and every length in a received packet is data rather than fact. Spans, the three string copies, framing and resynchronisation, serialisation, and parsing untrusted input safely.
  • C Basics & the Translation UnitFrom source text to a linked image: declarations against definitions, the translation unit the compiler actually sees, the preprocessor and what a macro can and cannot do, and the four build stages with the error vocabulary each one produces.
  • FSM / DispatchDesign finite state machines with dispatch tables in embedded C. Interactive lab demonstrates state transitions and event handling for firmware.
  • Embedded CA complete source-to-silicon workbench: C semantics, arrays and APIs, compiler and startup, MMIO, interrupts, DMA and caches, bounded systems, testing, safety evidence and production defense.