Zephyr LittleFS VFS:从裸 lfs API 到 VFS 集成

前一篇梳理了 STM32F1 上 W25Q128 + LittleFS + 异步日志的完整栈,那套栈从 SPI 比特流到日志文本一共四层,最底层是手写的 lfs_bd_read/prog/erase/sync 四个块设备回调。移植到 Zephyr 后,这四层里有两层被 Zephyr 内核吞掉了:SPI NOR 驱动由 DTS 节点自动绑定,LittleFS 块设备适配由 FS_LITTLEFS_DECLARE_DEFAULT_CONFIG 宏自动生成。剩下的只是 VFS API 调用和异步日志服务的线程模型重写。

本文聚焦 Zephyr 侧的变更,不重复 LittleFS 原理和按天轮转的业务逻辑——那些在 STM32 侧已经讲过。

架构对比:四层塌成两层

STM32 侧的垂直四层栈在 Zephyr 里塌成两层,中间的驱动层和适配层都由内核提供:

STM32 原工程                     Zephyr 新工程
┌─────────────────────┐          ┌─────────────────────┐
│  DataLogger/Service │          │  app_logger/service  │
├─────────────────────┤          ├─────────────────────┤
│  LittleFS 核心       │          │  LittleFS 核心       │
├─────────────────────┤          │  (Zephyr 内置)        │
│  lfs_port.c         │   ──→    │  VFS 抽象层           │
│  4 个块设备回调       │          │  (fs_open/fs_write…) │
├─────────────────────┤          ├─────────────────────┤
│  W25Q128.c          │          │  SPI NOR 驱动         │
│  (SPI2 + GPIO)      │          │  (jedec,spi-nor)     │
├─────────────────────┤          ├─────────────────────┤
│  STM32F1 SPI2       │          │  STM32F1 SPI2       │
└─────────────────────┘          └─────────────────────┘

最大的体感差异:原工程里 lfs_port.c 那 200 多行代码——544 字节静态缓冲区、读/写/擦/同步四个回调、手动 lfs_format——全部消失。Zephyr 的 CONFIG_SPI_NOR 驱动自动认领 jedec,spi-nor 节点,FS_LITTLEFS_DECLARE_DEFAULT_CONFIG 宏自动展开 lfs_config 结构体,缓存大小由 Kconfig 统一管理。

DTS 配置:SPI2 + NM25Q128 + lfs_partition

原工程的 SPI 引脚、Flash 几何参数全写死在 W25Q128.c 里。Zephyr 侧全部挪到 DTS,设备树节点描述硬件,驱动按 compatible 字符串绑定。

NM25Q128 是国产兼容片,16MB 容量,JEDEC ID 与 W25Q128 一致(0xEF4018),Zephyr 的 jedec,spi-nor 驱动直接识别。DTS 关键节点:

&spi2 {
    status = "okay";
    pinctrl-0 = <&spi2_default>;
    cs-gpios = <&gpiob 12 GPIO_ACTIVE_LOW>;

    nor0: nor@0 {
        compatible = "jedec,spi-nor";
        reg = <0>;
        spi-max-frequency = <9000000>;
        size = <0x800000>;  /* 8 Mibibytes = 16 MB */
        hold-disable;

        partitions {
            compatible = "fixed-partitions";
            #address-cells = <1>;
            #size-cells = <1>;

            lfs_partition: partition@0 {
                label = "lfs";
                reg = <0x00000000 0x800000>;
            };
        };
    };
};

lfs_partition 是一个带 label 的固定分区节点,C 代码通过 FIXED_PARTITION_ID(lfs_partition) 拿到分区 ID。这比原工程里写死的基地址和长度更安全——改 DTS 即可调整分区大小,不用动 C 代码。

FS_LITTLEFS_DECLARE_DEFAULT_CONFIG 宏声明

原工程在 lfs_port.c 里手写 lfs_config 结构体,手动管理 read_buffer / prog_buffer / lookahead_buffer 三块静态 RAM。Zephyr 用一个宏干掉这一切:

/* app_storage.c */
#include <zephyr/fs/littlefs.h>

FS_LITTLEFS_DECLARE_DEFAULT_CONFIG(storage);

