4、文件系统驱动实现:基于LittleFS的SPI Flash驱动编写实战

好,咱们进入实战环节。前面讲了文件系统的选型和原理,现在要真刀真枪地写代码了。LittleFS 本身是个纯软件层,它需要底层存储介质的读写接口才能工作。说白了,你得先让 MCU 能和 SPI Flash 对上话,然后 LittleFS 才能在上面建文件系统。

我个人习惯把驱动拆成两层:SPI 硬件抽象层和LittleFS 设备适配层。这样分开写,调试的时候能快速定位问题。你想想看,如果文件读写报错,到底是 SPI 时序不对,还是 LittleFS 配置错了?分两层,一目了然。

4.1 SPI Flash 硬件抽象层

这一层负责最底层的 SPI 通信。我以 W25Q64(8MB)为例,这是市面上最常见的 SPI NOR Flash。它的指令集基本是行业标准,换其他型号也大同小异。

先定义几个关键指令宏:

/* W25Q64 指令集 */
#define CMD_WRITE_ENABLE   0x06
#define CMD_VOLATILE_SR_WRITE_ENABLE 0x50
#define CMD_PAGE_PROGRAM   0x02
#define CMD_READ_DATA      0x03
#define CMD_FAST_READ      0x0B
#define CMD_SECTOR_ERASE   0x20
#define CMD_BLOCK_ERASE_32K 0x52
#define CMD_BLOCK_ERASE_64K 0xD8
#define CMD_CHIP_ERASE     0xC7
#define CMD_READ_STATUS_REG1 0x05
#define CMD_READ_STATUS_REG2 0x35
#define CMD_READ_ID        0x9F

嗯,这里要注意:写操作之前必须先发写使能指令(0x06)。我刚开始做这个项目时,就忘了这一步,结果数据怎么都写不进去,折腾了大半天。后来查手册才发现,W25Q 系列在每次写操作前都需要单独使能,这是硬件保护机制。

核心的 SPI 读写函数,我一般这样封装:

/* SPI 底层接口 */
static int flash_spi_transfer(uint8_t *tx_buf, uint8_t *rx_buf, uint32_t len)
{
    /* 这里调用你的 MCU SPI 驱动库 */
    /* 以 STM32 HAL 为例: */
    HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, len, HAL_MAX_DELAY);
    return 0;
}

/* 读取 Flash ID,用于验证通信是否正常 */
uint32_t flash_read_id(void)
{
    uint8_t cmd = CMD_READ_ID;
    uint8_t rx[3] = {0};
    uint8_t dummy[3] = {0xFF, 0xFF, 0xFF};

    flash_cs_low();
    flash_spi_transfer(&cmd, NULL, 1);
    flash_spi_transfer(dummy, rx, 3);
    flash_cs_high();

    return (rx[0] << 16) | (rx[1] << 8) | rx[2];
}
调试小技巧: 在初始化时先读一次 Flash ID。如果返回 0xEF4017(W25Q64 的标准 ID),说明 SPI 硬件通路没问题。如果返回 0xFFFFFF 或 0x000000,先检查 SPI 引脚配置和时钟极性。我曾经遇到过因为 SPI 模式(CPOL/CPHA)配错,读出来的 ID 全是乱码。

4.2 LittleFS 设备适配层

LittleFS 要求你实现 5 个底层操作函数:read、prog、erase、sync 和 lock/unlock(如果支持多线程)。这些函数通过一个 lfs_config 结构体注册给 LittleFS。

先看结构体定义:

/* LittleFS 配置结构体 */
static struct lfs_config cfg = {
    /* 块设备上下文,通常指向你的 Flash 对象 */
    .context = NULL,

    /* 读操作:从块设备读取数据 */
    .read = flash_read,

    /* 写操作:向块设备写入数据(注意:LittleFS 要求 prog 是 page 对齐的) */
    .prog = flash_prog,

    /* 擦除操作:擦除一个块 */
    .erase = flash_erase,

    /* 同步操作:确保数据真正写入物理介质 */
    .sync = flash_sync,

    /* 块大小:W25Q64 的擦除块大小是 4096 字节 */
    .block_size = 4096,

    /* 页大小:W25Q64 的页大小是 256 字节 */
    .page_size = 256,

    /* 块数量:8MB / 4KB = 2048 块 */
    .block_count = 2048,

    /* 读大小:最小读取单位,通常设为 1 */
    .read_size = 1,

    /* 写大小:最小写入单位,通常设为 page_size */
    .prog_size = 256,

    /* 缓存大小:LittleFS 内部使用的缓存,建议至少 block_size */
    .cache_size = 4096,

    /* 预分配块数:文件系统元数据预留 */
    .lookahead_size = 16,
};

接下来是实现这 5 个函数。我挑两个最关键的说说:

4.2.1 read 函数实现

static int flash_read(const struct lfs_config *c, lfs_block_t block,
                      lfs_off_t off, void *buffer, lfs_size_t size)
{
    uint32_t addr = block * c->block_size + off;
    uint8_t cmd[4] = {CMD_READ_DATA,
                      (addr >> 16) & 0xFF,
                      (addr >> 8) & 0xFF,
                      addr & 0xFF};

    flash_cs_low();
    flash_spi_transfer(cmd, NULL, 4);
    flash_spi_transfer((uint8_t *)buffer, NULL, size);
    flash_cs_high();

    return 0;
}

这里有个细节:地址是 24 位的,因为 W25Q64 最大寻址 8MB(2^23)。如果你的 Flash 容量更大(比如 16MB),就需要用 4 字节地址模式,指令也要换成 CMD_FAST_READ_4B。

4.2.2 prog 函数实现

