ESP32基础学习

0 参考资料

1 项目介绍

芯片、模组、开发板

ESP32芯片内部结构框架简图

模组的结构框架简图

2 外设

2.1 通用输入输出端口GPIO

  • GPIO的初始化配置流程
    • 使用gpio_config()函数来配置GPIO;
    • 使用gpio_set_level()函数来控制iO口输出高电平/低电平;
    • 使用gpio_get_level()函数来读取当前lO口的电平状态;

2.2 外部中断EXTI

图中,左侧的这一列是中断源(ESP32有99个),中间的是中断矩阵,最右侧的则是两个核心(即CPU)。这其中每一个中断源都需要连接中断矩阵,当某一个中断源发生特定的中断时,比如串口发生了接收中断,该中断会进入中断矩阵听从中断矩阵的统一安排,中断矩阵会将它分配到某一个中断通道,进入特定的CPU,由该CPU执行此中断。从这段话的描述可以看出,中断矩阵有点类似于STM32中的NVIC中断向量控制

EXTI可以监测指定IO口的电平信号,当其指定的IO口产生电平变化时,EXTI将立即向中断矩阵发出中断申请,经过中断矩阵裁决后即可中断CPU主程序,使CPU执行EXTI对应的中断程序

  • EXTI的初始化配置流程
    • 使用gpio_config()函数配置GPIO
    • 使用gpio_install_isr_service()函数向中断矩阵注册中断,有点类似于配置NVIC
    • 使用gpio_isr_handler_add()函数将IO口与中断服务函数匹配
    • 使用gpio_intr_enable()函数开启中断

2.3 通用定时器TIM

2.3.1 简介ESP32的时钟系统

ESP32-S3的时钟主要用来给CPU、外设或其他功能电路提供脉冲在芯片内部,所有功能模块的工作均按照时钟节拍进行。相当于芯片的“心脏”。

ESP32-S3的时钟主要来源于振荡器、RC震荡电路和PLL时钟生成电路。产生的时钟,经过各种分频器、选择器处理之后,使得大部分功能模块可以根据不同功耗和性能需求来获取对应频率的工作时钟。

下图为简化的框图:

根据图示,CPU的时钟是由CPU_CLK时钟提供的,CPU_CLK的时钟源有三个:PLL_CLK(480MHz)、XTAL_CLK(40MHz)、RC_FAST_CLK(17.5MHz)。下面的框图是外设时钟,由于ESP32S3内部的外设有很多,因此外设部分所对应的时钟源也有很多,左侧下半部分均是可选择的时钟,注意:外设部分有一个默认时钟源,也就是绿色框中的这个APB_CLK,当我们使用外设但是没有配置它的时钟源时芯片会默认使用APB_CLK作为这个外设的时钟源

但是CPU的时钟好像并没有默认时钟源,而且在之前编写代码的过程中好像并没有配置过CPU的时钟(源),那CPU的时钟(源)又使用哪一个呢?其实这一部分的操作ESP-IDF插件已经帮我们自动完成了。在之前搭建工程模板的过程中,在可视化配置页面配置过CPU的主频,ESP-IDF插件会根据我们所选择的CPU主频,自动匹配对应的CPU时钟源。比如若设置CPU主频为240MHz,那ESP-IDF会自动选择PLL_CLK作为时钟源(480MHz的频率会经过分频器进行2分频进入CPU)。
总之,使用VSCode编写ESP32代码,CPU所对应的时钟源是自动匹配的,而外设部分的时钟源则需要我们自行选择。

ESP32S3内部有很多外设,我们怎么知道哪一个外设对应选择哪一个时钟源呢?官方给我们提供了一个外设时钟源的选择表:

比如当我们要配置SPI外设时钟源时,可选择XTAL_CLK和APB_CLK。

2.3.2 通用定时器TIM外设简介

ESP32-S3包含两组通用定时器,即定时器组0和定时器组1,每个定时器组都有2个通用定时器1个主系统看门狗定时器,如下图所示:

下图是通用定时器的框图:

  • TIM的初始化配置流程
    • 使用gptimer_handle_t定义定时器句柄(给定时器起名,也就是给定时器起一个名方便我们后续使用)
    • 使用gptimer_new_timer()函数配置时钟源和计数器(比如计数方向、计数步长等)
    • 使用gptimer_set_alarm_action()函数配置比较器的动作(比如设置值、重置值、是否打开重置开关等)
    • 使用gptimer_register_event_callbacks()函数配置报警事件,也就是向中断矩阵/NVIC中注册中断
    • 使用gptimer_enable()函数使能通用定时器
    • 使用gptimer_start()函数开启通用定时器

【ESP32-S3 定时器详解:esp_timer 与 GPTimer 的工程级选择】

2.3.3 PWM控制器

ESP32S3中有两种PWM控制器:LEDPWM和MCPWM,其中LEDPWM是我们常用的PWM控制器,MCPWM是专门用于电机控制的PWM控制器。

LEDPWM控制器用于生成控制LED的PWM,具有占空比自动渐变等专门功能。该外设也可生成PWM信号用作其他用途。

ESP32S3具有八个独立的PWM生成器(即八个PWM输出通道)和四个独立定时器给PWM生成器提供时钟。四个定时器可独立配置,但是共用一个时钟源。每个PWM生成器会在四个定时器中选择一个,以该定时器为基准生成PWM信号。如下图所示:

PWM控制器框图如下图所示:

  • LEDPWM控制器初始化配置流程
    • 使用ledc_timer_config()函数配置时钟源和定时器参数
    • 使用ledc_channel_config()函数配置PWM控制器参数
    • 使用ledc_set_duty()函数修改占空比,使用ledc_update_duty()函数更新占空比

注意,使用ledc_timer_config()函数配置时候,频率和分辨率的大小不是随意配置的,需要根据官方提供的表:

2.4 系统定时器Systick

ESP32-S3包含一组系统定时器,可用于生成操作系统所需的滴答定时中断,也可以用作普通定时器使用(可以把系统定时器当作滴答定时器,用来状态机挺不错的)。系统定时器内部包含两个计数器和三个比较器:

系统定时器的工作原理框图:

  • Systick的初始化配置流程
    • 使用esp_timer_handle_t定义定时器句柄(起别名)
    • 使用esp_timer_create()函数创建一个定时器并配置报警事件
    • 使用esp_timer_start_periodic()esp_timer_start_once()函数配置比较器值,并开启定时器

注意1:计数器的计数步长是固定的(1us)。
注意2:esp_timer_start_periodic()函数将Systick配置为周期模式,esp_timer_start_once()函数将Systick配置为单次模式。

2.5 模数转换ADC外设

ESP32S3ADC资源:SAR ADC1、SAR ADC2共20路输入通道(SAR表示逐次逼近型),每个ADC有10路转换通道,也就是说每个ADC可以同时转换10路模拟信号。但是在ESP32S3的官方手册中指出:ESP32S3的ADC2控制器无法正常工作,这可能与芯片内部的设计有关,我们不用管,当使用ADC的时候选择ADC1即可。

2.5.1 ADC连续转换模式

ADC工作流程框图:

图中,ADC控制器中的定时器用于生成采样频率的时钟;阈值比饺器用于在开启ADC中断时当输入模拟电压大于设定的阈值的时候会产生中断;模式控制器用于控制ADC是工作在连续转换模式还是单次转换模式,当工作在连续转换模式时总控制器会自动下达使能转换的命令,此时整个转换步骤全自动进行,不需要我们参与,当工作在单次转换模式时,转换使能的开关则是由用户编写代码进行控制。
ESP32S3的ADC可以多个通道同时进行转换,那应该先转换哪个再转换哪个呢?不同的IO引脚又对应哪一个ADC通道呢?在转换过程中ADC的总控制器并不是随意进行转换的,而是我们会事先制定一个转换表,总控制器在下发配置命令之前,会按照顺序读取转换表中的内容。比如当程序开始运行时,总控制器会首先读取通道0的信息并下发下去通道0对应IO1,此时模拟电压会经过这一系列的步骤并通过通道0最终进入内存中,其转换结果在内存中暂时存放,然后总控制器会读取通道1的信息并将配置信息下发下去,通道1对应IO2,此时IO2引脚的模拟电压会经过这一系列步骤,通过通道1最终进入到内存中,其转换结果同样放在内存中暂时存放。转换表中所有的内容转换完成之后,再从头开始,以此循环,这样多个通道就能够有序的采集和转换了。
当多个通道同时转换时,最终生成的数据是怎样的呢?这些数据我们要怎样使用呢?

  • ADC连续转换模式配置
    • 使用adc_continuous_new_handle()函数配置转换结果存储单元大小、转换帧存储单元大小
    • 使用adc_digi_pattern_config_t结构体配置转换表,当转换多个通道时,可以定义该结构体的数组变量
    • 使用adc_continuous_config()函数配置ADC总控制器需要下发和执行的参数
    • 使用adc_continuous_register_event_callbacks()函数注册回调函数,用于数据读取
    • 使用adc_continuous_start()函数开启连续转换

