Skip to content

Part 2 · C Meets the Metal43 min read

Flash, EEPROM, and Non-Volatile Storage

Calibration constants, device settings, boot counters, crash logs, and the firmware image itself must survive power loss. On most microcontrollers that means flash memory, which is nothing like RAM: you cannot overwrite a byte, erasing takes a long time, freezes the CPU, and wears the cells out, and a power cut halfway through a write leaves data that is neither old nor new. In this lesson you will learn how NOR flash behaves, write an erase and program driver for the STM32F407, protect data with CRC-32 and versioned A/B copies, and then design and implement a small key-value store on flash with wear spreading that survives a power cut at any instruction. You will also see when to give up on internal flash and use an external SPI NOR chip, FRAM or EEPROM instead.

Each flash cell is a transistor with a floating gate that traps charge. The physics gives you three rules that shape everything else:

  1. Erase sets bits to 1, and it only works on a whole sector (or page) at a time. Erasing is slow: it uses high voltage to drain charge from thousands of cells.
  2. Programming can only change bits from 1 to 0, in units of the write granularity (a byte, half-word, word or double-word, depending on the device). You cannot program a 0 back to 1 without erasing the whole sector.
  3. Every erase wears the cells. After the rated number of erase cycles, cells may fail to erase or may leak charge sooner.

So “update a 4-byte setting” in flash actually means: copy the whole sector to RAM, erase the sector, write it all back. That is slow, wears the sector, and loses everything if power fails between the erase and the rewrite. Every technique in this lesson exists to avoid doing that.

Key numbers for the STM32F4 (check the datasheet for exact values):

PropertySTM32F407 internal flash
Erased value0xFF
Write granularity8, 16, 32 or 64 bits, set by PSIZE
Endurance10,000 erase cycles per sector (minimum)
Retentiondecades at moderate temperature, decreasing with cycling and heat
Word program timeabout 16 µs (32-bit)
Sector erase timehundreds of ms for 16 KB, 1 to 2 s typical for 128 KB, worst case several seconds

Reading is fast and random-access, which is why code runs directly from flash. Writing is the expensive, dangerous operation.

The 1 MB of flash is divided into 12 sectors of uneven size:

SectorAddressSize
00x0800000016 KB
10x0800400016 KB
20x0800800016 KB
30x0800C00016 KB
40x0801000064 KB
50x08020000128 KB
6 to 110x08040000 to 0x080E0000128 KB each

The small sectors at the start exist for exactly the reason you think: a bootloader and small blocks of settings fit there without wasting a 128 KB sector. Sector 0 holds the vector table at reset, so it always belongs to code.

The flash interface registers are at 0x40023C00:

OffsetRegisterUse
0x00ACRwait states, prefetch, instruction and data caches (Clocks, reset, and timing)
0x04KEYRunlock key for CR
0x08OPTKEYRunlock key for OPTCR
0x0CSRstatus: EOP (0), OPERR (1), WRPERR (4), PGAERR (5), PGPERR (6), PGSERR (7), BSY (16)
0x10CRcontrol: PG (0), SER (1), MER (2), SNB (6:3), PSIZE (9:8), STRT (16), LOCK (31)
0x14OPTCRoption bytes control

CR is locked after reset. Writing 0x45670123 and then 0xCDEF89AB to KEYR unlocks it; any wrong sequence locks it until the next reset (and raises a bus fault on the second wrong write). Setting LOCK locks it again. Like the watchdog keys, this protects against a stray pointer erasing your firmware.

PSIZE sets how many bits each program operation writes, and the maximum depends on the supply voltage, because programming wider words draws more current:

PSIZEParallelismSupply voltage
00x81.8 V to 3.6 V
01x162.1 V to 3.6 V
10x322.7 V to 3.6 V
11x64needs external VPP (8 to 9 V)

At 3 V use x32. Each program operation must then write an aligned 32-bit word; a write of the wrong size gives PGPERR, a misaligned one PGAERR.

The sector erase sequence: wait for BSY = 0, set SER and the sector number in SNB, set STRT, wait for BSY = 0, check the error flags. The programming sequence: wait for BSY = 0, set PG, write the word to its flash address with a normal store, wait for BSY = 0, check errors, repeat. The store to the flash address is what starts the programming; the flash interface intercepts it.

The F407’s 1 MB flash is a single bank. While an erase or program is running, any read from flash (an instruction fetch, a constant from a literal pool, an interrupt vector) stalls the bus until the operation finishes. The CPU does not crash; it simply freezes.

For a 16 µs word program that is harmless. For a sector erase it means the CPU, including every interrupt handler in flash, stops for up to several seconds. A UART receiving at 115200 overruns after 87 µs; a motor control loop misses hundreds of cycles; and an IWDG with a 1 second timeout fires in the middle of the erase.

What to do about it:

  • Erase only at moments when a stall is acceptable (at boot, in a maintenance mode, while the motor is stopped), and keep erases small: a 16 KB sector, not a 128 KB one.
  • Choose the watchdog timeout longer than the worst-case erase time of the sectors you erase, or erase in the shortest sector available.
  • If an interrupt must keep running, put its handler, everything it calls, and the vector table in RAM (__attribute__((section(".ramfunc"))) and VTOR relocation, from Linker scripts and Startup code and the vector table), and make sure it touches no flash constants. Everything else can stay masked.
  • Devices with two banks (STM32F42x/43x with 2 MB, many newer parts) can read from one bank while erasing the other.

Never erase the sector your code is executing from. On the F4 the erase itself would proceed, and then the CPU would fetch 0xFFFFFFFF as instructions.

Reserving storage sectors in the linker script

Section titled “Reserving storage sectors in the linker script”

