Memory Constraint Testing for Embedded Systems: Stack, Heap, and Fragmentation

Memory Constraint Testing for Embedded Systems: Stack, Heap, and Fragmentation

Memory bugs in embedded systems fail differently than in desktop software. There's no MMU to catch a buffer overflow. There's no operating system to clean up a leaked allocation. There's no swap space when you run out of RAM. The system just corrupts memory silently, hangs, or resets — often hours after the bug was triggered.

Systematic memory testing is one of the highest-value investments in embedded software quality.

The Embedded Memory Landscape

Embedded systems have multiple, separately managed memory regions:

Flash (program memory): where your code and constant data live. Read-only at runtime. Typically 64KB–16MB depending on the microcontroller.

RAM (data memory): where variables, stack, and heap live. Volatile, limited, shared between all threads. Typically 8KB–1MB.

Stack: grows downward from the top of RAM (or per-task in RTOS). Each function call consumes stack. Overflow corrupts adjacent memory.

Heap: grows upward from the bottom of RAM. Dynamic allocations via malloc/pvPortMalloc. Fragmentation reduces usable memory over time.

CCM/DTCM/ITCM (on some MCUs): tightly coupled memory for performance-critical code and data.

Each region has its own failure modes and requires different testing approaches.

Static Memory Analysis

Static analysis catches memory bugs before you run the code. Run it in CI on every commit.

PC-lint / FlexeLint

PC-lint is expensive but thorough. Key rules to enable for memory safety:

// lint.lnt configuration
+e413   // Likely use of NULL pointer
+e414   // Likely division by zero
+e415   // Access of out-of-bounds pointer
+e416   // Likely creation of out-of-bounds pointer
+e419   // Apparent data overrun for function
+e420   // Apparent access beyond array
+e424   // Inappropriate deallocation
+e429   // Custodial pointer not freed or returned
+e593   // Custodial pointer possibly not freed or returned
+e613   // Possible use of null pointer
+e668   // Possibly passing a null pointer to function

Cppcheck

Open source and good for embedded C:

cppcheck \
  --enable=all \
  --error-exitcode=1 \
  --suppress=missingInclude \
  --check-config \
  --platform=avr8 \
  src/

Key checks: nullPointer, uninitvar, memleak, memleakOnRealloc, bufferAccessOutOfBounds, arrayIndexOutOfBounds.

Size Reporting in CI

Track binary size across every build. A sudden size increase may indicate a memory regression (large constant array added, unused library linked):

# Add to Makefile
size-report:
    @echo "=== Memory Usage ==="
    @arm-none-eabi-size -A $(TARGET).elf
    @echo ""
    @arm-none-eabi-nm --size-sort --print-size $(TARGET).elf | tail -20

# CI integration
check-size:
    $(eval FLASH_USED := $(shell arm-none-eabi-size $(TARGET).elf | awk 'NR==2{print $$1+$$2}'))
    $(eval RAM_USED := $(shell arm-none-eabi-size $(TARGET).elf | awk 'NR==2{print $$2+$$3}'))
    @test $(FLASH_USED) -lt $(FLASH_BUDGET) || \
        (echo "FAIL: Flash $(FLASH_USED) bytes exceeds budget $(FLASH_BUDGET)"; exit 1)
    @test $(RAM_USED) -lt $(RAM_BUDGET) || \
        (echo "FAIL: RAM $(RAM_USED) bytes exceeds budget $(RAM_BUDGET)"; exit 1)

Stack Testing

Stack Painting

The most reliable way to measure worst-case stack usage: paint the stack with a known pattern before running and measure how deep the pattern has been overwritten.

// Paint stack with known pattern at startup
#define STACK_PAINT_VALUE  0xDEADBEEF
#define STACK_START        &_stack_start  // Linker symbol

void paint_stack(void) {
    uint32_t *p = (uint32_t *)STACK_START;
    uint32_t *stack_top = (uint32_t *)__get_MSP();
    
    while (p < stack_top) {
        *p++ = STACK_PAINT_VALUE;
    }
}

uint32_t measure_stack_usage(void) {
    uint32_t *p = (uint32_t *)STACK_START;
    
    // Find first unpainted word (first word written by real stack usage)
    while (*p == STACK_PAINT_VALUE) {
        p++;
    }
    
    return ((uint32_t)__get_MSP() - (uint32_t)STACK_START) - 
           ((uint32_t)p - (uint32_t)STACK_START);
}

Call paint_stack() at startup, exercise the system under worst-case conditions (maximum interrupt nesting, deepest call chains), then call measure_stack_usage() to see actual consumption.

In FreeRTOS, each task has its own stack, and uxTaskGetStackHighWaterMark() provides the high water mark using the same painting technique.

Stack Depth Analysis via Call Graph

Use the linker map and GCC's -fstack-usage flag to calculate theoretical worst-case stack depth:

gcc -fstack-usage -ffloat-store -c src/*.c

# Each .su file contains per-function stack usage:
# sensor.c:42:12:read_temperature	24	static
# sensor.c:98:12:process_data	96	static
# comms.c:156:8:transmit_packet	320	dynamic  # WARNING: dynamic = recursion or VLAs

The dynamic tag means stack usage can't be statically determined. Functions with dynamic stack usage need special attention — they may be recursive, use variable-length arrays, or call through function pointers.

Build a call graph and sum the static frame sizes along the deepest call chain to get worst-case stack depth:

def calculate_worst_case_stack(call_graph, stack_usages):
    """DFS to find maximum stack depth"""
    def dfs(node, current_depth, visited):
        if node in visited:
            return current_depth  # Recursion detected
        
        visited.add(node)
        frame_size = stack_usages.get(node, 0)
        current_depth += frame_size
        
        max_depth = current_depth
        for callee in call_graph.get(node, []):
            depth = dfs(callee, current_depth, visited.copy())
            max_depth = max(max_depth, depth)
        
        return max_depth
    
    return max(dfs(root, 0, set()) for root in call_graph)

Heap Testing

Heap Fragmentation Simulation

Dynamic allocation on embedded systems fragments the heap over time. A heap that has 10KB free may be unable to satisfy a 5KB allocation because the free space is fragmented into small chunks.

Test fragmentation under realistic usage patterns:

void test_heap_fragmentation(void) {
    const int NUM_ITERATIONS = 1000;
    const size_t ALLOC_SIZES[] = {32, 64, 128, 256, 512};
    void *allocs[50] = {0};
    int alloc_index = 0;
    int failures = 0;
    
    for (int i = 0; i < NUM_ITERATIONS; i++) {
        // Alternately allocate and free to create fragmentation
        if (alloc_index < 50 && (rand() % 2 == 0 || alloc_index == 0)) {
            size_t size = ALLOC_SIZES[rand() % 5];
            allocs[alloc_index] = pvPortMalloc(size);
            if (allocs[alloc_index] != NULL) {
                alloc_index++;
            } else {
                failures++;
            }
        } else if (alloc_index > 0) {
            int idx = rand() % alloc_index;
            vPortFree(allocs[idx]);
            allocs[idx] = allocs[--alloc_index];
            allocs[alloc_index] = NULL;
        }
    }
    
    // Clean up
    for (int i = 0; i < alloc_index; i++) {
        vPortFree(allocs[i]);
    }
    
    // Fragmentation failure rate should be < 1%
    float failure_rate = (float)failures / NUM_ITERATIONS;
    TEST_ASSERT_LESS_THAN_FLOAT(0.01f, failure_rate);
    
    printf("Heap fragmentation test: %d failures in %d iterations (%.1f%%)\n",
           failures, NUM_ITERATIONS, failure_rate * 100);
}

Memory Leak Detection

FreeRTOS tracks heap usage via xPortGetFreeHeapSize() and xPortGetMinimumEverFreeHeapSize(). Test for leaks by checking free heap after a complete operation cycle:

void test_no_memory_leak_in_request_handler(void) {
    size_t heap_before = xPortGetFreeHeapSize();
    
    // Run a complete request-response cycle that allocates and frees memory
    for (int i = 0; i < 100; i++) {
        http_request_t *req = create_request("/api/sensor/data");
        http_response_t *resp = process_request(req);
        free_response(resp);
        free_request(req);
    }
    
    size_t heap_after = xPortGetFreeHeapSize();
    
    // Allow small variance for internal fragmentation
    int leaked = (int)heap_before - (int)heap_after;
    
    TEST_ASSERT_LESS_OR_EQUAL_INT_MESSAGE(16, leaked,
        "Memory leak detected in request handler");
    
    if (leaked > 0) {
        printf("Warning: %d bytes potentially leaked over 100 cycles\n", leaked);
    }
}

Allocation Failure Injection

Test every code path that allocates memory by injecting allocation failures. Your code must handle NULL returns from malloc/pvPortMalloc correctly:

// Memory fault injection for testing
static int fail_after_n = -1;
static int allocation_count = 0;

void* test_malloc(size_t size) {
    if (fail_after_n >= 0 && allocation_count++ >= fail_after_n) {
        return NULL;  // Inject failure
    }
    return pvPortMalloc(size);
}

void test_message_creation_handles_oom(void) {
    // Fail after 0 allocations
    fail_after_n = 0;
    allocation_count = 0;
    
    message_t *msg = create_message("test payload");
    
    // Should handle NULL gracefully, not crash
    TEST_ASSERT_NULL_MESSAGE(msg, "create_message should return NULL on OOM");
    
    fail_after_n = -1;  // Disable injection
}

void test_all_allocation_failure_paths(void) {
    // Test failure at each successive allocation point
    for (int i = 0; i < 10; i++) {
        fail_after_n = i;
        allocation_count = 0;
        
        int result = complex_operation_that_allocates_multiple_times();
        
        // Any result is acceptable as long as no crash and memory is clean
        TEST_ASSERT_EQUAL_INT_MESSAGE(0, get_heap_corruption_flag(),
            "Heap corruption detected on allocation failure path");
        
        // Verify no leak
        TEST_ASSERT_EQUAL_UINT(EXPECTED_HEAP_SIZE, xPortGetFreeHeapSize());
    }
    
    fail_after_n = -1;
}

Buffer Overflow Detection

Stack Canaries

GCC supports stack canaries natively. Enable with -fstack-protector-all and implement the canary check handler:

// Weak symbol override for MCU environments
extern uintptr_t __stack_chk_guard;
uintptr_t __stack_chk_guard = 0xDEADBEEF;

void __stack_chk_fail(void) {
    // Log the overflow
    fault_log_write("STACK CANARY OVERFLOW DETECTED");
    
    // Enter safe state (disable motors, close valves, etc.)
    enter_safe_state();
    
    // Reset or halt
    NVIC_SystemReset();
}

Heap Guard Regions

Allocate guard regions around critical allocations and verify they're intact:

#define GUARD_SIZE    16
#define GUARD_PATTERN 0xCAFEBABE

typedef struct {
    uint32_t guard_pre[GUARD_SIZE / sizeof(uint32_t)];
    uint8_t data[];
    // guard_post follows data (can't declare in C — managed manually)
} guarded_allocation_t;

void* guarded_malloc(size_t size) {
    guarded_allocation_t *alloc = pvPortMalloc(
        sizeof(guarded_allocation_t) + size + GUARD_SIZE
    );
    if (!alloc) return NULL;
    
    // Fill pre-guard
    for (int i = 0; i < GUARD_SIZE / sizeof(uint32_t); i++) {
        alloc->guard_pre[i] = GUARD_PATTERN;
    }
    
    // Fill post-guard
    uint32_t *post_guard = (uint32_t *)(alloc->data + size);
    for (int i = 0; i < GUARD_SIZE / sizeof(uint32_t); i++) {
        post_guard[i] = GUARD_PATTERN;
    }
    
    return alloc->data;
}

bool check_guards(void *ptr) {
    guarded_allocation_t *alloc = (guarded_allocation_t *)(
        (uint8_t *)ptr - offsetof(guarded_allocation_t, data)
    );
    
    for (int i = 0; i < GUARD_SIZE / sizeof(uint32_t); i++) {
        if (alloc->guard_pre[i] != GUARD_PATTERN) return false;
    }
    return true;  // Post-guard check requires knowing allocation size
}

Automated Memory Regression Testing

Build a memory health check that runs after every system test:

typedef struct {
    size_t free_heap;
    size_t minimum_ever_free_heap;
    uint32_t task_stack_high_water_marks[MAX_TASKS];
    bool heap_corruption_detected;
} memory_health_report_t;

memory_health_report_t collect_memory_health(void) {
    memory_health_report_t report = {0};
    
    report.free_heap = xPortGetFreeHeapSize();
    report.minimum_ever_free_heap = xPortGetMinimumEverFreeHeapSize();
    report.heap_corruption_detected = check_heap_integrity();
    
    TaskStatus_t task_statuses[MAX_TASKS];
    UBaseType_t task_count = uxTaskGetSystemState(task_statuses, MAX_TASKS, NULL);
    
    for (int i = 0; i < task_count && i < MAX_TASKS; i++) {
        report.task_stack_high_water_marks[i] = 
            task_statuses[i].usStackHighWaterMark;
    }
    
    return report;
}

void assert_memory_health(memory_health_report_t *report, 
                          size_t min_free_heap,
                          uint32_t min_stack_headroom_words) {
    TEST_ASSERT_FALSE_MESSAGE(report->heap_corruption_detected,
        "Heap corruption detected");
    TEST_ASSERT_GREATER_THAN_MESSAGE(min_free_heap, report->free_heap,
        "Insufficient free heap");
    
    for (int i = 0; i < MAX_TASKS; i++) {
        if (report->task_stack_high_water_marks[i] == 0) break;
        TEST_ASSERT_GREATER_THAN_UINT32_MESSAGE(
            min_stack_headroom_words,
            report->task_stack_high_water_marks[i],
            "Insufficient stack headroom for task"
        );
    }
}

Add this to every integration test teardown. A test that leaves the system with 50 words of stack headroom or corrupted heap is failing, even if its functional assertions pass.


HelpMeTest monitors your embedded and IoT devices in production with continuous health checks — detecting when devices stop responding, reset unexpectedly, or report fault conditions that indicate memory failures in the field. Start free.

Read more

Start now free