注意:ADC的各个通道与IO口的关系不是随意指定的,必须查看引脚定义表,比如ADC1CH3-->IO4

补充:电压衰减系数,没有特殊要求配置为0~3.3即可

2.5.2 ADC单次转换模式

注意,要转换几个通道,就需要写几个adc_oneshot_config_channel()函数,开启转换也要写几个adc_oneshot_read()函数。

2.6 串口通信UART外设

UART(Universal Asynchronous Receiver/Transmitter)通用异步收发器:
UART是ESP32S3内部集成的硬件外设,可以将数据缓冲区中的一个字节数据自动生成数据帧时序,从TX引脚发送出去,也可自动接收RX引脚的数据帧时序,拼接为一个字节数据,存放在数据缓冲区里串口通信速度最高达5Mbits/s。

下图是UART工作框图:

我们知道,当两个芯片进行串口通信时,只需要配置好芯片的这部分参数,使这部分参数保持一致就可以正常通信了,而电脑内没有UART接口(硬件插口),因此我们需要使用一个工具模拟出一个带有UART的芯片,这个工具就是串口调试助手

  • UART初始化配置
    • 使用uart_param_config()函数配置时钟源和UART控制器
    • 使用uart_set_pin()函数配置输入输出引脚,不使用硬件流控,最后两个参数可以设为-1
    • 使用uart_driver_install()函数配置发送/接收缓冲区
    • 使用uart_write_bytes()函数向发送缓冲区中写入数据
    • 使用uart_read_bytes()函数从接收缓冲区中读取数据
    • 使用uart_enable_tx_intr()uart_enable_rx_intr()函数使能发送/接收中断
    • 使用uart_flash()函数清空发送或接收缓冲区

2.6 I2C通信外设

ESP32-S3内部集成2个I2C控制器,负责处理I2C总线上的通信;支持主机模式、从机模式;支持7位/10位地址模式;支持不同的通讯速度,标准速度(100 kHz), 快速(400 kHz)。

I2C外设的工作框图:

I2C通信的数据帧是由6个基本时序单元组成,而在基本单元中包括发送一个字节和接收一个字节,那命令控制器怎么知道我们想要发送一个字节还是想要接收一个字节呢?这就需要在开启控制开关之前在命令寄存器中事先制定好一个时序表:比如第一步发送起始位 -- 第二步发送一个字节 -- 第三步读取一个字节 -- 第四步发送一个终止位结束通信,这个表格像一个链条一样,我们把它称之为命令链。当控制开关开启时,命令控制器会根据命令链(命令寄存器)中的内容从上到下依次执行。比如我们想要发送两个字节之后就结束通信,则需将命令链(命令寄存器)中的内容配置为:先执行起始命令,然后执行发送一个字节的命令,再执行一次发送字节的命令,没有读取数据命令直接执行结束通信的命令。此时命令控制器按照这些顺序执行命令,就可以控制SCL控制器和SDA控制器生成一串发送两个字节的数据帧了。

  • I2C外设的工作模式:
    • 使用i2c_param_config()函数配置时钟源、GPIO交换矩阵引脚、I2C参数
    • 使用i2c_driver_install()向CPU注册I2C
    • 使用i2c_cmd_link_create()函数创建命令寄存器中的命令链(存放命令的数组)
    • 使用i2c_master_start()函数向命令链中存入I2C_START命令
    • 使用i2c_master_write_byte()函数向命令链中存入I2C_WRITE命令
    • 使用i2c_master_read_byte()函数向命令链中存入I2C_READ命令
    • 使用i2c_master_stop()函数向命令链中存入I2C_STOP命令
    • 使用i2c_master_cmd_begin()函数开启命令控制器,开始I2C通信
    • 使用i2c_cmd_link_delete()函数删除命令链,释放内存空间

I2C通信前必须要知道的几个事项:

2.7 SPI通信外设

2.7.1 SPI介绍

ESP32-S3内部集成了4个SPI控制器:SPIO、SPI1、通用SPI2 (GP-SPI2)、通用SPI3 (GP-SPI3)。其中:GP-SPI2最多可挂载6个从设备,GP-SPI3最多可挂载3个从设备。支持主机模式和从机模式;主机模式最大时钟速度:80MHz;支持半双工通信和全双工通信。

在前面讲解模组内部架构时我们说过,封装内PSRAM以及封装外FLASH都是通过SPI外设与CPU进行通信的,这里的SPI外设使用的就是此处的SPI0以及SPI1,所以这两个不是通用SPI。

  • 支持多种数据格式:
    • 1-bit SPI: 1个时钟周期传输1bit
    • 2-bit Dual SPI: 1个时钟周期传输2bit
    • 4-bit Quad SPI: 1个时钟周期传输4bit
    • 8-bit Octal SPl: 1个时钟周期传输8bit

SPI外设工作框图:

图中灰色框内表示ESP32S3芯片内部,内部包括了一个SPI控制器和GPIO交换矩阵,SPI控制器内部包含数据存储区域以及移位电路,最后是SPI内部总线。该图中ESP32S3总线上挂载了两个从设备,当主机需要与从设备1进行数据交换时,数据由存储区域经过移位电路到达GPIO交换矩阵,经过GPIO交换矩阵的交换,数据从某一个引脚输出出去到达从设备1,同时从设备1将交换的数据从某IO口发送给ESP32S3,数据进入芯片后,经过GPIO交换矩阵到达SPI控制器,经过移位电路后到达数据存储区域,这就完成了一个字节的交换过程。与从设备2通信也是同样的过程。

但是这里有一个问题,就是如果两个从设备的参数不一样,比如从设备1通信速度为20MHz而从设备2通信速度为60MHz,那么就无法完成对两个设备的通信。ESP32S3是这样处理的,在所有从设备与ESP32S3进行SPI通信之前,都要事先向芯片内部的SPI总线添加从设备信息,比如在通信时时钟源使用哪一个、通信速度是多少、使用哪种通信模式、队列大小是多少、使用哪个IO引脚作为片选引脚,这样参数添加完毕之后,SPI控制器会记录这些从机参数,当ESP32S3想要与某一个从机设备通信时,就会将SPI控制器自动切换成与之相对应的参数。这样有了向总线添加从设备信息的这一步骤,ESP32S3就可以与任意速度、任意模式的从机设备通信了。

  • SPI初始化配置
    • 使用spi_bus_initialize()函数配置SPI控制器参数和GPIO交换矩阵
    • 使用spi_bus_add_device()函数向SPl总线添加从设备信息
    • 使用spi_device_polling_transmit()函数进行数据交换
    • 使用spi_bus_remove_device()函数将设备从SPI总线上移除,需要确定后面不会再通信
    • 使用spi_bus_free()函数释放SPI总线

2.7.2 SPI使用示例

  • SPI外设通信程序框架
// SPI外设初始化 =================================================================
#include "driver/spi_master.h"
#include "esp_log.h"

static const char* TAG = "SPI2_DUAL_SLAVE";

spi_device_handle_t spi_device_a; // 设备A句柄
spi_device_handle_t spi_device_b; // 设备B句柄

