ESP32-C3 메모리 맵과 플래시 — SPIFFS·LittleFS 파일시스템 선택
#한 줄 요약
“C3는 400 KB SRAM을 IRAM/DRAM/RTC로 나누어 쓰고, 4 MB 플래시를 파티션 테이블로 영역화합니다.” 파일시스템은 SPIFFS는 레거시, LittleFS가 ESP-IDF 5.x 권장입니다. OTA는 A/B 파티션 + otadata로 구현됩니다.
ESP32-C3에서 메모리 관련 실수는 거의 모든 신규 프로젝트가 겪는 통과의례입니다. “Heap 부족”, “IRAM overflow”, “OTA가 절반에서 멈춤”, “전원 끊으니 NVS가 깨졌다” 같은 증상은 모두 메모리 모델을 모르고 짠 코드에서 나옵니다.
이 장은 어디에 무엇이 살고, 왜 영역이 나뉘어 있고, 언제 어떤 파일시스템을 고르는지를 정리합니다. 마지막에 OTA의 A/B 메커니즘과 NVS의 wear-leveling을 다룹니다.
#메모리 영역 — 한눈에
주소 공간 영역 크기 특징0x3FC8_0000~0x3FCE_0000 SRAM (DRAM) 384 KB 데이터 (read/write)0x4037_0000~0x4038_0000 SRAM (IRAM) (overlay) 코드 + ISR0x5000_0000~0x5000_2000 RTC SRAM 8 KB deep-sleep 유지0x4200_0000~0x4280_0000 External flash 4 MB MMU mapped (XIP)0x3C00_0000~0x3C80_0000 External flash 4 MB MMU mapped (data)0x4000_0000~0x4006_0000 ROM (boot) 384 KB immutableSRAM은 물리적으로 한 덩어리지만 bus mapping에 따라 IRAM과 DRAM 두 가지 view로 보입니다. 같은 워드를 IRAM 주소로 읽으면 명령어가 되고 DRAM 주소로 읽으면 데이터가 됩니다.
#IRAM vs DRAM — 왜 나누는가
코어의 명령어 fetch bus와 데이터 access bus가 서로 다른 주소 영역에서 출발하기 때문입니다. 명령어를 IRAM 주소에 두면 fetch가 빠르고, 데이터를 DRAM 주소에 두면 load/store가 빠릅니다. 같은 SRAM이라도 어떤 view로 접근하느냐에 따라 성능과 정렬 제약이 달라집니다.
| 영역 | 코드 | 데이터 | 정렬 |
|---|---|---|---|
| IRAM | OK (실행) | 32-bit aligned read/write만 | 4-byte alignment 필수 |
| DRAM | 불가 (fetch fault) | byte/word 모두 OK | byte alignment 가능 |
ISR을 IRAM_ATTR로 표시하는 이유가 여기에 있습니다. flash cache miss 중에도 IRAM에 있는 ISR은 항상 실행 가능합니다.
#include "esp_attr.h"
void IRAM_ATTR my_critical_isr(void *arg) { // 이 함수는 IRAM에 배치됨 → flash cache miss 영향 없음 *(volatile uint32_t *)0x3FF44004 = 0x1;}#RTC SRAM — sleep 너머의 8 KB
deep-sleep 진입 시 일반 SRAM은 전원이 끊깁니다. RTC SRAM 8 KB만 유지됩니다.
#include "esp_attr.h"
RTC_DATA_ATTR static int boot_count = 0;
void app_main(void) { boot_count++; printf("Boot count: %d\n", boot_count); // deep-sleep 후에도 boot_count 유지}RTC_DATA_ATTR로 표시한 변수는 RTC SRAM에 배치되어 deep-sleep 사이클을 넘어 유지됩니다. 8 KB 한도 안에서 신중히 사용합니다.
#Heap — capabilities-based allocator
ESP-IDF의 heap은 capabilities로 분류됩니다. 그냥 malloc()은 기본 capability로 할당하지만, 특수 영역이 필요할 때는 heap_caps_malloc()을 씁니다.
#include "esp_heap_caps.h"
// 일반 DRAM heap (대부분의 경우)uint8_t *buf = malloc(1024);
// DMA 가능한 영역 (페리퍼럴이 직접 접근)uint8_t *dma_buf = heap_caps_malloc(1024, MALLOC_CAP_DMA);
// 32-bit aligned access만 가능한 IRAM heapuint32_t *iram_buf = heap_caps_malloc(1024, MALLOC_CAP_32BIT | MALLOC_CAP_EXEC);
// 8 KB RTC SRAM heap (deep-sleep 유지)uint8_t *rtc_buf = heap_caps_malloc(64, MALLOC_CAP_RTCRAM);힙 상태를 런타임에 확인하는 명령도 유용합니다.
printf("Free DRAM: %zu\n", heap_caps_get_free_size(MALLOC_CAP_8BIT));printf("Free IRAM: %zu\n", heap_caps_get_free_size(MALLOC_CAP_32BIT));printf("Largest block: %zu\n", heap_caps_get_largest_free_block(MALLOC_CAP_8BIT));heap_caps_get_largest_free_block은 fragmentation 진단에 필수입니다. free 총량은 충분한데 큰 연속 블록이 없을 때 malloc(8192)가 실패합니다.
#파티션 테이블
플래시 4 MB는 파티션 테이블로 영역이 정해집니다. CSV 파일로 작성하고 idf.py menuconfig에서 Custom partition table을 선택해 사용합니다.
#기본 OTA 파티션 테이블
# Name, Type, SubType, Offset, Size, Flagsnvs, data, nvs, 0x9000, 0x6000,otadata, data, ota, 0xf000, 0x2000,phy_init, data, phy, 0x11000, 0x1000,factory, app, factory, 0x20000, 0x100000,ota_0, app, ota_0, 0x120000, 0x180000,ota_1, app, ota_1, 0x2A0000, 0x180000,spiffs, data, spiffs, 0x420000, 0x1E0000,각 행의 의미입니다.
| 파티션 | 역할 | 크기 |
|---|---|---|
nvs | 키-값 저장 (Wi-Fi 인증, 캘리브레이션) | 24 KB |
otadata | 현재 boot 파티션 인디케이터 | 8 KB |
phy_init | RF calibration 초기값 | 4 KB |
factory | 공장 firmware (OTA 실패 시 복귀용) | 1 MB |
ota_0 | OTA slot A | 1.5 MB |
ota_1 | OTA slot B | 1.5 MB |
spiffs | 파일시스템 | 1.9 MB |
총합이 flash 크기를 넘으면 안 됩니다. 위 예는 약 4 MB에 맞춰져 있습니다.
#menuconfig로 적용
$ idf.py menuconfig# Partition Table → Custom partition table CSV# Custom partition CSV file → partitions.csv$ idf.py build빌드 후 플래시는 다음과 같이 진행됩니다.
$ idf.py partition-table # 현재 파티션 테이블 보기$ idf.py flash # 부트로더 + 파티션 + 앱 전체 플래시$ idf.py app-flash # 앱만 다시 플래시 (개발 중 빠른 cycle)#NVS — 키/값 저장소
NVS(Non-Volatile Storage)는 작은 설정 값을 저장하는 용도입니다. WiFi SSID/비밀번호, RF 캘리브레이션, 시리얼 번호 등이 대표적입니다. 내부적으로 wear-leveling + CRC를 합니다.
#include "nvs_flash.h"#include "nvs.h"
void save_config(void) { nvs_handle_t handle; ESP_ERROR_CHECK(nvs_open("storage", NVS_READWRITE, &handle));
ESP_ERROR_CHECK(nvs_set_str(handle, "ssid", "MyRouter")); ESP_ERROR_CHECK(nvs_set_i32(handle, "boot_count", 42)); ESP_ERROR_CHECK(nvs_commit(handle));
nvs_close(handle);}
void load_config(void) { nvs_handle_t handle; if (nvs_open("storage", NVS_READONLY, &handle) != ESP_OK) { return; }
char ssid[33] = {0}; size_t len = sizeof(ssid); nvs_get_str(handle, "ssid", ssid, &len);
int32_t boot_count = 0; nvs_get_i32(handle, "boot_count", &boot_count);
nvs_close(handle);}NVS의 한계입니다.
- key 최대 15 chars
- value 최대 4000 bytes
- 전체 파티션 크기 안에서 한정
- binary blob도 지원 (
nvs_set_blob)
대용량 데이터는 NVS가 아니라 파일시스템으로 가야 합니다.
#SPIFFS vs LittleFS
ESP-IDF는 두 가지 임베디드 파일시스템을 지원합니다.
| 항목 | SPIFFS | LittleFS |
|---|---|---|
| 출시 | 2013 | 2017 |
| 라이선스 | MIT | BSD-3 |
| Power-fail safety | 약함 (마운트 깨짐 보고 다수) | 강함 (atomic update) |
| 디렉토리 | 미지원 (가짜 path만) | 지원 (진짜 디렉토리) |
| Wear-leveling | dynamic | dynamic |
| 메모리 사용 | 큼 | 작음 |
| ESP-IDF 5.x 권장 | X | O |
새 프로젝트는 LittleFS를 권장합니다. SPIFFS는 기존 코드 유지가 필요할 때만 씁니다.
#LittleFS 사용 예
menuconfig에서 LittleFS component를 활성화하고 파티션 테이블에 littlefs subtype을 더합니다.
storage, data, littlefs, 0x420000, 0x1E0000,코드는 다음과 같습니다.
#include "esp_littlefs.h"
esp_vfs_littlefs_conf_t conf = { .base_path = "/littlefs", .partition_label = "storage", .format_if_mount_failed = true, .dont_mount = false,};
void mount_lfs(void) { ESP_ERROR_CHECK(esp_vfs_littlefs_register(&conf));
size_t total = 0, used = 0; esp_littlefs_info(conf.partition_label, &total, &used); printf("LittleFS: %u / %u bytes used\n", used, total);}
void write_log(const char *line) { FILE *f = fopen("/littlefs/log.txt", "a"); if (f) { fprintf(f, "%s\n", line); fclose(f); }}POSIX fopen/fread/fwrite/fclose로 그대로 동작합니다. ESP-IDF의 VFS layer가 path를 분기합니다.
#OTA — A/B 파티션 메커니즘
OTA(Over-The-Air) 업데이트는 현재 안 쓰는 ota 파티션에 새 이미지를 받고, otadata에 부트 인디케이터를 갱신하는 방식입니다.
#부트 흐름
| 단계 | 동작 |
|---|---|
| boot | otadata 읽기 |
| select | 사용할 app 파티션 결정 |
→ factory | 즉시 실행 |
→ ota_0 또는 ota_1 | 이미지 verify → 실행 |
부팅 후 firmware가 정상 동작 확인되면 valid mark를 합니다. valid가 안 찍힌 채 재부팅되면 otadata가 자동으로 이전 파티션으로 롤백합니다.
#OTA 코드 예 (HTTPS download)
#include "esp_https_ota.h"#include "esp_ota_ops.h"
void do_ota(const char *url) { esp_http_client_config_t http_cfg = { .url = url, .cert_pem = (char *)server_cert_pem_start, }; esp_https_ota_config_t ota_cfg = { .http_config = &http_cfg, };
esp_err_t err = esp_https_ota(&ota_cfg); if (err == ESP_OK) { printf("OTA OK, rebooting\n"); esp_restart(); } else { printf("OTA failed: %s\n", esp_err_to_name(err)); }}esp_https_ota는 내부적으로 다음을 수행합니다.
esp_ota_get_next_update_partition()로 빈 슬롯 선택- HTTPS GET으로 이미지 다운로드
- flash에 쓰기 (page 단위)
- SHA-256 검증
esp_ota_set_boot_partition()으로 otadata 갱신
#Rollback
새 firmware가 처음 부트 후 N초 안에 valid mark를 하지 않으면 자동 롤백입니다.
#include "esp_ota_ops.h"
void app_main(void) { // ... 정상 동작 확인 후 esp_ota_mark_app_valid_cancel_rollback();}이 호출이 없으면 다음 부팅 시 이전 파티션으로 돌아갑니다. 깨진 OTA가 brick이 되지 않게 막는 safeguard입니다.
#자주 하는 실수
#”IRAM overflow”
IRAM_ATTR을 너무 많이 붙이면 IRAM이 터집니다. 정말 cache miss 중에도 실행되어야 하는 코드만 IRAM에 둡니다. 일반 hot path는 flash cache로 충분합니다.
#”Heap 단편화로 큰 할당이 실패”
총 free는 100 KB인데 malloc(8192)가 실패하면 fragmentation입니다. heap_caps_get_largest_free_block로 확인하고, *큰 버퍼는 부팅 직후 한 번만 할당해 두는 패턴이 안전합니다.
#”NVS가 가득 차서 새 키가 안 들어간다”
NVS 파티션이 작으면 binary blob 몇 개만 넣어도 차버립니다. 키 삭제 후 commit해도 공간이 즉시 회수되지 않습니다. wear-leveling 사이클 후에 재사용됩니다. 큰 데이터는 LittleFS로.
#”OTA 다운로드는 됐는데 부팅이 안 된다”
이미지 검증은 flash 쓰기 후에 SHA-256을 다시 읽어 확인합니다. flash chip이 다르면 페이지 크기 가정이 깨질 수 있고, 전원이 약하면 쓰기 중에 비트가 흔들립니다. idf.py monitor에서 부트로더 로그가 invalid checksum이라고 외칩니다.
#”deep-sleep에서 깨면 변수가 0이다”
RTC_DATA_ATTR을 안 붙인 변수는 모두 사라집니다. 부팅 카운터, 마지막 센서 값, 정전 시점 등은 RTC 영역에 두어야 합니다.
#”SPIFFS가 마운트 자체가 안 된다”
전원 끊김 후 자주 발생합니다. SPIFFS의 알려진 약점입니다. format_if_mount_failed = true로 최후의 수단을 두지만, 데이터를 잃습니다. LittleFS로 옮기는 것이 근본 해결입니다.
#정리
- C3의 SRAM은 400 KB이며 *IRAM(코드)·DRAM(데이터)·RTC SRAM(8 KB, deep-sleep 유지)*으로 view가 나뉩니다.
- Heap은 capabilities 기반으로
MALLOC_CAP_DMA·MALLOC_CAP_32BIT·MALLOC_CAP_RTCRAM같은 플래그로 영역을 지정합니다. - 4 MB 플래시는 파티션 테이블 CSV로 나뉘며, OTA 사용 시 nvs/otadata/phy_init/factory/ota_0/ota_1/spiffs 7개가 표준입니다.
- NVS는 작은 키/값 저장용(WiFi 자격 증명, 캘리브레이션), 대용량은 LittleFS를 씁니다.
- ESP-IDF 5.x는 LittleFS를 권장하며 SPIFFS는 power-fail 취약성 때문에 신규 프로젝트에서 회피합니다.
- OTA는 A/B 파티션 + otadata로 동작하고, valid mark 호출이 없으면 자동 롤백되어 brick을 막습니다.
- IRAM은 희소 자원이므로
IRAM_ATTR은 진짜 cache-miss-critical에만 붙이고 일반 코드는 flash cache에 둡니다.
#다음 장 예고
다음 편은 Ch 4: GPIO·LEDC·MCPWM — 디지털 출력의 세 모드입니다. 22개 GPIO가 GPIO Matrix를 통해 어떻게 페리퍼럴과 연결되는지, LEDC와 MCPWM이 무엇이 다른지 풀어봅니다.
#관련 항목
- Ch 2: RISC-V 코어 — RV32IMC + PMP + 인터럽트 컨트롤러
- Ch 4: GPIO·LEDC·MCPWM — 디지털 출력의 세 모드
- Ch 9: RTC·저전력 — RTC SRAM 활용 패턴
- Ch 11: 보안·Secure Boot — flash encryption
- Modern Embedded Recipes Part 6.3: OTA 전략
- Practical RTOS Internals Part 3.2: Heap fragmentation
- 원문 — ESP-IDF Partition Tables
- 원문 — LittleFS Design
ESP32-C3 Mastering · 3 of 12
- 1ESP32-C3 분석 — Espressif가 Xtensa에서 RISC-V로 갈아탄 이유
- 2ESP32-C3 RISC-V 코어 분석 — RV32IMC·PMP·인터럽트 컨트롤러
- 3ESP32-C3 메모리 맵과 플래시 — SPIFFS·LittleFS 파일시스템 선택
- 4ESP32-C3 디지털 출력 — GPIO·LEDC·MCPWM 세 모드 비교
- 5ESP32-C3 시리얼 통신 4종 — UART·SPI·I2C·I2S 분석
- 6ESP32-C3 ADC와 터치 센서 — 아날로그 입력 처리
- 7ESP32-C3 WiFi 4 스택 — Station·SoftAP·Mesh 구성
- 8ESP32-C3 BLE 5.0 분석 — GAP·GATT·Coded PHY
- 9ESP-IDF 빌드 시스템 분석 — 컴포넌트 구조와 CMake 통합
- 10ESP32-C3 위 FreeRTOS — 단일 코어 RTOS 활용 전략
- 11ESP32-C3 보안 분석 — Secure Boot·Flash Encryption·eFuse
- 12ESP32-C3 전력 관리 — Modem·Light·Deep Sleep와 Wake 소스
관련 글
ESP32-C3 전력 관리 — Modem·Light·Deep Sleep와 Wake 소스
5단계 power mode, RTC 도메인 활용, ULP 코프로세서 미지원 — C3는 RTC GPIO만.
ESP32-C3 보안 분석 — Secure Boot·Flash Encryption·eFuse
ECDSA 기반 Secure Boot V2, AES-256 Flash Encryption, eFuse 키 보관.
ESP32-C3 위 FreeRTOS — 단일 코어 RTOS 활용 전략
ESP-IDF의 modified FreeRTOS. 우선순위 25 단계, tickless idle, software timer.