この記事は「#NervesJP Advent Calendar 2019」19日目の記事です。
昨日は@inachiさんの
「Elixir超初心者が Nerves で心拍数測定アプリを作ってみる」でした。
はじめに
Elixirって型ゆるいしあんまり好きじゃないんだよね、っていろんな所でボヤいてたのに、結局Elixir書いてんじゃん、ツンデレかよ。と思われたり思われなかったりするs-hosoaiと申します。よろしくお願いします。
組込みも好きなのでAtomVMを試してみようと思います。M5Stackで。
昨年の@takaseせんせーの「ElixirでIoT#3.1:ESP32やSTM32でElixirが動く!AtomVMという選択肢」の焼き直しってのも芸がないので、ちょっと別ルートで行ってみましょう。
M5Stackの環境構築
開発環境の構築とFirmwareのビルド
組込みのクロス環境の構築って面倒ですよね。でもDockerなら楽々です。
ちなみに筆者の環境は、Windows10でElixirはWSL上に入れており、Docker for Windowsはwin/wsl両方から使えるようにしています。
とりあえず、AtomVMをgit cloneしましょう。
cloneしたフォルダでターミナルを開きます。
$ cd docker
$ docker build -t atomvm_dev .
# atomvm_devはイメージ名です。適当に換えてください。
# イメージのビルドに少々時間が掛かります。途中赤字が流れたりして怖いですが、、信じて待ちます。
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
atomvm_dev latest e1d64ab541e6 7 minutes ago 1.66GB
# ぼちぼちでかいですが・・OS+クロス環境ならこんなもんですかね。
# では、早速入ってみましょう。
$ docker run -it atomvm_dev
root@xxx~# ls /tools/esp/xtensa-esp32-elf/bin/
# (以降、docker上のプロンプトは \# とします。)
# ツールチェーンもちゃんと入ってますね。
# 開発はホストで、ビルドはコンテナで。ということで、一度出て、ソースをマウントしちゃいましょう。
\# exit
$ docker run -it -v [ホストのフルパス]/AtomVM:/home/AtomVM atomvm_dev
\# cd /home/AtomVM
\# ls
AtomVM.doxy CMakeModules LICENSE README.Md build docker libs tests
CMakeLists.txt CONTRIBUTING.md README.ESP32.Md README.STM32.Md doc examples src tools
# ビルドしてみましょう。
\# cd /home/AtomVM/src/platforms/esp32/
\# make
\# ls build
...
atomvvm-esp32.bin
atomvvm-esp32.elf
atomvvm-esp32.map
...
バイナリができてますね、これを書き込めば動くはず。
Win環境でDocker上のUSBを認識させるのはちょっと面倒なので、ホスト側でesptoolを使って焼きます。MacやLinux環境ならDockerにUSBを認識させ、そのまま焼いちゃう方が早いでしょう。
toolsフォルダにesptoolを置いています。AtomVMフォルダでターミナルを開き、以下コマンドで書き込みます。portはM5Stackが認識されているポートを指定してください。
$ python tools\esptool-2.8\esptool.py --chip esp32 --port COM4 --baud 115200 --before default_reset --after hard_reset write_flash -u --flash_mode dio --flash_freq 40m --flash_size detect 0x10000 src\platforms\esp32\build\atomvvm-esp32.bin
Lチカ(バックライトチカ)
とりあえずLチカしときますか。M5StackにはLEDが付いてないので、LCDのバックライトを点滅させてみます。マニュアルによるとLCDのバックライト(BL)はGPIO32ですね。
https://docs.m5stack.com/#/en/core/gray?id=peripherals-pin-map
サンプルのBlink.exのピン指定している箇所を定数化して、GPIO32に変更します。
defmodule Blink do
@lcd_bl_pin 32
def start() do
gpio = do_open_port("gpio", [])
set_direction(gpio, @lcd_bl_pin, :output)
loop(gpio, 0)
end
defp loop(gpio, 0) do
set_level(gpio, @lcd_bl_pin, 0)
sleep(200)
loop(gpio, 1)
end
defp loop(gpio, 1) do
set_level(gpio, @lcd_bl_pin, 1)
sleep(200)
loop(gpio, 0)
end
...
コンパイルしてPackbeamして焼きます。この時ElixirはOTP20か21のもので行ってください。OTP22は未対応です。
(以下、さらっと書いてますが、実は環境の都合でビルドはWinで、PackbeamはWSL、書き込みはWinとややっこしい事になってます。)
$ cd examples/elixir/esp32/Blink.ex
$ elixirc Blink.ex
$ ../../../build/tools/packbeam/PackBEAM Elixir.Blink.avm Elixir.Blink.beam
python ../../../tools\esptool-2.8\esptool.py --chip esp32 --port COM4 --baud 115200 --before default_reset --after hard_reset write_flash -u --flash_mode dio --flash_freq 40m --flash_size detect 0x110000 Elixir.Blink.avm
光りますね、眩しい。
LCDデバドラ
M5stackのLCDライブラリはArduino向けに書かれたもので、AtomVMのFirmwareにポンと載せられる感じではなさそうです。
AtomVMにはSPIライブラリがあるので、これを使えばElixirだけでドライバ書けるのでは・・?と思っていたのですが、レジスタ持ちのSPIデバイスにアドレスでアクセスするAPIしか用意されていないようです。
となると、AtomVMに手を入れることになりそうです。
Elixir(Erlang)とCの連携
GPIOは何をしているのか
Blink.exでGPIOにアクセスしてる部分だけ読み解いてみましょう。
gpio = Port.open({:spawn, "gpio"}, [])
send(gpio, {self(), :set_direction, 32, :output})
send(gpio, {self(), :set_level, 32, 1})
"gpio"という名前で、Portのプロセスを立ち上げ、そこに:set_direction
や:set_level
といったメッセージを投げているようです。
ちょっとCのコードを追ってみましょう。
Context *sys_create_port(GlobalContext *glb, const char *driver_name, term opts)
{
Context *new_ctx = context_new(glb);
if (!strcmp(driver_name, "socket")) {
socket_init(new_ctx, opts);
} else if (!strcmp(driver_name, "network")) {
network_init(new_ctx, opts);
} else if (!strcmp(driver_name, "gpio")) { // <-- ここに来る
gpiodriver_init(new_ctx);
...
void gpiodriver_init(Context *ctx)
{
if (LIKELY(!global_gpio_ctx)) {
global_gpio_ctx = ctx;
ctx->native_handler = consume_gpio_mailbox;
ctx->platform_data = NULL;
...
Cの実装の方では、ドライバ毎にプロセスを作っているようですね。シングルトン的に1ドライバ1プロセスにしているっぽいです。またmailboxのハンドラをctxに登録していますね。
static void consume_gpio_mailbox(Context *ctx)
{
Message *message = mailbox_dequeue(ctx);
...
switch (cmd) {
case SET_LEVEL_ATOM:
ret = gpiodriver_set_level(msg);
break;
case SET_DIRECTION_ATOM:
ret = gpiodriver_set_direction(msg);
break;
...
先程作ったgpio
に対して、:set_direction
, :set_level
と引数を投げていましたが、ここに飛んでくるようです。メールボックス経由で受け取って、atomに応じた関数を呼び出しています。
static term gpiodriver_set_level(term msg)
{
int32_t gpio_num = term_to_int32(term_get_tuple_element(msg, 2));
int32_t level = term_to_int32(term_get_tuple_element(msg, 3));
gpio_set_level(gpio_num, level != 0); // <-- ESP-IDFのAPI
TRACE("gpio: set_level: %i %i\n", gpio_num, level != 0);
return OK_ATOM;
}
さらに各関数では、msgをバラしてESP-IDFの関数を呼んでいます。やっとデバドラまで繋がりました。ちなみに、atomと定数の変換(:set_level
-> SET_LEVEL_ATOM
など)はplatform_defaultatoms.c,h
で行っているようです。
バックライト用のC実装
いきなりLCDのデバドラ書くのは怖いので、お試しに先程のバックライトの制御をCに持って行ってみましょう。
以下、gpiodriver.c/h
をがばっとコピーして作っていきます。include類は不要なものも含まれてそうですが、一旦全部入りとしています。
#ifndef _LCDDRIVER_H_
#define _LCDDRIVER_H_
#include "context.h"
void lcddriver_init(Context *ctx);
#endif
#include "gpio_driver.h"
#include <string.h>
#include <driver/gpio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include "atom.h"
#include "bif.h"
#include "context.h"
#include "debug.h"
#include "defaultatoms.h"
#include "platform_defaultatoms.h"
#include "globalcontext.h"
#include "mailbox.h"
#include "module.h"
#include "utils.h"
#include "term.h"
#include "trace.h"
#include "sys.h"
#include "esp32_sys.h"
static Context *global_lcd_ctx = NULL;
static void consume_lcd_mailbox(Context *ctx);
void lcddriver_init(Context *ctx)
{
if (LIKELY(!global_lcd_ctx))
{
global_lcd_ctx = ctx;
ctx->native_handler = consume_lcd_mailbox;
ctx->platform_data = NULL;
}
else
{
fprintf(stderr, "Only a single LCD driver can be opened.\n");
abort();
}
gpio_set_direction(32, GPIO_MODE_OUTPUT);
}
static term lcddriver_on(term msg)
{
gpio_set_level(32, 1);
return OK_ATOM;
}
static term lcddriver_off(term msg)
{
gpio_set_level(32, 0);
return OK_ATOM;
}
static void consume_lcd_mailbox(Context *ctx)
{
Message *message = mailbox_dequeue(ctx);
term msg = message->message;
term pid = term_get_tuple_element(msg, 0);
term cmd = term_get_tuple_element(msg, 1);
int local_process_id = term_to_local_process_id(pid);
Context *target = globalcontext_get_process(ctx->global, local_process_id);
term ret;
switch (cmd)
{
case LCD_ON_ATOM:
ret = lcddriver_on(msg);
break;
case LCD_OFF_ATOM:
ret = lcddriver_off(msg);
break;
default:
TRACE("lcd: unrecognized command\n");
ret = ERROR_ATOM;
}
free(message);
mailbox_send(target, ret);
}
Context *sys_create_port(GlobalContext *glb, const char *driver_name, term opts)
{
Context *new_ctx = context_new(glb);
if (!strcmp(driver_name, "socket")) {
socket_init(new_ctx, opts);
} else if (!strcmp(driver_name, "network")) {
network_init(new_ctx, opts);
} else if (!strcmp(driver_name, "gpio")) {
gpiodriver_init(new_ctx);
} else if (!strcmp(driver_name, "spi")) {
spidriver_init(new_ctx, opts);
} else if (!strcmp(driver_name, "i2c")) {
i2cdriver_init(new_ctx, opts);
} else if (!strcmp(driver_name, "lcd")) { // 追加
lcddriver_init(new_ctx); // 追加
// 94行目付近
#define I2C_CLOCK_HZ_ATOM_INDEX (I2CDRIVER_ATOMS_BASE_INDEX + 6)
#define LCDDRIVER_ATOMS_BASE_INDEX (I2C_CLOCK_HZ_ATOM_INDEX + 1) // 追加
#define LCD_ON_ATOM_INDEX (LCDDRIVER_ATOMS_BASE_INDEX + 0) // 追加
#define LCD_OFF_ATOM_INDEX (LCDDRIVER_ATOMS_BASE_INDEX + 1) // 追加
//179行目付近
#define LCD_ON_ATOM TERM_FROM_ATOM_INDEX(LCD_ON_ATOM_INDEX) // 追加
#define LCD_OFF_ATOM TERM_FROM_ATOM_INDEX(LCD_OFF_ATOM_INDEX) // 追加
static const char *const scl_io_num_atom = "\xA" "scl_io_num";
static const char *const sda_io_num_atom = "\xA" "sda_io_num";
static const char *const i2c_clock_hz_atom = "\xC" "i2c_clock_hz";
// 93行目付近
//lcddriver
static const char *const lcd_on_atom = "\x6" "lcd_on"; // 追加
static const char *const lcd_off_atom = "\x7" "lcd_off"; // 追加
atomの定数は、"\x文字数16進" "atom文字列"となります。
dockerでリビルドして、Firmwareを書き込んでおきます。
毎度Dockerに入るのも面倒なのでMake専用にDockerfileを書き換えておきます。
...
WORKDIR /home/AtomVM/src/platforms/esp32
CMD make
$ cd docker
$ docker build -t atomvm_dev .
$ docker run -it -v [ホストのフルパス]/AtomVM:/home/AtomVM atomvm_dev
$ cd ..
$ python tools\esptool-2.8\esptool.py --chip esp32 --port COM4 --baud 115200 --before default_reset --after hard_reset write_flash -u --flash_mode dio --flash_freq 40m --flash_size detect 0x10000 src\platforms\esp32\build\atomvvm-esp32.bin
Elixirから呼び出す
作ったCのコードをElixirから呼んでみましょう。先程と動作の違いが分からないので、sleepを1秒にしています。
defmodule LCD1 do
def start() do
lcd = Port.open({:spawn, "lcd"}, [])
loop(lcd, 0)
end
defp loop(lcd, 0) do
lcd_on(lcd)
sleep(1000)
loop(lcd, 1)
end
defp loop(lcd, 1) do
lcd_off(lcd)
sleep(1000)
loop(lcd, 0)
end
defp sleep(t) do
receive do
after
t -> :ok
end
end
defp lcd_on(lcd) do
send(lcd, {self(), :lcd_on})
receive do
ret ->
ret
end
end
defp lcd_off(lcd) do
send(lcd, {self(), :lcd_off})
receive do
ret ->
ret
end
end
end
無事動きました。
当初LCDのデバドラまで行こうと思ってたのですが、予想以上に落とし穴が多くて断念してしまいました。
そういえば、何故か私の環境だとDocker上でビルドすると、毎度フルビルドが走って鬱陶しいです。DockerfileのCOPYの際にタイムスタンプとか変わっちゃってるのかもしれません。いい方法をご存知の方は教えて下さい。
おわりに
Elixir/NervesのAdvent Calendarなのに殆どCになっちゃいましたね。もっとElixir書きたかったです。
Cが嫌でElixirに逃げようとしてるのに、より凶悪なCに襲われる感じがLightweight組込みのやみ 醍醐味ですね。
明日は、@32heroさんです。