ArduinoとESP32による大気質モニタリングアプリケーションの作成 (2)-データ通信UART

作成者:カランカラン
💡

質問やフィードバックがありましたら、フォームからお願いします

本文は台湾華語で、ChatGPT で翻訳している記事なので、不確かな部分や間違いがあるかもしれません。ご了承ください

本記事はシリーズの第二弾です:

  1. センサー紹介編 - DHT11 と MH-Z14A
  2. データ通信編 - UART(実装は UART を使用するので、UART のみについて説明します)
  3. Arduino の落とし穴編
  4. (未公開)WiFi 編:デバッグの時間を節約するために、WiFi と Bluetooth 機能を備えた ESP32 開発ボードを追加購入しました。
  5. (未公開)MQTT 編:他のデバイスにデータを送信するために、軽量な通信プロトコルである MQTT を使用しました。
  6. (未公開)Grafana / Web 編:データがデータベースにあるなら、魅力的な方法で表示しなければなりません!ここでは、Grafana + Prometheus と Svelte を使用してデータを表示します。

人と人との間にはコミュニケーションが必要であり、ハードウェア同士の間にも当然コミュニケーションが必要です。

ネットワークの中で最も一般的なのは TCP 通信プロトコルでしょう。データ伝送の正確性を保証するためには、データが正確であることを確保し、双方がメッセージを受信できるようにする一連のメカニズムが必要です。ハードウェアでも同様のことが必要です。

一般的なハードウェア通信プロトコルには次の三つがあります:

  • UART(Universal Asynchronous Receiver/Transmitter)
  • SPI
  • I2C

本記事では UART のみを紹介します。

データの伝送

例えば、10010 を別のハードウェアデバイスに送信するとしましょう。簡単な方法は、5 本の線を一度に接続し、同時に 1 0 0 1 0 を送信することです。下の図を参照してください:

同期通信

直感的に見えますが、16bit のデータを送信する場合は 16 本のピンが必要で、32bit の場合は 32 本のピンが必要です。配線上、できるだけピンを少なくしたいと思うのは皆さん同様です。無線が好まれるのと同じ理由ですね。なぜなら、それが比較的簡単で、製造工程も容易になるからです。

では、ピンの使用を減らす方法はあるのでしょうか?仮に双方が合意の上で 100ms ごとに 1 bit を送信することにすれば、1600ms 後に 16bit を受信することができます。下の図を参照してください:

Serial

確かに、この方法はピンの数が多すぎる問題を解決しますが、元々の同期データ伝送が「非同期」に変わり、速度が犠牲になります(合計で 1600ms かかります)。ハードウェアにおいて、このように一つずつデータを伝送する方法は「Serial」と呼ばれ、同期データ伝送は「Parallel」と呼ばれます。

今日紹介する UART は、Serial Communication の一種です。

先ほどの図に戻りますが、もし一本の線だけで、どれくらいの間隔でデータを受信するかを合意するだけでは不十分です。なぜなら、このプロトコルではデータがいつ送られてくるのか、いつ終わるのかがわからないからです。そのため、データの送信を開始および終了する信号が必要です。

UART のデータ交換

UART では、start bit と stop bit を定義して、双方がデータの送信をいつ開始し、いつ停止するかを知ることができます。

UART - データパケット

アイドル状態では高電位を維持し、データを送信する際には低電位に変わって start bit となり、その後データの送信が始まります。各データフレームは 8 ~ 9bit で、最後の bit はオプショナルなパリティビットです。データ伝送が完了した後、高電位に戻すことで stop bit を示します。

他の通信プロトコルとは異なり、UART には参照用のクロック信号がないため、双方は事前にそれぞれの baud rate を知っておかなければならず、どのくらいの速度でデータを伝送するかを理解する必要があります。この数字がなぜ重要なのかは、以下の図を参照してください:

UART baud rate

赤い部分が正しい baud rate で、データが正しく読み取られていますが、baud rate を二倍にすると、同じ bit が二回読み取られる状況が発生します(茶色の部分)。

UART を使用するためには、以下の条件を満たす必要があります:

  • 二つのハードウェアデバイスは同じ GND を共有する必要があります。
  • Baud rate は同じでなければなりません。

Arduino における UART

Arduino には、標準で UART の Serial Port が搭載されており、通常は 0 と 1 のピンにあり、tx と rx として示されています。tx は transmit を意味し、別のデバイスにデータを送信します;rx は read を意味し、他のデバイスの tx からデータを読み取ります。RX ピンは他のデバイスの TX ピンに接続され、TX ピンは他のデバイスの RX ピンに接続されます。