void spi2_dual_slave_init() 
{
    spi_bus_config_t buscfg = {
        .miso_io_num = 12, // MISO引脚
        .mosi_io_num = 13, // MOSI引脚
        .sclk_io_num = 14, // SCK引脚
        .quadwp_io_num = -1,
        .quadhd_io_num = -1,
        .max_transfer_sz = 4092 // 最大传输大小
    };

    // 初始化SPI2总线
    esp_err_t ret = spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "SPI2总线初始化失败");
        return;
    }

    // 配置设备A (例如:高速、模式0)
    spi_device_interface_config_t devcfg_a = {
        .clock_speed_hz = 10 * 1000 * 1000, // 10MHz
        .mode = 0, // SPI模式0 (CPOL=0, CPHA=0)
        .spics_io_num = 15, // 设备A的CS引脚
        .queue_size = 7,
        .flags = 0
    };

    // 配置设备B (例如:低速、模式1)
    spi_device_interface_config_t devcfg_b = {
        .clock_speed_hz = 1 * 1000 * 1000, // 1MHz
        .mode = 1, // SPI模式1 (CPOL=0, CPHA=1)
        .spics_io_num = 14, // 设备B的CS引脚
        .queue_size = 7,
        .flags = 0
    };

    // 将设备添加到总线
    ret = spi_bus_add_device(SPI2_HOST, &devcfg_a, &spi_device_a);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "添加设备A失败");
        return;
    }

    ret = spi_bus_add_device(SPI2_HOST, &devcfg_b, &spi_device_b);
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "添加设备B失败");
        return;
    }

    ESP_LOGI(TAG, "SPI2双从机初始化成功");
}

// SPI通信 =================================================================
void send_data_to_devices() 
{
    uint8_t tx_data_a[] = {0x01, 0x02, 0x03};
    uint8_t tx_data_b[] = {0xAA, 0xBB};

    // 发送数据到设备A
    spi_transaction_t t_a = {
        .length = 8 * sizeof(tx_data_a),
        .tx_buffer = tx_data_a,
        .flags = 0
    };
    spi_device_transmit(spi_device_a, &t_a);

    // 发送数据到设备B
    spi_transaction_t t_b = {
        .length = 8 * sizeof(tx_data_b),
        .tx_buffer = tx_data_b,
        .flags = 0
    };
    spi_device_transmit(spi_device_b, &t_b);
}

示例:使用SPI控制ST7789驱动屏幕显示

第一步:SPI初始化

#include "spi.h"
#include <string.h>
#include "driver/spi_master.h"
#include "driver/gpio.h"

spi_device_handle_t spi2_handle;

void spi2_init(void)
{
    spi_bus_config_t  spibus_structure = {
        .flags = SPICOMMON_BUSFLAG_MASTER,
        .isr_cpu_id = INTR_CPU_ID_AUTO,
        .max_transfer_sz = 240 * 240 * 2,
        .miso_io_num = GPIO_NUM_13,
        .mosi_io_num = GPIO_NUM_11,
        .sclk_io_num = GPIO_NUM_12,
        .quadhd_io_num = -1,
        .quadwp_io_num = -1,
    };
    spi_bus_initialize(SPI2_HOST,& spibus_structure, SPI_DMA_CH_AUTO);
}

uint8_t spi2_transfer_byte(uint8_t data)
{
    spi_transaction_t t;

    memset(&t, 0, sizeof(t));

    t.flags = SPI_TRANS_USE_TXDATA | SPI_TRANS_USE_RXDATA;
    t.length = 8;
    t.tx_data[0] = data;
    spi_device_polling_transmit(spi2_handle, &t);

    return t.rx_data[0];
}

void spi2_write_data(uint8_t *data, int len)
{
    spi_transaction_t t = {0};

    t.length = len * 8;                            
    t.tx_buffer = data;                            
    spi_device_polling_transmit(spi2_handle, &t);  
}

第二步:驱动ST7789

#include "lcd.h"
#include "lcdfont.h"
#include "spi.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

uint8_t lcd_buf[115200];

void lcd_write_cmd(uint8_t cmd)
{
    LCD_DC(0);
    spi2_write_data(&cmd,1);
}

void lcd_write_data(uint8_t data)
{
    LCD_DC(1);
    spi2_write_data(&data,1);
}

void lcd_write_data16(uint16_t data)
{
    uint8_t databuf[2] = {0,0};
    databuf[0] = data >> 8 ;
    databuf[1] = data & 0xFF ;
    LCD_DC(1);
    spi2_write_data(databuf,2);
}

void lcd_write_datan(uint8_t *data,uint16_t length)
{
    LCD_DC(1);
    spi2_write_data(data,length);
}

void lcd_hard_reset(void)
{
    LCD_RST(0);
    vTaskDelay(100);
    LCD_RST(1);
    vTaskDelay(100);
}

void lcd_on(void)
{
    LCD_BLK(1);
    vTaskDelay(10);
}

void lcd_off(void)
{
    LCD_BLK(0);
    vTaskDelay(10);
}

void lcd_set_window(uint16_t xstar, uint16_t ystar,uint16_t xend,uint16_t yend)
{	
    lcd_write_cmd(0x2a);
    lcd_write_data16(xstar);
    lcd_write_data16(xend);
    lcd_write_cmd(0x2b);
    lcd_write_data16(ystar);
    lcd_write_data16(yend);
    lcd_write_cmd(0x2c);
} 

void lcd_clear(uint16_t color)
{
    uint16_t i, j;
    uint8_t data[2] = {0};

    data[0] = color >> 8;
    data[1] = color;
    
    lcd_set_window(0, 0, 239, 239);

    for(j = 0; j < 115200/10/2; j++)
    {
        lcd_buf[j * 2] =  data[0];
        lcd_buf[j * 2 + 1] =  data[1];
    }

    for(i = 0; i < 10; i++)
    {
        lcd_write_datan(lcd_buf, 11520);
    }
}

void lcd_init(void)
{
    spi2_init();

    spi_device_interface_config_t   spidevice_structure = {0};
    spidevice_structure.clock_source = SPI_CLK_SRC_DEFAULT;
    spidevice_structure.clock_speed_hz = 60000000;  // SPI通信时钟频率:60MHz
    spidevice_structure.mode = 0;        // SPI工作模式:模式零
    spidevice_structure.queue_size = 7;  // 缓冲队列大小,CPU运行速度快,SPI发送不及时可暂时缓存
    spidevice_structure.spics_io_num = GPIO_NUM_48;  // 片选CS引脚
    spi_bus_add_device(SPI2_HOST, &spidevice_structure, &spi2_handle);

    gpio_config_t gpio_init_struct;
        /* WR管脚 */
    gpio_init_struct.intr_type = GPIO_INTR_DISABLE;                 /* 失能引脚中断 */
    gpio_init_struct.mode = GPIO_MODE_OUTPUT;                       /* 配置输出模式 */
    gpio_init_struct.pin_bit_mask = 1ull << GPIO_NUM_47 ;           /* 配置引脚位掩码 */
    gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE;          /* 失能下拉 */
    gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE;               /* 使能上拉 */
    gpio_config(&gpio_init_struct);                                 /* 引脚配置 */
    /* BL管脚 */
    gpio_init_struct.intr_type = GPIO_INTR_DISABLE;                 /* 失能引脚中断 */
    gpio_init_struct.mode = GPIO_MODE_OUTPUT;                       /* 配置输出模式 */
    gpio_init_struct.pin_bit_mask = 1ull << GPIO_NUM_40;            /* 配置引脚位掩码 */
    gpio_init_struct.pull_down_en = GPIO_PULLDOWN_ENABLE;           /* 使能下拉 */
    gpio_init_struct.pull_up_en = GPIO_PULLUP_DISABLE;              /* 失能上拉 */
    gpio_config(&gpio_init_struct);                                 /* 引脚配置 */
    /* RST管脚 */
    gpio_init_struct.intr_type = GPIO_INTR_DISABLE;                 /* 失能引脚中断 */
    gpio_init_struct.mode = GPIO_MODE_OUTPUT;                       /* 配置输出模式 */
    gpio_init_struct.pin_bit_mask = 1ull << GPIO_NUM_21;           /* 配置引脚位掩码 */
    gpio_init_struct.pull_down_en = GPIO_PULLDOWN_DISABLE;          /* 失能下拉 */
    gpio_init_struct.pull_up_en = GPIO_PULLUP_ENABLE;               /* 使能上拉 */
    gpio_config(&gpio_init_struct);                                 /* 引脚配置 */

    lcd_hard_reset();
    lcd_on();
    vTaskDelay(100);

    lcd_write_cmd(0x11);
    vTaskDelay(120);

    lcd_write_cmd(0xB2);
    lcd_write_data(0x0C);
    lcd_write_data(0x0C);
    lcd_write_data(0x00);
    lcd_write_data(0x33);
    lcd_write_data(0x33);

    lcd_write_cmd(0x35);
    lcd_write_data(0x00);

    lcd_write_cmd(0x36);
    lcd_write_data(0x70);
    lcd_write_data(0xA0);

    lcd_write_cmd(0x3A);
    lcd_write_data(0x05);

    lcd_write_cmd(0xB7);
    lcd_write_data(0x35);

    lcd_write_cmd(0xBB);
    lcd_write_data(0x2D);

    lcd_write_cmd(0xC0);
    lcd_write_data(0x2C);

    lcd_write_cmd(0xC2);
    lcd_write_data(0x01);

    lcd_write_cmd(0xC3);
    lcd_write_data(0x15);

    lcd_write_cmd(0xC4);
    lcd_write_data(0x20);

    lcd_write_cmd(0xC6);
    lcd_write_data(0x0F);

    lcd_write_cmd(0xD0);
    lcd_write_data(0xA4);
    lcd_write_data(0xA1);

    lcd_write_cmd(0xD6);
    lcd_write_data(0xA1);

    lcd_write_cmd(0xE0);
    lcd_write_data(0x70);
    lcd_write_data(0x05);
    lcd_write_data(0x0A);
    lcd_write_data(0x0B);
    lcd_write_data(0x0A);
    lcd_write_data(0x27);
    lcd_write_data(0x2F);
    lcd_write_data(0x44);
    lcd_write_data(0x47);
    lcd_write_data(0x37);
    lcd_write_data(0x14);
    lcd_write_data(0x14);
    lcd_write_data(0x29);
    lcd_write_data(0x2F);
    
    lcd_write_cmd(0xE1);
    lcd_write_data(0x70);
    lcd_write_data(0x07);
    lcd_write_data(0x0C);
    lcd_write_data(0x08);
    lcd_write_data(0x08);
    lcd_write_data(0x04);
    lcd_write_data(0x2F);
    lcd_write_data(0x33);
    lcd_write_data(0x46);
    lcd_write_data(0x18);
    lcd_write_data(0x15);
    lcd_write_data(0x15);
    lcd_write_data(0x2B);
    lcd_write_data(0x2D);

    lcd_write_cmd(0x21);
    lcd_write_cmd(0x29);
    lcd_write_cmd(0x2C);

    lcd_clear(BLACK);
}

