Siv3Dを使ってシリアル通信

Siv3D Advent Calender 2021 8日目の記事です

昔ほど見なくなった気がするシリアル通信をSiv3Dで行います

シリアル通信とは

PCと他のデバイスとの間で行う通信のひとつです
実際にはPC以外のもの同士で通信することもあります

ここでのシリアル通信とは、一般的にRS-232C通信と呼ばれる通信を指します
PCと何らかの機器をシリアルポートで接続して通信するのですが、シリアルポートは最近のPCにはあまり付いていません

D-Sub9ピンポート

ですがシリアル通信が絶滅したのかというとそうではなくて、物理的なシリアルポートを使わずにUSBの通信をシリアルポートでの通信に見立てるICが出回っており、そのICを媒介してシリアル通信を行う機器が生き残っています

Arduino(アルデュイーノ)と呼ばれる、電子回路やマイコンのプログラミングの勉強などに便利なデバイスがあり、比較的馴染み深く有名でしょう

また、分析測定を行う機器についてもシリアル通信を行う機能を持った機器は多く存在します
例えば科学実験などに使用する電子天秤は単体で使用できますが、PCと繋いでPC側から測定や風袋引きの命令を送ることもできるようになっているものがあります
複雑で大量なデータをやり取りするような機器では純粋なUSBと専用のドライバが使われる場合も多いですが、電子天秤のように簡単なもので、なおかつ通信が必須ではない場面においてはシリアル通信が比較的採用されていると思われます
いわゆる実験室で過ごされている方はこれを利用することで測定などの効率化が図れるかもしれません

ただし、機器の内部設定値を書き換えるコマンドを搭載しているような機器もあるかもしれないので、説明書はよく読んで取り掛かるほうが良いでしょう

通信する

では通信を行っていきましょう
本例では通信先のデバイスとして「Writer509CDC」を使います
これはPICと呼ばれるマイコン(IC)にプログラムを書き込むための機器で10年くらい前に重宝された電子工作物です

Writer509CDC

ピンポイントで「このWriter509CDCこそ自分が通信させたかった機器だ」と思っている人は一人もいないと思います
他の機器で置き換えてお考えください、そのための記事です

仕様を確認する

さて、通信するにあたり、どのようなコードを送ればよいかは通信先の機器に完全に依存します
つまりIntellisenseなどのコード補完でどうにかなる部分ではないということです
正直に仕様書や説明書を読みましょう
下記は「Writer509CDC」のベースになっている「Writer509」の仕様です

仕様

最初に見るべきは太枠の部分、通信の形式です
シリアル通信ではいくらか通信仕様にバリエーションがあります

これを通信先に合わせたプログラムを作らなければなりません

Writer509CDCの場合、設定がプロトコルの説明用ファイルに書いてあるため、これに従います
他の機器でも説明書のシリアル通信をさせるための節に必ず書いてあるはずです

仕様に合わせたプログラムを書く

シリアル通信はUSBと同じように複数のデバイスがPCに接続される場合があり得ます
しかし「このデバイスと通信して!」というプログラムを書くわけではありません
そういう仕組みはないからです

方法としてはこちらから特定のポートに所望のデバイス用のコマンドを送ってみて、うまく結果が返ってくるかどうかで判断をします
間違ったデバイスにコマンドを送ると結果が返ってきません
まずないでしょうが、最悪の場合はデバイスA用のコマンドが同時に繋いでいるデバイスBの全設定消去コマンドと共通で、うっかり消去してしまうという事故が起こるかもしれません
こうした経緯から大抵の場合は人が一覧から接続するポートを選ぶようになっています

プログラムの流れは次のようなものになります

  1. このPCが扱えるシリアルポートを列挙する
  2. 通信するポートを選択する(これは人間に選択してもらう必要があります)
  3. ポートを開く(通信を開始する)
  4. コマンドやデータを送受信する
  5. 通信を終了する

プログラム

ではプログラムを書いていきます

C++とSiv3Dで記述していきます
シリアル通信もサポートしているのでこうした簡単な通信プログラムが作れるのは重宝します
測定機器との通信を一部自動化させるようなソフト用途には便利なのではないでしょうか

なお、ここではフロー制御と呼ばれる操作は行いません
フロー制御とは、データ通信用の他にも信号線を使って、接続した相手がデータを受信できるかどうか様子をうかがいながら通信し、もし準備ができていない場合には通信を一時中断するものです
フロー制御を行うべきかどうかはデバイスに依存します
Writer509(CDC)ではフロー制御用の信号線を全く使っていない(常に通信OKとして扱う)仕様になっており
特に制御を必要としないため無視します

ここからはデバイスに応じたデータを送受信します
試しにWriter509(CDC)のVersionコマンドを扱ってみます

Versionコマンド

アプリケーションのコードを以下に示します(Siv3D 0.6.3)
長いのでクリックまたはタップで全て見られます


# include <Siv3D.hpp> // OpenSiv3D v0.6.3

const int BAUD_RATE = 38400;

