音频那部分先停停,这回想给板子加个ui。想法很朴素:屏幕上摆个会眨眼会呼吸的家伙,而且有交互
找了一套桌宠待机动画,每个动作24 帧,画布 214×238。
先算一笔账。直接存 RGB565:
214 × 238 × 2 × 24 ≈ 2.4 MB
Flash 放得下,但太浪费,而且这才只是"待机"一个动作,后面还要加唤醒、思考、说话,照这个吃法迟早撑爆。得压。
写了个转换脚本,做了这么几件事。
先是调色板化。这套动画配色很简单,全部帧扔进去统一量化成 256 色,导出一张 xd_default_palette[](RGB565,512 字节),每个像素就只存一个索引,1 字节。这一步直接砍掉一半。
然后指定透明索引。桌宠是抠图,边上大片透明。挑一个没被用到的索引当透明色:
#define XD_DEFAULT_TRANSPARENT_INDEX 0
运行时碰到这个索引就跳过不画,不用另外存 alpha 通道。
接着做逐帧裁剪。24 帧里桌宠是上下浮动的,每帧真正有内容的区域大小、位置都不一样。没必要每帧都存满 214×238,只存有效包围盒,同时记下这帧该画在哪:
typedef struct {
uint32_t offset; /* 在 bin 里的偏移 */
uint32_t zlen; /* 压缩后长度 */
uint32_t raw_len; /* 解压后长度 = w*h */
uint16_t x, y; /* 画到画布的哪个位置 */
uint16_t w, h; /* 这帧的实际尺寸 */
} xd_default_frame_t;最后 zlib 压一道。索引图重复度极高(大片透明、大片纯色),压完体积又掉了一大截。
产出两样东西:一个 xd_default_bin[](所有帧压缩数据首尾相接)和一个 xd_default_frames[24](索引表)。整套下来几十 KB,舒服多了。
运行时的流程也就清楚了:按帧号查表 → 从 bin 里取出这段 → uncompress() 解压到 idx_buf → 逐像素查调色板 → 写到 canvas → 刷新。
第一次烧录:屏都不亮
代码写完,如下
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "miniz.h" // tinfl_decompress_mem_to_mem()
#include "bsp/esp32_s31_korvo_1.h"
#include "lvgl.h"
#include "xd_default.h" // 调色板 / 帧表 / 各种宏
static const char *TAG = "pet";
extern const uint8_t xd_default_bin_start[] asm("_binary_xd_default_bin_start");
#define xd_default_bin xd_default_bin_start
// ── 画布尺寸 & 屏幕落点 ──
#define PET_W XD_DEFAULT_CANVAS_W // 358 逻辑画布宽
#define PET_H XD_DEFAULT_CANVAS_H // 342 逻辑画布高
#define PET_SCREEN_X (800 - PET_W) // 右下角摆放,可调
#define PET_SCREEN_Y (480 - PET_H)
// ── 全局状态 ──
static lv_obj_t *pet_canvas; // 承载桌宠的 canvas
static uint8_t *idx_buf; // 解压缓冲:一帧的索引数据
static void *canvas_buf; // canvas 像素缓冲(需外部提供)
// ============================================================
// 工具:RGB565(调色板里的格式)→ RGB888(LVGL 要的格式)
// ============================================================
static inline uint32_t rgb565_to_hex(uint16_t c)
{
uint8_t r = (c >> 11) & 0x1F;
uint8_t g = (c >> 5) & 0x3F;
uint8_t b = c & 0x1F;
r = (r << 3) | (r >> 2); // 5→8 位
g = (g << 2) | (g >> 4); // 6→8 位
b = (b << 3) | (b >> 2); // 5→8 位
return ((uint32_t)r << 16) | ((uint32_t)g << 8) | b;
}
// ============================================================
// 把第 n 帧解压并画到 canvas
// ============================================================
static void pet_draw_frame(uint8_t n)
{
const xd_default_frame_t *f = &xd_default_frames[n];
// 1. 解压这帧 → idx_buf
size_t out_len = tinfl_decompress_mem_to_mem(
idx_buf, f->raw_len,
xd_default_bin + f->offset, f->zlen,
TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
if (out_len != f->raw_len) {
ESP_LOGE(TAG, "frame %u inflate fail out=%u want=%u",
n, (unsigned)out_len, (unsigned)f->raw_len);
return;
}
// 2. 清空 canvas 为全透明(抹掉上一帧)
lv_canvas_fill_bg(pet_canvas, lv_color_black(), LV_OPA_TRANSP);
// 3. 逐像素:索引→查表→写到 canvas 的 (f->x+col, f->y+row)
for (uint16_t row = 0; row < f->h; row++) {
for (uint16_t col = 0; col < f->w; col++) {
uint8_t idx = idx_buf[row * f->w + col];
if (idx == XD_DEFAULT_TRANSPARENT_INDEX) {
continue; // 透明像素:跳过,露背景
}
lv_color_t px = lv_color_hex(rgb565_to_hex(xd_default_palette[idx]));
lv_canvas_set_px(pet_canvas, f->x + col, f->y + row,
px, LV_OPA_COVER);
}
}
}
// ============================================================
// 动画 task:每 80ms 换一帧,无限循环待机动画
// ============================================================
static void pet_anim_task(void *arg)
{
uint8_t frame = 0;
while (1) {
bsp_display_lock(0); // 碰 LVGL 前加锁
pet_draw_frame(frame);
bsp_display_unlock();
frame = (frame + 1) % XD_DEFAULT_FRAME_COUNT;
vTaskDelay(pdMS_TO_TICKS(XD_DEFAULT_FRAME_REFRESH_MS)); // 80ms
}
}
// ============================================================
// 对外入口
// ============================================================
void pet_ui_start(void)
{
// 1. 起屏 + 触摸 + LVGL(RGB framebuffer / DMA / 防撕裂都在这)
// 用带配置的初始化,把 LVGL 缓冲放 PSRAM(内部 RAM 装不下 800x480)
bsp_display_cfg_t disp_cfg = {
.lvgl_port_cfg = ESP_LVGL_PORT_INIT_CONFIG(),
.buffer_size = BSP_LCD_H_RES * BSP_LCD_V_RES, // 全屏像素数
.double_buffer = true,
.flags = {
.buff_dma = false, // 放 PSRAM 时不能要 DMA-capable
.buff_spiram = true, // ← 关键:缓冲分配到 PSRAM
},
};
bsp_display_start_with_config(&disp_cfg);
bsp_display_backlight_on();
// 2. 分配缓冲(都放 PSRAM,16MB 富余)
// canvas 用 ARGB8888(带透明通道);LV_CANVAS_BUF_SIZE 宏算字节数。
// ← LVGL 8/9 这个宏参数略有差异,让 Claude Code 按实际版本核对。
size_t buf_sz = LV_CANVAS_BUF_SIZE(PET_W, PET_H, 32, LV_DRAW_BUF_STRIDE_ALIGN);
canvas_buf = heap_caps_malloc(buf_sz, MALLOC_CAP_SPIRAM);
idx_buf = heap_caps_malloc(XD_DEFAULT_MAX_RAW_BYTES, MALLOC_CAP_SPIRAM);
if (!canvas_buf || !idx_buf) {
ESP_LOGE(TAG, "no mem for buffers");
return;
}
// 3. 创建 canvas,摆到落点;屏幕底色给个深灰便于验证透明
bsp_display_lock(0);
pet_canvas = lv_canvas_create(lv_screen_active());
lv_canvas_set_buffer(pet_canvas, canvas_buf, PET_W, PET_H,
LV_COLOR_FORMAT_ARGB8888);
lv_obj_set_pos(pet_canvas, PET_SCREEN_X, PET_SCREEN_Y);
lv_obj_set_style_bg_color(lv_screen_active(), lv_color_hex(0x202030), 0);
lv_obj_set_style_bg_opa(lv_screen_active(), LV_OPA_COVER, 0);
bsp_display_unlock();
// 4. 开动画 task,钉在核心 1
xTaskCreatePinnedToCore(pet_anim_task, "pet_anim",
16384, NULL, 5, NULL, 1);
}idf.py build flash monitor。
屏是黑的。日志翻上去,显示初始化压根没走完就挂了。
回头查内存。canvas 我用的是 ARGB8888(需要 alpha 做透明叠加),一块就是:
214 × 238 × 4 ≈ 200 KB
解压缓冲 idx_buf 还要 214×238 ≈ 50 KB。再加上 LVGL 自己的绘制缓冲、800×480 这个分辨率……内部 SRAM 哪够。
改成全扔 PSRAM:
canvas_buf = heap_caps_malloc((size_t)PET_W * PET_H * 4, MALLOC_CAP_SPIRAM); idx_buf = heap_caps_malloc(MAX_RAW_LEN, MALLOC_CAP_SPIRAM);
同时显示初始化不用默认的 bsp_display_start() 了,换成带配置的版本,把 LVGL 的绘制缓冲也指到 PSRAM,开双缓冲:
bsp_display_cfg_t cfg = {
.lvgl_port_cfg = ESP_LVGL_PORT_INIT_CONFIG(),
.buffer_size = BSP_LCD_H_RES * 100,
.double_buffer = true,
.flags = { .buff_dma = false, .buff_spiram = true },
};
bsp_display_start_with_config(&cfg);再烧,日志终于有动静:
I Display initialized with resolution 800x480 I gt1151: IC version ... W This board doesn't support to change brightness
屏起来了,触摸的 gt1151 也认到了。最后那句背光警告是说这板子背光只能开关不能调亮度,跟我没关系,忽略。
第二次烧录:无限重启
高兴了三秒,往下翻:
***ERROR*** A stack overflow in task pet_anim has been detected.
然后就是重启、重启、重启。
一开始我下意识又去查内存,看堆还剩多少——结果 PSRAM 还剩十五六 MB,够得很。
盯着那行报错看了会儿才反应过来:它说的是 stack,不是 heap。堆是够的,是我给 pet_anim 这个 task 分配的栈太小了。
想想也合理。pet_draw_frame 里有个逐像素的双重循环,循环里一层层往下调,加上 LVGL 内部的调用深度,8KB 栈确实悬。
改一个数:
xTaskCreatePinnedToCore(pet_anim_task, "pet_anim", 16384, NULL, 5, NULL, 1); /* 8192 → 16384 */
烧录。不崩了。
屏幕上,桌宠出来了。
出来了,但是像放慢镜头
第一眼是高兴的,图对了、位置对了、透明边也干净,没有难看的黑框。
但看了两秒就觉得不对劲——这动画怎么跟慢镜头似的。
我设的是 80ms 一帧:
vTaskDelay(pdMS_TO_TICKS(80));
理论上 12.5fps,一个待机浮动动画,怎么也该是顺的。实际看着大概只有不到一帧的样子,就是一两秒刷一次,一顿一顿。
问题只可能出在画上。我回头看自己写的绘制循环:
for (uint16_t row = 0; row < f->h; row++) {
for (uint16_t col = 0; col < f->w; col++) {
uint8_t idx = idx_buf[row * f->w + col];
if (idx == XD_DEFAULT_TRANSPARENT_INDEX) continue;
lv_color_t c = lv_color_hex(rgb565_to_hex(xd_default_palette[idx]));
lv_canvas_set_px(pet_canvas, f->x + col, f->y + row, c);
}
}问题就在 lv_canvas_set_px 上。
这函数是一次调用画一个像素。 一帧五万个像素,就是五万次函数调用,每次进去还要做边界检查、坐标计算、颜色格式转换。这函数本来是给你"偶尔画几笔"用的,我拿它来刷整帧动画,慢是必然的。
而且慢到什么程度呢——画一帧本身就已经超过 80ms 了,所以那个 vTaskDelay(80) 根本没起作用,瓶颈在画,不在等。我改 delay 是没有任何意义的。
那就别用它。canvas 的像素缓冲就是我自己 malloc 出来的那块 canvas_buf,一块连续的 ARGB8888 内存,我直接算地址往里写就完了,比一次次调函数快几十倍。
整个绘制函数换成这样:
static void pet_draw_frame(uint8_t n)
{
const xd_default_frame_t *f = &xd_default_frames[n];
/* 解压到索引缓冲 */
uLongf out_len = f->raw_len;
int r = uncompress(idx_buf, &out_len,
xd_default_bin + f->offset, f->zlen);
if (r != Z_OK || out_len != f->raw_len) {
ESP_LOGE(TAG, "frame %u uncompress fail r=%d", n, r);
return;
}
/* 直接操作 canvas 的 ARGB8888 缓冲
* 小端下一个 uint32 就是 0xAARRGGBB */
uint32_t *fb = (uint32_t *)canvas_buf;
/* 整屏先清成全透明 */
memset(fb, 0, (size_t)PET_W * PET_H * 4);
/* 把这帧写进去 */
for (uint16_t row = 0; row < f->h; row++) {
uint32_t *dst = fb + (size_t)(f->y + row) * PET_W + f->x;
const uint8_t *src = idx_buf + (size_t)row * f->w;
for (uint16_t col = 0; col < f->w; col++) {
uint8_t idx = src[col];
if (idx == XD_DEFAULT_TRANSPARENT_INDEX) {
dst[col] = 0; /* A=0 全透明 */
} else {
uint32_t rgb = rgb565_to_hex(xd_default_palette[idx]);
dst[col] = 0xFF000000 | rgb; /* A=FF 不透明 */
}
}
}
/* 绕过了 LVGL 的绘制接口,得手动告诉它这块脏了 */
lv_obj_invalidate(pet_canvas);
}有个地方差点忘了:因为不走 lv_canvas_set_px 了,LVGL 不知道这块 canvas 变过,不会重画。最后必须补一句 lv_obj_invalidate()。
还有个当时留了心眼的地方:ARGB8888 在内存里到底是不是 0xAARRGGBB,跟 LVGL 版本和字节序都有关系。我做好了颜色红蓝互换的心理准备,打算不对就把 rgb565_to_hex 里 R 和 B 对调。结果烧上去颜色是对的,白捡一个。
速度也正常了。桌宠在屏幕上不紧不慢地上下浮动,那种"活着"的感觉出来了。
不过这种写法不太正规,准备换lvgl标准写法试试,我把程序改成了这样
#include "bsp/esp32_s31_korvo_1.h"
#include "lvgl.h"
#include "xd_default.h"
#include "miniz.h"
#include "esp_log.h" // 现在是从 BSP 头文件传递进来的,显式写上更稳
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "pet_ui";
// EMBED_FILES "assets/xd_default.bin" 生成,落在 .rodata,长度 XD_DEFAULT_BIN_BYTES
extern const uint8_t xd_default_bin[];
static lv_obj_t *pet_img; // image 对象句柄
#define PET_W XD_DEFAULT_CANVAS_W // 358
#define PET_H XD_DEFAULT_CANVAS_H // 342
static uint8_t *pet_buf; // 放 PSRAM,开机分配
static uint8_t *idx_buf; // 解压缓冲
static lv_image_dsc_t pet_dsc;
void display_init(){
size_t px = (size_t)PET_W * PET_H;
// 分配缓冲(PSRAM):颜色 2字节 + alpha 1字节 = 3字节/像素
pet_buf = heap_caps_malloc(px * 3, MALLOC_CAP_SPIRAM);
idx_buf = heap_caps_malloc(XD_DEFAULT_MAX_RAW_BYTES, MALLOC_CAP_SPIRAM);
memset(pet_buf, 0, px * 3); // 整块清零一次
// 填描述符
pet_dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
pet_dsc.header.cf = LV_COLOR_FORMAT_RGB565A8; // 带透明的格式
pet_dsc.header.w = PET_W;
pet_dsc.header.h = PET_H;
pet_dsc.header.stride = 0; // 颜色区每行字节数
pet_dsc.data = pet_buf;
pet_dsc.data_size = px * 3;
bsp_display_cfg_t cfg = {
.lvgl_port_cfg = ESP_LVGL_PORT_INIT_CONFIG(),
.buffer_size = BSP_LCD_H_RES * BSP_LCD_V_RES, // 全屏像素数
.double_buffer = true,
.flags = {
.buff_dma = false,
.buff_spiram = true, // 缓冲放 PSRAM(关键)
},
};
bsp_display_start_with_config(&cfg);
bsp_display_backlight_on(); // 背光单独开
// 顺便给屏幕一个深色背景,方便看清 image 在哪
lv_obj_set_style_bg_color(lv_screen_active(), lv_color_hex(0x202030), 0);
lv_obj_set_style_bg_opa(lv_screen_active(), LV_OPA_COVER, 0);
}
static void pet_create_image(uint8_t n)
{
const xd_default_frame_t *f = &xd_default_frames[n];
// 1. 解压这帧 → idx_buf(得到 w×h 个索引)
size_t out_len = tinfl_decompress_mem_to_mem(
idx_buf, f->raw_len,
xd_default_bin + f->offset, f->zlen,
TINFL_FLAG_PARSE_ZLIB_HEADER | TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF);
if (out_len != f->raw_len) {
ESP_LOGE(TAG, "frame %u inflate fail", n);
return;
}
// 2. 定位颜色区和 alpha 区
uint16_t *color = (uint16_t *)pet_buf; // 颜色区:前 W*H*2 字节
uint8_t *alpha = pet_buf + (size_t)PET_W * PET_H * 2; // alpha区:紧随其后
// 3. 整图先清透明(alpha=0;颜色不用清,透明处看不见)
memset(alpha, 0, (size_t)PET_W * PET_H);
// 4. 把这帧内容写进去
for (uint16_t row = 0; row < f->h; row++) {
size_t base = (size_t)(f->y + row) * PET_W + f->x; // 该行在整图的起始像素
const uint8_t *src = idx_buf + (size_t)row * f->w;
for (uint16_t col = 0; col < f->w; col++) {
uint8_t idx = src[col];
if (idx == XD_DEFAULT_TRANSPARENT_INDEX) {
continue; // 透明:alpha 保持 0
}
color[base + col] = xd_default_palette[idx]; // RGB565 直接放
alpha[base + col] = 0xFF; // 不透明
}
}
bsp_display_lock(0); // 碰 LVGL 前加锁
pet_img = lv_image_create(lv_screen_active()); // 建 image,挂在屏幕上
lv_image_set_src(pet_img, &pet_dsc);
lv_obj_set_pos(pet_img, 100, 100); // 先摆在 (100,100)
bsp_display_unlock(); // 解锁
}
void pet_ui_start(void)
{
display_init();
pet_create_image(0); // 创建第一帧图像
while(1)
{
vTaskDelay(pdMS_TO_TICKS(10)); // 10ms 延时
}
}就想先测试一下,结果屏幕竟然没显示,程序启动后,就是显示花屏,然后就灭了,我最初还以为是lvgl的RGB565A8我没配置对,改了后还是一样的情况,我去仔细看日志,发现板子在反复重启,屏幕根本没机会显示 —— 复位后每一轮都是同样的 panic:
I (961) ESP32-S31-Korvo-1: Display initialized with resolution 800x480 I (1001) gt1151: IC version: GT1158_... W (1001) ESP32-S31-Korvo-1: This board doesn't support to change brightness of LCD Guru Meditation Error: Core 0 panic'ed (Stack protection fault). Detected in task "main" at 0x2f81856a Stack pointer: 0x2f024490 Stack bounds: 0x2f026060 - 0x2f027050
原因:tinfl_decompress_mem_to_mem 把 ~11KB 的解压器放在了调用者的栈上
main 任务的栈只有 4080 字节(CONFIG_ESP_MAIN_TASK_STACK_SIZE=3584,对齐后 0x2f026060–0x2f027050)。而崩的时候 SP 已经跑到 0x2f024490,比栈底还低 7120 字节,整个帧要 0x2f027050 - 0x2f024490 = 11200 字节。
这个数字的来源在 esp_rom 的 miniz.h:617:tinfl_decompress_mem_to_mem 内部会声明一个 tinfl_decompressor 局部变量,而这个结构体里有 3 张 huffman 表:
tinfl_huff_table = 288 + 1024*2 + 576*2 = 3488 字节 × TINFL_MAX_HUFF_TABLES(3) = 10464 + 头部字段和 m_len_codes ≈ 11008 字节
寄存器转储里 A0 : 0x00002b00 正好是 11008,S0 : 0x0000c39c = 50076 正好是 xd_default_frames[0].raw_len —— 就是死在第 70 行那个调用上。
时间线也对得上:LCD 和触摸都初始化成功了(800x480、GT1158 都认出来了),bsp_display_backlight_on() 的那句 warning 也打出来了,然后一进 pet_create_image 就炸,lv_image_create 从来没执行过。所以不是 LVGL、不是描述符、不是 PSRAM(16MB 认到了)—— 显示链路本身是好的。
原因就是我再app_main.c里顺手写的
#include "pet_ui.h"
void app_main(void)
{
pet_ui_start();
}后来才知道,espidf的app_main 本身就是运行在一个 FreeRTOS 任务里的,这个任务叫 "main",它有自己的栈,而且这个栈默认很小。
CONFIG_ESP_MAIN_TASK_STACK_SIZE=3584
对齐后实际 4080 字节
main 任务默认栈大约 3.5~4KB。 这是 menuconfig 里一个配置项定的,默认值就这么大。
对一般的初始化代码(建外设、起别的任务)够用。但我在 app_main 里直接调了 tinfl_decompress_mem_to_mem——这个函数一进去就要在栈上放 11KB 的解压器。4KB 的栈塞 11KB 的东西,当场溢出,栈保护机制检测到就 panic 重启。
所以改动很简单,改成建一个任务就行
#include "pet_ui.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
void app_main(void)
{
xTaskCreatePinnedToCore(pet_ui_start, "pet", 16384, NULL, 5, NULL, 1);
}然后就正常显示了,而且前面用 canvas 手动往缓冲里画,和 LVGL 的图像系统配合不方便。改成用 lv_image+ lv_image_dsc_t的标准写法,后续要叠多个 image、做缩放/动画都更顺
代码就微调一下就行
void pet_ui_start(void *arg)
{
display_init();
pet_create_image();
uint8_t frame = 0;
while(1)
{
pet_draw_frame(frame); // 刷新当前帧
frame = (frame + 1) % XD_DEFAULT_FRAME_COUNT;
vTaskDelay(pdMS_TO_TICKS(XD_DEFAULT_FRAME_REFRESH_MS)); // 80ms
vTaskDelay(pdMS_TO_TICKS(10)); // 10ms 延时
}
}贴一下最终效果
不过只有一个default,别的动作后续再补,下一篇准备把网络链路跑通
我要赚赏金
