LoginSignup
4

More than 3 years have passed since last update.

posted at

updated at

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

この記事は「#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さんです。

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
What you can do with signing up
4