NVS flash 键值对存储内部实现

    本文地址:http://tongxinmao.com/Article/Detail/id/488

    内部实现

    键值对日志

    NVS 按顺序存储键值对,新的键值对添加在最后。因此,如需更新某一键值对,实际是在日志最后增加一对新的键值对,同时将旧的键值对标记为已擦除。

    页面和条目

    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 |    <- 物理扇区+----------+  +----------+  +----------+  +----------+

    Copy to clipboard

    页面结构

    当前,我们假设 flash 扇区大小为 4096 字节,并且 ESP32 flash 加密硬件在 32 字节块上运行。未来有可能引入一些编译时可配置项(可通过 menuconfig 进行配置),以适配具有不同扇区大小的 flash 芯片。但目前尚不清楚 SPI flash 驱动和 SPI flash cache 之类的系统组件是否支持其他扇区大小。

    页面由头部、条目状态位图和条目三部分组成。为了实现与 ESP32 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)                             |+------------------------------------------------------------------+

    Copy to clipboard

    头部和条目状态位图写入 flash 时不加密。如果启用了 ESP32 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)

    • 条目中的键值对已丢弃,条目内容不再解析。

    条目结构

    如果键值类型为基础类型,即 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)     +----------+---------+-----------+

    Copy to clipboard

    条目结构中各个字段含义如下:

    • 命名空间 (NS, NameSpace)

    • 该条目的命名空间索引,详细信息见命名空间实现章节。

    • 类型 (Type)

    • 一个字节表示的值的数据类型,可能的类型见 nvs_types.h 中 ItemType 枚举。

    • 跨度 (Span)

    • 该键值对所用的条目数量。如果键值为整数型,条目数量即为 1。如果键值为字符串或 BLOB,则条目数量取决于值的长度。

    • 块索引 (ChunkIndex)

    • 用于存储 BLOB 类型数据块的索引。如果键值为其他数据类型,则此处索引应写入 0xff

    • CRC32

    • 对条目下所有字节进行校验,所得的校验和(CRC32 字段不计算在内)。

    • 键 (Key)

    • 即以零结尾的 ASCII 字符串,字符串最长为 15 字节,不包含最后一个字节的 NULL (\0) 终止符。

    • 数据 (Data)

    • 如果键值类型为整数型,则数据字段仅包含键值。如果键值小于八个字节,使用 0xff 填充未使用的部分(右侧)。

      如果键值类型为 BLOB 索引条目,则该字段的八个字节将保存以下数据块信息:

      如果键值类型为字符串或 BLOB 数据块,数据字段的这八个字节将保存该键值的一些附加信息,如下所示:

      • CRC32

      • 数据所有字节的校验和,该字段仅用于字符串和 BLOB 类型条目。

      • 数据大小

      • 实际数据的大小(以字节为单位)。如果键值类型为字符串,此字段也应将零终止符包含在内。此字段仅用于字符串和 BLOB 类型条目。



      • ChunkStart

      • BLOB 第一个数据块的块索引,后续数据块索引依次递增,步长为 1。该字段仅用于 BLOB 索引类型条目。

      • ChunkCount

      • 存储过程中 BLOB 分成的数据块数量。该字段仅用于 BLOB 索引类型条目。

      • 块大小

      • 整个 BLOB 数据的大小(以字节为单位)。该字段仅用于 BLOB 索引类型条目。




      可变长度值(字符串和 BLOB)写入后续条目,每个条目 32 字节。第一个条目的 span 字段将指明使用了多少条目。

      命名空间

      如上所述,每个键值对属于一个命名空间。命名空间标识符(字符串)也作为键值对的键,存储在索引为 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"+-------------------------------------------+

      Copy to clipboard

      条目哈希列表

      为了减少对 flash 执行的读操作次数,Page 类对象均设有一个列表,包含一对数据:条目索引和条目哈希值。该列表可大大提高检索速度,而无需迭代所有条目并逐个从 flash 中读取。Page::findItem 首先从哈希列表中检索条目哈希值,如果条目存在,则在页面内给出条目索引。由于哈希冲突,在哈希列表中检索条目哈希值可能会得到不同的条目,对 flash 中条目再次迭代可解决这一冲突。

      哈希列表中每个节点均包含一个 24 位哈希值和 8 位条目索引。哈希值根据条目命名空间、键名和块索引由 CRC32 计算所得,计算结果保留 24 位。为减少将 32 位条目存储在链表中的开销,链表采用了数组的双向链表。每个数组占用 128 个字节,包含 29 个条目、两个链表指针和一个 32 位计数字段。因此,每页额外需要的 RAM 最少为 128 字节,最多为 640 字节。

      NVS 加密

      NVS 分区内存储的数据可使用 AES-XTS 进行加密,类似于 IEEE P1619 磁盘加密标准中提到的加密方式。为了实现加密,每个条目被均视为一个扇区,并将条目相对地址(相对于分区开头)传递给加密算法,用作扇区号。NVS 加密所需的密钥存储于其他分区,并进行了 flash 加密。因此,在使用 NVS 加密前应先启用 flash 加密

      NVS 密钥分区

      应用程序如果想使用 NVS 加密,则需要编译进一个类型为 data,子类型为 key 的密钥分区。该分区应标记为已加密,且最小为 4096 字节,具体结构见下表。如需了解更多详细信息,请参考 分区表

      +-----------+--------------+-------------+----+|              XTS encryption key(32)         |+---------------------------------------------+|              XTS tweak key (32)             |+---------------------------------------------+|                  CRC32(4)                   |+---------------------------------------------+

      Copy to clipboard

      使用 NVS 分区生成程序生成上述分区表,并烧录至设备。由于分区已标记为已加密,而且启用了 flash 加密,引导程序在首次启动时将使用 flash 加密对密钥分区进行加密。您也可以在设备启动后调用 nvs_flash.h 提供的 nvs_flash_generate_keys API 生成加密密钥,然后再将密钥以加密形式写入密钥分区。

      应用程序可以使用不同的密钥对不同的 NVS 分区进行加密,这样就会需要多个加密密钥分区。应用程序应为加解密操作提供正确的密钥或密钥分区。

      加密读取/写入

      nvs_get_* 和 nvs_set_* 等 NVS API 函数同样可以对 NVS 加密分区执行读写操作。但用于初始化 NVS 非加密分区和加密分区的 API 则有所不同:初始化 NVS 非加密分区可以使用 nvs_flash_init 和 nvs_flash_init_partition,但初始化 NVS 加密分区则需调用 nvs_flash_secure_init 和 nvs_flash_secure_init_partition。上述 API 函数所需的 nvs_sec_cfg_t 结构可使用 nvs_flash_generate_keys 或者 nvs_flash_read_security_cfg 进行填充。

      应用程序如需在加密状态下执行 NVS 读写操作,应遵循以下步骤:

      1. 使用 esp_partition_find* API 查找密钥分区和 NVS 数据分区;

      2. 使用 nvs_flash_read_security_cfg 或 nvs_flash_generate_keys API 填充 nvs_sec_cfg_t 结构;

      3. 使用 nvs_flash_secure_init 或 nvs_flash_secure_init_partition API 初始化 NVS flash 分区;

      4. 使用 nvs_open 或 nvs_open_from_part API 打开命名空间;

      5. 使用 nvs_get_* 或 nvs_set_* API 执行 NVS 读取/写入操作;

      6. 使用 nvs_flash_deinit API 释放已初始化的 NVS 分区。

      NVS 迭代器

      迭代器允许根据指定的分区名称、命名空间和数据类型轮询 NVS 中存储的键值对。

      您可以使用以下函数,执行相关操作:

      • nvs_entry_find:返回一个不透明句柄,用于后续调用 nvs_entry_next 和 nvs_entry_info 函数;

      • nvs_entry_next:返回指向下一个键值对的迭代器;

      • nvs_entry_info:返回每个键值对的信息。

      如果未找到符合标准的键值对,nvs_entry_find 和 nvs_entry_next 将返回 NULL,此时不必释放迭代器。若不再需要迭代器,可使用 nvs_release_iterator 释放迭代器。

      NVS 分区生成程序

      NVS 分区生成程序帮助生成 NVS 分区二进制文件,可使用烧录程序将二进制文件单独烧录至特定分区。烧录至分区上的键值对由 CSV 文件提供,详情请参考 NVS 分区生成程序


      上一篇:VC++的Unicode编程
      下一篇:ESP8266开发技术全系列笔记