void lcd_set_cursor(uint16_t xpos, uint16_t ypos)
{
    lcd_set_window(xpos,ypos,xpos,ypos);	
} 

void lcd_draw_pixel(uint16_t x, uint16_t y, uint16_t color)
{
    lcd_set_cursor(x, y);
    lcd_write_data16(color);
}

void lcd_show_char(uint8_t line,uint8_t column,uint8_t chr,uint16_t fontcolor,uint16_t backgroundcolor)
{
    uint8_t i , j = 0 ;
    uint8_t chr_index = 0 ;
    uint8_t chr_temp = 0 ;
    lcd_set_window( (column - 1) * 16 , (line - 1) * 32 + 8 ,column * 16 - 1 , line * 32 + 7 );
    for( i = 0 ; i < 64 ; i++ )
    {
        chr_temp = ascii_3216[chr - ' '][i];
        for( j = 0 ; j < 8 ; j++ )
        {
            if( chr_temp & ( 0x01 << j ) )
            {
                lcd_write_data16(fontcolor);
            }
            else
            {
                lcd_write_data16(backgroundcolor);
            }
            chr_index++;
            if( chr_index == 16 )
            {
                chr_index = 0 ;
                break;
            }
        }
    }
}

void lcd_show_string(uint8_t line,uint8_t column,char *string,uint16_t fontcolor,uint16_t backgroundcolor)
{
    uint8_t i = 0 ;
    for( i = 0 ; string[i] != '\0' ; i++ )
    {
        lcd_show_char( line , column + i , string[i] , fontcolor , backgroundcolor);
    }
}

uint32_t lcd_pow(uint32_t x, uint32_t y)
{
	uint32_t Result = 1;
	while (y--)
	{
		Result *= x;
	}
	return Result;
}

void lcd_show_num(uint8_t line,uint8_t column,uint32_t number,uint8_t length,uint16_t fontcolor,uint16_t backgroundcolor)
{
	uint8_t i;
	for (i = 0; i < length; i++)							
	{
		lcd_show_char(line, column + i, number / lcd_pow(10, length - i - 1) % 10 + '0',fontcolor,backgroundcolor);
	}    
}

void lcd_show_hexnum(uint8_t line, uint8_t column, uint32_t number, uint8_t length,uint16_t fontcolor,uint16_t backgroundcolor)
{
	uint8_t i, singlenumber;
	for (i = 0; i < length; i++)							
	{
		singlenumber = number / lcd_pow(16, length - i - 1) % 16;
		if (singlenumber < 10)
		{
			lcd_show_char(line, column + i, singlenumber + '0',fontcolor,backgroundcolor);
		}
		else
		{
			lcd_show_char(line, column + i, singlenumber - 10 + 'A',fontcolor,backgroundcolor);
		}
	}
}

void lcd_show_float(uint8_t line, uint8_t column, float number, uint8_t length,uint16_t fontcolor,uint16_t backgroundcolor)
{
	uint8_t i;
    uint32_t temp;
    uint32_t number1 = number * 100;
    for( i = 0 ; i < length ; i ++ )
    {
        temp = ( number1 / lcd_pow( 10 , length - i - 1) ) % 10 ;
        if( i == ( length - 2 ) )
        {
            lcd_show_char( line , column + length - 2 , '.' , fontcolor , backgroundcolor );
            i++;
            length += 1;
        }
        lcd_show_num( line , column + i , temp , 1 , fontcolor , backgroundcolor);
    }
}

void lcd_show_picture(uint8_t *img)
{
    unsigned long i = 0;
    unsigned long j = 0;
    lcd_set_window(0, 0, 239, 239);
    /* lcd_buf存储摄像头整一帧RGB数据 */
    for (j = 0; j < 240 * 240; j++)
    {
        lcd_buf[2 * j] = img[2 * i] ;
        lcd_buf[2 * j + 1] =  img[2 * i + 1];
        i ++;
    }
    
    /* 例如:96*96*2/1536 = 12;分12次发送RGB数据 */
    for(j = 0; j < (240 * 240 * 2 / 11520); j++)
    {
        /* &lcd_buf[j * LCD_BUF_SIZE] 偏移地址发送数据 */
        lcd_write_datan(&lcd_buf[j * 11520] , 11520);
    }   
}

4 音频I2S协议

4.1 先搞懂I2S是干啥的?

4.1.1 概念简介

I2S(Inter-IC Sound)总线, 又称集成电路内置音频总线,是飞利浦公司为数字音频设备之间的音频数据传输而制定的一种总线标准。从名字就能看出来,I2S是专门用来在音频设备之间传数字音频数据的。比如你做一个语音识别项目,用数字麦克风采集声音,再把数据传给ESP32;或者做一个音乐播放器,让ESP32把音频数据发给DAC芯片,再转成模拟信号驱动音箱。这些场景下,用的就是I2S协议。

I2S协议定义了音频数据的传输格式、时序和控制信号。在工作中音频采集和输出,AD和主芯片之间的通信均是通过I2S进行通信的,其应用场景如下:

  • I2S RX方向:麦克风在机械振动下将声音信号转变为电压信号,电压信号经过放大等处理,给到ADC采样,将模拟信号转化为数字信号;音频在ADC与DSP之间的传输协议就是使用的I2S协议。
  • I2STX方向:数字信号经过编码、存储、压缩等技术后,发送给解码器-DAC(DSP、专用解码器),将数字信号还原为模拟信号,最后给到喇叭完成声音/音频的播放。音频在DAC与DSP之间的传输就是使用I2S协议。

4.1.2 I2S的核心:靠“双时钟”保证音频同步