This lesson stores data in sectors 2 and 3 (0x08008000 to 0x0800FFFF). The linker must place nothing there. One way, using the linker script from Linker scripts, is to split flash into regions:

MEMORY
{
FLASH_VEC (rx) : ORIGIN = 0x08000000, LENGTH = 32K /* sectors 0-1: vectors, startup */
STORAGE (r) : ORIGIN = 0x08008000, LENGTH = 32K /* sectors 2-3: key-value store */
FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 960K /* sectors 4-11: everything else */
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

Place .isr_vector (and optionally small startup functions) in FLASH_VEC and every other output section in FLASH. Nothing is assigned to STORAGE. The cost is the unused part of sectors 0 and 1. The simpler alternative is to use the last two sectors (10 and 11) and shorten FLASH by 256 KB, at the price of 128 KB erases that stall the CPU for over a second.

Add the flash and CRC overlays to the register header. The guard skips the flash struct if your header already defines it for ACR from the clocks lesson; the field order is the same.

stm32f407_regs.h (additions)
#ifndef FLASH
typedef struct {
volatile uint32_t ACR; /* 0x00 */
volatile uint32_t KEYR; /* 0x04 */
volatile uint32_t OPTKEYR; /* 0x08 */
volatile uint32_t SR; /* 0x0C */
volatile uint32_t CR; /* 0x10 */
volatile uint32_t OPTCR; /* 0x14 */
} FLASH_TypeDef;
#define FLASH ((FLASH_TypeDef *)0x40023C00U)
#endif
typedef struct {
volatile uint32_t DR; /* 0x00 */
volatile uint32_t IDR; /* 0x04 */
volatile uint32_t CR; /* 0x08 */
} CRC_TypeDef;
#define CRC ((CRC_TypeDef *)0x40023000U)
#define RCC_AHB1ENR_CRCEN (1u << 12)
flash.h
#ifndef FLASH_DRV_H
#define FLASH_DRV_H
#include <stddef.h>
#include <stdint.h>
typedef enum {
FLASH_OK = 0,
FLASH_ERR_BUSY, /* timed out waiting for BSY */
FLASH_ERR_PROG, /* hardware reported an error */
FLASH_ERR_VERIFY, /* read-back does not match */
FLASH_ERR_ARG,
} flash_status_t;
flash_status_t flash_erase_sector(uint32_t sector);
flash_status_t flash_write(uint32_t addr, const void *data, size_t len);
#endif
flash.c
#include <stdint.h>
#include <string.h>
#include "stm32f407_regs.h"
#include "flash.h"
#define FLASH_KEY1 0x45670123u
#define FLASH_KEY2 0xCDEF89ABu
#define FLASH_SR_EOP (1u << 0)
#define FLASH_SR_OPERR (1u << 1)
#define FLASH_SR_WRPERR (1u << 4)
#define FLASH_SR_PGAERR (1u << 5)
#define FLASH_SR_PGPERR (1u << 6)
#define FLASH_SR_PGSERR (1u << 7)
#define FLASH_SR_BSY (1u << 16)
#define FLASH_SR_ERRORS (FLASH_SR_OPERR | FLASH_SR_WRPERR | FLASH_SR_PGAERR | \
FLASH_SR_PGPERR | FLASH_SR_PGSERR)
#define FLASH_CR_PG (1u << 0)
#define FLASH_CR_SER (1u << 1)
#define FLASH_CR_SNB_Pos 3u
#define FLASH_CR_SNB_Msk (0xFu << 3)
#define FLASH_CR_PSIZE_Msk (3u << 8)
#define FLASH_CR_PSIZE_X32 (2u << 8)
#define FLASH_CR_STRT (1u << 16)
#define FLASH_CR_LOCK (1u << 31)
#define FLASH_ACR_ICEN (1u << 9)
#define FLASH_ACR_DCEN (1u << 10)
#define FLASH_ACR_ICRST (1u << 11)
#define FLASH_ACR_DCRST (1u << 12)
#define FLASH_TIMEOUT 50000000u /* several seconds: longer than any erase */
static void flash_unlock(void)
{
if ((FLASH->CR & FLASH_CR_LOCK) != 0u) {
FLASH->KEYR = FLASH_KEY1;
FLASH->KEYR = FLASH_KEY2;
}
}
static void flash_lock(void)
{
FLASH->CR |= FLASH_CR_LOCK;
}
/* Wait for the current operation, then clear and report its flags. */
static flash_status_t flash_wait(void)
{
for (uint32_t n = FLASH_TIMEOUT; (FLASH->SR & FLASH_SR_BSY) != 0u; n--) {
if (n == 0u) {
return FLASH_ERR_BUSY;
}
}
uint32_t sr = FLASH->SR;
FLASH->SR = sr & (FLASH_SR_ERRORS | FLASH_SR_EOP); /* write 1 to clear */
return ((sr & FLASH_SR_ERRORS) != 0u) ? FLASH_ERR_PROG : FLASH_OK;
}
/* The ART caches may hold old flash contents after an erase or program. */
static void flash_flush_caches(void)
{
uint32_t acr = FLASH->ACR;
uint32_t off = acr & ~(FLASH_ACR_ICEN | FLASH_ACR_DCEN);
FLASH->ACR = off; /* disable */
FLASH->ACR = off | FLASH_ACR_ICRST | FLASH_ACR_DCRST; /* reset */
FLASH->ACR = off;
FLASH->ACR = acr; /* restore */
}
flash_status_t flash_erase_sector(uint32_t sector)
{
flash_status_t st;
if (sector > 11u) {
return FLASH_ERR_ARG;
}
flash_unlock();
st = flash_wait(); /* idle, stale flags cleared */
if (st != FLASH_ERR_BUSY) {
FLASH->CR = (FLASH->CR & ~(FLASH_CR_PSIZE_Msk | FLASH_CR_SNB_Msk | FLASH_CR_PG))
| FLASH_CR_PSIZE_X32 | FLASH_CR_SER | (sector << FLASH_CR_SNB_Pos);
FLASH->CR |= FLASH_CR_STRT; /* CPU stalls here if it fetches from flash */
st = flash_wait();
FLASH->CR &= ~(FLASH_CR_SER | FLASH_CR_SNB_Msk);
}
flash_lock();
flash_flush_caches();
return st;
}
flash_status_t flash_write(uint32_t addr, const void *data, size_t len)
{
const uint8_t *src = data;
const uint32_t start = addr;
const size_t total = len;
flash_status_t st;
if ((addr & 3u) != 0u || (len != 0u && data == NULL)) {
return FLASH_ERR_ARG;
}
flash_unlock();
st = flash_wait();
if (st != FLASH_ERR_BUSY) {
st = FLASH_OK;
FLASH->CR = (FLASH->CR & ~(FLASH_CR_PSIZE_Msk | FLASH_CR_SER | FLASH_CR_SNB_Msk))
| FLASH_CR_PSIZE_X32 | FLASH_CR_PG;
while (len > 0u && st == FLASH_OK) {
uint32_t w = 0xFFFFFFFFu; /* pad a short tail with 0xFF */
size_t n = (len < 4u) ? len : 4u;
memcpy(&w, src, n); /* src may be unaligned */
*(volatile uint32_t *)addr = w; /* this store starts programming */
st = flash_wait();
addr += 4u;
src += n;
len -= n;
}
FLASH->CR &= ~FLASH_CR_PG;
}
flash_lock();
flash_flush_caches();
if (st == FLASH_OK && total != 0u
&& memcmp((const void *)(uintptr_t)start, data, total) != 0) {
st = FLASH_ERR_VERIFY;
}
return st;
}

