Memory in Robotics: RAM, Flash, and EEPROM Explained

Microcontrollers in robotics use three distinct types of memory, each serving a different purpose: Flash memory (program storage, non-volatile, holds your code permanently even without power—32KB on an Arduino Uno), SRAM (working memory, volatile, holds variables and the call stack while running—only 2KB on an Arduino Uno), and EEPROM (long-term data storage, non-volatile, survives power cycles and stores calibration or settings—1KB on an Arduino Uno). Running out of SRAM—the smallest and most critical of the three—is the most common memory-related failure in robot code, producing symptoms as unpredictable as random resets, corrupted sensor readings, and programs that run differently with every power cycle.

Introduction

“It works on the bench but not in the real robot.” This is a sentence that many robotics builders have said in frustration — and one of the most common causes has nothing to do with wiring, sensors, or motors. The program is running out of memory. Specifically, it’s running out of SRAM — the tiny working memory where variables, strings, and function calls live while the robot operates.

Memory in microcontrollers is not like memory in computers. A desktop computer has gigabytes of RAM and a vast virtual memory system that makes memory management nearly invisible. An Arduino Uno has 2,048 bytes — two kilobytes — of working memory. A single reasonably long string can exhaust it. A few global arrays can fill it entirely before a single line of loop() code runs.

Understanding the three types of memory in a microcontroller — what each stores, how much is available, how to check current usage, and what to do when you’re running out — is foundational knowledge for writing reliable robot code. The problems caused by memory exhaustion are some of the hardest to diagnose without this knowledge, because they manifest as strange, intermittent, seemingly unrelated failures rather than obvious error messages.

This article gives you the complete picture, from the physics of each memory type through the practical optimization techniques that keep even complex robots within their memory budgets.

The Three Memory Types: An Overview

Every microcontroller uses memory with fundamentally different physical characteristics for each purpose. These characteristics — not arbitrary design choices — determine what each memory type is used for.

Flash Memory: Where Your Program Lives

Flash memory is non-volatile electrically erasable programmable read-only memory (EEPROM by technology, but distinguished from what engineers call “EEPROM” in microcontrollers by its organization and speed). Its defining property: data persists when power is removed. Flash can be read rapidly but written only in blocks and with a limited number of write cycles.

In a microcontroller, Flash stores the compiled program — the machine code that the CPU fetches and executes. When you upload a sketch to an Arduino, the Arduino IDE compiles your C++ code into AVR machine code and writes it into the ATmega328P’s Flash. The CPU then reads instructions from Flash one by one, executes them, and progresses through your program.

