A/B dual-page flash storage for small MCUs. Zero dependencies. Stateless.
https://github.com/createskyblue/flashstore-ab-partition
Which to choose?
| FlashStore | FlashDB | LittleFS | |
|---|---|---|---|
| Purpose | Single-blob reliable storage | Embedded key-value DB | Embedded file system |
| Data model | Binary blob | Key-value pairs | Files |
| I/O | Save/Load whole blob | Set/Get by key | Open/Read/Write |
| Wear leveling | ❌ | ✅ | ✅ |
| Power-loss safe | ✅ Dual-page | ✅ Transaction log | ✅ |
| Best for | Config data, infrequent writes | Multiple KVs, independent updates | Files, logs, large data |
| Module | Flash | Static RAM | Stack |
|---|---|---|---|
| flash_store | 500 bytes | 0 | ~20 bytes |
| chacha20 (optional) | 700 bytes | 0 | ~200 bytes |
| xxtea (optional) | 737 bytes | 0 | ~0 |
flash_store breakdown: Load 158B / load_page 108B / save_page 100B / Save 66B / CRC32 52B / MaxDataSize 16B. Link what you use.
- Copy
include/flash_store.handsrc/flash_store.cinto your project. Addchacha20orxxteaif you need encryption. - Implement 3 callbacks:
bool my_read(void *ctx, uint32_t addr, uint8_t *out, size_t len) { memcpy(out, (void *)(uintptr_t)addr, len); // or HAL read return true; } bool my_erase(void *ctx, uint32_t addr, size_t len) { HAL_FLASH_ErasePage(addr); // full page erase return true; } bool my_program(void *ctx, uint32_t addr, const uint8_t *data, size_t len) { // word-by-word program, or memcpy if byte-addressable flash return true; }
- Init and go:
FlashStore_IO io = { .read = my_read, .erase = my_erase, .program = my_program, }; FlashStore_Config cfg = { .io = &io, .page_a_address = 0x0800FE00, .page_b_address = 0x0800FE80, .page_size = 128, }; FlashStore_Save(&cfg, data, len); // write FlashStore_Load(&cfg, data, len); // read
Need encryption? Use ChaCha20 — RFC 8439, stream cipher, no alignment requirements. Cortex-M0 -Os: 700B Flash + ~200B stack, 0 static RAM. For extreme resource savings, XXTEA — obfuscation only, requires 4-byte alignment and ≥8 bytes. Encrypt externally:
encrypt → FlashStore_Save/FlashStore_Load → decrypt.
A is written before B. At any power-loss moment, at least one valid copy exists in flash.
Save: A first → B second Load: read both → CRC check → return good → repair bad
| Power loss at… | A | B | Load result |
|---|---|---|---|
| Idle | v2 | v2 | Returns v2 |
| Writing A | bad | v1 | Returns v1, repairs A |
| A done, before B | v2 (CRC=c2) | v1 (CRC=c1) | CRC differ → A is newer → returns v2, repairs B |
| Writing B | v2 | bad | Returns v2, repairs B next time |
| Both pages bad | bad | bad | ERROR_NO_VALID_DATA |
WARN_REPAIR_FAILED? Means erase/program failed during repair. Should not happen in normal operation — check your hardware or IO.
12-byte header + payload per page:
| Offset | Field | Description |
|---|---|---|
| 0 | magic (4B) | 0x46534142 |
| 4 | length (4B) | Payload size |
| 8 | crc32 (4B) | Payload CRC32 |
page_size is a logical size — a page may span several physical erase sectors, so a
single blob can be larger than one sector without changing the library at all. Your
erase/program callbacks just handle the whole region:
#define FLASH_BASE 0x08000000u #define SECTOR_SIZE 0x800u /* 2KB erase sector */ #define A_SECTOR 8 /* A = sectors 8..11 */ #define SECTORS 4 /* logical page = 4 sectors */ FlashStore_Config cfg = { .io = &io, .page_a_address = FLASH_STORE_SECTOR_ADDR(FLASH_BASE, SECTOR_SIZE, A_SECTOR), .page_b_address = FLASH_STORE_SECTOR_ADDR(FLASH_BASE, SECTOR_SIZE, A_SECTOR + SECTORS), .page_size = FLASH_STORE_PAGE_SIZE(SECTORS, SECTOR_SIZE), };
The helpers FLASH_STORE_SECTOR_ADDR / FLASH_STORE_PAGE_SIZE / FLASH_STORE_PAGE_B
(see flash_store.h) are constant expressions, so they work in static initializers and
_Static_assert.
Three rules:
- A and B must not share a physical erase sector. Erasing A wipes every sector it
covers; if B overlaps any of them, the redundancy is destroyed.
ConfigCheckverifies the logical pages don't overlap, but it cannot know the physical sector map — that part is on you. - Erase the whole region. Your
erasecallback receivespage_sizebytes; if that spans N physical sectors, erase all N (or use a multi-sector erase). - Wear scales with page size. Every commit erases and rewrites both pages in full, so a 4KB page wears ~4x faster than a 1KB page — keep commits infrequent.
| Return | Meaning |
|---|---|
OK |
Success |
ERROR_ARGUMENT |
Bad parameter |
ERROR_WRITE |
Erase or program failed |
ERROR_NO_VALID_DATA |
Both pages are corrupt |
WARN_REPAIR_FAILED |
Data OK but redundancy not restored |
cmake -S . -B build && cmake --build build && ./build/flash_store_tests.exe
10 tests: round-trip, A corrupt→B, B corrupt→repair, A-write-fail, CRC-staleness, both corrupt, MaxDataSize, Save/Load arg check, ConfigCheck, repair-failed.