前言:给数据一个可靠的家

在上一篇文章中,我们记录了如何克服重重困难,最终获取到正确的 IMU 数据。但数据的正确性仅仅是第一步,如何将这些高速产生的数据流(本项目中 IMU 数据帧约为 47 字节/帧,频率可达数百赫兹)高效、可靠地存储下来,是所有物联网数据采集项目的核心挑战。

本文将详细剖析本项目中的数据存储方案:从最底层的 Flash 分区规划,到文件系统的选择与挂载,再到最终的数据读写策略。

第一章:规划蓝图 - partitions.csv

ESP32 的内部 Flash 并非一整块可随意使用的空间。它被一张“分区表”划分为不同的区域,用于存放 Bootloader、应用程序、NVS 数据等。要存储我们自己的大文件,第一步就是在这张蓝图上,为我们的数据规划出一块专属的“地皮”。

这一切都在 partitions.csv 文件中定义。我们为 IMU 数据添加了一个名为 storage_part 的自定义分区:

# Name,           Type, SubType, Offset,   Size,      Flags
nvs,            data, nvs,     0x9000,   0x6000,
phy_init,       data, phy,     0xf000,   0x1000,
factory,        app,  factory, 0x10000,  2M,
storage_part,   data, fat,     ,         1M,

让我们来解读 storage_part 这一行:

  • Name (storage_part): 为这个分区起一个独一无二的名字,我们将在代码中通过这个名字来找到它。
  • Type (data): 表明这是一个数据分区,而非可执行的应用程序分区。
  • SubType (fat): 指定这个分区的用途是存放一个 FAT 文件系统。
  • Offset (): 我们将偏移量留空,构建系统会自动计算并放置在上一个分区(factory)之后,避免了手动计算的麻烦。
  • Size (1M): 我们为它分配了 1MB 的空间,足以存储大量的 IMU 数据。

有了这个规划,ESP-IDF 在编译和烧录时,就会在 Flash 的指定位置,为我们预留出这 1MB 的空间。

第二章:搭建文件系统 - FATFS 与磨损均衡

有了“地皮”,我们还需要建立一个“文件管理系统”,而不是直接在原始的 Flash 地址上“堆放”数据。文件系统为我们带来了创建、删除、读写文件和目录的便利。

本项目选用了 ESP-IDF 官方支持的 FATFS 文件系统,并搭配了**磨损均衡(Wear Levelling)**层。

  • FATFS: 一个轻量级、兼容性极佳的文件系统,非常适合资源有限的嵌入式设备。
  • 磨损均衡 (Wear Levelling): SPI Flash 芯片的每个块都有读写次数的寿命限制。如果我们总是在同一个位置写入数据(例如日志文件),那个位置的 Flash 块会很快损坏。磨损均衡层就像一个聪明的管家,它会将写入操作均匀地分布到整个分区的不同物理块上,从而极大延长 Flash 的使用寿命

main.cppinit_fatfs 函数中,我们完成了文件系统的初始化和挂载:

void init_fatfs()
{
    // 1. 通过名字找到我们定义的分区
    const esp_partition_t* fat_partition = esp_partition_find_first(
        ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, "storage_part");

    if (!fat_partition) {
        ESP_LOGE(TAG, "Failed to find FATFS partition 'storage_part'");
        return;
    }

    // 2. 定义挂载配置
    const esp_vfs_fat_mount_config_t mount_config = {
            .format_if_mount_failed = true, // 关键!如果挂载失败(例如首次使用),则自动格式化
            .max_files = 4,                 // 最大同时打开文件数
            .allocation_unit_size = CONFIG_WL_SECTOR_SIZE
    };

    // 3. 将分区挂载到虚拟文件系统(VFS)的 "/storage" 路径下
    //    并启用磨损均衡(WL)
    wl_handle_t wl_handle = WL_INVALID_HANDLE;
    esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl(
        "/storage", "storage_part", &mount_config, &wl_handle);

    if (err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to mount FATFS (%s)", esp_err_to_name(err));
        return;
    }
    ESP_LOGI(TAG, "FATFS mounted successfully at /storage");
}

执行完这段代码后,我们就可以像操作电脑上的文件一样,通过标准 C 文件 I/O 函数(fopen, fwrite 等)来读写 /storage 目录下的文件了。