Points that matter:

  • Every operation starts by waiting for BSY and clearing stale flags. A leftover PGSERR from an earlier mistake blocks every later operation until it is cleared.
  • Flags are cleared by writing 1 to them in SR, the opposite of the “write 0 to clear” USART and I2C flags you saw earlier. Always check the reference manual for each register’s clearing rule.
  • flash_write builds each word in a local variable with memcpy, so data can come from any alignment, and a length that is not a multiple of 4 is padded with 0xFF (which leaves those bytes erased).
  • The ART accelerator’s caches are flushed after each operation. With caches enabled (as they are after the 168 MHz clock setup), a cached copy of an erased word could otherwise be returned after you programmed it. The caches must be disabled while being reset.
  • The read-back verify catches a worn-out or protected cell. A write that “succeeded” without error but reads back wrong is a sign the sector is at end of life.
  • The driver runs from flash. It works because the CPU merely stalls during each operation. The timeout loop does not even iterate during an erase; the CPU is frozen until the erase ends.

Whatever you store, you need to know later whether it is intact: not half-written, not corrupted by a failing cell, not left over from an older firmware with a different layout. The standard tool is a CRC (cyclic redundancy check). CRC-32 detects all single-bit and double-bit errors, all burst errors up to 32 bits, and misses random corruption with probability 1 in 2^32.

The common CRC-32 (used by zlib, Ethernet, PNG, ZIP) uses the reflected polynomial 0xEDB88320, initial value 0xFFFFFFFF, and a final inversion. The check value for the ASCII string 123456789 is 0xCBF43926; test any implementation against that.

crc32.c
#include <stddef.h>
#include <stdint.h>
uint32_t crc32_update(uint32_t crc, const void *data, size_t len);
void crc32_table_init(void);
uint32_t crc32_update_table(uint32_t crc, const void *data, size_t len);
/* Bitwise: tiny, about 8 iterations per byte. Chainable: pass the last result. */
uint32_t crc32_update(uint32_t crc, const void *data, size_t len)
{
const uint8_t *p = data;
crc = ~crc;
while (len-- != 0u) {
crc ^= *p++;
for (int k = 0; k < 8; k++) {
crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u)));
}
}
return ~crc;
}
/* Table-driven: 1 KB table, one lookup per byte. */
static uint32_t crc_table[256];
void crc32_table_init(void)
{
for (uint32_t i = 0; i < 256u; i++) {
uint32_t c = i;
for (int k = 0; k < 8; k++) {
c = ((c & 1u) != 0u) ? (c >> 1) ^ 0xEDB88320u : c >> 1;
}
crc_table[i] = c;
}
}
uint32_t crc32_update_table(uint32_t crc, const void *data, size_t len)
{
const uint8_t *p = data;
crc = ~crc;
while (len-- != 0u) {
crc = crc_table[(crc ^ *p++) & 0xFFu] ^ (crc >> 8);
}
return ~crc;
}

0u - (crc & 1u) is all ones when the low bit is set and zero otherwise, a branch-free mask. Because the function inverts on entry and exit, you can chain calls: crc32_update(crc32_update(0, a, n), b, m) equals the CRC of a followed by b. The table version is roughly 8 times faster and costs 1 KB of RAM (generate it into a const array at build time to put it in flash instead). For a few hundred bytes of settings, the bitwise version is plenty.

The STM32F4 also has a CRC peripheral at 0x40023000 (AHB1, RCC_AHB1ENR bit 12). Write CR = 1 to reset it to 0xFFFFFFFF, write 32-bit words to DR, read the result from DR. It computes one 32-bit word in 4 AHB clock cycles. But it uses the non-reflected polynomial 0x04C11DB7 on whole 32-bit words with no final inversion (the CRC-32/MPEG-2 variant), so its result does not match the zlib CRC-32 above. That is fine when the same chip both writes and checks the data. When a host tool must produce matching CRCs (for a firmware image header, for instance), make sure both sides use the same algorithm, and test with a known vector.

