ESP32 Flash Memory and How Firmware Is Stored
Every ESP32-based device has external SPI flash memory where the firmware lives. On the BLEShark Nano (ESP32-C3), that flash is 4MB. The bootloader, firmware, file system, and OTA update mechanism all share that space through a partition table. Understanding how it's laid out explains how OTA updates work, why rollback is possible, and how your DuckyScript files and captive portal HTML survive a firmware update.
Table of Contents
- Flash Memory Basics
- The Partition Table
- The Bootloader
- App Partitions: app0 and app1
- User Data: LittleFS / SPIFFS
- How OTA Updates Work at the Flash Level
- Rollback: Why the Old Firmware Stays
- BLEShark Nano Specifics
Flash Memory Basics
SPI NOR flash is the standard storage medium for ESP32 firmware. It's non-volatile (survives power loss), byte-addressable for reads, and erase-before-write for modifications. Flash is organized in sectors (typically 4KB each) and blocks (typically 64KB). You can read individual bytes, but to write, you must erase a full sector first - all bytes in the sector become 0xFF, then you write your new data.
This erase-before-write requirement has two practical consequences. First, flash writes are slower than reads. A sector erase takes 30-100ms. Second, flash has a limited erase cycle count - typically 100,000 cycles per sector. Firmware partitions get written infrequently (each OTA update), so this isn't a concern for app slots. Filesystem partitions used for storing user files need wear-leveling if written frequently, which is part of why LittleFS is preferred over raw SPIFFS for user data.
The ESP32-C3 accesses flash through an SPI interface. The processor executes code directly from flash via an instruction cache (IROM), though frequently-executed code is typically loaded into IRAM (internal RAM) for performance.
graph TD
subgraph "ESP32-C3 Flash Layout (4MB)"
subgraph "Boot Region (0x0000 - 0x9FFF)"
BL["Bootloader
0x0000 - 0x6FFF
28KB
Second-stage boot"]
PT["Partition Table
0x8000 - 0x8FFF
4KB
Defines all partitions"]
end
subgraph "Application Region"
NVS["NVS
0x9000 - 0xEFFF
24KB
Key-value store
(WiFi creds, settings)"]
OTD["otadata
0xF000 - 0xFFFF
4KB
Tracks active app slot"]
APP0["app0 (factory)
0x10000 - 0x1FFFFF
~1.9MB
Primary firmware"]
APP1["app1 (ota_0)
0x200000 - 0x2FFFFF
~1MB
OTA update slot"]
end
subgraph "Data Region"
FS["LittleFS/SPIFFS
0x300000 - 0x3FFFFF
~1MB
User files, configs,
DuckyScript payloads"]
end
end
BL -->|"Reads"| PT
PT -->|"Locates"| OTD
OTD -->|"Selects active"| APP0
OTD -.->|"After OTA"| APP1
APP0 -->|"Mounts"| FS
APP0 -->|"Reads config"| NVS
ESP32-C3 flash memory map showing the bootloader chain, dual app partitions for OTA updates, NVS key-value storage, and the file system partition for user data.
The Partition Table
The partition table lives at flash address 0x8000 (32KB from the start of flash). It's a 3072-byte table that tells the bootloader and the esp-idf runtime where each logical partition starts and ends. Each entry is 32 bytes and specifies a partition type, subtype, offset, size, and name.
The partition table on the Nano looks like this:
| Name | Type | SubType | Offset | Size |
|---|---|---|---|---|
| nvs | data | nvs | 0x9000 | 0x5000 (20KB) |
| otadata | data | ota | 0xE000 | 0x2000 (8KB) |
| app0 | app | ota_0 | 0x10000 | 0x1E0000 (1920KB) |
| app1 | app | ota_1 | 0x1F0000 | 0x1E0000 (1920KB) |
| spiffs | data | spiffs | 0x3D0000 | 0x20000 (128KB) |
| coredump | data | coredump | 0x3F0000 | 0x10000 (64KB) |
The nvs partition is Non-Volatile Storage, a key-value store used by ESP-IDF to persist device configuration, WiFi credentials, and application settings. The otadata partition stores which app slot is currently active. The app0 and app1 partitions are the two OTA firmware slots. The spiffs partition stores user files. The coredump partition captures CPU state, stack frames, and heap snapshot on panic for post-mortem analysis.
The Bootloader
The bootloader lives at flash address 0x0 (the very start). On ESP32-C3, Espressif's second-stage bootloader is a few dozen kilobytes and handles three main tasks:
graph TD
RST["Power On / Reset"] --> ROM["First-stage ROM bootloader
(burned in silicon)"]
ROM --> BL["Second-stage bootloader
(reads partition table)"]
BL --> CHK{"Check otadata
partition"}
CHK -->|"No OTA data"| FACTORY["Boot app0
(factory partition)"]
CHK -->|"OTA data present"| VALIDATE{"Validate
selected app"}
VALIDATE -->|"Header valid
SHA-256 OK"| BOOTOTA["Boot selected app
(app0 or app1)"]
VALIDATE -->|"Corrupted or
invalid header"| FALLBACK["Fallback to
other app slot"]
BOOTOTA --> RUN["Firmware executing"]
FACTORY --> RUN
FALLBACK --> RUN2{"Other slot
valid?"}
RUN2 -->|"Yes"| RUN
RUN2 -->|"No"| FAIL["Boot failure
(serial recovery needed)"]
RUN --> CONFIRM{"App confirms
itself valid?"}
CONFIRM -->|"Yes - esp_ota_mark_valid"| STABLE["Mark as stable
in otadata"]
CONFIRM -->|"No - timeout/crash"| ROLLBACK["Rollback on
next reboot"]
ESP32 boot decision tree - the bootloader validates firmware integrity before execution and supports automatic rollback if newly flashed firmware fails to confirm itself.
First, it checks which app partition is marked as active by reading the otadata partition. The otadata stores a sequence counter - each time an OTA update completes, the counter increments and the new partition is flagged as boot target.
Second, it verifies the firmware image. Espressif uses a SHA256 hash embedded in the image header. The bootloader recomputes the hash over the firmware binary and compares it. If the hash doesn't match, the bootloader can fall back to the previous partition (if rollback is configured) or halt and report an error.
Third, it sets up the memory map, configures the SPI flash interface, and jumps to the application entry point.
The bootloader itself is immutable after manufacturing - it lives in write-protected flash at address 0x0 and can't be updated by OTA. This is intentional. A corrupted bootloader would brick the device.
App Partitions: app0 and app1
The OTA partition scheme uses two identical-sized app partitions. At any time, one is "active" (currently running) and the other is "inactive" (available for the next update).
The active/inactive designation is stored in the otadata partition. Each entry in otadata has a state field with possible values:
- ESP_OTA_IMG_NEW: Partition has been written but not yet booted.
- ESP_OTA_IMG_PENDING_VERIFY: Booted once, waiting for application to confirm it's working.
- ESP_OTA_IMG_VALID: Application has confirmed it's good. Normal running state.
- ESP_OTA_IMG_INVALID: Application reported a problem. Bootloader will not boot this partition.
- ESP_OTA_IMG_ABORTED: OTA write started but didn't complete. Bootloader ignores this partition.
This state machine is the foundation of OTA rollback. If the new firmware fails to self-validate (call esp_ota_mark_app_valid_cancel_rollback()) within a configured timeout, the bootloader on the next reboot sees the partition is still in PENDING_VERIFY and falls back to the previous partition.
User Data: LittleFS / SPIFFS
The filesystem partition stores data that needs to survive OTA updates: captive portal HTML files, DuckyScript payloads, custom WiFi network lists, captured PCAP files, portal JSON templates, and device settings.
SPIFFS (SPI Flash File System) was the original esp-idf filesystem. It's flat (no directories), uses a log-structured write approach with wear leveling, and is fairly well tested. LittleFS is newer, supports directories, handles power-loss recovery better, and has better performance for small files. BLEShark uses LittleFS for the file portal.
The critical design decision here is that the filesystem partition is entirely separate from the app partitions. When the OTA process writes a new firmware image to app0 or app1, it writes only to the app partition. The LittleFS partition at 0x3D0000 is never touched by OTA. Your uploaded DuckyScript files, custom captive portal pages, saved PCAP captures, and settings all persist across firmware updates. This is the correct behavior and it's by design - the partition boundaries enforce it at the hardware level.
How OTA Updates Work at the Flash Level
An OTA update on the BLEShark Nano follows this sequence:
sequenceDiagram
participant USER as User/Server
participant FW as Running Firmware
(app0)
participant OTD as otadata
participant FL as Flash Controller
participant APP1 as app1 Partition
participant BL as Bootloader
USER->>FW: Initiate OTA update
FW->>FL: Begin OTA write to app1
loop Chunk Transfer
USER->>FW: Send firmware chunk
FW->>FL: Write chunk to app1
FL->>APP1: Program flash pages
FW->>USER: ACK chunk received
end
FW->>FL: Verify SHA-256 hash
FL->>FW: Hash matches
FW->>OTD: Set app1 as next boot
FW->>BL: Trigger restart
Note over BL,APP1: After Reboot
BL->>OTD: Read boot selection
OTD->>BL: Boot from app1
BL->>APP1: Load and execute
APP1->>OTD: Mark app1 as valid
Note over APP1: If validation fails
APP1-->>OTD: Rollback flag set
APP1-->>BL: Reboot to app0
OTA update sequence - firmware is written to the inactive partition while the current firmware keeps running, then the bootloader switches on restart with automatic rollback if validation fails.
Step 1: Determine the target partition. The firmware calls esp_ota_get_next_update_partition(). This returns whichever app partition is not currently running. If app0 is active, the update goes to app1. If app1 is active, it goes to app0.
Step 2: Begin the OTA write. The firmware opens the target partition for writing via esp_ota_begin(). This erases the target partition (all 1920KB). You hear nothing on the device, but the flash is being erased sector by sector during this step. This takes a few seconds.
Step 3: Download and write the firmware. The BLEShark connects to the update server over HTTPS (TLS, certificate verified) and downloads the firmware binary in chunks. Each chunk is written to the target partition via esp_ota_write(). The SHA256 hash is computed incrementally as the data arrives.
Step 4: Verify and complete. esp_ota_end() finalizes the write and verifies the hash. If the hash doesn't match the expected value (provided by the update server in the manifest), the OTA aborts. The target partition is left in ABORTED state and the current firmware continues running.
Step 5: Set the boot target. esp_ota_set_boot_partition() updates the otadata to point to the newly written partition. The state is set to NEW.
Step 6: Reboot. The device reboots. The bootloader reads otadata, sees the new partition is set to boot, verifies its hash, and jumps to the new firmware.
Step 7: Self-validate. The new firmware runs its startup checks. If everything looks good, it calls esp_ota_mark_app_valid_cancel_rollback(), setting the partition state to VALID. If the firmware crashes before reaching this call, the next reboot triggers rollback to the previous partition.
The BLEShark OTA system supports multiple saved WiFi networks for updates. You can store several networks in the file portal, and the OTA system tries each one until it finds a working connection. This means updates work at home, at the office, and at your preferred testing location without reconfiguring the device each time.
Rollback: Why the Old Firmware Stays
The most important property of the dual-partition OTA scheme is that the old firmware is never deleted until the new firmware proves itself valid. The "old" app partition just sits there, with its VALID state from the previous update cycle, waiting.
If the new firmware has a bug that causes a boot loop (repeated crashes before reaching the self-validate call), the bootloader detects this through the PENDING_VERIFY state and falls back to the old partition. The device boots the previous firmware version successfully. You might lose new features, but you don't lose a working device.
This rollback capability is why OTA-based firmware updates are significantly safer than the USB flash method used by some similar devices. A failed USB flash with no rollback leaves you with a potentially bricked device. A failed OTA on the BLEShark leaves you with the previous working firmware.
BLEShark Nano Specifics
The BLEShark firmware version is stored in the app binary header as a semantic version string (major.minor.patch). You can check it from the device settings menu. The OTA server compares the version in the manifest against your current version and only prompts for update if a newer version is available.
The NVS partition stores operational settings: configured WiFi networks for OTA, device name, and low-level configuration flags. These settings survive OTA updates because NVS is in its own partition. The on-device settings (adjustable through the settings menu without needing a web UI) write to NVS.
The 4MB flash on the ESP32-C3 is split roughly 50/50 between the two app partitions (1920KB each), with about 200KB reserved for the filesystem and small overhead for NVS, OTA data, and the bootloader. At current firmware sizes (roughly 1.2-1.5MB), there's room for the app to grow before the partition scheme needs adjustment. If future features push the firmware past 1920KB, the partition table would need to be reflashed - which requires USB, since OTA can't modify the partition table it's running under. That's a constraint to be aware of, though it's not a near-term concern for the BLEShark.
Understanding the flash layout also explains the BLEShark's file portal. Files you upload (captive portal HTML, DuckyScript payloads, custom PCAP files) go into the LittleFS partition, completely independent of the firmware partitions. There's no risk of an uploaded file overwriting firmware, and no risk of a firmware update wiping your uploaded files. The partition table enforces the separation at the hardware addressing level.