How to Optimize Memory Usage in Constrained Devices

Your firmware compiles cleanly. Zero warnings. You flash with confidence, and then the linker slaps you with region 'RAM' overflowed by 4096 bytes. Or worse: it fits, it flashes, and three days later your device crashes in the field with a hard fault nobody can reproduce. You check your logic. You check your peripherals. Everything looks fine. But the real culprit is invisible: you’ve silently blown past your stack, and memory is corrupting itself beneath your feet.
This is the defining challenge of embedded work. On a desktop, you have gigabytes of RAM and an operating system that papers over your mistakes with virtual memory. On an MCU with 64 KB of RAM, every byte has a name and a purpose, and there’s no safety net. Embedded memory optimization isn’t a nice-to-have performance trick. It’s the difference between firmware that ships and firmware that randomly burns down.
This article gives you a practical, 5-point checklist for auditing and optimizing memory on resource-constrained MCUs, with Zephyr RTOS as the primary reference platform. The principles apply whether you’re on an nRF52840, an STM32F4, or bare metal. You’ll walk away with concrete steps you can apply to your project today.
What Lives Where: Flash, RAM, Stack, and Heap on an MCU
Before you can shrink your memory footprint, you need to understand what occupies what. A typical Cortex-M MCU divides memory into two physical regions:
Flash (non-volatile) stores your program code and read-only data. On an nRF52840, that’s 1 MB. On a smaller chip like the STM32F031, it might be 32 KB.
RAM (volatile) stores everything your program actively works with at runtime. The nRF52840 gives you 256 KB, but many common MCUs offer just 64 KB or less.
Within those two regions, the linker organizes your firmware into sections:
.text: Your compiled machine code. Lives in Flash..rodata: Read-only data (string literals,consttables). Lives in Flash..data: Global/static variables initialized to a non-zero value. Stored in Flash (initial values) but copied into RAM at startup..bss: Global/static variables initialized to zero. Occupies RAM but doesn’t need Flash storage for initial values.- Stack: Per-thread scratch space for local variables and function call frames. Statically allocated in RAM (in Zephyr).
- Heap: Dynamically allocated memory (
malloc/free). Also lives in RAM.
The critical insight: RAM is almost always the bottleneck. .data, .bss, every thread stack, and your heap all compete for the same scarce RAM. Flash is usually more generous.
Measuring What You Have
You can’t optimize what you can’t measure. Two essential tools:
arm-none-eabi-size gives a quick overview:
$ arm-none-eabi-size build/zephyr/zephyr.elf
text data bss dec hex filename
94208 3072 12288 109568 1abc0 zephyr.elfHere, text + data is your Flash usage. data + bss is your baseline RAM usage (before accounting for stacks and heap).
Zephyr’s built-in reports break it down by module:
$ west build -t ram_reportThis produces a tree showing exactly which subsystem (kernel, drivers, your application code) is consuming RAM. The companion rom_report does the same for Flash. Run these before you change anything to establish a baseline.
The 5-Point Embedded Memory Optimization Checklist
These five techniques are ordered by impact and ease of implementation. Work through them in order.
1. Right-Size Your Thread Stack Allocations
Every thread in Zephyr gets a statically allocated stack. The default CONFIG_MAIN_STACK_SIZE is often 1024 or 2048 bytes, sometimes generous, sometimes not. If you’ve created additional threads, each one carries its own stack cost. Three threads with 2 KB stacks consume 6 KB of RAM before your application stores a single variable.
The fix is to profile actual usage. Enable Zephyr’s thread analyzer:
# prj.conf
CONFIG_THREAD_ANALYZER=y
CONFIG_THREAD_STACK_INFO=y
CONFIG_THREAD_ANALYZER_AUTO=y
CONFIG_THREAD_ANALYZER_AUTO_INTERVAL=5This prints periodic reports showing each thread’s stack usage versus its allocated size. You’ll often discover that a thread using 300 bytes at peak was allocated 2048.
Then trim:
CONFIG_MAIN_STACK_SIZE=512
# In your C code, size custom threads to match measured needs + margin/* Allocate 512 bytes instead of the default 1024 */
K_THREAD_STACK_DEFINE(sensor_stack, 512);A good rule of thumb: measure peak usage under worst-case conditions, then add a 20–30% safety margin. Trim aggressively, but never blindly.
2. Minimize Global and Static Variable Footprint
Every global or static variable lives in RAM for the entire lifetime of your program. The .bss section holds zero-initialized globals (costing RAM but no Flash). The .data section holds non-zero-initialized globals (costing both RAM and Flash).
The most common culprit is oversized buffers declared “just in case”:
/* BEFORE: 2 KB buffer "because we might need it" */
static uint8_t rx_buffer[2048];
/* AFTER: Sized to actual protocol max payload */
static uint8_t rx_buffer[256];That single change saves 1,792 bytes of RAM. On a 64 KB device, that’s nearly 3% of your total budget, reclaimed in one line.
Other quick wins:
- Scope variables locally instead of making them
staticor global. Local variables live on the stack only while the function executes, then the space is reused. - Use
constfor data that never changes. This moves it from RAM to Flash (covered in the next point). - Audit your
.bsswithram_report. Large unexpected entries often point to buffers you forgot about.
3. Use const and Compiler Placement Strategically
Every variable that could be const but isn’t silently costs you RAM. Lookup tables, calibration data, string constants, and configuration parameters are prime candidates.
/* Without const: 256 bytes in RAM (.data section) */
static uint8_t gamma_table[256] = { 0, 1, 1, 2, ... };
/* With const: 256 bytes in Flash (.rodata section), zero RAM cost */
static const uint8_t gamma_table[256] = { 0, 1, 1, 2, ... };On a project with several lookup tables, this technique alone can reclaim hundreds or thousands of bytes of RAM.
Compiler optimization level also matters. Ensure you’re building with size optimization:
# prj.conf — use -Os (optimize for size) instead of -O0 or -O2
CONFIG_SIZE_OPTIMIZATIONS=yThe difference between -O0 (no optimization, common during debugging) and -Os (optimize for size) can be 30–50% in code size. If your toolchain supports -Oz (even more aggressive size optimization), test whether it meets your performance needs. It often does on I/O-bound firmware.
4. Control Feature Creep via Kconfig
Zephyr’s modularity is a double-edged sword. Every subsystem you enable (logging, the shell, networking stacks, file systems) carries a memory cost that compounds fast. A full-featured logging subsystem with a 1 KB buffer, the interactive shell, and Bluetooth support can easily consume 30+ KB of RAM before you write a line of application code.
Audit aggressively:
$ west build -t menuconfigWalk through enabled features and ask: Does this need to be in the production build? Common savings:
# prj.conf — production-focused trimming
# Reduce logging buffer from default 1024 to 256, or disable entirely
CONFIG_LOG=n
# If you need logging, at minimum shrink the buffer:
# CONFIG_LOG_BUFFER_SIZE=256
# Disable the shell if it's not needed in deployment
CONFIG_SHELL=n
# Disable unused networking features
CONFIG_NET_UDP=n
CONFIG_NET_DHCPV4=nThe cheapest memory is the feature you never compile in. Make a habit of starting with a minimal prj.conf and adding only what you need, rather than starting from a full-featured sample and trying to trim later.
| Configuration | RAM Usage | Flash Usage |
|---|---|---|
| Sample app with logging + shell | 42.1 KB | 187.3 KB |
| Logging disabled, shell disabled | 28.4 KB | 112.7 KB |
| + Stack trimming + buffer reduction | 22.8 KB | 110.2 KB |
(Hypothetical values for an nRF52840 Bluetooth project. Your results will vary, but the magnitudes are representative.)
5. Avoid Dynamic Heap Allocation, or Manage It Deliberately
malloc() and free() are dangerous on MCUs. There’s no virtual memory system to save you from fragmentation. After hours of running, your heap can look like Swiss cheese: enough total free memory, but no single contiguous block large enough for your next allocation. The result is a crash that’s nearly impossible to reproduce during testing.
Zephyr offers deterministic alternatives. The most common is the memory slab, a pool of fixed-size blocks:
/* Define a slab: 10 blocks of 128 bytes each */
K_MEM_SLAB_DEFINE(my_slab, 128, 10, 4); /* block_size, num_blocks, align */
void send_message(void) {
void *block;
/* Allocate one 128-byte block, wait up to 100ms */
if (k_mem_slab_alloc(&my_slab, &block, K_MSEC(100)) == 0) {
/* Use the block */
memcpy(block, payload, payload_len);
process(block);
/* Free it back to the slab */
k_mem_slab_free(&my_slab, block);
}
}Because every block is the same size, fragmentation is impossible. Allocation and deallocation are O(1) and deterministic, which is critical for real-time systems.
If you must use dynamic allocation (some third-party libraries require it), limit it to one-time initialization that never frees. A malloc that runs once at boot and holds for the device’s entire lifetime doesn’t fragment.
Recognizing Memory Problems Before They Bite
Memory bugs are insidious because they often manifest as something else. Here’s what to watch for:
Stack overflow corrupts whatever sits adjacent to the stack in memory, often another thread’s stack or your .bss variables. Symptoms include random hard faults (a processor exception triggered by illegal memory access), variables mysteriously changing value, and crashes that move when you add or remove unrelated code. Enable Zephyr’s hardware stack protection:
CONFIG_HW_STACK_PROTECTION=yThis uses the Cortex-M MPU to trigger an immediate, identifiable fault the moment a stack overflows, instead of silently corrupting memory.
Heap fragmentation shows up as allocation failures after hours or days of runtime, even when total free memory appears sufficient. If your device works fine in testing but fails in the field, fragmentation is a prime suspect.
Linker overflow errors (region 'RAM' overflowed by N bytes) are actually the friendliest memory problem. They stop you at build time with a clear message. When you see one, run ram_report to identify the biggest consumers, then apply the checklist above.
Develop a diagnostic reflex: when something “weird” happens on an embedded system, suspect memory first.
Advanced Techniques Worth Exploring
Once you’ve internalized the checklist above, these topics will take your MCU memory management further:
- Memory Protection Units (MPU): Hardware-enforced boundaries between threads, catching illegal accesses at runtime rather than silently corrupting state.
- Link-Time Optimization (LTO): Lets the compiler eliminate dead code across translation units, not just within a single
.cfile. Can yield 5–15% Flash savings. - Execute-in-Place (XIP): Running code directly from external Flash, freeing internal Flash for data.
- Custom memory pools per subsystem: Isolating allocation domains so one subsystem’s leak or fragmentation can’t starve another.
- Read-only data compression: Compressing large datasets in Flash and decompressing on demand to save storage.
Each of these deserves its own deep dive. Consider them your next steps once the fundamentals are second nature.
Run ram_report on Your Project Today
Here’s your embedded memory optimization checklist in one glance:
- Right-size thread stacks — profile with Thread Analyzer, trim with margin.
- Shrink globals and statics — audit buffer sizes, scope variables locally.
- Use
constrelentlessly — move read-only data from RAM to Flash. - Disable unused Zephyr features — every Kconfig option has a cost.
- Replace
mallocwith memory slabs — deterministic, fragmentation-free.
Memory optimization is iterative. Measure, change, measure again. The single best thing you can do right now is open a terminal, navigate to your project, and run west build -t ram_report. Look at the numbers. Find the biggest consumer. Apply one item from this checklist. Then measure again.
That cycle, measure, understand, optimize, is the core skill that separates firmware that ships from firmware that crashes at 2 AM. Start today.
Hubble Network connects your constrained devices directly to satellite from a Bluetooth chip — no gateway infrastructure required. Learn how it works →