The classic way to store one configuration struct robustly:

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
uint32_t crc32_update(uint32_t crc, const void *data, size_t len);
#define CFG_MAGIC 0x31474643u /* "CFG1" in memory order */
#define CFG_VERSION 2u
typedef struct {
uint32_t magic;
uint16_t version;
uint16_t length; /* bytes before crc, as written */
uint32_t seq; /* incremented on every save */
uint32_t baud;
int16_t temp_offset_x10;
uint8_t led_mode;
uint8_t reserved; /* explicit: no hidden padding inside the CRC */
uint32_t crc; /* CRC-32 of every byte before this field */
} config_t;
static bool cfg_valid(const config_t *c)
{
return c->magic == CFG_MAGIC
&& c->length == offsetof(config_t, crc)
&& c->crc == crc32_update(0u, c, offsetof(config_t, crc));
}
/* Two copies in two sectors: use the valid one with the newest sequence. */
const config_t *cfg_select(const config_t *a, const config_t *b)
{
bool va = cfg_valid(a);
bool vb = cfg_valid(b);
if (va && vb) {
return ((int32_t)(b->seq - a->seq) > 0) ? b : a;
}
return va ? a : (vb ? b : NULL);
}

Each field earns its place:

  • magic distinguishes “a config is stored here” from erased flash (0xFFFFFFFF) and from unrelated data.
  • version says which layout this is. When firmware v3 adds a field, it can still read a v2 record, fill the new field with a default, and write back a v3 record.
  • length lets newer firmware handle a record from older firmware with fewer bytes, and lets older firmware refuse a record that is longer than it understands.
  • seq orders the two copies. The comparison is wraparound-safe, like the timer comparison from Clocks, reset, and timing.
  • crc covers everything before it. Without it, a write interrupted halfway leaves a record whose magic looks fine and whose content is garbage.
  • reserved makes the padding explicit, so the CRC is computed over defined bytes (the padding trap from Structs, unions, enums, and memory layout).

The A/B scheme: keep copies in two sectors. To save, find the older (or invalid) copy, erase its sector, write the new record with seq + 1. If power fails during the erase or the write, the other copy is untouched and still valid, so the device boots with the previous settings. At no point is there zero valid copies. This costs two sectors and one erase per save, which is fine for settings that change a few times a year.

EEPROM emulation: a key-value store on flash

Section titled “EEPROM emulation: a key-value store on flash”

Many values change more often than that: a boot counter, an odometer, a last-used channel, a usage log. Erasing a 16 KB sector to change 4 bytes would burn through 10,000 cycles in days. The answer is to never overwrite in place: append each new value to a log, and only erase when the log is full.

This is EEPROM emulation. ST publishes an application note implementing it for 16-bit variables; here you will build your own variant with variable-length values, CRC-protected records, and a design that survives a power cut at any point.

Two flash sectors (2 and 3, 16 KB each) act as pages. At any time one page is active.

Page layout (16 KB)
+0x0000 [ magic "KVP1" ][ seq ] page header, written LAST
+0x0008 [ key | len<<16 ][ crc32 ][ payload ... pad to 4 ] record 1
[ key | len<<16 ][ crc32 ][ payload ... ] record 2
...
[ 0xFFFFFFFF ... ] free space (erased)
  • A record is a header word (key in the low 16 bits, payload len in the high 16), a CRC-32 word covering the header and payload, and the payload padded to a multiple of 4 bytes.
  • Writing a value appends a new record. Reading returns the newest valid record for the key. Older records for that key become garbage.
  • Deleting appends a tombstone: a record with len = 0.
  • When the active page is full, compaction erases the other page, copies only the newest valid record of each live key into it, writes the new page’s header with seq + 1, and makes it active.
  • On boot, mount finds the valid page with the highest sequence number and scans it to rebuild a RAM index: for each key, the offset of its newest valid record.

Keys are limited to 1 to 63 and values to 64 bytes, which keeps the RAM index to 128 bytes and every operation O(1) or O(page). Those are knobs, not fundamental limits.

The ordering of writes is the whole trick. Each flash word is programmed exactly once, and each structure has a commit word written last:

  • A record is written header first, then payload, then CRC last. A record is valid only if its CRC matches. Until the CRC is written, the CRC word reads 0xFFFFFFFF, which (with overwhelming probability) does not match, so a half-written record is ignored.
  • A new page during compaction gets its records first and its header last (sequence number, then magic). Until the magic is written, the page is not valid, and the old page remains the active one.
  • The old page is not erased at the end of compaction. Both pages are then valid, and mount picks the higher sequence number. It is erased at the start of the next compaction.
Power fails during…State on the next boot
writing a record’s headerheader plausible: record skipped by its length, CRC fails; header garbage: page treated as full, next write compacts
writing a record’s payloadCRC still erased, record ignored, previous value returned
writing a record’s CRCCRC mismatch, record ignored
erasing the target pagetarget has no valid header; active page untouched
copying records to the targettarget has no header; active page untouched
writing the target headerseq written but no magic: target invalid
after the header, before anything elseboth valid, higher seq (the complete copy) wins

At every instant, the latest successfully committed value of every key is recoverable. A write that was interrupted is lost, which is the only sensible outcome: the caller never got a success return for it.

Programming each word exactly once has a second benefit. Some flash (STM32L4, STM32H7 and others with ECC) forbids programming a word twice even to clear more bits. A design that only appends ports to those parts by aligning records to their programming granularity.

Every compaction erases one page. Pages alternate, so wear spreads evenly over both. With 16-byte records (4-byte values) and 16,376 usable bytes, a page holds about 1,000 records. If 10 keys are live, each compaction frees about 990 slots, so each erase buys about 990 writes. Two pages times 10,000 cycles gives about 20 million value updates over the life of the device. A value written once a minute lasts 38 years; once a second, about 8 months. Add more pages (rotating through N sectors) or a bigger sector to scale it, and skip writes whose value has not changed, which kv_set below does automatically.

