STM32 microSD Data Logger with SPI and FatFs on the Blue Pill

Build an STM32 Blue Pill microSD data logger with SPI and FatFs. Wire an SDHC card, append CSV rows and verify the data after syncing.

Connect a 3.3 V microSD socket to SPI1 on the STM32F103C8PriceAliExpress price Blue PillPriceAliExpress price and you can create a CSV log that a computer can read. The STM32PriceAliExpress price clocks bytes over SPI, the SD card exposes 512-byte sectors, and FatFs turns those sectors into files. The three layers have different failure modes. A card that responds to a command can still hold a filesystem that the firmware cannot mount.

In this esp32.co.uk/ guide, you'll wire an SDHC card, initialise it at 281.25 kHz and switch to 4.5 MHz for transfers. The STM32Cube HAL lab appends three known rows to LOG.CSV, syncs the file, closes it, then opens it for reading and compares the appended bytes. The known input makes the storage path easy to verify before adding a sensor.

Quick answer. Use PA5 for SCK, PA6 for MISO, PA7 for MOSI and PA4 as a software chip select. Start with a FAT32-formatted microSDHC card, 3.3 V signalling and a common ground. In CubeMX select SPI1 full-duplex master, mode 0, 8-bit and software NSS. Add the matched FatFs files and the lab’s SPI disk driver so FatFs can read and write the card.

STM32F103C8 Blue Pill connected to a 3.3 V microSD socket with a CSV file shown on a computer

Choose the card and wire the socket

Use an STM32F103C8 Blue Pill with working SWD, a microSDHC card with a backed-up FAT32 volume, and a breakout designed for direct 3.3 V power and logic. A small card dedicated to the experiment is easier to recover if the card is removed during a write. You also need short jumper wires and a reader to inspect the finished file on a computer. A logic analyser is optional and can separate wiring, clock and protocol faults.

The C8 has two hardware SPI peripherals and 20 KiB of SRAM. The default SPI1 mapping uses PA5, PA6 and PA7. PA4 can serve as hardware NSS on this part, but the lab configures it as a regular output so the driver controls exactly when the card is selected. The same GPIO stays low through a complete read or write transaction. Check the markings against the Blue Pill pinout guide.

Blue PillmicroSD in SPI modeSignal direction or role
PA4CS, DAT3STM32 to card, active low
PA5SCK, CLKSTM32 to card, idle low
PA6MISO, DO, DAT0Card to STM32
PA7MOSI, DI, CMDSTM32 to card
3V3 from a suitable supplyVDD3.3 V at the socket
GNDVSSCommon signal and supply reference

Some breakout boards print DI and DO from the card’s point of view. Connect PA7 to DI and PA6 to DO. A breakout that includes a regulator or level shifting may have different VCC requirements from a bare socket. Check the breakout circuit before applying power. For this wiring, use a socket board whose supply and signal pins are designed for direct 3.3 V operation. Use a card socket or breakout with the pull-ups appropriate to its design. For a bare SPI breakout with no CS pull-up, a 10 kΩ resistor from CS to 3.3 V can keep the card deselected while the MCU pin is still high impedance at reset.

Power quality is especially important during a write. A card can read successfully and fail when it draws a higher transient current. Measure 3.3 V at the socket under load to include any drop along the wiring. Use local bypass and bulk capacitance close to the socket. For a short bench connection, 100 nF in parallel with 10 µF is a starting arrangement to evaluate against the actual regulator and card. Keep the ground return short. If you use an external 3.3 V rail for the socket, keep the grounds common and arrange power so driven data pins cannot feed a powered-down device.

Check whether another device already uses PA4–PA7. The earlier SPI tutorial covers bus configuration and mixed peripheral requirements. This lab gives the SD card a dedicated SPI1 bus so the mode, speed, idle state and chip select are predictable. STM32 SPI, HAL and DMA guide

Blue Pill SPI1 wiring to a 3.3 V microSD socket with PA4 CS PA5 SCK PA6 MISO and PA7 MOSI

Start with a known filesystem

Choose a microSDHC card with an existing FAT32 volume. Copy its existing files to your computer before running the lab. If the card already contains a file called LOG.CSV, move that file aside to give the test a fresh starting point. The firmware opens that name in append mode and will add rows to it if it exists. The lab has no formatting command. It can mount a supported FAT volume presented by the disk layer and report a filesystem error when it cannot find one.