音频数据最讲究"同步"一一采样率、位深、声道数必须精准匹配,否则播放出来就是杂音。I2S靠两根时钟线解决这个问题,再加上一根数据线,最少3根线就能工作:

  • SCK(位时钟):相当于"节拍器”,每敲一下就传输1位数据。频率是固定的,计算公式很简单:采样率×位深×声道数。比如常见的44.1kHz采样率、16位深、立体声,SCK频率就是44100×16×2=1.4112MHz。
  • WS(字选择,也叫LRCLK):用来区分左右声道,频率和采样率一样。比如44.1kHz的采样率,WS就每秒跳变44100次,高电平代表左声道,低电平代表右声道。
  • SD(串行数据):实际传输音频数据的线,遵循“最高位先行"的规则,而且数据会在WS切换后延迟1个SCK再传输,避免信号干扰。

有些高端音频芯片还会用到第四根线一一MCLK(主时钟),频率是采样率的256倍或384倍,用来提升音频精度,一般入门项目用不到。

4.1.3 I2S的工作模式:谁来当“节拍器”?

I2S只有两种工作模式,很简单:

  • 主模式(Master):由主控芯片(比如ESP32、STM32)产生SCK和WS时钟,音频设备(麦克风、DAC)跟着时钟走。这是最常用的模式。
  • 从模式(Slave):由音频设备产生时钟,主控芯片跟着同步。此模式极少用,除非是多音频设备协同的复杂场景。

4.2 再回顾I2C是干啥的?

4.2.1 概念简介

I2C的全称是Inter-Integrated Circuit,直译是"集成电路间通信"。和I2S的"专才"属性不同,I2C是"万能信使"一一通用的低速 串行通信协议,几乎能和所有低速外设通信。比如你用的温湿度传感器(DHT11除外,它用单总线)、OLED显示屏、EEPROM存储芯片、实时时钟模块(RTC),这些都是用I2C协议和主控通信的。它的核心优势是“省线”,两根线就能实现多设备通信。

4.2.2 I2C的核心:两根线搞定“多设备对话"

I2C只有两根线,一根时钟线,一根数据线:

  • SCL(时钟线):由主设备产生时钟,控制数据传输的节奏。
  • SDA(数据线):双向传输数据,既能由主设备发指令,也能由从设备回传数据。

I2C之所以能连多个设备,是因为每个从设备都有唯一的7位或10位地址。主设备要和某个设备通信时,先在SDA线上发送设备地址,只有地址匹配的设备才会回应,其他设备都保持沉默一一就像在会议室里喊名字,只有被叫到的人会回答。

4.3 核心区别:12S和I2C,完全是两个赛道的选手

虽然名字像,但I2S和I2C的设计目标、应用场景完全不同,用一张表就能看明白:

对比维度 I2S I2C
设计目标 专干音频传输,保证音频同步 通用低速数据通信,适配各种外设
信号线 SCK、WS、SD(可选MCLK),最少3根 SCL、SDA,仅2根
通信方向 单向或双向 (需额外加数据线) 双向(SDA线复用,收发一体)
同步方式 双时钟(SCK+WS)精准同步,无误差 单时钟(SCL),数据异步应答
数据格式 MSB先行的音频位流,按位传输 字节为单位的通用数据帧,带地址和应答位
寻址机制 无寻址,点对点或广播传输 7位/10位设备地址,支持多主多从
传输速率 高频(MHz级),随音频参数变化 低速(标准100kHz,快速400kHz,高速3.4MHz)
应用场景 数字麦克风、DAC/ADC、功放、音频解码芯片 温湿度传感器、OLED屏、EEPROM、RTC模块

总之,除了都是由飞利浦定义外,I2S和I2C没有任何关系。

4.4 I2S的使用

4.4.1 I2S的接口线组成

I2S总线一般由1根系统时钟线和3根信号线组成:

  • MCLK:称为主时钟,也叫系统时钟(Sys Clock),一般为了使系统间能够更好地同步时增加MCLK信号,MCLK的频率 = 128或者256或者512 * 采样频率;
  • SCLK(BCLK):串行时钟SCLK,也叫位时钟(BCLK),即对应数字音频的每一位数据,SCLK都有1个脉冲。SCK的频率 = 声道数 * 采样频率 * 采样位数;
  • LRCK:帧时钟LRCK,(也称WS),用于切换左右声道的数据。LRCK为“1”表示正在传输的是右声道的数据,为“0”则表示正在传输的是左声道的数据。LRCK的频率等于采样频率;
  • SDATA(SD):就是用二进制补码表示的音频数据。最高位拥有固定的位置,而最低位的位置则是依赖于数据的有效位数。

控制器(Controller)产生SCK信号和WS信号,控制器可以是Transmitter也可以是Receiver,也可以是单独设计的控制模块,如下图所示:

I2S是比较简单的数字接口协议,没有地址或设备选择机制。在I2S总线上,只能同时存在一个主设备和发送设备。主设备可以是发送设备,也可以是接收设备,或是协调发送设备和接收设备的其它控制设备。

注:在I2S系统中,提供时钟(SCK和WS)的设备为主设备。

4.4.2 时钟信号

(一)串行时钟SCLK

串行时钟SCLK,也叫位时钟BCLK。SCLK是模块内的同步信号,Slave模式时由外部提供,Master模式时由模块内部自己产生。不同厂家的芯片型号,时钟信号叫法可能不同,也可能称BCLK/Bit Clock或SCL/Serial Clock。

例如:假设设声音的采样频率为44.1kHz,即声道选择信号(帧时钟)WS的频率必须也为44.1kHz;左/右2个声道的量化深度均为16 bit, 则I2S的SCK的频率为:44.1kHz×16×2 = 1.4112 MHz。如果需要传输20 bit、24 bit或32 bit的左右声道的数据,可以提高SCK的频率,由上式可以计算出需要的SCK的频率。

4.4.3 左右声道选择信号

WS也称帧时钟,即LRCLK,Left Right Clock。WS频率等于声音的采样率。WS既可以在SCK的上升沿,也可以在SCK的下降沿变化。Slave设备在SCK的上升沿采样WS信号。数据信号MSB在WS改变后的第二个时钟(SCK)上升沿有效(即延迟一个SCK),这样可以让Slave设备有足够的时间以存储当前接收的数据,并准备好接收下一组数据。

  • WS是声道选择信号,表明数据发送端所选择的声道。当:
    • WS=0,表示选择左声道
    • WS=1,表示选择右声道

4.4.4 数据信号Serial Data

SD是串行数据,在I2S中以二进制补码的形式在数据线上传输。在WS变化后的第一个SCK脉冲,先传输最高位(MSB,Most Significant Bit)。先传送MSB是因为发送设备和接收设备的字长可能不同,当系统字长比数据发送端字长长的时候,数据传输就会出现截断的现象/Truncated,即如果数据接收端接收的数据位比它规定的字长长的话,那么规定字长最低位(LSB,Least Significant Bit)以后的所有位将会被忽略。如果接收的字长比它规定的字长短,那么空余出来的位将会以0填补。通过这种方式可以使音频信号的最高有效位得到传输,从而保证最好的听觉效果。

注:根据输入或输出特性,不同芯片上的SD也可能称SDATA、SDIN、SDOUT、DACDAT、ADCDAT等;

数据发送既可以同步于SCK的上升沿,也可以是下降沿,但接收设备在SCK的上升沿采样,发送数据时序需考虑。

4.4.5 Master Clock

在I2S/PCM接口的ADC/DAC系统中,除了SCK和WS外,CODEC经常还需要控制器提供MCLK(Master Clock),这是由CODEC内部基于Delta-Sigma(\(\Delta \Sigma\))的架构设计要求使然。其主要原因是因为这类的CODEC没有所谓提供芯片的工作时钟晶振电路。它需要外部的时钟提供内部PLL

4.5 I2S信号时序图

随着技术的发展,也出现了很多种不同的数据格式。根据data相对于LRCK与SCLK位置的不同,分为① I2S标准格式(飞利浦规定的格式),② 左对齐(较少使用),③ 右对齐(日本格式,普通格式),发送和接收端必须使用相同的数据格式。

4.5.1 I2S Philips标准

I2S Philips标准时序图如下图所示:

使用LRCLK信号表示当前正在发送数据所属的声道,LRCLK为“1”表示正在传输的是右声道的数据,为“0”则表示正在传输的是左声道的数据。LRCLK信号从当前声道数据的第一个位(MSB)之前的一个时钟开始有效。

