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 | <- 物理扇区+----------+ +----------+ +----------+ +----------+
页面结构
当前,我们假设 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) |+------------------------------------------------------------------+
头部和条目状态位图写入 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) +----------+---------+-----------+
条目结构中各个字段含义如下:
命名空间 (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"+-------------------------------------------+
条目哈希列表
为了减少对 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) |+---------------------------------------------+
使用 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 读写操作,应遵循以下步骤:
使用
esp_partition_find*
API 查找密钥分区和 NVS 数据分区;使用
nvs_flash_read_security_cfg
或nvs_flash_generate_keys
API 填充nvs_sec_cfg_t
结构;使用
nvs_flash_secure_init
或nvs_flash_secure_init_partition
API 初始化 NVS flash 分区;使用
nvs_open
或nvs_open_from_part
API 打开命名空间;使用
nvs_get_*
或nvs_set_*
API 执行 NVS 读取/写入操作;使用
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开发技术全系列笔记