Memory Footprint Optimization
Memory footprint optimization techniques for embedded firmware: struct packing, compiler flags, const placement, and measuring Flash/SRAM usage.

Contents & prerequisites
Prerequisites
Why memory footprint matters on resource-constrained MCUs
Memory footprint on a microcontroller unit (MCU) determines whether your firmware fits at all, not just how fast it runs. A Cortex-M0+ part with 32 KB of Flash and 4 KB of SRAM gives you no second chance: if the linker reports overflow, the build does not produce a usable image, full stop. Optimization here is not a performance nicety — it is a pass/fail gate before you ever touch a debugger.
SRAM and Flash constraints on typical Cortex-M devices
Flash holds your code, constants, and initial values for writable globals; SRAM holds your stack, heap, and all runtime read-write state. These two budgets are independent and fail differently. Running out of Flash is a link-time error you see immediately. Running out of SRAM is often a runtime failure you discover in the field, because the linker only checks static allocation — it has no idea how deep your call stack will actually go or how large your heap requests will get at runtime. Check your specific part's datasheet or reference manual for exact Flash/SRAM sizes; they vary enormously even within one vendor's product line, from a few kilobytes on entry-level Cortex-M0 parts to several hundred kilobytes of SRAM on high-end Cortex-M7 devices.
Cost and power implications of larger memory
Choosing a bigger-memory variant to dodge a footprint problem has a unit-cost consequence that multiplies across production volume, and it often carries a power penalty too, since Flash read current and SRAM retention current both scale with array size. On a battery-powered design doing periodic wake-sleep cycles, a larger SRAM array can measurably raise sleep-mode current — check the datasheet's low-power mode table for the specific delta at your target voltage and temperature. Fixing a footprint problem in software costs engineering time once; fixing it by upsizing the part costs money on every unit shipped forever.
Stack overflow and heap collision risks
Stack and heap conventionally grow toward each other from opposite ends of SRAM, and when they meet, you get silent corruption rather than a clean fault in many configurations. The stack grows downward from the top of SRAM with each function call and local array; the heap (if you use malloc) grows upward from the end of static data. Neither one checks the other's position by default on a bare-metal Cortex-M build. A deeply nested interrupt handler combined with a fragmented heap is a classic way to corrupt a global variable that sits nowhere near either in source code, because the damage happens through pointer arithmetic gone wrong at the boundary, not through the variable itself.
How to minimize data structure size through packing and alignment
Struct size depends on member order, not just member types, because the compiler inserts padding to satisfy each member's alignment requirement. Reordering members from largest to smallest typically recovers bytes with zero logic change.
Struct padding and member ordering
Consider this struct on a 32-bit ARM target:
struct bad_order {
uint8_t flag; // offset 0, 1 byte
uint32_t id; // needs 4-byte alignment -> 3 bytes padding inserted
uint8_t state; // offset 8, 1 byte
}; // total: 12 bytes (3 bytes trailing padding for array alignment)
Reordering by descending size:
struct good_order {
uint32_t id; // offset 0, 4 bytes
uint8_t flag; // offset 4, 1 byte
uint8_t state; // offset 5, 1 byte
}; // total: 8 bytes, 2 bytes trailing padding
The second layout saves 4 bytes per instance. That is trivial for one struct, but for an array of 1,000 log entries it is 4 KB of SRAM recovered for free. The general rule: order members from largest alignment requirement to smallest. For the full mechanics of struct layout and how pointers interact with it, see C Language Basics: Pointers, Structs, Unions.
Alignment requirements on ARM Cortex-M
Cortex-M cores require natural alignment for efficient access: a uint32_t must sit at an address divisible by 4, a uint16_t at an address divisible by 2. Some Cortex-M variants (notably Cortex-M0/M0+ without the unaligned-access feature) fault on misaligned word access entirely rather than just losing a cycle, so padding is not just a size concern — it's a correctness concern on those cores. Always confirm unaligned-access support in your specific core's technical reference manual before assuming the compiler's padding choices are merely cosmetic.
Bitfield usage for dense boolean and small integer storage
Bitfields pack multiple boolean or small-integer flags into a single word, trading some access-time overhead for substantial storage savings. Use a bitfield when you have five or more related flags stored together and access frequency is low to moderate; use a plain uint8_t per flag when you need fast, unambiguous single-instruction access or when the flags are accessed from an interrupt context where bitfield read-modify-write sequences risk a race condition. Bitfield layout within a word is implementation-defined by the C standard, so bitfields used for hardware register mapping need careful, compiler-specific verification — see Bit-Field Struct for Register Mapping for the register-mapping case specifically.
Union merging for mutually exclusive state
A union lets two or more mutually exclusive states share the same storage, sized to the largest member. If your firmware is never in "configuring" and "streaming" state simultaneously, a union holding config_t and stream_t costs only sizeof() the larger of the two, not the sum. The tradeoff is that the compiler can no longer catch you reading the wrong member — that discipline falls entirely on you.
Code size reduction through compiler flags and linkage control
Compiler flags change code size by 10-30% typically, with no source changes required, making them the cheapest first lever to pull.
Optimization levels: -Os vs -O2 tradeoffs
-Os optimizes for size by disabling transformations that trade code size for speed, such as aggressive loop unrolling and function inlining beyond a small threshold. -O2 optimizes for speed and will duplicate code paths — unrolled loops, inlined call sites — that -O2 judges will run faster even though they occupy more Flash. The actual delta is highly codebase-dependent; measure it on your own binary rather than assuming a fixed percentage, but it's common to see -O2 builds land noticeably larger than -Os builds of the same source on Cortex-M targets. If a specific hot loop needs -O2-level speed, apply it per-function with __attribute__((optimize("O2"))) rather than globally.
Link-time optimization (LTO) for dead code elimination
LTO defers optimization until link time, when the linker can see every translation unit at once and remove functions that are never called from any of them — something a single-file compile pass cannot do. Enable it with -flto on both the compile and link command lines, and pair it with -ffunction-sections -fdata-sections at compile time plus --gc-sections at link time so the linker can discard unused functions and data at section granularity rather than whole-object granularity.
Static and inline functions to eliminate unnecessary symbols
Marking a function static when it's only used within its own file lets the compiler see every call site and either inline it or drop it if unused, and it keeps the symbol out of the global symbol table entirely, which also reduces debug-info size. The linkage rules governing static, inline, and extern — and how they interact with LTO's cross-file visibility — are covered in Static, Inline, and Extern Linkage.
Thumb vs ARM instruction set selection
Cortex-M cores execute Thumb-2 instructions exclusively — there is no legacy 32-bit ARM mode to choose on these cores, unlike Cortex-A parts. The relevant compiler choice on Cortex-M is between Thumb-1-only code generation and full Thumb-2, controlled by your target's -mcpu/-march setting, which GCC and Clang select automatically once you specify the correct core (e.g., -mcpu=cortex-m0 vs -mcpu=cortex-m4). Specifying the wrong core can silently leave code-density-improving Thumb-2 instructions unused on a part that supports them.
Const and restrict qualifiers to shrink footprint without logic change
const correctness lets the compiler place data in Flash instead of copying it into SRAM at startup, and restrict lets it avoid redundant reloads from memory.
Placing immutable data in Flash (PROGMEM patterns)
Any data declared const with static or global storage duration is placed in the .rodata section, which the linker maps into Flash, not SRAM. Without const, an initialized global array is placed in .data, which requires both Flash space for the initial image and an equal amount of SRAM to hold the runtime copy, plus startup code that copies one into the other. A 2 KB lookup table declared const costs 2 KB of Flash and 0 bytes of SRAM; the same table without const costs 2 KB of Flash and 2 KB of SRAM. This is the direct Cortex-M equivalent of the AVR PROGMEM pattern, achieved without a special attribute because ARM's unified address space lets the linker place const data in Flash by default.
Compiler assumptions from const correctness
const is also an optimization hint, not just a placement directive: a function receiving const char * tells the compiler the callee won't modify the pointee, which permits the compiler to cache the value in a register across calls instead of reloading it defensively.
Restrict pointer hints for better register reuse
restrict on a pointer parameter tells the compiler that no other pointer in scope aliases the same memory, which allows it to keep values in registers instead of reloading from memory after every write through a different pointer, shrinking both instruction count and the resulting binary in pointer-heavy code such as DSP filter kernels or buffer-copy routines. The full mechanism, including where restrict is unsafe to apply, is covered in C const and restrict for Optimization Hints.
Choosing data types and representations for size efficiency
Type choice affects both per-variable SRAM cost and the code size needed to operate on that type, since narrower types often need fewer or cheaper instructions on Cortex-M's load/store architecture.
Fixed-width integer types (uint8_t, uint16_t) over int
int on Cortex-M is 32 bits, so using int for a counter that never exceeds 200 wastes 3 bytes per instance versus uint8_t, and that waste compounds across arrays and struct fields. Use <stdint.h> types sized to the actual data range: uint8_t for flags and small counters, uint16_t for anything up to 65,535, uint32_t only when the range genuinely requires it. Be aware that narrow types inside expressions get promoted to int during arithmetic per C's usual arithmetic conversions, so the savings are in storage, not necessarily in every intermediate computation.
Packed enums and narrow enumeration storage
C does not guarantee an enum's underlying type is smaller than int, so an enum with only five values can still cost 4 bytes per instance by default. GCC and Clang support __attribute__((packed)) on enum declarations, or you can sidestep the ambiguity entirely by storing the enum's values in an explicit uint8_t field and casting at the access points — more portable across compilers and safer against future ABI changes.
Avoiding double precision floating-point on M0/M3
Cortex-M0 and Cortex-M3 cores have no hardware floating-point unit (FPU), so every float or double operation is emulated in software, and double emulation is roughly twice the code size and cycle cost of float emulation for comparable operations. Unless a calculation genuinely needs double precision's range or mantissa width, use float throughout on these cores, and check whether fixed-point arithmetic is viable for the hot path — it eliminates the emulation library entirely.
String interning and lookup tables vs dynamic allocation
Repeated string literals should be deduplicated — most linkers do this automatically for identical string constants via string pooling, but only within the same translation unit or across all of them if you enable -fmerge-constants and link-time section merging. For configuration-driven behavior, a const lookup table indexed by an enum avoids both the Flash cost of per-case string literals scattered through switch statements and the SRAM/fragmentation cost of building strings with malloc at runtime.
Profiling and measuring actual memory consumption
You cannot optimize what you have not measured, and footprint regressions creep in silently across a project's lifetime without an explicit measurement step in the build.
Compiler-generated symbol map analysis
The linker's map file (-Wl,-Map=output.map with GNU ld) lists every symbol's address and size, section by section, letting you identify the single largest contributors to .text, .data, and .bss directly. Sort by size and look at the top ten symbols first — in most firmware, a small number of functions or tables account for a disproportionate share of total footprint.
Runtime heap and stack usage detection
Static analysis tells you worst-case stack depth only if your toolchain supports call-graph stack analysis (GCC's -fstack-usage emits per-function stack frame sizes you can sum along call paths); it cannot see recursive calls or function pointers resolved at runtime. For heap usage, instrument malloc/free with a wrapper that tracks high-water mark, or use a static allocator with a fixed pool so exhaustion is detectable at compile time instead of at a random runtime failure.
Identifying code bloat with nm and objdump
nm --size-sort -C your_binary.elf lists every symbol sorted by size, in demangled form for C++, giving you a fast ranked list of what to attack first. objdump -d disassembles a specific function when you need to see why it's larger than expected — often the answer is unwanted inlining, an unexpectedly wide switch-case jump table, or floating-point emulation calls you didn't know were present.
Firmware size tracking across builds
Run arm-none-eabi-size (or your toolchain's equivalent) on every build in continuous integration and log the Flash and SRAM totals over time, failing the build if either exceeds a threshold you set per target. A single large function added without review can consume 5-10% of a small MCU's Flash budget in one commit; catching that at merge time is far cheaper than catching it when a field update no longer fits.
Learning
Sign in to track your progress.
Evidence
Public projects engineers linked to Memory Footprint Optimization.
No engineer has linked a project to this topic yet. Built something that proves it? Add the project and tag it with embedded-firmware-memory-footprint-optimization — it then shows here and on your public profile.