The card capacity class and filesystem are different settings. The disk layer uses block addresses as confirmed by the card’s OCR response. FatFs separately looks for a supported volume on those blocks. An SDXC card may pass the block-addressing check and still contain exFAT, which this configuration omits. A byte-addressed SDSC card is outside the lab’s transport scope. Use SDHC and FAT32 as the starting combination so both layers meet the intended setup.

Configure SPI1 in STM32CubeMX

Select the STM32F103C8 target and keep SYS debug on Serial Wire. The code expects a running HAL millisecond timebase and a 72 MHz APB2 clock. On a board with an 8 MHz HSE crystal, set HSE to Crystal/Ceramic Resonator, choose HSE as PLL input with the ×9 multiplier, and use PLL as SYSCLK. Set AHB to divide by 1, APB1 to divide by 2 and APB2 to divide by 1. Confirm the actual board oscillator and the resulting frequencies in the clock tree. If your clock is different, adapt and verify both SPI clock rates and the guard in the driver together.

CubeMX settingLab valueReason
SPI1 modeFull-duplex masterGenerate clocks while receiving card bytes
Frame and order8 bits, MSB firstSend command and payload bytes in SPI order
Clock polarity and phaseLow, first edgeSPI mode 0
NSSSoftwarePA4 controls the card select independently
Initial baud prescaler25672 MHz ÷ 256 = 281.25 kHz
Working baud prescaler16, set by the driver72 MHz ÷ 16 = 4.5 MHz after initialisation
TI mode and hardware CRCDisabledThe driver handles the SD wire framing
PA4GPIO push-pull output, initial HighCard stays deselected during setup

Generate the project with SPI1 and the normal HAL SPI, GPIO and RCC support. Configure PA6 as the SPI1 input. Use the default, non-remapped PA5–PA7 pins. Ensure the generated GPIO setup drives the PA4 output latch high before configuring it as an output. Leave SPI interrupts and DMA out of the lab. It uses blocking calls in the foreground and depends on the timebase continuing to advance for its timeout checks. Follow the CubeMX and STM32CubeIDE setup guide for project generation and SWD debugging.

The code applies the slow setting even if the peripheral has previously been used at the faster rate. On this setup the APB2 bus clock feeds SPI1. The clock multiplication rule for some timers does not apply to SPI. At 281.25 kHz, 80 clocks take about 284 µs. The example also waits 10 ms after setting up the low-speed peripheral, before sending those clocks with CS and MOSI high. This delay assumes the board supply has already risen and is stable.

Use 4.5 MHz as the starting data rate. At that rate, shifting a 512-byte payload alone takes about 0.91 ms. Commands, data tokens, read polling, software gaps, card programming and filesystem work all increase the elapsed time. The DS5319 SPI limit for this MCU and the card’s supported operating mode both matter if you later change the prescaler. Measure the signal at the socket after any clock change.

Understand the card initialisation

At power-up the driver keeps CS high and sends ten bytes of 0xFF, providing 80 clock cycles with the card deselected and its command input high. It then selects the card and sends CMD0 with a valid CRC for that command. A working SPI-mode card replies with R1 0x01, indicating idle. The first R1 byte has bit 7 clear. Repeated 0xFF indicates that no such response arrived in the polling window.

StageCommand or bus actionExpected result for the lab card
PreclockCS high, ten 0xFF bytes80 clocks while deselected
Enter SPI idleCMD0, argument 0R1 0x01
Check the interfaceCMD8, argument 0x000001AAR1 0x01 and R7 voltage/check pattern ending 01 AA
Finish power-upRepeat CMD55 then ACMD41, argument 0x40000000 for ACMD41ACMD41 R1 changes from 0x01 to 0x00
Check addressingCMD58R1 0x00, OCR power-up and CCS bits set
TransferRaise CS, finish the transaction, then change the prescalerSPI1 uses 4.5 MHz for sectors

CMD8 checks that the card recognises the requested voltage range and returns the check pattern. Each ACMD41 attempt is preceded by CMD55. Its HCS bit tells the card that the host supports high-capacity addressing. HCS is a host request. The driver still checks the card’s CCS bit in the OCR from CMD58. The argument of CMD17 or CMD24 is then the logical sector number itself. For instance, LBA 2,048 is argument 0x00000800. Multiplying by 512 would select a different sector on a block-addressed card.

The lab uses the fixed CMD0 and CMD8 CRC bytes 0x95 and 0x87 for their fixed arguments. It uses the default SPI CRC-off policy after entry. For block reads it clocks out the two CRC bytes to finish the transaction. For writes it supplies two dummy CRC bytes. A production transport may enable the card’s CRC checks and implement the corresponding software CRC handling. STM32 SPI hardware CRC and the SD protocol’s command and data CRC fields are separate mechanisms.

