存储模拟——实现 Ion::Storage 的持久化
在前几篇文章中,我们成功实现了屏幕显示和按键输入。现在,我们需要解决一个关键问题——数据的持久化存储。NumWorks 使用内部闪存来保存用户数据(如计算器设置、Python 脚本、应用程序等)。在 ESP32-S3 上,我们需要找到一种方式来实现相同的功能。
1. 原版存储机制解析
在深入移植之前,有必要先理解 NumWorks 原版的存储设计。
1.1 内存中的 FileSystem
NumWorks 的存储系统位于 Ion::Storage 命名空间下,核心是一个名为 FileSystem 的类。在代码中,它被声明为一个全局单例:
cpp
namespace Storage {
OMG::GlobalBox<FileSystem> FileSystem::sharedFileSystem;
}
这个 sharedFileSystem 是一个内存中的数据结构,它管理着所有记录(Record)的元数据和内容。它包含一个缓冲区 m_buffer,用于存放序列化后的记录。当计算器运行时,所有记录的增删改查都直接在这个内存缓冲区中进行——这意味着掉电后数据会丢失。
1.2 真正的持久化:直接操作闪存
那么,数据是如何保存下来的呢?答案是通过底层闪存驱动。在 Ion::Device::Flash 命名空间下,提供了一系列直接操作闪存的函数:
cpp
namespace Ion {
namespace Device {
namespace Flash {
void MassEraseWithInterruptions(bool handleInterrupts);
bool EraseSectorWithInterruptions(int i, bool handleInterrupts);
bool WriteMemoryWithInterruptions(uint8_t* destination, const uint8_t* source,
size_t length, bool handleInterrupts);
} } }
在适当的时机(例如关机或定期保存),系统会将内存中的 FileSystem 缓冲区整体或部分写入到闪存的特定扇区中。启动时,再从闪存加载到内存中。因此,FileSystem 本身只是运行时缓存,持久化依赖于直接闪存读写。
1.3 为何不直接使用文件系统?
NumWorks 的硬件平台(如 STM32)通常将内部闪存划分为多个扇区,可以直接按扇区擦除和编程。这种方式非常高效,且无需文件系统开销。但在 ESP32-S3 上,情况有所不同:
- ESP32-S3 的代码和数据都存储在外部 SPI Flash 中,通过缓存映射到地址空间。
- 直接操作闪存扇区需要非常小心,因为可能干扰程序运行或损坏分区表。
- ESP-IDF 提供了更安全的抽象层,如分区表、SPIFFS 文件系统和 NVS 键值存储。
因此,我们需要在 ESP32-S3 上用更安全的方式模拟原版的存储行为。
2. 移植策略:用 SPIFFS 模拟闪存存储
我们的目标是在 ESP32-S3 上实现与 NumWorks 原版相同的 Ion::Storage 接口,但后端改用 ESP-IDF 提供的持久化方案。经过权衡,我们选择 SPIFFS(SPI Flash File System) 作为存储后端,理由如下:
- 每条记录可以自然地映射为一个独立文件,文件名即记录名,文件内容即记录数据。
- 支持任意大小的数据,不受 NVS 键值对大小限制。
- 使用标准 C 文件 API,实现简单。
这样,FileSystem 的内存缓冲区将不再直接对应闪存区域,而是作为文件系统操作的缓存层。实际上,我们可以简化设计:每次读写记录时直接操作文件,而不在内存中维护完整副本。但为了与原版接口兼容,我们需要实现 recordNamed、createRecord 等方法,它们内部通过文件系统完成。
3. 分区表配置
首先,我们需要在 Flash 中为 SPIFFS 划分一个分区。ESP32 使用分区表来管理 Flash 布局。
3.1 创建自定义分区表
在项目根目录下创建 partitions.csv 文件:
csv
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 2M,
storage, data, spiffs, , 1M,
关键点:
storage分区:这是我们为 SPIFFS 预留的分区,标签为"storage",类型data,子类型spiffs。- 大小:这里设为 1MB,可根据实际需求调整。
- 偏移量留空:ESP-IDF 会自动计算偏移地址,确保各分区不重叠。
3.2 在 menuconfig 中指定分区表
- 运行
idf.py menuconfig。 - 进入
Partition Table→Partition Table,选择Custom partition table CSV。 - 在
Custom partition CSV file中输入partitions.csv。 - 保存退出。
编译时,系统会打印当前使用的分区表信息,你可以检查 storage 分区是否正确出现。
4. 实现 SPIFFS 初始化和记录操作
接下来,我们在 ion/src/esp32s3/storage.cpp 中实现 Ion::Storage 的底层操作。
4.1 包含头文件和定义挂载点
cpp
#include <ion/storage.h>
#include "esp_spiffs.h"
#include "esp_log.h"
#include <stdio.h>
#include <string.h>
#include <dirent.h>
static const char *TAG = "IonStorage";
#define STORAGE_BASE_PATH "/storage" // SPIFFS 挂载路径
4.2 初始化 SPIFFS
NumWorks 的系统启动时会调用 Ion::Storage::init(),我们在这里挂载 SPIFFS。
cpp
namespace Ion {
namespace Storage {
static bool s_initialized = false;
void init() {
if (s_initialized) return;
esp_vfs_spiffs_conf_t conf = {
.base_path = STORAGE_BASE_PATH,
.partition_label = "storage",
.max_files = 10,
.format_if_mount_failed = true
};
esp_err_t ret = esp_vfs_spiffs_register(&conf);
if (ret != ESP_OK) {
ESP_LOGE(TAG, "Failed to mount SPIFFS (%s)", esp_err_to_name(ret));
return;
}
size_t total = 0, used = 0;
ret = esp_spiffs_info(conf.partition_label, &total, &used);
if (ret == ESP_OK) {
ESP_LOGI(TAG, "SPIFFS mounted: total=%d, used=%d", total, used);
}
s_initialized = true;
}
} // namespace Storage
} // namespace Ion
4.3 实现记录操作
我们将每条记录存储为一个独立文件,文件名即记录名,文件内容即记录数据。
4.3.1 辅助函数:记录名 ↔ 文件路径
cpp
static void get_record_path(const char *record_name, char *path, size_t path_len) {
snprintf(path, path_len, "%s/%s", STORAGE_BASE_PATH, record_name);
}
4.3.2 获取记录
cpp
Record recordNamed(const char *name) {
if (!s_initialized) init();
char path[128];
get_record_path(name, path, sizeof(path));
FILE *f = fopen(path, "rb");
if (!f) {
return Record(); // 返回空记录
}
fseek(f, 0, SEEK_END);
size_t size = ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *buffer = new uint8_t[size];
fread(buffer, 1, size, f);
fclose(f);
// 根据 Ion::Storage 的 Record 构造函数创建对象
// 注意:实际接口可能需要传递 name 和数据
return Record(name, buffer, size);
}
4.3.3 创建或更新记录
cpp
Record::ErrorStatus createRecord(const char *name, const void *data, size_t size) {
if (!s_initialized) init();
char path[128];
get_record_path(name, path, sizeof(path));
FILE *f = fopen(path, "wb");
if (!f) {
return Record::ErrorStatus::NotEnoughSpace;
}
size_t written = fwrite(data, 1, size, f);
fclose(f);
return (written == size) ? Record::ErrorStatus::None : Record::ErrorStatus::NotEnoughSpace;
}
4.3.4 删除记录
cpp
bool destroyRecord(const Record &record) {
if (!s_initialized) init();
char path[128];
get_record_path(record.name(), path, sizeof(path));
return (unlink(path) == 0);
}
4.3.5 遍历所有记录
cpp
int numberOfRecords() {
if (!s_initialized) init();
DIR *dir = opendir(STORAGE_BASE_PATH);
if (!dir) return 0;
int count = 0;
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
if (strcmp(entry->d_name, ".") != 0 && strcmp(entry->d_name, "..") != 0) {
count++;
}
}
closedir(dir);
return count;
}
Record recordAtIndex(int index) {
if (!s_initialized) init();
DIR *dir = opendir(STORAGE_BASE_PATH);
if (!dir) return Record();
int current = 0;
struct dirent *entry;
while ((entry = readdir(dir)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
continue;
}
if (current == index) {
closedir(dir);
return recordNamed(entry->d_name);
}
current++;
}
closedir(dir);
return Record();
}
4.4 集成到 Ion 层
将上述函数挂接到 Ion::Storage 的虚函数表中。通常,这需要修改 ion/src/device/shared/drivers/storage.cpp 或为 ESP32-S3 创建新的实现文件,并通过条件编译包含进来。
5. 构建时自动生成 SPIFFS 镜像(可选)
如果需要在首次烧录时预置一些文件(如默认的 Python 脚本),可以使用 ESP-IDF 的 spiffs_create_partition_image 功能。
5.1 准备预置文件
在项目根目录下创建文件夹 storage_data/,放入需要预置的文件。例如:
text
storage_data/
├── hello.py
├── settings.ini
5.2 修改 CMakeLists.txt
在项目主 CMakeLists.txt 中添加:
cmake
spiffs_create_partition_image(storage ../storage_data FLASH_IN_PROJECT)
这样,编译时会自动生成 SPIFFS 镜像并烧录到 storage 分区。
6. 调试与验证
6.1 测试存储功能
在 app_main 中调用测试函数:
cpp
void test_storage() {
Ion::Storage::init();
// 创建记录
const char *data = "Hello, ESP32-S3!";
auto err = Ion::Storage::sharedStorage->createRecord("test.txt", data, strlen(data) + 1);
printf("createRecord: %d\n", (int)err);
// 读取记录
auto record = Ion::Storage::sharedStorage->recordNamed("test.txt");
if (!record.isNull()) {
auto value = record.value();
printf("Read: %s\n", (const char*)value.buffer);
}
// 列出所有记录
int count = Ion::Storage::sharedStorage->numberOfRecords();
printf("Total records: %d\n", count);
for (int i = 0; i < count; i++) {
auto r = Ion::Storage::sharedStorage->recordAtIndex(i);
printf(" [%d] %s\n", i, r.name());
}
}
6.2 断电测试
重启开发板,再次运行测试函数,检查之前写入的数据是否仍然存在。
7. 注意事项
- 文件系统损坏:SPIFFS 在写入时断电可能损坏,可启用
format_if_mount_failed选项。 - 并发访问:如果多任务访问存储,需加锁保护。
- 性能考虑:直接操作文件系统比内存操作慢,但 NumWorks 的存储访问频率不高,可以接受。
8. 总结
通过本文,我们在 ESP32-S3 上使用 SPIFFS 成功模拟了 NumWorks 的内部存储。虽然底层实现与原版直接操作闪存不同,但向上层 Ion::Storage 提供的接口保持一致。现在,我们的移植版 NumWorks 已经具备了完整的持久化能力。
下一篇文章将进入最后的集成与调试阶段,将所有模块(显示、按键、存储)整合起来,让 NumWorks 的 UI 真正在 ESP32-S3 上运行!