LRCLK信号在BCLK的下降沿变化,发送方在时钟信号BCLK的下降沿改变数据,接收方在时钟信号BCLK的上升沿读取数据。正如上文所说,LRCLK频率等于采样频率Fs,一个LRCLK周期(1/Fs)包括发送左声道和右声道数据。

对于这种标准I2S格式的信号,无论有多少位有效数据,数据的最高位总是出现在LRCLK变化(也就是一帧开始)后的第2个BCLK脉冲处。这就使得接收端与发送端的有效位数可以不同。如果接收端能处理的有效位数少于发送端,可以放弃数据帧中多余的低位数据;如果接收端能处理的有效位数多于发送端,可以自行补足剩余的位。

这种同步机制使得数字音频设备的互连更加方便,而且不会造成数据错位。

4.5.2 左对齐(MSB)标准

左对齐(MSB)标准时序图如下图所示:

该标准较少使用,在LRCLK发生翻转的同时开始传输数据,注意LRCLK为1时,传输的是左声道数据,LRCLK为0时,传输的是右声道数据,这刚好与I2S Philips标准相反。

4.5.3 右对齐(LSB)标准

右对齐(LSB)标准时序图如下图所示:

声音数据LSB传输完成的同时,LRCLK完成第二次翻转(刚好是LSB和LRCLK是右对齐的,所以称为右对齐标准)。注意LRCLK为1时,传输的是左声道数据,LRCLK为0时,传输的是右声道数据,这刚好与I2S Philips标准相反。

参考资料I2S协议 - 可达达鸭 - 个人博客

4.6 ESP32中使用I2S

5 WiFi联网

5.1 WIFI之基础知识

5.1.1 前置网络基础知识

(一)动态主机配置协议DHCP

全称为:Dynamic Host Configuration Protocol。当设备连接到路由器时,路由器会为该设备分配一个IP地址。这是路由器的核心功能之一,使得设备能够识别并通信。

  • 路由器分配IP地址主要有两种方式:
    • 动态分配(DHCP)‌:这是最常见的方式。路由器内置一个DHCP服务器,当设备连接网络时,会自动向路由器请求IP地址。路由器会从预设的IP地址池中选择一个未被使用的地址,动态地分配给该设备。这种方式无需手动设置,方便快捷,适用于手机、笔记本电脑等大多数设备。‌
    • 静态分配(手动设置)‌:用户可以为特定设备(如网络打印机、监控摄像头或服务器)手动设置一个固定的IP地址。这个地址不会改变,确保了设备在网络中的稳定性,便于远程访问或端口转发等操作。‌

备注:路由器本身也会拥有一个IP地址,通常作为局域网的网关(如192.168.1.1),用于管理网络和连接外部互联网。‌

DHCP客户端接入流程:

DHCP(Dynamic Host Configuration Protocol)是一种网络管理协议,用于集中对用户IP地址进行动态管理和配置。DHCP于1993年10月成为标准协议,其前身是BOOTP协议。DHCP协议由RFC 2131定义,采用客户端/服务器通信模式,由客户端(DHCP Client)向服务器(DHCP Server)提出配置申请,DHCP Server为网络上的每个设备动态分配IP地址、子网掩码、默认网关地址,域名服务器(DNS))地址和其他相关配置参数,以便可以与其他IP网络通信。

  • 基本原理
    • 协议报文基于UDP的方式进行交互
    • DHCP采用C/S(Client/Server,客户端/服务器)通信模式,采用67(DHCP服务器)和68(DHCP客户端)两个端口号

5.1.2 WiFi工作模式

在 ESP-IDF 中,WiFi支持两种核心模式:AP模式STA模式,分别对应不同的网络角色。以下是两种模式的详细对比及配置方法:

模式 全称 角色 典型场景
STA模式 Station(工作站) 作为客户端,连接外部WiFi热点 连接路由器上网,与其他设备通信
AP模式 Access Point(接入点) 作为热点,供其他设备连接 搭建本地局域网、直连其他设备(如手机/电脑)

进一步举例解释:AP模式——比如别人手机没有流量了,你开热点,别人连接你,通过你再连接网络,对于ESP32而言就是别人连接ESP32,别人可访问ESP32中存储的网络内容,也就是相当于服务器?

一般来说,目前WiFi主流的频段是2.4GHz(经典频段,穿墙性能好)和5GHz(新频段,速度快)。2.4GHz的WiFi信道有13个,2.4GHz的WiFi信道我么国家有5个。信道简单说就是在这个频段上的分频,信道一般是由工作在AP的设备(路由器)负责配置,而工作在STA的设备(手机)只负责连接。

5.1.3 分区、NVS配置

非易失性存储(nonvolatile storage,简称NVS),又称非易失性存储器或非易失性随机存取存储器(NVRAM),是断电仍可保留数据的计算机存储设备,属于静态随机存取存储器的一种形式。主要类型涵盖ROM、PROM、EPROM、EEPROM、闪存等。

NVS是ESP-IDF中提供的一种轻量级存储解决方案,专门用于在设备重启或断电后保留数据。NVS是通过使用Flash存储实现的,主要用于存储小型的键值对(key-value pairs),例如系统配置参数、Wi-Fi配置参数、用户设置、计数器等不需要频繁更新但需要持久保存的数据。

KeyMap存储是指将键值对映射存储在某种数据结构中,以便快速查找和管理键值对数据。通常,键(Key)表示一个唯一标识符,值(Value)表示与该键关联的数据。

  • NVS 和 Wi-Fi 的关系

    • Wi-Fi 配置存储依赖 NVS:
      • ESP32 的 Wi-Fi 连接信息(如 SSID、密码)默认存储在 NVS 中。当你调用 esp_wifi_set_config() 设置 Wi-Fi 配置时,这些信息会被保存到 NVS,以便设备重启后自动连接。
    • 初始化 NVS 是使用 Wi-Fi 的前提:
      • 如果不初始化 NVS,Wi-Fi 相关的配置无法被持久化保存,可能导致无法正常连接或保存配置。
  • 使用 Wi-Fi 是否必须初始化 NVS?

    • 若要保存Wi-Fi配置(如esp_wifi_set_config()),必须先调用nvs_flash_init()
    • 若不要保存配置,而是每次启动都手动设置Wi-Fi参数,理论上可不初始化NVS,但会失去持久化功能。

VSCODE中可通过.csv文件,进一步划分NVS,不同的NVS分区保存不同类型的参数。

点击后,打开如下图所示,可以用可视化界面进行NVS分区:

从图中可以看到,NVS的分区主要是通过namespace进行划分的,有几种不同类型的参数就设置几个namespace,然后再在当前的namespace下设置并填写具体的参数。

以前没有学习这些分区的时候,我们烧录的程序都是烧录在Factory App分区中了,现在有了NVS,需要把NVS分区信息烧录到ESP32的FLASH中的时候,需要先生成一个镜像固件。生成此镜像固件的方法如下:

# 生成大小和位置需要和分区表一致

# 1.生成 NVS 镜像  nvs_init.csv 自定义的参数表 , nvs.bin 生成镜像 ,0x6000 镜像大小(24K)
python D:\Espressif\frameworks\esp-idf-v5.3.1\components\nvs_flash\nvs_partition_generator\nvs_partition_gen.py generate nvs_init.csv nvs.bin 0x6000

# 2.烧录镜像 0x9000 是偏移地址
esptool.py --port COM3 write_flash 0x9000 nvs.bin

关于 nvs_flash_init()partitions.csv(注意,此处不是nvs_init.csv) 的关系

  1. 是否需要在partitions.csv中分配 NVS 分区?答:是的,必须分配。
    ESP-IDF 默认的分区表(partitions_two_ota.csv 或 partitions_single_factory.csv)中已经包含了一个 NVS 分区(通常为 0x90000xA000,大小 24KB 或更大)。如果你自定义了 > partitions.csv,必须确保其中包含一个类型为 data、子类型为 nvs 的分区,否则 nvs_flash_init() 会失败。
  2. 如果 partitions.csv 中没有 NVS 分区会怎样?
    答:nvs_flash_init() 会返回错误(通常是 ESP_ERR_NVS_NO_PARTITION 或类似错误码)。
    无法使用 NVS 功能,包括:
    ① 无法保存 Wi-Fi 配置(SSID、密码等)。
    ② 无法使用nvs_set_*/nvs_get_*等系列API函数存储任何键值对数据。
    ③ 依赖 NVS 的组件(如 Wi-Fi、BLE 等)可能无法正常工作。