UART は独立した IC から提供されるか、MCU 内の回路として実装されていますが、Arduino には UART チップが一つしかありません。もし一つ以上のデバイスも Arduino と UART 通信を行いたい場合はどうすればよいのでしょうか?または、元々の Serial Port をデバッグコンソールとして使用し、他のデバイスに使用させたくない場合はどうすればよいのでしょうか?私たちの状況では、MH-Z14A と UART を介して通信したいと同時に、ESP32 にデータを送信したいと考えています。

その場合、Arduino に内蔵されているライブラリ — SoftwareSerial を使用します。Software Serial を使用すると、Arduino のデジタルピンを直接 UART として使用できます。

Software という名前からもわかるように、これはプログラムを利用して UART 通信をシミュレートするもので、映像でよく見られるハードウェアエンコーディングとソフトウェアエンコーディングの感覚に似ています。

今回使用する MH-Z14A Co2 センサーの UART の baud rate は 9600 です。最初は自分で SoftwareSerial を実装するつもりでしたが、いくつかの問題が見つかったので皆さんと共有したいと思います:

  • データシートによると、MH-Z14A センサーの伝送率は 9600Hz です。つまり、各 bit の送信時間は 1/9600 = 1041.6666666us です。まず、Arduino の delay は最大で delayMicroSecond までしか使えないため、小数点以下は切り捨てる必要があります(浮動小数点の関係で、delay に渡す引数は常に整数でなければなりません)。しかし、この誤差だけでもデータの読み取りに影響を与えるには十分です。
  • CPU の命令サイクルにも時間がかかります。この時間は短いものの、UART 通信中の各 bit に蓄積される誤差は無視できません。
  • 単純に delay を使用すると、Arduino の他の割り込みによって時間が不正確になる可能性があります。

下の図は、delay 関数を使用する際に直面する問題を明確に示しています:

UART baud rate - 2

理想的には delay 関数は非常に正確であるべきですが、実際には CPU が動作しているとき、コンパイルや割り込みなどの理由で、茶色の部分に偏差が生じる可能性があります。私たちは CPU が消費する時間を推測し、オフセット値を調整するか、時間の精度を向上させる方法を見つける必要があります。その後、SoftwareSerial のソースコードを見たところ、彼らは本当に計算していました!

しかも、CPU サイクル数を直接計算しているのです!今考えると、サイクルを直接計算するのが最も正確な結果ですね。しかし、これにはレジスタを直接操作するためにアセンブリ言語を書く必要があることを意味します。

MH-Z14A の baud rate は 9600 で、各 bit の時間は 1/9600 秒で、CPU にとっては何サイクルになるのでしょうか?計算が簡単になるように、CPU 周波数を 9600Hz に設定しましょう。これでちょうど 1 サイクルになります。計算方法は:CPUspeedbaudrate\frac{CPU speed}{baud-rate} です。

Software::begin の中では、実装を確認できます。

void SoftwareSerial::begin(long speed)
{
  // 略
  
  // 各遅延を、4 サイクルの遅延数で予め計算
  uint16_t bit_delay = (F_CPU / speed) / 4;

  // スタートビットから最初のビットまで 12 (gcc 4.8.2) または 13 (gcc 4.3.2) サイクル、
  // ビット間では 15 (gcc 4.8.2) または 16 (gcc 4.3.2) サイクル、
  // 最後のビットからストップビットまで 12 (gcc 4.8.2) または 14 (gcc 4.3.2) サイクル
  // ビット間のタイミングが最も重要であるため、すべては 15 サイクルを使用します(偏差は 8 倍に累積します)
  _tx_delay = subtract_cap(bit_delay, 15 / 4);

  // 有効な PCINT がこのピンにある場合のみ rx をセットアップ
  if (digitalPinToPCICR((int8_t)_receivePin)) {
    #if GCC_VERSION > 40800
    // gcc 4.8.2 の出力からのタイミング。これは 16Mhz で 115200 まで、8Mhz で 57600 まで動作します。
    //
    // スタートビットが発生すると、割り込みフラグが設定されるまでに 3 または 4 サイクルがかかり、PC が正しい割り込みベクタアドレスに設定されるまでに 4 サイクルかかり、
    // その後、最初の遅延までに 75 サイクルの命令が実行されます(ISR ベクタテーブルの RJMP を含みます)。遅延の後、ピンの値が読み取られるまでに 17 サイクルかかります(ループ内の遅延を除く)。
    // 合計で 1.5 ビット時間の遅延が必要です。ループ内では、すでに 1 ビット時間 - 23 サイクルを待っているため、ここでは 0.5 ビット時間 - (71 + 18 - 22) サイクルを待ちます。
    _rx_delay_centering = subtract_cap(bit_delay / 2, (4 + 4 + 75 + 17 - 23) / 4);

    // 各ループ反復には 23 サイクルがあります(遅延を除く)
    _rx_delay_intrabit = subtract_cap(bit_delay, 23 / 4);

    // 最後のビット読み取りからストップビット遅延の開始までに 37 サイクル、遅延から再度割り込みマスクが有効になるまでに 11 サイクルかかります(これはストップビット中に行われなければなりません)。
    // この遅延はビット時間の 3/4 を目指しており、つまり遅延の終わりはストップビットの 1/4 にあります。これにより、ISR のクリーンアップに追加の時間が確保され、16Mhz での 115200 baud がより信頼性を持って動作します。
    _rx_delay_stopbit = subtract_cap(bit_delay * 3 / 4, (37 + 11) / 4);
    #else // gcc 4.3.2 の出力からのタイミング
    // このコードは非常に遅く、主に gcc の不適切なレジスタ割り当ての選択によるものです。これは 16Mhz で 57600 まで、8Mhz で 38400 まで動作します。
    _rx_delay_centering = subtract_cap(bit_delay / 2, (4 + 4 + 97 + 29 - 11) / 4);
    _rx_delay_intrabit = subtract_cap(bit_delay, 11 / 4);
    _rx_delay_stopbit = subtract_cap(bit_delay * 3 / 4, (44 + 17) / 4);
    #endif

    // ここでポート全体の PCINT を有効にしますが、決して無効にはしません(他のデバイスも必要とするかもしれませんので、割り込みを無効にするためにピンごとの PCMSK レジスタを使用します)。
    *digitalPinToPCICR((int8_t)_receivePin) |= _BV(digitalPinToPCICRbit(_receivePin));
    // pcint マスクレジスタと値を事前に計算しておくので、setRxIntMask を ISR 内であまり時間をかけずに使用できます。
    _pcint_maskreg = digitalPinToPCMSK(_receivePin);
    _pcint_maskvalue = _BV(digitalPinToPCMSKbit(_receivePin));

    tunedDelay(_tx_delay); // もし低電位だった場合、これにより終了が確立されます
  }
  ...
}

