Работа с FAT32 и exFAT с помощью библиотеки FatFs

13 июня 2018

В рамках статьи Учимся работать с SDHC/SDXC-картами по протоколу SPI мы освоили использование SD-карт подобно блочным устройствам в *nix системах, то есть, читая и записывая блоки размером 512 байт. Навык этот, бесспорно, полезен, но настоящая крутизна заключается в умении работать с файловыми системами на эти картах. Написать соответствующую библиотеку с нуля — это проект далеко не на один и не на два вечера, как в случае с какими-нибудь OLED-экранчиками. Поэтому даже такой изобретатель колес, как я, в данном случае был вынужден использовать что-то готовое. А именно, библиотеку FatFs.

Коротко о главном

FatFs реализует поддержку файловых систем FAT и exFAT. Библиотека написана на ANSI C и имеет BSD-подобную лицензию. Характерно, что FatFs все равно, где вы его используете — на микроконтроллерах AVR, STM32, или вообще на десктопе. Также ему все равно, что вы используете для хранения информации. Это может быть и SD-карта, и SPI flash, и файл на диске, и даже какое-то сетевое устройство. В рамках этой заметки будет продемонстрировано использование данной библиотеки на примере микроконтроллеров STM32 и SD-карт. Однако важно помнить, что область применения FatFs этим не ограничивается. Кстати, выше я неспроста отметил, что написать аналогичную библиотеку с нуля крайне непросто. FatFs развивается с 2006 года, и в ней до сих пор временами исправляют небольшие ошибки.

Если говорить конкретно про STM32, то есть по крайней мере два способа подключения библиотеки FatFs к проекту. Это можно сделать через свойства проекта в STM32CubeMX, или же вручную. Проблема с первым способом заключается в том, что он добавляет в проект далеко не самую свежую версию FatFs. А как было отмечено выше, в старых версиях есть неисправленные ошибки. Поэтому в рамках данной статьи речь пойдет исключительно о ручном способе. Интересующиеся тем, как это делается в STM32CubeMX, могут изучить данный вопрос самостоятельно, это не сложно. Кроме того, в полной версии исходников к сей статье вы найдете два проекта, один из которых был сделан автоматическим способом, а второй — ручным. Отличия в них небольшие.

Подключение FatFs

FatFs подключается к проекту следующим образом. Качаем исходники библиотеки и распаковываем их в отдельный каталог:

fatfs/
+-- 00history.txt
+-- 00readme.txt
+-- diskio.c
+-- diskio.h
+-- ff.c
+-- ffconf.h
+-- ff.h
+-- ffsystem.c
+-- ffunicode.c
+-- integer.h

Makefile правим следующим образом. В C_SOURCES добавляем:

fatfs/ff.c \
fatfs/diskio.c \
fatfs/ffunicode.c \

Также дописываем в C_INCLUDES:

-Ifatfs \

Настройка библиотеки осуществляется через fatfs/ffconf.h. Настроек довольно много, но они хорошо документированы. Основные настройки, которые имеет смысл изменить, следующие.

Включаем поддержку длинных имен файлов (LFN, Long File Name). Эта опция необходима для exFAT, который в свою очередь нужен для работы с картами SDXC. Другими словами, если тип карты заранее неизвестен, или планируется использовать карты размером больше 32 Гб, включаем:

#define FF_USE_LFN     1

Чтобы не греть мозг с кодировками и локалями, включаем поддержку UTF-8:

#define FF_LFN_UNICODE 2

Поддержка exFAT:

#define FF_FS_EXFAT    1

В этом проекте я не использовал часы реального времени. Так как текущее время прошивке неизвестно, говорим FatFs использовать для всего захардкоженное время:

#define FF_FS_NORTC    1
#define FF_NORTC_MON   1
#define FF_NORTC_MDAY  1
#define FF_NORTC_YEAR  2018

Далее нужно отредактировать fatfs/diskio.c. Здесь объявляются процедуры, определяющие, где физически библиотека будет искать данные, и прочее в таком духе.

Процедура disk_status возвращает текущий статус диска. В нашем случае она всегда будет возвращать успех:

DSTATUS disk_status (
  BYTE pdrv /* Physical drive nmuber to identify the drive */
)
{
    return 0;
}

Процедура disk_initialize вызывается при инициализации диска. Как ни странно, в нашем случае она тоже будет просто возвращать успех. Связано это вот с чем. Как было объяснено в прошлой статье, SD-карта должна быть либо первым инициализируемым устройством на SPI-шине, либо использовать отдельную шину. К сожалению, FatFs производит отложенную инициализацию. Другими словами, мы не можем сказать точно, в какой момент она произойдет. Более того, момент этот будет меняться с изменением основного кода. Поэтому, чтобы гарантировать, что SD-карта инициализируется сразу после запуска прошиви, disk_initialize ничего не делает:

DSTATUS disk_initialize (
  BYTE pdrv /* Physical drive nmuber to identify the drive */
)
{
    // already initialized in init() procedure
    return 0;
}

… а сама инициализация выполняется вручную в процедуре init из Src/main.c:

int init() {
    // unselect all SPI devices first
    SDCARD_Unselect();
    ST7735_Unselect();

    // initialize SD-card as fast as possible, it glitches otherwise
    // (this is important if SPI bus is shared by multiple devices)
    int code = SDCARD_Init();
    if(code < 0) {
        UART_Printf("SDCARD_Init() failed, code = %d\r\n", code);
        return -1;
    }

     /* ... */
}

Процедура disk_read читает с диска заданное количество блоков. FatFs позволяет использовать блоки размером от 512 до 4096 байт. По умолчанию используются блоки размером 512 байт, что как раз совпадает с размером блока у SD-карт. Реализация процедуры:

DRESULT disk_read (
  BYTE pdrv,    /* Physical drive nmuber to identify the drive */
  BYTE *buff,   /* Data buffer to store read data */
  DWORD sector, /* Start sector in LBA */
  UINT count    /* Number of sectors to read */
)
{
    if(SDCARD_ReadBegin(sector) < 0) {
        return RES_ERROR;
    }

    while(count > 0) {
        if(SDCARD_ReadData(buff) < 0) {
            return RES_ERROR;
        }
        buff += 512;
        count--;
    }

    if(SDCARD_ReadEnd() < 0) {
        return RES_ERROR;
    }

    return RES_OK;
}

Аналогично, всю запись на диск FatFs осуществляет через disk_write:

DRESULT disk_write (
  BYTE pdrv,        /* Physical drive nmuber to identify the drive */
  const BYTE *buff, /* Data to be written */
  DWORD sector,     /* Start sector in LBA */
  UINT count        /* Number of sectors to write */
)
{
    if(SDCARD_WriteBegin(sector) < 0) {
        return RES_ERROR;
    }

    while(count > 0) {
        if(SDCARD_WriteData(buff) < 0) {
            return RES_ERROR;
        }

        buff += 512;
        count--;
    }

    if(SDCARD_WriteEnd() < 0) {
        return RES_ERROR;
    }

    return RES_OK;
}

Процедура disk_ioctl позволяет выполнять синхронизацию данных с диском, если используется какое-то кэширование, узнать количество блоков, что требуется при выполнении форматирования (нужно включить FF_USE_MKFS), и делать некоторые другие вещи. В нашем случае достаточно подтверждать, что синхронизация данных была выполнена:

DRESULT disk_ioctl (
  BYTE pdrv, /* Physical drive nmuber (0..) */
  BYTE cmd,  /* Control code */
  void *buff /* Buffer to send/receive control data */
)
{
    if(cmd == CTRL_SYNC) {
        return RES_OK;
    } else {
        // should never be called
        return RES_ERROR;
    }
}

Наконец, get_fattime возвращает текущее время. Поскольку мы определили FF_FS_NORTC, эта функция все равно не будет использована:

/*
DWORD get_fattime(void)
{
  return 0;
}
*/

Ура, теперь можно ходить в файловую систему!

Первый пример: изучаем API

Следующий код демонстрирует монтирование файловой системы, получение информации о свободном месте, просмотр содержимого заданного каталога, а также чтение и запись текстовых файлов:

void UART_Printf(const char* fmt, ...) {
    char buff[256];
    va_list args;
    va_start(args, fmt);
    vsnprintf(buff, sizeof(buff), fmt, args);
    HAL_UART_Transmit(&huart2, (uint8_t*)buff, strlen(buff),
                      HAL_MAX_DELAY);
    va_end(args);
}

void init() {
    FATFS fs;
    FRESULT res;
    UART_Printf("Ready!\r\n");

    // mount the default drive
    res = f_mount(&fs, "", 0);
    if(res != FR_OK) {
        UART_Printf("f_mount() failed, res = %d\r\n", res);
        return;
    }

    UART_Printf("f_mount() done!\r\n");

    uint32_t freeClust;
    FATFS* fs_ptr = &fs;
    // Warning! This fills fs.n_fatent and fs.csize!
    res = f_getfree("", &freeClust, &fs_ptr);
    if(res != FR_OK) {
        UART_Printf("f_getfree() failed, res = %d\r\n", res);
        return;
    }

    UART_Printf("f_getfree() done!\r\n");

    uint32_t totalBlocks = (fs.n_fatent - 2) * fs.csize;
    uint32_t freeBlocks = freeClust * fs.csize;

    UART_Printf("Total blocks: %lu (%lu Mb)\r\n",
                totalBlocks, totalBlocks / 2000);
    UART_Printf("Free blocks: %lu (%lu Mb)\r\n",
                freeBlocks, freeBlocks / 2000);

    DIR dir;
    res = f_opendir(&dir, "/");
    if(res != FR_OK) {
        UART_Printf("f_opendir() failed, res = %d\r\n", res);
        return;
    }

    FILINFO fileInfo;
    uint32_t totalFiles = 0;
    uint32_t totalDirs = 0;
    UART_Printf("--------\r\nRoot directory:\r\n");
    for(;;) {
        res = f_readdir(&dir, &fileInfo);
        if((res != FR_OK) || (fileInfo.fname[0] == '\0')) {
            break;
        }
       
        if(fileInfo.fattrib & AM_DIR) {
            UART_Printf("  DIR  %s\r\n", fileInfo.fname);
            totalDirs++;
        } else {
            UART_Printf("  FILE %s\r\n", fileInfo.fname);
            totalFiles++;
        }
    }

    UART_Printf("(total: %lu dirs, %lu files)\r\n--------\r\n",
                totalDirs, totalFiles);

    res = f_closedir(&dir);
    if(res != FR_OK) {
        UART_Printf("f_closedir() failed, res = %d\r\n", res);
        return;
    }

    UART_Printf("Writing to log.txt...\r\n");

    char writeBuff[128];
    snprintf(writeBuff, sizeof(writeBuff),
        "Total blocks: %lu (%lu Mb); Free blocks: %lu (%lu Mb)\r\n",
        totalBlocks, totalBlocks / 2000,
        freeBlocks, freeBlocks / 2000);

    FIL logFile;
    res = f_open(&logFile, "log.txt", FA_OPEN_APPEND | FA_WRITE);
    if(res != FR_OK) {
        UART_Printf("f_open() failed, res = %d\r\n", res);
        return;
    }

    unsigned int bytesToWrite = strlen(writeBuff);
    unsigned int bytesWritten;
    res = f_write(&logFile, writeBuff, bytesToWrite, &bytesWritten);
    if(res != FR_OK) {
        UART_Printf("f_write() failed, res = %d\r\n", res);
        return;
    }

    if(bytesWritten < bytesToWrite) {
        UART_Printf("WARNING! Disk is full.\r\n");
    }

    res = f_close(&logFile);
    if(res != FR_OK) {
        UART_Printf("f_close() failed, res = %d\r\n", res);
        return;
    }

    UART_Printf("Reading file...\r\n");
    FIL msgFile;
    res = f_open(&msgFile, "log.txt", FA_READ);
    if(res != FR_OK) {
        UART_Printf("f_open() failed, res = %d\r\n", res);
        return;
    }

    char readBuff[128];
    unsigned int bytesRead;
    res = f_read(&msgFile, readBuff, sizeof(readBuff)-1, &bytesRead);
    if(res != FR_OK) {
        UART_Printf("f_read() failed, res = %d\r\n", res);
        return;
    }

    readBuff[bytesRead] = '\0';
    UART_Printf("```\r\n%s\r\n```\r\n", readBuff);

    res = f_close(&msgFile);
    if(res != FR_OK) {
        UART_Printf("f_close() failed, res = %d\r\n", res);
        return;
    }

    // Unmount
    res = f_mount(NULL, "", 0);
    if(res != FR_OK) {
        UART_Printf("Unmount failed, res = %d\r\n", res);
        return;
    }

    UART_Printf("Done!\r\n");
}

Как видите, интерфейс достаточно простой и отдаленно напоминает POSIX.

Второй пример: вывод картинки

Следующий пример парсит картинку в формате BMP (наглядное описание формата есть на Википедии) и отображает ее с помощью экранчика на базе ST7735:

int displayImage(const char* fname) {
    UART_Printf("Openning %s...\r\n", fname);
    FIL file;
    FRESULT res = f_open(&file, fname, FA_READ);
    if(res != FR_OK) {
        UART_Printf("f_open() failed, res = %d\r\n", res);
        return -1;
    }

    UART_Printf("File opened, reading...\r\n");

    unsigned int bytesRead;
    uint8_t header[34];
    res = f_read(&file, header, sizeof(header), &bytesRead);
    if(res != FR_OK) {
        UART_Printf("f_read() failed, res = %d\r\n", res);
        f_close(&file);
        return -2;
    }

    if((header[0] != 0x42) || (header[1] != 0x4D)) {
        UART_Printf("Wrong BMP signature: 0x%02X 0x%02X\r\n",
                    header[0], header[1]);
        f_close(&file);
        return -3;
    }

    uint32_t imageOffset = header[10] | (header[11] << 8) |
                           (header[12] << 16) | (header[13] << 24);
    uint32_t imageWidth  = header[18] | (header[19] << 8) |
                           (header[20] << 16) | (header[21] << 24);
    uint32_t imageHeight = header[22] | (header[23] << 8) |
                           (header[24] << 16) | (header[25] << 24);
    uint16_t imagePlanes = header[26] | (header[27] << 8);

    uint16_t imageBitsPerPixel = header[28] | (header[29] << 8);
    uint32_t imageCompression  = header[30] | (header[31] << 8) |
                                 (header[32] << 16) |
                                 (header[33] << 24);
    UART_Printf(
        "--- Image info ---\r\n"
        "Pixels offset: %lu\r\n"
        "WxH: %lux%lu\r\n"
        "Planes: %d\r\n"
        "Bits per pixel: %d\r\n"
        "Compression: %d\r\n"
        "------------------\r\n",
        imageOffset, imageWidth, imageHeight, imagePlanes,
        imageBitsPerPixel, imageCompression);

    if((imageWidth != ST7735_WIDTH) ||
       (imageHeight != ST7735_HEIGHT))
    {
        UART_Printf("Wrong BMP size, %dx%d expected\r\n",
                    ST7735_WIDTH, ST7735_HEIGHT);
        f_close(&file);
        return -4;
    }

    if((imagePlanes != 1) || (imageBitsPerPixel != 24) ||
       (imageCompression != 0))
    {
        UART_Printf("Unsupported image format\r\n");
        f_close(&file);
        return -5;
    }

    res = f_lseek(&file, imageOffset);
    if(res != FR_OK) {
        UART_Printf("f_lseek() failed, res = %d\r\n", res);
        f_close(&file);
        return -6;
    }

    // row size is aligned to 4 bytes
    uint8_t imageRow[(ST7735_WIDTH * 3 + 3) & ~3];
    for(uint32_t y = 0; y < imageHeight; y++) {
        uint32_t rowIdx = 0;
        res = f_read(&file, imageRow, sizeof(imageRow), &bytesRead);
        if(res != FR_OK) {
            UART_Printf("f_read() failed, res = %d\r\n", res);
            f_close(&file);
            return -7;
        }

        for(uint32_t x = 0; x < imageWidth; x++) {
            uint8_t b = imageRow[rowIdx++];
            uint8_t g = imageRow[rowIdx++];
            uint8_t r = imageRow[rowIdx++];
            uint16_t color565 = ST7735_COLOR565(r, g, b);
            ST7735_DrawPixel(x, imageHeight - y - 1, color565);
        }
    }

    res = f_close(&file);
    if(res != FR_OK) {
        UART_Printf("f_close() failed, res = %d\r\n", res);
        return -8;
    }

    return 0;
}

Процедура в действии:

STM32: пример вывода картинки с SD-карты

Помимо прочего, приведенный код интересен тем, что он активно использует два SPI-устройства (карту и экранчик) на одной шине. Это позволило обнаружить все те интересные тонкости, о которых рассказывалось ранее. Кроме того, данный код использует процедуру f_lseek, которая оказалась нерабочей на exFAT в версии FatFs, предлагаемой STM32CubeMX. В последней версии FatFs с официального сайта этот баг уже исправлен.

Если что-то пошло не так

Все мы люди и делаем ошибки в коде. Поэтому я решил включить в статью команды для восстановления файловых систем в Linux:

# пересоздать FAT32
sudo mkfs.fat -F 32 /dev/mmcblk0p1

# пересоздать exFAT
sudo mkfs.exfat /dev/mmcblk0p1

Проверить существующую файловую систему на ошибки:

# для FAT32
sudo fsck.fat -w -r -l -a -v -t /dev/mmcblk0p1

# для exFAT
sudo fsck.exfat /dev/mmcblk0p1

Если вводить команды в консоли лень, обратите внимание на GUI-программу gparted. В этом контексте также хочу отметить, что FatFs понимает только таблицу разделов MBR. Так что, если будете чинить таблицу разделов, не создайте по привычке GPT.

Заключение

FatFs имеет свои странности. И речь не только о необычном форматировании кода или крайне неудобной отложенной инициализации устройства хранения данных. Возьмем для примера процедуру f_getfree. Она зачем-то принимает в качестве последнего аргумента указатель на указатель на структуру FATFS. Плюс к этому она имеет побочный эффект, который меняет поля структуры FATFS, которые не имеют прямого отношения к получению свободного места. Зачем было делать такой неудобный и очевидно чреватый ошибками интерфейс, мне решительно неясно. Еще более неясно, что он продолжает делать в библиотеке после стольких лет ее активной разработки. Давно можно было бы поправить!

Тем не менее, несмотря на названные странности, библиотека работает, и работает очень хорошо. С ее помощью вы успешно решите такие задачи, как запись на SD-карточку логов с показаниями датчиков, отображение картинок (скажем, каких-то элементов интерфейса), запись и воспроизведение звука в формате WAV, хранение файлов конфигурации, и так далее.

Полную версию исходников к посту, как обычно, вы найдете на GitHub.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.