书放在哪,决定了你能带多少本书出门——SD 卡与 SPIFFS,各是一只好箱子。
核心方法论:工欲善其事,必先利其器
「造一个书箱,先量三件事:箱子里要放几本书?书能不能随手抽换?箱子自己还能不能记个账?存储的选择从来不是'越大越好',而是'刚刚好'。SD 卡是个能换的大柜子,SPIFFS 是焊死在板子上的小暗格,而阅读进度和屏幕画面,得找个深睡眠也忘不掉的地方收好。这章我们就用鲁班的尺子,把这几只箱子挨个量一遍。」
阅读器的"书库"是文件系统(把存储空间整理成"文件夹套文件夹、里面放着文件"这种树状结构的机制):EPUB 文件放在这里,EpubList 扫目录、列书单、打开章节都从这里读。这个项目给了两条路——SD 卡(走 SPI 接口——一种串行通信协议,一条总线上一个主机带多个外设,靠片选线点名选谁通信——并挂载成 FAT 文件系统,挂载(mount)就是让系统把存储设备"接"进文件系统,之后用 /fs 这样的路径就能读写它)和 SPIFFS(直接写在板载 Flash 的一个分区里,分区就是把 Flash 划出的一块独立区域、各管各的用途)。README 只用一句话点了二者的差距:"You can use SPIFFS instead of an SD Card, but you won't be able to fit many books on it."(你可以用 SPIFFS 顶替 SD 卡,但装不下几本书。)
差距的根源在容量与可拆换。翻 partitions.csv(分区表:记录每块分区叫什么、从哪个地址开始、占多大的清单):这颗 ESP32 的 4 MB Flash 里,app0 固件分区占 0x120000(约 1.1 MiB),spiffs 数据分区从 0x130000 起、大小 0x2D0000——换算一下是 2,949,120 字节,约 2.8 MiB。而 data/ 里随仓库附带的两本示例书,pg43-images.epub 约 297 KiB、pg14838-images.epub 约 1.3 MiB,两本加起来就吃掉 1.6 MiB——也就是说,这个 2.8 MiB 的小暗格也就再塞得下一两本同等规模的书。反观 SD 卡动辄几 GB、还能随手抽换,装一百本古登堡书都不在话下。这就是"能装下多少本书"的物理边界。
# partitions.csv:这颗 4 MB Flash 的分区地界
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x120000, # 固件,约 1.1 MiB
spiffs, data, spiffs, 0x130000,0x2D0000, # SPIFFS 分区 ≈ 2.8 MiB
# data/:随仓库附带的两本古登堡示例书
pg43-images.epub 304004 bytes ≈ 297 KiB
pg14838-images.epub 1386783 bytes ≈ 1.3 MiB
但容量不是唯一维度。可拆换意味着书库与固件解耦:SD 卡拔下来、插一张装满别的书的卡,固件不用重刷、分区不用重烧;SPIFFS 的书则"焊死"在 Flash 里,要换内容得重新烧文件系统。代价是硬件上多出 4 根 SPI 引脚和一个卡座。README 给出的默认主张很务实:能用 SD 卡就用 SD 卡,SPIFFS 更像"没有卡座、或者引脚不够时的备胎"。
| 维度 | SD 卡(SPI 模式) | SPIFFS(板载 Flash) |
|---|---|---|
| 容量 | GB 级,几乎装不满 | 固定分区,本项目约 2.8 MiB |
| 可拆换 | 可插拔,换卡即换书库 | 固化在板上,需重新烧写 |
| 硬件开销 | 4 根 SPI 引脚 + 卡座 | 无额外引脚 |
| 挂载实现 | esp_vfs_fat_sdspi_mount | esp_vfs_spiffs_register |
| 深睡眠状态保存 | 稳定 | README 承认存在已知问题 |
容量的"刚刚好",得拿真实数字说话:SPIFFS 分区 0x2D0000 = 2,949,120 字节 ≈ 2.8 MiB,两本示例书合计 ≈ 1.6 MiB。动手算一遍这个比例,你就不会再纠结"SPIFFS 够不够用"——它天然只适合"装几本书、跑个演示",这就是 README 那句"won't fit many books"背后的算术。
SD 卡在 ESP32 上有两种接法:SDMMC(原生 4 位总线,快但占用引脚多)和 SPI 模式(只用 4 根线:MISO、MOSI(两根数据线,一根收、一根发)、CLK(时钟线,给通信打节拍)、CS(片选,选中哪颗芯片才和它说话))。本项目走的是 SPI 模式,因为对阅读器来说,读几本书的带宽需求远没有 4 位并行带来的复杂度划算。四根线接在哪些引脚,由 platformio.ini 里的四个宏注入:SD_CARD_PIN_NUM_MISO、SD_CARD_PIN_NUM_MOSI、SD_CARD_PIN_NUM_CLK、SD_CARD_PIN_NUM_CS——每一块板子的布线不同,宏的值也不同,而代码只认宏、不认具体引脚。
; platformio.ini [env:lilygo_t5_47]:SD 卡走 SPI,四根引脚由宏注入
-DSD_CARD_PIN_NUM_MISO=GPIO_NUM_14
-DSD_CARD_PIN_NUM_MOSI=GPIO_NUM_13
-DSD_CARD_PIN_NUM_CLK=GPIO_NUM_15
-DSD_CARD_PIN_NUM_CS=GPIO_NUM_12
| 构建环境 | MISO | MOSI | CLK | CS |
|---|---|---|---|---|
lilygo_t5_47 | GPIO_NUM_14 | GPIO_NUM_13 | GPIO_NUM_15 | GPIO_NUM_12 |
epdiy | GPIO_NUM_36 | GPIO_NUM_0 | GPIO_NUM_13 | GPIO_NUM_14 |
m5_paper | GPIO_NUM_13 | GPIO_NUM_12 | GPIO_NUM_14 | GPIO_NUM_4 |
挂载由 lib/sd_card/src/SDCard.cpp 完成,它把 ESP-IDF 的标准流程包成了一个类。构造时先 spi_bus_initialize 初始化 SPI 总线(DMA 通道 1),再用 SDSPI_DEVICE_CONFIG_DEFAULT()(SDSPI 就是"SD 卡走 SPI 协议"这一模式的合称)配置设备并把 CS 填进 slot_config.gpio_cs,最后 esp_vfs_fat_sdspi_mount("/fs", ...) 把卡挂到 /fs 挂载点。注意 mount_config 里 format_if_mount_failed = true:万一卡没格式化或文件系统坏了,驱动会直接帮你格式化再挂载——这在开发期很省心,但也意味着别在卡上放不可再生的数据。析构函数则反向收尾:esp_vfs_fat_sdcard_unmount 卸载、spi_bus_free 释放总线。
/* Board.cpp:编译期二选一——SD 还是 SPIFFS */
void Board::start_filesystem()
{
#ifdef USE_SPIFFS
spiffs = new SPIFFS("/fs");
#else
sdcard = new SDCard("/fs", SD_CARD_PIN_NUM_MISO, SD_CARD_PIN_NUM_MOSI,
SD_CARD_PIN_NUM_CLK, SD_CARD_PIN_NUM_CS);
#endif
}
/* lib/sd_card/src/SDCard.cpp:挂载 FAT 到 /fs(节选) */
esp_vfs_fat_sdmmc_mount_config_t mount_config = {
.format_if_mount_failed = true, // 挂载失败会直接格式化
.max_files = 5,
.allocation_unit_size = 16 * 1024};
ret = esp_vfs_fat_sdspi_mount("/fs", &m_host, &slot_config, &mount_config, &m_card);
if (ret != ESP_OK)
ESP_LOGE(TAG, "Failed to initialize the card (%s). Make sure SD card lines have pull-up resistors",
esp_err_to_name(ret));
// 成功则打印卡信息:sdmmc_card_print_info(stdout, m_card);
挂载成功后,EpubList::load("/fs/") 打开目录、逐条扫描以 .epub 结尾的文件,解析封面与书名后按标题排序生成书单——这正是第 1 章工作流里"扫目录列书单"的一步。如果没插卡、opendir 失败,它会在屏幕上画出警告图、"Please insert SD Card" 提示,然后延时重启。所以 SD 卡这条路上,"卡没插好"这个错误是有明确的用户反馈的,这也是选择 SD 卡的一个隐性好处的。
esp_vfs_fat_sdmmc_mount_config_t 里的 format_if_mount_failed 是一把双刃剑:开发时它替你兜底(卡没格式化也能用),但它会静默抹掉卡上数据。生产级设备通常要自己实现"格式化前先确认"的策略。动手改这个字段并观察行为,是你亲手理解挂载配置的最快方式。
SPIFFS(SPI Flash File System)是专为小容量串行 Flash 设计的文件系统,直接把书存在板子的 Flash 分区里,不需要任何外部硬件。接入方式是一个编译宏:在 platformio.ini 的 build_flags 里加上 -D USE_SPIFFS,Board.h/Board.cpp 里的 #ifdef USE_SPIFFS 就会让 start_filesystem() 走 SPIFFS("/fs") 分支,否则走 SD 卡分支。这是教科书级的"编译期配置切换实现"——同一份源码,改一个宏就换一套存储后端。
翻 platformio.ini 看三块板的真实取舍很有意思:lilygo_t5_47 和 epdiy 都启用了 -D USE_SPIFFS,其中 EPDIY 环境还专门注释了一句——"如果这个参数被注释掉,就默认用 SD 卡,需要占用 4 根 SPI 引脚";而 m5_paper 却把 -D USE_SPIFFS 注释掉了,也就是说 M5 Paper 默认走 SD 卡。同样的代码、不同的板子,存储策略可以各自独立,这正是构建系统里"每块板一套配置"的价值。
; platformio.ini:三个环境对 SPIFFS 的取舍
[env:lilygo_t5_47] -D USE_SPIFFS ; 用 SPIFFS(示例即如此)
[env:epdiy] -D USE_SPIFFS ; 用 SPIFFS,省下 4 根 SPI 引脚
[env:m5_paper] ;-D USE_SPIFFS ; 注释掉 → 走 SD 卡
/* lib/spiffs/src/SPIFFS.cpp:注册 SPIFFS 到 /fs(节选) */
esp_vfs_spiffs_conf_t mount_cofig = {
.base_path = "/fs",
.partition_label = NULL, // NULL = 自动找名为 spiffs 的分区
.max_files = 5,
.format_if_mount_failed = true};
esp_err_t ret = esp_vfs_spiffs_register(&mount_cofig);
SPIFFS 的内容从哪来?来自仓库根目录的 data/ 文件夹。PlatformIO 约定:data/ 是"要烧进文件系统分区的资产",执行 pio run -t uploadfs 就会把 data/ 里的 .epub 等文件整体烧进 partitions.csv 划出的 spiffs 分区。注意 data/ 里还有个 .keep 文件——那是为了把空目录保留进 git 而放的占位符,上传时会被一并烧进去,但因为 EpubList::load 会跳过以 . 开头的隐藏文件,所以不会污染书单。
SPIFFS 的代价,README 的 "SPIFFS support" 一节说得非常坦诚:它存在一些问题,"particularly with persisting the state of the display when going into deep sleep"(尤其是在深睡眠时保存屏幕显示状态)。这正好牵出 6.4 的主题——存储不仅要装书,还要保管设备自己的"记忆"。
# 把 data/ 里的书整包烧进 SPIFFS 分区
$ pio run -t uploadfs
# data/ 目录内容(.keep 为占位文件,上传时会被跳过)
.keep # 隐藏文件,EpubList 不把它当书
pg43-images.epub
pg14838-images.epub
README 原话:"There some issues with SPIFFS which cause some problems - particularly with persisting the state of the display when going into deep sleep - if you can get an SD Card attached these problems don't exist."(SPIFFS 有些问题,尤其深睡眠时保存显示状态;只要你能接上 SD 卡,这些问题就不存在。)翻译成工程决策就是:演示与原型可以用 SPIFFS,追求稳定就上 SD 卡。
存储的第三项使命,是保管设备自己的"记忆"。阅读器有两个必须跨过深睡眠活下来的东西:一是屏幕画面,二是阅读进度。屏幕画面很重要——墨水屏断电后画面仍在,但它的内部缓存是另一回事;若醒来时不把"上一屏"恢复出来就重绘,会出现残影或闪烁。所以在入睡前 main.cpp 调 renderer->dehydrate(),把整帧画面压缩后写进文件系统,醒来再 hydrate() 读回。
具体实现藏在 EpdiyFrameBufferRenderer 里:帧缓冲是 EPD_WIDTH * EPD_HEIGHT / 2 字节(4 bpp),直接用 miniz 的 tdefl_compress_mem_to_heap 压成 /fs/front_buffer.z,因为"写磁盘很慢,压缩后再写能省空间、提速"——这正是代码注释的原话。醒来时 tinfl_decompress_mem_to_mem 解压回缓冲,flush_display() 把画面原样刷回屏上;若解压失败,则 needs_redraw 置真、整页重绘。注意这个文件落在 /fs——也就是 SD 卡上,format_if_mount_failed 再顺手格式化一下,就等于把"屏保"删了,所以 README 才说 SPIFFS 在这条路上有已知问题。
/* main.cpp:120 秒无交互,进入深睡眠前的收尾 */
renderer->dehydrate(); // 压缩帧缓冲 → /fs/front_buffer.z
board->stop_filesystem(); // 卸载 /fs(SPIFFS 或 SD 卡)
board->prepare_to_sleep();
ESP_ERROR_CHECK(esp_sleep_enable_ulp_wakeup());
button_controls->setup_deep_sleep();
esp_deep_sleep_start();
/* EpdiyFrameBufferRenderer.h:把整屏内容压缩落盘(节选) */
virtual bool dehydrate()
{
// 压缩能省空间并提速——写磁盘很慢
size_t compressed_size = 0;
void *compressed = tdefl_compress_mem_to_heap(
m_frame_buffer, EPD_WIDTH * EPD_HEIGHT / 2, &compressed_size, 0);
if (compressed)
{
FILE *fp = fopen("/fs/front_buffer.z", "w");
if (fp)
{
fwrite(compressed, 1, compressed_size, fp);
fclose(fp);
return true;
}
}
return false;
}
阅读进度则走了另一条更省事的路——RTC 内存。上一章你见过 RTC_NOINIT_ATTR/RTC_DATA_ATTR:这段内存挂在不断电的 RTC 域上,深睡眠不掉。书单状态 EpubListState 里的每一项 EpubListItem 都带着 current_section、current_page、pages_in_current_section 三个字段,EpubReader 持有的是这份结构的引用(EpubListItem &state)——翻页时改的就是 RTC 内存,醒来后进度自然还在。于是"屏幕画面走文件系统、阅读进度走 RTC 内存",两种持久化手段各司其职,互不抢道。
把"什么数据、存在哪、何时写"摆成一张表,存储策略就一目了然。顺带一个细节:M5Paper::stop_filesystem() 里把 delete sdcard 注释掉了,理由是 "seems to cause issues with the M5 Paper"(在 M5 Paper 上删除会出问题)。存储生命周期在这种"按板子修修补补"里并不总是一帆风顺——这本身就是真实项目的常态。
/* src/boards/M5Paper.cpp:板级差异的活例子 */
void M5Paper::stop_filesystem()
{
#ifdef USE_SPIFFS
delete spiffs;
#else
// seems to cause issues with the M5 Paper
// delete sdcard;
#endif
}
| 数据 | 存储位置 | 写入时机 |
|---|---|---|
| 当前屏幕画面 | /fs/front_buffer.z(miniz 压缩) | 入睡前 dehydrate() |
| 书单 + 阅读进度 | RTC_DATA_ATTR 的 epub_list_state | 翻页/换章时实时更新 |
| 目录选中项 | RTC_DATA_ATTR 的 epub_index_state | 目录导航时更新 |
| 当前 UI 状态 | RTC_NOINIT_ATTR 的 ui_state | 状态切换时更新 |
持久化要按数据的"性格"选位子:屏幕画面是"一次性、要随睡眠恢复的缓存",适合放可擦写、可重写的文件系统;阅读进度是"要活到下次开机"的长期记忆,适合放一直供电的 RTC 内存。两类数据、两条通道,对应了两种断电后的存活能力——想清楚这个,你就能给自己的嵌入式项目也画一张"记忆分布图"。
用"特性→后果"的句式,各列出 SD 卡与 SPIFFS 的至少两条特性及其带来的后果。再判断:为什么 EPDIY 环境更愿意用 SPIFFS,而 M5 Paper 默认走 SD 卡?
对照 6.1 的表格与 6.3 里 platformio.ini 的注释("省下 4 根 SPI 引脚");想想哪块板子引脚更紧张。
SD 卡:容量 GB 级 → 能装下整座书库;可插拔 → 换卡即换书库、不重刷固件;需 4 根 SPI 引脚 + 卡座 → 占用硬件资源。SPIFFS:写进 Flash 分区(本项目约 2.8 MiB)→ 装不下几本书;无额外引脚 → 适合引脚紧张的板子;固化在板上 → 换书要重新 uploadfs。EPDIY 环境用 SPIFFS 正是为了省下 4 根 SPI 引脚(platformio.ini 注释原话),M5 Paper 则没有这个顾虑,默认走 SD 卡。
从 platformio.ini 读出三块板的 SD_CARD_PIN_NUM_* 宏,填进 6.2 的表格。再回答:SD 卡类为什么把引脚设计成构造函数参数而不是写死?
三块板的 CS 分别是 GPIO_NUM_12 / GPIO_NUM_14 / GPIO_NUM_4,MISO/MOSI/CLK 也各不相同;想想引脚由谁注入、要复用到别的板子时改哪里。
lilygo_t5_47:MISO=14、MOSI=13、CLK=15、CS=12;epdiy:MISO=36、MOSI=0、CLK=13、CS=14;m5_paper:MISO=13、MOSI=12、CLK=14、CS=4。引脚由编译宏(SD_CARD_PIN_NUM_*)经 Board::start_filesystem 传入构造函数,而不是写死在 SDCard 类里——这样同一个 SDCard 类可以服务任意板子,换板只需改 platformio.ini 的宏值,实现了"配置与实现分离"。
阅读器的"记忆"分两条通道保存。请分别说明:(a) 屏幕画面存在哪、何时写、何时读回、读不回来怎么办?(b) 阅读进度存在哪、为什么它不需要"写文件"这个动作?
屏幕走文件系统(/fs/front_buffer.z),进度走 RTC 内存;想想 EpubReader 持有的 EpubListItem &state 引用指向哪里。
(a) 入睡前 dehydrate() 把帧缓冲用 miniz 压缩成 /fs/front_buffer.z;醒来 hydrate() 读回、解压、刷回屏幕;若解压失败,hydrate 返回 false,主逻辑用 needs_redraw=true 触发整页重绘。(b) EpubListItem 的 current_section/current_page 存在 RTC_DATA_ATTR 的 epub_list_state 里,深睡眠不断电;EpubReader 持有它的引用,翻页即改写 RTC 内存,天然持久,无需显式"保存文件"动作。
由 partitions.csv 计算 spiffs 分区大小(0x2D0000),判断它能否放下 data/ 里的两本示例书。若你还要放 10 本与 pg14838-images.epub 同等大小的书,会怎样?由此解释 README 那句话。
0x2D0000 = 45 × 65536 = 2,949,120 字节;pg14838-images.epub ≈ 1.3 MiB、pg43-images.epub ≈ 297 KiB;算总账。
SPIFFS 分区 = 0x2D0000 = 2,949,120 字节 ≈ 2.8 MiB。两本示例书合计 ≈ 1.6 MiB,能放下(还余约 1.2 MiB)。若再放 10 本 1.3 MiB 的书,需要约 13 MiB,远超 2.8 MiB——上传会失败或内容写不进。这正是 README 说 "you won't be able to fit many books on it"(装不下几本书)的算术本质:2.8 MiB 的小暗格只够塞两三本中等篇幅的书,而 SD 卡以 GB 计、几乎装不满。