S2-06 和 S2-07 暂时先不发,课上没给同学们将,分别是 DMA 和 USB 章节,作为专项讲

存储

ESP32 系列芯片中,不同型号的芯片所携带的 ROM、SRAM、RCT SRAM、PSRAM 以及 Flash大小不同,他们的作用如下:
SRAM:可以理解成内存 ,Static Random Access Memory,即静态随机存储器,是 ESP32 中用于存储程序代码和数据的内存。在 ESP32 中,SRAM 的容量是 520 KB,这些内存可以被分配为多个不同的部分,用于存储程序堆栈、全局变量、动态分配的内存及其他数据。在编写 ESP32 应用程序时,需要合理安排 SRAM 的使用,以满足系统的内存需求。

ROM:可以理解成系统盘 ,Read-Only Memory, ESP32 中的 ROM 其实是指闪存存储器,用于存储固件程序和其他静态资源。ESP32 中的闪存大小为最大 16 MB,其中一部分用于存储 ESP-IDF 固件程序,另一部分则可以自由配置为存储其他用户的可执行程序或数据。在编写 ESP32 应用程序时,需要注意合理利用闪存资源,以满足系统的存储需求。

RTC SRAM:可以理解成 CMOS RAM(就是我们一直叫他 BIOS 的东西,但其实 BIOS 不是这货) Real-Time Clock Static Random Access Memory即实时时钟静态随机存储器,是 ESP32 中用于存储实时时钟和其他非易失性数据的内存。RTC SRAM 的容量为 8 KB,它通常用于存储一些需要在断电后仍能保持的系统状态信息,例如最近的系统时间、定时器状态、闹钟设置等。在 ESP32 应用程序中,可以通过使用 ESP-IDF 提供的 RTC 真实时钟驱动程序来访问 RTC SRAM 中的数据。

PSRAM:可以理解成扩展内存 ,Pseudo Static Random Access Memory,中文意思为伪静态随机存取存储器,是一种特殊的内存类型。与 SRAM 不同的是,PSRAM 的单元是由一个静态 RAM 基本单元和一个刷新电路组成的。在使用 PSRAM 时,它的表现与静态 RAM 类似。但是,由于 PSRAM 存在刷新电路,在访问 PSRAM 单元之前会自动执行刷新操作,以保证存储的数据不会丢失。

Flash:闪存,可以理解成扩展硬盘, Flash 指的是建立在闪存存储器之上的文件系统,用于存储应用程序和其他数据。闪存存储器是一种非易失性的存储器,类似于硬盘驱动器,但它不需要机械运动来访问数据。ESP32 的闪存大小为最大支持到了 128 MB(目前),其中一部分用于存储 ESP-IDF 固件程序,另一部分则可以自由配置为存储其他用户的可执行程序或数据。闪存中存储的数据可以被读取和编程,但其写入速度通常比内存慢得多,因此,闪存适合于存储静态数据,如程序代码和配置数据。

ESP32-S3中,我们使用的是 ESP32-S3R8 这款芯片,配置如下:
SRAM: 512KB
ROM: 384KB
RTC SRAM: 16KB
PSRAM: 8MB(R8指的就是 8M 的 PSRAM,如果没有这个标注,则表示没有 PSRAM)
Flash: 0

在 ESP32 中,ROM 包含了一些特定的固件程序和 bootloader。其中,固件程序包括 ESP-IDF 系统库和其他预编译的二进制文件,用于控制不同的外设和实现系统功能。而 bootloader 则是一个小型程序,用于引导操作系统加载和运行。

具体来说,在 ESP32 中的 ROM 中存放了以下的信息:

  • 芯片初始化代码:用于初始化内部与外部外设的连接和配置。
  • Bootloader:用于检测、解析和引导可执行程序。ESP32 中的 bootloader 具有多种启动模式,可以从不同的介质(如闪存、UART 或 SPIFFS 文件系统)加载 boot 镜像并引导应用程序。
  • 必要的固件程序:如 Wi-Fi 栈、TCP/IP 协议栈、NVS 存储服务等。
  • 预编译的二进制文件:包括系统库、驱动程序和其他依赖项二进制文件。

这些信息都存放在 ESP32 的 ROM 基础上,并随着芯片厂商发布不同版本的 ESP32,可能会有所变化。

ROM 存放的内容是由芯片厂商预先编写的,通常不建议用户对其进行修改。这些固件程序和 bootloader 是硬件系统的基础,并直接影响设备的性能和稳定性。因此,重写 ROM 中的代码可能会导致设备无法正常运行或产生安全性问题。但有些固件程序和驱动程序可以通过使用 ESP-IDF 的组件方式进行替换或升级。用户可以使用 ESP-IDF 自带的工具链来编译和构建自己的应用程序,然后将其烧录到闪存中。如果需要替换某些内置的组件,可以在编译时选择合适的配置参数来实现。但这种方式的修改是基于用户自己的应用程序和组件,不涉及到 ROM 中的代码。

SPI Flash

今天的重点是 Flash 部分,ESP32-S3 系列芯片中,一般是不带 Flash 的(代用N标号的除外),而内置的 ROM 非常小不说,还不让我们用,所以如果想使用这款芯片,就必须外扩 Flash,在我们使用的 WROOM-2 这款模块中,外扩了一个 32M 的 OSPI 八线 Flash,与SPI1 相连接,型号是 XM25QU256C ,这块 Flash 就是我们日常编程用的 Flash。

分区表

在 ESP32 中,为了将 Flash 的功能发挥到极限,乐鑫采用了分区表(Partition)的方式将 Flash 按照功能不同分类多个区,每个区域存储着不同类型的数据。ESP32 的分区方案是为方便固件开发、升级和管理而设计的,可以根据需要进行自定义配置。
ESP32中 Flash分区有以下几部分:

  • bootloader: 引导程序区,用于启动 ESP32 系统,实现初始化和验证等功能。
  • partition_table: 分区表区,用于描述 Flash 存储器中各个分区的位置、大小和类型等信息。
  • app: 应用程序区,用于存储用户的主程序和相关数据,可以进行 OTA(Over-the-Air)升级。
  • app_data: 应用程序数据区,用于存储应用程序的数据文件,如配置文件、日志文件等。
  • nvs: 非易失性存储区,用于存储应用程序的关键参数和配置信息,如 Wi-Fi 密码、设备 ID 等。
  • phy_init: 物理层初始化数据区,用于存储 ESP32 芯片的物理层参数和校准数据,保证系统的正常工作。

为了描述这些分区,我们在 Flash 默认偏移地址 0x8000 的地方存放量一张分表,分区表的长度为 0xC00 字节(最多可以保存 95 条分区表条目)。分区表数据后还保存着该表的 MD5 校验和,用于验证分区表的完整性。此外,如果芯片使能了 安全启动 功能,则该分区表后还会保存签名信息。

分区表中的每个条目都包括以下几个部分:Name(标签)、Type(app、data 等)、SubType 以及在 flash 中的偏移量(分区的加载地址)。

在使用分区表时,最简单的方法就是打开项目配置菜单(idf.py menuconfig),并在 CONFIG_PARTITION_TABLE_TYPE 下选择一个预定义的分区表:

  • Single factory app, no OTA : 包含一个 1M 的应用程序空间、一个 24K 的 NVS 空间,以及4 K的 硬件初始化信息表,但没有 OTA 区域
  • Single factory app (large), no OTA: 和上一个相同,应用程序空间大小为 1.5M 左右
  • Factory app, two OTA definitions: 和第一个相同,但多出两个用于空中升级的 OTA 空间,大小都为 1M
  • Custom partition table CSV: 用户自定义分区表

前面三项都是系统预设的,一般做测试的时候可以使用正式环境下还是需要使用自定义的分区表,已达到 Flash 的利用最大化。在上面所有的分区表设置中,出厂应用程序(factory)均将被烧录至 flash 的 0x10000 偏移地址处,为了避免不必要的麻烦,应用程序的位置尽量不要修改,如果程序过大可以调整该分区的大小。
如果想知道当前使用的分区表是怎么划分的,可以在命令行界面通过 idf.py partition_table 命令查看当前使用的分区表信息。

如果在 menuconfig 中选择了 “Custom partition table CSV”,则还需要输入该分区表的 CSV 文件在项目中的路径。CSV 文件可以根据需要,描述任意数量的分区信息,只需要在目录下创建一个 .cvs 后缀的文件即可,但文件的撰写需要遵循一定格式

# Notes: the offset of the partition table itself is set in
# $IDF_PATH/components/partition_table/Kconfig.projbuild.
# Name,   Type, SubType, Offset,  Size, Flags
nvs,      data, nvs,     0x9000,  0x6000,
phy_init, data, phy,     0xf000,  0x1000,
factory,  app,  factory, 0x10000, 0x1F0000,
storage1, data, fat,	 0x200000,0x40000,
storage2, data, fat,	 0x240000,0x50000,
eeprom,	  data, nvs,	 0x290000,0x10000,
vfs,      data, fat,     0x2A0000, 0x1D60000,

