esp: idf project architecture

了解几个概念芯片,模组,开发板

  • 芯片
  • 模组
  • 开发板

入门 ESP-IDF 工程结构【新手超详细教程】

目的:更高效的开发和管理自己的 ESP-IDF 工程

工程结构

一个 ESP-IDF 工程可以看作是多个组件的集合。那么什么是组件呢? 组件其实就是独立的代码模块,乐鑫提供基础的系统组件,例如:

image-20241130130141239

例如:开发一个智能台灯需要的组件:

  1. ESP-IDF基础库:提供基本的功能库和底层硬件绑定
  2. FreeRTOS 操作系统:实时操作系统内核,管理不同的任务
  3. WiFi 驱动:连接无线网络,发送和接受控制指令
  4. TCP/IP 协议栈:处理网络通信
  5. 应用层协议:处理来自云端的控制指令
  6. 灯的控制驱动:控制灯开/关和亮度
  7. 主程序:将所有组件整合在一起,实现功能逻辑

一个标准的ESP-IDF工程只是一个文件夹,包括一些组件和配置文件。通过工程顶层 CMakeLists.txt 文件,将所有组件整合在一起,通过 cmake 工具生成 makefile 文件,接下来使用 ninja (或make) 工具根据 makefile 执行编译、链接等步骤。CMakeLists.txt的位置:工程的顶层目录内和其他组件内,所有组件都要有自己的CMakeList.txt,包括main组件。

1
2
3
4
5
6
# 详细执行步骤,当前处于工程目录下
mkdir -p build
cd build
cmake .. -G Ninja
ninja

ESP-IDF 把上面的操作步骤封装一句命令:idf.py build

简单示例

hello-world 工程来说明 ESP-IDF 的工程结构:

1
2
3
4
5
6
7
8
$ tree -l 1
.
├── CMakeLists.txt
├── main
│   ├── CMakeLists.txt
│   └── main.c
└── README.md

通过CMakeLists.txt可以注册组件,工程顶层 CMakeLists.txt 内容如下:

1
2
3
4
5
6
7
8
# 遵循CMakeLists.txt的语法
# 指定cmake最小版本要求
cmake_minimum_required(VERSION 3.16)

# 导入cmake各种功能,用来配置项目和检索组件
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# 指定工程名称,编译后二进制的名字
project(hello_world)

main/CMakeLists.txt 内容如下:

1
2
3
4
5
# mian组件会默认require IDF的相关组件依赖,其他组件需要手动添加依赖
idf_component_register(SRCS
"main.c"
INCLUDE_DIRS
"")

执行 idf.py set-target esp32 ,自动创建 build 目录,并将过程文件放到该目录下,同时工程目录自动创建 sdkconfig 文件。

idf.py set-target 支持哪些目标芯片系列? 执行命令 idf.py --list-targets 就可以看到支持的芯片系列

执行 idf.py menuconfig ,出现编译配置菜单,用户可以修改配置项。

执行 idf.py build ,开始编译。

执行 idf.py -p /dev/ttyUSB0 flash ,通过指定的串口 /dev/ttyUSB0 将固件烧录到ESP硬件。

执行 idf.py -p /dev/ttyUSB0 monitor ,通过指定的串口 /dev/ttyUSB0 监控串口日志。

关于上述的构建系统的操作步骤建议参考乐鑫官方的教程:构建系统(CMake 版)

导入组件

导入组件到 ESP-IDF 工程的方式:

  1. 导入 ESP-IDF 本地框架下组件(自动导入,无需用户操作)
  2. 通过工程目录下的 CMakeLists.txt 指定额外的组件
  3. 在工程目录下创建 component 文件夹,并将自定义组件放进来。而且不需要在工程目录下的 CMakeLists.txt 下声明就可以直接使用组件。
  4. 通过组件管理器将乐鑫官方组件仓库导入组件,在工程目录下执行 idf.py add-dependency "[命名空间]/<组件名>[版本号]",例如:idf.py add-dependency "espressif/button^3.4.0"

对于组件的说明和解读,建议参考乐鑫官方的教程:组件管理和使用

sdkconfig.defaults

执行 idf.py menuconfig 后,会将用户修改后的配置项更改到 sdkconfig 文件。但是如果用户希望新建 sdkconfig 时,就能够根据默认的配置载入sdkconfig文件中,解决方式就是利用 sdkconfig.defaults 文件。

例如:当用户使用的开发板 ESP-DevKitC,该开发板的flash大小,以及按键对应的GPIO都是固定好的。在第一次 idf.py menuconfig 配置好后,接着执行 idf.py save-defconfig 就会创建 sdkconfig.defaults 文件,以后的工程就可以拷贝这个文件使用。

Kconfig

每个组件还可以包含一个 Kconfig 文件,它用于定义 menuconfig 时展示的 组件配置 选项。某些组件可能还会包含 Kconfig.projbuildproject_include.cmake 特殊文件,它们用于 覆盖项目的部分设置

组件中编辑好的 Kconfig.projbuild 文件,可以通过 idf.py menuconfig 来修改Kconfig 的配置项。

分区表

分区表是什么?

每片 ESP32 的 flash 可以包含多个应用程序,以及多种不同类型的数据(例如校准数据、文件系统数据、参数存储数据等)。因此,我们在 flash 的 默认偏移地址 0x8000 处烧写一张分区表。

分区表的长度为 0xC00 字节,最多可以保存 95 条分区表条目。MD5校验和 附加在分区表之后,用于在运行时验证分区表的完整性。分区表占据了整个 flash 扇区,大小为 0x1000 (4 KB)。因此,它后面的任何分区至少需要位于 (默认偏移地址) + 0x1000 处,即从 0x9000 初开始。

如果用户没有在 menuconfig 中选择用户自定义分区表,那么就会使用默认的分区表,默认定义的flash为2MB

1
2
3
4
5
# ESP-IDF Partition Table
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 1M,

自定义分区表,首先在 menuconfig 使能用户自定义分区表,然后设置实际flash大小,接着在项目顶层文件夹下创建 partitions.csv 文件

这里还可能涉及文件系统,可以参考乐鑫官方教程:SPIFFS 文件系统

关于分区表的介绍和使用,请参考乐鑫官方的教程:分区表

  • Name 字段

    Name 字段可以是任何有意义的名称,但不能超过 16 个字节,其中包括了一个空字节。

  • Type 字段

    Type 字段可以设置为 app(0x00)或者 data (0x01) ,也可以直接使用数字 0254 (或者十六进制 0x000xfe),但要注意的是 0x00~0x3f 不可以使用,这部分预留给 esp-idf 的核心功能。

    注意:启动加载器将忽略 app (0x00)和 data(0x01) 以外的其他分区类型!!!

  • SubType 字段

    SubType 字段长度为 8 bit,内容与具体分区 Type 字段有关。目前 esp-idf 仅仅规定了 app (0x00)和 data(0x01)两种分区类型的子类型含义。

    • 当 Type 设置为 app (0x00)时,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)。

    • 当 Type 设置为 data (0x01)时,Subtype 字段可以设置为:ota(0x00)、phy(0x01)、nvs(0x02)、nvs_keys(0x04) 、或 其他子类型。

      ota (0x00)即 OTA数据分区,用于存储当前所选择的 OTA 应用程序的信息。这个分区的大小要设置为 0x2000。

      phy (0x01)用于存放 PHY 初始化数据,从而保证可以为每一个设备单独配置 PHY,而非必须采用固件中的统一 PHY 初始化数据。默认配置下, phy 分区并不启动,而是直接将 phy 初始化数据编译至应用程序中,从而节省分区表空间。如果需要从此分区加载 phy 初始化数据,打开配置菜单,使能 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 加密功能的话)。此分区应该至少分配 0x1000 字节(即 4096 字节)。

  • 偏移地址(Offset)和 大小(Size)字段

    • 偏移地址表示 SPI flash 中的分区地址,扇区大小为 0x1000(4KB)。因此,偏移地址必须是 4KB 的整数倍。
    • 若 csv 文件中的分区偏移地址为空,则该分区会接在前一个分区之后;若为首个分区,则接在分区表之后。
    • app 分区的偏移地址必须与 0x10000(64KB)对齐。
    • 若启动了安全启动 V1(Secure Boot),则 app 分区的大小与 0x10000(64KB)对齐。
    • app 分区的大小和偏移地址可以采用 十进制数以0x为前缀的十六进制数,且支持 K 或 M 的倍数单位。
  • Flags 字段

    目前 Flags 字段仅支持 encryptedreadonly。如果 Flags 字段设置为 encrypted ,且启用了 flash 加密功能,则该分区将会被加密。注意:无论是否设置 Flags 字段,app 分区都将保持加密。

    如果 Flags 字段设置为 readonly ,则该分区为只读分区。readonly 标记仅支持 除 ota 和 coredump 子类型外的 data 分区。使用 readonly 标记,防止意外写入 如出厂数据分区等包含设备特定配置数据的分区。

    可以使用冒号连接不同的标记,来同时指定多个标记,例如 encrypted:readonly

