Using flash memory as non-volatile storage on the Pi Pico microcontroller

Chip logo The Raspberry Pi Pico is a relatively-new microntroller board based on a chip called the RP2040. This has a more modern, dual-core, ARM-based architecture than AVR-style devices, at a broadly similar price. The board has has 2Mb of flash ROM, 264kB of RAM, and has a rated clock speed of 133MHz. What it doesn't have is separate non-volatile memory like EEPROM although, of course, it's possible to add storage as a peripheral.

An alternative to peripheral storage is to use part of the flash ROM as storage. Although this mechanism is documented, it's a little fiddly. In this article I'll try to explain the details. I'm assuming that you're writing code using the official C/C++ SDK, which uses CMake, for better or worse. The SDK provides two specific API calls for modifying the flash -- flash_range_erase() and flash_range_program(). These API calls are defined in a header file hardware/flash.h.

Addressing

When reading, the flash ROM appears in the ARM address space starting at address 0x1000 0000 (that's 256Mb from the start of the 32-bit address space of the device). When uploading code in UF2 format, using the default bootloader, the user code is loaded into the start of flash and, broadly speaking, occupies a contiguous range of addresses that is the size of the program. So, for example, 256kB of program code will occupy the first 256kB of flash, and will appear in the ARM address space as the range 0x10000000 to 0x1004 0000.

The SDK defines a constant for the start of flash in the memory map -- it is XIP_BASE, which will be defined if you #include flash.h.

So if, for some reason, you wanted to read the first byte of your own program code, you could do:

#include <hardware/flash.h>

char *p = (char *)XIP_BASE;
char first_byte = *p;

The above applies to reading the flash. When writing or erasing it, things are different. The flash_range_erase() and flash_range_program() functions both take as their first an argument an offset into the flash. That makes sense, in a way -- operations that are specific to the flash are not operations in the ARM memory map. Still, it does mean that reading data from flash, and writing data to flash, use different addressing schemes.

For example, data your program writes at, say, offset 0x5 0000 in the flash, will appear in the memory map at address 0x1005 0000. It's probably more future-proof to think of this address as XIP_BASE + 0x50000.

Erasing the flash

In principle, you just need to do:

flash_range_erase (offset, size);

offset, as I described above, is the position in the flash, not a memory address. size must be a multiple of the "sector size", which is defined as the constant FLASH_SECTOR_SIZE, with a value of 4kB.

Writing the flash

Again, in principle, you need:

flash_range_program (offset, buffer, size);

The buffer argument points to the data to be written, which is of size size. This size must be a multiple of the "page size", which is defined as the constant FLASH_PAGE_SIZE, with a value of 256 bytes. Notice that the block sizes for writing and erasing are different.

Interrupts

In practice, I've found it necessary to disable interrupts when writing the flash. So a flash write will be like this:

uint32_t ints = save_and_disable_interrupts();
flash_range_erase (...);
restore_interrupts (ints);

It may not be necessary to disable interrupts if there are, in fact, no active interrupts that will run code in the flash. However, this is difficult to ensure -- the USB UART code, for example, will generate interrupts. Failing to suppress interrupts leads to highly unpredictable, always undesirable, behaviour.

So far as I know, the save_and_disable_interrupts() function only works on the core from which it is called. This means that writing flash safely in a dual-core application, with both cores running code in flash, is likely to be extremely fiddly.

Deciding where to store data

Providing that you start on a 4kB boundary, you can write any part of the flash that isn't used by program code.

If you only want to store a few bytes, deciding where to store those bytes probably isn't a big problem -- just store it at the very end of flash. It's unlikely that the program code will expand to that size and, if it does, you've got bigger problems.

Storing large volumes of data is more of a problem, because it's all to easy to overwrite the program code. At runtime, you can determine the end of the program in flash from the intrinsic variable __flash_binary_end (which is a memory address, not an offset in flash). There's no particular reason to think that this position will align on a 4kB boundary so, if you wanted to use it to locate the start of the writeable area of flash, you'd have to round it up.

However, if you expect the data to remain valid over multiple revisions of the program, it's inadvisable to use flash directly after the program code for storage. If the program code gets larger in later revisions -- as it tends to -- you'll have a conflict.

You can get an idea of the amount of flash occupied by the program, without running it, by examining the value of __flash_binary_end in the ELF file generated by the compiler:

$ objdump --all myprog.elf | grep flash_binary_end
10043d64 g .ARM.attributes 00000000 __flash_binary_end

In this case, my program is 0x43D64 bytes in size -- that value is obtained by subtracting XIP_BASE from the address in the ELF file.

CMake settings

If you're building using CMake -- and it's difficult-to-impossible to do otherwise -- you'll need to include the libraries for the flash and interrupt control functions. In CMakeLists.txt you'll need something like:

target_link_libraries (my_binary pico_stdlib hardware_flash hardware_sync)

Summary

Here are the key points to bear in mind.

One final point: flash memory cannot be rewritten indefinitely. It's unlikely that you'll wear out the flash by uploading program code to it; but if, for example, you put a filesystem in flash, you might have to consider matters like wear leveling.

On the one hand, the low cost of the pico means we might not have to worry too much about this kind of optimization. On the other hand, replacing a Pico that is soldered into a piece of equipment in a remote location might be a bit of a nuisance.