Flash memory properties (ATmega328P):
  Capacity: 32,768 bytes (32KB)
  Volatile: NO — contents survive power removal indefinitely
  Read speed: 1 clock cycle per instruction (16 million reads/second)
  Write speed: slow (page-level writes, 3–5ms per page)
  Write cycles: ~10,000 before wear begins (not a concern in normal use;
                you'd need to upload new code 10,000 times)
  What's stored: your compiled sketch, library code, constant strings
                 marked with PROGMEM, bootloader (Arduino uses ~512 bytes)

How full is your Flash? The Arduino IDE reports Flash usage after every compilation:

Sketch uses 4,892 bytes (15%) of program storage space.
Maximum is 32,256 bytes.

(The 32,256 rather than 32,768 is because 512 bytes are reserved for the bootloader.)

Flash is almost never the limiting factor in robotics — 32KB holds a great deal of compiled code. Complex multi-sensor navigation programs with multiple library inclusions typically use 10–20KB. Running out of Flash is unusual and usually signals excessive library inclusion or redundant code.

SRAM: The Working Memory That Matters Most

SRAM (Static Random-Access Memory) is the microcontroller’s working memory — the space where variables exist while the program runs. Unlike Flash, SRAM is volatile: everything stored in it disappears instantly when power is removed. This is the memory where every variable you declare, every array you create, every string you build, and every function call you make lives during program execution.

SRAM memory properties (ATmega328P):
  Capacity: 2,048 bytes (2KB)
  Volatile: YES — contents lost on power removal
  Read/Write speed: 1 clock cycle (same as CPU speed)
  Write cycles: unlimited (no wear mechanism)
  What's stored:
    - Global and static variables (allocated at startup, fixed size)
    - Local variables (allocated on the stack when function is called)
    - Function call stack (return addresses, saved registers)
    - Heap (dynamically allocated memory, if any — avoid in embedded)

The SRAM layout at runtime:

SRAM address space (ATmega328P, 2048 bytes total):

0x0100 ──── Start of SRAM
            │
            │  Global and static variables
            │  (allocated at compile time, fixed addresses)
            │  Size: determined by sum of all global/static variable declarations
            │
            ├── End of .data and .bss sections
            │
            │  Heap (dynamic allocation via malloc/new)
            │  Grows UPWARD from here
            │  (Best practice: avoid on microcontrollers — use static allocation)
            │
            │  FREE SPACE (the gap between heap top and stack bottom)
            │  This is what's "available" — too small and the program crashes
            │
            │  Stack (local variables, return addresses, saved registers)
            │  Grows DOWNWARD from top of SRAM
            │  Each function call pushes a stack frame; return pops it
            │
0x08FF ──── Top of SRAM (address 0x0100 + 2048 - 1)

When the heap grows up and the stack grows down until they meet — stack overflow — the result is undefined behavior. The program may read garbage values, corrupt variables, execute random code addresses, or reset. This is the failure mode that produces the mysterious, intermittent bugs that plagued programs that “worked on the bench.”

EEPROM: Long-Term Data Across Power Cycles

EEPROM (Electrically Erasable Programmable Read-Only Memory) in the microcontroller context refers specifically to a small area of byte-addressable non-volatile storage — slower to write than SRAM but persistent across power cycles. Its role: storing data that needs to survive power-off but isn’t part of the program.

EEPROM properties (ATmega328P):
  Capacity: 1,024 bytes (1KB)
  Volatile: NO — data persists through power cycles
  Read speed: variable (3–4ms typical read on Arduino)
  Write speed: ~3.4ms per byte — SLOW
  Write cycles: ~100,000 per byte before wear begins
                (realistic concern — 100 writes/day = 3 years until wear)
  What's stored:
    - Calibration data (sensor zero offsets, scale factors)
    - Configuration settings (operating mode, tuning parameters)
    - Odometry / position data to resume from on restart
    - User preferences
    - Serial number, device ID, firmware version

The 100,000 write cycle limit is a practical concern for EEPROM. Writing to the same EEPROM address every time through loop() (running at 100Hz) would exhaust that byte in 100,000 / (100 × 3600 × 24) = 0.01 days — less than 15 minutes. EEPROM must be written infrequently — only on meaningful state changes, not continuously.

Reading Memory Usage: The Arduino IDE Report

The Arduino IDE provides memory usage information after compilation. Understanding both lines is important:

Sketch uses 6,214 bytes (19%) of program storage space. Maximum is 32,256 bytes.
Global variables use 412 bytes (20%) of dynamic memory, leaving 1,636 bytes for local variables. Maximum is 2,048 bytes.

Line 1 (Flash): 6,214 bytes of your 32,256-byte Flash budget is used by compiled code. Comfortable — 80% remains.

Line 2 (SRAM): This is the critical line. Global variables (all variables declared outside functions, plus static variables inside functions) permanently occupy 412 bytes of SRAM. This leaves 1,636 bytes for the stack and any dynamic allocation. “For local variables” means for everything that happens at runtime — function call stacks, local variables within functions, and any String objects or other heap allocations.

The danger: This report only tells you about global variables at compile time. It cannot tell you:

  • How deep the stack grows during worst-case function nesting
  • How much the String class (if used) allocates on the heap at runtime
  • Whether your stack and heap will collide during execution

The “1,636 bytes remaining” figure is a starting budget, not a guarantee. Complex function call chains with large local arrays can consume hundreds of bytes of stack during execution.

Checking Actual Free Memory at Runtime

The only way to know how much SRAM is truly available during execution is to measure it at runtime, especially during the most stack-intensive operations:

// Free SRAM measurement — works on AVR-based Arduino boards
// Call this function at any point to see available memory

int freeMemory() {
  extern int __heap_start, *__brkval;
  int v;
  // Stack pointer minus heap top = free space between them
  return (int)&v - (__brkval == 0
                    ? (int)&__heap_start
                    : (int)__brkval);
}

void setup() {
  Serial.begin(9600);
  Serial.print("Free SRAM at startup: ");
  Serial.print(freeMemory());
  Serial.println(" bytes");
}

void loop() {
  // Call at the deepest point of your code to see minimum free memory
  doComplexOperation();  // Includes deep function nesting, large local arrays
  
  Serial.print("Free SRAM during operation: ");
  Serial.println(freeMemory());
  
  delay(1000);
}

Run this measurement at startup (to see global variable overhead), then inside the most complex operations in your code (to catch maximum stack usage). The minimum value seen across all measurements is your worst-case free memory. As a safety margin, you want at least 100–200 bytes of free SRAM even at worst-case to avoid stack overflow from interrupt service routines (which push additional frames onto the stack when they fire).

Healthy free SRAM during operation:
  > 500 bytes remaining: comfortable, no immediate concern
  200–500 bytes:         manageable, worth optimizing if code grows
  100–200 bytes:         danger zone — ISRs and String operations may overflow
  < 100 bytes:           critical — optimize immediately, stack overflow imminent
  < 0 (negative):        stack overflow has already occurred — symptoms: random crashes,
                         corrupted variables, unexpected resets

The String Problem: SRAM’s Biggest Trap

The Arduino String class (capital S) is a heap-allocated dynamic string object — convenient but dangerous on constrained memory platforms. Every time you create a String, the Arduino library requests heap memory. As Strings are created and destroyed, the heap becomes fragmented — small free blocks interspersed with allocated blocks that can’t be merged — until eventually a new String allocation fails even though there’s technically enough total free memory.

// PROBLEMATIC: String class on low-SRAM platforms
void loop() {
  String message = "Sensor reading: ";    // Heap allocation #1
  message += String(analogRead(A0));      // Heap allocation #2 (temporary)
  message += " mV";                       // Heap allocation #3 (temporary)
  Serial.println(message);               // Uses the String
  // Destructor frees the Strings... but heap may be fragmented
}
// After 100+ iterations: heap fragment crash — seemingly random reset

// SAFE alternative: use char arrays and sprintf/snprintf
void loop() {
  char buffer[32];  // Fixed-size array — lives on the stack, no heap
  int reading = analogRead(A0);
  snprintf(buffer, sizeof(buffer), "Sensor reading: %d mV", reading);
  Serial.println(buffer);
  // No heap allocation, no fragmentation, predictable memory use
}

The rule on Arduino Uno/Nano/Mega: avoid the String class entirely. Use char arrays and C string functions (snprintf, strcmp, strlen, strcpy) instead. They are slightly less convenient but completely predictable in memory use and will never cause heap fragmentation crashes.

// String class danger demonstrated:

void badExample() {
  String s1 = "Hello";          // heap: allocates 6 bytes
  String s2 = "World";          // heap: allocates 6 bytes
  String s3 = s1 + " " + s2;   // heap: allocates 12 bytes + temporaries
  Serial.println(s3);
  // s1, s2, s3 freed — but heap now has 3 separate freed blocks
  // Next large String may fail even though total free > required
}

void goodExample() {
  char s1[] = "Hello";          // stack: 6 bytes, automatically freed
  char s2[] = "World";          // stack: 6 bytes
  char s3[32];                  // stack: 32 bytes, fixed
  snprintf(s3, sizeof(s3), "%s %s", s1, s2);
  Serial.println(s3);
  // All freed automatically when function returns — no fragmentation
}

Flash Memory Optimization: The F() Macro

Constant strings — error messages, status labels, menu text — are stored in both Flash and SRAM by default. The compiler places them in Flash as data, then copies them to SRAM at startup so the CPU can access them (because on AVR, the CPU can only read data from SRAM, not directly from Flash). This “double storage” wastes precious SRAM for data that never changes.

The F() macro solves this by instructing the compiler to leave the string in Flash and use special AVR instructions (lpm, load from program memory) to read it at runtime, never copying it to SRAM:

// WITHOUT F() macro — string stored in BOTH Flash AND SRAM:
Serial.println("Motor controller initialized");
// This string occupies 31 bytes of SRAM permanently

// WITH F() macro — string stays in Flash only, never touches SRAM:
Serial.println(F("Motor controller initialized"));
// This string occupies 0 bytes of SRAM

// Dramatic impact on a program with many string literals:
void setup() {
  Serial.begin(9600);
  Serial.println(F("Robot initializing..."));
  Serial.println(F("Checking sensors..."));
  Serial.println(F("IMU: initializing"));
  Serial.println(F("Ultrasonic: ready"));
  Serial.println(F("Motor drivers: enabled"));
  Serial.println(F("Initialization complete"));
  // Without F(): these 6 strings consume ~120 bytes of SRAM permanently
  // With F():    these 6 strings consume 0 bytes of SRAM
}

For programs with many status messages, error strings, and diagnostic output, the F() macro can recover hundreds of bytes of SRAM. Always use it for string literals passed to Serial.print(), Serial.println(), and similar output functions.

PROGMEM for Lookup Tables

Large constant arrays — lookup tables, sine tables, coordinate maps — waste SRAM when stored as regular arrays. The PROGMEM attribute keeps them in Flash and provides pgm_read_* macros to read individual values:

#include <avr/pgmspace.h>

// Sine lookup table — 256 values for fast trigonometry
// Without PROGMEM: 256 bytes × 2 (int) = 512 bytes of SRAM consumed permanently
// With PROGMEM:    0 bytes of SRAM, stays in Flash

const int PROGMEM sinTable[256] = {
  0, 804, 1608, 2410, 3212, 4011, 4808, 5602,
  // ... 248 more values ...
  -804, -402, -201, -100
};

int getSineValue(int index) {
  // Must use pgm_read_word to read from Flash (not regular array access)
  return (int)pgm_read_word(&sinTable[index & 0xFF]);
}

// Usage:
int angle = 45;  // 0-255 maps to 0-360 degrees
int sinVal = getSineValue(angle);  // Reads from Flash, no SRAM cost

For robotics applications using trigonometric lookup tables (fast path calculations, encoder angle interpolation, motor commutation tables), PROGMEM saves hundreds of bytes of SRAM.

EEPROM in Practice: Saving Robot State

EEPROM’s most valuable robotics application is storing calibration data and configuration that would otherwise need to be re-entered or re-calculated every time the robot powers on.

Reading and Writing EEPROM on Arduino

#include <EEPROM.h>

// ── Storing a calibration value ─────────────────────────────────
// Robot measures its own sensor zero offset at startup calibration
// and saves it to EEPROM so it's remembered between power cycles

const int EEPROM_ADDR_GYRO_OFFSET = 0;  // EEPROM address for gyro offset
const int EEPROM_ADDR_MAGIC = 4;         // Magic number to detect fresh EEPROM
const long MAGIC_VALUE = 0xDEADBEEF;    // Sentinel: if not here, EEPROM is blank

void saveGyroCalibration(float offset) {
  EEPROM.put(EEPROM_ADDR_GYRO_OFFSET, offset);  // Writes 4 bytes (float)
  EEPROM.put(EEPROM_ADDR_MAGIC, MAGIC_VALUE);   // Mark as valid
  Serial.println(F("Calibration saved to EEPROM"));
}

float loadGyroCalibration() {
  long magic;
  EEPROM.get(EEPROM_ADDR_MAGIC, magic);
  
  if (magic != MAGIC_VALUE) {
    Serial.println(F("EEPROM: no calibration found, using default 0.0"));
    return 0.0;  // Fresh EEPROM — return safe default
  }
  
  float offset;
  EEPROM.get(EEPROM_ADDR_GYRO_OFFSET, offset);
  Serial.print(F("Loaded gyro offset from EEPROM: "));
  Serial.println(offset);
  return offset;
}

void setup() {
  Serial.begin(9600);
  float gyroOffset = loadGyroCalibration();
  // Apply calibration offset to sensor readings...
}

EEPROM Wear Leveling

Writing the same EEPROM address repeatedly wears it out. For data that changes frequently (odometer readings, session counters), wear leveling spreads writes across multiple addresses to distribute the wear:

// Simple wear leveling for a frequently-updated counter
// Writes cycle through 10 EEPROM locations to spread wear
// Each location lasts 100,000 writes → 10 locations = 1,000,000 total writes

const int WL_BASE_ADDR = 10;    // Starting EEPROM address for wear leveling
const int WL_COUNT    = 10;     // Number of locations to cycle through
const int WL_SLOT_SIZE = 5;     // Bytes per slot (4 bytes data + 1 byte index)

void writeWithWearLevel(uint32_t value) {
  // Find current active slot (the one with the highest sequence number)
  uint8_t maxSeq = 0;
  int activeSlot = 0;
  
  for (int i = 0; i < WL_COUNT; i++) {
    int addr = WL_BASE_ADDR + i * WL_SLOT_SIZE;
    uint8_t seq = EEPROM.read(addr + 4);  // Sequence byte is at offset 4
    if (seq > maxSeq || i == 0) {
      maxSeq = seq;
      activeSlot = i;
    }
  }
  
  // Write to next slot
  int nextSlot = (activeSlot + 1) % WL_COUNT;
  int addr = WL_BASE_ADDR + nextSlot * WL_SLOT_SIZE;
  EEPROM.put(addr, value);
  EEPROM.write(addr + 4, (uint8_t)(maxSeq + 1));  // Increment sequence
}

For most robotics applications — saving calibration values once after a calibration routine, updating a configuration setting occasionally — wear leveling is unnecessary. It becomes relevant for data logged continuously (position, distance traveled, runtime hours) where hundreds of writes per session could exhaust EEPROM over months.

Memory on Different Robotics Platforms

The severe constraints of the Arduino Uno are not universal. Understanding memory availability across common platforms helps set appropriate expectations:

Platform Memory Comparison:

Platform            Flash       SRAM        EEPROM/Storage    Notes
──────────────────────────────────────────────────────────────────────────────
ATtiny85            8 KB        512 B       512 B             Tiny — for simple tasks only
Arduino Nano        32 KB       2 KB        1 KB              Same as Uno
Arduino Uno         32 KB       2 KB        1 KB              Standard beginner board
Arduino Mega        256 KB      8 KB        4 KB              4× Uno SRAM — significant relief
Arduino Due         512 KB      96 KB       —                 ARM Cortex-M3; no AVR EEPROM
RP2040 (Pico)       2 MB (ext)  264 KB      —                 264KB SRAM is transformative
ESP8266             4 MB (ext)  80 KB       512 B (emulated)  WiFi MCU; EEPROM is Flash-emulated
ESP32               4–16 MB     520 KB      —                 Heap-based; no dedicated EEPROM
                    (ext Flash) (+PSRAM opt)                   Large SRAM — String class OK here
STM32F103           64–128 KB   20 KB       —                 Popular in motor controllers
STM32F4             1 MB        192 KB      —                 Powerful ARM M4 with FPU
Teensy 4.1          8 MB (ext)  1 MB        —                 1MB SRAM is laptop-class for MCU
Raspberry Pi 4      SD card     2–8 GB      —                 Full Linux; memory mostly unlimited
Jetson Nano         SD card     4 GB        —                 Full Linux + GPU for AI

The jump from 2KB (Arduino Uno) to 264KB (RP2040 Pico) is transformative — problems that required careful memory optimization on the Uno become trivial on the Pico. The jump from any microcontroller to the Raspberry Pi’s gigabytes of RAM is qualitative — an entirely different class of application becomes possible.

For robotics builders repeatedly fighting SRAM on an Arduino Uno, upgrading to an Arduino Mega (8KB SRAM) or RP2040 (264KB SRAM) is often the right solution — especially when the alternative is complex, difficult-to-maintain memory optimization of code that would be straightforward with more memory.

Practical Memory Optimization Checklist

When free SRAM is running low and optimization is needed before upgrading hardware:

Global Variable Audit

// Audit: print size of every major global variable
void printMemoryUsage() {
  Serial.print(F("sensorBuffer: ")); Serial.println(sizeof(sensorBuffer));
  Serial.print(F("pidState: "));     Serial.println(sizeof(pidState));
  Serial.print(F("mapGrid: "));      Serial.println(sizeof(mapGrid));
  // Look for: large arrays, structures with padding, redundant variables
}

Identify the largest consumers. A 100-element float array uses 400 bytes — 20% of the Uno’s SRAM. Can it be 50 elements? Can floats become ints (4 bytes → 2 bytes each)?

Data Type Downsizing

// Before optimization:
float distances[20];        // 20 × 4 bytes = 80 bytes
int  readings[50];          // 50 × 2 bytes = 100 bytes
bool flags[16];             // 16 × 1 byte = 16 bytes (bool is 1 byte in AVR)

// After optimization:
uint16_t distances[20];     // 20 × 2 bytes = 40 bytes (if max distance < 65535mm, fine)
int8_t   readings[50];      // 50 × 1 byte = 50 bytes (if values fit in -128 to 127)
uint16_t flags;             // 1 × 2 bytes = 2 bytes (16 flags as individual bits!)
// Bit access: flags |= (1 << FLAG_X);  // Set flag X
//             flags & (1 << FLAG_X)    // Test flag X

Rule: Use the smallest data type that can hold the required range. An encoder count that never exceeds 32,767 can be int16_t (2 bytes) instead of long (4 bytes). A sensor reading from 0–1023 fits in uint16_t (2 bytes). A status flag is bool (1 byte) or better, a single bit in a uint8_t bitmap.

Scope Reduction

// BEFORE: large array as global (always occupies SRAM)
int sensorHistory[100];  // 200 bytes, globally allocated

void analyzeTrend() {
  // Uses sensorHistory...
}

// AFTER: local to the function that uses it (stack-allocated, freed when done)
void analyzeTrend() {
  int sensorHistory[100];  // 200 bytes on stack, only during this call
  // ...fill and use sensorHistory...
}  // Automatically freed here — SRAM reclaimed

Caution: large local arrays can cause stack overflow if the function is called from deep nesting or from an interrupt. Check free memory before and after the function call to verify.

Eliminating Redundant Buffers

Serial receive buffers, intermediate calculation arrays, and temporary storage often accumulate in programs. Each serves its purpose but consumes memory continuously:

// Before: separate buffer for received command string
char cmdBuffer[64];      // 64 bytes always allocated

// After: parse commands character-by-character, no buffer needed
void processSerial() {
  static char buf[32];  // Static: allocated once, persists between calls
  static uint8_t idx = 0;
  
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\n' || idx >= sizeof(buf) - 1) {
      buf[idx] = '\0';
      executeCommand(buf);
      idx = 0;
    } else {
      buf[idx++] = c;
    }
  }
}
// Static variable: same memory cost as global, but scoped to function
// No heap allocation, no dynamic buffer

Memory and Program Structure: A Design Mindset

Writing memory-efficient robot code on constrained platforms is not just a set of tricks — it’s a design mindset that shapes how you structure programs from the beginning.

Declare constants in Flash, variables in SRAM. Anything that doesn’t change is a constant. Constants declared with const at global scope that the compiler can prove won’t change are often placed in Flash automatically by modern compilers. Explicit PROGMEM ensures they stay there.

Prefer static over dynamic allocation. Know the maximum size of every data structure at compile time and allocate it statically. Don’t use malloc(), new, or the String class unless you have abundant SRAM (ESP32, Teensy 4.x) — these introduce heap fragmentation over time.

Minimize global scope. Every global variable is permanently allocated from startup. Move variables to the smallest scope where they’re needed. Function-local variables live on the stack and are freed when the function returns.

Instrument before optimizing. Measure actual memory usage with freeMemory() during the most memory-intensive operations before spending time optimizing code that isn’t actually the problem.

Memory in a microcontroller is not one thing but three distinct resources with different properties, different sizes, and different failure modes:

Flash memory holds your program permanently — large (32KB on Uno), non-volatile, rarely the limiting factor. SRAM holds everything that happens at runtime — tiny (2KB on Uno), volatile, the most common point of failure in complex robot code. EEPROM provides a small persistent storage area for calibration and settings — limited in both size and write cycles but invaluable for data that must survive power cycles.

