非易失性存储库 ============================ :link_to_translation:`en:[English]` 简介 ------------ 非易失性存储 (NVS) 库主要用于在 flash 中存储键值格式的数据。本文档将详细介绍 NVS 常用的一些概念。 初始化 ^^^^^^^^^^^^^^ NVS 使用分区表中类型为 ``data``、子类型为 ``nvs`` 的分区。该库可以通过以下方式初始化: - :cpp:func:`nvs_flash_init` 初始化标签为 ``nvs`` 的默认 NVS 分区。 - :cpp:func:`nvs_flash_init_partition` 通过其标签初始化特定的 NVS 分区。 - :cpp:func:`nvs_flash_init_partition_ptr` 从 ``esp_partition_t`` 指针初始化 NVS 分区。 初始化完成后,应用程序使用 :cpp:func:`nvs_open` (用于默认分区)或 :cpp:func:`nvs_open_from_partition` (用于按标签指定的特定分区)访问 NVS 命名空间。 .. note:: 启用 :menuitem:`CONFIG_NVS_BDL_STACK` 后,NVS 也可以通过块设备层 (BDL) 运行,从而支持标准 flash 分区以外的其他存储后端。在 BDL 模式下,:cpp:func:`nvs_flash_init_partition_ptr` 不可用,但 :cpp:func:`nvs_flash_init_partition_bdl` 可用于自定义块设备初始化。详情见 :ref:`nvs_internals` > :ref:`nvs_underlying_storage`。 .. note:: 如果 NVS 分区被截断(例如,当分区表布局发生更改时),应擦除其内容。ESP-IDF 构建系统提供了 ``idf.py erase-flash`` 目标,用于擦除 flash 芯片的所有内容。 键值对 ^^^^^^^^^^^^^^^ NVS 的操作对象为键值对,其中键是 ASCII 字符串,当前支持的最大键长为 15 个字符。值可以为以下几种类型: - 整数类型:``uint8_t``、``int8_t``、``uint16_t``、``int16_t``、``uint32_t``、``int32_t``、``uint64_t``、``int64_t`` - 浮点数类型:``float`` 和 ``double`` - 以零终止的类 C 字符串 - 长度可变的二进制数据 (BLOB) .. note:: NVS 最适合存储数量适中、相对稳定的小型数据,例如设备配置、校准数据或状态标志,而不是少量的大型 ``string`` 或 ``blob`` 数据。这里所说的“小型数据”并不意味着 NVS 适合存储持续增长或频繁重写的数据集,例如事件日志或周期性采集的测量数据。随着这类数据不断累积,分区很容易被填满并产生碎片,导致空间回收更加频繁,同时加速 flash 磨损。如果需要存储大型 blob 或字符串数据,或需要持续追加数据,建议改用 ESP-IDF 提供的文件系统。 .. note:: 无论特定 SoC 是否配备 FPU,均支持浮点类型 ``float`` 和 ``double``。 键在其命名空间内必须唯一。向现有键写入新值会替换之前的键值对。实际数据类型由最近一次写入操作决定。 在读取值时,会进行数据类型检查。如果读取操作期望的数据类型与该键对应条目的数据类型不匹配,则返回错误 ``ESP_ERR_NVS_TYPE_MISMATCH``。 记录大小限制 ^^^^^^^^^^^^^^^^^^^^^^^ 单个存储值的最大大小取决于其数据类型: .. list-table:: :header-rows: 1 :widths: 30 70 * - 数据类型 - 值的最大大小 * - 整数和浮点数 - 大小由类型决定(1 到 8 字节);始终存储在单个条目中。 * - 字符串 - 4000 字节,包括空字符终止符。 * - 二进制大对象 - 508,000 字节,或者分区大小的 97.6% 减去 4000 字节,以较小者为准。 .. note:: 上文提到的字符串和 blob 大小限制是数据分区为空(无碎片)的情况下能达到的理论上限。实际运行时可存储的最大数据大小通常会更小,具体取决于分区的碎片程度。参见 :ref:`nvs_space_consumption`,了解 NVS 如何分配条目以及碎片为何会影响可用存储空间。 命名空间 ^^^^^^^^ 为减少不同组件之间键名的潜在冲突,NVS 将每个键值对分配到一个命名空间。命名空间的命名规则遵循键名的命名规则,例如,最多可占 15 个字符。此外,单个 NVS 分区最多只能容纳 254 个不同的命名空间。命名空间的名称在调用 :cpp:func:`nvs_open` 或 :cpp:type:`nvs_open_from_partition` 中指定。调用后将返回一个不透明句柄,用于后续调用 ``nvs_get_*``、``nvs_set_*`` 和 :cpp:func:`nvs_commit` 函数。这样,句柄就与命名空间和分区关联,键名不会与其他命名空间中的同名键发生冲突。 ``open_mode`` 参数控制访问级别和安全行为: - ``NVS_READONLY``:只读访问权限。所有写操作将被拒绝。 - ``NVS_READWRITE``:标准读写访问权限。被擦除的数据会被标记为已删除,但仍保留在 flash 中。 - ``NVS_READWRITE_PURGE``:安全的读写访问权限。已擦除的数据将从 flash 中物理移除。 .. note:: 在不同的 NVS 分区中,同名的命名空间被视为相互独立的命名空间。 C++ API ^^^^^^^ 除上文所述的 C API 外,NVS 还在 :component_file:`nvs_flash/include/nvs_handle.hpp` 中提供了 C++ 类接口(命名空间 ``nvs``)。 使用 ``nvs::open_nvs_handle()`` 或 ``nvs::open_nvs_handle_from_partition()`` 打开命名空间。这些函数返回 ``std::unique_ptr``。当该 ``std::unique_ptr`` 被销毁时,句柄会自动关闭(RAII),因此无需另行调用关闭函数。 ``nvs::NVSHandle`` 提供与 C API 对应的方法,包括: - ``set_item`` / ``get_item`` — 面向整型、浮点型和枚举类型的类型化读写 - ``set_string`` / ``get_string`` — 字符串值 - ``set_blob`` / ``get_blob`` — 二进制 blob 值 - ``commit``、``erase_item``、``erase_all``、``purge_all``、``find_key`` 及相关辅助方法 ``open_mode`` 的取值( ``NVS_READONLY``、``NVS_READWRITE``、``NVS_READWRITE_PURGE``)以及键名和命名空间约束与 C API 相同。完整的类与函数说明见下文 :ref:`API 参考 `,完整示例见 :example:`storage/nvs/nvs_rw_value_cxx`。 NVS 迭代器 ^^^^^^^^^^^^^ 迭代器允许根据指定的分区名称、命名空间和数据类型轮询 NVS 中存储的键值对。 使用以下函数,可执行相关操作: - ``nvs_entry_find``:创建一个不透明句柄,用于后续调用 ``nvs_entry_next`` 和 ``nvs_entry_info`` 函数; - ``nvs_entry_next``:让迭代器指向下一个键值对; - ``nvs_entry_info``:返回每个键值对的信息。 总的来说,所有通过 :cpp:func:`nvs_entry_find` 获得的迭代器(包括 ``NULL`` 迭代器)都必须使用 :cpp:func:`nvs_release_iterator` 释放。 :cpp:func:`nvs_entry_find` 和 :cpp:func:`nvs_entry_next` 在除发生参数错误之外的所有情况下,都会将给定的迭代器设为 ``NULL`` 或有效的迭代器(即返回 ``ESP_ERR_NVS_NOT_FOUND`` 时除外)。发生参数错误时,给定的迭代器不会被修改。因此,最佳实践是在调用 :cpp:func:`nvs_entry_find` 之前先将迭代器初始化为 ``NULL``,以避免在释放迭代器之前进行繁琐的错误检查。 安全性、篡改性及鲁棒性 ^^^^^^^^^^^^^^^^^^^^^^^^^^ .. only:: not SOC_HMAC_SUPPORTED NVS 与 {IDF_TARGET_NAME} flash 加密系统不直接兼容。然而,如果 NVS 加密与 {IDF_TARGET_NAME} flash 加密一起使用,数据仍可以加密形式存储。详情请参考 :doc:`nvs_encryption`。 .. only:: SOC_HMAC_SUPPORTED NVS 与 {IDF_TARGET_NAME} flash 加密系统不直接兼容。然而,如果 NVS 加密与 {IDF_TARGET_NAME} flash 加密或 HMAC 外设一起使用,数据仍可以加密形式存储。详情请参考 :doc:`nvs_encryption`。 如果未启用 NVS 加密,任何对 flash 芯片有物理访问权限的用户都可以读取、修改、擦除或添加键值对。启用 NVS 加密后,在不知道相应的 NVS 加密密钥的情况下,无法读取、修改或添加键值对并将其识别为有效键值对。但是,针对擦除操作没有相应的防篡改功能。 当 flash 处于不一致状态时,NVS 库会尝试恢复。在任何时间点关闭设备电源,然后重新打开电源,不会导致数据丢失;但如果关闭设备电源时正在写入新的键值对,这一键值对可能会丢失。该库还应该能够在 flash 中存在任何随机数据的情况下正常初始化。 .. _data_purging_security: 数据清除与安全 ^^^^^^^^^^^^^^^^^^^^^^^^^ 默认情况下,当 NVS 更新或擦除键值对时,flash 中的数据仅在元数据部分被标记为已擦除。这些值实际仍存在于 flash 中。这种做法可以提升写入性能。 对于需要更高安全性、必须将敏感数据从 flash 中物理删除的应用(即通过将所有位清零),NVS 提供了两种机制: **一次性清除** :cpp:func:`nvs_purge_all` 函数会清除命名空间内所有标记为已擦除的条目。该功能适用于尚未使用连续清除模式,且应用需要清理现有已擦除 flash 内容的场景。此函数可与以 ``NVS_READWRITE`` 或 ``NVS_READWRITE_PURGE`` 模式打开的句柄配合使用。 **连续清除模式** 以 ``NVS_READWRITE_PURGE`` 模式打开的命名空间句柄,除了将已擦除或覆盖的值标记为已擦除外,还会自动清除这些值所占用的 flash 空间。 .. note:: 以 ``NVS_READWRITE_PURGE`` 模式打开 NVS 命名空间时,不会清理 flash 中被标记为已擦除的数据。若命名空间中存在已更新或已擦除的数据,在使用连续清除模式前,请先执行一次性清除。 .. note:: 相较于标准的标记为已擦除模式,清除操作会增加额外的 flash 写入次数。在决定是否使用数据清除功能时,应用程序需要在安全需求和 flash 写入性能之间进行权衡。 特殊使用场景 ----------------- NVS 中的大量数据 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 虽然不推荐这样做,但 NVS 可以存储数以万计的键,且 NVS 分区的大小可达数兆字节级别。 .. note:: NVS 组件会在堆上占用 RAM。占用量取决于 flash 上的 NVS 分区大小以及正在使用的键数量。为估算 RAM 用量,请参考以下近似数值:每 1 MB NVS flash 分区消耗 22 KB RAM;每 1000 个键消耗 5.5 KB RAM。 .. note:: 使用 :cpp:func:`nvs_flash_init` 进行 NVS 初始化所需的时间与现有键的数量成正比。初始化 NVS 时,通常每 1000 个键需要 0.5 秒。 .. note:: NVS 初始化耗时会随着分区内数据量的增加以及值更新次数的增多而逐渐增长。为避免应用程序在客户实际使用过程中因初始化过程意外触发看门狗超时,请提前在包含所有键(包括预期更新历史)的 NVS 分区上测试初始化过程。 .. only:: SOC_SPIRAM_SUPPORTED 默认情况下,内部 NVS 会在内部 RAM 中分配堆内存。对于较大的 NVS 分区或大量键,应用程序可能仅因 NVS 的开销就耗尽内部 RAM 的堆内存。 如果应用程序所使用的模组配备了通过 SPI 连接的 PSRAM,则可通过启用 Kconfig 选项 :menuitem:`CONFIG_NVS_ALLOCATE_CACHE_IN_SPIRAM` 来克服这一限制。该选项会将 RAM 分配重定向到通过 SPI 连接的 PSRAM。 当启用 SPIRAM 且 :menuitem:`CONFIG_SPIRAM_USE` 设为 ``CONFIG_SPIRAM_USE_CAPS_ALLOC`` 时,此选项可在 menuconfig 菜单的 nvs_flash 组件中使用。 .. note:: 使用 SPI 接口的 PSRAM 后,NVS 整数操作的 API 耗时约为原来的 2.5 倍。 电源不稳定状态 ^^^^^^^^^^^^^^^^^^^^^^^^^ 当 NVS 用于弱电源或不稳定电源系统(如太阳能或电池供电系统)时,flash 擦除操作可能偶尔无法彻底完成,而应用程序无法检测到这一问题。这会导致实际 flash 内容与预留页面的预期布局不一致。在极少数情况下(特别是在意外断电时),可能造成可用 NVS 页面耗尽,导致分区初始化失败并返回 ``ESP_ERR_NVS_NO_FREE_PAGES`` 错误。 为解决此问题,可通过 Kconfig 选项 :menuitem:`CONFIG_NVS_FLASH_VERIFY_ERASE` 启用 flash 擦除操作的验证机制,通过回读受影响页面进行检测。若在 ``flash_erase`` 操作后页面未完全擦除为 ``0xFF``,系统将重试擦除操作直至页面被正确清空。包括首次尝试在内的擦除尝试总次数可通过 Kconfig 选项 :menuitem:`CONFIG_NVS_FLASH_ERASE_ATTEMPTS` 进行配置。 .. note:: 在可写分区上初始化 NVS 时,如果发现分区处于不一致的状态,NVS 库会尝试执行恢复操作。该操作可能涉及擦除和重写部分页面,而在电源持续不稳定的环境下,进而可能导致出厂默认数据意外丢失。因此,建议将关键的出厂默认数据保存在单独的只读分区中,这样就不会对其执行恢复操作。 .. _nvs_bootloader: 在引导加载程序代码中使用 NVS ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 本指南所述的标准 NVS API 可供正在运行的应用程序使用。此外,还可以在自定义引导加载程序代码中从 NVS 读取数据。更多信息见 :doc:`nvs_bootloader` 指南。 .. _nvs_encryption: NVS 加密 -------------- 详情请参考 :doc:`nvs_encryption`。 NVS 分区生成程序 ------------------ NVS 分区生成程序帮助生成 NVS 分区二进制文件,可使用烧录程序将二进制文件单独烧录至特定分区。烧录至分区上的键值对由 CSV 文件提供,详情请参考 :doc:`nvs_partition_gen`。 可以直接使用函数 ``nvs_create_partition_image`` 通过 CMake 创建分区二进制文件,无需手动调用 ``nvs_partition_gen.py`` 工具:: nvs_create_partition_image( [FLASH_IN_PROJECT] [DEPENDS dep dep dep ...]) **位置参数**: .. list-table:: :header-rows: 1 * - 参数 - 描述 * - ``partition`` - NVS 分区名 * - ``csv`` - 解析的 CSV 文件路径 **可选参数**: .. list-table:: :header-rows: 1 * - 参数 - 描述 * - ``FLASH_IN_PROJECT`` - 将生成的镜像与工程一并烧录 * - ``DEPENDS`` - 指定命令依赖的文件 在没有指定 ``FLASH_IN_PROJECT`` 的情况下,也支持生成分区镜像,不过此时需要使用 ``idf.py -flash`` 手动进行烧录。举个例子,如果分区名为 ``nvs``,则需使用的命令为 ``idf.py nvs-flash``。 目前,仅支持从组件中的 ``CMakeLists.txt`` 文件调用 ``nvs_create_partition_image``,且此选项仅适用于非加密分区。 应用示例 ------------------- ESP-IDF :example:`storage/nvs` 目录下提供了数个代码示例: :example:`storage/nvs/nvs_rw_value` 演示如何读取及写入 NVS 单个整数值。 此示例中的值表示 {IDF_TARGET_NAME} 模组重启次数。NVS 中数据不会因为模组重启而丢失,因此只有将这一值存储于 NVS 中,才能起到重启次数计数器的作用。 该示例也演示了如何检测读取/写入操作是否成功,以及某个特定值是否在 NVS 中尚未初始化。诊断程序以纯文本形式提供,有助于追踪程序流程,及时发现问题。 :example:`storage/nvs/nvs_rw_blob` 演示如何读取及写入 NVS 单个整数值和 BLOB(二进制大对象),并在 NVS 中存储这一数值,即便 {IDF_TARGET_NAME} 模组重启也不会消失。 * value - 记录 {IDF_TARGET_NAME} 模组软重启次数和硬重启次数。 * blob - 内含记录模组运行次数的表格。此表格将被从 NVS 读取至动态分配的 RAM 上。每次手动软重启后,表格内运行次数即增加一次,新加的运行次数被写入 NVS。下拉 GPIO0 即可手动软重启。 该示例也演示了如何执行诊断程序以检测读取/写入操作是否成功。 :example:`storage/nvs/nvs_rw_value_cxx` 这个例子与 :example:`storage/nvs/nvs_rw_value` 完全一样,只是使用了 C++ 的 NVS 句柄类(通过 ``nvs::open_nvs_handle()`` 获取 ``nvs::NVSHandle``)。 :example:`storage/nvs/nvs_statistics` 该示例演示了如何获取并解读 NVS 使用情况统计信息:包括指定 NVS 分区中的空闲、已用、可用、总条目数、以及命名空间数量。 默认的 NVS 分区会在运行本示例前被擦除,以确保干净的运行环境。随后,会写入模拟的字符串类型数据。 在写入数据前后分别获取使用情况统计信息,并将两者的差异与新占用条目的预期值进行比较。 本示例的第二部分展示了 NVS 分区碎片化对 blob 存储开销的影响。 :example:`storage/nvs/nvs_iteration` 该示例演示了如何遍历特定(或任意)NVS 数据类型的条目,以及如何获取这些条目的相关信息。 默认的 NVS 分区会在运行本示例前被擦除,以确保干净的运行环境。随后,会写入包含不同 NVS 整型数据类型的模拟数据。 之后,本示例会遍历各个数据类型以及通用的 ``NVS_TYPE_ANY`` 类型,并记录在每次遍历过程中获取到的信息。 .. _nvs_internals: 内部实现 --------- 键值对日志 ^^^^^^^^^^^^^^^^^^^^^^ NVS 按顺序存储键值对,新的键值对添加在最后。因此,如需更新某一键值对,实际是在日志最后增加一对新的键值对,同时将旧的键值对标记为已擦除。 .. note:: NVS 组件在设计上包含 flash 磨损均衡。执行写入操作时,新数据会追加写入现有条目之后的空闲空间,而旧数据失效后不会立即触发 flash 擦除操作。NVS 将存储空间组织为页和条目,从而降低了 flash 擦除操作的频率。对于可存储在单个条目中的数据类型,在理想情况下(每次擦除对应一整页均为单条目写入),flash 擦除与写入操作的频率比可以有效降低为原来的 1/126。实际情况下,这一比例通常会更低,且主要取决于分区的使用率:随着有效数据增长,NVS 会更频繁地执行空间回收,从而导致擦除与写入次数之比增大。此外,对于较大且从未被覆盖的数据块,它们可能会长期占用同一个 NVS 页。由于空间回收仅会选择包含已擦除条目的页,这类页面不会参与擦除循环,因此会减少参与磨损均衡的 flash 空间比例。 页面和条目 ^^^^^^^^^^^^^^^^^ NVS 库在其操作中主要使用两个实体:页面和条目。页面是一个逻辑结构,用于存储部分的整体日志。逻辑页面对应 flash 的一个物理扇区,正在使用中的页面具有与之相关联的 *序列号*。序列号赋予了页面顺序,较高的序列号对应较晚创建的页面。页面有以下几种状态: 空或未初始化 页面对应的 flash 扇区为空白状态(所有字节均为 ``0xff``)。此时,页面未存储任何数据且没有关联的序列号。 活跃状态 此时 flash 已完成初始化,页头部写入 flash,页面已具备有效序列号。页面中存在一些空条目,可写入数据。任意时刻,至多有一个页面处于活跃状态。 写满状态 flash 已写满键值对,状态不再改变。 用户无法向写满状态下的页面写入新键值对,但仍可将一些键值对标记为已擦除。 擦除状态 未擦除的键值对将移至其他页面,以便擦除当前页面。这一状态仅为暂时性状态,即 API 调用返回时,页面应脱离这一状态。如果设备突然断电,下次开机时,设备将继续把未擦除的键值对移至其他页面,并继续擦除当前页面。 损坏状态 页头部包含无效数据,无法进一步解析该页面中的数据,因此之前写入该页面的所有条目均无法访问。相应的 flash 扇区并不会被立即擦除,而是与其他处于未初始化状态的扇区一起等待后续使用。这一状态可能对调试有用。 flash 扇区映射至逻辑页面并没有特定的顺序,NVS 库会检查存储在 flash 扇区的页面序列号,并根据序列号组织页面。 :: +--------+ +--------+ +--------+ +--------+ | Page 1 | | Page 2 | | Page 3 | | Page 4 | | Full +---> | Full +---> | Active | | Empty | <- 状态 | #11 | | #12 | | #14 | | | <- 序列号 +---+----+ +----+---+ +----+---+ +---+----+ | | | | | | | | | | | | +---v------+ +-----v----+ +------v---+ +------v---+ | Sector 3 | | Sector 0 | | Sector 2 | | Sector 1 | <- 物理扇区 +----------+ +----------+ +----------+ +----------+ 页面结构 ^^^^^^^^^^^^^^^^^^^ 当前,我们假设 flash 扇区大小为 4096 字节,并且 {IDF_TARGET_NAME} flash 加密硬件在 32 字节块上运行。未来有可能引入一些编译时可配置项(可通过 menuconfig 进行配置),以适配具有不同扇区大小的 flash 芯片。但目前尚不清楚 SPI flash 驱动和 SPI flash cache 之类的系统组件是否支持其他扇区大小。 页面由头部、条目状态位图和条目三部分组成。为了实现与 {IDF_TARGET_NAME} flash 加密功能兼容,条目大小设置为 32 字节。如果键值为整数型,条目则保存一个键值对;如果键值为字符串或 BLOB 类型,则条目仅保存一个键值对的部分内容(更多信息详见条目结构描述)。 页面结构如下图所示,括号内数字表示该部分的大小(以字节为单位)。 :: +-----------+--------------+-------------+-------------------------+ | State (4) | Seq. no. (4) | version (1) | Unused (19) | CRC32 (4) | 页头部 (32) +-----------+--------------+-------------+-------------------------+ | Entry state bitmap (32) | +------------------------------------------------------------------+ | Entry 0 (32) | +------------------------------------------------------------------+ | Entry 1 (32) | +------------------------------------------------------------------+ / / / / +------------------------------------------------------------------+ | Entry 125 (32) | +------------------------------------------------------------------+ 头部和条目状态位图写入 flash 时不加密。如果启用了 {IDF_TARGET_NAME} flash 加密功能,则条目写入 flash 时将会加密。 通过将 0 写入某些位可以定义页面状态值,表示状态改变。因此,如果需要变更页面状态,并不一定要擦除页面,除非要将其变更为 *擦除* 状态。 头部中的 ``version`` 字段反映了所用的 NVS 格式版本。为实现向后兼容,版本升级从 0xff 开始依次递减(例如,version-1 为 0xff,version-2 为 0xfe,以此类推)。 头部中 CRC32 值是由不包含状态值的条目计算所得(4 到 28 字节)。当前未使用的条目用 ``0xff`` 字节填充。 条目结构和条目状态位图的详细信息见下文描述。 条目和条目状态位图 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 每个条目可处于以下三种状态之一,每个状态在条目状态位图中用两位表示。位图中的最后四位 (256 - 2 * 126) 未使用。 空 (2'b11) 条目还未写入任何内容,处于未初始化状态(全部字节为 ``0xff``)。 写入(2'b10) 一个键值对(或跨多个条目的键值对的部分内容)已写入条目中。 擦除(2'b00) 条目中的键值对已丢弃,条目内容不再解析。 .. _structure_of_entry: 条目结构 ^^^^^^^^^^^^^^^^^^ 如果键值类型为基础类型,即 1 - 8 个字节长度的整数型,条目将保存一个键值对;如果键值类型为字符串或 BLOB 类型,条目将保存整个键值对的部分内容。另外,如果键值为字符串类型且跨多个条目,则键值所跨的所有条目均保存在同一页面。BLOB 则可以切分为多个块,实现跨多个页面。BLOB 索引是一个附加的固定长度元数据条目,用于追踪 BLOB 块。目前条目仍支持早期 BLOB 格式(可读取可修改),但这些 BLOB 一经修改,即以新格式储存至条目。 :: +--------+----------+----------+----------------+-----------+---------------+----------+ | NS (1) | Type (1) | Span (1) | ChunkIndex (1) | CRC32 (4) | Key (16) | Data (8) | +--------+----------+----------+----------------+-----------+---------------+----------+ Primitive +--------------------------------+ +--------> | Data (8) | | Types +--------------------------------+ +-> Fixed length -- | | +---------+--------------+---------------+-------+ | +--------> | Size(4) | ChunkCount(1)| ChunkStart(1) | Rsv(2)| Data format ---+ BLOB Index +---------+--------------+---------------+-------+ | | +----------+---------+-----------+ +-> Variable length --> | Size (2) | Rsv (2) | CRC32 (4) | (Strings, BLOB Data) +----------+---------+-----------+ 条目结构中各个字段含义如下: 命名空间 (NS, NameSpace) 该条目的命名空间索引,详细信息参见命名空间实现章节。 类型 (Type) 一个字节表示的值的数据类型,:component_file:`nvs_flash/include/nvs_handle.hpp` 下的 :cpp:type:`ItemType` 枚举了可能的类型。 跨度 (Span) 该键值对所用的条目数量。如果键值为整数型,条目数量即为 1。如果键值为字符串或 BLOB,则条目数量取决于值的长度。 块索引 (ChunkIndex) 用于存储 BLOB 类型数据块的索引。如果键值为其他数据类型,则此处索引应写入 ``0xff``。 CRC32 对条目下所有字节进行校验后,所得的校验和(CRC32 字段不计算在内)。 键 (Key) 即以零结尾的 ASCII 字符串,字符串最长为 15 字节,不包含最后一个字节的零终止符。 数据 (Data) 如果键值类型为整数型,则数据字段仅包含键值。如果键值小于八个字节,使用 ``0xff`` 填充未使用的部分(右侧)。 如果键值类型为 BLOB 索引条目,则该字段的八个字节将保存以下数据块信息: - 块大小 整个 BLOB 数据的大小(以字节为单位)。该字段仅用于 BLOB 索引类型条目。 - ChunkCount 存储过程中 BLOB 分成的数据块总量。该字段仅用于 BLOB 索引类型条目。 - ChunkStart BLOB 第一个数据块的块索引,后续数据块索引依次递增,步长为 1。该字段仅用于 BLOB 索引类型条目。 如果键值类型为字符串或 BLOB 数据块,数据字段的这八个字节将保存该键值的一些附加信息,如下所示: - 数据大小 实际数据的大小(以字节为单位)。如果键值类型为字符串,此字段也应将零终止符包含在内。此字段仅用于字符串和 BLOB 类型条目。 - CRC32 数据所有字节的校验和,该字段仅用于字符串和 BLOB 类型条目。 可变长度值(字符串和 BLOB)写入后续条目,每个条目 32 字节。第一个条目的 ``Span`` 字段将指明使用了多少条目。 .. _nvs_space_consumption: 空间占用 ^^^^^^^^^^^^^^^^^ NVS 将每条记录存储为一个或多个 32 字节的条目,这些条目位于 4096 字节的数据页中。每个数据页包含 126 个可用条目。一个值需要占用多少个条目,以及存储该值所需的空闲条目数,取决于其数据类型: - 整数和浮点数只占用一个独立条目;只要任意位置存在一个空闲条目即可存储。 - 字符串占用一个元数据条目,随后占用 ``ceil(payload_size / entry_size)`` 个数据条目(有效载荷包含字符串结尾的空字符)。这些条目必须在同一个数据页内连续分配。 - blob 类型占用一个 ``BLOB_INDEX`` 元数据条目以及一个或多个数据块。每个数据块由一个元数据条目和若干数据条目组成,并存储在不同的数据页中。因此,存储一个 blob 共需要 ``1 + k + ceil(blob_size / entry_size)`` 个条目,其中 ``k`` 表示数据被拆分后的页数。 在写入新的键值对或更新现有键值对之前,NVS 会查找一个具有足够空闲条目(或可通过空间回收获得足够可用条目)的数据页。空间回收算法为应对突然断电而设计,每次调用仅对一个候选页进行空间整理。因此,可连续分配的最大条目数始终是在单个数据页内决定的。这会带来两个影响: - 字符串的实际最大长度受限于单个数据页中空闲条目与已删除条目数量之和的最大值。 - blob 会根据空间回收后各数据页中可用条目的数量拆分为多个数据块,并持续拆分直至整个值存储完成。这使得 NVS 即使在某个数据页仅剩 2 个空闲条目时也能继续利用该页进行存储,但每个数据块都需要额外占用一个元数据条目。在极端情况下,元数据的开销甚至可能超过有效负载大小的 100%。 由于上述按页分配的特性,实际可存储的数据大小通常低于 `记录大小限制`_ 中给出的理论最大值,并且随着分区逐渐填满和碎片不断增加,可存储的数据大小会进一步减小。碎片对 flash 寿命的影响请参阅 `键值对日志`_ 中关于磨损均衡的说明。 .. note:: 由于在现场已部署的设备上调整 NVS 分区大小较为困难,应将其初始大小设得足够大,以容纳当前需求以及键或其数据的潜在增长。还建议运行足够数量的测试,以真实反映写入和更新 NVS 键的频率。在测试软件更新之前(例如,通过 OTA),先在已被上一版本软件碎片化的数据分区上运行这些测试。 命名空间 ^^^^^^^^^^ 如上所述,每个键值对属于一个命名空间。命名空间标识符(字符串)也作为键值对的键,存储在索引为 0 的命名空间中。与这些键对应的值就是这些命名空间的索引。 :: +-------------------------------------------+ | NS=0 Type=uint8_t Key="wifi" Value=1 | Entry describing namespace "wifi" +-------------------------------------------+ | NS=1 Type=uint32_t Key="channel" Value=6 | Key "channel" in namespace "wifi" +-------------------------------------------+ | NS=0 Type=uint8_t Key="pwm" Value=2 | Entry describing namespace "pwm" +-------------------------------------------+ | NS=2 Type=uint16_t Key="channel" Value=20 | Key "channel" in namespace "pwm" +-------------------------------------------+ 条目哈希列表 ^^^^^^^^^^^^^^ 为了减少对 flash 执行的读操作次数,Page 类对象均设有一个列表,包含一对数据:条目索引和条目哈希值。该列表可大大提高检索速度,而无需迭代所有条目并逐个从 flash 中读取。``Page::findItem`` 首先从哈希列表中检索条目哈希值,如果条目存在,则在页面内给出条目索引。由于哈希冲突,在哈希列表中检索条目哈希值可能会得到不同的条目,对 flash 中条目再次迭代可解决这一冲突。 哈希列表中每个节点均包含一个 24 位哈希值和 8 位条目索引。哈希值根据条目命名空间、键名和块索引由 CRC32 计算所得,计算结果保留 24 位。为减少将 32 位条目存储在链表中的开销,链表采用了数组的双向链表。每个数组占用 128 个字节,包含 29 个条目、两个链表指针和一个 32 位计数字段。因此,每页额外需要的 RAM 最少为 128 字节,最多为 640 字节。 .. _read-only-nvs: 只读 NVS ^^^^^^^^ NVS 支持两种级别的只读模式: - 在分区表层面,可在分区表 CSV 文件中将分区标记为 ``readonly``。 - 在应用层面,可通过 :cpp:func:`nvs_open_from_partition` 函数并传入 ``NVS_READONLY`` 标志,以只读模式打开 NVS 分区。 NVS 正常运行所需的默认最小空间为 12 KiB (``0x3000``),即至少需要 3 页,且其中至少有一页处于空状态。但如果 NVS 分区在分区表 CSV 文件中被标记为 ``readonly``,并以只读模式打开,那么分区大小可以只有 4 KiB(``0x1000``)。 .. note:: 目前,如果 NVS 使用块设备层作为存储后端时,则不会反映只读标志。 .. _nvs_underlying_storage: 底层存储 ^^^^^^^^^^^^^^^^^^ 在构建时,可以配置 NVS 访问其底层存储的模式。menuconfig 选项 :menuitem:`CONFIG_NVS_BDL_STACK` 提供了两种模式。 **ESP 分区 API(默认)**:NVS 使用 :ref:`esp_partition ` 访问存储。这是默认运行模式,其中 NVS 使用由分区表定义的 SPI flash 分区。在此模式下: - 初始化函数 (:cpp:func:`nvs_flash_init`, :cpp:func:`nvs_flash_init_partition`) 会查找该分区,并使用 :ref:`esp_partition ` API 访问它。 - 应用程序可以提供自定义的 ``esp_partition_t`` 指针并调用 :cpp:func:`nvs_flash_init_partition_ptr`。这使应用程序能够克服基于分区表的分区所带来的限制,例如使用分区表中未定义的分区。 - 该模式提供最佳性能,因为 NVS 与底层存储之间没有额外的抽象层。 **块设备层 (BDL)**:NVS 通过 esp_blockdev 访问存储。此选项使 NVS 能在实现 esp_blockdev 接口的块设备上运行。在此模式下: - 初始化函数(:cpp:func:`nvs_flash_init`,:cpp:func:`nvs_flash_init_partition`)会通过 :ref:`esp_partition ` 透明地创建块设备,管理其生命周期,并在内部使用 esp_blockdev。对于应用而言,此模式与默认的运行模式类似。 - 应用程序可以提供自定义块设备句柄,并调用 :cpp:func:`nvs_flash_init_partition_bdl` 将其注册到 NVS。应用程序负责管理该块设备句柄的生命周期。 - 由于 BDL 抽象了底层存储,与直接使用 :ref:`esp_partition ` API 相比,会有额外开销。仅当基于 esp_partition 的存储无法满足应用需求时,才选择此模式。 .. note:: 如果在 NVS 中使用自定义块设备,则必须满足以下要求: - ``read_size`` 和 ``write_size`` 必须为 1 字节(NVS 要求按字节粒度进行访问)。 - ``erase_size`` 必须是 4096 的约数(NVS 页大小固定为 4096 字节)。 - ``disk_size`` 必须是 4096 字节的整数倍。 - 必须将 ``default_val_after_erase`` 标志位设为 1(即擦除后的存储器读出的值为 ``0xFF``)。 - 操作(``read``、``write``、``erase``)必须实现。 .. _nvs-api-reference: API 参考 ------------- .. include-build-file:: inc/nvs_flash.inc .. include-build-file:: inc/nvs.inc .. include-build-file:: inc/nvs_handle.inc