ESP32 进阶开发杂谈:从异步请求、动图显示到资源OTA

前言

很多时候,我们从零开始构建一个ESP32项目,往往会掉进各种各样的“坑”里。

在之前的一些项目中(比如做个桌面像素小屏幕吧),我遇到过各种各样的问题:网络请求卡死主线程、屏幕显示太单调、休眠时PWM停转、以及每次只更新几张图片却要重刷整个固件的痛苦。

把这些坑踩平之后,我整理了四个在ESP32开发中非常实用的技巧。为了避免大家重蹈覆辙,也为了方便我自己日后查阅(Copy),这篇文章将把这些技术点汇总起来。

希望能给正在折腾ESP32的你提供一些灵感。


技巧一:拒绝阻塞!使用异步网络请求

痛点分析

在早期的像素小屏幕项目中,我为了获取B站粉丝数,直接使用了同步的HTTP Client。结果就是每次请求网络时,整个设备的UI都会卡住几百毫秒甚至几秒。这对于用户体验来说简直是灾难——你不能让用户觉得设备“死机”了。

为了优雅,必须上异步。

解决方案

我们可以利用 AsyncTCP 库来实现非阻塞的HTTP请求。虽然写起来回调函数(Callback)套娃有点多,但换来的是丝般顺滑的主循环。

核心代码

这里使用的是 AsyncTCP-esphome 库。

AsyncRequest.cpp
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
#include <Arduino.h>
#include <AsyncTCP.h>
#include <WiFi.h>

// ... WiFi配置省略 ...

void asyncReqeust() {
static AsyncClient *aClient;
if (aClient) return; // 防止重复创建

aClient = new AsyncClient();
if (!aClient) return;

// 注册错误回调
aClient->onError([](void *arg, AsyncClient *client, int error) {
Serial.println("Connect Error");
aClient = NULL;
delete client;
}, NULL);

// 注册连接回调
aClient->onConnect([](void *arg, AsyncClient *client) {
Serial.println("Connected");
aClient->onError(NULL, NULL);

// 注册断开连接回调
client->onDisconnect([](void *arg, AsyncClient *c) {
aClient = NULL;
delete c;
Serial.println("Disconnected");
}, NULL);

// 注册数据接收回调(核心逻辑)
client->onData([](void *arg, AsyncClient *c, void *data, size_t len) {
Serial.write((uint8_t *)data, len);
// 这里可以解析JSON数据
}, NULL);

// 发送HTTP GET请求
client->write("GET /x/relation/stat?vmid=14374079 HTTP/1.1\r\n"
"Host: api.bilibili.com\r\n"
"Content-Type: application/json; charset=utf-8\r\n\r\n");
}, NULL);

if (!aClient->connect("api.bilibili.com", 80)) {
Serial.println("Connect Fail");
AsyncClient *client = aClient;
aClient = NULL;
delete client;
}
}

这样,网络请求在后台默默进行,你的主循环 loop() 依然可以跑得飞起,去处理按键扫描或者屏幕刷新。


技巧二:让画面动起来——播放GIF动图

视觉升级

网络通畅了,界面也不能太寒酸。在做一个带有240x135分辨率的装置时,我实在想不出什么高级的算法动画,于是决定“偷懒”:直接在屏幕上播放GIF表情包。

这里推荐使用 AnimatedGIF 库,配合 TFT_eSPI 驱动,效果非常不错。

制作GIF头文件

首先,我们需要把GIF文件转换成代码能读取的数组。在Linux或者WSL子系统下,一行 xxd 命令就能搞定:

1
2
# 将GIF转换为C数组
xxd -i angry_80px.gif >> loading.h

生成的 loading.h 里面就是一个巨大的 unsigned char 数组。

驱动代码

代码基于官方示例修改,适配了 TFT_eSPI。

GifPlayer.cpp
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
#include <AnimatedGIF.h>
#include <TFT_eSPI.h>
#include "loading.h" // 刚才生成的头文件

AnimatedGIF gif;
TFT_eSPI tft = TFT_eSPI();

// 回调函数:将解码后的一行像素推送到屏幕
void GIFDraw(GIFDRAW *pDraw) {
// ... 核心绘制逻辑,包含透明度处理和DMA加速 ...
// 篇幅原因,核心逻辑是调用 tft.pushPixels 将 pDraw->pPixels 推送显示
// 完整逻辑参考 AnimatedGIF 的 TFT_eSPI_memory 示例
}

void setup() {
tft.begin();
tft.setRotation(1);
tft.fillScreen(TFT_BLACK);
gif.begin(BIG_ENDIAN_PIXELS);
}

void loop() {
if (gif.open((uint8_t *)angry_80px_gif, sizeof(angry_80px_gif), GIFDraw)) {
while (gif.playFrame(true, NULL)) {
yield(); // 喂狗,防止复位
}
gif.close();
}
}

