“数据不怕多,就怕没地方放。”
上一章学会了在屏幕上画像素。但画什么?当然是电子书内容。一本 EPUB 可能有几十 MB,这些数据不可能塞进芯片内部。CrossPoint Reader 的解决方案:SD 卡。 这一章要搞清楚三件事:SD 卡怎么和芯片通信、文件系统怎么组织数据、多任务同时访问时怎么避免”打架”。

6.1 为什么需要 SD 卡

ESP32-C3 的存储困境

芯片内部有 16MB Flash,但几乎被占满了:
  ESP32-C3 内部 Flash(16MB)

  ┌──────────────────────────────────┐
  │  Bootloader + 分区表    ~36 KB   │
  │  NVS 键值存储           ~24 KB   │
  │  OTA 分区 A            ~3.5 MB   │ ← 固件主分区
  │  OTA 分区 B            ~3.5 MB   │ ← 备份分区(安全升级用)
  │  字体数据              ~8 MB     │ ← 中文字体需要大量存储
  │  其他(SPIFFS 等)      ~1 MB    │
  └──────────────────────────────────┘
  可用于存书的空间 ≈ 0
固件两份占 7MB(OTA 安全升级,详见第 14 章),字体约 8MB(支持中文要存几千个字形),再加上引导和配置区域,16MB 没有余量。而一本带封面的 EPUB 就要 5-30MB。 SD 卡完美解决了这个问题——便宜(32GB 几十元)、容量大(4GB-128GB)、可拆卸(用户能直接在电脑上拷书)。

SPI 模式连接

SD 卡通过 SPI 模式连接到 ESP32-C3,与墨水屏共享同一条 SPI 总线——因为芯片引脚有限。两个设备靠各自独立的 CS(Chip Select)引脚区分:
  ESP32-C3            MOSI/MISO/SCK(共享)
  ┌──────┐           ┌─────────────┐
  │      │───────────│    SD 卡    │  CS_SD  ← 拉低时选中 SD 卡
  │      │           └─────────────┘
  │      │           ┌─────────────┐
  │      │───────────│   墨水屏    │  CS_EPD ← 拉低时选中墨水屏
  └──────┘           └─────────────┘

  同一时刻只能跟一个设备通信
这意味着不能同时读 SD 卡和刷屏幕。CrossPoint Reader 的做法:先从 SD 卡读数据到内存,再切换 CS 去驱动屏幕。

6.2 FatFS 文件系统

为什么选 FAT32

SD 卡是”裸”的存储介质,需要文件系统才能用文件夹和文件名组织数据。CrossPoint Reader 使用 FAT32,因为兼容性最好:
  • Windows / macOS / Linux / Android / ESP32 都能读写
  • 用户把 SD 卡插到任何设备都能操作文件
FAT32 虽然”古老”(1996 年),但”到处都能用”就是最大优势。ESP-IDF 内置了 FatFS 驱动,把 SD 卡挂载到 /sd/ 路径后,就能用标准文件 API 访问。
FAT32 限制:单文件不超过 4GB。对电子书完全不是问题。

目录结构约定

  /sd/
  ├── books/                      ← 用户的电子书
  │   ├── novel.epub
  │   └── manga.xtc
  └── .crosspoint/                ← 系统目录(隐藏)
      ├── cache/                  ← 书籍缓存
      │   └── a1b2c3d4/           ← 每本书一个目录(哈希命名)
      │       ├── book.bin        ← 元数据缓存
      │       ├── section.bin     ← 章节索引
      │       ├── progress.bin    ← 阅读进度
      │       └── cover.bin       ← 封面缓存
      ├── settings.json           ← 用户设置
      └── webdav/                 ← WebDAV 同步临时目录
用户只需把书丢进 books/,系统文件藏在以点号开头的隐藏目录中互不干扰。

6.3 线程安全的文件操作

问题:两个任务同时读写

