19
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-18

この記事は「#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に変更します。

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とややっこしい事になってます。)

$ 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

光りますね、眩しい。

LCDデバドラ

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

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

Elixir(Erlang)とCの連携

GPIOは何をしているのか

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

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のコードを追ってみましょう。

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);
...
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に登録していますね。

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に応じた関数を呼び出しています。

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類は不要なものも含まれてそうですが、一旦全部入りとしています。

src/platforms/esp32/main/lcddriver.h
#ifndef _LCDDRIVER_H_
#define _LCDDRIVER_H_
#include "context.h"
void lcddriver_init(Context *ctx);
#endif
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);
}
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);              // 追加
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)    // 追加
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を書き換えておきます。

...
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秒にしています。

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組込みのやみ 醍醐味ですね。

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

19
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
4