只要内存够大,放个蔡徐坤打篮球的GIF也不是不可能(逃)。


技巧三:Light-Sleep模式下保持PWM输出

奇怪的需求

有些场景下(比如背光保持),我们需要ESP32进入 Light-Sleep 省电,但又不希望 屏幕背光的PWM 信号中断。
默认情况下,进入睡眠后高速时钟会关闭,导致 PWM 停摆。

开启 RTC8M 时钟

查阅 ESP-IDF 手册发现,如果将 PWM 的时钟源配置为 RTC8M_CLK,即使在 Light-Sleep 下也能工作。但这有个前提,需要修改 menuconfig

  1. 环境配置

    1
    idf.py menuconfig

    进入 Component config -> Hardware Settings -> Sleep Config
    务必关闭 light sleep GPIO reset workaround

  2. 代码实现
    使用 ESP-IDF 原生 API 配置 LEDC。

pwm_sleep.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
#include "driver/ledc.h"
#include "esp_sleep.h"

void app_main(void) {
// 1. 定时器配置:重点是选用 LEDC_USE_RTC8M_CLK
ledc_timer_config_t ledc_timer = {
.duty_resolution = LEDC_TIMER_13_BIT,
.freq_hz = 1000,
.speed_mode = LEDC_LOW_SPEED_MODE, // 必须是低速模式
.timer_num = LEDC_TIMER_0,
.clk_cfg = LEDC_USE_RTC8M_CLK, // 关键!
};
ESP_ERROR_CHECK(ledc_timer_config(&ledc_timer));

// 2. 通道配置... (常规配置,略)

// 3. 强制开启RTC8M电源域
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC8M, ESP_PD_OPTION_ON);

while (1) {
// 设置一个占空比
ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 1000);
ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);

// 进入浅睡眠,PWM依然会保持输出
esp_sleep_enable_timer_wakeup(1000 * 1000 * 5); // 睡5秒
esp_light_sleep_start();
}
}

实测这个功能对于降低功耗非常有用。


技巧四:只更新资源文件?试试 SPIFFS 分区 OTA

场景

随着项目越来越大,我发现一个问题:有时候我只想更新一下UI里的图片资源或者字体库,并不想更新代码。
传统的OTA是更新 App 分区,这很浪费流量和时间。其实我们完全可以只针对 SPIFFS 数据分区进行 OTA。

分区表设计

首先,我们需要自定义分区表(partitions.csv),把数据独立出来。

1
2
3
4
5
# Name,   Type, SubType, Offset,  Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
app0, app, ota_0, 0x10000, 0x300000,
storage, data, spiffs, 0x310000, 0xC000,

这里我划了一个 48KB 的 storage 分区用于演示。

OTA 核心逻辑

不同于更新 App,更新分区实际上就是“擦除 + 写入”的过程。假设新的文件镜像已经通过网络下载到了内存中(或者像本例一样,为了演示直接 embed 在代码里)。

spiffs_ota.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
void update_spiffs_partition() {
// 1. 查找 SPIFFS 分区
const esp_partition_t *spiffs_part = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, NULL);

if (spiffs_part == NULL) {
ESP_LOGE("OTA", "SPIFFS partition not found!");
return;
}

// 2. 擦除原分区内容
ESP_LOGI("OTA", "Erasing partition...");
esp_partition_erase_range(spiffs_part, 0, spiffs_part->size);

// 3. 写入新数据
// 假设 spiffs2_start 和 spiffs2_end 是新镜像在内存中的地址
size_t image_size = spiffs2_end - spiffs2_start - 1;
ESP_LOGI("OTA", "Writing new data: %d bytes", image_size);

esp_partition_write(spiffs_part, 0, spiffs2_start, image_size);

ESP_LOGI("OTA", "Done! Restarting...");
esp_restart();
}

这个技巧在做图片更换、字体切换等功能时特别好用,不用动核心代码,安全又快速。


总结

以上就是我近期折腾ESP32时总结的四个实用技巧。
从避免阻塞的异步请求,到花里胡哨的GIF播放,再到低功耗下的PWM控制和灵活的资源OTA,每一个点都是在实际开发中为了解决特定痛点而摸索出来的。

硬件开发的乐趣大概就在于此:遇到一个坑,填平它,然后看着设备按照预想的方式运行,那种成就感是无法替代的。

希望这些笔记对你有所帮助,我们下个项目见!

ESP32 进阶开发杂谈:从异步请求、动图显示到资源OTA

https://chaosgoo.com/2022/09/20/esp32-advanced-dev-tips/

作者

Chaos Goo

发布于

2022-09-20

更新于

2025-12-09

许可协议