Memory Management in C: Stack, Heap, Static and Global Memory
Memory management in C is the skill of knowing where every object lives, how long it lives, and who is responsible for it. This tutorial walks through the memory layout of a C program (code, .rodata, .data, .bss, heap and stack), compares stack vs heap vs static storage with compiled examples, shows the bugs that each region invites, and explains how the picture changes on a microcontroller. It is written for embedded and firmware engineers and for anyone preparing for interviews. The host-runnable examples were compiled with GCC (-std=c11 -Wall -Wextra -pedantic); the start-up code is marked as illustrative.
Topics Covered
- The memory layout of a C program
- Stack: automatic storage
- Static and global memory: .data, .bss and .rodata
- Heap: dynamic allocation with malloc and free
- Stack vs heap vs static: comparison table
- Common memory bugs and how to catch them
- Memory management on microcontrollers
- Interview questions on C memory
The Memory Layout of a C Program
The C standard does not talk about “the stack” or “the heap”. It talks about storage duration: static (the object lives for the whole program), automatic (it lives until its block ends), allocated (it lives from malloc to free) and thread storage duration. Real toolchains map those onto memory regions, and on most platforms you will see this picture, from low to high addresses:
| Region | Holds | Lifetime | Set up by |
|---|---|---|---|
.text | Machine code | Whole program | Loader / flash image |
.rodata | String literals, const tables | Whole program | Loader / flash image |
.data | Globals and statics with a non-zero initial value | Whole program | Copied from flash at start-up |
.bss | Globals and statics initialised to zero or not at all | Whole program | Zeroed at start-up |
| Heap | Blocks returned by malloc | Until free | The allocator, at run time |
| Stack | Locals, arguments, return addresses | Until the function returns | The compiler, at run time |
The names .text, .data and .bss come from object-file sections; the exact placement of the heap and stack is up to the operating system or your linker script. The common arrangement is that the heap grows towards higher addresses and the stack grows towards lower ones, but that is a convention of the platform, not a rule of C. The program below prints one address from each kind of storage. Run it on your machine; the numbers will differ from run to run on systems with address-space randomisation, but the grouping (code and read-only data together, .data and .bss next, heap and stack far away) is what to look for.
#include <stdio.h>
#include <stdlib.h>
int g_init = 42; /* initialised global -> .data */
int g_zero; /* zero-initialised -> .bss */
static int s_file = 7; /* file-scope static -> .data */
const int k_table[4] = {1, 2, 3, 4}; /* const data -> usually read-only */
int counter(void)
{
static int calls; /* static local: one object, lives for the whole program */
return ++calls;
}
int main(void)
{
int local = 1; /* automatic -> stack */
int *heap = malloc(sizeof *heap); /* dynamic -> heap */
if (heap == NULL) return 1;
printf("rodata (k_table) : %p\n", (const void *)k_table);
printf("data (g_init) : %p\n", (void *)&g_init);
printf("data (s_file) : %p\n", (void *)&s_file);
printf("bss (g_zero) : %p\n", (void *)&g_zero);
printf("heap (malloc) : %p\n", (void *)heap);
printf("stack (local) : %p\n", (void *)&local);
int c1 = counter();
int c2 = counter();
int c3 = counter();
printf("counter: %d %d %d\n", c1, c2, c3);
free(heap);
return 0;
}A note on the last line: the three calls to counter() are made in separate statements on purpose. If you write them as three arguments of one printf, the order in which function arguments are evaluated is unspecified, and a compiler is free to print 3 2 1. (That is exactly what GCC did when this example was first written.)
const table → .rodata, local → stack.Stack: Automatic Storage
Every function call gets a stack frame: space for its local variables, saved registers, and the return address. Allocation is just moving the stack pointer, and freeing is moving it back, which is why the stack is the fastest and the safest place for short-lived data. The price is that the lifetime is tied to the call. Once the function returns, those bytes are reused by the next call.
The classic bug is handing out a pointer to something that no longer exists:
#include <stdio.h>
int *bad(void)
{
int value = 123;
return &value; /* BUG: 'value' dies when bad() returns */
}
int *good(int *out)
{
*out = 123; /* caller owns the storage */
return out;
}
int main(void)
{
int mine;
printf("%d\n", *good(&mine));
(void)bad; /* bad() is shown only to illustrate the warning */
return 0;
}Compiled with gcc -std=c11 -Wall -Wextra -pedantic, GCC reports warning: function returns address of local variable [-Wreturn-local-addr]. Using that pointer afterwards is undefined behaviour: it may appear to work, print garbage, or crash, depending on what ran in between. The fix is to let the caller own the storage (as good() does), to use static storage, or to allocate dynamically and document who frees it.
How big can the stack get?
On a desktop OS the main thread’s stack is typically a few megabytes and the OS grows it on demand; on a microcontroller it is a fixed region, often a few kilobytes, and each RTOS task has its own. Deep recursion and large local arrays are the usual ways to overflow it. The example below makes the cost visible: each level holds a 256-byte buffer, so 100 levels need more than 25 KB of stack, more than many small MCUs have in total.
#include <stdio.h>
/* Each frame holds a 256-byte buffer, so depth costs real stack. */
static unsigned depth_reached;
static void recurse(unsigned depth)
{
volatile char buf[256];
buf[0] = (char)depth;
depth_reached = depth;
if (depth < 100) {
recurse(depth + 1);
}
buf[1] = buf[0]; /* keep the frame live */
}
int main(void)
{
recurse(1);
printf("reached depth %u (about %u bytes of frames)\n", depth_reached, depth_reached * 256u);
return 0;
}On the target, measure rather than guess. GCC’s -fstack-usage writes per-function stack usage to .su files, -Wstack-usage=N warns when a function exceeds a budget (see the GCC instrumentation options), and under FreeRTOS uxTaskGetStackHighWaterMark() reports the smallest amount of stack a task has ever had left. Paint-and-scan (fill the stack with a known pattern at boot, then look at how much of it was overwritten) works on any system.
Static and Global Memory: .data, .bss and .rodata
Objects with static storage duration (globals, file-scope static variables, and static locals) exist from program start to program end. A static local keeps its value between calls (the calls counter in the first listing), but its name is visible only inside the function. File-scope static limits the name to one translation unit (internal linkage). For more on these keywords see storage duration and linkage on cppreference.
Two rules explain most of the .data/.bss split:
- Static objects with no explicit initialiser are zero-initialised by the language. Because the value is known to be zero, the toolchain does not store it in the image; it only records the size, and start-up code clears the region. That is
.bss, and it costs RAM but no flash. - Static objects with a non-zero initialiser need their initial value stored somewhere. In a hosted program the loader reads it from the executable; on a microcontroller the initial values sit in flash and are copied to RAM before
main(). That is.data, and it costs both flash and RAM.
This is the start-up work a typical bare-metal Reset_Handler performs. Symbol names come from the linker script and differ between toolchains, so treat this as an illustration:
#include <stdint.h>
/* Illustrative start-up code: symbol names come from your linker script
and differ between toolchains. Not host-compiled. */
extern uint32_t _sidata; /* load address of .data in flash */
extern uint32_t _sdata; /* start of .data in RAM */
extern uint32_t _edata; /* end of .data in RAM */
extern uint32_t _sbss; /* start of .bss in RAM */
extern uint32_t _ebss; /* end of .bss in RAM */
extern int main(void);
void Reset_Handler(void)
{
uint32_t *src = &_sidata;
for (uint32_t *dst = &_sdata; dst < &_edata; ) {
*dst++ = *src++; /* copy initial values from flash to RAM */
}
for (uint32_t *dst = &_sbss; dst < &_ebss; ) {
*dst++ = 0; /* zero .bss */
}
main();
for (;;) { }
}Two practical consequences. First, a large initialised array that never changes should be const, so the linker can keep it in flash (.rodata) and you do not pay RAM for a copy. Second, “my global is not initialised” is a non-bug in C: it is zero. If a global holds garbage on a microcontroller, suspect a linker script or start-up problem, or a section the start-up code never clears.
String literals deserve their own warning. char *s = "abc"; points to storage that must not be modified (doing so is undefined behaviour; on many systems it faults because the data is in read-only memory), whereas char s[] = "abc"; creates a modifiable array initialised from the literal. Prefer const char * for the pointer form.
size (or arm-none-eabi-size) output after every build: the text, data and bss columns tell you flash use (text + data) and static RAM use (data + bss) at a glance. The map file shows who is responsible for each byte.Heap: Dynamic Allocation with malloc and free
The heap is for objects whose size or lifetime is not known at compile time. malloc returns a block aligned for any object type, or NULL on failure; free returns it. The rules are short, and nearly every heap bug is a violation of one of them:
- Check the result of
malloc. On a microcontroller, running out of heap is a normal event, not an exotic one. - Every block is freed exactly once. Freeing twice, or freeing a pointer that did not come from
malloc, is undefined behaviour.free(NULL)is allowed and does nothing (seefree). - Do not touch a block after freeing it.
- Do not use
p = realloc(p, n)directly: ifreallocfails it returnsNULLand the original block is still allocated, so you have just leaked it.
A pattern that makes ownership explicit:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Correct ownership pattern: allocate, check, use, free, clear the pointer. */
char *copy_name(const char *src)
{
size_t n = strlen(src) + 1;
char *dst = malloc(n);
if (dst == NULL) {
return NULL; /* caller must handle failure */
}
memcpy(dst, src, n);
return dst;
}
int main(void)
{
char *name = copy_name("sensor-1");
if (name == NULL) return 1;
printf("%s\n", name);
free(name);
name = NULL; /* a second free(NULL) is harmless; a use now crashes loudly */
return 0;
}Setting the pointer to NULL after free does not fix a design with two owners, but it turns a silent use-after-free into an immediate, debuggable null dereference.
Fragmentation: the heap problem that testing hides
A general-purpose allocator carves blocks out of one region. After thousands of allocations and frees of different sizes, the free space can be broken into small pieces: plenty of total free bytes, but no single block big enough for the next request. Desktop programs rarely notice; a device that must run for months without a reboot can fail in the field after a pattern of allocations that never appeared on the bench. That is why embedded teams often restrict dynamic allocation: allocate everything once at start-up, use fixed-size pools, or avoid the heap entirely. Coding standards such as MISRA C:2012 go as far as prohibiting the <stdlib.h> allocation functions (Rule 21.3), which is a common requirement in safety-related projects.
The standard alternative is a fixed-block pool. All blocks have the same size, so there is no fragmentation, allocation and release are O(1), and failure is deterministic. This version keeps free blocks in a linked list threaded through the free blocks themselves, using a union so no extra memory is needed:
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#define BLOCK_SIZE 32u
#define BLOCK_COUNT 8u
typedef union block {
union block *next; /* valid while the block is free */
_Alignas(max_align_t) uint8_t data[BLOCK_SIZE]; /* valid while it is allocated */
} block_t;
static block_t pool[BLOCK_COUNT];
static block_t *free_list;
void pool_init(void)
{
free_list = NULL;
for (size_t i = 0; i < BLOCK_COUNT; i++) {
pool[i].next = free_list;
free_list = &pool[i];
}
}
void *pool_alloc(void)
{
block_t *b = free_list;
if (b != NULL) {
free_list = b->next; /* O(1), no fragmentation */
}
return b;
}
void pool_free(void *p)
{
block_t *b = p;
if (b == NULL) return;
b->next = free_list;
free_list = b;
}
int main(void)
{
pool_init();
void *blocks[BLOCK_COUNT + 1];
size_t got = 0;
for (size_t i = 0; i < BLOCK_COUNT + 1; i++) {
blocks[i] = pool_alloc();
if (blocks[i] != NULL) got++;
}
printf("allocated %zu of %u requested\n", got, BLOCK_COUNT + 1u);
pool_free(blocks[0]);
printf("after free, alloc %s\n", pool_alloc() != NULL ? "succeeds" : "fails");
return 0;
}Compiled with -std=c11 -Wall -Wextra -pedantic, the program prints allocated 8 of 9 requested and then shows that a freed block can be allocated again. Note that this sketch is not thread-safe: if an ISR and a task can both call pool_alloc, protect the list with a critical section.
Stack vs Heap vs Static: Comparison
| Stack | Heap | Static / global | |
|---|---|---|---|
| Lifetime | Until the function returns | Until free | Whole program |
| Allocation cost | Adjust stack pointer | Allocator search, can vary | None at run time |
| Size known at | Compile time (VLAs aside) | Run time | Compile time |
| Typical failure | Stack overflow | NULL, leak, fragmentation, double free | Image does not fit in RAM; shared-state bugs |
| Reentrancy | Each call has its own copy | Depends on who owns the block | One shared copy: needs care with ISRs and threads |
| Best for | Small, short-lived data | Variable-size data when the budget allows | Buffers and state with a fixed, known size |
For firmware, the usual preference order is: automatic storage for small, short-lived data; static storage for buffers whose size is known; and the heap only where the lifetime or size truly cannot be known in advance, with a measured upper bound.
Common Memory Bugs and How to Catch Them
| Bug | Symptom | How to find it |
|---|---|---|
| Returning a pointer to a local | Garbage values, intermittent crashes | -Wall warning; static analysis |
| Buffer overflow (stack, heap or global) | Corrupted neighbouring data | -fsanitize=address on the host, bounds checks, code review of every copy |
| Use after free / double free | Crashes inside the allocator or later | AddressSanitizer, Valgrind on the host; null the pointer after freeing |
| Memory leak | Heap shrinks over time; eventual NULL | Track allocations in a debug build; Valgrind or LeakSanitizer on the host |
| Uninitialised automatic variable | Different behaviour per build or per run | -Wall -Wextra, -Wmaybe-uninitialized, MemorySanitizer or Valgrind |
| Stack overflow | Random corruption, hard faults | High-water-mark checks, -fstack-usage, guard regions or an MPU |
Compile as much logic as you can on the host with -Wall -Wextra and AddressSanitizer/UBSan enabled. Hardware-independent code, such as protocol parsers, ring buffers and the pool above, can be tested there at no risk to hardware. Detailed behaviour of the sanitizers is in the GCC documentation linked earlier.
Memory Management on Microcontrollers
On a Cortex-M part, the layout is decided by the linker script. Flash holds .text, .rodata and the initial copy of .data; RAM holds .data, .bss, the heap (if any) and the stack. The first word of the vector table is the initial main stack pointer, which the hardware loads at reset, and the reset handler is where the copy-and-zero loop shown earlier runs. Three habits go a long way:
- Budget up front. Write down flash and RAM per module, check the numbers from
sizeand the map file on every build, and fail the build if a budget is exceeded. - Prefer static allocation. FreeRTOS, for example, offers
xTaskCreateStaticand similar calls that take caller-supplied buffers, so tasks, queues and semaphores can exist without any heap. If you do use its heap, read the FreeRTOS memory management documentation to choose between the provided schemes: some never free, one coalesces adjacent free blocks. - Protect the stack. Size it from measurements, keep a margin, check the high-water mark in testing, and use the MPU or a canary where the part allows.
Memory also interacts with volatile and hardware registers: peripheral registers are not ordinary RAM, and the compiler must be told so. That topic is covered in why volatile and const are used in device drivers and in memory-mapped vs port-mapped I/O.
Interview Questions on C Memory
These are representative practice questions for C memory topics, not a claim about what any specific company asks. For broader sets, see the 50 must-know embedded C interview questions and the advanced scenarios for experienced engineers.
malloced memory stored?Locals live on the stack (or in registers), globals in .data or .bss depending on their initialiser, and malloc blocks on the heap. Say that this describes the usual implementation, and that the standard talks about storage duration.
.data holds static objects with non-zero initial values, whose initial image is stored in flash and copied to RAM at start-up; .bss holds zero-initialised static objects, which only need to be cleared. .bss therefore costs RAM but no flash.
Undefined behaviour. The object’s lifetime ended when the function returned, so the pointer is dangling. GCC warns with -Wreturn-local-addr.
Allocation time is not bounded, the heap can fragment over long uptimes, failure must be handled everywhere, and the allocator may not be safe to call from an ISR. Static allocation and fixed-block pools avoid all four.
static mean on a local variable, and on a global?A static local has static storage duration but block scope, so it keeps its value across calls. A static global (file scope) keeps static duration but has internal linkage, so other files cannot refer to it by name.
Measure it: stack-usage reports from the compiler for a static estimate, then a high-water mark or pattern fill under worst-case load, and add a margin. Do not rely on a guess.
Turn this into interview-ready answers
Practise embedded C, RTOS and Linux questions with guided feedback in the interview prep bootcamp.
Explore the Interview Prep Bootcamp →Frequently Asked Questions
Q: What is the difference between stack and heap in C?
The stack holds automatic variables and call frames and is managed by the compiler: memory is released automatically when the function returns. The heap holds blocks you request with malloc and release with free, so you control the lifetime and are responsible for errors such as leaks and double frees.
Q: Is the heap always used when I call malloc?
malloc requests memory from the C library’s allocator, which usually manages a heap region. On bare-metal targets that region is defined by the linker script and may be tiny or absent, in which case malloc returns NULL.
Q: Are uninitialised global variables really zero?
Yes. In C, objects with static storage duration that are not explicitly initialised are zero-initialised. Uninitialised automatic (local) variables, on the other hand, have indeterminate values.
Q: Does the stack always grow downwards?
On the common architectures (x86, Arm Cortex-M and most others) it does, but the C standard does not require it. Do not write code that depends on stack direction.
Q: Where should I read more about C pointers and memory?
Pointers and memory are tightly linked; start with the C pointers interview questions and the cppreference page on objects and alignment.
