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

8 阅读6分钟

本文首发于我的个人博客: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

  1. 环境配置

    idf.py menuconfig
    

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

  2. 代码实现: 使用 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,每一个点都是在实际开发中为了解决特定痛点而摸索出来的。

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