Arduinoをコマンドラインで制御する

Arduino

Arduinoのをシリアル通信でコマンドラインで制御するスケッチを作ります。

具体的な例として、「echo」と「Lチカ」をPCからシリアル通信を介して行うことを目指します。

「echo」はechoの後に続く文字をシリアル出力するコマンドとして、「Lチカ」はコマンドラインでピン番号と状態を指定してdigitalWriteを実行できるコマンドとして実装します。

ArduinoがPCから受け取ったシリアル通信を制御機能(以降、Commandと呼びます)で解釈して、用意しておいたコマンドを呼び出します。

図にするとこのようになります。


シリアル通信でコマンドラインの制御ができると以下のようなことができるようになります。

  • 回路を変更しても、スケッチを変更せずにLチカができる。
  • 好きなタイミングでdigitalWhite()ができる。回路のデバグにつかえます。

また、digitalWhite()以外のコマンドを実装することで、様々なデバグができます。Lチカを試した次のステップとして、コマンドラインの環境を整えておくのがおすすめです。

スケッチはGitに置いておきます。

programming-note-tech/Arduino-blog at v0.1.0 (github.com)

必要なもの

ArduinoArduino Uno R3で開発しましたが、機種は別のものでも問題ないはずです。
シリアルケーブルArduino付属のものでOK
PCWindows10で開発しましたが、Arduino IDEがインストールできていれば他のOSでも問題ないはずです。
LEDと抵抗あればでOK。スケッチを変更せずに回路を組み替えて遊べます

前提知識

  • Arduino IDE をインストールしていること
  • フローチャート図が読めること
  • C言語のコードが書けること

やり方

一度にプログラムを完成させた時にうまく動かなかった場合、原因を特定するのが難しくなってしまいます。そこで、5個のステップに分けて、問題が生じないことを確認しながら段階的に実装していきます。

ステップやること目的
1LチカArduinoIDEとArduinoが正しく動くことを確認する
2シリアル出力シリアル出力ができていることを確認する
3echoを実装シリアル入力ができていることを確認する
4Commandを実装シリアル入力をコマンドとして解釈できるようにする
5ファイル分割スケッチから機能ごとにファイルを分割して整理する

Step1 Lチカ

第1ステップとして「Lチカ」を行います。「Lチカ」はArduinoの基盤のLEDを点滅させることです。

このステップを踏むことで、Arduino IDEで作成したスケッチが正しくArduinoに書き込みされていること、また、Arduinoの基盤のLEDが壊れていないことを確認できます。

LチカのスケッチはArduino IDEにサンプルとして用意されています。[ファイル]→[スケッチ例]→[01.Basics]→[Blink]と選択してスケッチを開いてください。

COMの番号がArduinoをつないである番号であること、ボードタイプが接続しているArduinoのタイプになっていることを確認して、[⇒マイコンボードに書き込む]を実行します。

成功すると基板に組み込まれているLEDが点滅します。

Arduino IDEからArduinoにスケッチを転送して、Arduinoを制御できていることが確認できました。

うまく動かない場合はシリアルケーブルの接続、ポート番号、ボードタイプを確認してみます。それでも動かない場合はArduinoの故障の可能性があります。

Step2 シリアル出力

コード:programming-note-tech/Arduino-blog at v0.0.2 (github.com)

第2ステップとしてシリアル出力を行います。シリアル出力では、起動時にArduinoから「Hello World」と出力させて、Arduino IDEのコンソールで確認します。

このステップを踏むことで、ArduinoとPCの物理的な接続が行えていること、また、通信速度などのプロトコルが一致していることを確認できます。

Step1ではサンプルスケッチを実行しましたが、このステップではスケッチを新規に作成していきます。

適当な作業フォルダに「arduino」というフォルダを作り、その中に「arduino.ino」というファイルを作ります。

Arduino IDEがインストールされていれば、作ったファイルをダブルクリックしてArduino IDEで開くことができると思います。まっさらなスケッチが開かれるので、以下のコードを記入して「マイコンボードに書き込む」を行います。

#define BIT_RATE 9600

void setup()
{
  Serial.begin(BIT_RATE);
  Serial.println("Hello World");
}

void loop()
{
  
}

シリアル出力を行うスケッチなので、一見するとArduinoは動いていないように見えます。シリアル出力を確認するにはArduino IDEで[ツール]→[シリアルモニタ]を選択します。

シリアルモニタを開くと自動的にArduinoがリブートされて「Hello World」が出力されます。先ほど開いたArduino IDEのシリアルモニタを確認してみましょう。

