numworks移植记录:9.存储模拟——实现 Ion::Storage 的持久化

6 阅读8分钟

存储模拟——实现 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 的内存缓冲区将不再直接对应闪存区域,而是作为文件系统操作的缓存层。实际上,我们可以简化设计:每次读写记录时直接操作文件,而不在内存中维护完整副本。但为了与原版接口兼容,我们需要实现 recordNamedcreateRecord 等方法,它们内部通过文件系统完成。


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 中指定分区表

  1. 运行 idf.py menuconfig
  2. 进入 Partition TablePartition Table,选择 Custom partition table CSV
  3. Custom partition CSV file 中输入 partitions.csv
  4. 保存退出。

编译时,系统会打印当前使用的分区表信息,你可以检查 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 上运行!