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_SIZE | 256 | 单次读最小粒度 |
CONFIG_FS_LITTLEFS_PROG_SIZE | 256 | 单次写最小粒度 |
CONFIG_FS_LITTLEFS_CACHE_SIZE | 256 | 文件缓存大小(必须 ≥ read/prog) |
CONFIG_FS_LITTLEFS_LOOKAHEAD_SIZE | 32 | 块分配前瞻缓冲区 |
CONFIG_FS_LITTLEFS_BLOCK_CYCLES | 500 | 单块擦除次数上限,触发磨损均衡 |
这些值和原工程一致,匹配 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_format → lfs_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_opencfg | fs_open | 打开文件 |
lfs_file_write | fs_write | 写文件 |
lfs_file_read | fs_read | 读文件 |
lfs_file_close | fs_close | 关闭文件 |
lfs_dir_open | fs_opendir | 打开目录 |
lfs_dir_read | fs_readdir | 读目录项 |
lfs_dir_close | fs_closedir | 关闭目录 |
lfs_remove | fs_unlink | 删除文件 |
lfs_fs_size | fs_statvfs | 查询空间 |
| — | fs_stat | 查询文件大小 |
| — | fs_seek | 定位偏移 |
lfs_mkdir | fs_mkdir | 创建目录 |
fs_file_t_init 和 fs_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_opencfg 的 LFS_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_save 用 FS_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_GetFreeBytes 用 fs_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_Init 里 k_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_NO → K_NO_WAIT,rt_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_mount | fs_mount 一次调用 |
| 异步队列 | rt_messagequeue + rt_mq_init | K_MSGQ_DEFINE 静态定义 |
| 线程 | rt_thread_init + rt_thread_startup | K_THREAD_DEFINE 静态定义 |
| 初始化同步 | rt_event_send / rt_event_recv | K_SEM_DEFINE + k_sem_give/take |
省掉的代码不是消失了,而是挪进了 Zephyr 内核和 SPI NOR 驱动里。代价是学习成本——DTS、Kconfig、VFS 抽象层、内核对象宏这套体系比 RT-Thread 的裸 API 陡峭。但一旦走通,后续换 Flash 型号、换文件系统、换分区布局都是改配置不改代码。
真正还留在应用代码里的,只有日志业务逻辑:元数据管理、按天轮转、过期清理、云端上报扩展。这些和具体文件系统无关,从 STM32 到 Zephyr 几乎原样保留。这才是分层架构应该有的样子——底层换了一身骨头,上层纹丝不动。