The practical skill in robotics memory management centers almost entirely on SRAM. Measuring current usage with freeMemory(), using the F() macro for string literals, replacing the String class with char arrays, using PROGMEM for large constant tables, choosing minimal data types, and auditing global variable scope are the tools that keep even complex robots within their 2KB SRAM budgets.

When optimization is exhausted or the architecture genuinely needs more memory, the upgrade path is clear: Arduino Mega (8KB SRAM), RP2040 (264KB SRAM), or ESP32 (520KB SRAM) each provide dramatically more working memory at modest cost, transforming memory management from a constant constraint into a non-issue.

Diagnosing Memory Problems: Real Symptoms and Their Causes

Because memory failures produce indirect, unpredictable symptoms, recognizing the pattern of a memory problem is the first step to fixing it. These are the characteristic failure signatures:

Symptom: Random Resets Without Apparent Cause

The robot runs for 30 seconds, then resets and starts over. Sometimes it runs for 2 minutes; sometimes it resets immediately. No error message appears.

Memory cause: Stack overflow. A deeply nested function call, or an interrupt firing at a moment of deep stack depth, pushes the stack into the global variable space. A critical variable (like the loop counter or a control output) gets overwritten with garbage from the stack. The program attempts to execute from a nonsensical address and the watchdog timer (or hardware exception) triggers a reset.