kv.h
#ifndef KV_H
#define KV_H
#include <stddef.h>
#include <stdint.h>
#define KV_MAX_KEY 63u /* keys 1..63 */
#define KV_MAX_LEN 64u /* bytes per value */
typedef enum {
KV_OK = 0,
KV_ERR_ARG = -1,
KV_ERR_NOT_FOUND = -2,
KV_ERR_SIZE = -3, /* caller's buffer too small */
KV_ERR_FULL = -4, /* live data does not fit in a page */
KV_ERR_FLASH = -5,
} kv_status_t;
kv_status_t kv_mount(void);
int32_t kv_get(uint16_t key, void *buf, size_t cap); /* length or kv_status_t */
kv_status_t kv_set(uint16_t key, const void *data, size_t len);
kv_status_t kv_delete(uint16_t key);
uint32_t kv_used_bytes(void);
uint32_t kv_active_page(void);
uint32_t kv_compactions(void);
#endif
kv.c
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <string.h>
#include "flash.h"
#include "kv.h"
uint32_t crc32_update(uint32_t crc, const void *data, size_t len);
#define KV_PAGE_SIZE 0x4000u /* 16 KB sectors */
#define KV_HDR_SIZE 8u /* page header: magic, seq */
#define KV_MAGIC 0x3150564Bu /* "KVP1" in memory order */
#define KV_REC_HDR 8u /* record: header word + crc word */
static const struct {
uint32_t base;
uint32_t sector;
} pages[2] = {
{ 0x08008000u, 2u },
{ 0x0800C000u, 3u },
};
static uint32_t active; /* 0 or 1 */
static uint32_t active_seq;
static uint32_t write_off; /* next free byte in the active page */
static uint16_t latest[KV_MAX_KEY + 1u];/* offset of newest valid record, 0 = none */
static uint32_t compaction_count;
static uint32_t rd32(uint32_t addr)
{
return *(const volatile uint32_t *)addr;
}
static const void *flash_ptr(uint32_t addr)
{
return (const void *)(uintptr_t)addr;
}
static uint32_t pad4(uint32_t n)
{
return (n + 3u) & ~3u;
}
static uint32_t rec_crc(uint32_t hdr, const void *payload, uint32_t len)
{
uint32_t c = crc32_update(0u, &hdr, sizeof hdr);
return crc32_update(c, payload, len);
}
static bool page_valid(uint32_t page, uint32_t *seq)
{
if (rd32(pages[page].base) != KV_MAGIC) {
return false;
}
*seq = rd32(pages[page].base + 4u);
return true;
}
/* Commit a page: sequence first, magic last. */
static flash_status_t write_page_header(uint32_t page, uint32_t seq)
{
const uint32_t magic = KV_MAGIC;
flash_status_t st = flash_write(pages[page].base + 4u, &seq, 4u);
if (st == FLASH_OK) {
st = flash_write(pages[page].base, &magic, 4u);
}
return st;
}
/* Rebuild the index from a page. Returns the offset of the first free byte,
or KV_PAGE_SIZE if the log ends in something unparseable (forces compaction). */
static uint32_t scan_page(uint32_t page, uint16_t *index)
{
const uint32_t base = pages[page].base;
uint32_t off = KV_HDR_SIZE;
memset(index, 0, (KV_MAX_KEY + 1u) * sizeof index[0]);
while (off + KV_REC_HDR <= KV_PAGE_SIZE) {
uint32_t hdr = rd32(base + off);
if (hdr == 0xFFFFFFFFu) {
return off; /* end of the log */
}
uint32_t key = hdr & 0xFFFFu;
uint32_t len = hdr >> 16;
uint32_t size = KV_REC_HDR + pad4(len);
if (key == 0u || key > KV_MAX_KEY || len > KV_MAX_LEN
|| off + size > KV_PAGE_SIZE) {
return KV_PAGE_SIZE; /* torn header: stop here */
}
if (rd32(base + off + 4u) == rec_crc(hdr, flash_ptr(base + off + KV_REC_HDR), len)) {
index[key] = (uint16_t)off; /* newest valid so far */
}
off += size; /* invalid records are skipped */
}
return KV_PAGE_SIZE;
}
/* Append one record: header, payload, then CRC as the commit. */
static kv_status_t append(uint32_t page, uint32_t *off, uint16_t key,
const void *data, uint32_t len)
{
const uint32_t at = pages[page].base + *off;
const uint32_t size = KV_REC_HDR + pad4(len);
const uint32_t hdr = (uint32_t)key | (len << 16);
const uint32_t crc = rec_crc(hdr, data, len);
if (*off + size > KV_PAGE_SIZE) {
return KV_ERR_FULL;
}
*off += size; /* the slot is consumed even on failure */
if (flash_write(at, &hdr, 4u) != FLASH_OK) {
return KV_ERR_FLASH;
}
if (len > 0u && flash_write(at + KV_REC_HDR, data, len) != FLASH_OK) {
return KV_ERR_FLASH;
}
if (flash_write(at + 4u, &crc, 4u) != FLASH_OK) {
return KV_ERR_FLASH;
}
return KV_OK;
}
static kv_status_t compact(void)
{
const uint32_t dst = active ^ 1u;
const uint32_t src_base = pages[active].base;
uint16_t new_index[KV_MAX_KEY + 1u] = { 0 };
uint32_t off = KV_HDR_SIZE;
if (flash_erase_sector(pages[dst].sector) != FLASH_OK) {
return KV_ERR_FLASH;
}
for (uint32_t key = 1u; key <= KV_MAX_KEY; key++) {
uint32_t o = latest[key];
if (o == 0u) {
continue;
}
uint32_t len = rd32(src_base + o) >> 16;
if (len == 0u) {
continue; /* tombstone: the key is gone */
}
uint32_t at = off;
kv_status_t st = append(dst, &off, (uint16_t)key,
flash_ptr(src_base + o + KV_REC_HDR), len);
if (st != KV_OK) {
return st; /* old page is still active and intact */
}
new_index[key] = (uint16_t)at;
}
if (write_page_header(dst, active_seq + 1u) != FLASH_OK) { /* the commit */
return KV_ERR_FLASH;
}
active = dst;
active_seq++;
write_off = off;
memcpy(latest, new_index, sizeof latest);
compaction_count++;
return KV_OK;
}
kv_status_t kv_mount(void)
{
uint32_t s0 = 0u;
uint32_t s1 = 0u;
bool v0 = page_valid(0u, &s0);
bool v1 = page_valid(1u, &s1);
if (!v0 && !v1) { /* first boot, or both destroyed */
if (flash_erase_sector(pages[0].sector) != FLASH_OK
|| write_page_header(0u, 1u) != FLASH_OK) {
return KV_ERR_FLASH;
}
v0 = true;
s0 = 1u;
}
if (v0 && v1) {
active = ((int32_t)(s1 - s0) > 0) ? 1u : 0u;
} else {
active = v1 ? 1u : 0u;
}
active_seq = (active == 1u) ? s1 : s0;
write_off = scan_page(active, latest);
return KV_OK;
}
int32_t kv_get(uint16_t key, void *buf, size_t cap)
{
if (key == 0u || key > KV_MAX_KEY) {
return KV_ERR_ARG;
}
uint32_t o = latest[key];
if (o == 0u) {
return KV_ERR_NOT_FOUND;
}
uint32_t base = pages[active].base;
uint32_t len = rd32(base + o) >> 16;
if (len == 0u) {
return KV_ERR_NOT_FOUND; /* deleted */
}
if (len > cap || buf == NULL) {
return KV_ERR_SIZE;
}
memcpy(buf, flash_ptr(base + o + KV_REC_HDR), len);
return (int32_t)len;
}
static kv_status_t kv_put(uint16_t key, const void *data, uint32_t len)
{
uint32_t need = KV_REC_HDR + pad4(len);
if (write_off + need > KV_PAGE_SIZE) {
kv_status_t st = compact();
if (st != KV_OK) {
return st;
}
if (write_off + need > KV_PAGE_SIZE) {
return KV_ERR_FULL;
}
}
uint32_t at = write_off;
kv_status_t st = append(active, &write_off, key, data, len);
if (st == KV_OK) {
latest[key] = (uint16_t)at;
}
return st;
}
kv_status_t kv_set(uint16_t key, const void *data, size_t len)
{
if (key == 0u || key > KV_MAX_KEY || len == 0u || len > KV_MAX_LEN || data == NULL) {
return KV_ERR_ARG;
}
uint32_t o = latest[key];
if (o != 0u) { /* unchanged value: do not wear the flash */
uint32_t base = pages[active].base;
if ((rd32(base + o) >> 16) == len
&& memcmp(flash_ptr(base + o + KV_REC_HDR), data, len) == 0) {
return KV_OK;
}
}
return kv_put(key, data, (uint32_t)len);
}
kv_status_t kv_delete(uint16_t key)
{
if (key == 0u || key > KV_MAX_KEY) {
return KV_ERR_ARG;
}
uint32_t o = latest[key];
if (o == 0u || (rd32(pages[active].base + o) >> 16) == 0u) {
return KV_OK; /* already absent */
}
return kv_put(key, NULL, 0u); /* tombstone */
}
uint32_t kv_used_bytes(void) { return write_off; }
uint32_t kv_active_page(void) { return active; }
uint32_t kv_compactions(void) { return compaction_count; }

