環境設定
WSL2
今回利用するESP-IDF/AtomVMの環境ではLinuxが必要なためWSL2を用いる.
usb / usbipd
WSL2でマイコンに接続するためusbipd(https://github.com/dorssel/usbipd-win)を利用.
GUIツール(https://github.com/nickbeth/wsl-usb-manager)が便利.
デフォルトではUSBシリアルデバイスがルート権限になっており,都度chmodが必要でめんどい.
ESP32シリーズの主要なUSBシリアルチップをざっくり登録してしまう.
またユーザをdialoutグループに追加しておく.
sudo usermod -aG dialout $USER
sudo nano /etc/udev/rules.d/99-esp32.rules
# CP210x (多くのESP32開発ボード)
SUBSYSTEMS=="usb", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", MODE="0666", GROUP="dialout"
# CH340 / CH341
SUBSYSTEMS=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", MODE="0666", GROUP="dialout"
SUBSYSTEMS=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55d4", MODE="0666", GROUP="dialout"
# FTDI FT232
SUBSYSTEMS=="usb", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", MODE="0666", GROUP="dialout"
SUBSYSTEMS=="usb", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6010", MODE="0666", GROUP="dialout"
# ttyUSB / ttyACM 全般(上記に漏れがあった場合の保険)
KERNEL=="ttyUSB[0-9]*", MODE="0666", GROUP="dialout"
設定を反映するためWSLを再起動しておく.PS等でwsl --shutdown
ESP-IDF
EspressifのESPIDFインストーラのeimを入れ,5.x系の最新版をインストール
echo "deb [trusted=yes] https://dl.espressif.com/dl/eim/apt/ stable main" | sudo tee /etc/apt/sources.list.d/espressif.list
sudo apt update
sudo apt install eim-cli
eim install -i v5.5.4
activateコマンドが長くて面倒なので,bashrc等に適当なaliasを作っておく.(以降getidfとする)
alias getidf='source /home/hosoai/.espressif/tools/activate_idf_v5.5.4.sh'
VSCode
WSL内から起動し,適当なプロファイルを作って,Espressifの拡張を入れておく.お好みのエージェントも入れておく.
Elixir / Erlang / Reber3
いつの間にかasdfではなくmiseというツールに変わったらしい
curl https://mise.run | sh
インストール後の指示に従いbash等に追加しとく.
順番が前後するが,以降のAtomVMをCloneしたフォルダにmise.tomlを作成して,インストールする.
なお,AtomVMの現時点(2026/06/06)でサポートされている最新環境を指定している.AtomVMのバージョンが上がっていたら適宜修正を.
[tools]
erlang = '27'
elixir = '1.17'
rebar = '3.27.0'
mise install
AtomVM
まずはソースをClone
git clone https://github.com/atomvm/AtomVM
Unix/Tool build
AtomVMのコマンド類やもろもろのベースとなるものをビルド,インストール
cd AtomVM
mkdir build
cd build
cmake ..
make -j 8
sudo make install
ESP32向けFirmwareのビルド
とりあえずデフォルトのままビルドしてみる
cd src/platform/esp32
getidf
(->envに入る)
idf.py set-target esp32s3
idf.py reconfigure
idf.py build
出来上がったイメージを書き込み,シリアルコンソールで接続する.
今回はAtomS3で試している.AtomS3を繋ぎ,usbipdで認識したポートをbind/attachしておく.
以下で書き込みし,シリアルで繋ぐ.書き込みできない場合はWSL内で/dev/ttyACM* or ttyUSB*があるか,権限が合っているか確認する.
idf.py flash
idf.py monitor
AtomVMのロゴが出てればとりあえずおk,メインのアプリは入ってないのでたぶんなんも動かん.
AtomVMのプラットフォームイメージのカスタマイズ
上まではメモをベースにパッと書いたものだが,以降はClaudeさんがGitのコミットログからいい感じに仕上げてくれたんで,そのまま使う.今回はAtomVM上でZenoh-picoを動作させることを目的としている.
お試しで作成した環境はこちら https://github.com/s-hosoai/AtomVM_Zenoh
AtomVM に外部ライブラリを統合する手順
以下の手順は Zenoh-pico (C) と M5Unified (C++) の2例から抽出した一般的なパターンです。
ステップ 0:前提環境の確認
# ツールバージョン管理 (mise を推奨)
# プロジェクトルートに mise.toml を置く
[tools]
erlang = '27'
elixir = '1.17'
rebar = '3.27.0'
gleam = '1.17.0' # Gleam を使う場合
mise install
# ESP-IDF (v5.4 以上推奨)
source ~/.espressif/tools/activate_idf_v5.x.x.sh
idf.py --version
ステップ 1: ライブラリを third_party/ またはサブモジュールとして追加
# git submodule として追加する場合
git submodule add https://github.com/<ライブラリ>/repo.git \
src/platforms/esp32/third_party/<libname>
git submodule update --init --recursive
.gitmodules に自動追記されます。
ライブラリが ESP-IDF 公式コンポーネント(idf_component.yml で管理)の場合は、
コンポーネント側のidf_component.ymlに依存を書くだけでサブモジュール不要です(M5Unified の例)。
ステップ 2: ESP-IDF コンポーネントディレクトリを作成
src/platforms/esp32/components/<コンポーネント名>/
├── CMakeLists.txt ← 必須
├── Kconfig ← 有効化フラグ(任意だが推奨)
├── <nif_impl>.c ← NIF 実装
└── [追加ファイル]
CMakeLists.txt の基本形(純 C ライブラリの場合)
# COMPONENT_DIR は ESP-IDF が自動設定するコンポーネントの絶対パス
# CMake の requirements フェーズでも正しく解決される唯一の変数
set(LIB_DIR "${COMPONENT_DIR}/../../third_party/<libname>")
# ライブラリのソースを列挙
file(GLOB_RECURSE LIB_SRCS "${LIB_DIR}/src/**/*.c")
idf_component_register(
SRCS "<nif_impl>.c" ${LIB_SRCS}
INCLUDE_DIRS "${LIB_DIR}/include"
PRIV_REQUIRES "libatomvm" "avm_sys" "esp_system" "driver"
WHOLE_ARCHIVE # IDF v5+ では NIF 登録に必要
)
target_compile_definitions(${COMPONENT_LIB} PRIVATE
SOME_BACKEND_FLAG=1
)
⚠️ CMake パス注意: requirements フェーズ中は
CMAKE_CURRENT_SOURCE_DIRと
CMAKE_SOURCE_DIRはビルドツリーのパスに解決されて誤動作します。
サードパーティライブラリのパス解決には必ずCOMPONENT_DIRを使ってください。
CMakeLists.txt の基本形(C++ ライブラリの場合)
idf_component_register(
SRCS
"nif_impl.cpp" # C++ ラッパー(AtomVM ヘッダを include しない)
"nif_reg.c" # NIF 登録(AtomVM ヘッダを include する)
PRIV_REQUIRES
"libatomvm"
"avm_sys"
"<cpp_library_component>"
WHOLE_ARCHIVE
)
target_compile_features(${COMPONENT_LIB} INTERFACE cxx_std_17)
⚠️ C++ NIF の分離パターン: AtomVM の
term.hは#ifndef __cplusplusガードがあり
C++ ファイルから直接 include できません。NIF 実装を.cpp(C++ ライブラリ呼び出し)と
.c(AtomVM ヘッダ + NIF テーブル登録)の2ファイルに分けてください。
Kconfig(有効化フラグ)
menu "AtomVM <LibName> Component"
config AVM_ENABLE_<LIBNAME>_NIFS
bool "Enable <LibName> NIFs"
default y
help
Enable NIFs for <LibName>.
endmenu
ステップ 3: NIF 実装を書く
基本テンプレート(C NIF)
#include <sdkconfig.h>
#ifdef CONFIG_AVM_ENABLE_<LIBNAME>_NIFS
#include "context.h"
#include "defaultatoms.h"
#include "erl_nif.h"
#include "erl_nif_priv.h"
#include "interop.h"
#include "memory.h"
#include "nifs.h"
#include "portnifloader.h"
#include "term.h"
// --- NIF リソース型の定義 ---
typedef struct {
SomeLibHandle handle;
bool is_open;
} MyLibResource;
static ErlNifResourceType *my_lib_resource_type;
// デストラクタ(GC 時に自動呼び出し)
static void my_lib_resource_dtor(ErlNifEnv *env, void *obj)
{
UNUSED(env);
MyLibResource *res = (MyLibResource *) obj;
if (res->is_open) {
some_lib_close(res->handle);
res->is_open = false;
}
}
static const ErlNifResourceTypeInit MyLibResourceTypeInit = {
.members = 1,
.dtor = my_lib_resource_dtor,
};
// --- NIF 関数 ---
static term nif_my_lib_open(Context *ctx, int argc, term argv[])
{
UNUSED(argc);
// 引数チェック
if (!term_is_binary(argv[0])) {
RAISE_ERROR(BADARG_ATOM);
}
// リソースを AtomVM ヒープに確保
MyLibResource *res = enif_alloc_resource(my_lib_resource_type, sizeof(MyLibResource));
if (!res) {
RAISE_ERROR(OUT_OF_MEMORY_ATOM);
}
// ライブラリ初期化
res->handle = some_lib_open(...);
res->is_open = true;
// {ok, Resource} を返す
term resource_term = enif_make_resource(ctx->global->erl_nif_env, res);
enif_release_resource(res);
term ok_tuple[2] = { OK_ATOM, resource_term };
return term_alloc_tuple(2, &ctx->heap);
// ※ 実際は memory.h の API でタプルを構築
}
// --- NIF テーブル登録 ---
static const struct Nif my_lib_open_nif = {
.base.type = NIFFunctionType,
.nif_ptr = nif_my_lib_open
};
static const struct Nif *my_lib_nif_get_nif(const char *nifname)
{
if (strcmp("my_lib:open/1", nifname) == 0) { return &my_lib_open_nif; }
// ... 他の NIF を追加
return NULL;
}
REGISTER_NIF_COLLECTION(my_lib, NULL, NULL, my_lib_nif_get_nif)
#endif // CONFIG_AVM_ENABLE_<LIBNAME>_NIFS
よく使う AtomVM API
| 用途 | API |
|---|---|
| バイナリ引数をチェック | term_is_binary(argv[N]) |
| 整数引数をチェック | term_is_integer(argv[N]) |
| バイナリを C 文字列に変換 |
interop_binary_to_string(term) ※ free() 必要 |
| 整数値を取得 | term_to_int(term) |
ok アトムを返す |
return OK_ATOM |
| エラーを発生させる | RAISE_ERROR(BADARG_ATOM) |
| リソース確保 | enif_alloc_resource(type, size) |
| リソース解放(参照カウント) | enif_release_resource(res) |
| リソース → term | enif_make_resource(env, res) |
ステップ 4: Elixir/Gleam ラッパーを追加
Elixir ラッパー (libs/exavmlib/lib/MyLib.ex)
defmodule MyLib do
@compile {:no_warn_undefined, [:my_lib]}
@doc "セッションを開く"
def open(config) when is_binary(config), do: :my_lib.open(config)
@doc "データを送信する"
def send(session, key, payload) when is_binary(key) and is_binary(payload) do
:my_lib.send(session, key, payload)
end
@doc "セッションを閉じる"
def close(session), do: :my_lib.close(session)
end
libs/exavmlib/lib/CMakeLists.txt に追記:
target_sources(exavmlib INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/MyLib.ex)
Gleam バインディング (libs/gleam_avm/src/gleam_avm/my_lib.gleam)
pub type Session
@external(erlang, "my_lib", "open")
pub fn open(config: String) -> Result(Session, Nil)
@external(erlang, "my_lib", "send")
pub fn send(session: Session, key: String, payload: BitArray) -> Result(Nil, Nil)
@external(erlang, "my_lib", "close")
pub fn close(session: Session) -> Nil
ステップ 5: ビルド設定の確認
sdkconfig / パーティション
フラッシュ容量・スタックサイズ・ヒープの調整が必要な場合:
# src/platforms/esp32/sdkconfig.defaults に追記
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y
CONFIG_SPIRAM_USE_MALLOC=y
# ... ライブラリ固有の設定
フラッシュレイアウト (partitions.csv):
| パーティション | オフセット | 用途 |
|---|---|---|
| factory | 0x10000 | AtomVM VM 本体 |
| boot.avm | 0x1D0000 | ブート AVM |
| main.avm | 0x250000 | ユーザーアプリ ← ここに書き込む |
ビルド & フラッシュ
cd src/platforms/esp32
getidf # ESP-IDF 環境を有効化
idf.py set-target esp32s3
idf.py reconfigure # コンポーネント変更後は必須
idf.py build
idf.py flash
# アプリのビルド(プロジェクトルートの build/ で)
make MyApp
# アプリのフラッシュ(アドレスは 0x250000 固定)
esptool.py --chip esp32s3 --port /dev/ttyACM0 \
write_flash 0x250000 build/examples/elixir/esp32/MyApp.avm
3. Zenoh 統合の補足事項
ハマりやすい落とし穴
raweth スタブ問題
zenoh-pico はRaw Ethernet機能を無効にしても、tx.c から _z_raweth_send_* シンボルを無条件参照します。ライブラリ側に #else スタブがないため、raweth_stubs.c でスタブを提供する必要があります。
// raweth_stubs.c
#include "zenoh-pico/transport/raweth/tx.h"
#include "zenoh-pico/utils/result.h"
#if Z_FEATURE_RAWETH_TRANSPORT != 1
z_result_t _z_raweth_send_t_msg(...) { return _Z_ERR_TRANSPORT_NOT_AVAILABLE; }
z_result_t _z_raweth_send_n_msg(...) { return _Z_ERR_TRANSPORT_NOT_AVAILABLE; }
#endif
AtomVM GC によるセッション解放問題
末尾再帰ループでセッション変数を引数として渡すとき、コンパイラがセッション参照を最適化で消去することがあります。GC がセッションをデストラクトし、次の通信でクラッシュします。
解決策: セッションをループの引数として明示的に渡し続ける。
# NG: session が GC される可能性がある
defp loop(pub, count) do
Zenoh.publisher_put(pub, "data")
loop(pub, count + 1) # session への参照がない
end
# OK: session も引数に含める
defp loop(session, pub, count) do
Zenoh.publisher_put(pub, "data")
loop(session, pub, count + 1) # session への参照を維持
end
スタック上の大きな構造体によるクラッシュ
AtomVM のスケジューラタスクはスタックサイズが限られています(約 4KB 程度)。ZenohMessage のような大きな構造体(keyexpr 256 + payload 4096 = ~4.4KB)をスタックに置くと MMU エントリフォルトでクラッシュします。
解決策: malloc() でヒープに確保し、free() で解放。
// NG: スタックに確保(クラッシュの原因)
ZenohMessage msg;
// OK: ヒープに確保
ZenohMessage *msg = malloc(sizeof(ZenohMessage));
if (!msg) { /* エラー処理 */ }
// ... 使用後 ...
free(msg);
UDP マルチキャスト/IPv6 の無効化
ESP-IDF の LwIP はデフォルトで UDP マルチキャストと IPv6 が制限されています。zenoh_generic_config.h でこれらを明示的に無効化します。
#define Z_FEATURE_RAWETH_TRANSPORT 0
#define Z_FEATURE_LINK_TCP 1
#define Z_FEATURE_LINK_BLUETOOTH 0
動作確認手順
# PC 側で Zenoh ルーターを起動
docker run --network host eclipse/zenoh
# ESP32 側でサブスクライバーを起動してモニタ
esptool.py ... write_flash 0x250000 ZenohSub.avm
idf.py monitor
# PC 側からパブリッシュして受信確認
# (z_pub や zenoh-cli などのツールを使用)
4. ディレクトリ構成まとめ
src/platforms/esp32/
├── components/
│ ├── avm_m5unified/ ← M5Unified NIF (C++ + C 分離パターン)
│ │ ├── CMakeLists.txt
│ │ ├── Kconfig
│ │ ├── idf_component.yml ← idf_component_manager 依存
│ │ ├── m5unified_nif.cpp ← C++ ラッパー(AtomVM ヘッダ不使用)
│ │ └── m5unified_reg.c ← NIF 登録(AtomVM ヘッダ使用)
│ └── avm_zenoh/ ← Zenoh NIF (純 C + submodule パターン)
│ ├── CMakeLists.txt
│ ├── Kconfig
│ ├── zenoh_nif.c
│ ├── raweth_stubs.c ← 未定義シンボルスタブ
│ └── zenoh_config/
│ └── zenoh_generic_config.h ← カスタム設定
└── third_party/
└── zenoh-pico/ ← git submodule
libs/
├── exavmlib/lib/
│ ├── M5.ex ← Elixir ラッパー
│ └── Zenoh.ex
└── gleam_avm/src/gleam_avm/
├── m5.gleam ← Gleam バインディング
└── zenoh.gleam
examples/elixir/esp32/
├── M5Blink.ex
├── ZenohPub.ex
└── ZenohSub.ex
なお,サンプルを利用する場合は,Zenoh RouterのIPアドレス,WiFiのSSID/Passwordを書き換えてからビルドして利用してください.ほとんどコード書いてないけど,いつの間にかAtomVM上からZenoh-picoを叩いてPub/Subすることができました.