Diagnosis: Add freeMemory() calls throughout the code. Print free memory before and after every major function call. If free memory drops to near zero or goes negative before a reset, stack overflow is the cause.

Fix: Reduce stack depth (fewer nested function calls), reduce sizes of local arrays, or move large local arrays to global scope (accepting they’re always allocated) if they’re used often.

Symptom: Variables That Change “By Themselves”

A sensor reading that should be stable shows wild variations at certain code paths. A counter that should increment by 1 sometimes jumps by large amounts. A configuration flag that you set to true somehow becomes false.

Memory cause: Buffer overrun. Code writes beyond the end of an array (e.g., writing to array[10] when the array has only 10 elements — valid indices are 0–9), corrupting the adjacent variable in memory.

Diagnosis: Check every array access. Is the index ever out of bounds? Use sizeof(array) / sizeof(array[0]) for the length rather than a hardcoded number that may be wrong.

// Buffer overrun example — classic and dangerous:
char buffer[10];
// If input is longer than 9 chars + null terminator, this overwrites adjacent memory:
Serial.readBytesUntil('\n', buffer, 20);  // BUG: allows 20 bytes into 10-byte buffer

// Safe version:
Serial.readBytesUntil('\n', buffer, sizeof(buffer) - 1);  // -1 for null terminator
buffer[sizeof(buffer) - 1] = '\0';  // Ensure null termination