Walk through the parts that carry the design:

  • scan_page treats 0xFFFFFFFF as the end of the log, skips records whose CRC fails by their declared length, and gives up at the first header that cannot be a record (key out of range, impossible length). Giving up returns “page full”, so the next write compacts, and compaction copies only records that were indexed, which are all valid. Damage never propagates into the new page.
  • append advances the write offset before programming. If a write fails partway, the slot stays consumed and the next record goes after it; the scan on the next boot skips it by length or stops there. Nothing is ever programmed twice.
  • compact works from the RAM index, so it copies exactly one record per live key and drops tombstones. Note that append is passed a pointer into the old page; the payload is copied flash to flash (through flash_write’s local word buffer) and its CRC is recomputed over the same bytes.
  • The commit points are the three flash_write calls that come last: the CRC in append and the magic in write_page_header. Re-read the power-fail table with the code beside it.
  • kv_set refuses zero-length values so that a length of 0 always means tombstone, and skips writes whose value is unchanged. Code that saves settings “just in case” on every loop iteration costs no wear.
  • Everything is O(1) except mount and compaction. Mount scans the page once (up to a few tens of milliseconds for a full 16 KB page at 16 MHz with the bitwise CRC; use the table version if that matters). Compaction costs one erase, which stalls the CPU for a few hundred milliseconds.

The program mounts the store, increments a boot counter, stores a device name the first time it runs, and prints the state. Hold the user button during reset to run a stress test that writes one key 2,000 times, forcing at least one compaction. Reset the board in the middle of the stress test (or pull power) as often as you like: the boot counter and name must survive every time. Build it with flash.c, crc32.c, kv.c, console.c from SPI and I2C, and the linker script change that reserves sectors 2 and 3.

