9
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

ESP32 と QuickJS で小さなJavaScript実行環境を作る

社員ではない上に仕事とも関係ない内容ですが 第二のドワンゴ Advent Calendar 2020 用に書きました.

はじめに

IoT機器に組み込める JavaScript 実行環境が欲しくなったので,ESP32 を使った開発で QuickJS を使ってみました.

ESP32のモジュールは小さい&簡単に使えそうな M5Atom を使います.24 x 24 x 10 mm に収まります.
誰でも入手可能でネットにつながる機器としては,世界最小&最安の JavaScript 実行環境ではないでしょうか(※当社調べ) .

ニッチな内容なので読み飛ばしたい人のために,3行で書くと:

  • ESP32 に QuickJS を載せて JavaScript を実行できるように
  • Python や lua を組み込む感覚で JavaScript を使えると便利
  • 使用メモリ削減のためのあれこれ

JavaScript が動くと嬉しいこと

普段はC++で開発していますが,ネットワーク越しにJSON APIを叩くような処理を書くのが少し面倒なのと,ファームウェアのビルド&アップロードの待ち時間を無くしたかったのが発端です.

また,ESP32 は JTAG でのデバッグをサポートしていますが,モジュールの外に必要なピンが出てなかったり他の用途に使ってしまっていたりして,Serial.printlnや特定のピンに信号を出すことでデバッグするしか無いことも多いです.そしてログを仕込むためにビルドし直したりする必要も出てきます...

そんなとき,JavaScript に限らずアプリケーションに何らかのスクリプトエンジンを組み込んでおくと,手軽に挙動を書き換えたり,REPL的な口を用意しておけばデバッグにも便利です.REPLにブラウザからアクセスできるようにしておけば,どこからでも自宅で動いている機器のデバッグができます.

ESP32

みんな大好き Espressif Systems 製のマイクロコントローラです.

ESP32 を使ったモジュールはたくさん種類が存在しますが,今回は M5Atom を使いました.
コネクタやケース込みで 24 x 24 x 10 mm .Bluetooth と WiFi が使えて技適マークも付いているので安心です.
この記事では動作時の見栄えの都合で Matrix LED 付きのバージョンを使っています.こちらは厚みが4mm増えますがそれでも小さいです.

esp32.jpg
左から, ESP-WROOM-32の開発ボード(NodeMCU), M5Atom Lite, M5Atom Matrix.USBケーブルと比べるとおおよその大きさがわかると思います.

2020/12現在,スイッチサイエンスや千石電商で購入可能です.(Amazonには ATOM Matrix しか無い?ようです)

QuickJS

QuickJS はCで実装されたコンパクトなJavaScriptエンジンです.コンパクトとはいっても ES2020 に対応しています.

Chrome や Node.js 等で使われているV8が色々削っても10MB近いサイズになってしまうのに比べ,QuickJS は1MB以下に余裕で収まります.
そして M5Atom には 4MB のフラッシュメモリと 520KB の SRAM が載っているので動きそうな予感がします.

QuickJS をPC上で試しに動かしてみるときは下記の記事が参考になりそうです.

開発環境

手軽に様々なプラットフォームの開発環境を利用できる PlatformIO を使います.
Arduino IDEにESP32用のボードをインストールしても良いですが,Arduino IDEに強い愛着がある場合を除いて,PlatformIO + VS Code用の拡張がお薦めです.

ESP32 で QuickJS を動かす

QuickJS は標準的なC言語で実装されていて標準ライブラリ以外に依存しているものは無いので,PC以外の環境で動かすのも難しい箇所はほぼありません.(もちろん,ファイルシステムもスレッドも標準入出力も無いので quickjs-libc.c 周りは使えなかったり,環境ごとにマクロで切り替えてる箇所を数カ所変更する等は必要です)

platform.io 用のライブラリ

QuickJS を簡単に組み込めるように platform.io 用のライブラリにしました.

あまり色々入れてもサイズが大きくなるので最小限のArduinoの関数のラッパーとHTTPアクセスやタイマー周りだけ入れてあります.用途に合わせて ESP32QuickJS を継承したり JSContext* を public にしてあるので直接操作して色々追加する感じで.

hello_world (Hello, world)

platformio.ini
[env:m5atom]
platform = espressif32
board = m5stick-c
framework = arduino
lib_deps = 
    https://github.com/binzume/esp32quickjs.git#v0.0.1
monitor_speed = 115200

GitHub上にあるので platformio.ini の lib_depshttps://github.com/binzume/esp32quickjs.git を追加すれば使えます.
boardm5atom が登録されてなさそうだったので構成が似ているm5stick-cにしています.

src/hello_world.cc
#include <Arduino.h>
#include "esp32/QuickJS.h"

static const char *jscode = R"CODE(
  console.log("Hello, JavaScript!");
)CODE";

ESP32QuickJS qjs;

void setup() {
  Serial.begin(115200);
  qjs.begin();
  qjs.exec(jscode);
}

void loop() {
  qjs.loop(); // For timer, async, etc.
}

pioコマンドでビルド&アップロードします.(もしくはVSCode上でpioのアップロードボタンをクリック)

pio run -t upload

起動するとシリアルポートに Hello, JavaScript! と出力します.この例ではloop()は不要ですが,ここでasyncな処理やtimer周りの処理があれば実行されます.

サンプルなのでコード内にスクリプトを埋め込んでますが,実際にはSPIFFSなどを使ってフラッシュメモリ上にファイルを置くか,ネットワーク経由でロードするのが扱いやすいと思います.今回も,ブラウザ上で気軽にIoT機器のファームウェア編集したり動作確認したかったのが本来の目的なので実際には別途アップロードしています.

atom_matrix (M5Atom Matrix用デモ)

matrix_hello.gif

マトリクスLEDを光らせるデモ.ESP32QuickJS を継承した M5QuickJS にマトリクスLEDとボタンにアクセスする関数を追加してあります.

JavaScript 関数の例:

  • console.log(string)
  • setTimeout(callback, ms)
  • setInterval(callback, ms)

Arduino

  • esp32.millis() : int
  • esp32.digitalRead(pin) : int
  • esp32.digitalWrite(pin, value) // value=0: LOW, 1: HIGH
  • esp32.pinMode(pin, mode) // mode=1: INPUT, 2: OUTPUT
  • esp32.setLoop(func) // func is called every arduino loop().

ESP32/WiFi

  • esp32.deepSleep(us) // not returns
  • esp32.isWifiConnected() : bool
  • esp32.fetch(url, {method:string, body:string}): Promise<{body:string, status:int}>

細かくモジュールに分けようかとも考えましたが,メモリ使用量をケチって一つにまとめています.(GLOBAL_ESP32 マクロを define しておくと esp32 もモジュールではなく初期化時に global に追加するようになるので数百B程度メモリ使用量を節約できます)
fetchはResponseじゃなくてbodyを含んだオブジェクトを返したり,リクエストの送信が同期処理になっててプロっキングしたりするのも手抜きです.

メモリ周り

520KB という広大なメモリ空間(実際には他にもメモリを使うので現実的には200~300KB)を利用可能とはいっても,連続稼働させる機器で使う場合には動作中にメモリリークしていないか不安だったり,将来的にメモリ不足に悩まされないためにもSRAMの使用量を削減する手段を用意しておきたいです.

QuickJS のメモリ管理

QuickJSはリファレンスカウンタと mark-and-sweep によるGCのハイブリッドな仕組みでメモリを管理を行っています(ちょっと面白い実装).そのためオブジェクトの循環参照が存在する場合を除き,参照カウンタが0になった時点でヒープ上のメモリが開放されます.循環参照は避けられない場合もありますが,多くのケースでGCに頼る必要が無いので利用可能なメモリが少ない環境でも安心して実行できます.

オブジェクトの生成・破棄の度に内部では malloc() / free() が呼ばれるので小さいオブジェクトを大量に作るプログラムは速度面でもメモリ使用量でもオーバーヘッドが大き目です.速度が重要であればなるべく再利用したり,大きな配列は TypedArray に詰め込む等が必要になります.

あとGCが実行されるタイミングですが,QuickJSはメモリ使用量が前回のGC直後から1.5倍になるたびにGCをトリガします.注意点は,このタイミングの判定は JS_SetMemoryLimit() で設定するメモリ上限とは独立していることです.メモリ残量が少ないからといって頻繁にGCしてくれたりすることはなく,上限までメモリを使い切ると例外が投げられます.GCのタイミングはJSRuntimeが持っているmalloc_gc_thresholdが使われるのでこちらを制御する必要があります.

Flash上に中間コードを置く

組み込み機器ではSRAMに比べてフラッシュメモリの容量に余裕がある場合が多々あります.ESP32にも4MBのFlashが載っていてSRAMと同じアドレス空間にマップできるので,immutableなデータであればメモリ上にあるのと同じようにアクセスできます(フラッシュメモリはその性質上,ブロック単位でしか書き換えができないのでメモリとしてはROMとして扱われます).

ただ,JavaScript のソースコードをFlash上に配置したとしても,動的に生成される構文木や中間コードなどはSRAM上で構築されるのでSRAMに収まるサイズのプログラムしか実行できませんし,実行中はSRAMを専有し続けます.

この問題は,JS_WriteObject で関数のバイトコードも含めてシリアライズしたデータをフラッシュメモリに書き込んでおくことでかなり改善します.書き込んだ先をmmapした上で JS_ReadObject で読み込めば書き換えが不要なデータはSRAMにコピーすることなくJavaScriptの関数を取得できます(JS_READ_OBJ_ROM_DATAフラグを指定する必要があります).

とはいえ,JavaScriptの多くのオブジェクトはmutableなので,色々工夫してもプログラムのサイズに比例してSRAMの使用量も増加しがちです.また,SPIFFSを含め,巷で利用されているファイルシステムの多くがファイルをメモリにマッピングできない設計になってるので,手を加えるか,自分で実装する必要があります.

メモリリークの可能性があるとき

メモリリークが疑われるときは,JS_ComputeMemoryUsage() で現在のメモリの割当状態がわかるのでこれを見てみるのが良さそうです.またESP32のsdkに含まれるESP.getFreeHeap()からもヒープの空きが取得できます.上にも書きましたが,GCはメモリの確保量によってトリガされるため,メモリを消費する処理がないといつまでも未使用オブジェクトは回収されません.その場合はGCを明示的に呼んだ直後に確認するのが良いです.

ネイティブコード側のメモリリークで一番疑わしいのは,JS_FreeValue()の呼び忘れです.基本的にはJS_Get*/JS_Set*という名前の関数はメモリの所有権が移動して,受け取った側で使い終わったらリファレンスカウントを減らす思想のようですが,例外もあるのでうっかり開放し忘れがちです.関数名よりは型が JSValueJSValueConst かを参考にしたほうが良いですが,#define JSValueConst JSValue とされてるだけなので,C++で書いていてもコンパイル時に型でチェックできないのは少し不便です.QuickJS自体小さくてコードの見通しも悪くないので実装を確認してから使うのが確実です.

どうせ1秒もかからず再起動できるので,メモリが不足しそうになったらESP32をリセットする処理を入れておくという割り切った考え方もありかもしれませんが...

パフォーマンス

ちゃんとしたベンチマークはとってないので雰囲気だけです.

JITは無いのでネイティブコード並みにとはいけませんが,インタプリタとしては遅くない印象です.ただし,オブジェクトの生成のたびにmallocが呼ばれるので細かいオブジェクトを大量に作るようなプログラムは極端に遅くなることがあります.GCも単純な mark and sweep なので,ほぼ生存しているオブジェクト数に比例した時間がかかります(100ms近く止まることも多々あります).タイミングが重要な場合は,処理の間はGCが動かないようにしておくかネイティブコードで実装して呼び出したほうが良さそうです.

とりあえず,多くの人によって活発に開発されているソフトウェアの一つである,「Lチカ」の速度を確認しました.

import * as esp32 from "esp32";
console.log("start");

const ledPin = 25;
esp32.pinMode(ledPin, 2); // 2: OUTPUT
setInterval(function () {
  let v = esp32.digitalRead(ledPin);
  esp32.digitalWrite(ledPin, v ^ 1);
}, 0);

実行結果:
blink.jpg
60us/loop...なんだか予想より遅い気が.

タイマー周りは手抜き実装なのでそのせいかと思ったのですが,esp32.digitalRead 等のプロパティの検索も予想以上に遅いようなので,以下のように変更してみます.

import {pinMode, setLoop, digitalRead, digitalWrite} from "esp32";
console.log("start");

const ledPin = 25;
pinMode(ledPin, 2); // 2: OUTPUT
setLoop(function() {
  let v = digitalRead(ledPin);
  digitalWrite(ledPin, v ^ 1);
});

これで,10us/loop くらいになりました.

esp32.setLoop(callback) は Arduino の loop() 相当の関数を登録し,他に処理が無い間呼び出し続けます.意味的には setInterval(callback, 0) と同等ですが,より効率的です.

digitalWrite みたいな本質的には1命令で済む処理にus単位の時間がかかるのは残念な気持ちになりますが,これ以上の速度を求める場合はネイティブコードで実装したほうが無難そうです.

ESP32の開発に使えそうな他のスクリプト言語

Low.js というのも見つけましたが,要求メモリ多すぎて素のESP32では動かない&真面目に使うと有料.

おわりに

自宅で動いている自作ガジェット&Prometheus+Grafanaでの監視の話を書こうと思ったのですが,書いててあまり面白くなかったのでこんな内容に.

QuickJS は今まで lua や Python を使ってたような場面で簡単に JavaScript の実行環境を組み込めるのが良いです.もう少し広く使われていてもよさそうですが,この記事含め,触ってみた系の記事はちらほら見かけますが,本格的に使うための情報はまだあまり多くない印象です.

明日は fusagiko さんです.

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
9
Help us understand the problem. What are the problem?