前三行带有 # 的地方可以不用管,他们属于注释,从第三行开始时正式的分区表内容。

  • 字段之间的空格会被忽略,任何以 # 开头的行(注释)也会被忽略。
  • CSV 文件中的每个非注释行均为一个分区定义。
  • 每个分区的 Offset 字段可以为空,gen_esp32part.py 工具会从分区表位置的后面开始自动计算并填充该分区的偏移地址,同时确保每个分区的偏移地址正确对齐。
1. Name 字段

Name 字段可以是任何有意义的名称,但不能超过 16 个字符(之后的内容将被截断)。该字段对 ESP32-S3 并不是特别重要。

2. Type 字段

Type 字段可以指定为 app (0x00) 或者 data (0x01),也可以直接使用数字 0-254(或者十六进制 0x00-0xFE)。注意,0x00-0x3F 不得使用(预留给 esp-idf 的核心功能)。
如果您的应用程序需要以 ESP-IDF 尚未支持的格式存储数据,请在 0x40-0xFE 内添加一个自定义分区类型。

系统默认的是 app(0x00)和 data (0x01)两个,分别表示应用程序和数据。

3. SubType 字段

SubType 字段长度为 8 bit,内容与具体分区 Type 有关。目前,esp-idf 仅仅规定了 “app” 和 “data” 两种分区类型的子类型含义。
系统定义的 esp_partition_subtype_t 中包含以下几个类型:

  • 当 Type 定义为 app 时,SubType 字段可以指定为 factory (0x00)、 ota_0 (0x10) … ota_15 (0x1F) 或者 test (0x20)。
  • factory (0x00) 是默认的 app 分区。启动加载器将默认加载该应用程序。但如果存在类型为 data/ota 分区,则启动加载器将加载 data/ota 分区中的数据,进而判断启动哪个 OTA 镜像文件。
  • OTA 升级永远都不会更新 factory 分区中的内容。
  • 如果您希望在 OTA 项目中预留更多 flash,可以删除 factory 分区,转而使用 ota_0 分区。
  • ota_0 (0x10) … ota_15 (0x1F) 为 OTA 应用程序分区,启动加载器将根据 OTA 数据分区中的数据来决定加载哪个 OTA 应用程序分区中的程序。在使用 OTA 功能时,应用程序应至少拥有 2 个 OTA 应用程序分区(ota_0 和 ota_1)。更多详细信息,请参考 OTA 文档 。
  • test (0x20) 为预留的子类型,用于工厂测试流程。如果没有其他有效 app 分区,test 将作为备选启动分区使用。也可以配置启动加载器在每次启动时读取 GPIO,如果 GPIO 被拉低则启动该分区。详细信息请查阅 从测试固件启动。
  • 当 Type 定义为 data 时,SubType 字段可以指定为 ota (0x00)、phy (0x01)、nvs (0x02)、nvs_keys (0x04) 或者其他组件特定的子类型(请参考 子类型枚举).
  • ota (0x00) 即 OTA 数据分区 ,用于存储当前所选的 OTA 应用程序的信息。这个分区的大小需要设定为 0x2000。更多详细信息,请参考 OTA 文档 。
  • phy (0x01) 分区用于存放 PHY 初始化数据,从而保证可以为每个设备单独配置 PHY,而非必须采用固件中的统一 PHY 初始化数据。
  • 默认配置下,phy 分区并不启用,而是直接将 phy 初始化数据编译至应用程序中,从而节省分区表空间(直接将此分区删掉)。
  • 如果需要从此分区加载 phy 初始化数据,请打开项目配置菜单(idf.py menuconfig),并且使能 CONFIG_ESP_PHY_INIT_DATA_IN_PARTITION 选项。此时,您还需要手动将 phy 初始化数据烧至设备 flash(esp-idf 编译系统并不会自动完成该操作)。
  • nvs (0x02) 是专门给 非易失性存储 (NVS) API 使用的分区。
  • 用于存储每台设备的 PHY 校准数据(注意,并不是 PHY 初始化数据)。
  • 用于存储 Wi-Fi 数据(如果使用了 esp_wifi_set_storage(WIFI_STORAGE_FLASH) 初始化函数)。
  • NVS API 还可以用于其他应用程序数据。
  • 强烈建议您应为 NVS 分区分配至少 0x3000 字节空间。
  • 如果使用 NVS API 存储大量数据,请增加 NVS 分区的大小(默认是 0x6000 字节)。
  • nvs_keys (0x04) 是 NVS 秘钥分区。详细信息,请参考 非易失性存储 (NVS) API 文档。
  • 用于存储加密密钥(如果启用了 NVS 加密 功能)。
  • 此分区应至少设定为 4096 字节。
  • ESP-IDF 还支持其它预定义的子类型用于数据存储,包括 FAT 文件系统 (ESP_PARTITION_SUBTYPE_DATA_FAT), SPIFFS (ESP_PARTITION_SUBTYPE_DATA_SPIFFS) 等。
4. Offset 和 Size 字段

分区若偏移地址为空,则会紧跟着前一个分区之后开始;若为首个分区,则将紧跟着分区表开始。
app 分区的偏移地址必须要与 0x10000 (64K) 对齐,如果将偏移字段留空,gen_esp32part.py 工具会自动计算得到一个满足对齐要求的偏移地址。如果 app 分区的偏移地址没有与 0x10000 (64K) 对齐,则该工具会报错。
app 分区的大小和偏移地址可以采用十进制数、以 0x 为前缀的十六进制数,且支持 K 或 M 的倍数单位(分别代表 1024 和 1024 * 1024 字节)。
如果您希望允许分区表中的分区采用任意起始偏移量 (CONFIG_PARTITION_TABLE_OFFSET),请将分区表(CSV 文件)中所有分区的偏移字段都留空。注意,此时,如果您更改了分区表中任意分区的偏移地址,则其他分区的偏移地址也会跟着改变。这种情况下,如果您之前还曾设定某个分区采用固定偏移地址,则可能造成分区表冲突,从而导致报错。

5. Flags 字段

当前仅支持 encrypted 标记。如果 Flags 字段设置为 encrypted,且已启用 Flash 加密 功能,则该分区将会被加密。

app 分区始终会被加密,不管 Flags 字段是否设置。

分区实验

为了满足后续 SPI-Flash 的实验,后续分区采用自定义方式,默认的 nvs、pyh_init 和 factory 分区我们不动,在此基础上再加入两个用于测试的小容量 fat分区,大小分别是 256K(0x40000) 和320K(0x50000),以及用于 NVS 测试的扩大分区,大小为64K(0x10000),然后将剩余部分都分给 用于做文件系统的 VFS分区。
ESP32-S3N32R8V 的 Flash 大小为 32MB,在Flash 0x8000 之前的位置留给 BootLoader(丫大概率是不用的,浪费!),0x8000 到 0x9000 地方存放分区表以及他的验证信息,所以分区表内容从 0x9000 开始。

  1. 第一部分存放 默认的 NVS 分区表,起始地址 0x9000 ,大小 24K(0x6000)
  2. 第二部分存放 默认的 硬件初始化信息,起始地址 0xf000,大小 4K(0x1000)
  3. 第三部分存放 应用程序代码,起始地址 0x10000,大小 2M左右(0x1F0000)
  4. 第四部分存放 自定义的分区,名称 storage1,类型为 data,子类型 fat,起始地址0x200000,大小256K(0x40000)
  5. 第五部分存放 另一个自定义的分区 名称 storage2,类型为 data,子类型 fat,起始地址0x240000,大小256K(0x50000)
  6. 第六部分存放 用于测试自定义的 NVS 分区,分区名称 eeprom ,类型 data,子类型 nvs,起始地址0x290000,大小64K(0x10000)
  7. 最后将剩余的所有空间都留给文件存储用,分区名称 vfs,类型 data,子类型 fat,起始地址0x2A0000,大小 30M左右(0x1D60000)

因为 Flash 大小是32MB,换算成字节是 32 * 1024 * 1024 = 33554432,换算成16进制是 0x2000000。

最后一部分起始地址是 0x2A0000,0x2000000 - 0x2A0000 = 0x1D60000,这样我们Flash的所有空间就都有效的利用起来。

esp32 获取打印使用内存 esp32可用内存_esp32 获取打印使用内存

加载分区

如果想在代码中使用分区,则首先要通过 esp_partition_find() 或者 esp_partition_find_first() 等函数获取分区表的句柄,然后通过句柄对不同类型分区进行不同操作。
esp_partition_find_first() 函数用于找到符合类型的第一个分区,函数原型如下:

const esp_partition_t* esp_partition_find_first(esp_partition_type_t type, esp_partition_subtype_t subtype, const char* label);

参数

描述

type

要查找的分区类型,枚举值,默认类型值包含 ESP_PARTITION_TYPE_APP 和 ESP_PARTITION_TYPE_DATA,或者查询所有类型使用的 ESP_PARTITION_TYPE_ANY, 也可以是用户自定义的值

subtype

要查找的子类型,取决于 type 的取值,该值可以参考 esp_partition_subtype_t 的枚举,或者是 ESP_PARTITION_SUBTYPE_ANY 查看所有分区

lable

要查找的分区名称。该参数是一个字符串,表示分区的名称,可以为空,表示不指定名称限制。

该函数会返回符合条件的第一个分区的句柄,类型为 esp_partition_t,其原型如下:

typedef struct {
    void* flash_chip;            /*!< SPI flash chip on which the partition resides */
    esp_partition_type_t type;          /*!< partition type (app/data) */
    esp_partition_subtype_t subtype;    /*!< partition subtype */
    uint32_t address;                   /*!< starting address of the partition in flash */
    uint32_t size;                      /*!< size of the partition, in bytes */
    char label[17];                     /*!< partition label, zero-terminated ASCII string */
    bool encrypted;                     /*!< flag is set to true if partition is encrypted */
} esp_partition_t;

元素

描述

flash_chip

指向 SPI Flash 芯片的指针,表示该分区所在的 Flash 芯片。

type

枚举类型,表示分区的类型。

subtype

枚举类型,表示分区的子类型,具体取值范围根据 esp_partition_type_t 类型而定。

address

分区在 Flash 存储器中的起始地址。

size

分区的大小,以字节为单位。

label

分区的名称,是一个 16 字节的 ASCII 字符串,末尾被 ‘\0’ 所结束。

encrypted

布尔类型,表示该分区是否被加密。

该句柄可以通过提取 flash_chip 执行相关的 SPIFFS 文件系统的相关操作。

该函数的应用:

const esp_partition_t *part = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, "storage1");

ESP_LOGI(TAG, "\t分区找到,name = %s, type = %s , subtype = %s , address = 0x%X , size = 0x%X(%d k) , 加密[%s]",
                 part->label, get_type_str(part->type), get_subtype_str(part->subtype), part->address, part->size, part->size, part->encrypted ? "YES" : "NO");
    }

或者,可以通过 esp_partition_find() 函数获得一个分区表的迭代器,遍历所有分区,该函数的使用方式和 esp_partition_find_first 相同,但返回值是 esp_partition_iterator_t 类型的迭代器,配合 esp_partition_next()esp_partition_get() 函数迭代处所有符合要求的分区,代码如下:

static void list_partition(esp_partition_type_t type, esp_partition_subtype_t subtype, const char *name)
{
    ESP_LOGI(TAG, "尝试列出分区,type = %s , SubType = %s , name = %s", get_type_str(type), get_subtype_str(subtype), name == NULL ? "任意" : name);
    // 通过类型找到列表的迭代器
    esp_partition_iterator_t it = esp_partition_find(type, subtype, name);
    // 找出下一条内容
    printf("============================================================================================================================\n");
    printf("%-16s %-35s %-35s %-10s   %-10s  %-10s\n", "Name", "Type", "SubType", "Address", "Size", "Encrypted");
    printf("----------------------------------------------------------------------------------------------------------------------------\n");
    for (; it != NULL; it = esp_partition_next(it))
    {
        // 从迭代器中获得分区表信息
        const esp_partition_t *part = esp_partition_get(it);
        printf("%-16s %-35s %-35s 0x%-10X 0x%-10X %-10s\n", part->label, get_type_str(part->type), get_subtype_str(part->subtype), part->address, part->size, part->encrypted ? "YES" : "NO");
    }
    printf("============================================================================================================================\n");
}

通过测试程序提供的代码,打印出所有分区信息:

esp32 获取打印使用内存 esp32可用内存_ESP-IDF_02

分区的操作

通过 esp_partition_find() 或者 esp_partition_find_first() 获得分区表句柄信息后,便可以通过这个句柄对分区进行读写操作了,需要注意的是,分区在写入内容之前,必须要先进行擦除,否则无法写入正确的信息,这是Flash的存储机制决定的,Flash擦除往往是以块为单位的,我们所使用的Flash 块大小是4K(1096),擦除的时候无论需要擦除多大分区,大小应该是4K的倍数,而且地址也必须是4K对齐的,否则就会报错,但 esp_partition_erase_range 函数内部已经贴心的为我们“解除”了这个限制,但其实内部依然是如此运行的。

在 Flash 存储器中,每个存储单元(cell)都是由浮动栅 EEPROM(Floating Gate EEPROM,简称 FEEPROM)组成的。FEEPROM 通常是由两个极板分隔开的浮动栅,当一个电子进入浮动栅时,它就会改变存储单元的电荷状态,从而改变存储单元的值。因此,Flash 存储器的读写操作都是通过控制电子的进出来实现的。

然而,这种双极板结构在 FEEPROM 上也产生了一个问题,即当一个存储单元中已经存储了电子时,如果要再次向其中写入一个电子,则需要将原来存储的电子取出来,这个过程被称为“擦除”。在 Flash 存储器中,擦除操作是以块为单位进行的,每个块通常包含多个存储单元。

具体来说,在 Flash 中进行写操作时,只能将原来为 1 的存储单元写为 0,不能将原来为 0 的存储单元写为 1。因此,在进行写操作之前必须先将整个块擦除,将所有存储单元都置为 1,然后才能进行写操作。

擦除操作是一个相对耗时的过程,取决于 Flash 存储器的型号和容量,以及块的大小。相比之下,写操作则会更快一些。因此,为了保证数据的正确性和可靠性,Flash 写入内容之前必须先进行擦除操作。
分区操作流程如下:


esp32 获取打印使用内存 esp32可用内存_ESP-IDF_03


擦除使用的函数是 esp_partition_erase_range 该函数共有三个参数,依次是 分区表句柄,起始位置(相对于该分区,需要擦除的起始位置),擦除大小(大小不能超过分区大小),擦除指令是比较耗时的,擦除面积越大耗时越长,为了提高使用效率,执行擦除指令的时候注意以下几点:

  1. 擦除起始地址一定要和 4K 对齐,最好在分区的时候最做好对齐,否则会多擦除和部分恢复上一块分区的数据;
  2. 擦除区域尽可能的小,一般用多少擦除多少,计量不要整个区域擦除;
  3. 擦除区域尽可能的进行 4K 对齐,否则会多擦除和部分恢复下段分区的数据。

再次提醒:擦除不只是简单的内容清零,还是在写入内容之前必须操作的一步,如果不擦除直接写入,内容必定错误!

分区内容写入函数 esp_partition_write() 原型如下:

esp_err_t esp_partition_write(const esp_partition_t* partition, size_t dst_offset, const void* src, size_t size);

参数

描述

partition

表示要写入的分区,类型为 const esp_partition_t * 。要注意的是,如果该分区被加密,则需要在调用此函数之前通过 esp_secure_bootloader_load_and_verify_partition() 的方式加载分区并进行验证。

dst_offset

表示目标地址偏移量,类型为 size_t。具体来说,它表示将数据写入分区时,相对于分区起始地址的偏移量,单位为字节。

src

表示要写入的数据源缓冲区指针,类型为 const void * 。

size

表示要写入的数据长度,单位为字节,类型为 size_t。

如果写入操作成功,则返回 ESP_OK,否则返回相应的错误码。例如,ESP_ERR_INVALID_ARG 表示参数错误,ESP_ERR_INVALID_STATE 表示 Flash 状态错误等等。

与之对应的是读取函数 esp_partition_write() ,该函数原型如下:

esp_err_t esp_partition_read(const esp_partition_t* partition, size_t src_offset, void* dst, size_t size);

参数

描述

partition

表示要读取的分区,类型为 const esp_partition_t * 。

src_offset

表示源地址偏移量,类型为 size_t。具体来说,它表示从分区起始地址开始,读取数据的偏移量,单位为字节。

dst

表示目标数据缓冲区指针,类型为 void * 。调用该函数时,将读取的数据存储到该缓冲区中。

size

表示要读取的数据长度,单位为字节,类型为 size_t。

该函数会根据给定的分区和偏移量,计算出实际的读取地址,并将指定长度的数据从源地址中读取到目标缓冲区中。如果读取成功,则返回 ESP_OK,否则返回相应的错误码,例如 ESP_ERR_INVALID_ARG 表示参数无效,ESP_ERR_INVALID_STATE 表示 Flash 状态无效等等。

以下例程是对分区进行重复读写操作的例子,例子中对分区进行的整体擦除,这是不提倡的

const esp_partition_t *partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "storage1");
static const char store_data[] = "这是我要写入的一些二进制数据";
static char read_data[sizeof(store_data)];
// 擦除整个分区的内容
ESP_ERROR_CHECK(esp_partition_erase_range(partition, 0, partition->size));
// 写入数据
ESP_ERROR_CHECK(esp_partition_write(partition, 0, store_data, sizeof(store_data)));
ESP_LOGI(TAG, "写入数据完毕,大小:%d,内容 : %s", sizeof(store_data), store_data);
// 读取数据
ESP_ERROR_CHECK(esp_partition_read(partition, 0, read_data, sizeof(read_data)));
// 验证数据是否正确
if (memcmp(store_data, read_data, sizeof(read_data)) == 0)
{
    ESP_LOGI(TAG, "读取数据完毕,大小:%d,内容 : %s", sizeof(read_data), read_data);
}
else
{
    ESP_LOGE(TAG, "读取数据失败");
}
// 再次擦除分区内容
ESP_ERROR_CHECK(esp_partition_erase_range(partition, 0, partition->size));
// 重新读取
memset(read_data, 0x00, sizeof(read_data));
ESP_ERROR_CHECK(esp_partition_read(partition, 0, read_data, sizeof(read_data)));
if (memcmp(store_data, read_data, sizeof(read_data)) != 0)
{
    ESP_LOGI(TAG, "未读取到数据");
}
else
{
    ESP_LOGE(TAG, "获得了同样的数据,这明显是错的");
}