main.c
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "stm32f407_regs.h"
#include "gpio_util.h"
#include "kv.h"
void console_init(void);
enum {
KEY_BOOTS = 1,
KEY_NAME = 2,
KEY_COUNTER = 3,
};
int main(void)
{
console_init(); /* also enables the GPIOA clock */
gpio_mode(GPIOA, 0u, GPIO_IN); /* user button */
kv_status_t st = kv_mount();
printf("\nkv_mount: %d, active page %lu, %lu bytes used, %lu compactions\n",
(int)st, (unsigned long)kv_active_page(),
(unsigned long)kv_used_bytes(), (unsigned long)kv_compactions());
if (st != KV_OK) {
for (;;) { }
}
uint32_t boots = 0u;
if (kv_get(KEY_BOOTS, &boots, sizeof boots) != (int32_t)sizeof boots) {
boots = 0u;
}
boots++;
st = kv_set(KEY_BOOTS, &boots, sizeof boots);
printf("boot count: %lu (save: %d)\n", (unsigned long)boots, (int)st);
char name[KV_MAX_LEN + 1u];
int32_t n = kv_get(KEY_NAME, name, KV_MAX_LEN);
if (n < 0) {
static const char def[] = "firmware-path";
st = kv_set(KEY_NAME, def, sizeof def - 1u);
memcpy(name, def, sizeof def);
printf("device name: %s (stored now, %d)\n", name, (int)st);
} else {
name[n] = '\0';
printf("device name: %s\n", name);
}
if ((GPIOA->IDR & 1u) != 0u) {
printf("stress test: 2000 writes to key %d...\n", KEY_COUNTER);
for (uint32_t i = 0u; i < 2000u; i++) {
st = kv_set(KEY_COUNTER, &i, sizeof i);
if (st != KV_OK) {
printf("kv_set failed at %lu: %d\n", (unsigned long)i, (int)st);
break;
}
}
uint32_t last = 0u;
(void)kv_get(KEY_COUNTER, &last, sizeof last);
printf("done: counter %lu, active page %lu, %lu bytes used, %lu compactions\n",
(unsigned long)last, (unsigned long)kv_active_page(),
(unsigned long)kv_used_bytes(), (unsigned long)kv_compactions());
}
for (;;) {
__asm volatile ("wfi");
}
}

First boot, second boot, then a boot with the button held:

kv_mount: 0, active page 0, 8 bytes used, 0 compactions
boot count: 1 (save: 0)
device name: firmware-path (stored now, 0)
kv_mount: 0, active page 0, 44 bytes used, 0 compactions
boot count: 2 (save: 0)
device name: firmware-path
kv_mount: 0, active page 0, 56 bytes used, 0 compactions
boot count: 3 (save: 0)
device name: firmware-path
stress test: 2000 writes to key 3...
done: counter 1999, active page 1, 7748 bytes used, 1 compactions

The byte counts follow from the layout: the 8-byte page header, a 12-byte boot counter record and a 24-byte name record (8 bytes of header and CRC plus 13 characters padded to 16) give 44 bytes after the first boot. During the stress test, the first page fills after about 1,360 writes; compaction copies the three live records (48 bytes) into page 1, and the remaining writes land after them. A stall of a few hundred milliseconds during the erase is visible if you watch the output closely.

Internal flash is limited in size and endurance and shares the bus with your code. Common alternatives:

SPI NOR flash (W25Q, MX25, IS25 and friends): 1 to 64 MB, 4 KB sectors, 256-byte pages, 100,000 erase cycles typical, a few dollars. It uses a standard command set:

CommandCodeNotes
JEDEC ID0x9Freturns manufacturer, memory type, capacity: verify wiring first
Read data0x03then 24-bit address; streams bytes until CS rises
Write enable0x06sets WEL; required before every program and erase, in its own CS frame
Page program0x0224-bit address plus up to 256 bytes; wraps within the page
Sector erase (4 KB)0x2024-bit address; tens of milliseconds
Read status 10x05bit 0 WIP (busy), bit 1 WEL

Using the SPI driver from SPI and I2C:

#include <stdbool.h>
#include <stdint.h>
#include "spi.h"
static bool nor_cmd(const spi_dev_t *d, const uint8_t *cmd, size_t n,
const uint8_t *tx, uint8_t *rx, size_t len)
{
spi_cs_assert(d);
bool ok = spi_transfer(d->spi, cmd, NULL, n)
&& (len == 0u || spi_transfer(d->spi, tx, rx, len));
spi_cs_release(d);
return ok;
}
static bool nor_wait_ready(const spi_dev_t *d)
{
const uint8_t cmd = 0x05u;
for (uint32_t n = 1000000u; n != 0u; n--) {
uint8_t sr = 0xFFu;
if (!nor_cmd(d, &cmd, 1u, NULL, &sr, 1u)) {
return false;
}
if ((sr & 0x01u) == 0u) { /* WIP clear */
return true;
}
}
return false;
}
bool nor_page_program(const spi_dev_t *d, uint32_t addr, const uint8_t *data, size_t len)
{
const uint8_t wren = 0x06u;
const uint8_t cmd[4] = { 0x02u, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr };
if (len == 0u || len > 256u - (addr & 0xFFu)) { /* must not cross a page */
return false;
}
return nor_cmd(d, &wren, 1u, NULL, NULL, 0u)
&& nor_cmd(d, cmd, sizeof cmd, data, NULL, len)
&& nor_wait_ready(d);
}

The same NOR rules apply (erase to 0xFF, program 1 to 0), so the key-value design above works on external flash too, with a 4 KB sector as the page. Every program and erase must be preceded by write enable in a separate CS frame; forgetting it is the most common reason “nothing gets written”.