Keep the timing visible when debugging the ACMD41 loop. A single idle reply is normal while the card powers up. A repeated idle reply through the entire initialisation budget is a fault condition for the lab. The loop uses an unsigned elapsed-time check, including across HAL tick rollover, and delays briefly between attempts. The 2 s limit in the code is a lab policy for the polling loop. Individual HAL transactions also have their own bounded waits.

SDHC SPI initialisation showing 80 clocks CMD0 CMD8 CMD55 ACMD41 CMD58 and the change from 281.25 kHz to 4.5 MHz

Connect FatFs to the SPI disk driver

FatFs calls a small platform interface for sector access. In this project disk_initialize() brings up drive 0, disk_status() returns its state, disk_read() and disk_write() transfer one or more sectors, and disk_ioctl(CTRL_SYNC) waits for the device to be ready. The filesystem passes an LBA and a buffer. The SPI layer has no concept of directories or filenames.

Use the tagged ST FatFs R0.16 patch 2 source. Add ff.c, ff.h and diskio.h. Use the lab’s ffconf.h or rename the tag’s ffconf_template.h to ffconf.h and apply the configuration settings below. Add the lab listings as sd_spi_diskio.c, sd_csv.c and sd_csv.h. Place the source files in compiled project folders, add the header paths to the C compiler settings and build as C11.

This integration uses the direct disk_* functions. Leave CubeMX’s FatFs middleware unchecked for this project so there is one FatFs build, one ffconf.h and one implementation of the disk interface. The separately obtained ff.c provides the filesystem. The supplied disk driver provides its hardware hooks. If you add the source manually, keep the original licence notices in the upstream files.

ConfigurationValueEffect
FF_VOLUMES1Drive 0 maps directly to the SD card
FF_MIN_SS and FF_MAX_SS512Fixed 512-byte sector size
FF_LBA64 and FF_FS_EXFAT032-bit LBA and FAT-only volume support
FF_USE_LFN0Use short filenames such as LOG.CSV
FF_FS_TINY1Share the volume’s sector window across files
FF_FS_NORTC1Use the configured fixed date for filesystem metadata
FF_USE_MKFS and FF_USE_TRIM0No formatting or discard commands
FF_FS_REENTRANT0Use one foreground owner for this volume and driver

The supplied ffconf.h matches R0.16. If the compiler reports a revision mismatch, check which header it picked and confirm that the three upstream files are from the same tag. The fixed timestamp is a filesystem metadata setting. It leaves the sample time in the CSV under application control. To record date and time per sample, add an RTC timestamp to the row and define how the device handles a clock that hasn’t been set. STM32 RTC and VBAT retention guide

Transfer one sector at a time

On a read, the driver sends CMD17, checks R1, and waits for a 0xFE data start token. It transmits dummy bytes so the card can clock out 512 data bytes and the CRC field. A token other than the expected start token causes the read to fail. On a write, CMD24 sets the LBA. The host then sends the start token, 512 bytes and the two CRC-field bytes. The response token’s low five bits must equal 0x05 for accepted data. The card can still be busy after acceptance. The driver clocks the card until it is ready and checks CMD13 status before treating that sector write as complete.

The example leaves CS asserted during each command and its data phase, then raises CS and sends another dummy byte. When FatFs requests several sectors, the driver walks the full request using successive single-block commands. That design is easy to inspect on a logic analyser. Multiblock transfers with CMD18 and CMD25 are a separate optimisation with different stop handling. A transport error marks the drive uninitialised so later calls fail clearly or reinitialise through a fresh mount.

#include "stm32f1xx_hal.h"
#include "diskio.h"
#include <stdbool.h>
#include <string.h>

#if FF_MIN_SS != 512 || FF_MAX_SS != 512 || FF_LBA64 || FF_FS_READONLY || FF_USE_MKFS
#error "Use the fixed-sector, writable FatFs lab configuration."
#endif

extern SPI_HandleTypeDef hspi1;
volatile BYTE sd_last_cmd = 0xFF;
volatile BYTE sd_last_r1 = 0xFF;
volatile BYTE sd_last_write_token = 0xFF;
static DSTATUS disk_state = STA_NOINIT;
static BYTE dummy[512];

enum { CARD_WAIT_MS = 750, BLOCK_WAIT_MS = 300, INIT_WAIT_MS = 2000 };

