42а. LittleFS on SPI NOR: a file system that survives power loss
In section 42 FatFS went onto the W25Q128 and showed two weaknesses. Power lost in the middle of a write leaves a file of zero length or full of garbage. The allocation table is written to the same sectors all the time and wears them out first. For a single value, a journal of our own was enough in 173а. When you need actual files — settings, calibrations, an event log — you put LittleFS on the bare SPI NOR: it never writes over old data and spreads the erases across the chip by itself.
This section covers how LittleFS keeps data consistent across a power cut, what every
lfs_config setting means, a driver of four functions on top of SPI, and the numbers
from experiment: a thousand power cuts for each file size, block wear over two million
saves, space on disk and in memory.
Sources: LittleFS 2.11 (disk format 2.1) from the Zephyr 4.4.2 tree,
directory modules/fs/littlefs: lfs.h, lfs.c, and the design description
DESIGN.md. Experiment: LittleFS built with gcc on a PC on top of a NOR flash model
that pulls the power. The driver for the F407 is built with armclang; the
W25Q128 commands are the same as in 41.5.
Terms used in the documentation
Term Meaning block device the four functions that access the flash block, erase block the unit of erase; on the W25Q it is a 4 KB sector metadata pair a pair of metadata blocks commit a batch of entries in the metadata log with one checksum compaction moving the live entries into the second block of the pair revision count the revision number of a block copy-on-write (COW) writing to a new place instead of overwriting inline file a file stored directly in the metadata CTZ skip-list the list of a file's blocks with pointers that skip ahead in steps lookahead buffer the window for finding free blocks, one bit per block dynamic wear leveling wear leveling over free blocks only superblock the volume description in blocks 0 and 1
42а.1 How LittleFS avoids corrupting data on a power cut
FAT changes data in place: the clusters, the table and the directory — three writes, and between them the volume is inconsistent. LittleFS changes nothing in place.
Metadata — names, sizes, where files are stored — is kept in a pair of blocks. The pair is a log: changes are appended to the end of a block in commits, and every commit has its own 32-bit CRC. Old entries are not touched — overwriting flash would need an erase. When a block is full, the live entries are moved to the second block of the pair: it is erased, gets a revision one higher and is filled completely. Until the new copy is written up to its CRC, the old one is intact in the first block.
File data larger than 256 bytes is stored in separate blocks, and new data
is always written to free blocks. The old blocks stay as they are. The last
step of a save is a commit to the metadata that switches the file to the new
blocks. A file of up to 256 bytes (inline_max in our configuration) has no blocks
of its own at all: it is stored directly in the commit.
On mount LittleFS takes the block of the pair with the newer revision, and in it the last commit with a valid CRC. An unfinished commit fails the CRC and is discarded: the file stays at its previous version.
Wear leveling follows from this. Data goes to free blocks, and the
allocator walks the volume in a circle, starting from a random place after every mount.
LittleFS moves the metadata pair to other blocks
every block_cycles erases (42а.5).
42а.2 Configuration: lfs_config
LittleFS knows nothing about the chip. You give it an lfs_config structure:
four access functions and sizes. The values below are for the first 512 KB
of the W25Q128; the meaning is taken from the comments in lfs.h.
| Field | Ours | Meaning |
|---|---|---|
read_size |
16 | smallest read; all reads are multiples of it |
prog_size |
16 | smallest write; all writes are multiples of it |
block_size |
4096 | erase block; a file larger than an inline one takes at least one block |
block_count |
128 | how many blocks the volume has: 128 × 4 KB = 512 KB |
cache_size |
256 | cache size; you need a read cache, a write cache and one per open file |
lookahead_size |
16 | window for finding free blocks, one bit per block: 16 bytes — 128 blocks |
block_cycles |
500 | after how many erases the metadata pair moves; 100–1000 recommended, −1 — never move |
lfs_init checks the hard conditions: cache_size is a multiple of read_size and
prog_size, block_size is a multiple of cache_size, a block is at least 128 bytes,
block_cycles is not zero. Zero is no longer supported in 2.11 —
lfs.c says so directly: either −1 or a number.
The W25Q can write even a single byte, so prog_size could have been 1. We
take 16: it still divides the 256-byte page, and a cache_size of 256 is a multiple of both.
inline_max is left at zero, which means the largest possible: no more than
cache_size and one eighth of a block. In the run this is 256 bytes.
The buffers are static (read_buffer, prog_buffer,
lookahead_buffer, the file buffer in lfs_file_config). Building with
LFS_NO_MALLOC removes lfs_file_open, which needs a heap; what remains is
lfs_file_opencfg with its own buffer.
42а.3 🔧 The driver: four functions on top of SPI
The W25Q128 sits on SPI1 of the F407 (PA5 — SCK, PA6 — MISO, PA7 — MOSI, PA4 — CS
in software); the commands are from 41.5: 0x03 read, 0x02 page program,
0x20 4 KB sector erase, 0x06 write enable, 0x05 status. A LittleFS
block equals a sector, so erase is a single 0x20 command, and read is
a single 0x03 for a chunk of any length.
Only writing needs attention. LittleFS passes prog a chunk of up to
cache_size bytes at any offset that is a multiple of 16 — and it can cross
a 256-byte page boundary. Page Program does not go past the boundary:
the excess wraps around to the start of the same page (41.5). In the experiment, of 200013
write calls 96775 crossed a page boundary — almost every other one.
So the driver splits the chunk by pages:
static int bd_prog(const struct lfs_config *c, lfs_block_t block,
lfs_off_t off, const void *buffer, lfs_size_t size)
{
const uint8_t *p = buffer;
uint32_t addr = (LFS_FIRST_SECTOR + block) * W25_SECTOR + off;
(void)c;
while (size > 0u) { /* кусками до границы страницы */
uint32_t n = W25_PAGE - addr % W25_PAGE;
if (n > size) {
n = size;
}
w25_write_enable();
CS_LOW();
w25_cmd_addr(W25_CMD_PAGE_PROGRAM, addr);
for (uint32_t i = 0; i < n; i++) {
spi_xfer(p[i]);
}
CS_HIGH();
w25_wait_ready();
addr += n;
p += n;
size -= n;
}
return LFS_ERR_OK;
}
The configuration and the size checks at build time are the same conditions that lfs_init
checks at startup, plus our own:
static const struct lfs_config cfg = {
.read = bd_read, .prog = bd_prog, .erase = bd_erase, .sync = bd_sync,
.read_size = 16,
.prog_size = 16,
.block_size = W25_SECTOR,
.block_count = LFS_BLOCKS,
.block_cycles = LFS_CYCLES,
.cache_size = LFS_CACHE,
.lookahead_size = sizeof lookahead_buf,
.read_buffer = read_buf, .prog_buffer = prog_buf, .lookahead_buffer = lookahead_buf,
};
/* Первые четыре — условия, которые lfs_init проверяет при запуске; здесь — при сборке. */
_Static_assert(LFS_CACHE % 16u == 0u, "cache_size кратен read_size и prog_size");
_Static_assert(W25_SECTOR % LFS_CACHE == 0u, "block_size кратен cache_size");
_Static_assert(W25_SECTOR >= 128u, "блок вмещает указатели CTZ");
_Static_assert(LFS_CYCLES != 0, "block_cycles = 0 не поддерживается: -1 или 100…1000");
Saving the settings means open with truncation, write, close. The new
version becomes visible in lfs_file_close (or lfs_file_sync): until that
moment a power cut leaves the old one.
int settings_save(const struct settings *s)
{
int err = lfs_file_opencfg(&lfs, &file, "settings",
LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC, &file_cfg);
if (err) {
return err;
}
lfs_ssize_t n = lfs_file_write(&lfs, &file, s, sizeof *s);
err = lfs_file_close(&lfs, &file); /* здесь новая версия становится видна */
return (n != (lfs_ssize_t)sizeof *s) ? LFS_ERR_IO : err;
}
The whole file is lfs42b.c: SPI1 setup, bd_read, bd_erase, mounting
with formatting on the first start, reading the settings. The wait for ready
has no timeout here; in a product you bound it, as described in 41.5. For the
F103 only spi1_init changes: on the F1 the pins are configured through CRL,
not MODER and AFR (section 8); the rest is the same.
42а.4 Experiment: a thousand power cuts
LittleFS is built on a PC together with a NOR flash model: 128 blocks of 4096 bytes, erase sets 0xFF, a write can only clear bits. Every erase and every write call is a step. The power cut falls on a random step of a single save: an interrupted write leaves some bytes written and one half-written; an interrupted erase leaves the block a random mix of old data and 0xFF. Then the volume is mounted again and the file is compared with both versions.
For comparison, the naive way: the file sits at a fixed address, and its sectors are erased and written again page by page.
| File | Steps per save | LittleFS: old / new / garbage | Overwrite: steps | Overwrite: old / new / garbage |
|---|---|---|---|---|
| 100 bytes | 1–2 | 1000 / 0 / 0 | 2 | 0 / 2 / 998 |
| 1000 bytes | 6–7 | 988 / 12 / 0 | 5 | 0 / 0 / 1000 |
| 10000 bytes | 44–45 | 999 / 1 / 0 | 43 | 0 / 0 / 1000 |
1000 power cuts per row. LittleFS never returned garbage and never refused to mount. The new version appears only when the cut comes after the CRC of the last commit is already written — hence the 12 and 1 "new". LittleFS also survived cuts during an erase: for the 100-byte file, 521 of the 1000 cuts fell on an erase, for 1000 bytes — 269, for 10000 — 89.
Overwriting corrupts the file almost every time: the old version is erased at the very first step, and the new one is not yet written. The two "new" for the 100-byte file are a cut on the last byte, where the surviving bits happened to match the required ones.
One more check of the model: LittleFS never tried to raise a bit with a write — in all runs it wrote only to erased memory.
42а.5 Wear: where the erases go
The same 100-byte file is saved two million times on an empty volume, with
different block_cycles:
block_cycles |
Blocks touched | Total erases | Blocks 0–1 | Other blocks |
|---|---|---|---|---|
| −1 | 2 | 64516 | 32259 | 0 |
| 500 | 128 | 64518 | 251 | 501–947 |
| 100 | 128 | 64522 | 53 | 303–606 |
The number of erases is almost the same with any setting: one per 31 saves. The file is inline, commits are appended to the block, and an erase is needed only on compaction. The difference is where they land. With −1 all erases go to the superblock pair 0–1, 32259 per block. With a number the pair moves, and the erases spread over all 128 blocks.
With an endurance of 100000 cycles, as in 42.7, this means:
- overwriting a single sector — 100 thousand saves;
- LittleFS with
block_cycles−1 — 6.2 million; - with 500 — 211 million, with 100 — 330 million.
A smaller block_cycles gives more even wear but more frequent moves. A 10000-byte file
shows that leveling works by itself for data: 2000 saves give
6015 erases, three per save, all 128 blocks are touched, and blocks 2–127 get
from 24 to 49 erases both at 500 and at −1. Data blocks are taken
in a circle anyway; block_cycles affects only the metadata.
A full volume. LittleFS levels wear only over free blocks.
Static files occupy blocks that are never erased, and all the erases
converge on what is left. The volume is filled with 4000-byte files, then the same two
million saves, block_cycles 100:
| Used | Free | Blocks touched | Most erases on one block |
|---|---|---|---|
| 100 files in the root | 26 | 27 | 3838 |
| 112 files in the root | 14 | 3 | 43479 |
112 files, settings in directory d/ |
12 | 16 | 6262 |
The second row is worse even than no leveling on an empty volume. The root
directory lives in the superblock pair, and the superblock can move only by
expanding — and lfs.c does not expand it if no more than one eighth
of the volume is free: 16 blocks of 128. With 14 free, the pair 0–1 is erased
43479 times, and block_cycles 500 gives the same number. A frequently rewritten
file in its own directory gets its own pair, and that pair moves as usual.
42а.6 How much space and memory
How many 4 KB blocks a file adds on an empty volume (the empty volume itself takes 2 blocks — the superblock pair):
| File | Blocks |
|---|---|
| 1, 100 or 256 bytes | 0 — inline in the metadata |
| 257, 1000, 4000 or 4096 bytes | 1 |
| 8000 bytes | 2 |
| 16000 bytes | 4 |
| 10 files of 100 bytes | 0 |
| 10 files of 1000 bytes | 10, that is 40 KB |
The boundary is sharp: 256 bytes — zero blocks, 257 — a whole 4 KB block. A file of 1000 bytes took 1255 bytes in SPIFFS in 155.3, and 4096 in LittleFS. So small logs are not split into hundreds of files but kept in one, appended to.
LittleFS 2.11 code, armclang for Cortex-M4, without debug output,
assert and heap:
| Code | Constants | |
|---|---|---|
-Oz |
15830 bytes | 950 bytes |
-O2 |
21794 bytes | 606 bytes |
For Cortex-M3 (F103) with -Oz it is 15822 bytes of code. That is more than FatFS with write support
(~10 KB, 42.8). But the RAM of the driver from 42а.3 is 996 bytes for everything:
three buffers of 256, 16 bytes of lookahead window, lfs_t of 128 bytes and lfs_file_t of
84 bytes. The stack is not included in this number. In FatFS with a 4 KB sector the volume structure alone is over 4 KB.
42а.7 Pitfalls
A write wraps inside a page. The driver passed the chunk from prog
in a single 0x02 command. LittleFS writes up to 256 bytes at any offset that is
a multiple of 16, and almost half of the calls cross a page boundary. The tail
lands at the start of the same page. Splitting by pages belongs in the driver.
block_cycles = 0. It looks like a harmless "default", but in 2.11 zero
is no longer supported, and lfs_init rejects it. Use −1
or 100–1000.
The volume is clogged with small files. Every file larger than 256 bytes takes at least one 4 KB block. A hundred files of 300 bytes took 100 blocks in the experiment — 400 KB of 512.
Wear on an almost full volume. Leveling works only over free blocks. When less than one eighth of the volume is free, the superblock pair stops moving, and a frequently rewritten file in the root erases it tens of times more often than the others. Keep spare space and put such files in their own directory.
Saving is sometimes fast, sometimes slow. Usually it is one write call. Once every 31 saves of a small file there is a compaction with a sector erase — up to 400 ms on the W25Q128 (41.5). Do not save from an interrupt or from a fast control loop.
A power cut before lfs_file_close. Everything written with lfs_file_write but
not closed and not synced is lost on a power cut — the old
version remains. This is not a bug but the point: the file changes as a whole or not at all.
Formatting on any mount error. lfs_mount also returns
an error when the driver reads the wrong address. Formatting in response
erases a real volume. On your own chip this is acceptable on the first
start, but it is better to tell a driver error apart: read the JEDEC ID (41.5)
before mounting.
42а.8 Summary
- LittleFS does not overwrite: metadata is a log of commits with CRCs in a pair of blocks, data goes to free blocks, and the file is switched by the last commit.
- 1000 power cuts for each of three file sizes: 0 garbage for LittleFS, 998–1000 for overwriting with an erase.
- A file of up to 256 bytes is inline in the metadata and takes no blocks; 257 bytes is already a 4 KB block.
- A small file costs one erase per 31 saves. With
block_cycles100, two million saves spread over all 128 blocks, the most erased one 606 times; with −1 the pair 0–1 got 32259 each. - Leveling is over free blocks only: with 14 free of 128 the superblock pair does not move and accumulates 43479 erases.
- The driver is four functions;
progmust split by 256-byte pages: 96775 of 200013 calls crossed a boundary. - Code is 15830 bytes (
-Oz, Cortex-M4); buffers and structures in RAM take 996 bytes.
Full source code
lfs42b.c — LittleFS on a W25Q128 over SPI1 on the STM32F407.
/* Раздел 42а — LittleFS на W25Q128 по SPI1 у STM32F407.
Флеш подключена к SPI1: PA5 — SCK, PA6 — MISO, PA7 — MOSI (AF5),
PA4 — CS программно. Под файловую систему — первые 128 секторов
по 4 КБ (512 КБ); остальное место микросхемы свободно для других нужд.
Проверяется сборкой armclang без предупреждений; часть статических
утверждений повторяет проверки lfs_init из lfs.c. */
#include <stdint.h>
#include "stm32f4xx.h"
#include "lfs.h"
/* ── команды W25Q128 (те же, что в разделе 41.5) ─────────────────────── */
#define W25_CMD_WRITE_ENABLE 0x06u
#define W25_CMD_READ_STATUS1 0x05u
#define W25_CMD_READ_DATA 0x03u
#define W25_CMD_PAGE_PROGRAM 0x02u
#define W25_CMD_SECTOR_ERASE 0x20u /* 4 КБ */
#define W25_STATUS_BUSY 0x01u
#define W25_PAGE 256u
#define W25_SECTOR 4096u
#define W25_SECTORS 4096u /* 16 МБ */
#define LFS_FIRST_SECTOR 0u
#define LFS_BLOCKS 128u /* 512 КБ */
#define LFS_CACHE 256u
#define LFS_CYCLES 500 /* -1 — без переезда метаданных */
#define CS_LOW() (GPIOA->BSRR = GPIO_BSRR_BR4)
#define CS_HIGH() (GPIOA->BSRR = GPIO_BSRR_BS4)
void spi1_init(void)
{
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC->APB2ENR |= RCC_APB2ENR_SPI1EN;
GPIOA->BSRR = GPIO_BSRR_BS4; /* CS вверху до настройки вывода */
GPIOA->MODER = (GPIOA->MODER & ~0x0000FF00u)
| (1u << 8) | (2u << 10) | (2u << 12) | (2u << 14); /* PA4 выход, PA5–7 — AF */
GPIOA->OSPEEDR |= 0x0000FF00u; /* PA4–7: высокая скорость фронтов */
GPIOA->AFR[0] = (GPIOA->AFR[0] & ~0xFFF00000u)
| (5u << 20) | (5u << 24) | (5u << 28); /* AF5 — SPI1 */
SPI1->CR1 = SPI_CR1_MSTR | SPI_CR1_SSM | SPI_CR1_SSI /* ведущий, NSS программно */
| SPI_CR1_BR_0 /* fPCLK / 4: 84 / 4 = 21 МГц */
| SPI_CR1_SPE; /* режим 0: CPOL = 0, CPHA = 0 */
}
static uint8_t spi_xfer(uint8_t data)
{
while (!(SPI1->SR & SPI_SR_TXE)) { }
*(volatile uint8_t *)&SPI1->DR = data;
while (!(SPI1->SR & SPI_SR_RXNE)) { }
return *(volatile uint8_t *)&SPI1->DR;
}
static void w25_cmd_addr(uint8_t cmd, uint32_t addr)
{
spi_xfer(cmd);
spi_xfer((uint8_t)(addr >> 16));
spi_xfer((uint8_t)(addr >> 8));
spi_xfer((uint8_t)addr);
}
static void w25_write_enable(void)
{
CS_LOW();
spi_xfer(W25_CMD_WRITE_ENABLE);
CS_HIGH();
}
static void w25_wait_ready(void)
{
CS_LOW();
spi_xfer(W25_CMD_READ_STATUS1);
while (spi_xfer(0xFF) & W25_STATUS_BUSY) { }
CS_HIGH();
}
/* ── четыре функции блочного устройства для LittleFS ──────────────────── */
static int bd_read(const struct lfs_config *c, lfs_block_t block,
lfs_off_t off, void *buffer, lfs_size_t size)
{
uint8_t *p = buffer;
(void)c;
CS_LOW();
w25_cmd_addr(W25_CMD_READ_DATA, (LFS_FIRST_SECTOR + block) * W25_SECTOR + off);
while (size--) {
*p++ = spi_xfer(0xFF);
}
CS_HIGH();
return LFS_ERR_OK;
}
static int bd_prog(const struct lfs_config *c, lfs_block_t block,
lfs_off_t off, const void *buffer, lfs_size_t size)
{
const uint8_t *p = buffer;
uint32_t addr = (LFS_FIRST_SECTOR + block) * W25_SECTOR + off;
(void)c;
while (size > 0u) { /* кусками до границы страницы */
uint32_t n = W25_PAGE - addr % W25_PAGE;
if (n > size) {
n = size;
}
w25_write_enable();
CS_LOW();
w25_cmd_addr(W25_CMD_PAGE_PROGRAM, addr);
for (uint32_t i = 0; i < n; i++) {
spi_xfer(p[i]);
}
CS_HIGH();
w25_wait_ready();
addr += n;
p += n;
size -= n;
}
return LFS_ERR_OK;
}
static int bd_erase(const struct lfs_config *c, lfs_block_t block)
{
(void)c;
w25_write_enable();
CS_LOW();
w25_cmd_addr(W25_CMD_SECTOR_ERASE, (LFS_FIRST_SECTOR + block) * W25_SECTOR);
CS_HIGH();
w25_wait_ready();
return LFS_ERR_OK;
}
static int bd_sync(const struct lfs_config *c)
{
(void)c;
return LFS_ERR_OK; /* запись синхронная, кэша нет */
}
static uint8_t read_buf[LFS_CACHE], prog_buf[LFS_CACHE], file_buf[LFS_CACHE];
static uint32_t lookahead_buf[4]; /* 16 байт = 128 бит, по биту на блок */
static const struct lfs_config cfg = {
.read = bd_read, .prog = bd_prog, .erase = bd_erase, .sync = bd_sync,
.read_size = 16,
.prog_size = 16,
.block_size = W25_SECTOR,
.block_count = LFS_BLOCKS,
.block_cycles = LFS_CYCLES,
.cache_size = LFS_CACHE,
.lookahead_size = sizeof lookahead_buf,
.read_buffer = read_buf, .prog_buffer = prog_buf, .lookahead_buffer = lookahead_buf,
};
/* Первые четыре — условия, которые lfs_init проверяет при запуске; здесь — при сборке. */
_Static_assert(LFS_CACHE % 16u == 0u, "cache_size кратен read_size и prog_size");
_Static_assert(W25_SECTOR % LFS_CACHE == 0u, "block_size кратен cache_size");
_Static_assert(W25_SECTOR >= 128u, "блок вмещает указатели CTZ");
_Static_assert(LFS_CYCLES != 0, "block_cycles = 0 не поддерживается: -1 или 100…1000");
/* Остальные — свои: геометрия W25Q128 и окно поиска свободных блоков. */
_Static_assert(sizeof lookahead_buf * 8u >= LFS_BLOCKS, "окно поиска свободных блоков покрывает весь том");
_Static_assert(LFS_FIRST_SECTOR + LFS_BLOCKS <= W25_SECTORS, "том помещается в 16 МБ");
_Static_assert(W25_PAGE % 16u == 0u, "страница кратна prog_size");
/* ── настройки прибора: один файл, сохраняется целиком ────────────────── */
struct settings {
uint32_t magic;
int32_t offset_mv;
uint32_t gain_q16;
uint32_t boot_count;
};
_Static_assert(sizeof(struct settings) <= LFS_CACHE, "файл настроек помещается в метаданные (inline)");
static lfs_t lfs;
static lfs_file_t file;
static const struct lfs_file_config file_cfg = { .buffer = file_buf };
int storage_mount(void)
{
int err = lfs_mount(&lfs, &cfg);
if (err) { /* первый запуск: тома нет */
err = lfs_format(&lfs, &cfg);
if (!err) {
err = lfs_mount(&lfs, &cfg);
}
}
return err;
}
int settings_save(const struct settings *s)
{
int err = lfs_file_opencfg(&lfs, &file, "settings",
LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC, &file_cfg);
if (err) {
return err;
}
lfs_ssize_t n = lfs_file_write(&lfs, &file, s, sizeof *s);
err = lfs_file_close(&lfs, &file); /* здесь новая версия становится видна */
return (n != (lfs_ssize_t)sizeof *s) ? LFS_ERR_IO : err;
}
int settings_load(struct settings *s)
{
int err = lfs_file_opencfg(&lfs, &file, "settings", LFS_O_RDONLY, &file_cfg);
if (err) {
return err; /* LFS_ERR_NOENT — ещё не сохраняли */
}
lfs_ssize_t n = lfs_file_read(&lfs, &file, s, sizeof *s);
lfs_file_close(&lfs, &file);
return (n == (lfs_ssize_t)sizeof *s) ? LFS_ERR_OK : LFS_ERR_CORRUPT;
}
int main(void)
{
struct settings s = { 0x5E77u, 0, 1u << 16, 0u };
spi1_init();
if (storage_mount() == LFS_ERR_OK) {
(void)settings_load(&s);
s.boot_count++;
(void)settings_save(&s);
lfs_unmount(&lfs);
}
for (;;) { }
}
Next: section 47 "Control: feedback, P and PID controllers".