QSPI uses four data lines and can run at 50 to 100+ MHz. On parts with a memory-mapped QSPI controller (STM32F7, H7, L4, RP2040) the external flash appears in the address space and code can execute directly from it (XIP, execute in place). The F407 has no QSPI controller; its FSMC handles parallel NOR and SRAM instead.

FRAM (ferroelectric RAM, I2C or SPI) writes at bus speed with no erase and roughly 10^12 to 10^14 write cycles. It is ideal for counters and logs that change constantly, at a higher price per byte. I2C/SPI EEPROM (24Cxx, 25xx) offers byte-writable storage with about 1 million cycles and a few milliseconds per page write; good for small calibration data. SD cards offer gigabytes behind a FAT filesystem but have their own controllers, high and variable current draw, and unpredictable latency, so they need a careful power-fail strategy of their own.

The OPTCR register at offset 0x14 controls the option bytes, a small non-volatile configuration area separate from the sectors: BOR_LEV (bits 3:2, the brown-out threshold from the watchdogs lesson), WDG_SW (bit 5, hardware or software watchdog start), the read-out protection level RDP (bits 15:8), and per-sector write protection nWRP (bits 27:16). They are unlocked with a separate key pair written to OPTKEYR (0x08192A3B, 0x4C5D6E7F), then changed and committed with OPTSTRT. Read-out protection level 2 is permanent and disables the debug port forever. The security implications and the correct procedures belong to Firmware security; until then, leave option bytes alone on boards you want to keep.

NeedWrite frequencySizeChoice
Firmware imagerare (updates)100 KB to 1 MBinternal flash, or external NOR with a bootloader
Calibration, set once at the factoryonceunder 1 KBinternal flash sector with CRC, or OTP area
User settingsa few times a yearunder 1 KBA/B config copies in internal flash
Counters, last-state, small logminutes to hourstens to hundreds of bytesEEPROM emulation in internal flash
Values updated every second or fastersecondssmallFRAM (or EEPROM emulation with a lot of flash)
Data logging, megabytescontinuousMBexternal SPI NOR with a log structure, or SD card
Large files, removable mediavariableGBSD card with FAT
  • Writing to flash without erasing. Programming only clears bits, so the stored value becomes the AND of old and new. Program only erased locations.
  • PSIZE mismatched with the write width. A 32-bit store with PSIZE = x8 raises PGPERR. Set PSIZE and write exactly that width.
  • Not clearing stale error flags. One old PGSERR blocks every later operation. Clear flags (write 1s to SR) before each operation.
  • Erasing the wrong sector, or one the linker uses. Firmware disappears. Reserve storage sectors in the linker script and assert sector numbers.
  • Ignoring the erase stall. UART overruns and watchdog resets during erases. Erase small sectors at safe moments and size the watchdog for it.
  • Overwriting a single config record in place. A power cut leaves nothing valid. Use A/B copies or an append-only log with a commit word written last.
  • No CRC, or CRC over uninitialized padding. You cannot tell old, torn or garbage data from good data. Add magic, version, length and CRC, and make padding explicit.
  • Saving settings on every loop. 10,000 cycles vanish in hours. Compare before writing and rate-limit saves.
  1. Write a host program (with gcc) that checks crc32_update and crc32_update_table against the vector 123456789 = 0xCBF43926, and against each other on 1 MB of random data. Then measure cycles per byte for both on the board with the DWT counter.
  2. On the board, erase sector 3, program a word 0x12345678, then program 0xFFFF0000 to the same address without erasing. Predict the read-back value before you run it, and explain it from the NOR rules.
  3. Use arm-none-eabi-objdump -h and the map file to prove that nothing in your firmware is placed between 0x08008000 and 0x0800FFFF after the linker script change. Then deliberately make the code too large for FLASH_VEC and read the linker error.
  4. Add a fault-injection hook to flash_write (a global countdown that makes the Nth word write return FLASH_ERR_PROG and stop). Run the stress test thousands of times on the host with a RAM-backed fake flash, failing at every possible word, and assert after each “reboot” that every key holds either its previous or its new value.
  5. Extend the store to N pages (for example sectors 1 to 3) with round-robin compaction, and measure how the number of erases per sector changes for the same write load. Then add a crash-record key and save the record from Watchdogs, faults, and recovering from them on the boot after a fault.

Question 1:A flash word holds 0x0000FFFF. Without erasing, you program 0x12345678 to it. What does it read back on an STM32F4?

Question 2:During a 128 KB sector erase, a UART receive interrupt handler located in flash is pending. What happens?

Question 3:In the key-value store, why is the CRC word of a record written after the payload rather than first?

Question 4:After a power cut during compaction, both pages have a valid header, with sequence numbers 41 and 42. Which page does kv_mount use, and why is that safe?

Question 5:At 3.0 V supply, which PSIZE setting should you use for 32-bit programming on the STM32F407?

  • NOR flash erases whole sectors to 1s and programs 1s to 0s; never “overwrite” in place, and count erases against the 10,000-cycle endurance.
  • STM32F407 programming: unlock with the two keys, wait for BSY, clear stale flags, set PSIZE to match the supply and the write width, check errors, lock again.
  • Erases on a single-bank F4 stall every flash access for hundreds of milliseconds to seconds; plan watchdog timeouts, interrupt latency and sector choice around that.
  • Protect every stored structure with magic, version, length and CRC, and keep two copies or an append-only log so that a valid copy always exists.
  • Power-fail safety comes from write ordering: write data first and a single commit word last, and never program the same word twice.
  • An append-only key-value log with compaction between two pages gives byte-sized updates, wear spread across sectors, and survival of a power cut at any instant.
  • Pick storage by write frequency and size: internal flash for code and rare writes, emulated EEPROM for moderate rates, FRAM or external flash beyond that.

Next: Driver design and hardware abstraction, where the register-level drivers from Part 2 become clean, reusable, testable modules.