Arduinoからのシリアル出力をPCのシリアルモニタで表示することができました。

うまく表示されない場合は、ビットレートが9600bpsになっていることを確認してください。

Step3 echoを実装

第3ステップとしてechoを実装します。echoは一般的に文字列を表示するコマンドですが、ここでは、シリアル入力をそのままシリアル出力するプログラムとして実装します。

入力を文字列に保存して、改行コードが来たら保存した文字列をシリアル出力します。図にするとこんな感じです。プログラミングらしくなってきましたね。

実装

新たに実装したコードのみを記載します。動かすことができる全体のコードはGithubを確認してください。

programming-note-tech/Arduino-blog at v0.0.3 (github.com)

宗教上の理由から、私はライブラリを極力使わずにC言語で実装しますが、無宗教の方はライブラリを使うとすっきりと書くことができると思います。

初期化

グローバル変数として、文字列のバッファと、文字列の末尾を示すポインタを定義します。末尾のポインタは文字列の先頭のアドレスで初期化しておきます。

#define READ_BUFFER_SIZE 64

static char ReadBuffer[READ_BUFFER_SIZE];
static char* pReadBufferEnd;

void setup()
{
  pReadBufferEnd = &ReadBuffer[0];

  Serial.begin(BIT_RATE);
}

READ_BUFFER_SIZEはArduinoのRAMに確保する文字列のサイズです。64byteと定義しましたので、終端文字を除いて63文字までシリアル入力された文字をArduino内に保管できます。

ループ

loop()にフローチャートの処理内容を実装するとこんな感じになります。

#define IS_TERMINATION(c) ((c) == '\r' || (c) == '\n' || (c) == '\0')


void loop()
{
  if(Serial.available() <= 0)
  {
    return;
  }

  if(pReadBufferEnd == &ReadBuffer[READ_BUFFER_SIZE])
  {
    ReadBuffer[READ_BUFFER_SIZE - 1] = '\0';
  }
  else
  {
    *pReadBufferEnd = (char)Serial.read();
  }

  if(IS_TERMINATION(*pReadBufferEnd))
  {
    *pReadBufferEnd = '\0';
    Serial.println(ReadBuffer);
    pReadBufferEnd = &ReadBuffer[0];

    return;
  }

  pReadBufferEnd++;
}

実機確認

適当な文字列「abc」をシリアルモニタから送信してみます。

入力した文字列がそのまま「abc」がシリアルモニタに表示されました。

問題なく動いていそうですね。PCからのシリアル入力をArduino内で処理できていることが確認できました。

Step4 Commandを実装

第4ステップとしてCommandを実装します。Commandはシリアル入力した文字列をコマンドに変換して実行するプログラムとして実装します。

まずは、Step3で実装したechoをコマンドとして実装しなおします。RAMに保存した文字列を単語ごとに分解して、最初の単語をコマンドテーブルに用意しておいた単語と付き合わせます。

Step3のフローチャートと比較すると、「文字列をシリアル出力する」という処理が「文字列を単語ごとに分割する」「コマンドテーブルと突き合わせて実行する」という2つの処理に置き換わっています。

シリアル通信の入出力のイメージはこんな感じです。

入力例

echo abc def    ghi

出力例

abc def ghi

シリアル入力した最初の単語”echo”をキーワードとしてコマンドを呼び出して、シリアル入力したた単語がシリアル出力されています。

実装

動作可能なスケッチは以下を確認してください

programming-note-tech/Arduino-blog at v0.0.4 (github.com)

初期化

コマンドテーブルを定義します。

コマンドテーブルのそれぞれのコマンドは以下の3つのメンバを定義しておきます

  1. キーとなる単語
  2. コマンドが呼び出す関数
  3. 使い方

コマンドが呼び出す関数はコマンドライン引数に倣って、以下の2つを引数とします。

  1. 入力した単語の個数(argc)
  2. 単語の先頭のポインタの配列(argv)
#define ARGC_MAX 8

static unsigned short cmd_echo(unsigned short argc, char** argv);

typedef struct tCommand
{
  const char* key;                                                // キーとなる単語
  unsigned short (*func)(unsigned short argc, char** argv);       // 呼び出す関数
  const char* usage;                                              // 使い方
} SCommand;

static const SCommand COMMAND_TABLE[] =                           // コマンドテーブル
{
  {"echo", cmd_echo, "echo <prm>            :display prm"},       // echoコマンド
};
コマンド

echoをコマンド実装しなおすとこのようになります。

#ifdef SUCCESS
#undef SUCCESS
#endif
#define SUCCESS 0
#ifdef ERROR
#undef ERROR
#endif
#define ERROR   1

static unsigned short cmd_echo(unsigned short argc, char** argv)
{
  unsigned short i;

  if(argc == 0)
  {
    return ERROR;
  }

  Serial.print(argv[0]);

  for(i = 1; i < argc; i++)
  {
    Serial.print(" ");
    Serial.print(argv[i]);
  }
  Serial.println();

  return SUCCESS;
}

echoコマンドは、コマンドライン引数で受け取った全ての単語をSerial.print()関数に渡してシリアル出力しています。

また、簡単なエラーハンドリングとして、SUCCESSERRORを定義しました。コマンドを呼び出す際に使います。

文字列を単語ごとに分割する

文字列を単語に分解する関数を実装します。

入力した文字列から単語の個数(argc)と単語の先頭のポインタの配列(argv)を取得したいです。

文字列に対し、文字列の先頭から以下の4つの操作を行うことで実現できます。

  1. スペースを終端文字に上書きする
  2. ひとつ前の文字が終端文字である文字のアドレスを単語の先頭のアドレスとして、ポインタの配列に保存する。
  3. 単語の先頭のアドレスを保存したら、単語の個数をインクリメントする。
  4. 改行を終端文字に置き換え、処理を終了する

引数lineに入力する文字列を入力すると、引数argvを上書きしてargcを返却する関数 static unsigned short get_arg(char* line, char** argv)を実装するとこのようになります。

#define IS_TERMINATION(c) ((c) == '\r' || (c) == '\n' || (c) == '\0')
#define IS_SPACE(c)       ((c) == ' ')

static unsigned short get_arg(char* line, char** argv);

static unsigned short get_arg(char* line, char** argv)
{
  unsigned short i;
  unsigned short argc = 0;

  // 1文字目
  if(!IS_SPACE(line[0]) && !IS_TERMINATION(line[0]))
  {
    // 「スペースでも終端文字でもない」ので「単語の先頭の文字」
    argv[argc] = &line[0];
    argc++;
  }

  // 2文字目以降
  for(i = 1; i < READ_BUFFER_SIZE; i++)
  {
    if(IS_TERMINATION(line[i]))
    {
      // 4.改行を終端文字に置き換え、処理を終了する
      line[i] = '\0';
      return argc;
    }

    if(IS_SPACE(line[i]))
    {
      // 1.スペースを終端文字に上書きする
      line[i] = '\0';
      continue;
    }

    if(IS_TERMINATION(line[i - 1]))
    {
      // 2.「スペースでも終端文字でもない かつ ひとつ前の文字が終端文字」ということは「単語の先頭の文字」
      argv[argc] = &line[i];
      // 3.インクリメント
      argc++; 

      if(argc == ARGC_MAX)
      {
        return ARGC_MAX - 1;
      }
    }
  }

  Serial.println("[error]line without end");
  return 0;
}
コマンドテーブルと突き合わせて実行する

文字列の一番最初の単語とコマンドテーブルを突き合わせて、一致するコマンドがあれば対応する関数を呼び出します。

コマンドライン引数を受け取ってコマンドを実行する関数static void execute_command(unsigned short argc, char** argv)を実装するとこのようになります。

#define IS_ERROR(code)    ((code) != SUCCESS)

static void execute_command(unsigned short argc, char** argv);

static void execute_command(unsigned short argc, char** argv)
{
  unsigned short i;
  unsigned short result;

  if (argc == 0)
	{
		return;
	}

  for(i = 0; i < sizeof(COMMAND_TABLE) / sizeof(SCommand); i++)
  {
    if(!strcmp(COMMAND_TABLE[i].key, argv[0]))
    {
      result = COMMAND_TABLE[i].func(argc - 1, &argv[1]);
      if(IS_ERROR(result))
      {
        Serial.println(COMMAND_TABLE[i].usage);
      }
      return;
    }
  }

  for(i = 0; i < sizeof(COMMAND_TABLE) / sizeo
    Serial.println(COMMAND_TABLE[i].usage);
  }
}

一致するコマンドがない場合はすべてのコマンドの使い方を表示するように工夫しておきました。

ループ

新たに実装した二つの関数を使ってloop()を修正するとこのようになります。

static unsigned short ArgC;
static char* ArgV[ARGC_MAX];

