AtomVMでM5Stack向けのデバドラを書いてみる

この記事は「[#NervesJP Advent Calendar 2019](https://qiita.com/advent-calendar/2019/elixir)」19日目の記事です。
昨日は@inachiさんの
「[Elixir超初心者が Nerves で心拍数測定アプリを作ってみる](https://qiita.com/inachi/items/ff006fd20246f0a0a358)」でした。

# はじめに
Elixirって型ゆるいしあんまり好きじゃないんだよね、っていろんな所でボヤいてたのに、結局Elixir書いてんじゃん、ツンデレかよ。と思われたり思われなかったりするs-hosoaiと申します。よろしくお願いします。

組込みも好きなのでAtomVMを試してみようと思います。M5Stackで。
昨年の@takaseせんせーの「[ElixirでIoT#3.1：ESP32やSTM32でElixirが動く！AtomVMという選択肢](https://qiita.com/takasehideki/items/847d3d8f9d255e01ce9c)」の焼き直しってのも芸がないので、ちょっと別ルートで行ってみましょう。

# M5Stackの環境構築
## 開発環境の構築とFirmwareのビルド
組込みのクロス環境の構築って面倒ですよね。でもDockerなら楽々です。
ちなみに筆者の環境は、Windows10でElixirはWSL上に入れており、Docker for Windowsはwin/wsl両方から使えるようにしています。

とりあえず、[AtomVM](https://github.com/bettio/AtomVM)をgit cloneしましょう。
cloneしたフォルダでターミナルを開きます。

```bash
$ 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](https://github.com/espressif/esptool)を使って焼きます。MacやLinux環境ならDockerにUSBを認識させ、そのまま焼いちゃう方が早いでしょう。

toolsフォルダにesptoolを置いています。AtomVMフォルダでターミナルを開き、以下コマンドで書き込みます。portはM5Stackが認識されているポートを指定してください。

```sh
$ 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に変更します。

```elixir:examples/elixir/esp32/Blink.ex
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とややっこしい事になってます。）

```sh
$ 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
```
![m5stack1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3472/31136c2f-df97-a073-85fc-3e728e7c63a7.gif)

光りますね、眩しい。

# LCDデバドラ
M5stackのLCDライブラリはArduino向けに書かれたもので、AtomVMのFirmwareにポンと載せられる感じではなさそうです。

AtomVMにはSPIライブラリがあるので、これを使えばElixirだけでドライバ書けるのでは・・？と思っていたのですが、レジスタ持ちのSPIデバイスにアドレスでアクセスするAPIしか用意されていないようです。
となると、AtomVMに手を入れることになりそうです。

# Elixir(Erlang)とCの連携

## GPIOは何をしているのか
Blink.exでGPIOにアクセスしてる部分だけ読み解いてみましょう。

```elixir:Blink.ex
    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のコードを追ってみましょう。

```c:src/platforms/esp32/main/sys.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);
...
```
```c:src/platforms/esp32/main/gpiodriver.c(51行目付近)
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に登録していますね。

```c:src/platforms/esp32/main/gpiodriver.c(189行目付近)
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に応じた関数を呼び出しています。

```c:src/platforms/esp32/main/gpiodriver.c(82行目付近)
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類は不要なものも含まれてそうですが、一旦全部入りとしています。

```c:src/platforms/esp32/main/lcddriver.h
#ifndef _LCDDRIVER_H_
#define _LCDDRIVER_H_
#include "context.h"
void lcddriver_init(Context *ctx);
#endif
```
```c:src/platforms/esp32/main/lcddriver.c
#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);
}
```
```c:src/platforms/esp32/main/sys.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);
    } 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);              // 追加
```


```c:src/platforms/esp32/main/platform_defaultatoms.h
// 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)    // 追加
```
```c:src/platforms/esp32/main/platform_defaultatoms.c
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を書き換えておきます。

```docker/Dockerfile
...
WORKDIR /home/AtomVM/src/platforms/esp32
CMD make
```
```sh
$ 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秒にしています。

```elixir:LCD.ex
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組込みの<strike>やみ</strike> 醍醐味ですね。

明日は、@32heroさんです。