本文首发于我的个人博客:chaosgoo.com,欢迎来访交流。
很多时候,我们从零开始构建一个ESP32项目,往往会掉进各种各样的“坑”里。
在之前的一些项目中(比如做个桌面像素小屏幕吧),我遇到过各种各样的问题:网络请求卡死主线程、屏幕显示太单调、休眠时PWM停转、以及每次只更新几张图片却要重刷整个固件的痛苦。
把这些坑踩平之后,我整理了四个在ESP32开发中非常实用的技巧。为了避免大家重蹈覆辙,也为了方便我自己日后查阅(Copy),这篇文章将把这些技术点汇总起来。
希望能给正在折腾ESP32的你提供一些灵感。
技巧一:拒绝阻塞!使用异步网络请求
痛点分析
在早期的像素小屏幕项目中,我为了获取B站粉丝数,直接使用了同步的HTTP Client。结果就是每次请求网络时,整个设备的UI都会卡住几百毫秒甚至几秒。这对于用户体验来说简直是灾难——你不能让用户觉得设备“死机”了。
为了优雅,必须上异步。
解决方案
我们可以利用 AsyncTCP 库来实现非阻塞的HTTP请求。虽然写起来回调函数(Callback)套娃有点多,但换来的是丝般顺滑的主循环。
核心代码
这里使用的是 AsyncTCP-esphome 库。
#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 命令就能搞定:
## 将GIF转换为C数组
xxd -i angry_80px.gif >> loading.h
生成的 loading.h 里面就是一个巨大的 unsigned char 数组。
驱动代码
代码基于官方示例修改,适配了 TFT_eSPI。
##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。
-
环境配置:
idf.py menuconfig进入
Component config->Hardware Settings->Sleep Config。 务必关闭light sleep GPIO reset workaround。 -
代码实现: 使用 ESP-IDF 原生 API 配置 LEDC。
#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),把数据独立出来。
## 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 在代码里)。
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,每一个点都是在实际开发中为了解决特定痛点而摸索出来的。
硬件开发的乐趣大概就在于此:遇到一个坑,填平它,然后看着设备按照预想的方式运行,那种成就感是无法替代的。