第三章:数据 I/O 策略

写入数据:直接、原子化

在经历了惨痛的内存损坏 bug 后,我们最终采用了一种最直接、最不容易出错的数据写入策略。该逻辑位于 imu_calibration.cpp 中。

  1. 开始采集 (imu_start_calibration):

    const char* file_path = "/storage/imu_data.bin";
    // 以 "wb" 模式打开文件。'w' 会清空旧文件,'b' 代表二进制模式。
    g_calib_imu_file = fopen(file_path, "wb");
    if (g_calib_imu_file == NULL) {
        ESP_LOGE(TAG, "Failed to open file!");
    }
    

    关键在于使用 "wb" 模式。这确保了每一次新的采集任务都会创建一个全新的、干净的文件,避免了旧数据的干扰。

  2. 处理数据流 (imu_process_data_stream):

    // 数据在内存中一经验证,就立刻写入文件
    if (fwrite(data, 1, len, g_calib_imu_file) != len) {
        ESP_LOGE(TAG, "Failed to write complete IMU packet to file!");
    }
    

    这里是整个方案的核心。我们放弃了所有复杂的内存缓存机制,在数据被验证为正确的下一刻,就调用 fwrite 将其直接、原子化地写入 Flash。这彻底杜绝了数据在内存中被其他任务破坏的可能性。

  3. 停止采集 (imu_stop_calibration):

    if (g_calib_imu_file != NULL) {
        fclose(g_calib_imu_file);
        g_calib_imu_file = NULL;
    }
    

    调用 fclose 至关重要。它不仅会释放文件句柄,更重要的是会将文件系统缓冲区中剩余的、尚未完全写入物理 Flash 的数据,强制刷入(Flush) 到磁盘,确保数据的完整性。

读取数据:为可靠传输而设计

当 PC 端需要下载 imu_data.bin 文件时,我们面临一个新的挑战:如何通过一个主要用于打印日志的文本串口,来传输一个二进制文件?

直接发送二进制数据是不可行的,因为很多字节可能会被串口终端误解为控制字符(如换行、回车等),导致数据截断或乱码。

我们的解决方案是设计一个健壮的、基于文本的传输协议

  1. AT 指令: PC 端发送 AT+READFILE=<offset>,<len> 指令,请求从文件的某个偏移量开始,读取特定长度的数据块。

  2. 分块读取与 Base64 编码: ESP32 的 send_file_chunk 函数负责响应。

    // main.cpp -> send_file_chunk
    FILE* f = fopen("/storage/imu_data.bin", "rb");
    fseek(f, offset, SEEK_SET); // 定位到请求的偏移
    
    // 读取一小块数据(例如256字节)到内存缓冲区
    size_t read_len = fread(buf.data(), 1, len, f); 
    fclose(f);
    
    // 将二进制数据块编码为 Base64 字符串
    size_t enc_len = 0;
    unsigned char* enc = base64_encode(buf.data(), read_len, &enc_len);
    

    Base64 是一种将任意二进制数据,转换为只包含 A-Z, a-z, 0-9, +, /, = 等安全字符的文本编码。这使得我们的二进制数据块可以安全地在任何文本通道中传输。

  3. 自定义帧格式: 为了让 PC 端的 Python 脚本能从混杂的日志中准确地“捞出”我们需要的数据,我们将 Base64 字符串用一个自定义的“帧”包裹起来再发送:

    printf("###BEGIN###%lu,%u,", (unsigned long)offset, (unsigned)read_len);
    printf("%.*s", (int)j, enc); // j 是移除换行符后的编码长度
    printf("###END###\r\n");
    

    这个 ###BEGIN###...###END### 格式,就像给我们的数据包裹加上了独一无二的“信封”。Python 脚本 read_imu_data.py 只需在串口数据流中搜索这两个标记,就能精确地提取出有效的数据载荷,而忽略所有的系统日志。

结语

通过 partitions.csv 的精心规划、FATFS 与磨损均衡的强力支持,以及针对性设计的直接写入和 Base64 分块读取策略,我们为 IMU 数据构建了一个既高效又极其可靠的存储和传输方案。这套方案不仅解决了我们项目中遇到的所有数据损坏问题,也为其他类似的嵌入式数据采集项目,提供了一套可供参考的实践范例。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