Symptom: Program Works With Debugging Serial.print, Fails Without It

Adding Serial.print() statements to debug a problem makes the problem disappear. Removing them makes it return. This is known as a “Heisenbug” — the act of observation changes the behavior.

Memory cause: The timing difference introduced by Serial.print() changes when interrupts fire relative to the main code, masking a race condition. Alternatively, the memory layout changes slightly with the strings from Serial.print(), moving a vulnerable variable to a safer address.

Diagnosis: This is the hardest bug to diagnose. Suspect: shared variables between interrupt context and main code that aren’t declared volatile. Without volatile, the compiler may cache the variable in a register and miss updates made by the ISR.

// Volatile: tells the compiler not to optimize away reads/writes
// because the variable may change from outside normal program flow (ISR)
volatile bool newDataAvailable = false;
volatile int latestSensorValue = 0;

void sensorISR() {
  latestSensorValue = readSensor();
  newDataAvailable = true;  // Without volatile, this write might be optimized away
}

void loop() {
  if (newDataAvailable) {   // Without volatile, this might always read the cached false
    int val = latestSensorValue;
    newDataAvailable = false;
    processValue(val);
  }
}

Symptom: Code Works on Arduino Mega, Fails on Arduino Uno

The same program behaves correctly on the Mega but crashes or misbehaves on the Uno.