宏展开后会生成一个 struct fs_littlefs_t storage,内部的 lfs_config 字段引用 Kconfig 里的默认值:

Kconfig 参数含义
CONFIG_FS_LITTLEFS_READ_SIZE256单次读最小粒度
CONFIG_FS_LITTLEFS_PROG_SIZE256单次写最小粒度
CONFIG_FS_LITTLEFS_CACHE_SIZE256文件缓存大小(必须 ≥ read/prog)
CONFIG_FS_LITTLEFS_LOOKAHEAD_SIZE32块分配前瞻缓冲区
CONFIG_FS_LITTLEFS_BLOCK_CYCLES500单块擦除次数上限,触发磨损均衡

这些值和原工程一致,匹配 NM25Q128 的页大小(256 字节)。BLOCK_CYCLES=500 意味着每个擦除块达到 500 次擦写后,LittleFS 会主动把数据搬到其他块,避免热点块提前磨损。

原工程里 544 字节的缓冲区(s_read_buf[256] + s_prog_buf[256] + s_lookahead_buf[32])现在由宏内部静态分配,Kconfig 改一行参数全局生效。

fs_mount:挂载到 /lfs,自动格式化空白分区

原工程的挂载流程是「检测空白分区 → 手动 lfs_formatlfs_mount」,Zephyr 侧压缩成一个 fs_mount 调用:

/* app_storage.c */
static struct fs_mount_t lfs_mnt = {
    .type = FS_LITTLEFS,
    .fs_data = &storage,
    .storage_dev = (void *)FIXED_PARTITION_ID(lfs_partition),
    .mnt_point = "/lfs",
};

int app_storage_init(void)
{
    int rc = fs_mount(&lfs_mnt);
    if (rc == 0) {
        atomic_set(&storage_ready, 1);
        LOG_INF("[LFS] Mounted at %s", lfs_mnt.mnt_point);
    } else {
        LOG_ERR("[LFS] Mount failed: %d", rc);
    }
    return rc;
}

fs_mount 内部会判断分区是否已有合法的 LittleFS 超级块,没有就自动格式化。原工程里那段手写的空白分区检测(读第一个字节是否为 0xFF)和 lfs_format 调用全部不需要了。

互斥锁和原子位保证挂载只执行一次,storage_ready 同时作为 app_storage_is_ready() 的返回值——挂载失败时日志写入会被静默跳过,不阻塞系统启动。

挂载点从原工程的 / 改为 /lfs 后,日志路径随之变化:

#define LOG_DIR   "/lfs/log"         /* 原工程:/log */
#define META_FILE "/lfs/meta.dat"    /* 原工程:/meta.dat */

static void build_log_path(char *out, size_t n, uint32_t day)
{
    (void)snprintf(out, n, "%s/%06lu.log", LOG_DIR, (unsigned long)day);
}

/lfs 前缀是 VFS 多挂载点架构的必然结果——Zephyr 可以同时挂多个文件系统(比如 /lfs 挂 LittleFS,/nvs 挂 NVS),必须有挂载点前缀区分。

VFS API 替代裸 lfs API

原工程直接调用 lfs_file_opencfg / lfs_dir_open / lfs_remove / lfs_fs_size 等 LittleFS 原生 API。Zephyr 侧全部改为 VFS 标准 API,文件系统类型对上层透明——换 FATFS 或 FatFS 不用改一行代码。

对照表:

原工程(裸 lfs)Zephyr(VFS)用途
lfs_file_opencfgfs_open打开文件
lfs_file_writefs_write写文件
lfs_file_readfs_read读文件
lfs_file_closefs_close关闭文件
lfs_dir_openfs_opendir打开目录
lfs_dir_readfs_readdir读目录项
lfs_dir_closefs_closedir关闭目录
lfs_removefs_unlink删除文件
lfs_fs_sizefs_statvfs查询空间
fs_stat查询文件大小
fs_seek定位偏移
lfs_mkdirfs_mkdir创建目录

fs_file_t_initfs_dir_t_init 是 VFS 的初始化宏,把文件/目录对象清零。Zephyr 2.x 之后必须先 init 再使用,否则可能踩到未定义状态。

写一条日志的完整流程:

struct fs_file_t file;
fs_file_t_init(&file);
rc = fs_open(&file, path, FS_O_WRITE | FS_O_CREATE | FS_O_APPEND);
wr = fs_write(&file, line, (size_t)total);
rc = fs_close(&file);

fs_open 的 flag 和 POSIX 完全一致:FS_O_CREATE 不存在就创建,FS_O_APPEND 追加写。原工程里 lfs_file_opencfgLFS_O_WRONLY | LFS_O_CREAT | LFS_O_APPEND 现在少一个参数(不需要传自定义 buffer)。

元数据 /lfs/meta.dat

元数据结构和原工程完全一致,4 个 32 位字段共 16 字节:

typedef struct {
    uint32_t magic;       /* 0x4D4C4F47 = "MLOG" */
    uint32_t boot_count;  /* 累计启动次数 */
    uint32_t day_index;   /* 当前日志日序号 */
    uint32_t reserved;    /* 预留 */
} Metadata;

#define META_MAGIC 0x4D4C4F47U  /* "MLOG" 的 ASCII */

meta_load 先尝试读 /lfs/meta.dat,文件不存在或 magic 校验失败就用默认值初始化(day_index = 1)。meta_saveFS_O_WRITE | FS_O_CREATE | FS_O_TRUNC 覆盖写。

初始化时的日序号推进逻辑:

if (meta_rc == 0 && s_meta.boot_count > 0u) {
    s_meta.day_index++;  /* 上次的日志日变成历史 */
}
s_meta.boot_count++;
meta_save();

这样每次重启都会开启新的日志日,上次启动的活动文件自动变成只读历史文件,不会被覆盖。

按天日志轮转与过期清理

日志文件名是 /lfs/log/000001.log/lfs/log/000002.log……六位数字补零。轮转只是 day_index++meta_save,不涉及文件改名:

int AppLogger_RollDay(void)
{
    s_meta.day_index++;
    return meta_save();
}

清理过期日志遍历 /lfs/log 目录,把文件名解析成数字,小于阈值就 fs_unlink

static int cleanup_dir(const char *dir, uint32_t keep_from_day)
{
    while ((rc = fs_readdir(&dirp, &entry)) == 0 && entry.name[0] != '\0') {
        if (entry.type == FS_DIR_ENTRY_FILE) {
            uint32_t day = (uint32_t)strtoul(entry.name, NULL, 10);
            if (day == 0u || day >= keep_from_day) {
                continue;
            }
            (void)snprintf(full, sizeof(full), "%s/%s", dir, entry.name);
            if (fs_unlink(full) == 0) {
                removed++;
            }
        }
    }
}

fs_readdir 返回 0 且 entry.name[0] == '\0' 表示目录遍历结束,这是 VFS 的约定。

AppLogger_GetFreeBytesfs_statvfs 查询可用空间,注意 f_bfree 是块数,要乘 f_frsize 才是字节数:

rc = fs_statvfs("/lfs", &sbuf);
uint64_t free_bytes = (uint64_t)sbuf.f_bfree * (uint64_t)sbuf.f_frsize;

原工程的 lfs_fs_size 返回的是已用空间,这里用的是 fs_statvfs 返回可用空间,语义反过来但更适合监控。

云端上报扩展接口

Zephyr 侧新增了三个原工程没有的接口,配合云端补传日志:

  • AppLogger_ReadReportChunk(day, offset, out) —— 按 (day, offset) 读取一个 chunk,返回 {total_len, data_len, eof} 三元组,云端分片拉取历史日志
  • AppLogger_DeleteDayLog(day) —— 上报确认后删除指定日的日志,释放空间
  • AppLogger_GetCurrentDay() —— 返回当前 day_index,云端判断设备日志进度

ReadReportChunk 内部先用 fs_stat 拿到文件总长度,再 fs_seek 定位到 offset,最后 fs_read 读一个 chunk。eof 标志的计算 out->eof = (offset + rd >= total_len)

这套接口的设计前提是:日志文件一旦轮转就只读不改,offset 可以直接当游标用,不需要担心并发写入。

app_logger_service:K_MSGQ_DEFINE 替代 rt_messagequeue

