Skip to content

SD writes corrupt the filesystem: sdWriteBytes() passes the FatFs window buffer to the in-place SPIClass::transfer(void*, size_t) #18

Description

@ygCEO

Summary

sdWriteBytes() in src/SD/Seeed_sdcard_hal.cpp sends the 512-byte data block with

card->spi->transfer((uint8_t *)buffer, 512);

On the SAMD core, that overload of SPIClass::transfer is in-place: it overwrites the caller's buffer with the bytes received from the bus. During an SD write the card returns 0xFF, so the buffer is filled with 0xFF immediately after being sent.

The buffer passed here is the FatFs window (fs->win), which FatFs still considers a valid cache of fs->winsect after disk_write returns. Every FAT and directory sector is therefore destroyed in RAM right after being written to the card. The next modification of that same sector reads garbage and writes the garbage back.

The result is progressive corruption of the volume. It shows up in two ways, both reproduced below:

  1. Silent data corruption — the write reports FR_OK, the file has exactly the expected size, but its contents are damaged.
  2. Hard failureFR_DISK_ERR or FR_NO_FILE mid-session, with the file disappearing from the directory.

Mode 1 is the dangerous one: nothing in the API indicates that anything went wrong.

Environment

Component Version
Seeed Arduino FS 2.1.3 (also present on master at the time of writing)
Board Seeed Wio Terminal (Seeeduino:samd:seeed_wio_terminal)
Core Seeeduino:samd 1.8.6
Arduino CLI 1.5.1
Card 4 GB SDHC (cardType() == 3, 3781 MB), FAT32

Root cause

src/SD/Seeed_sdcard_hal.cpp, sdWriteBytes():

char sdWriteBytes(uint8_t pdrv, const char *buffer, char token)
{
    ardu_sdcard_t *card = s_cards[pdrv];
    unsigned short crc = (card->supports_crc) ? CRC16(buffer, 512) : 0xFFFF;
    if (!sdWait(pdrv, 500))
    {
        return false;
    }
    card->spi->transfer(token);
    card->spi->transfer((uint8_t *)buffer, 512);   // <-- destroys *buffer
    card->spi->transfer16(crc);
    return (card->spi->transfer(0xFF) & 0x1F);
}

The overload it resolves to, in the SAMD core (libraries/SPI/SPI.cpp):

void SPIClass::transfer(void *buf, size_t count)
{
  uint8_t *buffer = reinterpret_cast<uint8_t *>(buf);
  for (size_t i=0; i<count; i++) {
    *buffer = transfer(*buffer);
    buffer++;
  }
}

Note also that the const on sdWriteBytes's buffer parameter is cast away to call it, which is what hides the mutation from the compiler.

Call path: sync_window() in FatFs calls disk_write(fs->pdrv, fs->win, fs->winsect, 1)sd_disk_write()sdWriteSector()sdWriteBytes(). FatFs does not reload fs->win afterwards; fs->winsect still names the sector, so a later move_window() for the same sector returns the clobbered buffer. During a long append-heavy workload the same FAT sector is revisited many times, so corruption is effectively guaranteed.

The multi-sector path (sdWriteSectors()) has the same problem and additionally clobbers the application's own data buffer.

Reproduction

Firmware that logs accelerometer samples to a CSV on the microSD, then reopens the file and verifies header, line count and size against what was written. Same card, same sketch, same board; the only variable is this one library line.

Unpatched, 100 Hz for 10 s:

SESSAO: Periodo concluido, arquivo=/ACC0007.CSV, amostras=858, bytes=52179, FatFs FR=0
VERIFY: bytes=52179/52179, linhas_dados=815/858, cabecalho=DIVERGENTE => INCONSISTENTE

The session reports success. The file size is exactly right. But the CSV header is corrupted and 43 data lines are missing.

Unpatched, 25 Hz for 10 s:

SESSAO: Erro de escrita, arquivo=/ACC0008.CSV, amostras=240, bytes=0, FatFs FR=4
VERIFY: f_open falhou, FR=4

FR_NO_FILE — the directory entry was destroyed. After these two runs, previously written files were gone from the volume and filename allocation restarted from the first free slot.

Patched (see below), identical runs:

VERIFY: bytes=52423/52423, linhas_dados=862/862, cabecalho=ok => INTEGRO
VERIFY: bytes=14551/14551, linhas_dados=240/240, cabecalho=ok => INTEGRO

A 1-minute run at 100 Hz also verifies clean with the patch: 5095/5095 data lines, 318967 bytes, FR=0.

Reverting the patch reproduces the failure; reapplying it restores integrity. Rebuild with --clean when testing, since the library object is cached between builds.

Suggested fix

Do not use the in-place block overload for transmit-only data. The minimal change:

     card->spi->transfer(token);
-    card->spi->transfer((uint8_t *)buffer, 512);
+    for (size_t i = 0; i < 512; i++)
+    {
+        card->spi->transfer((uint8_t)buffer[i]);
+    }
     card->spi->transfer16(crc);

This is what the reproduction above was tested with. Where the core provides it, the DMA-capable transfer(const void *txbuf, void *rxbuf, size_t count, bool block) with rxbuf == NULL would preserve block-transfer performance without mutating the source; that variant is not available on every supported core, so a guarded version may be needed.

sdWriteSectors() needs the same treatment.

Removing the (uint8_t *) cast so the const is enforced would prevent this class of bug from reappearing.

Note: a separate, unrelated defect in the same file

While tracing this, a second issue turned up. AcquireSPI's two-argument constructor ignores its frequency parameter:

AcquireSPI(ardu_sdcard_t *card, int frequency)
    : card(card)
{
    card->spi->beginTransaction(SPISettings(card->frequency, MSBFIRST, SPI_MODE0));
}

sd_disk_initialize() constructs it as AcquireSPI card_locked(card, 400000) specifically to bring the card up at 400 kHz, but the argument is discarded and initialization runs at whatever was passed to SD.begin(). The compiler flags this as unused parameter 'frequency'. It is a distinct problem from the corruption above and did not cause it, but it silently defeats the intended low-speed card initialization.

Note on a false positive

The same in-place overload is used at sdCommand():

card->spi->transfer((uint8_t *)cmdPacket, (cmd == STOP_TRANSMISSION) ? 7 : 6);

That one is harmless — cmdPacket is a local array rebuilt on every iteration and never read after the transfer. It is mentioned only so it is not mistaken for a second instance of the bug.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions