ESP32 2432S028R (CYD)でLVGL - ダブルバッファとマルチコアで応答性を高める(1)
はじめに
始めは LovyanGFX で LVGL を立ち上げるだけのつもりが、ふと、とあるマジックナンバーが気になり、タイトル通りのことを試すことになりました。
1回目の本記事では、LVGL の美麗なデモを元に、タイトルのベースラインとなるプログラムを作成します。さらに GUI の応答性を測る指標として、フレームレートと画像の転送時間に着目し、画像メモリの設定を最適化する条件を検討したいと思います。
LVGL の全体概要

図1は LVGL のドキュメント から拝借し、日本語に意訳した全体概要の図です。
LVGL 本体は、Web ブラウザの DOM ツリー 同様、UI 要素であるウィジェットのツリーを構築します。ツリーの各ノードは親子関係持ち、例えばボタンは文字を表示するラベルを子に持つことが出来ます。こうしたツリー構造により、ユーザーの操作に応じて更新すべきウィジェットを効率的に特定します。
また LVGL 本体は、様々な機器で動作できるようハードウェアから独立し、アプリケーションとの役割を明確にしています。この LVGL 本体とアプリケーションとの接点として押さえるべきポイントが、赤枠で囲った3つの要素 ─ UI 入力、時間管理、そしてパネルへの出力です。
UI 入力
LVGL では、タッチパネルやマウスなどのポインティングデバイスの他、組み込み機器を対象にエンコーダーやボタンといった 入力デバイスの型 が定義されていて、コールバック関数を介して入力デバイスの状態変化を LVGL に伝える仕組みとなっています。
アプリケーションは、例えばタッチパネルの監視をコールバック関数に登録し、各 UI 要素(ウィジェット)に対するユーザー要求のビジネスロジックを イベント駆動型 で実装します。あとは LVGL が良きに計らってくれます。
時間管理
ミリ単位の正確な時間を刻むのはハードウェアの役割です。アプリケーションは、時間管理用 API 介して LVGL からの経過時間の問い合わせに応えたり、ディプレイパネルの更新にかかった時間を申告したりすることがその役割となります。
パネルへの出力
ウィジェットを描画用の画像メモリにレンダリングするまでは LVGL の役割ですが、メモリ中のデータをディスプレイに転送するのはアプリケーション側の役割です。
組み込み向けにメジャーなドライバの ドキュメント や コード(ココ とか ココ)が提供されていますが、Arduino 環境では TFT_eSPI が 推奨されています。「データをディスプレイに転送する」だけで、TFT_eSPI が持つリッチな機能は使わないので何とも勿体無いハナシですが、お手軽に試すことが出来ます。
「レンダリング」と「パネルへの出力」との関係を可視化
ここで、LVGL が担当する「レンダリング」とアプリケーションが担当する「パネルへの出力」を可視化したデモを提示します。
映像中の矩形は LVGL が指定した更新すべき領域で、毎フレームごとに枠を「赤→緑→青」に変色しています。色が変化しなくなった領域は更新の必要が無くなったことを示しています。
スクロール時には横長の矩形領域が縦に並び画面全体を更新します。一方スクロールの停止時は、スタイルが変化した領域だけが更新されている様子が分かると思います。このように幾つかの矩形領域に分割することで省メモリを実現をすると共に、GPU などの H/W がある場合には並列処理によるレンダリングを可能にしています。
LVGL の Issues では、CPU と GPU の連携、CPU によるソフトウェアレンダリング、マルチコアによる並列化、そしてこれらを統括するレンダリングの最適化戦略などが議論されています。
- Multi-threaded drawing #3643
- [multi-threaded-drawing] Discussion about drawing tasks #3700
- [Parallel rendering] General discussion #4016
これらを読むと、全てをソフトウェアでレンダリングしなければならない ESP32 では、DMA はもちろん2コアの活用 ─ 特に矩形領域の分割数と並列化によるダブルバッファの活用が機敏な GUI を実現するカギとなることが予想出来ます。
何はともあれ、まずはベースラインとなるデモを動かすことから始めたいと思います。
LVGL のデモを動かす
何はともあれ、まずはデモを動かすことが先決です。
LVGL の ドキュメント には TFT_eSPI 向けの手順が記載されていますが、僕の最終目標は LovyanGFX で動かすことなので、多少のアレンジをしています。
1. 動作環境
動作確認したそれぞれのバージョンは以下の通りです。
パッケージ | バージョン |
---|---|
Arduino IDE | 2.3.4 |
ESP32 by Espressif Systems | 3.1.3 |
LVGL | 9.2.2 |
TFT_eSPI | 2.5.43 |
XPT2046_Touchscreen | 1.4 |
LovyanGFX | 1.2.0 |
今回は LVGL のサンプルスケッチ LVGL_Arduino をベースに、TFT_eSPI 版と LovyanGFX 版を作成しました。これらは GitHub リポジトリ LVGL_Arduino_FastRendering に上げたので、ご活用ください。
現在 LovyanGFX 版は esp32 by Espressif v3.2.0 (ESP-IDF v5.4.1) でランタイムエラーが発生しています。LovyanGFX の issue に上げていますが、v3.1.3 でお試し下さい。
2. ボードの設定


CYD 用のボードタイプとして ESP32-2432S028R CYD
を選択します。pins_arduino.h
が読み込まれ、CYD 用のピン配が設定されます。
CYD 以外のボードの場合は、以降の手順で CYD_*
を適切な GPIO 番号に置き換えてください。
CYD では書き込み時のボーレートに 460800
を選択します。またデフォルトのフラッシュ ROM サイズではギリギリか不足する可能性があるため、パーティションスキームを取り敢えず Huge APP
に設定するのが吉です。
3. LVGL の設定
3.1. lv_conf.h
の作成
ライブラリフォルダに LVGL をインストールし、lvgl
直下の lv_conf_template.h
をすぐ上のフォルダに lv_conf.h
としてコピーします(LVGL のバージョンアップ時にも設定を残すための措置だと思います)。
libraries
├── ...
├── lv_conf.h // <-- ココに `lv_conf_template.h` をコピーして編集する
├── lvgl
│ ├── demos
│ ├── docs
│ ├── env_support
│ ├── examples
│ ├── ...
│ ├── lv_conf_template.h // ライブラリフォルダ直下に `lv_conf.h` としてコピーする
│ ├── ...
│ ├── scripts
│ ├── src
│ ├── tests
│ └── zephyr
└── ...
3.2. lv_conf.h
の編集
適当なエディタで lv_conf.h
を開き、下記を参考に設定を変更します。バージョンアップの度に行数が増減するので、シンボル名を検索して該当箇所を見つけてください。
lv_conf.h
の設定を有効化する(14〜15行目付近)
/* clang-format off */
#if 1 /*Set it to "1" to enable content*/
- 以下、デモを動かすのに必要なシンボルと設定値を示します。
セクション | シンボル | 値 | 内容 |
---|---|---|---|
FONT USAGE | LV_FONT_MONTSERRAT_12 |
1 | lv_demo_music() に必要 |
^ | LV_FONT_MONTSERRAT_14 |
1 | デフォルト |
^ | LV_FONT_MONTSERRAT_16 |
1 | lv_demo_music() に必要 |
OTHERS | LV_USE_SYSMON |
1 | 以下の表示に必要な指標をモニタリング |
^ | LV_USE_PERF_MONITOR |
1 | フレームレートを表示 |
^ | LV_USE_MEM_MONITOR |
1 | メモリ使用状況を表示 |
DEVICES | LV_USE_TFT_ESPI |
0 | TFT_eSPI用のコードをリンクしない |
^ | LV_USE_ST7789 |
1 | 使用するディスプレイドライバを有効化 |
^ | LV_USE_ILI9341 |
1 | ^(複数指定可) |
DEMO USAGE | LV_USE_DEMO_WIDGETS |
1 | lv_demo_widgets() に必要なコードをリンク |
^ | LV_USE_DEMO_MUSIC |
1 | lv_demo_music() に必要なコードをリンク |
^ | LV_DEMO_MUSIC_LANDSCAPE |
1 | lv_demo_music() をランドスケープで表示 |
^ | LV_DEMO_MUSIC_AUTO_PLAY |
1 | lv_demo_music() を自動再生 |
3.3. demos
と examples
の配置変更
両フォルダを src
の下に移動またはコピーします。本記事執筆時点の v9.2.2 では Issue #6778 が未反映のため、デモのコンパイル時に幾つかファイルの修正が必要です。修正前のファイルを残しておきたい場合はコピーが良いかも知れません。
libraries
├── ...
├── lv_conf.h
├── lvgl
│ ├── demos // `src` に移動またはコピーします
│ ├── docs
│ ├── env_support
│ ├── examples // `src` に移動またはコピーします
│ ├── ...
│ ├── lv_conf_template.h
│ ├── ...
│ ├── scripts
│ ├── src
│ │ ├── demos // ココに移動またはコピーします
│ │ └── examples // ココに移動またはコピーします
│ ├── tests
│ └── zephyr
└── ...
4. TFT_eSPI 版デモの構築
LovyanGFX 版 に先立ち、動作確認と比較テスト用に TFT_eSPI 版 を構築しました。
LVGL_Arduino_FastRendering
└── LVGL_Arduino_TFT_eSPI
├── LVGL_Arduino_TFT_eSPI.ino
├── User_Setup.h
└── lv_tft_espi.hpp
コードの説明は割愛しますが 😜、ポイントは以下の通りです。
lv_conf.h
でLV_USE_TFT_ESPI
を有効にしなくても動作するよう、lv_tft_espi.cpp をローカルに.hpp
としてコピーし、若干の修正をかけています。- Random Nerd チュートリアル を参考に XPT2046_Touchscreen(CYD ではディスプレイと異なる SPI バスに接続)を追加しています。
- スクリーンの向きを変える場合は
TFT_ROTATION
を変更して下さい(TFT_HOR_RES
とTFT_VER_RES
は変えない)。 - CYD 用の
User_Setup.h
を定義してあります。デモと例題の動作に不必要なコードは極力削除しています。
コンパイルエラーの修正方法
最新版 の LVGL でも Issue #6778 の修正が未反映の場合、コンパイルでエラーになるので、該当箇所をエディタで開き、#include
の相対パスから /src
を削除します。
- 修正前の例
#include "../../src/themes/lv_theme_private.h"
- 修正後の例
#include "../../themes/lv_theme_private.h"
全てのエラーを修正し、次のように起動すれば成功です 👍
システムモニタの読み方
- メモリ(左下)
- 上段:
lv_conf.h
で設定されたLV_MEM_SIZE
中の使用量とその割合% - 下段:最大使用量と断片化の割合%
- 上段:
- パフォーマンス(右下)
- 上段:フレームレートの平均値と CPU 使用率
- 下段:1フレームの平均処理時間(レンダリング時間|パネルへの転送時間)
5. LovyanGFX 版デモの構築
応答性向上のベースラインとして LovyanGFX 版 を構築します。
LVGL_Arduino_FastRendering
└── LVGL_Arduino_LovyanGFX
└── LVGL_Arduino_LovyanGFX.ino
以下、LVGL のサンプルスケッチ LVGL_Arduino の修正点を説明します。
デモ用スケッチの作成
.ino
中の#if LV_USE_TFT_ESPI
〜#endif
ディレクティブを以下と差し替えます。
#define LGFX_AUTODETECT
#include <LovyanGFX.h>
- 次にデモ用のヘッダファイルを有効にします。
//#include <examples/lv_examples.h>
#include <demos/lv_demos.h>
- 縦横の解像度は LovyanGFX に合わせます。スクリーンの向きを変える場合は
TFT_HOR_RES
とTFT_VER_RES
はそのままで、TFT_ROTATION
を変更して下さい。
/*Set to your screen resolution and rotation*/
#define TFT_HOR_RES 240
#define TFT_VER_RES 320
#define TFT_ROTATION LV_DISPLAY_ROTATION_0 // LV_DISPLAY_ROTATION_{0|90|180|270}
- 画像メモリ
draw_buf
の元は配列uint32_t draw_buf[DRAW_BUF_SIZE / 4]
でしたが、動的に割り当てるためポインタuint8_t *draw_buf
に変更します(理由は後述)。
/*LVGL draw into this buffer, 1/10 screen size usually works well. The size is in bytes*/
#define DRAW_BUF_SIZE (TFT_HOR_RES * TFT_VER_RES / 10 * (LV_COLOR_DEPTH / 8))
static uint8_t *draw_buf;
- 「パネルへの出力」用コールバック関数
my_disp_flush()
を置き換えます。出力用の関数pushPixelsDMA()
はpushImageDMA()
でも動作しますが、前者の方が若干高速です。
my_disp_flush()
/* LVGL calls it when a rendered image needs to copied to the display*/
static void my_disp_flush(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map) {
uint32_t w = lv_area_get_width(area);
uint32_t h = lv_area_get_height(area);
tft.setAddrWindow(area->x1, area->y1, w, h);
tft.pushPixelsDMA((lgfx::rgb565_t *)px_map, w * h);
/*Call it to tell LVGL you are ready*/
lv_display_flush_ready(disp);
}
- 続いて
my_touchpad_read()
にタッチを読み取るコードを追加します。より汎用的にするためにswitch()
文で振り分けていますが、向きを動的に変えなければ該当する向きのコードだけで OK です。
my_touchpad_read()
/*Read the touchpad*/
static void my_touchpad_read(lv_indev_t *indev, lv_indev_data_t *data) {
uint16_t x, y;
bool touched = tft.getTouch(&x, &y);
if (!touched) {
// Serial.printf("Released\n");
data->state = LV_INDEV_STATE_RELEASED;
} else {
data->state = LV_INDEV_STATE_PRESSED;
switch (tft.getRotation()) {
case LV_DISPLAY_ROTATION_0:
data->point.x = x;
data->point.y = y;
break;
case LV_DISPLAY_ROTATION_90:
data->point.x = y;
data->point.y = TFT_VER_RES - x;
break;
case LV_DISPLAY_ROTATION_180:
data->point.x = TFT_HOR_RES - x;
data->point.y = TFT_VER_RES - y;
break;
case LV_DISPLAY_ROTATION_270:
data->point.x = TFT_HOR_RES - y;
data->point.y = x;
break;
}
Serial.printf("x: %d (%d), y: %d (%d)\n", data->point.x, x, data->point.y, y);
}
}
- また TFT_eSPI 版に倣い、パネル回転時のコールバック関数を追加します。
resolution_changed_event_cb()
static void resolution_changed_event_cb(lv_event_t *e) {
lv_display_t *disp = (lv_display_t *)lv_event_get_target(e);
lv_display_rotation_t rot = lv_display_get_rotation(disp);
/* handle rotation */
switch (rot) {
case LV_DISPLAY_ROTATION_0:
tft.setRotation(0); /* Portrait orientation */
break;
case LV_DISPLAY_ROTATION_90:
tft.setRotation(1); /* Landscape orientation */
break;
case LV_DISPLAY_ROTATION_180:
tft.setRotation(2); /* Portrait orientation, flipped */
break;
case LV_DISPLAY_ROTATION_270:
tft.setRotation(3); /* Landscape orientation, flipped */
break;
}
}
- タッチパネルの キャリブレーション を行う関数
calibrate_touch()
を追加します。
calibrate_touch()
// Calibrate touch when enabled (optional)
static void calibrate_touch(uint16_t cal[8]) {
// Draw guide text on the screen.
tft.setTextDatum(textdatum_t::middle_center);
tft.drawString("touch the arrow marker.", tft.width() >> 1, tft.height() >> 1);
tft.setTextDatum(textdatum_t::top_left);
// You will need to calibrate by touching the four corners of the screen.
uint16_t fg = TFT_WHITE;
uint16_t bg = TFT_BLACK;
if (tft.isEPD()) { // Electronic Paper Display
std::swap(fg, bg);
}
tft.calibrateTouch(cal, fg, bg, std::max(tft.width(), tft.height()) >> 3);
Serial.print("\nuint16_t cal[8] = { ");
for (int i = 0; i < 8; i++) {
Serial.printf("%d%s", cal[i], (i < 7 ? ", " : " };\n"));
}
Serial.print("tft.setTouchCalibrate(cal);\n");
}
setup()
直前には、初期化用の関数tft_init()
を追加します。if (true) {…}
を修正し、キャリブレーションの実行を制御して下さい。
tft_init()
static void tft_init(void) {
tft.init();
tft.initDMA();
tft.setColorDepth(16); // Set to 16-bit RGB565
if (tft.touch()) {
if (true) {
const uint16_t cal[8] = {
240, // x_min
3700, // y_min
240, // x_min
200, // y_max
3800, // x_max
3700, // y_min
3800, // x_max
200 // y_max
};
tft.setTouchCalibrate((uint16_t*)cal);
} else {
uint16_t cal[8];
calibrate_touch(cal);
tft.setTouchCalibrate(cal);
}
} else {
Serial.println("Touch device not found.");
}
}
- 最後は
setup()
とloop()
です。That Project を運営する Eric さんのコード を参考に、画像メモリdraw_buf
をヒープ領域から動的に割り当てているところがミソです。
setup() と loop()
void setup() {
Serial.begin(115200);
while (millis() < 1000);
tft_init();
lv_init();
/*Set a tick source so that LVGL will know how much time elapsed. */
lv_tick_set_cb(my_tick);
/* register print function for debugging */
#if LV_USE_LOG != 0
lv_log_register_print_cb(my_print);
#endif
lv_display_t *disp = lv_display_create(TFT_HOR_RES, TFT_VER_RES);
lv_display_add_event_cb(disp, resolution_changed_event_cb, LV_EVENT_RESOLUTION_CHANGED, NULL);
lv_display_set_rotation(disp, (lv_display_rotation_t)TFT_ROTATION);
lv_display_set_flush_cb(disp, my_disp_flush);
// 画像メモリをヒープ領域から動的に割り当てる
draw_buf = (uint8_t*)heap_caps_malloc(DRAW_BUF_SIZE, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
lv_display_set_buffers(disp, draw_buf, NULL, DRAW_BUF_SIZE, LV_DISPLAY_RENDER_MODE_PARTIAL);
/*Initialize the input device driver*/
lv_indev_t *indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); /*Touchpad should have POINTER type*/
lv_indev_set_read_cb(indev, my_touchpad_read);
#if defined(LV_DEMOS_H)
lv_demo_widgets();
//lv_demo_music();
#elif defined(LV_EXAMPLES_H)
lv_example_arc_1();
//lv_example_checkbox_1();
#endif
Serial.println("Setup done");
}
void loop() {
lv_timer_handler(); /* let the GUI do its work */
}
画像メモリの動的割り当てについて
Technical Reference Manual Version 5.3(2025.01版)1.3.2.6 DMA によれば、
DMA uses the same addressing as the CPU data bus to read and write Internal SRAM 1 and Internal SRAM 2. This means DMA uses an address range of 0x3FFE_0000 ~ 0x3FFF_FFFF to read and write Internal SRAM 1 and 0x3FFA_E000 ~ 0x3FFD_FFFF to read and write Internal SRAM 2.
In the ESP32, 13 peripherals are equipped with DMA.
DMA は、CPU データ バスと同じアドレス指定を使用し、内部 SRAM 1 と内部 SRAM 2 の読み書きを 行います。つまり DMA は内部 SRAM 1 に 0x3FFE_0000 ~ 0x3FFF_FFFF のアドレス範囲を使用し、 内部 SRAM 2 に 0x3FFA_E000 ~ 0x3FFD_FFFF を使用します。
ESP32 には 13 個の周辺機器が DMA を搭載しています。(訳:Google 翻訳)
とあり、SRAM1 または SRAM2 と SPI 間で DMA が効くように書かれています。

heap_caps_malloc()
でヒープ領域(SRAM1)に割り当てられるメモリも、BSS セグメント(SRAM2)に配置される配列 draw_buf[…]
も、どちらも DMA が効くアドレスの範囲内です(ESP32 のメモリマップ 参照)。
ただし後者はあまり大きなメモリを確保できないという違いがあり、画像メモリを最適化する際の制約事項になるため、これを嫌って動的割り当てとしています。
画像メモリのサイズとフレームレート
さて本章では、フレームレートの向上に繋がる画像メモリサイズの適切な条件を見出すために行った実験の結果を示します。
画像メモリのサイズは以下の様に定義されています。
/*LVGL draw into this buffer, 1/10 screen size usually works well. The size is in bytes*/
#define DRAW_BUF_SIZE (TFT_HOR_RES * TFT_VER_RES / 10 * (LV_COLOR_DEPTH / 8))
画像のフォーマットが RGB565 の場合、LV_COLOR_DEPTH
は 16
となり、マジックナンバー 10
により、画像メモリのサイズは1フレームの 1/10 になります。これは 先に示したビデオ
における最大10個の各矩形領域のサイズに該当します。
上記コード中のコメントにある通り、この設定で「大抵の場合はうまく機能します」が、10
という数字は、Issue #3643 の次のような議論が元になっていると考えられます(分かり易い一文を切り出しましたが、かなり複雑なアルゴリズムを議論してます 😳)。
Let say 4 out the 10 areas were working with the GPU and remaining 6 is CPU only or mixed where the GPU can help with some parts.
たとえば、10 個の領域のうち 4 個は GPU を使用して動作し、残りの 6 個は CPU のみ、または GPU が一部の部分で役立つ混合領域であるとします。(訳:Google 翻訳)
ここでの CPU 処理は、ソフトウェアによるレンダリングを意味しています。つまり 10
は、分割された領域の幾つかはハードウェアで並列処理されることを想定した数字というワケです。
一方 ESP32 では2つのコアを使っても並列に処理できる領域は高々2個です。そこで分割数を 5
や 2
に変えたところ、若干フレームレートが上がりました。SPI の転送速度自体は変わらないので、分割数が減った分、LVGL を含めたやり取りの オーバーヘッドが少なくなった 結果と推測できます。
lv_demo_music()
(括弧内は画像メモリのサイズおよび1フレーム分の平均転送時間)10分割 (15KB), 8FPS (17.4ms) 5分割 (30KB), 9FPS (16.6ms) 2分割 (75KB), 10FPS (16.1ms)
ちなみにこの「分割レンダリングモード」は、以下の様に lv_display_set_buffers()
に LV_DISPLAY_RENDER_MODE_PARTIAL
を指定することで動作します。
lv_display_set_buffers(disp, draw_buf, NULL, DRAW_BUF_SIZE, LV_DISPLAY_RENDER_MODE_PARTIAL);
分割しない「全画面レンダリングモード」もありますが、残念ながら CYD ではフレームメモリとして全画面分は確保できません。
実験結果から言えること
以上の実験結果から、「画像サイズの最適化」については、次のことが言えると考えています。
- マジックナンバー
10
は、1フレームにおけるレンダリング領域の分割数を表す - 分割数は、ハードウェアで並列化できるレンダリング領域の個数を想定している
- 分割数が小さくするとオーバーヘッドも小さくなるが、画像メモリが大きくなる
- ∴ アプリ全体でメモリ容量の許す限り分割数を小さくすれば応答性を上げられる
次回は…
LVGL のドキュメントには、「DMA を活かしたダブルバッファ化が有効」と書かれています。
LovyanGFX の 概要 Overview. にも「DMA 転送を用いた通信動作中の別処理実行」とあり、「パネルへの出力」中も LVGL にレンダリングを継続させることでスループットの向上が期待できるというワケです。
ただ実際に実験をしてみると、今のままダブルバッファ化しても殆ど効果はみられず、本格的にマルチコアを活用する必要がありそうです。
ということで、次回は「画像メモリのダブルバッファ化とマルチコア/マルチタスク化による応答性の向上」についてレポートしたいと思います 🌸