异步日志服务的核心是消息队列。原工程用 RT-Thread 的 rt_messagequeue,需要 rt_mq_init 手动初始化;Zephyr 用 K_MSGQ_DEFINE 宏静态定义,内核启动时自动初始化,零运行时开销。

/* 消息体:type(1) + reserved(3) + text(96) = 100 字节,4 字节对齐 */
typedef struct {
    uint8_t type;
    uint8_t reserved[3];
    char    text[LOGSERVICE_TEXT_MAX];
} LogServiceMessage;

K_MSGQ_DEFINE(s_log_queue, sizeof(LogServiceMessage), LOGSERVICE_QUEUE_DEPTH, 4);

宏的四个参数:队列名、元素大小、队列深度(16)、对齐(4 字节)。宏内部会分配一块环形缓冲区,k_msgq_put / k_msgq_get 是无锁的原子拷贝操作,比 RT-Thread 的 rt_mq_send / rt_mq_recv 更轻量。

提交一条消息:

static int AppLoggerService_SubmitMessage(const LogServiceMessage *msg, k_timeout_t timeout)
{
    ret = k_msgq_put(&s_log_queue, msg, timeout);
    if (ret != 0) {
        s_log_drop_count++;
    }
    return ret;
}

队列满时 k_msgq_put 返回非零,s_log_drop_count 自增。这个丢包计数器是原工程没有的诊断指标——STM32 侧的 rt_mq_send 失败时只是静默丢弃,连计数都没有。AppLoggerService_Init 时清零,事后查这个计数可以判断日志压力是否超载。

K_THREAD_DEFINE 静态线程定义

原工程用 rt_thread_init + rt_thread_startup 两步启动线程。Zephyr 用 K_THREAD_DEFINE 宏静态定义线程,内核启动时自动创建并运行:

#define APP_LOGGER_STACK_SIZE  4096
#define APP_LOGGER_PRIO        7

K_THREAD_DEFINE(logger_thread, APP_LOGGER_STACK_SIZE,
                AppLoggerService_ThreadEntry, NULL, NULL, NULL,
                APP_LOGGER_PRIO, 0, 0);

优先级 7 比 maint 线程(优先级 6)低一级。这个安排很重要:maint 线程负责喂狗,必须比 logger 优先级高,否则日志写入阻塞时会把狗饿死。原工程的优先级数字是反的(RT-Thread 数字越小优先级越低),Zephyr 这里数字越大优先级越低,所以 7 < 6 在 Zephyr 里是 logger 让位 maint。

线程入口:

void AppLoggerService_ThreadEntry(void *p1, void *p2, void *p3)
{
    /* 等待 main 完成 LittleFS 挂载和 AppLogger_Init */
    k_sem_take(&s_log_start_sem, K_FOREVER);

    while (1) {
        recv_err = k_msgq_get(&s_log_queue, &msg, K_MSEC(1000));
        /* 处理消息 + 定期 flush + 定期 roll */
    }
}

k_msgq_get 的 1000ms 超时是个心跳:没消息时每秒醒来检查 flush 和 roll 周期,避免用单独的定时器。

K_SEM_DEFINE 初始化信号量

K_THREAD_DEFINE 定义的线程在内核启动时就开始跑,但此时 LittleFS 还没挂载,AppLogger_Init 还没调用。直接让 logger 线程跑会踩空。用一个信号量做门控:

K_SEM_DEFINE(s_log_start_sem, 0, 1);  /* 初始计数 0,最大计数 1 */

初始计数为 0,所以 k_sem_take 会阻塞;AppLoggerService_Initk_sem_give 之后才放行:

rc = AppLogger_Init();
s_log_ready = 1u;
k_sem_give(&s_log_start_sem);

原工程里这部分是 rt_event_send + rt_event_recv,Zephyr 侧简化为信号量。信号量语义更清晰:只表示「初始化完成」这一个事件,不混入其他标志位。

WARN 非阻塞 / ERROR 50ms 阻塞 / FLUSH 50ms 阻塞

三类消息的提交策略不同,对应不同 timeout:

/* Warn:队列满立即丢,不阻塞调用方 */
AppLoggerService_SubmitV(LOGSERVICE_MSG_WARNING, "WARN", fmt, args, K_NO_WAIT);