void Main()
{
  Scene::SetBackground(Palette::Gray);

  //シリアルポートの列挙 (ラジオボタンの選択で使うnone付き)
  const Array array_serial_info = System::EnumerateSerialPorts();
  const Array array_serial_names = array_serial_info.map([](const SerialPortInfo& info)
  {
    return U"{} ({})"_fmt(info.port, info.description);
  }) << U"none";


  Serial serial;
  size_t radio_index = (array_serial_names.size() - 1);
  char c_buf[256];
  size_t sz;

  while (System::Update())
  {
    // シリアルポートを列挙するラジオボタンが押されたときの処理
    if (SimpleGUI::RadioButtons(radio_index, array_serial_names, Vec2(200, 300)))
    {
      ClearPrint();

      //noneを選択したらシリアルオブジェクトをクリア
      if (radio_index == (array_serial_names.size() - 1))
      {
        serial = Serial();
      }
      else
      {
        Print << U"Open {}"_fmt(array_serial_info[radio_index].port);

        // シリアルポートをオープン
        if (serial.open(array_serial_info[radio_index].port,
                BAUD_RATE,
                s3d::Serial::ByteSize::EightBits,
                s3d::Serial::Parity::None_,
                s3d::Serial::StopBits::One))
        {
          Print << U"Succeeded";
        }
        else
        {
          Print << U"Failed";
        }
      }
    }


    // 通信を実行するボタン
    if (SimpleGUI::Button(U"Get Version Info", Vec2{ 440, 40 }))
    {
      Array bytes;
      if (serial.isOpen())
      {
        String err;
        try {
          serial.clear();

          err = U"コマンド00 00の1バイト目の送信で問題が発生しました: ";
          if (not serial.writeByte(0x00))
          {
            throw(err + U"writeByte");
          }
          System::Sleep(10);
          bytes = serial.readBytes();
          if (bytes.size() != 1)
          {
            throw(err + U"read length is not 1");
          }
          if (bytes[0] != 0)
          {
            throw(err + U"read data is not 0x00");
          }

          err = U"コマンド00 00の2バイト目の送信で問題が発生しました: ";
          if (not serial.writeByte(0x00))
          {
            throw(err + U"writeByte");
          }
          System::Sleep(200);
          sz = serial.read((void *)c_buf, 1);
          if (sz != 1)
          {
            throw(err + U"response is not 1 byte");
          }
          sz = serial.read((void*)c_buf, 13);
          if (sz != 13)
          {
            throw(err + U"version string is not 13 byte");
          }
          c_buf[13] = '\0';

          Print << Unicode::WidenAscii(c_buf);
        }
        catch (String e)
        {
          Print << U"エラー:" << e;
        }
      }
    }

  }
}
      

ポートの取得はSystem::EnumerateSerialPorts()で行い、これでSerialPortInfoのArrayを取得できます
この情報さえあれば通信はできますが、ポートが複数ある場合は少々厄介です
先述したとおり極力間違ったデバイスにコマンドを送りたくはないのですが、デバイスは名乗ってくれません
それゆえ正しいポートを人間様に選んでもらうことになります
多少なりとも見分けをつけやすくするためにそれぞれのポートに関する名称も知っておきたいですね
これはSerialPortInfoのportとdescriptionにそれぞれ格納されています

ポートを開くにはs3d::Serial::open()を使います
プログラム上ではラジオボタンをクリックした時にポートを開いています
列挙したSerialPortInfoの中から接続したいポートの情報が入っているものを引数に指定し、同時に通信速度等の条件も引数に入れてやります
ボーレート(通信速度)以外はs3d::Serial名前空間で指定します
本例では示されていませんがフロー制御の利用もここで指定します

ポートを開くのはあくまで今後の通信のための準備であり、この時点でポートにデバイスが接続されている必要はありません
また、デバイスの通信条件とSerial.open()で指定する条件が食い違ってもポートを開くことについては支障が出ません
なのでここは大抵の場合は成功します
他のアプリケーションでポートを開いているときは失敗するので、Teratermなどのソフトと挙動を比べながら開発する場合は留意してください

データを送信する場合はs3d::Serial::write()またはSerial::writeByte()を使います
データを受信する場合はs3d::Serial::read()またはSerial::readBytes()です
readやwriteのやり取りはデバイスに依存しますが、本記事では先に挙げたWrite509(CDC)のバージョンを取得するため以下のような流れになります

  1. 0x00を送信
  2. 1バイト受信、0x00が返されることを確認
  3. 0x00を送信
  4. 1バイト受信、0x00が返されることを確認
  5. 13バイト受信、“W509 Ver. X.XX”形式の文字列かどうかを確認

Serial::readBytes()はデバイスから送信されてきたデータをすべて取得してきてくれます
しかし今回の例では3.で0x00を送信するとデバイスは4.と5.の結果を間髪入れず送ってくるため、Serial::readBytesを使う場合は一気に14バイト受信する形になります
本例では14バイト一括で取得してもあまり不都合はありませんが、Serial::readを使って1バイトと13バイトの2回に分けて受信しています

なお、Writer509の仕様を見ると5.の際に200ms程度かけてすべて読んでやることが推奨されています
これはつまりコマンド送信後に待たずにすぐデータを受信しようとするとデータが欠落する可能性があるということです
s3d::Sleep()を使って待ってやりましょう
このあたりの処理は別スレッドにやらせたほうが良いかもしれません

コードを実行するとデバイスへのコマンド送信が正常に行われ、デバイスのバージョン文字列が取得できました

通信してデバイスからバージョンを取得している画像