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:
- Silent data corruption — the write reports
FR_OK, the file has exactly the expected size, but its contents are damaged.
- Hard failure —
FR_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.
Summary
sdWriteBytes()insrc/SD/Seeed_sdcard_hal.cppsends the 512-byte data block withOn the SAMD core, that overload of
SPIClass::transferis in-place: it overwrites the caller's buffer with the bytes received from the bus. During an SD write the card returns0xFF, so the buffer is filled with0xFFimmediately after being sent.The buffer passed here is the FatFs window (
fs->win), which FatFs still considers a valid cache offs->winsectafterdisk_writereturns. 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:
FR_OK, the file has exactly the expected size, but its contents are damaged.FR_DISK_ERRorFR_NO_FILEmid-session, with the file disappearing from the directory.Mode 1 is the dangerous one: nothing in the API indicates that anything went wrong.
Environment
masterat the time of writing)Seeeduino:samd:seeed_wio_terminal)Seeeduino:samd1.8.6cardType() == 3, 3781 MB), FAT32Root cause
src/SD/Seeed_sdcard_hal.cpp,sdWriteBytes():The overload it resolves to, in the SAMD core (
libraries/SPI/SPI.cpp):Note also that the
constonsdWriteBytes'sbufferparameter is cast away to call it, which is what hides the mutation from the compiler.Call path:
sync_window()in FatFs callsdisk_write(fs->pdrv, fs->win, fs->winsect, 1)→sd_disk_write()→sdWriteSector()→sdWriteBytes(). FatFs does not reloadfs->winafterwards;fs->winsectstill names the sector, so a latermove_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:
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:
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:
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
--cleanwhen 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:
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)withrxbuf == NULLwould 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 theconstis 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 itsfrequencyparameter:sd_disk_initialize()constructs it asAcquireSPI 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 toSD.begin(). The compiler flags this asunused 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():That one is harmless —
cmdPacketis 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.