Skip to content

Instantly share code, notes, and snippets.

@aaronjamt
Last active February 29, 2024 19:20
Show Gist options
  • Save aaronjamt/c8a3fc02739cc001807b7ba4abb1aa3d to your computer and use it in GitHub Desktop.
Save aaronjamt/c8a3fc02739cc001807b7ba4abb1aa3d to your computer and use it in GitHub Desktop.
RP2040 persistent storage using flash memory (Arduino)
#include "hardware/flash.h"
// The two primary functions that you would use are:
// bool read(uint8_t *output, uint8_t size);
// void write(uint8_t *data, uint8_t size);
// The read(...) function will either fill the *output buffer
// with size bytes (and return true), or will return false if
// there is no saved data available to be read.
// The write(...) function will write the provided buffer to
// flash memory.
// There is also a function:
// bool isFirstRun()
// which can be called to check if this is the first time
// this program has run since it was flashed to the MCU.
// Technically, this checks for whether this persistent
// storage module has ever performed a write operation,
// but in most cases that will likely mean the same thing.
// NOTE:
// The data being read or written can be, at most, 255 bytes in
// length, due to the way the flash memory is arranged (see the
// end of this file for a detailed explanation of how it works,
// including information about why this limitation exists).
namespace PersistentStorage {
// How many sectors to use? Each sector contains CHUNKS_PER_SECTOR
// chunks, which is 16 chunks per sector on my RP2040 board.
const uint8_t NUMBER_OF_SECTORS = 1;
// The number of chunks in each flash sector
const uint8_t CHUNKS_PER_SECTOR = FLASH_SECTOR_SIZE / FLASH_PAGE_SIZE;
// This buffer contains one extra flash sector, as a sector is the minimum size we can erase, and we
// don't know where this variable will end up in memory. By giving it one extra sector, we guarantee
// that at least NUMBER_OF_SECTORS flash sector(s) will fit entirely within it, making those sector(s)
// safe to erase and reprogram, without risking accidentally overwriting the program itself.
const uint8_t _PERSISTENT_STORAGE_BUFFER[FLASH_SECTOR_SIZE * (NUMBER_OF_SECTORS+1)] = {};
// This pointer points to the beginning of the "usable" chunk of the persistent storage, which
// is the flash sector that entirely fits within the persistent storage buffer (as explained above)
uint8_t *PERSISTENT_STORAGE = (
// If the persistent storage buffer is already aligned to the flash sector boundary, use it as the pointer
((unsigned long)_PERSISTENT_STORAGE_BUFFER % FLASH_SECTOR_SIZE == 0) ? (uint8_t*)_PERSISTENT_STORAGE_BUFFER : (uint8_t*)(
// Otherwise, calculate the offset for the first full sector within the bounds of the buffer
((unsigned long)_PERSISTENT_STORAGE_BUFFER - ((unsigned long)_PERSISTENT_STORAGE_BUFFER % FLASH_SECTOR_SIZE) + FLASH_SECTOR_SIZE)
)
);
uint32_t PERSISTENT_STORAGE_FLASH_OFFSET = ((uint32_t)PERSISTENT_STORAGE - XIP_BASE);
// Forward declarations
uint8_t findCurrentChunk();
bool read(void *output, uint8_t size);
void write(void *data, uint8_t size);
void _writePage(uint32_t pageAddr, uint8_t *chunk);
void _eraseSector(uint32_t sectorAddr);
inline bool isFirstRun();
// Preproc definitions and macros
#define INVALID_CHUNK 0xFF
#define CHUNK_VALID_FLAG 0x55
#define ChunkAddr(chunkIdx) (PERSISTENT_STORAGE + (FLASH_PAGE_SIZE*chunkIdx))
uint8_t findCurrentChunk() {
uint8_t lastValidChunk = INVALID_CHUNK;
// Iterate over chunks until we find the last valid chunk
for (uint8_t chunkIdx = 0; chunkIdx < NUMBER_OF_SECTORS*CHUNKS_PER_SECTOR; chunkIdx++) {
if (ChunkAddr(chunkIdx)[0] == CHUNK_VALID_FLAG) {
// Valid chunk!
lastValidChunk = chunkIdx;
} else {
// No more valid chunks
break;
}
}
return lastValidChunk;
}
bool read(void *output, uint8_t size) {
uint8_t chunkIdx = findCurrentChunk();
if (chunkIdx == INVALID_CHUNK) {
return false;
}
// Add one to the chunk address to get the actual data part of the chunk (first byte is a flag)
memcpy(output, ChunkAddr(chunkIdx) + 1, size);
return true;
}
void write(void *data, uint8_t size) {
// Will get the next available chunk
uint8_t chunkIdx = findCurrentChunk() + 1;
// If we've already written all chunks (or ), erase the flash
if (chunkIdx >= NUMBER_OF_SECTORS*CHUNKS_PER_SECTOR || chunkIdx == 0) {
for (uint8_t sectorIdx = 0; sectorIdx < NUMBER_OF_SECTORS; sectorIdx++)
_eraseSector(sectorIdx);
// Since we've erased the flash sector(s), write to chunk 0
chunkIdx = 0;
}
// Prepare the chunk by creating a 256 byte buffer to write
// The first byte should be set to indicate a valid chunk
uint8_t chunk[256] = {CHUNK_VALID_FLAG};
memcpy(chunk+1, data, size);
// Write the actual chunk to the corresponding flash page
_writePage(chunkIdx * FLASH_PAGE_SIZE, chunk);
}
// Write a single FLASH_PAGE_SIZE-byte flash page
// Wrapper that takes care of disabling interrupts, locking the 2nd core, etc
void _writePage(uint32_t pageAddr, uint8_t *chunk) {
// The address that was passed is the address within the persistent
// storage region, add the offset to find the true flash address
pageAddr += PERSISTENT_STORAGE_FLASH_OFFSET;
// We can't have interrupts or the 2nd core running
rp2040.idleOtherCore();
uint32_t ints = save_and_disable_interrupts();
// Do the actual write
flash_range_program(pageAddr, chunk, FLASH_PAGE_SIZE);
// Set things back
restore_interrupts(ints);
rp2040.resumeOtherCore();
}
// Erase a single FLASH_SECTOR_SIZE-byte flash sector
// Wrapper that takes care of disabling interrupts, locking the 2nd core, etc
void _eraseSector(uint32_t sectorAddr) {
// The address that was passed is the address within the persistent
// storage region, add the offset to find the true flash address
sectorAddr += PERSISTENT_STORAGE_FLASH_OFFSET;
// We can't have interrupts or the 2nd core running
rp2040.idleOtherCore();
uint32_t ints = save_and_disable_interrupts();
// Do the actual erase
flash_range_erase(sectorAddr, FLASH_SECTOR_SIZE);
// Set things back
restore_interrupts(ints);
rp2040.resumeOtherCore();
}
// Determines if this is the first time this program has been run.
// It takes advantage of the fact that the program, when compiled, fills persistent storage
// with 0x00s by default, but when the flash chunks are erased, they are filled with 0xFF,
// and when we write data, we write a 3rd, different value. This means that the only time
// the chunk validity flag(s) would ever be set to 0x00 is after a program flash, before
// the first erase or write operation(s). As a result, we can check just the first chunk's
// validity flag and, if it's 0x00, we can assume the rest are, and that we haven't yet
// written any data.
inline bool isFirstRun() {
return PERSISTENT_STORAGE[0] == 0x00;
}
// Here's how it all works:
// * Use flash page size as the size of a "chunk" of data
// * Search through each "chunk" looking for a specific byte flag to indicate there's valid data
// * The last valid chunk is the current one, ignore any previous chunks
// * If it doesn't find a valid chunk, there's no save data
// * By using a value other than 0x00 and 0xFF, a chunk is considered invalid both after a flash sector erase,
// and after initial programming with 0x00s
// * When writing, find the most recent valid chunk and save to the slot after it
// * That way, any future reads will consider it the "current" chunk and ignore the rest
// * If there's no more free slots, erase the flash sector and start from first chunk
// * Also erase if findCurrentChunk() returns INVALID_CHUNK, meaning something went wrong, which usually indicats
// that all of the chunks are full of 0x00. This happens because, when the program is compiled, that data is
// populated with 0x00s by default, so after the program is flashed, it's all 0x00. After the flash sector(s)
// are erased, however, they are left at 0xFF instead. This could potentially be used to do a "first run" check,
// might be something to look into in the future (edit: now implemented).
// This means that, for each flash erase, there can be multiple chunks written (up to CHUNKS_PER_SECTOR)
// This also means that you can theoretically undo a write by going to the 2nd or 3rd used chunk, provided there
// wasn't an erase, although there are no guarantees about this and it's not really supported.
// It's up to userland code to handle what the data actually means, and each chunk has 255 bytes available (since
// the first byte is reserved for "valid chunk" flag). This means that you can, for example, create a struct
// to store different parameters, and pass a pointer to the struct to the read/write functions to automatically
// store different values with different data types, without having to do any special (de)serialization.
}
@aaronjamt
Copy link
Author

aaronjamt commented Feb 18, 2024

This uses the same flash memory that code executes from. It works with the Raspberry Pi Pico Arduino core, but should be fairly easy to tweak for use with the C SDK (I believe just changing rp2040.idleOtherCore() and rp2040.resumeOtherCore() to multicore_lockout_start_blocking() and multicore_lockout_end_blocking() but I have not tested it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment