ESP32 で Lua + Sol2 を使う

M5Stack にて Lua を使う機会があり、ESP32 の環境で Lua を動かす方法についてまとめます。また、Lua の C/C++ バインドである Sol2 を使う方法についても触れます。


## 前提条件

- VSCode / PlatformIO 使用
  - ビルドとアップロードを確認した環境
    - Windows 10
    -  macOS Sequoia 15.3.2 
- Lua [5.4.7](https://github.com/lua/lua/tree/v5.4.7)
- Sol2 [3.3.0](https://github.com/ThePhD/sol2/tree/v3.3.0)
- C++17 以降（Sol2を使う場合; 後述）

利用した ESP32 のハードウェアは [M5Stack Basic](https://shop.m5stack.com/products/basic-core-iot-development-kit?variant=16804801937498) (SoC: ESP32-D0WDQ6-V3) です。かなり昔の M5Stack ですが、他のハードウェアでも問題ないはずです。


## 準備: 環境構築

簡便のために、PlatformIO のプロジェクトを使ったコードを用意しました。

https://github.com/nanase/esp32-lua-example

ビルド時に以下のディレクトリ構造になることを想定しています。`*` がついたディレクトリとファイルはビルドまでに自動でダウンロードされます。

```
- include*
  - sol*
    - config.hpp*
    - forward.hpp*
    - sol.hpp*
- lib*
  - lua* (Luaのリポジトリ または サブモジュール)
- src
  - main.cpp
- patch.py
- platformio.ini
```


### リポジトリのクローン

上記リポジトリを `--recursive` オプションつきでクローンしてください。

```sh
$ git clone --recursive git@github.com:nanase/esp32-lua-example.git

# --recursive をつけずにクローンしてしまった場合
$ git clone git@github.com:nanase/esp32-lua-example.git
$ git submodule update --init --recursive
```


### Sol2 の準備

Sol2 についてはリポジトリのクローン **ではなく**、**[Releases](https://github.com/ThePhD/sol2/releases/tag/v3.3.0) に添付されている** 3つのヘッダファイルが必要です。

今回は後述する patch.py が自動でダウンロードを行うため、PlatformIO を利用する場合は手動での取得は不要です。

### patch.py

ESP32 は `long long`（64bit整数型）をサポートしていません。lua/luaconf.h 内には以下のマクロが存在しますが、

```cpp:lua/luaconf.h
/*
@@ LUA_32BITS enables Lua with 32-bit integers and 32-bit floats.
*/
#define LUA_32BITS      0
```

このマクロを `1` へ変更せずにビルドを行うと次のエラーが発生します。

```
"Compiler does not support 'long long'. Use option '-DLUA_32BITS'  or '-DLUA_C89_NUMBERS' (see file 'luaconf.h' for details)
```

Lua 5.4.7 現在、[lua/luaconf.h](https://github.com/lua/lua/blob/v5.4.7/luaconf.h#L122-L125) ではこのマクロが必ず `0` に上書きされるような実装がされています。そのため、ビルドオプションに `-DLUA_32BITS` を指定しても効果がありません。単純に lua/luaconf.h を直接編集すれば解決しますが、今回はコンパイル時に自動でマクロを書き換えるスクリプト patch.py を用意して対応しました。

さらに、今回はスタンドアロン版のLuaを使わないため lua/onelua.c と lua/lua.c をビルド対象外にするための処理を含んでいます。また、前述の Sol2 に必要なヘッダファイルのダウンロードも行います。

patch.py の実装は記事の主題から外れるため、以下にリンクだけを示しておきます。

https://github.com/nanase/esp32-lua-example/blob/main/patch.py


### platformio.ini

M5Stack Basic用の構成になっています。最低限必要なもののみ載せました。ここでのポイントは `extra_scripts`, `build_flags`, `build_unflags` です。

`extra_scripts` で patch.py をビルド開始時に呼び出して前述した lua/luaconf.h の書き換えなどを行います。

`build_flags`, `build_unflags` は Sol2 のために C++17 でビルドを行うための指定です。Sol2 は C++17 以降でしかビルドできないため、このフラグ指定がないとやはりビルドに失敗します。もちろん、Sol2 を使わず純粋な Lua 連携を行う場合は指定不要です。

```ini
[env:m5stack-core-esp32]
platform = espressif32
board = m5stack-core-esp32
framework = arduino
extra_scripts = pre:patch.py
build_flags =
	-std=gnu++17
build_unflags =
	-std=gnu++11
lib_deps =
	m5stack/M5Stack@^0.4.6
```

### PlatformIO の環境

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30370/5b674a41-b19e-49d1-9227-98a08d5b5d95.png" width="540">

サンプルコードに対応した環境を用意しています。ビルド・アップロードの際は `env:example` から始まる環境をひとつ選択してください。Default を選択するとすべての環境についてビルドされます。


## 実装

### Lua を直接使う場合

LuaからのC++関数の呼び出しと、C++からのLua関数の呼び出しを行う簡単なプログラムです。lua.h をインクルードする前に `lua_writestring`, `lua_writestringerror`, `l_system` の3つの関数を定義する必要があります。

このプログラムは PlatformIO の環境に `env:sample2-lua` を選択してください。

```c++:src/2-lua.cpp
#include <Arduino.h>
#include <M5Stack.h>
#undef min

extern "C" {
int lua_writestring(char* s) {
  printf(s);
  return 0;
}

int lua_writestringerror(char* s) {
  printf(s);
  return 0;
}

int l_system(const char* cmd) {
  return 0;
}

#include "lua.h"
#include "lualib.h"
#include "lauxlib.h"
}

int l_add(lua_State* L) {
  int a = lua_tointeger(L, 1);
  int b = lua_tointeger(L, 2);
  lua_pushinteger(L, a + b);
  return 1;
}

int l_print(lua_State* L) {
  const char* s = lua_tostring(L, 1);
  Serial.println(s);
  return 0;
}

void runLuaScript(lua_State* L, const char* script) {
  if (luaL_dostring(L, script) != LUA_OK) {
    fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1));
    lua_pop(L, 1);
  }
}

void setup() {
  M5.begin();
  M5.Power.begin();
  M5.Speaker.begin();
  M5.Speaker.mute();
  Serial.begin(115200);

  lua_State* L = luaL_newstate();
  lua_register(L, "add", l_add);
  lua_register(L, "print", l_print);

  runLuaScript(L, R"(
    function mul(a, b)
      return a * b
    end
    print("Hello Lua!")
  )");

  lua_getglobal(L, "mul");
  lua_pushinteger(L, 4);
  lua_pushinteger(L, 5);

  if (lua_pcall(L, 2, 1, 0) == LUA_OK && lua_isinteger(L, -1)) {
    printf("ret=%d\n", lua_tointeger(L, -1));
  } else {
    fprintf(stderr, "cannot exec mul. %s\n", lua_tostring(L, -1));
  }

  lua_pop(L, 1);
  lua_close(L);
}

void loop() { }
```

```:実行結果
Hello Lua!
ret=20
```

### Sol2 を使う場合

Sol2を使いつつ同じ実行結果になるプログラムです。こちらはスタック操作が消え、かなり短いコードになっています。エラー処理は省略しています。

このプログラムは PlatformIO の環境に `env:sample4-sol2` を選択してください。

```c++:src/4-sol2.cpp
#include <Arduino.h>
#include <M5Stack.h>
#undef min

#include "sol/sol.hpp"

void setup() {
  M5.begin();
  M5.Power.begin();
  M5.Speaker.begin();
  M5.Speaker.mute();
  Serial.begin(115200);

  sol::state lua;

  lua["add"]   = [](int a, int b) { return a + b; };
  lua["print"] = [](const char* s) { Serial.println(s); };

  lua.script(R"(
     function mul(a, b)
       return a * b
     end
     print("Hello Lua!")
  )");

  int ret = lua["mul"](4, 5);
  printf("ret=%d\n", ret);
}

void loop() { }
```


## バイナリサイズの変化

コードのシンプルさを鑑みると Sol2 のメリットが大きいのですが、ビルド結果の firmware.elf のサイズは増大します。ESP32 はフラッシュサイズが少なくとも数 MB 以上を持つ SoC が多いため、Lua 単体では大きな問題にはならないでしょう。しかし将来的に WiFi などを併用したいと考えると、バイナリサイズには余裕を持たせておきたいものです。

そこで今回は以下の条件でバイナリサイズを比較しました。番号は PlatformIO の環境とソースコードの番号に対応しています。

1. M5Stackの初期化だけを行う場合（Luaは使わない）
2. Luaだけを直接使う場合
3. LuaとLuaの標準ライブラリを使う場合
4. Sol2を使う場合
5. Sol2とLuaの標準ライブラリを使う場合

3\. と 5. にある Lua の標準ライブラリは以下のようにして読み込ませました。Sol2側も同じ標準ライブラリを選択しています。

```c++:Luaを直接使う場合
lua_State* L = luaL_newstate();
luaL_openlibs(L);
```

```c++:Sol2を使う場合
sol::state lua;
lua.open_libraries(sol::lib::base, sol::lib::package, sol::lib::coroutine, sol::lib::table,
                   sol::lib::io, sol::lib::os, sol::lib::string, sol::lib::math,
                   sol::lib::utf8, sol::lib::debug);
```

結果は以下のようになりました。

|条件|Flashサイズ|1. からの増分|標準ライブラリの増分|
|:----:|---:|---:|---:|
| 1. | 385,641 バイト<br> (376.6 KiB) | n/a | n/a |
| 2. | 454,625 バイト<br> (444.0 KiB) | +  68,984 バイト<br>  (67.4 KiB) | n/a |
| 3. | 521,013 バイト<br> (508.8 KiB) | + 135,372 バイト<br> (132.2 KiB) | + 66,388 バイト<br> (64.8 KiB) |
| 4. | 677,997 バイト<br> (662.1 KiB) | + 292,356 バイト<br> (285.5 KiB) | n/a |
| 5. | 734,785 バイト<br> (717.6 KiB) | + 349,144 バイト<br> (341.0 KiB) | + 56,788 バイト<br> (55.5 KiB)

ビルドのホスト環境により数十バイトの変動はあるものの、概ね上記のようになりました。Lua 単体で 67.4 KiB の容量増に対し、Sol2 を使うとさらに 218.1 KiB の容量増となります。

Sol2 + Lua 標準ライブラリの構成では 717.6 KiB と、他の機能についても本格的に実装を始めると 1 MiB を超えるでしょう。

ESP32 の場合は [フラッシュのパーティションを調整できます](https://qiita.com/yomori/items/743bc84c961bb921d8cb)。すなわち [platformio.ini の `board_build.partitions`](https://github.com/nanase/esp32-lua-example/blob/3dcc965ba31ae9472349f45f99616f40544ffffc/platformio.ini#L18) を指定すればプログラムに割り当てられる容量を変更できます。これによりプログラム実装後でもある程度の対応は可能かと思います。


## 参考文献（先駆者）

https://qiita.com/inajob/items/f714ac346fbb190a2574

Lua 5.3.4 を使った方法について解説されています。サンプルコードはここで例示されているものがベースになっています。コードの解説なども大変参考になりました。

https://qiita.com/gpsnmeajp/items/4ab0bc17513364b96ec6

Lua 5.4.1 を使っています。スタンドアロン版Luaが不要な場合において参考にしました。

https://qiita.com/voidProc/items/152a70bae7e41cf934cd

Lua 5.2.4 と Sol2 の連携方法について解説されています。基本的な Sol2 の使い方も紹介されており、参考にしました。