As firmware evolves, stored settings formats change. If EEPROM layout is not versioned, upgrades can silently load garbage and produce hard-to-diagnose behavior.

My pattern is a small config header with magic bytes, schema version, payload length, and CRC. On boot, the firmware validates the header first. If validation fails, it loads defaults and writes a clean config block.

For each schema change, I add a migration function from version N to N+1. This makes upgrades explicit and testable.

The result is predictable: old devices can be flashed in the field without manual resets, and new firmware can still recover safely from corrupted storage.

The config header and struct

I keep the layout as a packed struct so its size and field offsets are predictable across builds. A magic value catches uninitialized or foreign EEPROM contents, the version byte drives migration, and the CRC catches corruption.

#include <EEPROM.h>
#include <util/crc16.h>   // AVR: _crc16_update

static const uint16_t CONFIG_MAGIC   = 0xC0DE;
static const uint8_t  CONFIG_VERSION = 2;
static const int      CONFIG_ADDR    = 0;

struct __attribute__((packed)) Config {
  uint16_t magic;      // must equal CONFIG_MAGIC
  uint8_t  version;    // schema version
  uint8_t  reserved;   // keep the payload word-aligned
  // --- payload ---
  uint16_t sampleIntervalMs;
  int16_t  tempOffsetC10;   // added in v2
  // --- integrity ---
  uint16_t crc;        // CRC over every byte before this field
};

// CRC16 over the struct excluding its own crc field.
uint16_t configCrc(const Config &c) {
  const uint8_t *p = reinterpret_cast<const uint8_t *>(&c);
  size_t len = sizeof(Config) - sizeof(c.crc);
  uint16_t crc = 0xFFFF;
  for (size_t i = 0; i < len; i++) {
    crc = _crc16_update(crc, p[i]);
  }
  return crc;
}

On a non-AVR core without util/crc16.h, a small library such as FastCRC gives you the same helper without hand-rolling the polynomial.

Read, validate, or default

Boot reads the block, then checks magic and CRC before trusting a single field. Anything unexpected falls back to known-good defaults and rewrites a clean block.

void loadDefaults(Config &c) {
  c.magic            = CONFIG_MAGIC;
  c.version          = CONFIG_VERSION;
  c.reserved         = 0;
  c.sampleIntervalMs = 1000;
  c.tempOffsetC10    = 0;
}

void saveConfig(Config &c) {
  c.magic   = CONFIG_MAGIC;
  c.version = CONFIG_VERSION;
  c.crc     = configCrc(c);
  EEPROM.put(CONFIG_ADDR, c);   // put() only writes changed bytes
}

Config loadConfig() {
  Config c;
  EEPROM.get(CONFIG_ADDR, c);

  if (c.magic != CONFIG_MAGIC || c.crc != configCrc(c)) {
    loadDefaults(c);
    saveConfig(c);
    return c;
  }

  if (c.version != CONFIG_VERSION) {
    migrate(c);        // bring an older layout up to the current one
    saveConfig(c);
  }
  return c;
}

Migration skeleton

Each migration step is explicit and one version at a time, so upgrades from any old version chain forward through the same tested code paths.

void migrate(Config &c) {
  switch (c.version) {
    case 1:
      // v1 had no tempOffsetC10 field; choose a safe default.
      c.tempOffsetC10 = 0;
      c.version = 2;
      // fall through so a v1 device lands on the latest version
    case 2:
      // current version: nothing to do
      break;
    default:
      // unknown/newer than firmware expects: refuse to guess
      loadDefaults(c);
      break;
  }
}

The fall-through is deliberate: a device sitting on v1 walks through every intermediate step until it reaches the current version, and each step stays small enough to unit-test on the host.

A note on write endurance

Classic AVR EEPROM is rated around 100k write cycles per cell, so I never rewrite blindly. EEPROM.put() (and EEPROM.update() under it) only writes bytes that actually changed, which is why I prefer it over EEPROM.write() for whole-struct saves. The one trap to avoid is a rewrite-on-every-failed-boot loop: if validation keeps failing and you keep writing defaults, you can wear a cell in a way that is hard to diagnose in the field. On ESP32 the "EEPROM" is emulated over flash, so I use Preferences (NVS) instead, which does its own wear levelling and key-value storage rather than raw byte offsets.

Sources