分区的内存映射

在大多数的操作中,我们对分区内容都是读得多,写的少,并且在存储数据的时候都是遵循一定的格式,按照做好的数据结构将数据分区域存储,这时候在使用 esp_partition_read() 对数据进行读取,就会让程序逻辑变得啰嗦,不仅是增加了代码量,还会对 CPU 和 内存造成极大的消耗,因为在使用 esp_partition_read 函数的时候,起始内部交互过程中是要先找到扇区、在找到分区、然后把整个分区读出来,再读取分区中需要的一部分数据,如果正好我们的数据存放在 0分区尾和1分区头,但即便是数据之后 2 字节(当然 大部分 Flash 已经帮我们做了很好的数据存储优化方案,这种情况很少发生),Flash 的读取程序也需要把两个分区(8K)的内容都读出来,然后在分拣我们所需要的内容。
所以,为了方便 Flash 内存的存储,人们发明了内存映射技术,就是将 Flash 中的内容直接对应到一个虚拟地址上,然后就可以通过像访问内存一样,通过地址直接访问 Flash 的数据了,这就是虚拟内存映射机制。但其实其中的逻辑依然没有逃离 Flash 的硬件限制,在物理层面上,其仍然是通过原始的方式对应的,好消息是,这部分代码不需要我们操作,也不需要 CPU 处理, 一般 Flash 驱动会帮我们搞定的。

可以通过 esp_partition_mmap() 映射到虚拟内存中,该函数原型如下:

esp_err_t esp_partition_mmap(const esp_partition_t* partition, size_t offset, size_t size, spi_flash_mmap_memory_t memory, const void** out_ptr, spi_flash_mmap_handle_t* out_handle)

参数

描述

partition

指定分区的指针,表示要映射的分区。

offset

映射的偏移量,表示从分区起始地址开始的偏移量,单位为字节。

size

映射的大小,表示要映射的字节数。

memory

指定存储器类型的枚举变量,可选值为 SPI_FLASH_MMAP_DATA 或 SPI_FLASH_MMAP_INST,分别表示数据存储器和指令存储器。

out_ptr

输出参数,指向映射后的内存指针的指针。调用函数后,会将指向映射后内存区域的指针存入该参数所指向的指针变量中。

out_handle

输出参数,指向映射句柄的指针。调用函数后,会将映射句柄存入该参数所指向的指针变量中。

在调用 esp_partition_mmap() 函数时,需要注意以下几点:

  • 被映射的分区必须已经被挂载,否则会返回错误。
  • 映射后的内存指针和句柄需要手动释放,以避免内存泄漏。
  • 可以通过映射不同的存储器类型来满足不同的应用需求。
  • 当映射的大小大于分区剩余可用空间时,函数会返回错误。

在 esp_partition_mmap() 函数中,spi_flash_mmap_memory_t memory 参数用于指定要将分区映射到哪种类型的存储器中,可选值包括 SPI_FLASH_MMAP_DATA 和 SPI_FLASH_MMAP_INST 两种。具体含义如下:

  • SPI_FLASH_MMAP_DATA:表示将分区映射到数据存储器中。这种方式可以方便地读写分区中的数据,但是无法直接执行其中的代码。
  • SPI_FLASH_MMAP_INST:表示将分区映射到指令存储器中。这种方式可以直接执行分区中的代码,但是无法随机读写其中的数据。

虚拟内存使用完毕后一定要记得使用 spi_flash_munmap() 回收。

测试代码:

const esp_partition_t *partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "storage2");
static const char store_data[] = "这里是要做内存映射的区域,首先写入一些数据";
// 擦除整个分区的内容
ESP_ERROR_CHECK(esp_partition_erase_range(partition, 0, partition->size));
// 写入数据
ESP_ERROR_CHECK(esp_partition_write(partition, 0, store_data, sizeof(store_data)));
ESP_LOGI(TAG, "写入数据完毕,大小:%d,内容 : %s", sizeof(store_data), store_data);
// 映射分区
const void *map_ptr;
spi_flash_mmap_handle_t map_handle;
ESP_ERROR_CHECK(esp_partition_mmap(partition, 0, partition->size, SPI_FLASH_MMAP_DATA, &map_ptr, &map_handle));
ESP_LOGI(TAG, "分区映射成功,地址 : 0x%p", map_ptr);
// 从映射中读取数据
char read_data[sizeof(store_data)];
memcpy(read_data, map_ptr, sizeof(read_data));
ESP_LOGI(TAG, "从映射中读取数据,大小:%d,内容 : %s", sizeof(read_data), read_data);
// 释放映射
spi_flash_munmap(map_handle);

SPI Flash 直接操作

以上所有对分区进行操作的函数其实是直接对 SPI Flash操作函数的封装,而这些函数是凌驾于分区之上的,日常开发中除非做 Flash 的底层开发,否则很少用到,因为他会破坏分区。
如果将来想做设备底层开发,这些操作是必须要掌握的。

spi_flash_init() : SPI-Flash 初始化操作,在使用 Flash 之前必须通过该函数对 Flash 进行初始化。
spi_flash_read(): 读取操作,传入的三个参数依次是读取的偏移地址(4字节对齐的地址),数据保存缓冲区,读取的数据长度(4字节对齐)。
spi_flash_write(): 写入操作。
spi_flash_erase_range(): 按字节擦除,传入两个参数依次是,要擦除的起始地址(块对齐,在我们用的Flash中,一个块的大小是4k,也就是4096字节,其他Flash可能有所不同),要擦除的数据长度(必须块对齐,大小是4k的倍数)。
spi_flash_erase_sector(): 扇区(块)擦除,传入要擦除的扇区编号,从0开始,每个扇区(块)大小是4096。

这部分的知识不做过多讲解,后面我们基本用不到,有兴趣的小朋友自己学习
https://docs.espressif.com/projects/esp-idf/zh_CN/v4.4.4/esp32s3/api-reference/storage/spi_flash.html

NVS

ESP32-S3 中的 NVS (Non-Volatile Storage,非易失性存储) 是一种用于保存应用程序数据的非易失性存储器。与其他类型的存储器相比,NVS 具有体积小、速度快、耗电量低等优点,因此在 ESP32-S3 系统中被广泛应用于保存配置信息、设备状态和用户数据等。需要注意的是,由于 NVS 存储器是非易失性存储器,写入操作需要消耗一定的时间,因此在进行数据读写时,应避免大量的连续写入或读取操作,以免影响系统的稳定性和性能。
ESP32 中的 NVS 分区 和 EEPROM 十分接近,所以在 Arduino 中对 EEPROM 的操作就是对NVS的操作。

底层存储

NVS 库通过调用 esp_partition API 使用主 flash 的部分空间,包括 data 类型和 nvs 子类型的所有分区。应用程序可调用 nvs_open() API 选择使用带有 nvs 标签的分区,也可以通过调用 nvs_open_from_partition() API 选择使用指定名称的任意分区。

如果 NVS 分区被截断(例如,更改分区表布局时),则应擦除分区内容。可以使用 ESP-IDF 构建系统中的 idf.py erase-flash 命令擦除 flash 上的所有内容。
NVS 最适合存储一些较小的数据,而非字符串或二进制大对象 (BLOB) 等较大的数据。如需存储较大的 BLOB 或者字符串,请考虑使用基于磨损均衡库的 FAT 文件系统。

键值对

NVS 的操作对象为键值对,其中键是 ASCII 字符串,当前支持的最大键长为 15 个字符。值可以为以下几种类型:

  • 整数型:uint8_t、int8_t、uint16_t、int16_t、uint32_t、int32_t、uint64_t 和 int64_t;
  • 以 0 结尾的字符串;
  • 可变长度的二进制数据 (BLOB)

字符串值当前上限为 4000 字节,其中包括空终止符。BLOB 值上限为 508,000 字节或分区大小的 97.6% 减去 4000 字节,以较低值为准。

键必须唯一。为现有的键写入新的值可能产生如下结果:

  • 如果新旧值数据类型相同,则更新值;
  • 如果新旧值数据类型不同,则返回错误。

读取值时也会执行数据类型检查。如果读取操作的数据类型与该值的数据类型不匹配,则返回错误。

命名空间

为了减少不同组件之间键名的潜在冲突,NVS 将每个键值对分配给一个命名空间。命名空间的命名规则遵循键名的命名规则,例如,最多可占 15 个字符。此外,单个 NVS 分区最多只能容纳 254 个不同的命名空间。命名空间的名称在调用 nvs_open() 或 nvs_open_from_partition 中指定,调用后将返回一个不透明句柄,用于后续调用 nvs_get_* 、nvs_set_* 和 nvs_commit 函数。这样,一个句柄关联一个命名空间,键名便不会与其他命名空间中相同键名冲突。请注意,不同 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 的操作,请参考以下连接:
https://docs.espressif.com/projects/esp-idf/zh_CN/v4.4.4/esp32s3/api-reference/storage/nvs_flash.html?highlight=nvs#c.ESP_ERR_NVS_XTS_DECR_FAILED