/* Error:阻塞 50ms,优先保证错误日志落盘 */
AppLoggerService_SubmitV(LOGSERVICE_MSG_ERROR, "ERR", fmt, args, K_MSEC(50));

/* Flush:阻塞 50ms,确保 flush 命令进入队列 */
AppLoggerService_SubmitMessage(&msg, K_MSEC(50));

对照原工程的 RT_WAITING_NOK_NO_WAITrt_tick_from_millisecond(50)K_MSEC(50),语义完全一致。

WARN 非阻塞是因为告警量可能很大(比如传感器周期性越限),如果队列满还阻塞,会把调用方(业务线程)拖死。ERROR 阻塞 50ms 是因为错误日志必须留痕,短暂等待是值得的。

60秒自动 Flush + 86400秒自动轮转

线程主循环里两个周期任务:LOGSERVICE_FLUSH_PERIOD_MS = 60000(60秒)触发一次 AppLogger_Flush()LOGSERVICE_ROLL_PERIOD_MS = 86400000(24小时)触发一次 AppLogger_RollDay()

LittleFS 的 fs_write + fs_close 已经是同步落盘,所以 60 秒的 flush 其实是冗余的——代码注释里也写了「这里仅为兼容原逻辑」。真正起作用的是 86400 秒轮转,没有 RTC 时按连续运行时间翻日。

Kconfig 参数全景

最后汇总 prj.conf 里和 LittleFS / 文件系统相关的配置:

# 文件系统 + LittleFS VFS 集成
CONFIG_FILE_SYSTEM=y
CONFIG_FILE_SYSTEM_LITTLEFS=y
CONFIG_SPI_NOR=y

# LittleFS 默认参数(匹配 NM25Q128 物理特性)
CONFIG_FS_LITTLEFS_READ_SIZE=256
CONFIG_FS_LITTLEFS_PROG_SIZE=256
CONFIG_FS_LITTLEFS_CACHE_SIZE=256
CONFIG_FS_LITTLEFS_LOOKAHEAD_SIZE=32
CONFIG_FS_LITTLEFS_BLOCK_CYCLES=500

CONFIG_FILE_SYSTEM 启用 VFS 抽象层,CONFIG_FILE_SYSTEM_LITTLEFS 注册 LittleFS 驱动到 VFS,CONFIG_SPI_NOR 提供 SPI NOR 块设备驱动。三者缺一不可——少了任何一个,fs_mount 都会返回 -ENOSYS

同时 prj.conf 里还启用了 NVS(CONFIG_NVS=y + CONFIG_SETTINGS=y),用于保存 MCUboot 镜像确认状态和业务配置。NVS 和 LittleFS 共享同一个 SPI NOR 芯片,但分区不同——NVS 在 STM32 内部 Flash,LittleFS 在外部 NM25Q128,互不干扰。

移植总结

从 STM32 到 Zephyr,日志系统的代码量大幅缩减:

模块STM32 原工程Zephyr 新工程
块设备适配lfs_port.c ~200 行 + 544B 缓冲区FS_LITTLEFS_DECLARE_DEFAULT_CONFIG 一行宏
SPI NOR 驱动W25Q128.c ~300 行DTS 节点 + jedec,spi-nor 驱动
文件系统挂载手动检测空白 + lfs_format + lfs_mountfs_mount 一次调用
异步队列rt_messagequeue + rt_mq_initK_MSGQ_DEFINE 静态定义
线程rt_thread_init + rt_thread_startupK_THREAD_DEFINE 静态定义
初始化同步rt_event_send / rt_event_recvK_SEM_DEFINE + k_sem_give/take

省掉的代码不是消失了,而是挪进了 Zephyr 内核和 SPI NOR 驱动里。代价是学习成本——DTS、Kconfig、VFS 抽象层、内核对象宏这套体系比 RT-Thread 的裸 API 陡峭。但一旦走通,后续换 Flash 型号、换文件系统、换分区布局都是改配置不改代码。

真正还留在应用代码里的,只有日志业务逻辑:元数据管理、按天轮转、过期清理、云端上报扩展。这些和具体文件系统无关,从 STM32 到 Zephyr 几乎原样保留。这才是分层架构应该有的样子——底层换了一身骨头,上层纹丝不动。