static bool exchange(BYTE tx, BYTE *rx)
{
    return HAL_SPI_TransmitReceive(&hspi1, &tx, rx, 1, 10) == HAL_OK;
}

static bool wait_ready(uint32_t timeout)
{
    uint32_t start = HAL_GetTick();
    BYTE value;
    do {
        if (!exchange(0xFF, &value)) {
            return false;
        }
        if (value == 0xFF) {
            return true;
        }
    } while (HAL_GetTick() - start < timeout);
    return false;
}

static bool select_card(void)
{
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET);
    return wait_ready(CARD_WAIT_MS);
}

static bool release_card(void)
{
    BYTE ignored;
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
    return exchange(0xFF, &ignored);
}

/* Called with CS low. Allow up to ten byte exchanges to find R1. */
static bool command(BYTE cmd, uint32_t argument, BYTE *r1)
{
    BYTE frame[6] = {
        (BYTE)(0x40U | cmd), (BYTE)(argument >> 24), (BYTE)(argument >> 16),
        (BYTE)(argument >> 8), (BYTE)argument, 0x01
    };
    if (cmd == 0) {
        frame[5] = 0x95;
    } else if (cmd == 8) {
        frame[5] = 0x87;  /* For the fixed CMD8 argument 0x000001AA. */
    }
    sd_last_cmd = cmd;
    sd_last_r1 = 0xFF;
    if (HAL_SPI_Transmit(&hspi1, frame, sizeof frame, 10) != HAL_OK) {
        return false;
    }
    for (unsigned n = 0; n < 10; ++n) {
        if (!exchange(0xFF, r1)) {
            return false;
        }
        if ((*r1 & 0x80U) == 0) {
            sd_last_r1 = *r1;
            return true;
        }
    }
    return false;
}

static bool query(BYTE cmd, uint32_t arg, BYTE *r1, BYTE *tail, unsigned length)
{
    bool ok = select_card() && command(cmd, arg, r1);
    /* Read an extended response only after an R1 with no error bits. */
    if (ok && *r1 <= 1) {
        for (unsigned i = 0; i < length && ok; ++i) {
            ok = exchange(0xFF, &tail[i]);
        }
    }
    return release_card() && ok;
}

static bool set_speed(uint32_t prescaler)
{
    hspi1.Init.BaudRatePrescaler = prescaler;
    return HAL_SPI_Init(&hspi1) == HAL_OK;
}

static bool initialise_sdhc(void)
{
    BYTE r1, r7[4], ocr[4];
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);
    if (hspi1.Instance != SPI1 || HAL_RCC_GetPCLK2Freq() != 72000000U ||
        !set_speed(SPI_BAUDRATEPRESCALER_256)) {
        return false;
    }
    memset(dummy, 0xFF, sizeof dummy);
    HAL_Delay(10);
    if (HAL_SPI_Transmit(&hspi1, dummy, 10, 10) != HAL_OK ||
        !query(0, 0, &r1, NULL, 0) || r1 != 1 ||
        !query(8, 0x1AA, &r1, r7, sizeof r7) || r1 != 1 ||
        (r7[2] & 0x0FU) != 1 || r7[3] != 0xAA) {
        return false;
    }

    uint32_t start = HAL_GetTick();
    do {
        if (!query(55, 0, &r1, NULL, 0) || r1 > 1 ||
            !query(41, UINT32_C(0x40000000), &r1, NULL, 0) || r1 > 1) {
            return false;
        }
        if (r1 == 0) {
            break;
        }
        HAL_Delay(2);
    } while (HAL_GetTick() - start < INIT_WAIT_MS);

    /* OCR bits 31 and 30 are power-up complete and block addressing. */
    return r1 == 0 && query(58, 0, &r1, ocr, sizeof ocr) && r1 == 0 &&
           (ocr[0] & 0xC0U) == 0xC0U && set_speed(SPI_BAUDRATEPRESCALER_16);
}

static bool read_block(LBA_t sector, BYTE *dst)
{
    BYTE r1, token = 0xFF, crc[2];
    bool ok = select_card() && command(17, sector, &r1) && r1 == 0;
    uint32_t start = HAL_GetTick();
    while (ok && token == 0xFF && HAL_GetTick() - start < BLOCK_WAIT_MS) {
        ok = exchange(0xFF, &token);
    }
    ok = ok && token == 0xFE &&
         HAL_SPI_TransmitReceive(&hspi1, dummy, dst, 512, 100) == HAL_OK &&
         HAL_SPI_TransmitReceive(&hspi1, dummy, crc, 2, 10) == HAL_OK;
    return release_card() && ok;
}

static bool write_block(LBA_t sector, const BYTE *src)
{
    BYTE r1, response = 0xFF, ignored, status;
    const BYTE prefix[] = {0xFF, 0xFE};
    sd_last_write_token = 0xFF;
    bool ok = select_card() && command(24, sector, &r1) && r1 == 0 &&
              HAL_SPI_Transmit(&hspi1, prefix, sizeof prefix, 10) == HAL_OK &&
              HAL_SPI_Transmit(&hspi1, src, 512, 100) == HAL_OK &&
              HAL_SPI_Transmit(&hspi1, dummy, 2, 10) == HAL_OK;
    for (unsigned n = 0; ok && response == 0xFF && n < 8; ++n) {
        ok = exchange(0xFF, &response);
    }
    sd_last_write_token = response;
    /* Supply clocks after the response, then wait for programming to finish. */
    ok = ok && exchange(0xFF, &ignored) && wait_ready(CARD_WAIT_MS) &&
         (response & 0x1FU) == 0x05U;
    return release_card() && ok && query(13, 0, &r1, &status, 1) &&
           r1 == 0 && status == 0;
}

DSTATUS disk_initialize(BYTE drive)
{
    if (drive != 0) {
        return STA_NOINIT;
    }
    if (disk_state != 0 && initialise_sdhc()) {
        disk_state = 0;
    }
    return disk_state;
}

DSTATUS disk_status(BYTE drive)
{
    return drive == 0 ? disk_state : STA_NOINIT;
}

DRESULT disk_read(BYTE drive, BYTE *dst, LBA_t sector, UINT count)
{
    if (drive != 0 || dst == NULL || count == 0 || sector > UINT32_MAX - (count - 1U)) {
        return RES_PARERR;
    }
    if (disk_state != 0) {
        return RES_NOTRDY;
    }
    for (UINT n = 0; n < count; ++n, ++sector, dst += 512) {
        if (!read_block(sector, dst)) {
            disk_state = STA_NOINIT;
            return RES_ERROR;
        }
    }
    return RES_OK;
}

DRESULT disk_write(BYTE drive, const BYTE *src, LBA_t sector, UINT count)
{
    if (drive != 0 || src == NULL || count == 0 || sector > UINT32_MAX - (count - 1U)) {
        return RES_PARERR;
    }
    if (disk_state != 0) {
        return RES_NOTRDY;
    }
    for (UINT n = 0; n < count; ++n, ++sector, src += 512) {
        if (!write_block(sector, src)) {
            disk_state = STA_NOINIT;
            return RES_ERROR;
        }
    }
    return RES_OK;
}

DRESULT disk_ioctl(BYTE drive, BYTE request, void *buffer)
{
    (void)buffer;
    if (drive != 0 || request != CTRL_SYNC) {
        return RES_PARERR;
    }
    if (disk_state != 0) {
        return RES_NOTRDY;
    }
    bool ready = select_card();
    if (release_card() && ready) {
        return RES_OK;
    }
    disk_state = STA_NOINIT;
    return RES_ERROR;
}

The driver’s three volatile diagnostic bytes are visible in the debugger. sd_last_cmd and sd_last_r1 describe the most recent attempted command. sd_last_write_token holds the most recent write response byte. A later status command can replace the command and R1 fields, so inspect all three. Treat them as snapshots. For a sequence across several commands, capture the bus traffic instead.

FatFs disk layer calling STM32 HAL SPI for 512-byte CMD17 reads and CMD24 writes with a busy wait

Create, sync and verify the CSV file

Use the public function in sd_csv.h after the generated GPIO and SPI initialisation. It mounts the volume immediately with f_mount(&filesystem, "0:", 1). Immediate mounting makes initialisation and volume errors visible at that line. A deferred mount would register the filesystem object and postpone the checks until the first file operation.

The logger opens 0:/LOG.CSV with FA_WRITE | FA_OPEN_APPEND. FatFs creates the file if needed and places the write position at the end of an existing file. The program adds the header only if the file is empty, then remembers the offset where the sample rows start. It uses the file byte count returned by f_write() as well as the result code. A short write can occur when the volume runs out of space even though the function returns FR_OK.

#ifndef SD_CSV_H
#define SD_CSV_H

#include "ff.h"

FRESULT SdLog_RunDemo(void);

#endif
#include "sd_csv.h"
#include <string.h>

static FATFS filesystem;
static const TCHAR path[] = "0:/LOG.CSV";
static const char header[] = "elapsed_ms,sample\r\n";
static const char rows[] = "0,123\r\n1000,456\r\n2000,789\r\n";

static FRESULT write_exact(FIL *file, const void *bytes, UINT length)
{
    UINT written = 0;
    FRESULT result = f_write(file, bytes, length, &written);
    if (result == FR_OK && written != length) {
        return FR_DENIED;  /* Report a short write, for example on a full volume. */
    }
    return result;
}

static FRESULT close_with_result(FIL *file, FRESULT earlier)
{
    FRESULT closed = f_close(file);
    return earlier == FR_OK ? closed : earlier;
}

static FRESULT append_rows(FSIZE_t *first_row)
{
    FIL file;
    FRESULT result = f_open(&file, path, FA_WRITE | FA_OPEN_APPEND);
    if (result != FR_OK) {
        return result;
    }
    if (f_size(&file) == 0) {
        result = write_exact(&file, header, sizeof header - 1U);
    }
    *first_row = f_tell(&file);
    if (result == FR_OK) {
        result = write_exact(&file, rows, sizeof rows - 1U);
    }
    if (result == FR_OK) {
        result = f_sync(&file);
    }
    return close_with_result(&file, result);
}

static FRESULT verify_rows(FSIZE_t first_row)
{
    FIL file;
    char actual[sizeof rows - 1U];
    UINT read = 0;
    FRESULT result = f_open(&file, path, FA_READ);
    if (result != FR_OK) {
        return result;
    }
    result = f_lseek(&file, first_row);
    if (result == FR_OK) {
        result = f_read(&file, actual, sizeof actual, &read);
    }
    if (result == FR_OK && (read != sizeof actual || memcmp(actual, rows, sizeof actual) != 0)) {
        result = FR_INT_ERR;  /* Lab readback did not match the appended bytes. */
    }
    return close_with_result(&file, result);
}

FRESULT SdLog_RunDemo(void)
{
    FSIZE_t first_row = 0;
    FRESULT result = f_mount(&filesystem, "0:", 1);
    if (result == FR_OK) {
        result = append_rows(&first_row);
    }
    if (result == FR_OK) {
        result = verify_rows(first_row);
    }
    FRESULT unmounted = f_mount(NULL, "0:", 0);
    return result == FR_OK ? unmounted : result;
}

Put the include in the generated main.c user include section. Put the volatile result in a global user section so you can inspect it after the call. Place the assignment inside the USER CODE BEGIN 2 section, after MX_GPIO_Init() and MX_SPI1_Init(). Let the generated main loop run afterwards. Calling the demo repeatedly from the loop would append a new batch every time it ran.

/* Includes */
#include "sd_csv.h"

/* PV */
volatile FRESULT log_result = FR_NOT_READY;

/* USER CODE 2 */
log_result = SdLog_RunDemo();

The append function calls f_sync(), then closes the file and preserves the earliest error if either step fails. The explicit sync illustrates what a long-running logger can do while keeping a file open. For a short application, a successful close also flushes the file. The demo reopens with FA_READ, seeks to the recorded offset and compares the exact bytes. It closes that handle and unregisters the volume before returning. A successful return means the application saw its appended data through the same filesystem and transport.

Check the file independently on a computer

Flash the firmware over SWD. Let the demo run to completion and inspect log_result. On a healthy bench run, it should be FR_OK. Stop the application after it returns, power the target down and remove the card. Mount the card on the computer and open LOG.CSV in a plain text editor. Expect one header and three data lines after a first successful run on a card with no previous log.

elapsed_ms,sample
0,123
1000,456
2000,789

These values are known test data. The millisecond column shows sample labels rather than measured delays. The file contains CRLF line endings, and the first batch takes 46 bytes including the header. Safely eject the card on the computer, reinsert it with the target powered down and start the same firmware again. The file should now contain the header once and the three rows twice, for 73 bytes. This checks that a reset does not overwrite the first batch. The demo assumes any existing LOG.CSV uses the same CSV schema and ends at a row boundary. Inspect an interrupted or unrelated file before letting the application append to it.

Bench checkExpected observation
Power up without a cardMount reports FR_NOT_READY if no valid card response arrives
Use a known FAT32 cardDrive initialises and the demo returns FR_OK
Inspect a fresh log on a computerOne header and three rows
Run again using the same cardSix data rows and one header
Use a card whose filesystem is exFATMount may return FR_NO_FILESYSTEM after transport initialisation
Use a full FAT32 volumeCheck the returned file result and byte count. A partial row can remain

The firmware and ST’s SPI driver were compiled and linked for Cortex-M3 using a build fixture. The same disk and CSV source also ran against a simulated card backed by a FAT32 disk image. The simulation verified normal appends, write rejection and a missing-card response. The bench checks above verify the conditions that the simulator cannot represent, including wiring, SPI signal quality and actual card behaviour.

CSV logging sequence showing mount append sync close and reopen with the expected LOG.CSV rows

Plan the sample rate and flush interval

Once you can recover the expected file, replace the known samples with real measurements. Start by defining the row format and the sample time base. A logger that resets its elapsed counter on every boot should record a boot identifier or create a new session file. Otherwise two rows labelled 1000 can represent different runs. The application can also record a monotonic sample index and add wall-clock time when its RTC is valid. Our STM32 ADC and DMA guide covers acquisition on a separate part of the system.

Make the sample producer and file writer separate in the application’s design. A timer or DMA callback can collect a value and timestamp into a bounded RAM queue. A foreground consumer formats complete rows and writes them in batches. Keep FatFs calls out of the interrupt. The disk driver polls the SPI hardware and card, and the HAL timebase must run during those waits. If a card write stalls the foreground for longer than the queue can cover, count dropped samples or stop acquisition deliberately. A visible sequence gap is easier to diagnose than a logger that silently drops rows.

Estimate storage and buffering

Assume an average row length of 24 bytes including its line ending. At ten rows per second, the logger produces 240 bytes per second. Over one hour this is 864,000 bytes, about 844 KiB. Over one day it is 20,736,000 bytes, about 19.8 MiB. These are file payload figures. Cluster allocation and filesystem metadata use additional space. A CSV field with a growing timestamp or more digits changes the average row length.

Budget exampleCalculationResult
Wire time for a sector payload512 × 8 ÷ 4,500,000About 0.91 ms
Average log rate24 bytes × 10 samples/s240 bytes/s
One day of rows240 × 86,40020,736,000 bytes
Rows during a 750 ms stallRound up 10 × 0.7508 rows
Buffered bytes for those rows8 × 24192 bytes before headroom

A 750 ms wait is one timeout in the card driver, not a bound on a whole f_sync(). A single file operation may update several sectors. To size a queue, measure the worst gaps in the complete write and sync path on the cards you will deploy. Include repeated stalls, existing queue occupancy and time spent formatting rows. At 100 samples per second, the same 750 ms interval already calls for 75 queued samples before headroom. Store the compact binary readings in the queue if RAM is tight and format them only when draining it.

Batching also changes how often the filesystem updates metadata. A long-running logger can write several rows at a time and call f_sync() at an interval chosen for its power-loss budget. As a planning example, five seconds at ten rows per second corresponds to 50 rows between scheduled syncs. Extra rows may be sitting in the RAM queue or arriving while a sync is in progress. State the interval in terms of successful sync completion when evaluating data loss. Successful sync reduces the exposure window. It does not make the FAT volume transactional or make power removal during the next write safe.

Treat a write error as an uncertain file boundary

If f_write() is short or f_sync() fails, keep the file result and the last card diagnostics. Part of the batch or a metadata update may already have reached the card. Resending the same batch can duplicate rows or append after a partial line. When recovery matters, include a sample sequence number in each row and define a restart rule for incomplete lines. One option is to start a new file after an uncertain close, then reconcile the files later using their sample IDs.

Make card removal an explicit operation in the user interface. Stop collecting new rows, drain the queue, check sync and close, unmount, and then indicate that the card can be removed. The example uses a fixed card with no insertion switch. If the target continues running and you swap the card, the driver has no automatic way to invalidate its state and clear filesystem assumptions. For the bench procedure, power down between swaps. A field design with removable media needs card detection and coordinated stop, unmount and reinitialisation.

Troubleshoot STM32 microSD and FatFs errors

Start with the earliest failed stage. A timeout on CMD0 is different from a mount error after CMD58 succeeds. An SPI trace can show the command frame and card response with CS low. A filesystem result can show whether transport returned data that fails FAT validation. Keep the return values and card diagnostics together in the debugger or on a separate UART. STM32 UART and HAL diagnostics guide