初始化

在使用 NVS 之前,必须保证分区已经被初始化完成,需要注意的是,每次少如新的固件时,如果分区表没有变化,分区表中除了需要覆盖的部分会单独烧入(factory部分),其他部分不会受到影响,也就是说,NVS 分区在不修改分区表的情况下是不会被覆盖的。
一旦修改了分区表,第一次尝试初始化的时候,有可能会报告一个 NVS分区截断错误(ESP_ERR_NVS_NO_FREE_PAGES),这是因为分区表中关于 NVS 的分区有变动,或者如果之前刷的是不同版本IDF创造的固件,NVS 分区的版本可能会有些些许不同,这时候会报告版本异常的错误(ESP_ERR_NVS_NEW_VERSION_FOUND), 这时候,我们必须通过擦除整个 NVS分区后再进行重新初始化。
初始化部分代码:

if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND)
{
    // NVS 区域被截断,或者 NVS 分区该版本的固件无法设别,则需要重新初始化 NVS 分区
    ESP_ERROR_CHECK(nvs_flash_erase());
    err = nvs_flash_init();
}

这些代码用来初始化默认的 NVS 分区,也就是第一个,名称为 nvs 的分区。
但在分区表中有可能存在多个 NVS 分区,在测试用例中,我们还未其创造了一个名称为 eeprom 的更大的 NVS 分区,这时候在通过默认初始化方式则无法完成其他分区的初始化操作。所以需要用到 nvs_flash_init_partition()nvs_flash_erase_partition() 对分区进行操作,两个函数都需要传入分区的名称。

err = nvs_flash_init_partition(nvs_name);
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND)
{
    // NVS 区域被截断,或者 NVS 分区该版本的固件无法设别,则需要重新初始化 NVS 分区
    ESP_ERROR_CHECK(nvs_flash_erase_partition(nvs_name));
    err = nvs_flash_init_partition(nvs_name);
}

以上函数需要注意的是,虽然 partition 分区表的 Type 和SubType 对分区本质没有任何改变,但我们使用 VNS 的时候,在分区表中也一定要将 Type 设置为 data, SubType 设置为 nvs。

打开和关闭

一切准备就绪,就可以对 NVS 分区进行操作了,但在读写操作之前,一定先打开命名空间,获得命名空间的句柄后做对应操作。
打开命名空间共有两个函数,分别用来打开默认 NVS 分区的命名空间和打开指定分区的命名空间,其函数原型如下:

esp_err_t nvs_open(const char* name, nvs_open_mode_t open_mode, nvs_handle_t *out_handle);

esp_err_t nvs_open_from_partition(const char *part_name, const char* name, nvs_open_mode_t open_mode, nvs_handle_t *out_handle);

参数

描述

part_name

一个指向类型为 const char 的字符串的指针,用于指定需要打开的NV存储区所在的分区的名称。

name

一个指向类型为 const char 的字符串的指针,用于指定需要打开的NV存储区的名称。

open_mode

类型为 nvs_open_mode_t 的参数,用于指定打开NV存储区的模式。可以是 NVS_READWRITE 或 NVS_READONLY,分别表示可读写和只读两种模式。

out_handle

一个 nvs_handle_t 类型的指针,用于返回打开NV存储区后的句柄。

其中,part_name 和 name 参数都是字符串类型的指针,需要传入有效的字符串。open_mode 参数可以取值为 NVS_READWRITE 或 NVS_READONLY,分别表示可读写和只读两种模式。out_handle 参数是一个指向 nvs_handle_t 类型的指针,用于返回打开NV存储区后的句柄。

使用该函数需要注意以下几点:

  • 在使用该函数之前,需要先确保目标分区已经正确地进行了初始化。
  • 在同一时刻,同一个分区同一个名称的NV存储区只能存在一个实例。
  • 如果需要在代码中多次使用同一个NV存储区,可以考虑将句柄缓存在全局变量或者静态变量中,以便更方便地进行操作和维护。

该函数返回值含义:

  • ESP_OK,表示存储句柄成功打开。
  • ESP_FAIL,表示出现内部错误,可能是由于 NVS 分区损坏导致的(只有在禁用了 NVS 断言检查时才会返回此错误)。
  • ESP_ERR_NVS_NOT_INITIALIZED,表示存储驱动未正确初始化。
  • ESP_ERR_NVS_PART_NOT_FOUND,表示未找到名为 “nvs” 的分区。
  • ESP_ERR_NVS_NOT_FOUND,表示命名空间尚不存在,且模式为 NVS_READONLY。
  • ESP_ERR_NVS_INVALID_NAME,表示命名空间名称不符合约束条件。
  • ESP_ERR_NO_MEM,表示无法为内部结构分配内存。
  • ESP_ERR_NVS_NOT_ENOUGH_SPACE,表示没有足够的空间用于新条目或存在过多不同的命名空间(最大允许不同命名空间数:254)。
  • 其他原始存储驱动程序的错误代码。

这些返回值被定义在 nvs.h 文件中。

该函数最后一个参数 out_handle 是用于返回操作句柄的,之后所有的读写等操作都是依照该句柄进行的,但在使用完命名空间后,需要对命名空间进行关闭,关闭函数是 nvs_close() ,传入句柄即可。

读写操作

NVS 中数据类型支持 uint8_t、int8_t、uint16_t、int16_t、uint32_t、int32_t、uint64_t 、 int64_t 以及字符串和二进制数据,所以对应的操作函数有:

  1. 读取数据:
esp_err_t nvs_get_i8(nvs_handle_t handle, const char *key, int8_t *out_value);
esp_err_t nvs_get_u8(nvs_handle_t handle, const char *key, uint8_t *out_value);
esp_err_t nvs_get_i16(nvs_handle_t handle, const char *key, int16_t *out_value);
esp_err_t nvs_get_u16(nvs_handle_t handle, const char *key, uint16_t *out_value);
esp_err_t nvs_get_i32(nvs_handle_t handle, const char *key, int32_t *out_value);
esp_err_t nvs_get_u32(nvs_handle_t handle, const char *key, uint32_t *out_value);
esp_err_t nvs_get_i64(nvs_handle_t handle, const char *key, int64_t *out_value);
esp_err_t nvs_get_u64(nvs_handle_t handle, const char *key, uint64_t *out_value);
esp_err_t nvs_get_blob(nvs_handle_t handle, const char *key, void *out_value, size_t *length);
esp_err_t nvs_get_str(nvs_handle_t handle, const char *key, char *out_value, size_t *length);
  1. 写入数据:
esp_err_t nvs_set_i8(nvs_handle_t handle, const char *key, int8_t value);
esp_err_t nvs_set_u8(nvs_handle_t handle, const char *key, uint8_t value);
esp_err_t nvs_set_i16(nvs_handle_t handle, const char *key, int16_t value);
esp_err_t nvs_set_u16(nvs_handle_t handle, const char *key, uint16_t value);
esp_err_t nvs_set_i32(nvs_handle_t handle, const char *key, int32_t value);
esp_err_t nvs_set_u32(nvs_handle_t handle, const char *key, uint32_t value);
esp_err_t nvs_set_i64(nvs_handle_t handle, const char *key, int64_t value);
esp_err_t nvs_set_u64(nvs_handle_t handle, const char *key, uint64_t value);
esp_err_t nvs_set_blob(nvs_handle_t handle, const char *key, const void *value, size_t length);
esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value);
  1. 批量读取数据:
esp_err_t nvs_get_all(nvs_handle_t handle, nvs_iterator_t *it);

在使用 nvs_set_* 函数写入数据到 NVS 中时,数据并没有立即写入到闪存中,而是先写入到内存缓冲区中,等待调用 nvs_commit 函数将数据批量写入到闪存中。因此,如果不调用 nvs_commit 直接关闭 NVS,新增加或者修改的键值对将不会被保存到闪存中,这些数据将会丢失,所以,必须写入完毕后必须手动调用该函数完成写入操作。

例子:用于记录 ESP32 重启次数的例程

NVS 的特性就是,如果不对分区表进行操作,该部分区域不会丢失,所以对于小批量数据的存储,NVS是首选,本例中,我们通过 NVS 存储一个中 key 为 restart_counter 的值,用于记录 EPS32 的重启次数,每次启动后从缓冲区中读取这个数据,并对其进行 +1 操作,然后再存入。

err = nvs_get_i32(my_handle, "restart_counter", &restart_counter);
switch (err)
{
case ESP_OK:
    ESP_LOGI(TAG, "重启次数:%d", restart_counter);
    break;
case ESP_ERR_NVS_NOT_FOUND:
    ESP_LOGE(TAG, "读取重启次数失败,值不存在");
    restart_counter = 1;
    break;
default:
    printf("Error (%s) reading!\n", esp_err_to_name(err));
}
// 写入数据
restart_counter++;
ESP_ERROR_CHECK(nvs_set_i32(my_handle, "restart_counter", restart_counter));

代码中,首先通过 nvs_get_i32 读取这个值,第一次读取可能会出现 ESP_ERR_NVS_NOT_FOUND 的错误,因为第一次启动的时候该值并不存在,会直接输出错误信息,但是我们实际测试的时候,第一次并没有出现这种情况,是因为实际烧录到 Flash后,系统会自动重启一次,而这时候我们并没有进入 Monitor 过程中,第二次重启才会进入

