Skip to content

ESP32: raw SD IO#2343

Open
bettio wants to merge 9 commits into
atomvm:release-0.7from
bettio:raw-sd-io
Open

ESP32: raw SD IO#2343
bettio wants to merge 9 commits into
atomvm:release-0.7from
bettio:raw-sd-io

Conversation

@bettio

@bettio bettio commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

These changes are made under both the "Apache 2.0" and the "GNU Lesser General
Public License 2.1 or later" license terms (dual license).

SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later

@bettio
bettio changed the base branch from main to release-0.7 June 24, 2026 20:36
@petermm

This comment was marked as outdated.

bettio added 7 commits July 21, 2026 13:46
Move the "sdmmc" / "sdspi" host and slot configuration out of
nif_esp_mount into sdcard_config_from_source so the upcoming raw block
device NIFs can reuse it instead of duplicating the option parsing.

Pure refactor: the FAT mount path is unchanged.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Add esp:sdcard_open/2, esp:sdcard_read/2, esp:sdcard_write/3,
esp:sdcard_info/1 and esp:sdcard_close/1 to read and write raw SD card
sectors without mounting a filesystem, for custom filesystems, partition
inspection and raw imaging.

The card is opened with the same source and options as esp:mount/4 but
initialized via sdmmc_card_init with no VFS/FAT layer. A new
AVM_ENABLE_RAW_SDCARD_NIFS option enables them independently of the mount
NIFs, so a raw-only build links no filesystem code.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Signed-off-by: Davide Bettio <davide@uninstall.it>
Signed-off-by: Davide Bettio <davide@uninstall.it>
Signed-off-by: Davide Bettio <davide@uninstall.it>
Signed-off-by: Davide Bettio <davide@uninstall.it>
Signed-off-by: Davide Bettio <davide@uninstall.it>
bettio added 2 commits July 21, 2026 14:56
Signed-off-by: Davide Bettio <davide@uninstall.it>
Signed-off-by: Davide Bettio <davide@uninstall.it>
@petermm

petermm commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

PR review: low-level ESP32 SD-card block-device NIFs

Range reviewed: HEAD~9..HEAD (e4a68125e through 3f7c7a837)
Recommendation: Changes requested

The shared SD-card option parser appears to preserve the existing FAT mount behavior, and the new API has useful happy-path coverage. However, the raw-device implementation currently has three release-blocking correctness issues: it can tear down host state belonging to another card, it can truncate oversized Erlang sector integers, and it can dereference an invalid binary term after allocation failure.

Findings

1. [P1] Closing or failing to open one raw card can tear down unrelated cards

sdcard_blockdev_close() removes the selected SDSPI device and then calls the global sdspi_host_deinit(); the SDMMC path similarly calls the forceful global sdmmc_host_deinit() (storage_nif.c:540-550). The initialization error paths repeat those global teardown calls (storage_nif.c:569-613).

This is unsafe when another raw card or FAT mount is active. ESP-IDF documents sdmmc_host_deinit() as forceful regardless of the number of active slots, while sdmmc_host_deinit_slot() performs reference-counted per-slot cleanup. SDSPI_HOST_DEFAULT() likewise exposes sdspi_host_remove_device() through the host's deinit_p callback. Therefore, a failed second open or closing one resource can invalidate transport state still used by an unrelated mount, causing subsequent I/O failure or filesystem corruption.

Use the host descriptor's per-slot callback when SDMMC_HOST_FLAG_DEINIT_ARG is set, and do not globally deinitialize a host after *_init_slot/*_init_device itself failed because that call acquired no device or slot. Older supported IDF versions that do not provide per-slot native-SDMMC cleanup need explicit exclusive ownership rather than unconditional global teardown.

Illustrative minimal direction (with version guards as required by the supported IDF matrix):