Memory cause: Almost certainly SRAM. The Mega has 8KB of SRAM; the Uno has 2KB. Code that works within 8KB fails when constrained to 2KB. The Mega has headroom that hides the memory problem; the Uno doesn’t.

Fix: Apply the SRAM optimization techniques from this article — F() macro, char arrays instead of String, PROGMEM for large tables, data type minimization. Or accept that this program genuinely needs a Mega or larger platform.

Memory Architecture Across the Robot System

For robots using both a microcontroller and a companion computer, memory considerations exist at both levels but with completely different character.

Microcontroller Memory: Tight, Static, Predictable

The microcontroller’s 2–8KB SRAM is managed as described above: static allocation, no dynamic memory, careful data type selection. The entire memory footprint of the program is essentially known at compile time (global variables) plus an upper bound on stack depth (estimated from code analysis and runtime measurement).

Companion Computer Memory: Abundant, Dynamic, OS-Managed

The Raspberry Pi’s 2–8GB of RAM operates under Linux’s virtual memory system. Applications can allocate memory dynamically as needed; the OS manages physical RAM, handles paging, and kills processes that request more memory than available (out-of-memory killer). Python programs can use megabytes for data structures with no concern about running out.

The practical memory constraints on a Raspberry Pi are different:

# Memory concern on Raspberry Pi: numpy arrays for sensor data
import numpy as np