删除键值对

nvs_erase_key()

FATFS

FATFS (File Allocation Table File System) 是一个开源的 FAT 文件系统实现,能够运行在嵌入式系统中。它由 ChaN 开发,基于 ANSI C 标准,具有可移植性好、代码简单易懂等优点,广泛应用于各种嵌入式系统中。ESP-IDF 使用 FatFs 库来实现 FAT 文件系统。FatFs 库位于 fatfs 组件中,默认情况下该组件是被挂载到系统中的,可以直接使用,也可以借助 C 标准库和 POSIX API 通过 VFS(虚拟文件系统)使用 FatFs 库的大多数功能。

虚拟文件系统 (VFS) 组件可为一些驱动提供一个统一接口。有了该接口,用户可像操作普通文件一样操作虚拟文件。这类驱动程序可以是 FAT、SPIFFS 等真实文件系统,也可以是有文件类接口的设备驱动程序。
VFS 组件支持 C 库函数(如 fopen 和 fprintf 等)与文件系统 (FS) 驱动程序协同工作。在高层级,每个 FS 驱动程序均与某些路径前缀相关联。当一个 C 库函数需要打开文件时,VFS 组件将搜索与该文件所在文件路径相关联的 FS 驱动程序,并将调用传递给该驱动程序。针对该文件的读取、写入等其他操作的调用也将传递给这个驱动程序。

FatFs 与 VFS 配合使用

头文件 fatfs/vfs/esp_vfs_fat.h 定义了连接 FatFs 和 VFS 的函数。

函数 esp_vfs_fat_spiflash_mount() 将一个分区挂载到 VFS 系统上,并在 VFS 中注册特定路径前缀。如果文件路径以此前缀开头,则对此文件的后续操作将转至 FatFs API。 函数 esp_vfs_fat_spiflash_unmount() 卸载在 VFS 中的挂载,并释放资源。
esp_vfs_fat_spiflash_mount 原型如下:

esp_err_t esp_vfs_fat_spiflash_mount(const char* base_path, const char* partition_label, const esp_vfs_fat_mount_config_t* mount_config, wl_handle_t* wl_handle);

参数

描述

base_path

FATFS 分区应挂载的路径(例如,“/flash”)。

partition_label

应使用的分区的标签。

mount_config

指向含有额外的挂载参数的结构体的指针。如果不需要额外参数,请传递 NULL。

wl_handle

wear levelling 驱动程序的句柄,是一个输出参数。

挂载函数中,第一个参数表示挂载的文件路径,也是以后操作文件的前缀标识,第二个是分区的标签,在我们之前的分区表中,最后一部分都给了FAT分区,第三个参数是 mount_config 类型的结构体,该结构体是对文件分区的描述,其中关键性的是 max_files 和 format_if_mount_failed 两个参数,前者表示该分区中同时可以打开多少个文件,后者是说如果分区挂载错误的情况下是否对分区进行格式化操作。
该部分代码如下:

// 挂载分区
esp_vfs_fat_mount_config_t mnt_cfg = {
    .format_if_mount_failed = true,     // 挂载失败的时候格式化系统
    .max_files = 5,                     // 最大打开五个文件
};
wl_handle_t handle;
esp_err_t err = esp_vfs_fat_spiflash_mount("/flash","vfs", &mnt_cfg, &handle);
if(err == ESP_OK){
    ESP_LOGI(TAG, "分区挂载成功");
}else{
    ESP_LOGE(TAG, "挂载分区错误:%d", err);
}

挂载成虚拟文件系统后,就可以使用虚拟文件系统的函数对分区进行操作了。
文件系统挂载完毕后就可以使用标准 C 库中的文件操作函数对分区进行操作了。
分区操作的时候,需要注意的是,文件操作的前缀必须和注册时候的前缀保持一致,这样文件系统就会定位到该分区及操作函数上。

标准 C 库中的文件操作函数:

  • fopen():打开文件。
  • fclose():关闭文件。
  • freopen():重新打开一个文件。
  • setbuf():设置文件流缓冲区。
  • setvbuf():设置文件流缓冲区。
  • fflush():刷新输出缓冲区或强制将缓冲区内容写入文件。
  • fwrite():向文件写入数据。
  • fread():从文件读取数据。
  • fgetc():从文件中获取一个字符。
  • fgets():从文件中获取一行字符串。
  • fputc():将一个字符写入文件。
  • fputs():将一行字符串写入文件。
  • fprintf():格式化输出到文件。
  • fscanf():从文件中格式化输入。
  • feof():测试文件流的结尾。
  • ferror():测试错误状态。
  • clearerr():清除错误状态。
  • rewind():将文件指针移到文件的开头。
  • fseek():移动文件指针。
  • ftell():获取文件指针位置。
文件的打开和关闭
FILE *fopen(const char *filename, const char *mode);
int fclose(FILE *stream);

打开文件需要传入两个参数,分别是文件的具体路径和打开模式,路径根据前缀判断使用哪个类型的文件处理器进行处理。
mode 参数选择项如下:

  • “r” :以只读方式打开文件,文件必须存在。
  • “w” :以写入或新建方式打开文件,清空文件内容,如果文件不存在则创建。
  • “a” :以追加或新建方式打开文件,不清空文件内容,如果文件不存在则创建。
  • “r+”:以读写方式打开文件,文件必须存在。
  • “w+”:以读写或新建方式打开文件,清空文件内容,如果文件不存在则创建。
  • “a+”:以读写或新建方式打开文件,不清空文件内容,如果文件不存在则创建。
  • “b” :二进制模式打开文件。例如:“rb” 表示以只读方式打开二进制文件。
  • “t” :文本模式打开文件(默认)。例如:“rt” 表示以只读方式打开文本文件。

一定要注意,文件的打开和关闭必须成对出现 ,在挂载文件系统的时候,wl_handle_t::max_files 参数指明了最多打开文件的数量,如果超出这个数量再次打开文件会操作失败,文件打开后不进行关闭,则会占用一个名额。

文件的读写

可以用作文件读写的函数有很多,以下列举几个常用的函数:

1. 文件读取 fread

fread() 是一个 C 语言标准库函数,用于从文件中读取二进制数据。该函数的原型如下:

size_t fread(void *ptr, size_t size, size_t count, FILE *stream);

其中,void * ptr 是指向接收数据的缓冲区的指针;size_t size 是每个数据元素的大小,以字节为单位;size_t count 是要读取的数据元素的个数;FILE * stream 是指向 FILE 结构体类型的指针,表示要读取的文件流。

函数返回成功读取的元素数目(而非字节数)。

例如,以下代码使用 fread() 函数从一个名为 “input.bin” 的二进制文件中读取一个整数数组:

#include <stdio.h>

int main()
{
    FILE *fp;
    int arr[5];
    size_t elements_read;

    fp = fopen("input.bin", "rb");
    if (fp == NULL) {
        printf("Error: cannot open file\n");
        return -1;
    }

    elements_read = fread(arr, sizeof(int), 5, fp);

    fclose(fp);

    printf("Elements read: %zu\n", elements_read);

    for (int i = 0; i < elements_read; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    return 0;
}

在这个例子中,我们定义了一个大小为 5 的整数数组 arr,并从一个名为 “input.bin” 的二进制文件中读取它。为了读取二进制文件,需要以二进制模式打开 “rb”。接着,我们使用 fread() 函数读取整数数组,并判断是否读取成功。最后,我们使用 fclose() 函数关闭文件,并输出成功读取的元素数,以及读取的整数数组到标准输出。

2. 文件单个字符读取 fgetc

fgetc() 是一个 C 语言标准库函数,用于从文件中读取一个字符。该函数的原型如下:

int fgetc(FILE *stream);

其中,FILE * stream 是指向 FILE 结构体类型的指针,表示要读取的文件流。

函数返回下一个字符作为无符号字符值(0 到 255 之间的整数),如果到达文件结尾或发生错误,返回 EOF (-1)。

例如,以下代码使用 fgetc() 函数从一个名为 “input.txt” 的文本文件中读取每个字符并逐个输出到标准输出:

#include <stdio.h>

int main()
{
    FILE *fp;
    int c;

    fp = fopen("input.txt", "r");
    if (fp == NULL) {
        printf("Error: cannot open file\n");
        return -1;
    }

    while ((c = fgetc(fp)) != EOF) {
        putchar(c);
    }

    fclose(fp);

    return 0;
}

在这个例子中,我们使用 fopen() 函数打开一个名为 “input.txt” 的文本文件,并以只读模式打开 “r”。接着,我们使用 fgetc() 函数读取每个字符并逐个输出到标准输出,直到到达文件结尾或发生错误。最后,我们使用 fclose() 函数关闭文件。

3. 文件按行读取读取 fgets

fgets() 是一个 C 语言标准库函数,用于从文件中读取一行文本数据。该函数的原型如下:

char *fgets(char *s, int size, FILE *stream);

其中,char * s 是指向用于存储读取数据的字符数组的指针;int size 表示要读取的最大字符数(包括字符串结尾的空字符);FILE * stream 是指向 FILE 结构体类型的指针,表示要读取的文件流。
函数返回指向字符数组的指针,如果发生错误或到达文件结尾,返回 NULL。
例如,以下代码使用 fgets 函数从一个名为 “input.txt” 的文本文件中读取一行文本数据:

#include <stdio.h>

int main()
{
    FILE *fp;
    char line[100];

    fp = fopen("input.txt", "r");
    if (fp == NULL) {
        printf("Error: cannot open file\n");
        return -1;
    }

    if (fgets(line, sizeof(line), fp) != NULL) {
        printf("Read line: %s", line);
    } else {
        printf("Error: failed to read line\n");
    }

    fclose(fp);

    return 0;
}

在这个例子中,我们定义了一个大小为 100 的字符数组 line,并从一个名为 “input.txt” 的文本文件中读取它。为了读取文本文件,需要以只读模式打开 “r”。接着,我们使用 fgets() 函数读取第一行文本数据,并判断是否读取成功。最后,我们使用 fclose() 函数关闭文件,并输出读取的文本行到标准输出。

4. 读取文件 格式化输出 fscanf

fscanf() 是一个 C 语言标准库函数,用于从文件中按指定格式读取数据。该函数的原型如下:

int fscanf(FILE *stream, const char *format, ...);

其中,FILE * stream 是指向 FILE 结构体类型的指针,表示要读取的文件流;const char * format 表示需要读取的数据的格式,类似于 printf() 函数的格式化字符串,但多了一个 %n 格式,用于返回成功读取的字符数。后面的参数是可变长参数列表,对应 format 中的格式化占位符。
函数返回成功读取的参数个数,如果到达文件结尾或发生错误,返回 EOF (-1)。
例如,以下代码使用 fscanf() 函数从一个名为 “input.txt” 的文本文件中读取两个整数和一个字符串并输出到标准输出:

#include <stdio.h>

int main()
{
    FILE *fp;
    int num1, num2;
    char str[20];
    int success_count;

    fp = fopen("input.txt", "r");
    if (fp == NULL) {
        printf("Error: cannot open file\n");
        return -1;
    }

    success_count = fscanf(fp, "%d %d %s%n", &num1, &num2, str, &success_count);

    fclose(fp);

    if (success_count == EOF) {
        printf("Error: failed to read data\n");
    } else if (success_count != 3) {
        printf("Error: %d parameters matched\n", success_count);
    } else {
        printf("Read: %d %d %s\n", num1, num2, str);
    }

    return 0;
}

在这个例子中,我们使用 fopen() 函数打开一个名为 “input.txt” 的文本文件,并以只读模式打开 “r”。接着,我们使用 fscanf() 函数按格式 %d %d %s 读取两个整数和一个字符串,并将它们存储在变量 num1、 num2 和 str 中。我们还使用 %n 格式在成功读取后获取字符数,以便检查是否成功读取所有参数。最后,我们使用 fclose() 函数关闭文件,并根据成功读取的参数个数输出所读取的数据或错误信息到标准输出。

5. 文件写入 fwrite

fwrite() 是一个 C 语言标准库函数,用于将数据块写入文件中。该函数的原型如下:

size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);

其中,const void * ptr 是指向要写入的数据块的指针;size_t size 是每个数据元素的大小,以字节为单位;size_t count 是要写入的数据元素的个数;FILE * stream 是指向 FILE 结构体类型的指针,表示要写入的文件流。
函数返回成功写入的元素数目(而非字节数)。
例如,以下代码使用 fwrite 函数将一个整数数组写入文件中:

#include <stdio.h>

int main()
{
    FILE *fp;
    int arr[5] = {1, 2, 3, 4, 5};
    size_t elements_written;

    fp = fopen("output.bin", "wb");
    if (fp == NULL) {
        printf("Error: cannot open file\n");
        return -1;
    }

    elements_written = fwrite(arr, sizeof(int), 5, fp);

    fclose(fp);

    printf("Elements written: %zu\n", elements_written);

    return 0;
}

在这个例子中,我们定义了一个包含 5 个整数的数组 arr,并将其写入一个名为 “output.bin” 的文件中。为了输出二进制文件,需要以二进制模式打开 “wb”。最后,我们使用 fclose() 函数关闭文件,并输出成功写入的元素数到标准输出。

6. 文件按字符写入

fputc() 是一个 C 语言标准库函数,用于将一个字符写入到文件中。该函数的原型如下:

int fputc(int c, FILE *stream);

其中,int c 表示要写入的字符,它是一个整数类型,但实际上只使用其低8位;FILE * stream 是指向 FILE 结构体类型的指针,表示要写入的文件流。
函数返回写入的字符作为无符号字符值(0 到 255 之间的整数),如果发生错误,返回 EOF (-1)。
例如,以下代码使用 fputc() 函数将一个字符按顺序写入到一个名为 “output.txt” 的文本文件中:

#include <stdio.h>

int main()
{
    FILE *fp;
    int i, c;

    fp = fopen("output.txt", "w");
    if (fp == NULL) {
        printf("Error: cannot open file\n");
        return -1;
    }

    for (i = 0; i < 26; i++) {
        c = 'a' + i;
        fputc(c, fp);
    }

    fclose(fp);

    return 0;
}

在这个例子中,我们使用 fopen() 函数打开一个名为 “output.txt” 的文本文件,并以写入模式打开 “w”。接着,我们使用 fputc() 函数按顺序写入从 ‘a’ 到 ‘z’ 的所有小写字母字符,直到写入完毕或发生错误。最后,我们使用 fclose() 函数关闭文件。

7. 文件按行写入 fputs

fputs() 是一个 C 语言标准库函数,用于将一个字符串写入到文件中。该函数的原型如下:

int fputs(const char *s, FILE *stream);

其中,const char * s 表示要写入的字符串,FILE * stream 是指向 FILE 结构体类型的指针,表示要写入的文件流。
函数返回一个非负数(即成功写入的字符数),如果发生错误,返回 EOF (-1)。
例如,以下代码使用 fputs() 函数将一个字符串写入到一个名为 “output.txt” 的文本文件中:

#include <stdio.h>

int main()
{
    FILE *fp;

    fp = fopen("output.txt", "w");
    if (fp == NULL) {
        printf("Error: cannot open file\n");
        return -1;
    }

    fputs("Hello, world!\n", fp);

    fclose(fp);

    return 0;
}

在这个例子中,我们使用 fopen() 函数打开一个名为 “output.txt” 的文本文件,并以写入模式打开 “w”。接着,我们使用 fputs() 函数将一个字符串 “Hello, world!\n” 写入文件中。最后,我们使用 fclose() 函数关闭文件。

8. 文件写入 fprintf

fprintf() 是 C 语言中的一个标准库函数,用于将格式化的数据输出到指定的文件流中。
该函数的原型如下:

int fprintf(FILE *stream, const char *format, ...);

其中,FILE * stream 是指向 FILE 结构体类型的指针,表示要写入的文件流;const char * format 是一个格式化字符串,用于描述输出的格式;… 表示可变参数列表,用于替换格式化字符串中的占位符。
函数返回值为写入到文件中的字符数,如果发生错误则返回负值。
例如,以下代码使用 fprintf 函数向文件写入一个字符串:

#include <stdio.h>

int main()
{
    FILE *fp;

    fp = fopen("test.txt", "w");
    if (fp == NULL) {
        printf("Error: cannot open file\n");
        return -1;
    }

    fprintf(fp, "Hello, World!");

    fclose(fp);

    return 0;
}

在这个例子中,fopen() 函数用于打开一个名为 “test.txt” 的文件,并以写模式打开,返回一个指向该文件的指针。然后,使用 fprintf() 函数将字符串 “Hello, World!” 输出到文件中,最后使用 fclose() 函数关闭文件。

9. 刷新缓冲区 fflush

fflush() 是一个 C 语言标准库函数,用于刷新指定的输出流或清空指定的输入缓冲区。该函数的原型如下:

int fflush(FILE *stream);

其中,FILE * stream 是指向 FILE 结构体类型的指针,表示要刷新的输出流或要清空的输入缓冲区。如果 stream 为 NULL,则刷新所有输出流,并忽略所有输入缓冲区。
函数返回一个整数值,如果成功,返回 0;如果发生错误,返回 EOF (-1)。
例如,以下代码使用 fflush() 函数刷新一个名为 “output.txt” 的文本文件的输出缓冲区:

#include <stdio.h>

int main()
{
    FILE *fp;

    fp = fopen("output.txt", "w");
    if (fp == NULL) {
        printf("Error: cannot open file\n");
        return -1;
    }

    fprintf(fp, "Hello, world!\n");
    fflush(fp);

    fclose(fp);

    return 0;
}

在这个例子中,我们使用 fopen() 函数打开一个名为 “output.txt” 的文本文件,并以写入模式打开 “w”。接着,我们使用 fprintf() 函数将一个字符串 “Hello, world!\n” 输出到文件中,但由于是使用带缓冲的输出方式进行的,因此可能需要等待一段时间才能实际写入到文件中。为了立即将缓冲区中的数据写入文件,我们使用 fflush() 函数刷新文件缓冲区。最后,我们使用 fclose() 函数关闭文件。