diff --git a/src/platforms/esp32/components/avm_builtins/storage_nif.c b/src/platforms/esp32/components/avm_builtins/storage_nif.c
@@
+static esp_err_t sdcard_host_deinit(const sdmmc_host_t *host)
+{
+    if (host->flags & SDMMC_HOST_FLAG_DEINIT_ARG) {
+        return host->deinit_p(host->slot);
+    }
+    return host->deinit();
+}
+
 static esp_err_t sdcard_blockdev_close(struct SDCardBlockDevice *dev)
 {
-    esp_err_t ret = ESP_OK;
-
-    if (dev->interface == SDCardSDSPI) {
-        sdspi_host_remove_device(dev->spi_handle);
-        ret = sdspi_host_deinit();
-#ifdef SDMMC_SLOT_CONFIG_DEFAULT
-    } else {
-        ret = sdmmc_host_deinit();
-#endif
-    }
+    esp_err_t ret = sdcard_host_deinit(&dev->card->host);
@@
         err = sdspi_host_init_device(&cfg->slot.spi_dev, &dev->spi_handle);
         if (UNLIKELY(err != ESP_OK)) {
-            sdspi_host_deinit();
             free(dev->card);
@@
         err = sdmmc_card_init(&cfg->host, dev->card);
         if (UNLIKELY(err != ESP_OK)) {
-            sdspi_host_remove_device(dev->spi_handle);
-            sdspi_host_deinit();
+            sdcard_host_deinit(&cfg->host);
@@
         err = sdmmc_host_init_slot(cfg->host.slot, &cfg->slot.mmc_slot);
         if (UNLIKELY(err != ESP_OK)) {
-            sdmmc_host_deinit();
             free(dev->card);
@@
         err = sdmmc_card_init(&cfg->host, dev->card);
         if (UNLIKELY(err != ESP_OK)) {
-            sdmmc_host_deinit();
+            sdcard_host_deinit(&cfg->host);

Add a regression that keeps one card/mount usable after a second open fails, plus close/reopen and resource-GC coverage. This needs hardware coverage for both SDMMC and SDSPI; the current QEMU happy path cannot establish ownership correctness.

2. [P1] Oversized sector integers can wrap to another sector, including sector 0

Both read and write accept any AtomVM integer with term_is_any_integer() and immediately call term_maybe_unbox_int64() (storage_nif.c:707-718, storage_nif.c:761-779). term_is_any_integer() includes arbitrary-size boxed integers, but term_maybe_unbox_int64() is not a range-checked conversion. For example, 1 bsl 64 can truncate to zero and make sdcard_write/3 overwrite sector 0 instead of raising badarg.

The card capacity is stored as uint32_t, and AtomVM already provides checked uint32_t conversion helpers. Validate before taking the device mutex:

diff --git a/src/platforms/esp32/components/avm_builtins/storage_nif.c b/src/platforms/esp32/components/avm_builtins/storage_nif.c
@@ static term nif_esp_sdcard_read(Context *ctx, int argc, term argv[])
-    if (UNLIKELY(!term_is_any_integer(argv[1]))) {
+    if (UNLIKELY(!term_is_uint32(argv[1]))) {
         RAISE_ERROR(BADARG_ATOM);
     }
-    avm_int64_t sector = term_maybe_unbox_int64(argv[1]);
+    uint32_t sector = term_to_uint32(argv[1]);
@@
-    if (UNLIKELY(sector < 0 || (uint64_t) sector >= dev->sector_count)) {
+    if (UNLIKELY(sector >= dev->sector_count)) {
@@ static term nif_esp_sdcard_write(Context *ctx, int argc, term argv[])
-    if (UNLIKELY(!term_is_any_integer(argv[1]))) {
+    if (UNLIKELY(!term_is_uint32(argv[1]))) {
         RAISE_ERROR(BADARG_ATOM);
     }
-    avm_int64_t sector = term_maybe_unbox_int64(argv[1]);
+    uint32_t sector = term_to_uint32(argv[1]);
@@
-    if (UNLIKELY(sector < 0 || (uint64_t) sector >= dev->sector_count)) {
+    if (UNLIKELY(sector >= dev->sector_count)) {
diff --git a/src/platforms/esp32/test/main/test_erl_sources/test_sdcard.erl b/src/platforms/esp32/test/main/test_erl_sources/test_sdcard.erl
@@ test_bad_args(SDCard) ->
     {ok, #{sector_size := SectorSize, sector_count := SectorCount}} = esp:sdcard_info(SDCard),
+    ok = expect_badarg(fun() -> esp:sdcard_read(SDCard, -1) end),
+    ok = expect_badarg(fun() -> esp:sdcard_read(SDCard, 1 bsl 32) end),
+    ok = expect_badarg(fun() -> esp:sdcard_read(SDCard, 1 bsl 64) end),
     ok = expect_badarg(fun() -> esp:sdcard_write(SDCard, 0, <<0>>) end),

3. [P1] Read allocation failure can pass an invalid term to term_binary_data()

memory_ensure_free() reserves process-heap words, but a normal 512-byte sector is represented by a separately allocated ref-counted binary. term_create_uninitialized_binary() explicitly returns term_invalid_term() if that backing allocation fails. The result is currently passed directly to term_binary_data() (storage_nif.c:723-733), violating its binary precondition and potentially crashing the VM under memory pressure.

Check the allocation result while preserving the mutex-unlock path:

diff --git a/src/platforms/esp32/components/avm_builtins/storage_nif.c b/src/platforms/esp32/components/avm_builtins/storage_nif.c
@@
     term binary = term_create_uninitialized_binary(sector_size, &ctx->heap, ctx->global);
+    if (UNLIKELY(term_is_invalid_term(binary))) {
+        SMP_MUTEX_UNLOCK(dev->lock);
+        RAISE_ERROR(OUT_OF_MEMORY_ATOM);
+    }
     // sdmmc_read_sectors bounces non-DMA-capable / unaligned destinations (e.g. a

A ref-counted-binary allocation fault-injection test would cover the otherwise hard-to-reproduce branch.

4. [P2] Supported feature-flag combinations can fail to link or run tests for disabled NIFs

AVM_ENABLE_RAW_SDCARD_NIFS is independent of both storage and the SPI port driver (Kconfig:81-113). Nevertheless, the shared parser unconditionally references spi_driver_get_peripheral() (storage_nif.c:263-273), whose definition is completely omitted when CONFIG_AVM_ENABLE_SPI_PORT_DRIVER=n. Thus the valid raw-on/storage-off/SPI-off configuration cannot link even though native SDMMC needs no SPI port.

The new test is also guarded only by ESP32/OpenETH (test_main.c:388-400), so a test firmware with raw NIFs disabled still invokes missing NIFs.

Keep native SDMMC available while conditionally compiling only SDSPI integration, and guard the runtime test:

diff --git a/src/platforms/esp32/components/avm_builtins/storage_nif.c b/src/platforms/esp32/components/avm_builtins/storage_nif.c
@@
+#ifdef CONFIG_AVM_ENABLE_SPI_PORT_DRIVER
 #include "spi_driver.h"
+#endif
@@ static bool sdcard_config_from_source(
+#ifdef CONFIG_AVM_ENABLE_SPI_PORT_DRIVER
     if (!strcmp(source, "sdspi")) {
         /* existing SDSPI option parsing */
         return true;
     }
+#endif
 
     return false;
 }
diff --git a/src/platforms/esp32/test/main/test_main.c b/src/platforms/esp32/test/main/test_main.c
@@
 TEST_CASE("test_mount", "[test_run]")
 {
     term ret_value = avm_test_case("test_mount.beam");
     TEST_ASSERT(ret_value == OK_ATOM);
 }
 
+#ifdef CONFIG_AVM_ENABLE_RAW_SDCARD_NIFS
 TEST_CASE("test_sdcard", "[test_run]")
 {
     term ret_value = avm_test_case("test_sdcard.beam");
     TEST_ASSERT(ret_value == OK_ATOM);
 }
+#endif

At minimum, add compile-only coverage for raw on/storage off, raw off/storage on, both off, and raw on/SPI port off on an SDMMC-capable target.

5. [P2] An SDSPI card does not retain ownership of the SPI port it depends on

The parser copies only the numeric host device from the SPI port and releases the port lock immediately (storage_nif.c:263-273, spi_driver.c:683-702). If the caller then executes spi:close/1, the driver attempts spi_bus_free() but discards its state even when that call fails because the SDSPI device is still attached (spi_driver.c:312-339). Closing the card later removes the device, but no owner remains to free the bus, so reopening SPI on that host can fail for the rest of the VM lifetime.

Track dependent users in SPIData (for both FAT-mounted and raw SDSPI cards), reject/defer SPI close while dependents exist, and decrement the count on card close/unmount. At minimum, document that esp:sdcard_close/1 must precede spi:close/1, but documentation alone does not prevent the leaked bus state.

Non-blocking design risk

sdmmc_card_init(), sdmmc_read_sectors(), sdmmc_write_sectors(), host teardown, and waits on the per-device mutex all execute synchronously inside ordinary NIF calls. A slow, removed, or failing card can therefore block an AtomVM scheduler until the ESP-IDF timeout expires; on SMP, another scheduler can also block on the same mutex. If this is an accepted constraint for the low-level API, document it. Otherwise, card operations need an owned native worker/port rather than an inline NIF.

Verification and remaining hardware coverage

  • git diff --check HEAD~9..HEAD passes.
  • Reviewed the complete nine-commit diff and surrounding resource, SPI-port, term-conversion, and binary-allocation code.
  • Cross-checked cleanup semantics against ESP-IDF's documented per-slot versus forceful host teardown APIs and default host callbacks.
  • No ESP-IDF build or hardware test was run in this workspace. In addition to the regressions above, verify SDMMC and SDSPI on the oldest and newest supported ESP-IDF versions, card removal during I/O, write protection, allocation/DMA pressure, and SDXC geometry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants