ESP-IDF

简介

介绍:ESP-IDF === 乐鑫物联网开发框架,是官方提供的一套开发框架,或者 sdk,这个是需要安装到你本地的电脑上面或者集成到你的IDE 工具,这样就可以调用它的 API 也就是说和写 Java 需要先给电脑配 sdk 写 Python 需要给电脑安装 python 环境一个意思,不是把这个安装到你的开发版上,电脑有了这个 sdk 以后就可以调用官方的 API 去写自己的代码了,写好以后再编译,最后把编译好的烧录进开发版里面

准备工作:原本我们是需要在我们的电脑上(Windows/Mac/Linux)上去配置完整的工作环境包含工具链 编译构建工具(CMake 和 Ninja) ESP-IDF

分类 名称 说明
工具链 把 C/C++ 代码翻译成芯片能识别的机器指令,核心编译转换程序
编译构建工具 CMake 管理项目文件、编译规则,告诉程序哪些代码要参与编译、如何组织工程
Ninja 高速编译工具,加速代码编译打包,比常规编译速度更快
SDK 框架 ESP-IDF 封装硬件驱动、WiFi/蓝牙、系统接口,直接调用函数操控芯片外设

安装上述内容可通过官方的图形化软件 EIM 进行安装,也可使用 vscode 插件扩展的方式,例如 vscode 的 vscode-esp-idf-extension
:所谓编译链,就是把你电脑上写的代码最终能成功运行在你开发板上需要做的一些工作(编译器,汇编器,链接器,二进制转换…),编译工具链则是指的干这些活用到的具体工具,ESP-IDF是集成自带了它自己那一套工具链(和 Java 那一套 jdk 感觉差不多)

下载官方的 gui 和 esp-idf 离线安装包
下载GUI和离线安装包

常识

include什么时候需要详细路径

  • <> 表示只在标准库目录搜索引入,用 " " 表示优先从当前目录搜索,如果没有搜索到就会从标准目录中去找,所以原本的 <> 换成 " " 也不会错,因为假如先在当前目录没找到就去标准目录找,不过不符合约定俗成
  • 如果你需要引入的头文件 xxx.h 这个头文件在 include 文件夹下,那么直接 #include
  • 如果需要引入的头文件 yyy.h 的上级目录不是 include 文件夹,则需要用 上级文件夹名称/yyy.h
  • C语言自己自带的引用时是用 #include ,例如 #include ** <> 与 " " 的区别**
  • 例如

传参

  • 当一个函数的形参是 指针类型的(如 * int ,esp_chip_info_t *out_info),那么调用函数时就要传地址(用 &变量名 取地址)
  • 数组类型的可以直接传变量名,不加 & 符号,因为默认传的就是下标为 0 的地址(指向第一个元素的地址)
1
2
3
4
5
6
7
8
void func(int *p) {  // 形参:int类型指针,接收地址
*p = 100;
}

int main() {
int a = 10;
func(&a); // a是普通变量,用&取a的地址传给指针p
}
1
2
3
4
esp_chip_info_t chip_info;
esp_chip_info(&chip_info);
ESP_LOGD(hw, "CPU 核心数:%d", chip_info.cores);
ESP_LOGD(hw, "CPU 版本号:%d", chip_info.revision);
  • 注: 为什么会有需要传地址?我的理解是传地址=传变量本身,后续的方法可以对这个变量进行编辑操作改变,然后就不需要返回一个新的变量,也就是函数其实虽然没有返回值,但是你传进来的数据已经通过地址进行修改处理过了,如果传变量值,那么就是只是对数据进行了编辑修改,这种是不会改变原始数据,你可以返回 return 一个新的或者只是在里面调用一下而已

枚举

  • 枚举的作用大概就是为了简化定义的,比如我们要定义 7 个变量,表示周一到周天
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

// 用宏定义一周七天
#define MONDAY 1
#define TUESDAY 2
#define WEDNESDAY 3
#define THURSDAY 4
#define FRIDAY 5
#define SATURDAY 6
#define SUNDAY 7

int main(void)
{
int today = WEDNESDAY;
printf("今天是星期%d\n", today);
return 0;
}
  • 上面我们需要用 7 次 #define 并且每个变量都要指定值
  • 下面的枚举,我们可以不赋值,默认是按顺序从 0 开始给默认值
  • typedef = 给已有的数据类型起一个别名,方便简写、提高可读性,也是可以用不带 typedef 的写法的,enum WeekDay{}; 然后用的时候就是 enum WeekDay 变量名; 会麻烦一些
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <stdio.h>

// 枚举定义一周七天,默认从0开始
typedef enum {
MONDAY, // 0
TUESDAY, // 1
WEDNESDAY, // 2
THURSDAY, // 3
FRIDAY, // 4
SATURDAY, // 5
SUNDAY // 6
} WeekDay;

// 如果想要从1开始,手动指定第一个值
/*
typedef enum {
MONDAY = 1,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
} WeekDay;
*/

int main(void)
{
WeekDay today = FRIDAY;
printf("今天是星期%d\n", today + 1);
return 0;
}

类型

  • 正常写 C 语言的时候我们定义一个整型会用 int,但是我们在开发单片机的时候会用 uint8_t uint16_t 这种形式来定义,是因为直接用 int 来定义在不同的系统上(也就是不同的单片机上,可能占有的自己数是不一样的 有的单片机上 int 可能占有 2 个字节,有的占 4 个字节)
  • 无符号,带 u 的就是只能是正数,例如 uint8,只能用来表示正数,也就是取值范围是 0000 0000 <----> 1111 1111 即 0-255
  • 带符号,没有 u ,取值可以是正数也可以是负数,最高位用来表示符号 0表示正数,1表示负数,以 int8 为例 同样取值范围是 1111 1111 <----> 0111 1111 ,但是真正表示数值的只有后面 7 位,即 -128 <-> 127

单片机

常听说的单片机包括: C51 单片机,stm32,esp

名称 用途
C51 应该已经被淘汰了?很老的东西了,主要用于教学,简单控制
stm32 工业控制,量产产品,学校比赛…
esp IoT,WiFi 产品

单片机工作原理:单片机上有很多的引脚,引脚可以接入对应的外设,通过切换寄存器(全部封装在单片机芯片内部,黑色方块 IC 里面,寄存器会和引脚物理上进行连接)的值(0/1 两种状态),去控制外设
单片机所有外设(串口、ADC、GPIO、定时器)全都靠寄存器操控

TODO…

macos 查看串口
通常我们用IDE或者 cmd 的方式去设置目标芯片后,通过命令 idf.py flash 会自动去找对应的串口,如果提示找不到或者有多个串口是可以尝试通过指定串口的方式去执行烧录,idf.py -p PORT flash
另外就是串口监听的命令了 idf.py [-p PORT] monitor 也可以在多设备下指定串口

项目规范化

  • 下面是一个项目模板,可参照对应格式进行项目管理,实现类似分模块的效果

LED 示例

监控截图

日志系统

大多数完善的框架都会有自己的日志系统,或者集成第三方日志框架,在 C 语言中可以调用函数 print 这些去进行打印,但是无法实现产品上下线的控制输出,或者通过 if 判断这些并不灵活

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include "esp_log.h"
#include "esp_log_level.h"
#include <stdbool.h>

const char *TAG = "main";
bool debug_mode;

void app_main(void) {
// 通过读取配置文件来开启调试模式
debug_mode = false;
if (debug_mode) {
esp_log_level_set(TAG, ESP_LOG_INFO);
} else {
esp_log_level_set(TAG, ESP_LOG_VERBOSE);
}

ESP_LOGE(TAG, "错误级别日志1");
ESP_LOGW(TAG, "警告级别日志1");
ESP_LOGI(TAG, "信息级别日志1");
ESP_LOGD(TAG, "调试级别日志1");
ESP_LOGV(TAG, "详细级别日志1");

debug_mode = true;
if (debug_mode) {
esp_log_level_set(TAG, ESP_LOG_INFO);
} else {
esp_log_level_set(TAG, ESP_LOG_VERBOSE);
}
ESP_LOGE(TAG, "错误级别日志2");
ESP_LOGW(TAG, "警告级别日志2");
ESP_LOGI(TAG, "信息级别日志2");
ESP_LOGD(TAG, "调试级别日志2");
ESP_LOGV(TAG, "详细级别日志2");
}

  • 设置日志最高的输出详细等级:Component config -> Log -> Log Level -> Maximum log verbosity -> Verbose
  • 线上使用可把变换 debug_mode 的值设置在某个按钮一次性触发或者设置在配置文件中修改后触发

日志控制

看门狗

以任务看门狗(task dog 为例)有点类似一个监督者,会监督查看某个任务是否会一直被占用没有释放

  • 释放 CPU 除了 vTaskDelay 外,也可以有其它 函数|方式 去释放资源占用
  • 这里的预警时间周期不是固定 5 秒,而是默认时间,也可以进行修改,具体见第二个 tab
  • 触发后动作也不一定是系统复位,也是可以修改的见第三个 tab

修改时间

  • 下面的 Watch CPU0 Idle Task Watch CPU1 Idle Task 是因为我们选择的芯片有两个 cpu 所以可以看成每个 cpu 上都有"一条狗",并且都是需要每隔"5 秒"去"喂狗"
  • Invoke panic handler on Task Watchdog timeout:任务看门狗超时触发系统异常崩溃处理函数(Panic)
  • 开启该选项后:当任务看门狗定时器(Task WDT)发生超时时,会自动调用 ESP-IDF 的 panic handler 系统崩溃处理函数;该功能也可以在程序运行时通过任务看门狗的官方 API 动态配置开关。
    Panic相关配置

自定义menuconfig

  • 在 main 文件夹下新建文件 Kconfig.projbuild 并遵循对于的语法编写菜单
  • 每次修改后记得重新编译一下项目

自定义菜单

名称 含义
menu 定义可折叠的配置菜单分组,用于归类多个配置项
endmenu 结束 menu 菜单定义,必须与 menu 成对使用
config 声明一个配置项,最终会生成 CONFIG_配置名 宏变量
bool 布尔类型,仅支持 y(开启) / n(关闭) 两种开关状态
tristate 三态类型,支持 y(内置编译)m(编译成模块)n(不编译)
int 整型配置,允许用户输入十进制数字,宏为数值
hex 十六进制配置,用于填写硬件地址、寄存器等十六进制数值
string 字符串类型,用户可自定义输入文本内容
prompt 配置项在 menuconfig 界面展示的提示文字
default 设置配置项的默认值(y/n/m/数字/字符串均可)
help 配置项的详细描述说明,在配置界面可查看帮助文档
choice 定义一组下拉单选框,同一时间只能选中其中一个选项
endchoice 结束 choice 单选配置组,必须成对使用
depends on 配置依赖,只有前置配置满足条件时,当前配置才可见可修改
select 反向依赖,当前配置选中时,自动强制开启另一个配置项
range 限制 int/hex 类型配置的合法输入数值范围(最小值,最大值)
if 条件配置块,条件成立时,块内所有配置才会显示
endif 结束 if 条件配置块,必须成对使用
optional 用于 choice 组内,允许该单选组所有选项都不选中
source 导入外部Kconfig配置文件,实现配置模块化拆分
comment 在菜单中插入纯说明文本,仅用于展示,不会生成任何宏

系统API

注: 以 ESP32-S3 基于 ESP-IDF V5.5.4 为例

官方地址:杂项系统 API

freertos

main函数内存不够

  • 其实我们的 void app_main(void) { … } 函数也是一个 freertos 的任务,esp的 menuconfig 是给了默认的内存大小的,如果我们在这个任务也就是 app_main 函数中申明了很多内存地址,当超出系统设定的值以后肯定就会报错,毕竟我没有给你那么大的内存空间,你超了肯定不对,这个时候就可以去通过修改 menuconfig 的设置调大我们的值
  • 修改 Component config -> ESP System Settings -> Main task stack size 的值

示例

GPI外部中断(EXIT)

  • 立即响应: 让 MCU 在事件发生时,立即响应,而不是不停地轮询,举个简单的例子如果是飞机刹车系统,每 1 秒轮询一次是否有刹车,和触发刹车按钮以后立即响应,可想而知带来的影响是天差地别
  • 节省资源: 避免 CPU 空转,提高资源利用率,达到节能高效
  • 除了外部中断(按键这种触发),也用内部触发的,就是芯片内部

  • 如果没有中断,那么事件只能等到主程序执行完毕后才能执行事件的响应
    外部中断

  • 中断的触发方式: ①电平触发(高电平/低电平时触发) ②边沿触发(上升沿/下降沿/双边沿触发)

  • 电平触发需要保持高/低电平状态直到 CPU 响应 边沿触发是瞬间即可响应
    触发方式

  • 数值越大越优先 NMI > 6 > 5 > 4 > 3 > 2 > 1 共6个等级?
  • 如果当前同时发生 4 和 2 两个优先级的中断,优先处理 优先级为 4 的,在 4 处理完后 又发生一个 3 的,那么也会优先处理 3 的,不会因为 2 原本触发的时间早就先执行,一切顺序都是看优先级的

定时器

  • 周期运行任务

Button 示例

  • 上拉电阻,默认是高电平,也就是默认是 Vcc 状态,没有按下时, 电平为1,按下以后电平变为 0
  • 下拉电阻,默认是低电平,也就是默认是 GND 状态,没有按下时, 电平为0,按下以后电平变为 1
1
2
3
4
5
6
7
8
#ifndef MAIN_CONFIG_H
#define MAIN_CONFIG_H

#include "driver/gpio.h"

#define BUTTON_PIN_16 GPIO_NUM_16

#endif
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

#include <stdio.h>
#include "driver/gpio.h"

#include "config.h"

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

void app_main(void)
{
// 重置+配置按钮引脚+设置为上拉电阻
gpio_reset_pin(BUTTON_PIN_16);
gpio_set_direction(BUTTON_PIN_16, GPIO_MODE_INPUT);
gpio_set_pull_mode(BUTTON_PIN_16, GPIO_PULLUP_ONLY);

while (1)
{
// 读取按钮状态并打印
int button_state = gpio_get_level(BUTTON_PIN_16);
printf("Button state: %d\n", button_state);
// 每秒读取一次按钮状态
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}

运行示例

按钮控制LED

1
2
3
4
5
6
7
8
9
10
11
12
#ifndef MAIN_CONFIG_H
#define MAIN_CONFIG_H


#include "driver/gpio.h"

#define BUTTON_PIN_16 GPIO_NUM_16
#define LED_PIN_47 GPIO_NUM_47
#define HIGH_LEVEL 1
#define LOW_LEVEL 0

#endif // MAIN_CONFIG_H
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <stdio.h>

#include "config.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

void app_main(void)
{
gpio_reset_pin(BUTTON_PIN_16);
gpio_reset_pin(LED_PIN_47);

gpio_set_direction(BUTTON_PIN_16, GPIO_MODE_INPUT);
gpio_set_pull_mode(BUTTON_PIN_16, GPIO_PULLUP_ONLY);

gpio_set_direction(LED_PIN_47, GPIO_MODE_OUTPUT);
while (1)
{
// 返回的结果只有 0 和 1,用 uint8_t 类型接收比 int 类型更合适,因为它占用更少的内存空间,并且足够表示按钮状态的两种可能值(0 和 1)
uint8_t button_state = gpio_get_level(BUTTON_PIN_16);
printf("Button state: %d\n", button_state);
// 上拉电阻按下时值是 0,松开时值为 1,因此值为 0 时点亮 LED,值为 1 时熄灭 LED
gpio_set_level(LED_PIN_47, button_state == LOW_LEVEL ? HIGH_LEVEL : LOW_LEVEL);
// 500ms 轮询一次按钮状态,避免过于频繁地读取引脚状态导致性能问题
vTaskDelay(pdMS_TO_TICKS(500));
}
}

按钮控制LED闪灭

  1. 在上面我们是通过写一个 config.h 把引脚和高低电平设置弄成了一个类似 配置文件 的方式,但是官方的推荐写法是采用结构体进行配置
  2. 使用 gpio_config_t 结构体来进行配置 GPIO相关信息
1
2
3
4
5
6
7
8
9
10
gpio_config_t io_conf = {
// 配置按钮和LED引脚 可配置单个或通过 | 运算符配置多个引脚 pin_bit_mask 是 uint64_t 类型,支持最多 64 个 GPIO 引脚的配置
// ULL 表示无符号长长整型常量,确保在 32 位和 64 位平台上都能正确表示 64 位的位掩码 1ULL 相当于 0000....0001,前面 63 个 0,最后一个是1
// 通过 << 左移 gpio_num_n 位后就会得到一个对应 GPIO 引脚的位掩码,从而最多支持配置 64 个 GPIO 引脚
.pin_bit_mask = (1ULL << GPIO_NUM_16) | (1ULL << GPIO_NUM_26),
.intr_type = GPIO_INTR_DISABLE, // 禁止中断
.mode = GPIO_MODE_INPUT, // 设置为输入模式
.pull_down_en = 0, // 禁止下拉
.pull_up_en = 1 // 启用上拉
};
  1. pin_bit_mask: 需要设置的引脚,可以只设置一个 如 .pin_bit_mask = (1ULL << GPIO_NUM_16), 也可以设置多个,通过 | 符号拼接,如 .pin_bit_mask = (1ULL << GPIO_NUM_16) | (1ULL << GPIO_NUM_26), 但是这种需要它的所有的属性都是一样的只有 GPIO 口不一样
  2. 项目实例
    结构体进行配置

在左侧 table 结构体优化 篇章中,其实这种方式也并不优雅,要重复的去定义 gpio_config_t 与 gpio_config 所以我们可以自己去封装一个通用的 GPIO 初始化函数,实现一行代码就能初始化一个 gpio

  1. 先封装组件,在根目录下新建 3 个文件分别是 components/gpio_utils/gpio_utils.c components/gpio_utils/gpio_utils.h components/gpio_utils/CMakeLists.txt

gpio_utils.c 内容如下(具体的实现方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "gpio_utils.h"

/**
* @brief 简单的 GPIO 初始化函数,适用于单个引脚的配置
* @param gpio_num 引脚编号
* @param mode GPIO 模式(可选值 input, output, etc.)
* @param pullup 是否启用上拉电阻(true: 启用,false: 禁用)
* 调用示例:gpio_simple_init(GPIO_NUM_16, GPIO_MODE_INPUT, true);
*/
void gpio_simple_init(gpio_num_t gpio_num, gpio_mode_t mode, bool pullup)
{
uint64_t mask = 1ULL << gpio_num;
gpio_config_t conf = {
.pin_bit_mask = mask,
.intr_type = GPIO_INTR_DISABLE,
.mode = mode,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.pull_up_en = pullup ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE
};
gpio_config(&conf);
}

/**
* @brief 批量 GPIO 初始化函数,适用于多个引脚的配置
* @param pin_mask 引脚掩码
* @param mode GPIO 模式(可选值 input, output, etc.)
* @param pullup 是否启用上拉电阻(true: 启用,false: 禁用)
* 调用示例:gpio_batch_init((1ULL << GPIO_NUM_16) | (1ULL << GPIO_NUM_47), GPIO_MODE_OUTPUT, false);
*/
void gpio_batch_init(uint64_t pin_mask, gpio_mode_t mode, bool pullup)
{
gpio_config_t conf = {
.pin_bit_mask = pin_mask,
.intr_type = GPIO_INTR_DISABLE,
.mode = mode,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.pull_up_en = pullup ? GPIO_PULLUP_ENABLE : GPIO_PULLUP_DISABLE
};
gpio_config(&conf);
}

components/gpio_utils/gpio_utils.h 内容如下(对外暴露的头文件)

1
2
3
4
5
6
7
8
9
#ifndef GPIO_UTILS_H
#define GPIO_UTILS_H

#include "driver/gpio.h"

void gpio_simple_init(gpio_num_t gpio_num, gpio_mode_t mode, bool pullup);
void gpio_batch_init(uint64_t pin_mask, gpio_mode_t mode, bool pullup);

#endif

CMakeLists.txt 内容如下

1
2
3
4
5
idf_component_register(
SRCS "gpio_utils.c"
INCLUDE_DIRS "."
REQUIRES driver
)

示例

注:

  1. 采用组件的模式所以 components/gpio_utils 文件夹下三个文件 CMakeLists.txt gpio_utils.h gpio_utils.c 一个都不能少(目前写的初始化函数gpio_simple_init gpio_batch_init 都只有三个参数,所有有的是采用默认值的,后面不够了可以再加参数即可)
  2. main文件夹下的 CMakeLists.txt 需要 REQUIRES gpio_utils 引入这个自己写的组件
  3. main.c 中需要 #include “gpio_utils.h” 这个头文件
  4. 这里的 gpio_config.h 好像并没有在 CMakeLists.txt 有任何配置,应该是和 main.c 是同一层级的文件(个人感觉)

PWM

  • 通过调节高低电平输出的频率实现LED的亮度和时间,呼吸灯的效果,可以用作呼吸灯 灯条 灯带的效果 TODO

ADC

  • 模拟信号(Analog): 生活中连续连续变化的物理量,如:声音,温度,压力
  • 数字信号(Digital): 计算机系统中用于存储和处理的信号 0 和 1
  • ADC: 模数转换器(Analog-to-Digital Converter),一个将模拟信号转换为数字信号的器件
    ADC
ADC类型 转换速度 分辨率 抗噪声能力 典型应用场景
逐次逼近型 (SAR ADC) 中等,几 μs~ 几十 μs,单通道速度快 28 ~ 218(256 ~ 262144) 一般,易受电源、时钟噪声影响 工业采集、单片机外设、便携式仪器、电池电压检测
双积分型 ADC 很慢,毫秒级转换 212 ~ 224(4096 ~ 16777216) 极强,工频干扰抑制优秀 数字万用表、温控仪表、低频微弱信号测量
流水线型 (Pipeline ADC) 极快,ns 级,MSPS~GSPS 级别 28 ~ 216(256 ~ 65536) 中等,多级级联噪声会逐级累积 通信基带、雷达信号采集、高速图像采集、示波器前端
闪速型 (Flash ADC) 速度最快,纳秒级超高采样 23 ~ 28(8 ~ 256) 较差,参考分压多易引入噪声 高频瞬时信号检测、雷达快捕、高速电平比较
Σ-Δ 型 (Delta-Sigma ADC) 低速,Ksps~ 几百 Ksps 216 ~ 232(65536 ~ 4294967296) 优秀,过采样 + 数字滤波降噪 音频采集、精密称重、心电 / 脑电生物信号、高精度传感器
电容阵列逐次逼近 (Cap SAR) 较快,低功耗场景下高速 210 ~ 216(1024 ~ 65536) 较好,版图优化后噪声控制优良 手机传感器、触控检测、穿戴设备、低功耗物联网设备
  • 注: ESP32-S3用的是逐次逼近型,官方手册标准的分辨率是12位,也就是212=4096
    20260711032744

转换流程

  • 输入模拟信号 -> 采样 -> 保持 -> 量化 -> 编码
  • 采样:将时间上连续的信号变为时间上离散的脉冲信号
  • 保持:锁定采样瞬间的电压值,给转换电路足够的时间进行处理
  • 量化:将保持的模拟电压值近似为最小量化单位的整数倍(比如采样的信号是 37.9284 会向下取整为 37)
  • 编码:将量化后的数值转换成二进制代码输出
  • 基于ESP32-S3计算
  • 参考电压: 3.3V(这是ESP32-S3的)
  • ADC最值:0-4095 (因为212=4096)
  • 引脚输入电压=(引脚读取到的AD值/分辨率)*参考电压,公式如下

引脚输入电压=(引脚读取到的AD212)×3.3V\text{引脚输入电压} = \left( \frac{引脚读取到的AD值}{2^{12}} \right) \times 3.3V

分区表

在嵌入式领域常见的两个存储名词,RAMFLASH,可以粗略的理解为电脑的内存条和硬盘的区别
RAM: (Random Access Memory),中文名:随机存取存储器,读写速度极快,容量小,掉电后数据丢失。(就像电脑的内存条一样,同理这个也是一样,程序运行时把变量这些加载到内存中速度更快)
FLASH: (Flash Memory),中文名:闪存存储器,读写速度较 RAM 慢,容量大,掉电后数据不丢失。(就像电脑的硬盘一样)
N16R8:以ESP32S3-WROOM-1-N16R8模组为例,这里的 16 则是指的 FLASH 的大小,R8 指的 RAM 的大小(严格的叫法是 PSRAM )
个人理解:这里所谓的相对的快慢速度是指的同一产品上的设计方案或者同一时代的两种存储,应该和电脑的内存和硬盘是一个原理,与用的什么颗粒有关,RAM 设计的相对小是因为造价更高并且程序运行时存储在堆栈里面的变量这些并不需要像硬盘那么大的空间

如下图,电脑通过 USB(或者 OTA 等方式),给芯片烧录固件的时候就像给电脑安装系统,这个时候是把固件烧录到我们的 FLASH 存储上面的,就像电脑把系统是安装在 C 盘里面,但是C盘也只是硬盘的一部分,程序运行的时候CPU 进行相关计算,会去 PSRAM 中取对应的变量信息,但是PSRAM本身是没有的,所以 PSRAM 会在程序运行时把这些程序变量数据从 FLASH 中存储在自己 PSRAM 内存中,因为 PSRAM 比 FLASH 更快,相当于做了个承上启下的作用

内部图结构

注: 通过上图中的左侧部分我们可以发现,PSRAM 是在芯片里面的(所以速度除了颗粒不一样还有距离?就像之前苹果的M系芯片统一内存管理和英伟达新发布的CPU RTX Spark ARM架构方案的原理?),FLASH 是和 MCU 通过一些线接起来的

  1. 就是对 FLASH 进行分区的表格,对于一个存储器来说它就像一个快递站,是一个存放各种快递的仓库,有京东快递的,有圆通的,有顺丰的,有韵达的各种各样的快递,我们要给这个仓库划分区域,100m² 的屋子,我们根据每个快递公司的货物量来分配不同的面积给他们,同样 FLASH 也是一样你要给它划分不同的区域哪里是存固件的 哪里是存自己的数据的,哪里是系统关键信息的… 这个和电脑一样,你要给电脑硬盘分区,C盘系统盘划分多少G,D盘多少,或者还分割E F…盘分别多少,这个就是关于分区大小的划分
  2. 那么对应一个存储器来说怎么去知道那块区域是谁的呢? 存储器都有一个物理地址以 16MB 的为例,起始地址是 0x00000000 结束地址是 0x01000000

16MB=16*MB=16*1024KB=16*1024*1KB=16*1024*1024B=16,777,216B 用 16 进制表示就是 0X1000000
0x1000=4KB 0x10000=64KB 0x100000=1MB

分区表对照图

注: 我们在使用开发板或者模组时如果没有手动去设置分区表,会系统默认给他设置好的分区表策略

查看默认分区配置

名称 含义
name 分区名称,比如上面图片的 boot、partition table、nvs、phy_init、user
type 分区类型只有 data 和 app 两种
SubType 分区类型的细分类型,也就是 data 和 app 的二级分类
offset 物理地址的偏移地址,就是该区域物理存储的起始位置,也就是上面图片中的 0x00000000 0x00008000 0x00009000 这些
Size 该分区的分区大小,可用 0x…这种 16 进制表示,也可以写 xKB yMB 但是我看一般大家都写十六进制
Flags 该分区是否用于一些特殊设置,例如对该分区进行加密或者设置为只读一般我们自己的都是空着不使用
type SubType 说明
date fat FAT文件系统类型
ota OTA远程升级相关数据
phy WIFI/蓝牙射频校准参数
nvs WIFI/蓝牙配置信息
nvs_key NVS加密密钥
spiffs SPIFFS文件系统类型
littlefs LittleFS 高可靠 Flash 文件系统(推荐替代 SPIFFS) 大小必须是4KB也就是0x1000的整倍数
coredump 存储系统崩溃数据
app factory 存储程序代码固件 大小必须是64KB也就是0x10000的整倍数
ota0~ota15 存储OTA远程升级固件
test 存储测试程序固件

注:

  1. data大类存放数据,app 大类存放代码
  2. 我们常用的就两种 date 的 spiffs 类型和 app 的 factory 类型,分别用于存文件和存代码
  3. 上面一张查看分区配置的截图中,只能看到从 0x6000 nvs 开始的分区,也就是前面有 24KB 的空间是不能使用的,这个就是官方限制的,nvs 前面的 boot、partition table 是和系统相关的不能去设置去修改,并且nvs、phy_init 这些也最好安装官方对你当前模组的大小给的参数去设置(上面我们查看的那个默认分区设置是并不知道我们的模组的 FLASH的大小的,所以并不是所谓的当前自己使用模组的建议方案可以租官方找找看)
  1. 使用分区表编辑器(或者手写 csv 但是要注意格式),对FLASH进行分区
    partitions.csv设置

  2. 使用 Sdkmenuconfig 中将默认分区表改成自定义分区表
    配置为自定义的 partitions table

使用esp_vfs_spiffs_register()函数初始化文件系统参数
调用C语言文件操作函数操作文件(建立文件/打开文件/写数据/读数据)

  1. factory 的大小设置
    在我们上面的示例中我们 type=app Subtype=factory 起始地址 Offset 是 0x10000,Size=0x100000 也就是大小设置为的 1M,如何去看这个大小设置的过大?过小还是合适?
    factory 空间评估

  2. 查看是否使用了自定义分区
    确认是否使用了自定义分区表

  3. FLASH物理实际大小与配置中大小的关系
    我们在使用自定义分区的时候要保存我们自己设置的 partitions.csv 分区表的大小不要超过最好是和我们模组中 FLASH 大小一致,也就是最后一行中的 Offset + Size = 模组 FLASH 的大小,比如我的 N16R8 就是 16MB 的 FLASH 存储大小,我的最后一行的 Offset + Size = 0x110000 + 0xef0000 = 0x1000000 = 16777216B = 16384KB = 16MB
    以下是 partitions.csv 的容量 > Sdkmenuconfig 中 Flash Size 的情况,在编译时就会报错
    分区表大于配置的 Flash Size

Sdkmenuconfig 中 Flash Size > 模组 FLASH 实际大小的情况,运行报错
Flash Size 大于模组实际 FLASH 的大小

nvs

  • NVS: Non-Volatile Storage(非易失性存储),说人话就是个硬盘差不多,断电之后数据不会丢失、可以永久保存在 Flash 闪存里的轻量级键值对(Key-Value)存储库
  • NVS 有很多存储方案,我自己用到的是 littlefs
  • LittleFS 是 ARM 官方开源、专为 MCU 微控制器设计的轻量级、掉电安全型嵌入式文件系统
  • 内置动态磨损均衡:自动分散所有擦写操作到整个 Flash 分区,避免频繁修改某一块导致闪存提前报废,自带坏块检测隔离,大幅延长 NOR Flash 使用寿命。
  • 和 SPIFFS 对比: SPIFFS 已停止维护,存在断电极易损坏、不支持多级目录、磨损均衡弱等缺陷;LittleFS 是乐鑫官方推荐的下一代替代方案,新项目一律优先使用 LittleFS
  • 这里是一个示例,我有个 JSON 文件,通过 LittleFS 给他存储在 FLASH 中,并用JSON去解析的部分实现
  • esp_err_t err = esp_vfs_littlefs_register(&conf); esp_err_t 就是返回的挂载状态 只有 #define ESP_OK 0 表示成功的,具体其它的返回可以对着开发文档查具体的内容
  • 要想使用 LittleFS 必须要进行分区 也就是配置 partition table 可以查看对应上面的分区表相关内容,以及引入对应的 #include “json/cJSON.h” #include “esp_littlefs.h”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
static const char *MOUNT_PT = "/storage"; /* 与 partitions.csv 分区名一致 */
static const char *PARTITION = "storage";
/* ═══════════════════════════════════════════════════════════════
* 公开 API 实现
* ═══════════════════════════════════════════════════════════════ */
int gamepad_config_load(const char *filename, GamePadConfig *response) {
if (!filename || !response)
return -1;

/* ── 1. 挂载 LittleFS(幂等) ──────────────────────────────── */
if (!s_mounted) {
esp_vfs_littlefs_conf_t conf = {
.base_path = MOUNT_PT,
.partition_label = PARTITION,
.format_if_mount_failed = false,
.dont_mount = false,
};
esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err != ESP_OK) {
ESP_LOGE(TAG, "LittleFS mount failed: %s", esp_err_to_name(err));
return -1;
}
s_mounted = true;
ESP_LOGI(TAG, "LittleFS mounted at %s", MOUNT_PT);

/* ── 列出根目录,确认文件已烧入 ── */
DIR *dir = opendir(MOUNT_PT);
if (dir) {
struct dirent *entry;
ESP_LOGI(TAG, "目录内容 [%s]:", MOUNT_PT);
while ((entry = readdir(dir)) != NULL) {
ESP_LOGI(TAG, " %s", entry->d_name);
}
closedir(dir);
} else {
ESP_LOGW(TAG, "无法打开挂载点目录: %s", MOUNT_PT);
}
}

/* ── 2. 拼接文件路径 ───────────────────────────────────────── */
char path[FILE_PATH_MAX];
snprintf(path, sizeof(path), "%s/app/%s", MOUNT_PT, filename);

/* ── 3. 打开文件 ───────────────────────────────────────────── */
FILE *f = fopen(path, "r");
if (!f) {
ESP_LOGE(TAG, "Cannot open file: %s", path);
return -2;
}

/* ── 4. 读取文件内容 ───────────────────────────────────────── */
char *buf = (char *)malloc(JSON_BUF_MAX);
if (!buf) {
ESP_LOGE(TAG, "malloc failed (%d bytes)", JSON_BUF_MAX);
fclose(f);
return -3;
}
size_t len = fread(buf, 1, JSON_BUF_MAX - 1, f);
fclose(f);

if (len == 0) {
ESP_LOGE(TAG, "File is empty or read error: %s", path);
free(buf);
return -3;
}
buf[len] = '\0';
ESP_LOGI(TAG, "Read %u bytes from %s", (unsigned)len, path);

/* ── 5. 解析 JSON ──────────────────────────────────────────── */
cJSON *root = cJSON_Parse(buf);
free(buf); /* 解析完立即释放读取缓冲 */

if (!root) {
ESP_LOGE(TAG, "JSON parse error near: %s",
cJSON_GetErrorPtr() ? cJSON_GetErrorPtr() : "unknown");
return -4;
}

/* ── 6. 清零结构体,保证未命中字段有安全默认值 ─────────────── */
memset(response, 0, sizeof(GamePadConfig));

/* ── 7. 逐层解析写入 response ──────────────────────────────── */
const cJSON *node;
if ((node = cJSON_GetObjectItemCaseSensitive(root, "meta")))
parse_meta(node, &response->meta);
if ((node = cJSON_GetObjectItemCaseSensitive(root, "runtime")))
parse_runtime(node, &response->runtime);
if ((node = cJSON_GetObjectItemCaseSensitive(root, "base")))
parse_base(node, &response->base);
if ((node = cJSON_GetObjectItemCaseSensitive(root, "key_aliases")))
parse_key_aliases(node, response);
if ((node = cJSON_GetObjectItemCaseSensitive(root, "groups")))
parse_groups(node, &response->groups);

cJSON_Delete(root);

ESP_LOGI(TAG, "Config loaded: %s v%s -> normal=%d macro=%d joy=%d",
response->meta.game_target, response->meta.version,
response->groups.normal_btn_count, response->groups.macro_btn_count,
response->groups.joystick_count);

return 0;
}

性能考量

提升编译速度

设置最小构建选项,减少编译时间 和生成的二进制文件大小 idf_build_set_property(MINIMAL_BUILD ON) 实际发现放在 project 前,比放在 project 后需要打包的内容更少一些,也就是构建更快一点

1
2
3
4
5
6
cmake_minimum_required(VERSION 3.16)

include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# 设置最小构建选项,减少编译时间 和生成的二进制文件大小,但是会限制一些功能的使用,例如不支持动态内存分配等。
idf_build_set_property(MINIMAL_BUILD ON)
project(LittleFS_JSON_Example)

define

能用 #define 定义的尽量用,因为用#define 定义的是在编译时就会被替换的,而变量是在运行时产生的,所以 #define 会更省内存一些