5.2 WIFI的简单使用

5.2.1 WIFI之STA模式

在ESP-IDF这个框架源码里面包含了很多例程,其中就有简单的STA例程,为迅速上手,可直接采用例程中的代码为框架,然后进行自己的修改。

void wifi_init_sta(void)
{
    s_wifi_event_group = xEventGroupCreate();

    ESP_ERROR_CHECK(esp_netif_init());

    ESP_ERROR_CHECK(esp_event_loop_create_default());
    esp_netif_create_default_wifi_sta();

    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&cfg));

    esp_event_handler_instance_t instance_any_id;
    esp_event_handler_instance_t instance_got_ip;
    ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
                                                        ESP_EVENT_ANY_ID,
                                                        &event_handler,
                                                        NULL,
                                                        &instance_any_id));
    ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
                                                        IP_EVENT_STA_GOT_IP,
                                                        &event_handler,
                                                        NULL,
                                                        &instance_got_ip));

    wifi_config_t wifi_config = {
        .sta = {
            .ssid = EXAMPLE_ESP_WIFI_SSID,
            .password = EXAMPLE_ESP_WIFI_PASS,
            /* Authmode threshold resets to WPA2 as default if password matches WPA2 standards (password len => 8).
             * If you want to connect the device to deprecated WEP/WPA networks, Please set the threshold value
             * to WIFI_AUTH_WEP/WIFI_AUTH_WPA_PSK and set the password with length and format matching to
             * WIFI_AUTH_WEP/WIFI_AUTH_WPA_PSK standards.
             */
            .threshold.authmode = ESP_WIFI_SCAN_AUTH_MODE_THRESHOLD,
            .sae_pwe_h2e = ESP_WIFI_SAE_MODE,
            .sae_h2e_identifier = EXAMPLE_H2E_IDENTIFIER,
        },
    };
    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA) );
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config) );
    ESP_ERROR_CHECK(esp_wifi_start() );

    ESP_LOGI(TAG, "wifi_init_sta finished.");

    /* Waiting until either the connection is established (WIFI_CONNECTED_BIT) or connection failed for the maximum
     * number of re-tries (WIFI_FAIL_BIT). The bits are set by event_handler() (see above) */
    EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group,
            WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
            pdFALSE,
            pdFALSE,
            portMAX_DELAY);

    /* xEventGroupWaitBits() returns the bits before the call returned, hence we can test which event actually
     * happened. */
    if (bits & WIFI_CONNECTED_BIT) {
        ESP_LOGI(TAG, "connected to ap SSID:%s password:%s",
                 EXAMPLE_ESP_WIFI_SSID, EXAMPLE_ESP_WIFI_PASS);
    } else if (bits & WIFI_FAIL_BIT) {
        ESP_LOGI(TAG, "Failed to connect to SSID:%s, password:%s",
                 EXAMPLE_ESP_WIFI_SSID, EXAMPLE_ESP_WIFI_PASS);
    } else {
        ESP_LOGE(TAG, "UNEXPECTED EVENT");
    }
}

