The Architecture Divide: Why PROGMEM Exists
When porting Arduino sketches between different microcontroller families, few concepts cause as much compilation friction and runtime confusion as Arduino PROGMEM. Originally designed to solve a very specific hardware limitation in 8-bit AVR microcontrollers, the PROGMEM keyword and its associated pgmspace.h library have become deeply embedded in the Arduino ecosystem. However, as makers migrate to 32-bit ARM Cortex-M boards (like the SAMD21) and Xtensa/RISC-V architectures (like the ESP32 and RP2040), the underlying memory models change drastically.
This compatibility guide breaks down exactly how PROGMEM behaves across the most popular MCU architectures in 2026, why your legacy code might trigger HardFaults or silent data corruption, and how to write truly cross-platform flash-storage code.
AVR (Uno, Mega, Nano): The Harvard Architecture Bottleneck
The official Arduino PROGMEM reference was written specifically for the AVR architecture. AVRs utilize a strict Harvard architecture, meaning program memory (Flash) and data memory (SRAM) occupy entirely separate address spaces and are accessed via different hardware buses.
On an ATmega328P (Arduino Uno), you have 32KB of Flash but only 2KB of SRAM. If you declare a standard constant array:
const char myString[] = "This is a long string that wastes SRAM";
The compiler will copy this string from Flash into SRAM during the boot sequence (.data segment initialization), consuming precious RAM. By appending PROGMEM, you force the compiler to leave the data in the .progmem.data section of Flash.
The Catch: LPM Instructions
Because the AVR Arithmetic Logic Unit (ALU) cannot directly fetch data from the Flash bus in a single standard instruction, you cannot use standard pointer dereferencing. You must use the LPM (Load Program Memory) assembly instruction, which is abstracted by the avr-libc pgmspace macros like pgm_read_byte() and pgm_read_word().
AVR Rule of Thumb: If you use
PROGMEMon an AVR, you must usepgm_read_*macros or theF()wrapper to read the data. Standard pointers will read from the equivalent SRAM address, returning garbage data.
ESP32 & ESP32-S3: Flash Caching and XIP
The ESP32 family represents a massive paradigm shift. It utilizes a Von Neumann-style unified memory space managed by a Memory Management Unit (MMU). External SPI Flash is mapped into the CPU's data address space via a cache.
In the modern ESP32 Arduino Core, PROGMEM is essentially a legacy no-op. When you declare const char str[] PROGMEM = "Hello";, the compiler places the data in the .rodata (Read-Only Data) segment. The ESP32's MMU automatically handles fetching this data from SPI Flash into the internal SRAM cache when accessed.
How ESP32 Handles pgmspace
To maintain backward compatibility with thousands of legacy AVR libraries, the ESP32 Arduino core includes a dummy pgmspace.h. If your code calls pgm_read_byte(addr), the ESP32 core simply aliases it to a standard pointer dereference: (*(const uint8_t *)(addr)).
According to the Espressif Memory Types documentation, data placed in .rodata is accessed via the cache. If the data is not currently in the cache, a cache miss occurs, and the CPU stalls for a few microseconds while the SPI flash is read. For high-performance interrupt service routines (ISRs) on the ESP32, you should avoid PROGMEM (Flash) and instead use DRAM_ATTR to force data into internal SRAM, bypassing the cache latency entirely.
SAMD21 & RP2040: Unified Memory Simplicity
Boards like the Arduino Nano 33 IoT (ATSAMD21G18) and the Raspberry Pi Pico (RP2040) use ARM Cortex-M0+ cores. These feature a unified memory address space where Flash memory is mapped directly into the CPU's standard memory map (typically starting at 0x00000000 or 0x10000000).
- SAMD21:
PROGMEMis defined as empty. Data is placed in standard Flash. Standard pointers work perfectly. Usingpgm_read_byte()is unnecessary and often unsupported without custom macro definitions. - RP2040: The RP2040 uses XIP (eXecute In Place) via a QSPI interface. Like the ESP32, it relies on a cache (2KB XIP cache).
PROGMEMis ignored by the compiler, and standard pointer arithmetic reads directly from the cached Flash window.
Cross-Platform Compatibility Matrix
Understanding how different cores interpret flash storage directives is critical for library developers. Below is a compatibility matrix for modern MCU families.
| Architecture | PROGMEM Behavior | pgm_read_* Required? | F() Macro Behavior | Primary Bottleneck |
|---|---|---|---|---|
| AVR (8-bit) | Places in .progmem.data | Yes (Strict) | Wraps in __FlashStringHelper | SRAM Limits (2KB-8KB) |
| ESP32 (Xtensa) | Places in .rodata (Cached) | No (Aliased to pointer) | Returns standard const char* | SPI Flash Cache Misses |
| SAMD (ARM M0+) | Ignored / Placed in Flash | No (Causes compile error) | Returns standard const char* | None (Direct Bus Access) |
| RP2040 (ARM M0+) | Ignored / Placed in XIP | No | Returns standard const char* | QSPI Cache Latency |
Writing Universal Code: The Macro Strategy
If you are developing an Arduino library that must compile flawlessly on an Uno, an ESP32, and a Nano 33 IoT, you cannot rely on pgmspace.h alone. Hardcoding pgm_read_byte() will cause fatal compilation errors on SAMD boards where the macro does not exist.
The most robust E-E-A-T approved strategy is to implement an architecture-agnostic abstraction layer using preprocessor directives in your library's header file:
#pragma once
#include
// Define universal flash read macros
#if defined(ARDUINO_ARCH_AVR)
#include
#define FLASH_READ_BYTE(addr) pgm_read_byte(addr)
#define FLASH_READ_WORD(addr) pgm_read_word(addr)
#define FLASH_STRING_TYPE const __FlashStringHelper*
#elif defined(ARDUINO_ARCH_ESP32) || defined(ARDUINO_ARCH_SAMD) || defined(ARDUINO_ARCH_RP2040)
#define FLASH_READ_BYTE(addr) (*(const uint8_t *)(addr))
#define FLASH_READ_WORD(addr) (*(const uint16_t *)(addr))
#define FLASH_STRING_TYPE const char*
#else
#error "Unsupported architecture for Flash Abstraction"
#endif
// Universal PROGMEM definition fallback
#ifndef PROGMEM
#define PROGMEM
#endif
By using FLASH_READ_BYTE() in your library's .cpp files, the compiler will automatically select the correct hardware-level instruction (LPM for AVR, direct pointer dereference for ARM/Xtensa) without requiring the end-user to modify their sketches.
Edge Cases: Alignment Faults and the F() Macro
The 32-bit Alignment Trap
On 8-bit AVRs, memory is byte-addressable, and reading a 16-bit or 32-bit integer from an odd memory address works perfectly fine via pgm_read_dword(). However, ARM Cortex-M processors (SAMD, RP2040) and Xtensa (ESP32) enforce memory alignment.
If you cast a PROGMEM byte array to a uint32_t pointer on an ARM board and attempt to read it from an unaligned address (e.g., address 0x00000005), the CPU will trigger a HardFault exception, instantly crashing your microcontroller. When porting lookup tables from AVR to 32-bit boards, always ensure your data structures are padded to 4-byte boundaries using __attribute__((aligned(4))).
The F() Macro Illusion
The F() macro is ubiquitous in AVR code to save RAM during Serial.print() calls. On AVR, it wraps the string in a __FlashStringHelper class, signaling the Print base class to use pgm_read_byte iteratively. On ESP32 and SAMD, F() is simply defined as:
#define F(string_literal) (string_literal)
It does absolutely nothing except return the standard string pointer. While this means your code will compile, it also means that on ESP32, wrapping massive JSON payloads in F() won't trigger any special memory handling—it just relies on the standard MMU cache. If you are pushing the limits of the ESP32's cache, consider storing large static assets in LittleFS or PROGMEM-partitioned SPIFFS instead of hardcoding them as C-arrays.
Memory Profiling: Verifying Your Sections
Don't guess where your compiler is putting your data. Use architecture-specific toolchains to inspect your compiled binary:
- AVR: Run
avr-objdump -h -S sketch.elfand look for the.progmem.datasection to verify your arrays aren't leaking into.data(SRAM). - ESP32: Use
xtensa-esp32-elf-sizeto check therodatasegment size. If yourdatasegment is unusually large, you have failed to properly flag constants, and they are being loaded into precious RAM at boot.
Mastering Arduino PROGMEM compatibility requires moving beyond the 8-bit mindset. By understanding the underlying MMU, cache behaviors, and bus architectures of modern MCUs, you can write highly optimized, crash-free firmware that scales seamlessly from a $5 Arduino Nano to a $15 ESP32-S3 dev kit.