static int flash_prog(const struct lfs_config *c, lfs_block_t block,
                      lfs_off_t off, const void *buffer, lfs_size_t size)
{
    uint32_t addr = block * c->block_size + off;
    uint8_t cmd[4] = {CMD_PAGE_PROGRAM,
                      (addr >> 16) & 0xFF,
                      (addr >> 8) & 0xFF,
                      addr & 0xFF};

    /* 写使能 */
    flash_write_enable();

    flash_cs_low();
    flash_spi_transfer(cmd, NULL, 4);
    flash_spi_transfer((uint8_t *)buffer, NULL, size);
    flash_cs_high();

    /* 等待写完成 */
    flash_wait_busy();

    return 0;
}
避坑指南: 我曾经在 prog 函数里忘记调用 flash_wait_busy(),结果连续写入时,第二个写操作在 Flash 还没完成前就开始了,导致数据错乱。W25Q 系列写一页大约需要 0.5~3ms,一定要等 BUSY 位清零后再返回。否则 LittleFS 会认为写入成功,实际上数据根本没落盘。

4.3 初始化与挂载流程

驱动写好后,挂载文件系统就简单了。我一般这样组织初始化代码:

/* LittleFS 对象 */
static lfs_t lfs;

/* 挂载文件系统 */
int littlefs_mount(void)
{
    int err;

    /* 1. 初始化 SPI Flash 硬件 */
    flash_hw_init();

    /* 2. 验证 Flash 通信 */
    uint32_t id = flash_read_id();
    if (id != 0xEF4017) {
        printf("Flash ID mismatch: 0x%06X\n", id);
        return -1;
    }

    /* 3. 尝试挂载已有文件系统 */
    err = lfs_mount(&lfs, &cfg);

    /* 4. 如果挂载失败,格式化并重新挂载 */
    if (err) {
        printf("Mount failed (err=%d), formatting...\n", err);
        err = lfs_format(&lfs, &cfg);
        if (err) {
            printf("Format failed: %d\n", err);
            return -2;
        }
        err = lfs_mount(&lfs, &cfg);
        if (err) {
            printf("Re-mount failed: %d\n", err);
            return -3;
        }
    }

    printf("LittleFS mounted successfully!\n");
    return 0;
}

你可能会问:为什么要先尝试挂载,失败再格式化?因为如果 Flash 里已经有文件系统,直接格式化会丢失数据。第一次上电或者 Flash 被擦除过,挂载才会失败,这时候才需要格式化。这个流程在量产设备里很常见。

4.4 整体架构图

下面这张图展示了从 LVGL 到物理 Flash 的完整调用链:

LittleFS + SPI Flash 驱动架构 LVGL 图片加载层 POSIX 文件接口 LittleFS 核心层 LittleFS 设备适配层 (lfs_config) flash_read() flash_prog() flash_erase() flash_sync() lock/unlock 底层:SPI Flash 硬件 (W25Q64 / 8MB)

4.5 关键参数配置表

LittleFS 的配置参数直接影响性能和空间利用率。我整理了一份常用配置对照表,方便你根据 Flash 型号调整:

参数 W25Q64 (8MB) W25Q128 (16MB) 说明
block_size 4096 4096 擦除块大小,W25Q 系列固定 4KB
page_size 256 256 编程页大小,W25Q 系列固定 256B
block_count 2048 4096 总块数 = 容量 / block_size
cache_size 4096 4096 建议 ≥ block_size,否则性能下降
lookahead_size 16 32 大容量 Flash 可适当增大
read_size 1 1 最小读取单位,通常 1 字节
prog_size 256 256 必须等于 page_size
经验之谈: cache_size 这个参数我踩过坑。如果设得太小(比如 256 字节),LittleFS 内部会频繁读写 Flash,导致文件操作奇慢无比。建议至少设为 block_size(4KB)。如果你的 RAM 够用,设成 8KB 甚至 16KB 效果更好。代价是多占一些内存,但换来的是文件读写速度翻倍。

4.6 调试与验证

驱动写完后,别急着上 LVGL。先跑几个简单的测试用例,确认文件系统能正常工作:

/* 测试用例:创建文件并写入数据 */
void test_littlefs(void)
{
    lfs_file_t file;
    const char *test_str = "Hello from LittleFS!";
    char buf[64] = {0};

    /* 创建文件 */
    int err = lfs_file_open(&lfs, &file, "test.txt", LFS_O_WRONLY | LFS_O_CREAT);
    if (err) { printf("Open failed: %d\n", err); return; }

    /* 写入数据 */
    err = lfs_file_write(&lfs, &file, test_str, strlen(test_str));
    if (err < 0) { printf("Write failed: %d\n", err); return; }

    /* 关闭文件 */
    lfs_file_close(&lfs, &file);

    /* 重新打开读取 */
    err = lfs_file_open(&lfs, &file, "test.txt", LFS_O_RDONLY);
    if (err) { printf("Re-open failed: %d\n", err); return; }

    lfs_file_read(&lfs, &file, buf, sizeof(buf));
    printf("Read back: %s\n", buf);

    lfs_file_close(&lfs, &file);

    /* 删除文件 */
    lfs_remove(&lfs, "test.txt");
}

如果这个测试通过,说明你的驱动基本没问题。接下来就可以把 LVGL 的文件接口挂载到 LittleFS 上了。嗯,这部分内容我们会在下一节详细展开。

最后提醒: 调试阶段建议把 LittleFS 的日志等级调到最高。在 lfs_config 里设置 .attr_max = 0 可以关闭属性功能,减少调试干扰。如果遇到文件读写返回负值,先检查 block_size 和 page_size 是否和 Flash 手册一致——我见过太多人把这两个参数搞反了。