下面是对上述代码重点语句的解释

  • esp_netif_init():这行代码是TCP/IP协议栈的初始化,是必不可少的
  • esp_event_loop_create_default():创建事件调度循环系统
    • 初始化一个默认的、全局共享的事件循环实例:事件循环是 ESP-IDF 中处理异步事件(如 WiFi 连接成功 / 断开、IP 地址获取、网络错误等)的 “消息中转站”。
    • 为各类外设 / 协议栈(WiFi、ESP-NETIF、蓝牙、以太网等)提供统一的事件分发通道:比如 WiFi 连接成功后,会向这个事件循环发送 WIFI_EVENT_STA_CONNECTED 事件;获取到 IP 后,会发送 IP_EVENT_STA_GOT_IP 事件。
    • 简单来说:这个函数是开启 ESP-IDF 异步事件处理的 “总开关”,如果不创建事件循环,你无法监听和响应 WiFi、网络等核心异步事件,程序只能 “盲跑” 而无法感知网络状态变化。
  • esp_netif_create_default_wifi_sta():创建STA对象
    • 要使用STA的功能就需创建一个STA对象,底层会把这个STA对象绑定到WiFi的接口,然后将相关的WiFi STA事件处理函数注册到默认的事件循环中
  • esp_wifi_init(&cfg):初始化配置整个WiFi
    • 分配资源‌:为Wi-Fi控制结构、接收(RX)和发送(TX)缓冲区、NVS(非易失性存储)结构等分配内存。
    • 启动Wi-Fi任务‌:初始化并启动ESP-IDF内部的Wi-Fi协议栈任务,该任务负责处理底层的无线通信逻辑。
  • esp_event_handler_instance_register():用于注册回调函数
    • 示例代码中注册了两类事件WIFI_EVENTIP_EVENT,其中WIFI_EVENT主要就是值连接WiFi成功、连接WiFi失败、断开WiFi连接,IP_EVENT中最常用的就是获取IP这个事件(也就说,工作在STA模式的ESP32连上了路由器,接下来路由器还得分配一个IP地址给ESP32,当ESP32成功获得路由器分配的IP地址后,就会返回IP_EVENT_STA_GOT_IP
  • esp_wifi_set_mode(WIFI_MODE_STA):设置WiFi的工作模式为STA
  • esp_wifi_set_config():为ESP32的Wi-Fi站点(Station)接口设置配置参数‌
    • 具体来说,它将wifi_config结构体中定义的参数(如目标Wi-Fi网络的SSID、密码、安全模式、信道等)应用到ESP32的STA模式下,使设备能够根据这些配置去连接指定的无线网络。
  • esp_wifi_start():启动Wi-Fi‌
  • esp_wifi_connect():ESP32将根据之前设置的配置尝试连接到指定的Wi-Fi网络。

5.2.2 WIFI之AP模式

(一)AP模式介绍

上面5.2.1小节中,我们使用固定的ssid和密码来连接热点/路由器,但在实际项目中是不可能用固定的ssid和密码来连接的,因为是动态变化的。所以,需要有办法把ssid和密码告诉ESP32,这个过程叫做配网

AP模式常用于配网。常用的配网方式有如下几种:

  • ① 手动输入
    • 优点:直接简单
    • 缺点:需要有屏有按键
  • ② smartconfig
    • 优点:操作稍简单
    • 缺点:配网成功率不高,不同平台难以移植兼容
  • ③ ble配网
    • 优点:操作较为简单,成功率高
    • 缺点:需要有ble支持,且平台之间差别很大
  • ④ ap配网
    • 优点:成功率高,无须安装app或小程序,移植性强
    • 缺点:操作稍微复杂

AP配网一种常用的方法就是:ESP32工作在AP(路由器)模式下,手机/电脑通过ESP32的AP模式IP地址连接ESP32内的Http服务器,ESP32扫描到的热点信息上传到内部的Http服务器上,然后会显示在手机/电脑网页,手机/电脑在网页端找到扫描到的WiFi,输入密码,即可让ESP32连接到网络。

(二)Http和Websocket基础简介

URL简介

URL路径是URL(统一资源定位符)中用于标识资源在服务器上具体位置的部分,通常以斜杠 / 开头,位于域名或端口号之后,表示服务器上的目录层级结构,类似于文件系统中的路径。它告诉服务器用户请求的是哪个文件或资源,比如网页、图片或接口地址。下面这个表格能帮你快速理解它的位置和作用。

HTTP简介

左侧子图是TCP/IP的四层网络模型,而HTTP协议就是处于应用层的协议,它是基于TCP协议进行通信的,即HTTP协议是可靠的传输。右侧子图是对HTTP协议概念的简单梳理。

下图是客户端通过HTTP协议向服务端请求数据的一个例子:

WebSocket简介

WebSocket是基于TCP的一种新的应用层网络协议。它提供了一个全双工的通道,允许服务器和客户端之间实时双向通信。因此,在 WebSocket 中,客户端和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,客户端和服务器之间的数据交换变得更加简单。

Websocket通信都是基于“帧”进行通信的

Websocket与http也有一点联系,要想使用Websocket通信,首先客户端要发起http请求,并且在请求头中要带上两个内容:Upgrade: websocketConnection: UpgradeUpgrade: websocket表示接下来的通信协议升级为Websocket。流程如下图所示:

(三)AP配网流程图

在了解完HTTP和Websocket协议之后,回到这个AP配网流程。上图中这个APP网流程分了两部分:左边这里是ESP32要做的事情,右边这里是手机/电脑浏览器要做的事情。下面从上到下、从左到右往一步步看整个流程。

首先是ESP32S3要进入AP模式,然后建立HTTP/Websocket服务器。接下来客户端(手机/电脑)要连接ESP32的热点,这样才能跟这个ESP32开发板进行通信,接着打开浏览器,在浏览器上面输入ESP32的IP地址,然后发起HTTP请求,然后就可以获取到这个HTML网页了。获取到HTML网页之后,浏览器上面就出现ap配网页面,那么根据这个HTML网页的指示,还要建立这个Websocket连接,因为浏览器和ESP32随时都有可能相互收发数据,用websocket是更合适的。接下来浏览器要下发一个扫描指令让ESP32S3执行扫描。左侧ESP32收到扫描指令之后就执行扫描,执行完扫描之后就直接上报结果了,上报结果给客户端,客户端就会收到一串的热点列表。我们只需要选择其中一个热点,然后输入SSID或者密码后点击配置按钮,就会把这组ssid和password下发。那么ESP32S3收到SSID和password之后,它第一步就会停止HTTP服务器,然后用这组SSID和password来发起连接,最终连接到指定的WiFi。

描述
描述
描述

6 嵌入式OTA与OTA升级

6.1 OTA的相关知识

6.1.1 OTA基本概念

OTA:Over-the-Air Technology,即空中下载技术。OTA升级:通过OTA方式实现固件或软件的升级。

只要是通过无线通信方式实现升级的,都可以叫OTA升级,比如网络/蓝牙。

通过有线方式进行升级,叫本地升级,比如通过UART,USB或者SPI通信接口来升级设备固件。

6.1.2 OTA之启动流程

现代的嵌入式系统一般都包含两个部分:① BootLoader 和 ②

BootLoader可负责镜像加载。为什么需要BootLoader来加载呢?我们知道,硬件芯片上电之后不是一个可以直接运行程序的状态,并不能直接运行我们编写的复杂操作系统或应用程序。它需要一个“引导”过程,而 BootLoader 正是完成这一关键任务的初始软件。

ESP-IDF 的启动过程包含多个阶段,以确保启动的安全性与可靠性:

  • 一级 Bootloader (ROM Bootloader)
    • 不可修改,出厂的时候就固化在内部ROM里的代码。
    • 初始化最基础的硬件组件(如时钟、Flash接口)。
    • 从外部Flash中加载二级Bootloader到芯片的内部RAM(主要是 IRAM,即指令 RAM)中执行。
    • 执行 Bootloader 镜像的 SHA-256 哈希校验;如果开启了 Secure Boot,还会进行数字签名验证(如 RSA 或 ECDSA)。
  • 二级 Bootloader
    • 这部分代码存储在外部Flash中,负责更复杂的初始化工作,比如配置时钟、外设(如将Flash切换到高速四线模式)。
    • 使用 SHA-256 校验应用镜像完整性;若开启 Secure Boot,还需进行加密签名验证。
  • 应用程序执行
    • 验证通过后,将控制权移交给应用程序镜像。

从上图可知,二级Bootloader烧录到0x0位置,partition_tabel烧录到0x8000位置,FactoryApp(main.bin)烧录到0x0001 0000位置。

6.1.3 分区表构建

ESP-IDF使用双分区OTA结构,当APP0激活时,OTA升级默认升级至APP1,反之当当前是APP1激活,使用otadata分区保存OTA的相关数据,并切换系统调用的分区镜像,重启后,系统调用至新的镜像分区。

ESP-IDF 中工厂固件与 OTA 分区的启动切换机制

在 ESP-IDF 中,factory app(工厂固件)、app0、app1 是闪存分区表中定义的不同 应用程序分区,它们的启动优先级和执行逻辑由 bootloader(引导程序)和 ota_data 分区(OTA 状态记录)共同决定。即使默认首次启动的是 factory app,后续也能执行 app0 或 app1,核心原因是 启动分区的动态切换机制。

一、分区表中各App分区的角色

首先需要明确:factory、app0、app1 是分区表中定义的 独立应用分区,各自存储不同的固件,功能如下:

分区名称 类型 / 子类型 作用
factory app, factory 工厂固件分区,设备 首次启动 时默认加载(无 OTA 记录时的 fallback)。
app0 app, ota_0 OTA 备用分区 0,用于存储 OTA 升级时下载的新固件。
app1 app, ota_1 OTA 备用分区 1,与 app0 交替使用(避免升级失败时无可用固件)。
ota_data data, ota 记录 OTA 状态的小分区(存储当前启动的分区标记、升级结果等)。

二、启动逻辑:为什么factory启动后能切换到app0/app1?

ESP32 的启动流程由 bootloader 控制,其核心逻辑是 根据ota_data分区的记录选择启动分区,具体步骤如下:

1. 首次启动(无 OTA 记录):
    - 设备刚出厂时,ota_data 分区为空(或未标记有效启动分区)。
    - bootloader 会优先检查 factory 分区:如果存在且有效(固件校验通过),则启动 factory app。
    - 这就是 “默认启动 factory” 的原因。
/* ================================================================================================ */
2. OTA 升级后(有 OTA 记录):
    - 当执行 OTA 升级时,新固件会被写入 app0 或 app1(二者中当前非活跃的分区),升级成功后:
    - ota_data 分区会被更新,标记 app0 或 app1 为 “下次启动的活跃分区”。
    - 设备重启时,bootloader 读取 ota_data,发现有有效标记,就会跳过 factory 分区,直接启动标记的 app0 或 app1。
/* ================================================================================================ */
3. 升级失败的回滚机制:
    - 如果 OTA 升级失败(如固件损坏、写入中断),ota_data 会保留之前的有效标记,bootloader 仍会启动原活跃分区
    (可能是 factory、app0 或 app1),避免设备变砖。

三、举例说明:从factory到app0的切换过程

假设分区表定义了factory、app0、app1 和 ota_data,流程如下:

  1. 首次上电:ota_data 为空 → bootloader 启动 factory app(执行初始固件)。
  2. 执行 OTA 升级:
      ◾ 新固件被下载并写入 app0(此时 app0 是备用分区)。
      ◾ 升级成功后,ota_data 标记 app0 为 “活跃分区”。
  3. 重启设备:bootloader 读取 ota_data → 发现 app0 为活跃分区 → 启动 app0 中的新固件。
  4. 再次 OTA 升级:新固件会写入 app1(此时 app1 是备用分区),ota_data 标记 app1 为活跃分区 → 下次启动 app1。

四、关键:factory 与 OTA 分区的优先级

  • ota_data分区的记录 优先级最高:只要有有效标记,bootloader 就会启动标记的 OTA 分区(app0/app1)。
  • factory分区是 “保底分区”:仅在 ota_data 无有效记录(首次启动、OTA 记录损坏)时才会被启动。

五、小结:

默认启动 factory app 是因为设备首次启动时 ota_data 为空,bootloader 选择了工厂固件;而后续能执行 app0 或 app1,是因为 OTA 升级过程中更新了 ota_data 分区的标记,bootloader 会根据标记优先启动对应的 OTA 分区。这种机制既保证了设备出厂时的初始启动,又支持通过 OTA 动态切换固件,实现无缝升级。

6.1.3 OTA之HTTP升级流程

要实现OTA功能,至少需要两块设备,分别是服务器与客户端。服务器只有一个,客户端可有多个。服务器与设备连接,需要下载的镜像文件存放于服务器。首先获取当前可用的镜像文件信息,客户端收到信息后进行对比,若有与自身相匹配的镜像,则向服务器请求数据。服务器收到请求后向命令执行器索取固定大小的块,再点对点传送给客户端。镜像传输完毕后,客户端进行校验,完成后发送终止信号。

升级流程如下图所示:

首先,需要(搭建)一个HTTP的文件服务器。

7 SNTP

ESP32C3实现SNTP时钟同步的全面教程 - CSDN

posted @ 2026-01-14 16:04  博客侦探  阅读(51)  评论(0)    收藏  举报