重要的事情:该函数非常重要,如果不调用,写入可能不会出问题,但这是个坑,有时候不调用的话直接关闭文件会保存失败,所以,在写入文件后,务必调用该函数刷新缓冲区!!!!

10. 删除文件 remove

在 C 标准库中,删除一个文件可以通过 remove 函数实现。该函数定义在 <stdio.h> 头文件中,其原型如下:

int remove(const char *filename);

其中,filename 参数指定要删除的文件的路径和名称。如果成功删除文件,则返回 0;否则返回 -1,并根据错误类型设置 errno 变量指示具体错误原因。
需要注意的是,remove 函数只能删除普通文件,不能删除目录。如果要删除目录,可以使用 rmdir 函数,但前提是目录必须为空(即不存在任何文件或子目录)。
在使用 remove 函数时,应该对其返回值进行检查,以避免由于权限不足等原因导致删除操作失败。为了确保安全,建议在删除文件之前先备份文件内容,以备后续需要。
另外,在一些特殊情况下,可能需要使用操作系统提供的特定函数来删除文件,例如 unlink 函数(在 POSIX 系统下可用)等。具体实现方式可以根据操作系统和应用场景选择适当的方法。

11. 查看文件或目录的属性
目录操作

stat 函数是 C 标准库中定义的一个系统调用函数,在 <sys/stat.h> 头文件中声明。它用于获取指定文件或目录的状态信息,例如文件大小、权限、创建时间、修改时间等。
stat 函数的原型如下:

#include <sys/stat.h>
int stat(const char *pathname, struct stat *statbuf);

其中,pathname 参数指定要获取信息的文件路径,可以是相对路径或绝对路径。statbuf 参数是一个 struct stat 类型的指针,用于存储获取到的文件信息。struct stat 结构体包含了各种文件信息,定义如下:

struct stat {
    dev_t     st_dev;         /* 文件所在设备的 ID */
    ino_t     st_ino;         /* 文件 inode 号 */
    mode_t    st_mode;        /* 文件访问权限 */
    nlink_t   st_nlink;       /* 文件硬连接数 */
    uid_t     st_uid;         /* 文件所有者的用户 ID */
    gid_t     st_gid;         /* 文件所有者的组 ID */
    dev_t     st_rdev;        /* 文件类型和设备号 */
    off_t     st_size;        /* 文件大小(以字节为单位) */
    blksize_t st_blksize;     /* 文件系统的块大小 */
    blkcnt_t  st_blocks;      /* 分配给文件的块的数量 */
    time_t    st_atime;       /* 最后一次访问时间 */
    time_t    st_mtime;       /* 最后一次修改时间 */
    time_t    st_ctime;       /* 最后一次状态变更时间 */
};

综合测试:

// 挂载分区
esp_vfs_fat_mount_config_t mnt_cfg = {
    .format_if_mount_failed = true,     // 挂载失败的时候格式化系统
    .max_files = 5,                     // 最大打开五个文件
};
wl_handle_t handle;
esp_err_t err = esp_vfs_fat_spiflash_mount("/flash","vfs", &mnt_cfg, &handle);
if(err == ESP_OK){
    ESP_LOGI(TAG, "分区挂载成功");
}else{
    ESP_LOGE(TAG, "挂载分区错误:%d", err);
}
// 写入一个文件
FILE* f = fopen("/flash/first.txt", "w");
if(f!=NULL){
    ESP_LOGI(TAG, "写入:文件打开成功");
    fprintf(f, "Hello ESP32!\n");
    fprintf(f, "这是一个文本文件的第二行\n");
    fflush(f);  // 刷新缓冲区,写入数据。
    fclose(f);
    ESP_LOGI(TAG, "文件写入完毕");
}else{
    ESP_LOGE(TAG, "写入:文件打开失败");
}
// 追加内容
// 写入一个文件
f = fopen("/flash/first.txt", "a");
if(f!=NULL){
    ESP_LOGI(TAG, "追加:文件打开成功");
    fprintf(f, "This is the third line of the file!\n");
    fflush(f);  // 刷新缓冲区,写入数据。
    fclose(f);
    ESP_LOGI(TAG, "文件写入完毕");
}else{
    ESP_LOGE(TAG, "追加:文件打开失败");
}
// 读取文件
f = fopen("/flash/first.txt", "r");
if(f!=NULL){
    char buffer[256];
    ESP_LOGI(TAG, "读取:文件打开成功");
    printf("以下是读出文件的内容:\n");
    printf("=====================================================================\n");
    while (fgets(buffer, sizeof(buffer), f) != NULL) {
        printf("%s", buffer);
    }
    printf("=====================================================================\n");
}else{
    ESP_LOGE(TAG, "读取:文件打开失败");
}

如果 stat 函数调用成功,则返回 0;否则返回 -1,并根据错误类型设置 errno 变量指示具体错误原因。
注意:stat 函数只能获取指定文件或目录的元数据信息,不能读取文件内容。如果要读取文件内容,可以使用 fread 或 fgets 等文件读取函数。

目录的操作

除了我之前列出的 VFS 操作相关函数外,C 标准库还提供了许多其他用于操作目录的函数,以下是一些常见的 C 标准库函数列表:

  • dirent.h 头文件
  • opendir():打开一个目录并返回一个 DIR 结构体指针。
  • readdir():读取目录中的下一个条目,并返回一个 dirent 结构体指针。
  • closedir():关闭一个已打开的目录。
  • sys/stat.h 头文件
  • mkdir():创建一个新目录。
  • rmdir():删除一个空目录(如果目录非空则会失败)。
  • stat():获取一个文件或目录的状态信息。
  • fstatat():获取一个相对路径名的文件或目录的状态信息。
  • unistd.h 头文件
  • chdir():将当前工作目录更改为指定的目录。
  • getcwd():获取当前工作目录的绝对路径。
  • access():检查文件或目录是否可访问。

这些函数允许我们在程序中方便地创建、访问、遍历和删除目录及其内容。需要注意的是,不同操作系统下这些函数的实现可能略有不同,例如在 Windows 和 Unix/Linux 系统中路径分隔符不同等。

综合测试:

/**
 * @brief 递归打印目录结构
*/
static void print_dir(const char *path, int depth){
    DIR *dir;
    struct dirent *dp;
    struct stat st;
    char sub_path[512];
    if (!(dir = opendir(path))) {
        printf("打开目录失败:%s\n", path);
        return;
    }
    while ((dp = readdir(dir)) != NULL) {
        if (strcmp(dp->d_name, ".") == 0 || strcmp(dp->d_name, "..") == 0) {
            continue;
        }
        memset(sub_path, 0, sizeof(sub_path));
        sprintf(sub_path, "%s/%s", path, dp->d_name);
        if (stat(sub_path, &st) == -1) {
            continue;
        }
        if (S_ISDIR(st.st_mode)) {
            printf("%*s[%s]\n", depth * 2, "", dp->d_name);
            print_dir(sub_path, depth + 1);
        } else {
            printf("%*s- %s\n", depth * 2, "", dp->d_name);
        }
    }
    closedir(dir);
}

/**
 * @brief FATFS 文件系统测试
*/
static void fatfs_test(){
    // 创建目录
    // 判断目录是否存在
    if(access("/flash/lib", F_OK)!=0){
        ESP_LOGI(TAG, "目录不存在,创建...");
        err = mkdir("/flash/lib", S_IRWXU | S_IRWXG | S_IRWXO); // 相当于0x777
        if(err == ESP_OK){
            ESP_LOGI(TAG, "目录创建成功");
            // 在创建几个
            mkdir("/flash/build", S_IRWXU | S_IRWXG | S_IRWXO);
            mkdir("/flash/target", S_IRWXU | S_IRWXG | S_IRWXO);
        }else{
            ESP_LOGE(TAG, "目录创建失败:%d",err);
        }
    }else{
        ESP_LOGI(TAG, "目录存在,无需创建");
    }
   
    mkdir("/flash/text", S_IRWXU | S_IRWXG | S_IRWXO);
    f = fopen("/flash/text/readme.txt", "w");
    fclose(f);
    // 列出目录结构
    printf("=====================================================================\n");
    print_dir("/flash", 0);
    printf("=====================================================================\n");
    // 尝试删除这个目录
    err = rmdir("/flash/text");
    if(err !=ESP_OK){
        ESP_LOGE(TAG, "目录删除失败:可能是目录不存在或者目录不为空");
    }else{
        ESP_LOGI(TAG, "text 目录删除成功!");
    }
    //清空目录后删除文件
    err = remove("/flash/text/readme.txt");
    if(err == ESP_OK){
        ESP_LOGI(TAG, "/flash/text/readme.txt 文件删除成功!");
    }else{
        ESP_LOGE(TAG, "/flash/text/readme.txt 文件删除失败!");
    }
    err = rmdir("/flash/text");
    if(err !=ESP_OK){
        ESP_LOGE(TAG, "目录删除失败:%d", err);
    }else{
        ESP_LOGI(TAG, "text 目录删除成功!");
    }
}

代码共享位置:http://192.168.172.17:3000/Mars.CN/ESP-IDF-S2-UART-SPI-Flash.git