# Storing 1 hour of IMU data at 100Hz:
# 6 values × 4 bytes × 100 Hz × 3600 s = 8,640,000 bytes ≈ 8.6MB
imu_history = np.zeros((360000, 6), dtype=np.float32)  # 8.6MB — trivially fine on Pi

# Storing a 3D occupancy map (10m × 10m × 2m at 5cm resolution):
# 200 × 200 × 40 × 1 byte = 1,600,000 bytes = 1.6MB — fine
occupancy_map = np.zeros((200, 200, 40), dtype=np.uint8)

# Loading a neural network model for object detection:
# MobileNetV2: ~14MB — fine for Pi 4 with 2GB+
# ResNet50: ~100MB — fine for Pi 4 with 2GB+
# Large transformer models: 1GB+ — marginal on Pi 4, needs Pi 4 8GB

The concern on a companion computer is not running out of RAM in the same dramatic way as a microcontroller — it’s more about efficient data structures for real-time processing (using numpy arrays instead of Python lists for numerical data), memory allocation patterns that enable garbage collection efficiency, and selecting models and algorithms that fit within available RAM for real-time execution.

EEPROM Alternatives: When 1KB Is Not Enough

The ATmega328P’s 1KB EEPROM fills quickly when storing multiple calibration values, configuration parameters, and persistent state. Several alternatives extend persistent storage capacity:

SD card via SPI: An SD card module provides gigabytes of FAT-formatted storage. Libraries like SD.h for Arduino enable file read/write. Limitations: SD card initialization adds seconds to startup, file I/O takes milliseconds per operation, and SD cards can fail or become corrupted on sudden power loss. Best for: logging large datasets (sensor logs, map data), storing configuration files.

External I2C/SPI EEPROM: Dedicated EEPROM ICs like the AT24C256 (256KB, I2C) provide far more EEPROM capacity at low cost ($0.50–2.00). Libraries like extEEPROM provide simple byte/block read/write APIs. Best for: calibration data, machine configuration, where more than 1KB but less than 1MB is needed with byte-level access.

Flash memory emulation: Some platforms (ESP8266, ESP32) provide EEPROM emulation using a portion of their SPI Flash. This works similarly to AVR EEPROM from the code perspective but has different wear characteristics (Flash erases in 4KB pages, so every byte write actually rewrites an entire 4KB block). Writes are slower and wear considerations differ. Best for: familiar EEPROM-style API on platforms without dedicated EEPROM.

// External I2C EEPROM example (AT24C256, 32KB)
#include <extEEPROM.h>

extEEPROM eep(kbits_256, 1, 64);  // 256Kbit, 1 device, 64-byte page size

void setup() {
  Wire.begin();
  eep.begin(extEEPROM::twiClock400kHz);  // 400kHz I2C for faster access
}

void saveCalibration(float gyroOffset, float accelScale) {
  eep.write(0, (byte*)&gyroOffset, sizeof(gyroOffset));   // 4 bytes at addr 0
  eep.write(4, (byte*)&accelScale, sizeof(accelScale));   // 4 bytes at addr 4
}

void loadCalibration(float &gyroOffset, float &accelScale) {
  eep.read(0, (byte*)&gyroOffset, sizeof(gyroOffset));
  eep.read(4, (byte*)&accelScale, sizeof(accelScale));
}

With 32KB of external EEPROM via a $1 chip, the storage constraint effectively disappears for calibration and configuration purposes — all 100,000 write cycles per byte remain, there’s no Flash sector erase overhead, and byte-level random access is fully supported.

Hot this week

Understanding Clock Speed and Processing Power in Robot Brains

Learn how clock speed and processing power affect robot performance—understand MHz, instruction cycles, benchmarking, and matching processor specs to your robot's real needs.

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Topics

Understanding Clock Speed and Processing Power in Robot Brains

Learn how clock speed and processing power affect robot performance—understand MHz, instruction cycles, benchmarking, and matching processor specs to your robot's real needs.

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

CMake Mastery: Modern C++ Build Systems

Master CMake for modern C++ projects — learn targets, properties, find_package, FetchContent, generator expressions, testing with CTest, and professional project structure.

Building Cross-Platform C++ Applications

Learn to build cross-platform C++ applications — handle OS differences, use CMake, manage compiler quirks, abstract platform APIs, write portable code, and test on multiple targets.

Related Articles

Popular Categories