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];
}
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;
}
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 的完整调用链:
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 上了。嗯,这部分内容我们会在下一节详细展开。
lfs_config 里设置 .attr_max = 0 可以关闭属性功能,减少调试干扰。如果遇到文件读写返回负值,先检查 block_size 和 page_size 是否和 Flash 手册一致——我见过太多人把这两个参数搞反了。