CrossPoint Reader 用 FreeRTOS 运行多个任务(详见第 7 章)。假设 UI 任务在读封面图,网络任务在写入新上传的书——两个任务同时操作 SD 卡会怎样?
  任务A:[读文件头] [暂停────]  [继续读...但文件系统状态已被 B 改了!]
  任务B:      [写入新文件] [写入中...]
  → 可能读到损坏数据,甚至文件系统损坏

解决方案:互斥锁 + RAII

同一时刻只允许一个任务操作 SD 卡,用 FreeRTOS 的互斥锁(Mutex)实现。CrossPoint Reader 用 C++ 的 RAII 模式自动管理锁:
// lib/hal/HalStorage.h
class HalStorage::StorageLock {
 public:
  StorageLock() { xSemaphoreTake(HalStorage::getInstance().storageMutex, portMAX_DELAY); }
  ~StorageLock() { xSemaphoreGive(HalStorage::getInstance().storageMutex); }
};
RAII 的核心思想:利用 C++ 对象的生命周期自动管理资源。
// ❌ 手动管理——容易忘记解锁
void readFile() {
  xSemaphoreTake(mutex, portMAX_DELAY);  // 加锁
  FILE* f = fopen("book.epub", "r");
  if (f == NULL) return;  // 忘了解锁!其他任务将永远卡死
  fclose(f);
  xSemaphoreGive(mutex);  // 解锁
}

// ✅ RAII 管理——无论从哪里 return 都自动解锁
void readFile() {
  StorageLock lock;  // 构造 → 加锁
  FILE* f = fopen("book.epub", "r");
  if (f == NULL) return;  // lock 销毁 → 自动解锁,安全!
  fclose(f);
}  // lock 销毁 → 自动解锁,安全!

宏封装:每次 SD 卡调用都安全

// lib/hal/HalStorage.h
#define HAL_STORAGE_WRAPPED_CALL(method, ...) \
  HalStorage::StorageLock lock;               \
  return SDCard.method(__VA_ARGS__);
使用示例:
File HalStorage::open(const char* path, const char* mode) {
  HAL_STORAGE_WRAPPED_CALL(open, path, mode);
  // 展开后:StorageLock lock; return SDCard.open(path, mode);
  // 自动加锁 → 调用 SD 卡 → 函数结束自动解锁
}

bool HalStorage::exists(const char* path) {
  HAL_STORAGE_WRAPPED_CALL(exists, path);
}
每个对外的文件操作都经过这个宏包装。上层代码调用 HalStorage::open() 时完全不知道锁的存在——锁被封装在底层。就像银行柜台自动处理排队,你只需说”我要取钱”。

6.4 NVS:芯片内部的小仓库

什么是 NVS

ESP32-C3 内部还有一个小仓库——NVS(Non-Volatile Storage),是 ESP-IDF 提供的键值存储,直接写在芯片 Flash 上:
  NVS ≈ 一个微型数据库

  ┌─────────────────┬─────────────┐
  │  Key            │  Value      │
  ├─────────────────┼─────────────┤
  │  "device_type"  │  "X4"       │  ← 设备型号
  │  "boot_count"   │  42         │  ← 开机次数
  │  "wifi_ssid"    │  "MyWiFi"   │  ← 上次连接的 WiFi
  └─────────────────┴─────────────┘
  断电不丢失 | 约 24KB | 读写快速

NVS vs SD 卡

特性NVSSD 卡
容量~24KB几 GB - 几百 GB
速度中等
可靠性高(Flash 内部)中等(可能被拔出)
适合存什么小配置、标记位文件、书籍、缓存

CrossPoint Reader 中的 NVS 用途

1. 设备类型缓存:首次开机通过 I2C 扫描检测硬件型号(~100ms),结果存入 NVS,后续开机直接读取(~1ms)。 2. 关键启动设置:某些在 SD 卡挂载前就需要的设置(如 SPI 总线速度),额外在 NVS 中保存一份。 3. OTA 升级状态:记录升级进度标记,断电重启后能判断上次升级是否完成,必要时回滚。
24KB 够用吗? 一个键值对通常几十字节,存上百个配置项也用不完。大文件是 SD 卡的活儿。