SymptomFirst checkLikely area
CMD0 returns only 0xFFProbe CS, clock, MOSI, MISO and VDD at the socketNo card reply, wiring or power
CMD8 has the illegal-command bit setVerify the card type and frame bytesOlder card or bad command reception
ACMD41 remains idleConfirm CMD55 before each poll and verify supplyInitialisation or card power
CMD58 returns with CCS clearCheck card capacity classByte addressing outside this lab
FR_NO_FILESYSTEM after initialisationCheck the card on a computerUnsupported or damaged filesystem
Read works but a write failsInspect the write token, busy period and socket voltageSupply transient, card fault or programming error
FR_DENIED from the labCheck free space, attributes and write byte countFull volume, access denial or short write
Failure appears only at 4.5 MHzTry a slower transfer prescaler and scope the socket signalsWiring length, level shift or signal integrity
Mount works once then fails after a swapReboot with the replacement card installedDriver state and media change handling

For a CMD0 diagnosis, trigger on the first falling edge of CS after the 80 startup clocks. The six MOSI bytes should be 40 00 00 00 00 95. If they decode correctly at the MCU but not at the socket, inspect the connection and common ground. When CMD0 succeeds but CMD8 fails, compare the 48 00 00 01 AA 87 frame and check that CS stays low through all four extra response bytes. Keep the analyser configured for mode 0 and probe at the card to include breakout circuitry in the measurement.

For failures after the speed change, leave the initialisation prescaler alone and lower only the working speed. A change from divide-by-16 to divide-by-32 halves the data clock to 2.25 MHz. A stable result at the lower speed gives you a reproducible signal-integrity test case. Check the breakout for level shifters with slow edges and check that jumpers are short. Compare the waveform during a command and during a full sector transfer. The latter can expose behaviour that a short command exchange misses.

When you have an oscilloscope, probe SCK and MISO at the STM32 pins using a short ground connection. Check whether MISO has settled before the rising clock edge at the receiver. Probe the same signal at the socket to see what the card is actually driving. A logic analyser can decode the digital traffic and identify the failing command, while an oscilloscope can show ringing, slow transitions or a supply dip that explains the bit errors.

The same strategy applies to a write that fails only during a large log. Confirm that the file opens and that initial blocks are accepted. Observe the 3.3 V rail and the data-out busy period while the card is programming. Check whether a watchdog deadline overlaps the stall. Raise a debug GPIO around f_sync() to measure the entire operation, including gaps in the SPI clock. STM32 watchdog and reset flags guide

Questions about Blue Pill microSD logging

Can I use SDIO on an STM32F103C8?

The C8’s documented peripherals include SPI and omit SDIO. Use the card’s SPI mode on this board. An STM32 board with SDIO uses a different peripheral and can use a wider card bus if the pins and socket are wired for it. The STM32F407VET6 board guide covers an example of that wiring.

Does FatFs choose which SPI peripheral to use?

The disk layer chooses SPI1 and PA4 in this project. FatFs sees drive 0 and a sector interface. To move to SPI2, change the handle, chip select, peripheral clock calculation and generated pins together. Recalculate the initialisation and working divisors from the SPI2 clock. The same file-layer calls can stay in place.

Why does the computer show a fixed modification date?

The lab’s FF_FS_NORTC setting makes FatFs use the date in ffconf.h. Real filesystem timestamps require the time hook expected by the matching FatFs release and a clock set to a valid date. Add that support separately from the sample timestamp so each remains useful if the other is unavailable.

Can I keep the file open while logging?

Yes. Keep the volume object and file handle valid for the whole session and give one task ownership. Sync periodically, check each result and close before removal. In that design a failure in one operation can leave later calls unable to proceed. Have the logger stop and record the earliest failure instead of continuing to feed a damaged session.

Datasheets & External Resources

SourceWhat to check
ST DS5319 STM32F103x8/xB datasheetC8 peripherals, SPI1 pins, electrical limits and SRAM
ST RM0008 reference manualSPI clock selection, frame setup and master operation
ST ES096 medium-density errataSPI CRC limitations relevant to adaptations
ST STM32F1 HAL SPI source v1.1.10Polling transfers, SPI initialisation and timeout behaviour
SD Association simplified specificationsPhysical Layer v9.10, SPI initialisation and block transactions
FatFs application noteDisk port, memory usage and critical sections
FatFs f_sync documentationFlush semantics and their use in data logging
CONTINUE EXPLORING / ESP32 & ELECTRONICS

Read next

Practical guides related to this article.

Explore the topic →
HARDWARE DECISION GUIDEWhich ESP32 board should you use?

Compare practical options before you choose hardware.

Compare options →