内置分区表

ESP-IDF 提供了内置分区表,用户通过 idf.py menuconfig 勾选即可。”Single factory app, no OTA” 分区表信息:

1
2
3
4
5
# ESP-IDF Partition Table
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 1M,
  • 在 flash 的 0x10000(即64 KB)偏移地址处存放一个标记为 “factory” 的二进制应用程序,且启动加载器默认加载这个程序。
  • 分区表中还定义了两个数据区域,分别用于存储 NVS 库专用分区和 PHY 初始化数据。

“Factory app, two OTA definitions” 分区表信息:

1
2
3
4
5
6
7
8
# ESP-IDF Partition Table
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x4000,
otadata, data, ota, 0xd000, 0x2000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 1M,
ota_0, app, ota_0, 0x110000, 1M,
ota_1, app, ota_1, 0x210000, 1M,
  • 分区表中定义了三个应用程序分区,这三个分区的类型都被设置为 “app” ,但具体 app 类型不同。其中位于 0x10000 偏移地址处的为出厂应用程序(factory),其余两个为 OTA 应用程序(ota_0, ota_1)。
  • 新增了一个名为 “otadata” 的数据分区,用于保存 OTA 升级时需要的数据。启动加载器会查询该分区的数据,以判断应该从哪个 OTA 应用程序分区加载程序。如果 “otadata” 分区为空,则会执行出厂程序。

自定义分区表

除了使用 ESP-IDF 的内置分区表外,还可以自定义分区表。

例如:

1
2
3
4
5
6
7
8
# Name,   Type, SubType,  Offset,   Size,  Flags
nvs, data, nvs, 0x9000, 0x4000
otadata, data, ota, 0xd000, 0x2000
phy_init, data, phy, 0xf000, 0x1000
factory, app, factory, 0x10000, 1M
ota_0, app, ota_0, , 1M
ota_1, app, ota_1, , 1M
nvs_key, data, nvs_keys, , 0x1000
  • 新增了一个名为 “nvs_key” 的数据分区,大小为 4KB。
  • 其中三个分区的 Offset 字段可以为空,因为 gen_esp32part.py 工具会从分区表位置的后台开始自动计算并填充分区的偏移地址,通知确保每个分区的偏移地址正确对齐。

生成二进制分区表

烧写到 ESP32 中的分区表采用二进制格式,而不是 csv 文件本身。partition_table/gen_esp32part.py 工具可以实现 csv 文件和二进制文件之间的转换。

如果在项目配置菜单 idf.py menuconfig 中设置了分区表 csv 文件的名称,然后构建项目 idf.py build 或 执行 idf.py partition-table 。这时,转换将在编译过程自动完成。

  • 手动将 csv 文件转换为二进制文件

    1
    python gen_esp32part.py input_partitions.csv binary_partitions.bin
  • 手动将二进制文件转换为csv 文件

    1
    python gen_esp32part.py binary_partitions.bin input_partitions.csv
  • 打印二进制分区表的内容

    1
    2
    3
    4
    idf.py partition-table

    # 或者
    python gen_esp32part.py binary_partitions.bin

分区大小检查

esp-idf 构建系统将自动检查生成的二进制文件大小与可用分区大小是否匹配,如果二进制文件太大,则会构建失败并报错提示。

MD5校验和

二进制格式的分区表中含有一个 MD5 校验和。这个 MD5 校验和是根据分区表内容计算的,可在设备启动阶段用于验证分区表的完整性。

烧写分区表

使用 esptool.py 工具烧写分区表,命令:idf.py partition-flash。或者 执行命令 idf.py flash ,烧写所有内容,包括分区表。

OTA

OTA(Over-The-Air)升级机制可以让设备在固件 正常运行时 通过Wi-Fi、蓝牙或以太网 接收数据进行自我更新。

要运行 OTA 机制,需要配置设备的分区表,该分区表至少包括两个 OTA 应用程序分区(即 ota_0 和 ota_1)和 一个 OTA 数据分区。

OTA 功能启动后,向当前 未用于启动的 OTA 应用程序分区 写入新的应用固件镜像。镜像验证后, OTA 数据分区更新,指定在下一次启动时使用新镜像。

OTA 数据分区

所有使用 OTA 功能项目,其分区表 必须 包含一个 OTA 数据分区,Type 字段为 data,SubType 字段为 ota。

工厂启动设置下,OTA 数据分区中应没有数据。如果分区表中有工厂应用程序,esp-idf 引导加载程序会启动工厂应用程序。如果分区表没有工厂应用程序,则启动第一个可用的 OTA 分区,通常是 ota_0。

第一次 OTA 升级后,OTA 数据分区更新,指定下一次启动哪个 OTA 应用程序分区。

OTA 数据分区的容量是 2 个 flash 扇区的大小,即0x2000 字节(8 KB),防止写入时电源故障引发问题。两个扇区单独擦除、写入匹配数据,若存在不一致,则用计数器字段判定按个扇区为最新数据。

应用程序回滚

应用程序回滚的主要目的:确保设备在更新后正常工作。如果新版应用程序出现严重故障,该功能科使设备回滚到之前正常运行的应用版本。

在使能回滚并且 OTA 升级应用程序至新版本后, 可能出现的结果:

  • 应用程序运行正常

    esp_ota_mark_app_valid_cancel_rollback() 将正在运行的应用程序状态标记为 ESP_OTA_IMG_VALID,启动此应用程序无限制。

  • 应用程序出现严重故障,无法继续工作,必须回滚到 OTA 升级之前的版本

    esp_ota_mark_app_invalid_rollback_and_reboot() 将正在运行的版本标记为 ESP_OTA_IMG_INVALID ,然后系统复位。引导加载程序不会选择次版本,而是启动 OTA 升级之前的版本。

  • 如果 CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE 使能(默认情况下是没有使能的),则无需调用函数便可系统复位,并回滚到 OTA 升级之前的版本。

OTA工具

app_update 组件中有 app_update/otatool.py 工具,用于在目标设备完成 OTA 分区相关的操作:

  • 读取 otadata 分区
  • 擦除 otadata 分区,将设备复位至工厂应用程序
  • 切换 OTA 分区
  • 擦除 OTA 分区
  • 写入 OTA 分区
  • 读取 OTA 分区

存储器类型

esp-idf 区分了指令总线(IRAM、IROM、RTC FAST memory)和 数据总线(DRAM、DROM)。

指令存储器是可以执行的,只能通过 4 字节对齐读取或写入。

数据存储器不可执行,可以通过单独的字节操作访问。

DRAM

非常量静态数据(.data段)和 零初始化数据(.bss段)由链接器放入内部 SRAM 作为数据存储。此区域中的剩余空间可在程序运行时用作堆 heap。

如果使用蓝牙堆栈,内部 DRAM 区域的可用大小将减少 64 KB。

如果使用内存跟踪功能,内部 DRAM 区域的可用大小将减少 16 KB 或者 32 KB。

ESP32 上有 520 KB 的可用 SRAM,其中320 KB的DRAM 和 200 KB的IRAM。但是由于技术限制,用于静态分区的DRAM 最多可为 160 KB,剩余的 160 KB 只能在运行时分配为堆 heap。

IRAM

esp-idf 将内部 SRAM0 的部分区域分配为 指令RAM。

  • 如果在注册中断处理程序时,使用了 ESP_INTR_FLAG_IRAM ,则中断处理程序必须放入 IRAM。
  • 可将一些时序关键代码放入IRAM,以减少从 flash 中加载代码造成的相关损失。

编译 hello_world 项目时,执行 idf.py size 查看 DRAM、IRAM 和 flash 的使用:

1
2
3
4
5
6
7
8
9
10
11
Total sizes:
Used static DRAM: 10956 bytes ( 169780 remain, 6.1% used)
.data size: 8716 bytes
.bss size: 2240 bytes
Used static IRAM: 51074 bytes ( 79998 remain, 39.0% used)
.text size: 50047 bytes
.vectors size: 1027 bytes
Used Flash size : 116999 bytes
.text: 79067 bytes
.rodata: 37676 bytes
Total image size: 176789 bytes (.bin may be padded larger)

ESP Component Registry

可以直接将组件仓库中的组件添加到项目依赖中,例如 在项目中添加 button 组件,在项目的顶层目录执行命令:

1
2
3
4
5
6
7
8
# 方法1
idf.py add-dependecy "espressif/button=*"

# 方法2
idf.py add-dependecy "espressif/button^3.4.1"

# 方法3
idf.py add-dependecy "espressif/button==3.4.1"