6.5 缓存目录管理

为什么需要缓存

EPUB 本质是 ZIP 压缩包(内含 HTML、CSS、图片)。每次翻页都去解压、解析 XML、排版——对 160MHz 的 CPU 太慢了。策略:首次打开做完整解析,结果保存为二进制缓存,之后直接读缓存
  首次打开:epub → 解压 → 解析 → 排版 → 写缓存(几秒)
  再次打开:直接读缓存 → 跳过上述步骤(几百毫秒)
缓存目录用文件路径的哈希值命名(如 a1b2c3d4),避免文件名冲突和特殊字符问题。
缓存文件内容大小
book.bin元数据(书名、作者、章节列表、总页数)~1 KB
section.bin章节内容的二进制索引几 KB
progress.bin阅读进度(当前章节、页码、书签)~100 B
cover.bin封面图预处理缓存几 KB

缓存失效与重建

  打开一本书
  ├── 缓存不存在 → 全新解析,生成缓存
  └── 缓存存在 → 检查版本号
        ├── 版本不匹配 → 删除旧缓存,重新解析
        └── 版本匹配 → 检查文件指纹(大小+时间)
              ├── 不匹配 → 重新解析
              └── 匹配 → 直接使用缓存
失效时机:书籍文件被替换、排版设置变更(字体大小/页边距)、固件升级导致缓存格式变化、用户手动清除。这和浏览器 HTTP 缓存(ETag/Last-Modified)是同样的思路。

6.6 存储系统全局视图

  ┌───────────────────────────────────────────┐
  │                  应用层                    │
  │  (打开书籍、保存进度、读取设置、WiFi 传书)   │
  └──────┬────────────────────┬───────────────┘
         │                    │
  ┌──────▼──────┐      ┌─────▼─────────┐
  │ HalStorage  │      │  NVS API      │
  │ (互斥锁保护) │      │  (ESP-IDF)    │
  └──────┬──────┘      └─────┬─────────┘
         │                    │
  ┌──────▼──────┐      ┌─────▼─────────┐
  │   FatFS     │      │  NVS 分区      │
  │  文件系统    │      │ (Flash 24KB)  │
  └──────┬──────┘      └───────────────┘

  ┌──────▼──────┐
  │  SPI 总线    │
  │ (与墨水屏共享)│
  └──────┬──────┘

  ┌──────▼──────┐
  │   SD 卡     │
  └─────────────┘
每层职责明确:应用层只关心”读文件/存设置”,HalStorage 管线程安全,FatFS 管文件组织,SPI 管数据传输,NVS 独立为小配置服务。

本章要点

  1. 内部 Flash 不够用:16MB 被固件和字体占满,电子书必须存在 SD 卡上。
  2. SPI 共享总线:SD 卡与墨水屏共享 SPI,通过 CS 引脚分时复用,不能同时操作。
  3. FAT32 文件系统:兼容性最好,所有主流系统都能读写。用户可直接用电脑往 SD 卡拷书。
  4. 线程安全:互斥锁防止多任务同时操作 SD 卡。RAII 模式(StorageLock)自动管理加锁解锁,杜绝忘释放锁的隐患。
  5. NVS 键值存储:芯片内部 24KB 小仓库,存设备型号、关键设置、OTA 状态等小而重要的数据。
  6. 缓存系统:首次解析结果以二进制格式缓存在 SD 卡上,再次打开直接读取。通过版本号和文件指纹实现自动失效重建。

下一章预告:第 7 章我们将深入 FreeRTOS——屏幕在刷新的同时按键还能响应,WiFi 在传文件的同时界面还能显示进度条。这一切背后是多任务调度机制。