void loop()
{
~~ 中略 ~~

  if(IS_TERMINATION(*pReadBufferEnd))
  {
    Serial.println();
    
    *pReadBufferEnd = '\0';

    ArgC = get_arg(&ReadBuffer[0], ArgV);
    execute_command(ArgC, ArgV);
    
    pReadBufferEnd = &ReadBuffer[0];

    Serial.print("> ");

    return;
  }

~~ 中略 ~~

}

おまけで、コマンドプロンプトっぽく「> 」も出力するようにしています。

コマンドを追加

独自にコマンドを追加する場合は、以下の流れで追加できます。

  1. コマンドが呼び出す関数のプロトタイプを宣言する
  2. コマンドが呼び出す関数の本体を定義する
  3. コマンドテーブルにコマンドを追加する

digitalWriteコマンドのコードをそれぞれ適切な場所に追加してみてください。

// プロトタイプ宣言
static unsigned short cmd_dw(unsigned short argc, char** argv);

// コマンドテーブル
static const SCommand COMMAND_TABLE[] =
{
  {"echo", cmd_echo, "echo <prm>            :display prm"},
  {"dw",   cmd_dw,   "dw <pin> <value(0/1)> :digitalWrite(pin,value)"},
};

// 関数本体
static unsigned short cmd_dw(unsigned short argc, char** argv)
{
  unsigned short pin_num;
  unsigned short value;

  if(argc < 2)
  {
    return ERROR;
  }

  pin_num = atoi(argv[0]);
  value = atoi(argv[1]);

  pinMode(pin_num, OUTPUT);
  digitalWrite(pin_num, value);

  return SUCCESS;
}

digitalWriteコマンドは、コマンドライン引数で受け取った2つの単語をそれぞれ10進数に変換してdigitalWrite()の2つの引数として渡しています。

実機確認

LEDを光らせてみます。12ピンにLEDを接続します。

programming-note-tech/Arduino-blog at v0.0.4 (github.com)

上記のスケッチをArduinoに書き込み、シリアルモニタを開いて動作を確認します。

入力期待動作結果
abc全てのコマンドの使い方が出力される
echo abc def ghi「abc def ghi」が出力される
dw 12 112ピンがHIGHになり、LEDが点灯するLEDが点灯する
dw 12 012ピンがLOWになり、LEDが消灯するLEDが消灯する

PCからのシリアル通信でLチカを制御することが出来ました。

Step5 ファイル分割

やりたい制御はStep4で完成していますが、最後のステップとして、Commandのコードを別のファイルに分けます。

機能ごとにファイルを分けることで、可読性やメンテナンス性が上がります。また、別のスケッチでコードを使いまわすのも容易になります。

ファイルを分割するためには、arduino.inoファイルが入っているフォルダにcommand.hファイルとcommand.cppファイルを作ります。その後、arduino.inoをダブルクリックしてArduino IDEで開くと上部のタブにcommand.hとcomannd.cppが増えていると思います。タブを選択してコードを書き込みます。

実装

動作するスケッチはこちらを確認してください
programming-note-tech/Arduino-blog at v0.0.5 (github.com)

どのようにコードを切り分けるか悩みますが、Command機能にvoid CommandInit(void)void CommandSerialRead(void)という2つの関数を作ろうと思います。

setup()ではCommandInit()を呼ぶだけ、loop()ではCommandSerialRead()を呼ぶだけにします。

void CommandInit(void);
void CommandSerialRead(void);
#include <Arduino.h>

#include "command.h"

#ifdef SUCCESS
#undef SUCCESS
#endif
#define SUCCESS 0
#ifdef ERROR
#undef ERROR
#endif
#define ERROR   1

#define READ_BUFFER_SIZE 64
#define ARGC_MAX 8

#define IS_TERMINATION(c) ((c) == '\r' || (c) == '\n' || (c) == '\0')
#define IS_SPACE(c)       ((c) ==' ')
#define IS_ERROR(code)    ((code) != SUCCESS)

static char ReadBuffer[READ_BUFFER_SIZE];
static char* pReadBufferEnd;
static unsigned short ArgC;
static char* ArgV[ARGC_MAX];

static void execute_command(unsigned short argc, char** argv);
static unsigned short cmd_echo(unsigned short argc, char** argv);
static unsigned short cmd_dw(unsigned short argc, char** argv);

static unsigned short get_arg(char* line, char** argv);

typedef struct tCommand
{
  const char* key;                                                // キーとなる単語
  unsigned short (*func)(unsigned short argc, char** argv);       // 呼び出す関数
  const char* usage;                                              // 使い方
} SCommand;

