펌웨어 캐시 동기화 문제로 발생한 와치독 리셋 분석

들어가는 말
파직은 자체 레퍼런스 보드뿐 아니라, 고객사 하드웨어를 위한 맞춤형 펌웨어 포팅과 통합 개발을 함께 제공하고 있습니다. 고객사마다 상이한 하드웨어 구성과 요구사항에 대응하기 위해, 파직 펌웨어는 산업 현장에서 폭넓게 채택되고 활성화된 생태계와 커뮤니티를 통해 지속적으로 검증되어 온 FreeRTOS와 Zephyr를 모두 지원합니다. 각 RTOS는 독립적인 포팅 레이어로 유지되며, 프로젝트의 제약 조건과 운영 환경에 따라 적합한 RTOS를 선택해 적용합니다.
이 글은 FreeRTOS 기반 ESP-IDF 환경의 ESP32-S3 보드에서, 플래시 접근과 캐시 동기화가 특정 타이밍에 충돌하면서 와치독 리셋으로 이어진 원인을 분석합니다.
테스트 환경과 문제의 배경
파직은 펌웨어 품질 검증을 위해 세 단계로 구성된 테스트 프로세스를 사용합니다.
- 1단계 — 개발 단계 테스트: 개발자가 직접 수행하는 기능 검증 및 초기 안정성 테스트
- 2단계 — 사내 에이징 및 자동화 테스트: 사무실에 배치된 여러 대의 보드에서 장시간 동작 및 자동화된 시나리오 테스트
- 3단계 — 제한적 베타 테스트: 실제 사용자 환경에서 friend-family 또는 제한된 베타 테스트 그룹에서 수행
각 단계는 테스트 목적에 따라 서로 다른 빌드 구성을 사용합니다. 특히 2단계에서는 디버깅 편의성과 실제 배포 환경 재현을 동시에 확보하기 위해 development build와 production build를 1:1로 혼합해 운용합니다.

두 빌드는 애플리케이션 코드 자체는 동일하지만, MCU의 보안 부트 및 플래시 접근 정책을 포함한 시스템 레벨 설정에서 차이가 있습니다. production build에서는 플래시 암호화와 read-out protection이 활성화됩니다. 이로 인해 JTAG을 통한 디버깅이 차단되며, 외부 버스로 전달되는 모든 플래시 데이터는 암호화된 상태로 전송됩니다.
| 구분 | Development Build | Production Build |
|---|---|---|
| 플래시 암호화 | 비활성 | 활성 |
| read-out protection | 비활성 | 활성 |
| JTAG 디버깅 | 가능 | 불가 |
| 플래시 데이터 전송 | 평문 | 암호문 |
이로 인해 production build 환경에서는 물리적 접근이 가능하더라도 런타임에 발생하는 문제의 내부 상태를 직접 관측하기 어렵습니다. 그래서 이 글에서 다루는 문제처럼 특정 타이밍 조건에서만 재현되는 문제는 분석 난이도가 높아집니다.
메모리 요구사항 증가와 PSRAM 도입
제품 기능이 확장되면서 런타임 메모리 요구량이 꾸준히 증가했습니다. 초기에는 ESP32-S3의 내부 SRAM만으로 충분했지만, 추가 기능 도입과 TLS 동시 연결 수가 늘어나면서 내부 메모리만으로는 한계가 분명해졌습니다.
mbedTLS 기반 TLS 세션 하나를 유지하기 위해서는, 힙 사용량 기준으로 약 40KiB 수준의 메모리가 필요했습니다. 출력 버퍼나 애플리케이션 레벨 버퍼는 조정할 수 있었지만, 입력 레코드 크기는 감소시킬 수 없습니다. 그 이하로 레코드 크기를 줄이면 일부 서버와는 아예 세션 수립이 불가능해지기 때문입니다.
TLS 표준에서는 최대 16KiB 크기의 레코드를 정의하고 있습니다. 구현체에서 내부 버퍼 크기를 줄일 수는 있지만, 일부 서버와의 호환성 문제가 발생할 수 있습니다.
향후 VAS 등 부가 기능 확장까지 고려해 최소 6개의 TLS 동시 접속을 목표로 했고, 이 경우 약 240KiB(40KiB × 6)의 추가 힙 메모리가 필요했습니다. 당시 heap low watermark가 25KiB 수준까지 떨어진 상태였기 때문에, 내부 SRAM만으로는 감당할 수 없는 상태였습니다.