コードは少ないですが、コメントが非常に明確です。彼らは各 gcc バージョンで必要な CPU サイクル数を計算し、その後に delay を行っています。

ここでの tunedDelay の実装も興味深いもので、アセンブリ言語で直接書かれています!

私は AVR のアセンブリ命令セットに詳しくありませんが(もちろん Intel も同様です XD)、これはおそらくより正確な delay 関数のようです。volatile を使用することで、コンパイラにこのコードを最適化する必要がないことを伝えています。なぜなら、この変数は変更される可能性があり、毎回アドレスから直接アクセスするからです。

void tunedDelay(uint16_t __count)
{
	asm volatile (
		"1: sbiw %0,1" "\n\t"
		"brne 1b"
		: "=w" (__count)
		: "0" (__count)
	);
}

そして、SoftwareSerial::write の中で

size_t SoftwareSerial::write(uint8_t b)
{
  volatile uint8_t *reg = _transmitPortRegister;
  uint8_t reg_mask = _transmitBitMask;
  uint8_t inv_mask = ~_transmitBitMask;
  uint8_t oldSREG = SREG; 
  bool inv = _inverse_logic;
  uint16_t delay = _tx_delay;

  if (inv)
    b = ~b;

  cli();  // クリーンな送信のために割り込みをオフにします

  // スタートビットを書き込みます
  if (inv)
    *reg |= reg_mask;
  else
    *reg &= inv_mask;

  tunedDelay(delay);
  
  // 各ビットを 8 ビット分書き込みます
  for (uint8_t i = 8; i > 0; --i)
  {
    if (b & 1) // ビットを選択
      *reg |= reg_mask; // 1 を送信
    else
      *reg &= inv_mask; // 0 を送信

    tunedDelay(delay);
    b >>= 1;
  }

  // ピンを自然な状態に戻します
  if (inv)
    *reg &= inv_mask;
  else
    *reg |= reg_mask;

  SREG = oldSREG; // 割り込みを再度オンにします
  tunedDelay(_tx_delay);
  
  return 1;
}

ここで特に言及すべきコードの断片がいくつかあります:

  • *reg|= reg_mask の部分は、レジスタを直接操作する方法で高低電位を変更しています。一般的な digitalWrite() の書き方ではなく、コンパイル後のアセンブリで不必要な命令サイクルを減らすためでしょう。
  • cli():Arduino の割り込み機構を解除します。原子操作や時間関連の操作を行う場合に使用しますが、データ伝送の過程では他のプログラムが一時的に動作できなくなることを意味します。疑問なのは、なぜ公式文書の noInterrupts() ではなく cli() を使用するのかということです。
  • SREG = oldSREG:説明によると、これで割り込みを再び有効にできるとのことですが、なぜ interrupts() を直接呼び出さないのでしょうか?