static const SCommand COMMAND_TABLE[] =                           // コマンドテーブル
{
  {"echo", cmd_echo, "echo <prm>            :display prm"},
  {"dw",   cmd_dw,   "dw <pin> <value(0/1)> :digitalWrite(pin,value)"},
};

static unsigned short cmd_echo(unsigned short argc, char** argv)
{
  unsigned short i;

  if(argc == 0)
  {
    return ERROR;
  }

  Serial.print(argv[0]);

  for(i = 1; i < argc; i++)
  {
    Serial.print(" ");
    Serial.print(argv[i]);
  }
  Serial.println();

  return SUCCESS;
}

static unsigned short cmd_dw(unsigned short argc, char** argv)
{
  unsigned short pin_num;
  unsigned short value;

  if(argc < 2)
  {
    return ERROR;
  }

  pin_num = atoi(argv[0]);
  value = atoi(argv[1]);

  pinMode(pin_num, OUTPUT);
  digitalWrite(pin_num, value);

  return SUCCESS;
}

static unsigned short get_arg(char* line, char** argv)
{
  unsigned short i;
  unsigned short argc = 0;

  // 1文字目
  if(!IS_SPACE(line[0]) && !IS_TERMINATION(line[0]))
  {
    argv[argc] = &line[0];
    argc++;
  }

  // 2文字目以降
  for(i = 1; i < READ_BUFFER_SIZE; i++)
  {
    if(IS_TERMINATION(line[i]))
    {
      line[i] = '\0';
      return argc;
    }

    if(IS_SPACE(line[i]))
    {
      line[i] = '\0';
      continue;
    }

    if(IS_TERMINATION(line[i - 1]))
    {
      argv[argc] = &line[i];
      argc++; 

      if(argc == ARGC_MAX)
      {
        return ARGC_MAX - 1;
      }
    }
  }

  Serial.println("[error]line without end");
  return 0;
}

static void execute_command(unsigned short argc, char** argv)
{
  unsigned short i;
  unsigned short result;

  if (argc == 0)
	{
		return;
	}

  for(i = 0; i < sizeof(COMMAND_TABLE) / sizeof(SCommand); i++)
  {
    if(!strcmp(COMMAND_TABLE[i].key, argv[0]))
    {
      result = COMMAND_TABLE[i].func(argc - 1, &argv[1]);
      if(IS_ERROR(result))
      {
        Serial.println(COMMAND_TABLE[i].usage);
      }
      return;
    }
  }

  for(i = 0; i < sizeof(COMMAND_TABLE) / sizeof(SCommand); i++)
  {
    Serial.println(COMMAND_TABLE[i].usage);
  }
}

void CommandInit(void)
{
  pReadBufferEnd = &ReadBuffer[0];
}

void CommandSerialRead(void)
{
  
 if(Serial.available() <= 0)
  {
    return;
  }

  if(pReadBufferEnd == &ReadBuffer[READ_BUFFER_SIZE])
  {
    ReadBuffer[READ_BUFFER_SIZE - 1] = '\0';
  }
  else
  {
    *pReadBufferEnd = (char)Serial.read();
  }

  if(IS_TERMINATION(*pReadBufferEnd))
  {
    Serial.println();
    
    *pReadBufferEnd = '\0';

    ArgC = get_arg(&ReadBuffer[0], ArgV);
    execute_command(ArgC, ArgV);
    
    pReadBufferEnd = &ReadBuffer[0];

    Serial.print("> ");

    return;
  }

  pReadBufferEnd++;
}

ファイル内でしか使わない変数や関数は念のため、staticをつけておいたほうが良いです。一方、ほかのファイルで使いたい関数はstaticをつけてはいけません。

#include "command.h"

#define BIT_RATE 9600

void setup()
{
  CommandInit();
  
  Serial.begin(BIT_RATE);
  Serial.println("Hello World");
  
  Serial.print("> ");
}

void loop()
{
  CommandSerialRead();
}

loop()がすっきりしてわかりやすくなったと思います。

新しく作成したヘッダファイルは同じフォルダ内にあるので、ダブルクォーテーション「”」でインクルードする必要があることに注意してください。

[☑検証]をクリックして、コンパイルが通れば成功です。

まとめ

最終的なスケッチはここを見てください。

programming-note-tech/Arduino-blog at v0.1.0 (github.com)

この記事で紹介したコード以外にもいくつかコマンドを加えてあります

ArduinoをPCのシリアル通信を用いてコマンドラインで制御する方法について解説しました。他にもコマンドを実装することでArduinoのデバグを手軽に行うことができます。

コメント

タイトルとURLをコピーしました