초기 하드웨어 설계 단계에서 1MiB 이상의 외부 메모리 확보를 요구사항으로 설정해 두었고, 최종적으로 2MiB PSRAM을 채택했습니다. 이 결정 덕분에 v1.0.6부터는 PSRAM을 활용한 메모리 확장을 본격적으로 적용할 수 있었습니다.
문제 — Flash Encryption과 PSRAM 조합에서 발생한 Interrupt Watchdog Reset
PSRAM을 도입한 이후, 2단계 테스트 환경에서 Interrupt Watchdog Reset이 간헐적으로 발생하기 시작했습니다. 문제 발생 조건을 기능 조합별로 정리하면 다음과 같습니다.
| WDT Reset | Flash Encryption | PSRAM |
|---|---|---|
| 미발생 | 미사용 | 미사용 |
| 미발생 | 미사용 | 사용 |
| 미발생 | 사용 | 미사용 |
| 발생 | 사용 | 사용 |
플래시 암호화가 활성화된 상태에서 PSRAM을 함께 사용할 때만 이슈가 발생했습니다. 그래서 플래시 암호화만 활성화되어 있던 기존의 production build 환경에서는 문제가 발생하지 않았고, PSRAM만 활성화해 개발한 development build 환경에서도 문제가 발생하지 않았습니다. 각 빌드를 1:1 비율로 테스트하는 2단계 테스트 환경에서 문제를 식별할 수 있었습니다.
초기에는 플래시 암호화로 인해 플래시 접근 지연이 증가하면서 인터럽트 처리 역시 지연되고, 그 결과 Interrupt Watchdog이 발생한다고 가정했습니다. 이에 따라 Interrupt Watchdog 타임아웃을 단계적으로 늘려 증상을 관찰했지만, 타임아웃을 수백 밀리초 수준까지 증가시켜도 문제는 동일하게 발생했습니다. 이 정도의 타임아웃 증가는 시스템 응답성과 time-critical task의 실행 보장을 저해하기 때문에, 단순한 타이밍 완화로는 문제를 해결할 수 없다고 판단했습니다.
다음으로, 장시간 실행 중 특정 태스크 간의 데드락 가능성을 의심했습니다. 의심되는 태스크들의 우선순위를 조정하고, processor affinity를 통해 단일 코어에 바인딩하는 방식으로 동작 구조를 단순화했지만, 문제는 여전히 재현되었습니다.
이슈가 장시간 동작 후에만 발생했기 때문에 원인 추적은 쉽지 않았습니다. 그러나 로그를 분석한 결과, 파일시스템 접근과 네트워크 통신이 동시에 수행되는 시점에서만 문제가 발생한다는 점을 확인할 수 있었습니다. 즉, 플래시 I/O와 네트워크 스택이 동시에 활성화되는 특정 타이밍에 문제가 재현되는 것이었습니다.
이 과정에서 OTA 업데이트 시 전체 플래시 영역을 한 번에 erase하는 구현으로 인해, 수 초 이상 주요 태스크가 블로킹될 수 있는 별도의 문제도 함께 발견했습니다. 이는 본 이슈와 무관했지만, 시스템 응답성에 영향을 주기 때문에 전체 erase 방식 대신 chunk 단위 erase 방식으로 개선했습니다. 그 결과 OTA 전체 소요 시간은 다소 증가했지만, 단일 블로킹 구간은 크게 감소했습니다.
실마리를 찾지 못해 여러 가설을 세우던 중 혹시 DMA 불가능한 메모리 영역을 DMA에 사용하고 있는 것은 아닌지 의심이 들었습니다. 그래서 PSRAM이 DMA 기능과 충돌을 일으키는지 확인하기 위해 전체 코드를 리뷰하는 한편, 관련 ESP-IDF 소스 코드도 살펴보기 시작했습니다. 하지만, DMA 요구 사항은 이미 프레임워크 내부에서 적절히 처리되고 있었고, DMA와 PSRAM 간의 경합은 본 이슈의 원인이 될 수 없다는 점을 확인했습니다.
원인 — Flash Cache Disable 구간과 esp_cache_msync 충돌
문제의 원인을 추적하는 과정에서, 애초에 용의선상에 올리지 않았던 플래시 연산 중 캐시 비활성화 구간에서의 stall 가능성을 검토하게 되었습니다. ESP-IDF에서는 플래시 연산을 수행할 때 캐시를 비활성화하며, 이 구간에서는 캐시를 필요로 하는 코드나 데이터 접근이 불가능해집니다. 프레임워크에서 처리하는 이와 같은 시스템 레벨의 제어는 이미 오랜 기간 검증되어 잘 동작하리라 가정했기 때문에 이런 가능성은 처음부터 고려하기도 어렵고 부담스러운 가설이었습니다.
ESP-IDF 내부 플래시 API의 호출 흐름은 아래와 같습니다:
- flash 관련 함수는 작업 전에
rom_spiflash_api_funcs->start를 호출(inesp_flash_api.c) - 이 함수는
esp_flash_spi1_default_os_functions.start에 적당한 spi 인터페이스로 등록되어 있음(예:spi1_startincomponents/spi_flash/spi_flash_os_func_app.c) spi1_start는 캐시를 비활성하는 함수.cache_disable를 호출함cache_disable()은 wrapper로 실제 캐시를 비활성하는spi_flash_disable_interrupts_caches_and_other_cpu를 호출spi_flash_disable_interrupts_caches_and_other_cpu는 스케줄링을 멈추고 다른 코어에 플래시 관련 연산을 하지 못하도록 ipc 호출 (incomponents/spi_flash/cache_utils.c)
즉, 플래시 erase/write가 수행되는 동안에는 캐시 접근이 전면적으로 차단되며, 이 구간에서는 캐시 writeback이나 sync가 정상적으로 수행될 수 없습니다.
그러니까 PSRAM에 위치한 스택이나 코드로의 접근이 문제를 유발한다고 가정했고, PSRAM을 데이터 전용으로만 사용하도록 설정해 문제를 회피하려 했습니다. 그러나 문제는 동일하게 계속 발생했습니다. 생각해보면, 캐시 비활성 구간 자체는 일시적이고 스핀락을 통해 기본적인 동시성도 제어되고 있다는 점에서 이 가설은 논리적으로 성립하지 않았습니다.
문제의 실마리를 찾기 위해 코어덤프(coredump)를 수집하기로 했습니다. 플래시에 위치한 panic 핸들러는 캐시 비활성화 구간에서 정상 동작할 수 없기 때문에, panic 핸들러를 SRAM으로 재배치하고 Interrupt Watchdog이 panic을 유발하도록 설정했습니다.
...
#0 0x4004e555 in ?? ()
#1 0x4004e65c in ?? ()
#2 0x4004ea14 in ?? ()
#3 0x40385b20 in cache_ll_invalidate_addr (cache_level=<optimized out>, type=CACHE_TYPE_ALL, cache_id=<optimized out>, vaddr=1010368512, size=65536) at /opt/es
p/idf/components/hal/esp32s3/include/hal/cache_ll.h:360
#4 cache_hal_invalidate_addr (vaddr=1010368512, size=65536) at /opt/esp/idf/components/hal/cache_hal.c:249
#5 0x40378ee0 in s_do_cache_invalidate (vaddr_start=1010368512, size=65536) at /opt/esp/idf/components/esp_mm/esp_mmu_map.c:407
#6 0x40378fd3 in s_do_mapping (target=MMU_TARGET_FLASH0, vaddr_start=1010368512, paddr_start=7864320, size=65536) at /opt/esp/idf/components/esp_mm/esp_mmu_map
.c:452
#7 0x420a8f35 in esp_mmu_map (paddr_start=7864320, size=<optimized out>, target=MMU_TARGET_FLASH0, caps=(MMU_MEM_CAP_READ | MMU_MEM_CAP_8BIT), flags=<optimized
out>, out_ptr=0x3fcee500) at /opt/esp/idf/components/esp_mm/esp_mmu_map.c:596
#8 0x420a2dc1 in spi_flash_mmap (src_addr=7864320, size=1024, memory=SPI_FLASH_MMAP_DATA, out_ptr=0x3fcee560, out_handle=0x3fcee564) at /opt/esp/idf/components
/spi_flash/flash_mmap.c:88
#9 0x420ac244 in esp_partition_mmap (partition=0x3fcb6188, offset=<optimized out>, size=512, memory=ESP_PARTITION_MMAP_DATA, out_ptr=0x3fcee560, out_handle=0x3
fcee564) at /opt/esp/idf/components/esp_partition/partition_target.c:172
#10 0x420ac2c7 in esp_partition_read (partition=0x3fcb6188, src_offset=512, dst=0x3fcbc454, size=512) at /opt/esp/idf/components/esp_partition/partition_target.
c:50
...
#0 0x4037eedb in Cache_WriteBack_Addr (addr=1008346656, size=0) at /opt/esp/idf/components/esp_rom/patches/esp_rom_cache_esp32s2_esp32s3.c:141
#1 0x40385b48 in cache_ll_writeback_addr (cache_level=<optimized out>, type=CACHE_TYPE_DATA, cache_id=<optimized out>, vaddr=1008346656, size=16) at /opt/esp/i
df/components/hal/esp32s3/include/hal/cache_ll.h:403
#2 cache_hal_writeback_addr (vaddr=1008346656, size=16) at /opt/esp/idf/components/hal/cache_hal.c:264
#3 0x4037d514 in s_c2m_ops (vaddr=1008346656, size=16) at /opt/esp/idf/components/esp_mm/esp_cache_msync.c:56
#4 0x4037d60a in esp_cache_msync (addr=0x3c1a2620, size=16, flags=6) at /opt/esp/idf/components/esp_mm/esp_cache_msync.c:135
#5 0x420efeb7 in esp_aes_process_dma (ctx=0x3c1a2640, input=0x3c1a2620 <error: Cannot access memory at address 0x3c1a2620>, output=0x3fcbae40 "", len=16, strea
m_out=0x0) at /opt/esp/idf/components/mbedtls/port/aes/dma/esp_aes_dma_core.c:1006
#6 0x420e7a89 in esp_aes_crypt_ecb (ctx=0x3c1a2640, mode=1, input=0x3c1a2620 <error: Cannot access memory at address 0x3c1a2620>, output=0x3fcbae40 "") at /opt
/esp/idf/components/mbedtls/port/aes/dma/esp_aes.c:173
#7 0x420d9350 in ctr_drbg_update_internal (ctx=0x3c1a2620, data=0x3fcbae70 "") at /opt/esp/idf/components/mbedtls/mbedtls/library/ctr_drbg.c:363
#8 0x420d95b7 in mbedtls_ctr_drbg_random_with_add (p_rng=0x3c1a2620, output=<optimized out>, output_len=0, additional=0x0, add_len=<optimized out>) at /opt/esp
/idf/components/mbedtls/mbedtls/library/ctr_drbg.c:685
#9 0x420d95ec in mbedtls_ctr_drbg_random (p_rng=0x3c1a2620, output=0x3fcdb710 <error: Cannot access memory at address 0x3fcdb710>, output_len=32) at /opt/esp/i
df/components/mbedtls/mbedtls/library/ctr_drbg.c:708
#10 0x420ec3e1 in mbedtls_mpi_core_fill_random (X=0x3fcdb710, X_limbs=8, n_bytes=32, f_rng=0x420d95dc <mbedtls_ctr_drbg_random>, p_rng=0x3c1a2620) at /opt/esp/i
df/components/mbedtls/mbedtls/library/bignum_core.c:630
...
코어덤프를 분석한 결과, 문제가 발생하는 지점은 항상 동일한 코드 경로였습니다.
플래시 연산 중 캐시 writeback(esp_cache_msync)이 호출되면서 이미 캐시가
비활성화된 상태와 충돌하는 것이었습니다.
Espressif의 공식 문서에는
“플래시 연산 중에는 esp_cache_msync를 호출해서는 안된다”
고 명시하고 있습니다. ESP-IDF는 이 상황을 내부적으로 serialize하거나 동시성
보장 메커니즘을 제공하지 않습니다. 따라서 이 조합은 구조적으로 충돌이 발생할 수
밖에 없는 상태입니다.
이번 문제의 경우, TLS 통신 과정에서 사용되는 AES 하드웨어 가속기가 DMA를 통해
데이터를 처리한 뒤 esp_cache_msync를 호출합니다. 이 호출이 플래시 연산으로
인해 캐시 비활성 구간과 겹치면, 캐시 writeback이 불가능해지고 시스템이
장시간 stall 되어 결국 Interrupt Watchdog Reset으로 이어집니다.
즉, 문제는 플래시 연산과 캐시 동기화가 상호 배타적으로 수행되어야 한다는 구조적 제약이 ESP-IDF에서 보장되지 않았다는 것입니다.
결과 — 가능한 해결책과 선택
이 문제를 해결하기 위해 고려할 수 있는 접근 방식은 다음 다섯 가지였습니다.
- 애플리케이션 레벨에서 flash 작업과 TLS 작업을 serialize
- 플래시 연산과 TLS 처리를 애플리케이션 레벨에서 순차화하는 방식. 이는 SDK 내부에서 보장되어야 할 동시성 제약을 애플리케이션으로 전가하는 구조로, 비효율적이며 근본적인 해결책으로 보기 어려움.
- AES 하드웨어 가속기 비활성화
- 가능하지만 TLS 처리 속도가 크게 저하됨.
- PSRAM 사용 중단
- 메모리 요구량이 이미 내부 SRAM 한계를 초과하기 때문에 현실적 선택지가 아님.
esp_cache_msync호출을 강제로 우회하거나 제거- DMA output 영역이 cache coherence를 잃기 때문에 오히려 더 심각한 문제 발생 가능.
- 플래시 연산(
esp_flash)과 캐시 writeback(esp_cache_msync)을 SDK 내부에서 serialize하도록 패치 적용- 동시성 문제를 구조적으로 해소하는 유일한 실질적 대안.
가능한 한 서드파티 코드를 수정하지 않는 방향을 우선 검토했지만, 이 문제는 애플리케이션 레벨에서 우회하기 어렵고, SDK 내부의 실행 경로에서 발생하는 구조적 충돌이었기 때문에 결국 5번 방식으로 프레임워크를 수정하기로 결정했습니다. I2S 드라이버의 비동기 처리 문제 역시 패치해 사용하고 있기 때문에 유지보수에 미치는 영향을 다소 감수하기로 했습니다.
diff --git a/components/mbedtls/port/aes/dma/esp_aes_dma_core.c b/components/mbedtls/port/aes/dma/esp_aes_dma_core.c
index a4a3d7c43b..78050f3aa1 100644
--- a/components/mbedtls/port/aes/dma/esp_aes_dma_core.c
+++ b/components/mbedtls/port/aes/dma/esp_aes_dma_core.c
@@ -986,6 +986,8 @@ int esp_aes_process_dma(esp_aes_context *ctx, const unsigned char *input, unsign
bool output_needs_realloc = false;
int ret = 0;
+ extern SemaphoreHandle_t g_extmem_flash_serial_lock;
+
assert(len > 0); // caller shouldn't ever have len set to zero
assert(stream_bytes == 0 || stream_out != NULL); // stream_out can be NULL if we're processing full block(s)
@@ -1003,11 +1005,14 @@ int esp_aes_process_dma(esp_aes_context *ctx, const unsigned char *input, unsign
/* Flush cache if input in external ram */
#if (CONFIG_SPIRAM && SOC_PSRAM_DMA_CAPABLE)
if (esp_ptr_external_ram(input)) {
+ xSemaphoreTake(g_extmem_flash_serial_lock, portMAX_DELAY);
if (esp_cache_msync((void *)input, len, ESP_CACHE_MSYNC_FLAG_DIR_C2M | ESP_CACHE_MSYNC_FLAG_UNALIGNED) != ESP_OK) {
mbedtls_platform_zeroize(output, len);
ESP_LOGE(TAG, "Cache sync failed for the input in external RAM");
+ xSemaphoreGive(g_extmem_flash_serial_lock);
return -1;
}
+ xSemaphoreGive(g_extmem_flash_serial_lock);
}
if (esp_ptr_external_ram(output)) {
size_t dcache_line_size;
@@ -1121,11 +1126,14 @@ int esp_aes_process_dma(esp_aes_context *ctx, const unsigned char *input, unsign
#if (CONFIG_SPIRAM && SOC_PSRAM_DMA_CAPABLE)
if (block_bytes > 0) {
if (esp_ptr_external_ram(output)) {
+ xSemaphoreTake(g_extmem_flash_serial_lock, portMAX_DELAY);
if(esp_cache_msync((void*)output, block_bytes, ESP_CACHE_MSYNC_FLAG_DIR_M2C) != ESP_OK) {
mbedtls_platform_zeroize(output, len);
ESP_LOGE(TAG, "Cache sync failed for the output in external RAM");
+ xSemaphoreGive(g_extmem_flash_serial_lock);
return -1;
}
+ xSemaphoreGive(g_extmem_flash_serial_lock);
}
}
#endif
diff --git a/components/mbedtls/port/sha/core/sha.c b/components/mbedtls/port/sha/core/sha.c
index 8aa2f1e0e1..e9731fb9ac 100644
--- a/components/mbedtls/port/sha/core/sha.c
+++ b/components/mbedtls/port/sha/core/sha.c
@@ -318,12 +318,17 @@ int esp_sha_dma(esp_sha_type sha_type, const void *input, uint32_t ilen,
}
#if (CONFIG_SPIRAM && SOC_PSRAM_DMA_CAPABLE)
+ extern SemaphoreHandle_t g_extmem_flash_serial_lock;
+ xSemaphoreTake(g_extmem_flash_serial_lock, portMAX_DELAY);
+
if (esp_ptr_external_ram(input)) {
esp_cache_msync((void *)input, ilen, ESP_CACHE_MSYNC_FLAG_DIR_C2M | ESP_CACHE_MSYNC_FLAG_UNALIGNED);
}
if (esp_ptr_external_ram(buf)) {
esp_cache_msync((void *)buf, buf_len, ESP_CACHE_MSYNC_FLAG_DIR_C2M | ESP_CACHE_MSYNC_FLAG_UNALIGNED);
}
+
+ xSemaphoreGive(g_extmem_flash_serial_lock);
#endif
/* Copy to internal buf if buf is in non DMA capable memory */
diff --git a/components/spi_flash/esp_flash_spi_init.c b/components/spi_flash/esp_flash_spi_init.c
index d16506d391..9a097ae15c 100644
--- a/components/spi_flash/esp_flash_spi_init.c
+++ b/components/spi_flash/esp_flash_spi_init.c
@@ -43,6 +43,8 @@ __attribute__((unused)) static const char TAG[] = "spi_flash";
esp_flash_t *esp_flash_default_chip = NULL;
#endif
+SemaphoreHandle_t g_extmem_flash_serial_lock;
+
#if defined CONFIG_ESPTOOLPY_FLASHFREQ_120M
#define DEFAULT_FLASH_SPEED 120
#elif defined CONFIG_ESPTOOLPY_FLASHFREQ_80M
@@ -488,6 +490,11 @@ esp_err_t esp_flash_app_init(void)
err = esp_flash_init_main_bus_lock();
if (err != ESP_OK) return err;
#endif
+
+ if ((g_extmem_flash_serial_lock = xSemaphoreCreateMutex()) == NULL) {
+ return ESP_ERR_NO_MEM;
+ }
+
err = esp_flash_app_enable_os_functions(&default_chip);
return err;
}
diff --git a/components/spi_flash/flash_mmap.c b/components/spi_flash/flash_mmap.c
index d9476cbe30..b07dccb433 100644
--- a/components/spi_flash/flash_mmap.c
+++ b/components/spi_flash/flash_mmap.c
@@ -85,7 +85,10 @@ esp_err_t spi_flash_mmap(size_t src_addr, size_t size, spi_flash_mmap_memory_t m
} else {
caps = MMU_MEM_CAP_READ | MMU_MEM_CAP_8BIT;
}
+ extern SemaphoreHandle_t g_extmem_flash_serial_lock;
+ xSemaphoreTake(g_extmem_flash_serial_lock, portMAX_DELAY);
ret = esp_mmu_map(src_addr, size, MMU_TARGET_FLASH0, caps, ESP_MMU_MMAP_FLAG_PADDR_SHARED, &ptr);
+ xSemaphoreGive(g_extmem_flash_serial_lock);
if (ret == ESP_OK) {
vaddr_list[0] = (uint32_t)ptr;
block->list_num = 1;
@@ -195,7 +198,10 @@ esp_err_t spi_flash_mmap_pages(const int *pages, size_t page_count, spi_flash_mm
}
for (int i = 0; i < block_num; i++) {
void *ptr = NULL;
+ extern SemaphoreHandle_t g_extmem_flash_serial_lock;
+ xSemaphoreTake(g_extmem_flash_serial_lock, portMAX_DELAY);
ret = esp_mmu_map(paddr_blocks[i][0], paddr_blocks[i][1], MMU_TARGET_FLASH0, caps, ESP_MMU_MMAP_FLAG_PADDR_SHARED, &ptr);
+ xSemaphoreGive(g_extmem_flash_serial_lock);
if (ret == ESP_OK) {
vaddr_list[i] = (uint32_t)ptr;
successful_cnt++;
적용한 해결책은 플래시 연산 경로와 캐시 writeback 경로를 글로벌 mutex로 serialize하는 방식입니다. 이는 즉각적인 문제 해결을 위한 타협안으로, ESP-IDF 업스트림 변경에 취약하고, 플래시 연산 경로의 지연 특성을 변경할 수 있다는 점에서 장기적인 유지보수 리스크를 내포하고 있습니다.
근본적으로는 플래시 연산과 cache sync 경로 간의 동시성 제약을 SDK 내부에서 명시적으로 모델링하고 serialize하는 업스트림 패치가 필요합니다.
나가는 말
이 글에서 정리한 분석 과정과 패치는, ESP-IDF 내부에 이미 존재하던 구조적 제약을 명확히 드러내고, 플래시 연산과 캐시 동기화가 동시에 수행되지 않도록 프레임워크 차원에서 최소한의 실행 안전성을 확보하는 데 목적이 있습니다.
본 패치는 SDK 내부 동작을 근본적으로 재설계하는 해결책은 아니며, 글로벌 mutex를 사용한 실무적 타협안이라는 한계를 갖고 있습니다. 그럼에도 불구하고, Flash Encryption 환경에서 TLS, 파일시스템 접근, OTA와 같은 플래시 I/O와 DMA 기반 암호 연산이 동시에 발생할 수 있는 구성에서는 의미 있는 안정성 개선 효과를 제공합니다.
Flash Encryption을 활성화한 상태에서 TLS 통신과 플래시 연산을 함께 사용하는 프로젝트라면, 이 글에서 정리한 재현 조건과 분석 과정이 문제의 원인을 좁히거나 유사한 증상을 진단하는 데 직접적인 참고 자료로 활용될 수 있을 것입니다.