他の実装については一つ一つ説明しませんが、ソースコードを観察することでいくつかの事実を発見しました:

  • 単純に delay を使用するだけでは精度が不十分である。
  • 正確な時間制御を達成するためには、サイクル数を直接計算する必要がある。
  • 通信の過程では割り込み機構が使用できない。

さて、これで SoftwareSerial の実装の詳細がわかり、安心して使えるようになりました!

SoftwareSerial

SoftwareSerial の API は HardwareSerial(すなわち、事前定義された Serial)インターフェースと同じで、上記のコード実装により、UART の通信はすでに処理されているため、Serial.write を直接使用してコマンドを送信すれば大丈夫です。

唯一注意すべき点は、Mega および Mega 2560 ではすべてのピンが RX として使用できるわけではないということです。

Mega と Mega 2560 のすべてのピンが変更割り込みをサポートしているわけではないため、RX として使用できるのは以下のピンのみです: 10, 11, 12, 13, 14, 15, 50, 51, 52, 53, A8 (62), A9 (63), A10 (64), A11 (65), A12 (66), A13 (67), A14 (68), A15 (69)。

SoftwareSerial を使用して MH-Z14A にコマンドを送信する

前回も触れた MH-Z14A のコマンドについて、再確認しましょう。

コマンド機能(原文 / 和訳)備考
0x86ガス濃度測定 / 二酸化炭素濃度を測定
0x87ゼロポイントの校正 / ゼロ校正の実施このセンサーには 3 つのゼロ校正方法があり、コマンドを送信することがその一つです
0x99スパンポイントの校正 / 二酸化炭素検出範囲の変更
0x79ゼロポイントの自動校正機能の開始/停止 / 自動校正機能の使用(または停止)出荷時にこのセンサーは自動校正がデフォルトでオンになっています

このコマンド表から、私たちが最も関心を持っているのは 0x86 コマンドですが、データシートの説明によると、コマンドの送信は以下のフォーマットに従う必要があります:

MH-Z14A データシート

確認すると、合計 9 バイトで、最初のバイトは 0xff、2 番目のバイトは 0x01、3 番目のバイトがコマンド、バイト 3 ~ 7 は 0x00、最後の値がチェック値です。データシートにはチェック値の計算方法のサンプルコードも親切に添付されています:

char getCheckSum(char *packet)
{
  char i, checksum;
  for( i = 1; i < 8; i++)
  {
    checksum += packet[i];
  }
  checksum = 0xff – checksum;
  checksum += 1;
  return checksum;
}

checksum の計算と使用についても興味深いテーマであり、後日別の記事で詳しく紹介します!

SoftwareSerial を使用すると、次のように書けます:

#include <SoftwareSerial.h>
SoftwareSerial co2Serial(3, 4); // tx, rx ピン、デジタルピンならどれでも可能

void sendCommand(byte command)
{
  byte commands[9] = {0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
  commands[2] = command;
  commands[8] = getCheckSum(commands);
  co2Serial.write(commands, 9);
}

これで MH-Z14A にコマンドを正常に送信できます!データシートの返り値に基づいて、serial.readBytes を使用して応答データを取得できます。

MH-Z14A データシート

byte response[9];
byte high = response[2];
byte low = response[3];
byte ppm = high << 8 + low;
Serial.println(ppm);

データシートによると、ppm の値は high * 256 (つまり high を左に 8 ビットシフトすること)を加算して low を加えます。byte8 の数字を通じて数値の正確さを確認することもできますが、ここでは省略します。

こうして、二酸化炭素の数値を成功裏に取得できました!次に、MH-Z14A のピン図を見てみましょう:

MH-Z14A ピン説明

このセンサーは、同様の機能を持つ多くのピンを提供しており、デバッグに便利だと思われます。たとえば、グランドはピン 2, 3, 12, 16, 22 など、様々な組み合わせを選べます。ここでは、GND を Arduino の GND に接続し、RXD を SoftwareSerial の tx(先ほど定義したピンですが、実際の tx ピンではありません)に、TXD を SoftwareSerial の rx に接続します。

注意:MH-Z14A センサーは購入時にピンがハンダ付けされていないため、直接ブレッドボードに接続できません。最初はジャンパー線を穴に差し込んでいましたが、非常に不安定でしたので、皆さんも必ずハンダ付けを行ってください!

関連リンク

まとめ

この記事では、UART を紹介し、SoftwareSerial の背後にある原理を説明しました。最後に、コマンドを送信して MH-Z14A から二酸化炭素濃度を取得しました。次回の記事では、実装時に遭遇したトラブルについて共有しますので、ご期待ください。

この記事が役に立ったと思ったら、下のリンクからコーヒーを奢ってくれると嬉しいです ☕ 私の普通の一日が輝かしいものになります ✨

Buy me a coffee