ArduinoとESP32で作る空気質モニタリングアプリ(2)- データ通信編 UART
本記事はシリーズの第2回である:
- センサー紹介編 - DHT11とMH-Z14A
- データ通信編 - UART(実装ではUARTを使ったので、UARTのみ解説)
- Arduino ハマりどころ編
- (未公開)WiFi編:デバッグ時間を節約するため、最初からWiFiとBluetoothを搭載しているESP32開発ボードを追加購入した
- (未公開)MQTT編:他のデバイスにデータを送信するため、軽量でコンパクトな通信プロトコルであるMQTTを使用した
- (未公開)Grafana / Web編:データベースに保存したデータは、もちろんイケてる方法で可視化したい!ここではGrafana + Prometheus、そしてSvelteを使ってデータを表示した。
人と人との間にコミュニケーションが必要なように、ハードウェア同士の間にも当然コミュニケーションが必要だ。
ネットワークにおいて、最も一般的なのはおそらくTCPプロトコルだろう。データ転送の正確性を保証するためには、データに誤りがなく、双方がメッセージを受信できるようにするためのさまざまな仕組みが必要となる。ハードウェアの世界でも同様のことが求められる。
ハードウェアでよく使われる通信プロトコルには主に以下の3つがある:
- UART(Universal Asynchronous Receiver/Transmitter)
- SPI
- I2C
本記事ではUARTのみを紹介する。
データを送る
仮に別のハードウェア機器へ 10010 を送信するとしよう。単純な方法としては、5本の配線を一度に繋ぎ、以下の図のように 1 0 0 1 0 を同時に送信することだ:

直感的ではあるが、これだと16bitなら16本のピン、32bitなら32本のピンが必要になってしまう。回路設計においては、可能ならピン数は少ないほうが望ましい。みんなが無線を好むのと似たような理由で、そのほうがシンプルだし、製造工程も容易になるからだ。
では、ピン数を減らす方法はないだろうか?例えば、双方が「100msごとに1bitずつ送る」と取り決めておけば、1600ms後には16bitを受信できる。以下の図の通りだ:

確かにこの方法ならピン数が多すぎる問題は効果的に解決できるが、本来同時だったデータ転送が「非同期」になり、相対的に速度をいくらか犠牲にすることになる(16bitを送り切るのに合計1600msかかる)。ハードウェアでは、このようにデータを1つずつ順に送る方式を「シリアル(Serial)」、同時に送るデータ転送を「パラレル(Parallel)」と呼ぶ。
今回紹介するUARTは、このシリアル通信(Serial Communication)の一種だ。
上の図に戻ると、配線が1本だけで「一定時間ごとにデータを受信する」と取り決めるだけではまだ不十分だ。この取り決めだけではいつデータが送られてきて、いつ終わるのかが分からないからだ。そのため、データの送受信がいつ開始され、いつ終了するかを双方が把握できる信号も必要になる。
UARTのデータ交換
UARTでは、データの送信開始と終了を双方が認識できるようにスタートビット(start bit)とストップビット(stop bit)が定義されている。

アイドル(IDLE)状態ではHighレベルを維持し、データ送信を開始する際にスタートビットとしてLowレベルに落とし、その後データの送信を始める。各データフレーム(dataframe)は8〜9bitで、最後の1bitは任意のパリティビットだ。データ転送完了後はHighレベルに引き上げられ、ストップビットを表す。
他の通信プロトコルとは異なり、UARTには参照用のクロック信号(clock)が存在しない。そのため双方はあらかじめ互いのボーレート(baud rate)を知っておく必要があり、それによってどの速度でデータを送信するかを把握する。なぜこの数値がそれほど重要なのかは、以下の図を見てほしい:

赤色の部分は正しいボーレートで、データが正しく読み取られていることがわかる。しかしボーレートを2倍に上げてしまうと、同じbitを2回重複して読み取ってしまうような現象が発生する(茶色の部分)。
UARTを使用するには、以下の条件を満たす必要がある:
- 2つのハードウェアデバイスが同一のGNDを共有していること
- ボーレート(Baud rate)が同一であること
ArduinoにおけるUART
ArduinoにはUARTのシリアルポートが標準で備わっており、通常はピン0とピン1に割り当てられ、TXとRXと表記されている。TXはTransmit(送信)を表し、別のデバイスにデータを送信する。RXはRead/Receive(受信)を表し、別のデバイスのTXからデータを読み取る。RXピンは相手側デバイスのTXピンに接続し、TXピンは相手側デバイスのRXピンに接続する。
UARTは独立したIC、あるいはMCU内の回路として提供されているが、ArduinoのハードウェアUARTチップは1つしかない。もし複数のデバイスとArduinoでUART通信を行いたい場合はどうすればいいだろうか?あるいは、デバッグをしやすくするためにネイティブのシリアルポートをデバッグコンソールとして使いたいので他のデバイスには占有されたくない場合は?僕たちのユースケースでは、UART経由でMH-Z14Aと通信したいと同時に、UART経由でESP32へデータを送信したかった。
そこで役立つのが、Arduino標準のライブラリである SoftwareSerial だ。SoftwareSerialを使えば、Arduinoのデジタルピンを直接UARTとして使用できる。
「Software」という名前からも分かるように、これはプログラムによってUARTの通信をエミュレートするもので、動画配信などでよくあるハードウェアエンコードとソフトウェアエンコードのような関係だ。
今回僕たちが使用したMH-Z14A CO2センサーのUARTボーレートは9600だ。当初、僕たちはSoftwareSerialを自作、つまりUARTに準拠した通信をスクラッチで実装しようと考えていたが、いくつか問題に直面したので共有しておこう:
- データシートの記載によれば、このMH-Z14Aセンサーの転送レートは9600Hzだ。つまり各bitの転送時間は 1/9600 = 1041.6666666us となる。まずArduinoの遅延関数は delayMicroseconds が限界であり、小数点以下を切り捨てる必要がある(浮動小数点数の関係で、delayに渡す引数は整数でなければならない)。しかしこの程度の誤差でも、データを正確に読み取れなくするには十分だ。
- CPUのインストラクションサイクル(命令サイクル)にも時間がかかる。この時間は極めて短いが、UART通信においては1bitごとに蓄積される誤差を無視できない。
- 単純にdelayを使った場合、Arduinoの他の割り込み処理に邪魔されてタイミングが狂うのではないかと推測した。
以下の図は、delay関数を使った際に直面する問題をわかりやすく示している:

理想的にはdelay関数は極めて正確であるべきだが、実際にはコンパイル結果や割り込みなどの要因によって、CPU動作中に茶色のブロックのようなズレが生じる可能性がある。CPUが消費する時間を当て推量してオフセット値を調整するか、なんとかして時間精度を高めるかのどちらかだ。その後、SoftwareSerialのソースコードを見てみたところ……彼らは本当に計算していた!
しかもCPUのサイクル数を直接計算している!今思えばそれも至極もっともで、サイクル数を直接計算するのが最も正確な結果になる。ただしそれは、アセンブリ言語を書いてレジスタを直接操作することを意味する。
MH-Z14Aのボーレートは9600で、1bitあたりの時間は 1/9600 秒になる。これはCPUにとって何サイクルにあたるのだろうか?計算しやすくするため仮にCPU周波数も9600Hzだとすれば、ちょうど1サイクルになる。計算式は だ。
Software::begin の実装 を見ると、以下のようになっている。
void SoftwareSerial::begin(long speed)
{
// 略
// Precalculate the various delays, in number of 4-cycle delays
uint16_t bit_delay = (F_CPU / speed) / 4;
// 12 (gcc 4.8.2) or 13 (gcc 4.3.2) cycles from start bit to first bit,
// 15 (gcc 4.8.2) or 16 (gcc 4.3.2) cycles between bits,
// 12 (gcc 4.8.2) or 14 (gcc 4.3.2) cycles from last bit to stop bit
// These are all close enough to just use 15 cycles, since the inter-bit
// timings are the most critical (deviations stack 8 times)
_tx_delay = subtract_cap(bit_delay, 15 / 4);
// Only setup rx when we have a valid PCINT for this pin
if (digitalPinToPCICR((int8_t)_receivePin)) {
#if GCC_VERSION > 40800
// Timings counted from gcc 4.8.2 output. This works up to 115200 on
// 16Mhz and 57600 on 8Mhz.
//
// When the start bit occurs, there are 3 or 4 cycles before the
// interrupt flag is set, 4 cycles before the PC is set to the right
// interrupt vector address and the old PC is pushed on the stack,
// and then 75 cycles of instructions (including the RJMP in the
// ISR vector table) until the first delay. After the delay, there
// are 17 more cycles until the pin value is read (excluding the
// delay in the loop).
// We want to have a total delay of 1.5 bit time. Inside the loop,
// we already wait for 1 bit time - 23 cycles, so here we wait for
// 0.5 bit time - (71 + 18 - 22) cycles.
_rx_delay_centering = subtract_cap(bit_delay / 2, (4 + 4 + 75 + 17 - 23) / 4);
// There are 23 cycles in each loop iteration (excluding the delay)
_rx_delay_intrabit = subtract_cap(bit_delay, 23 / 4);
// There are 37 cycles from the last bit read to the start of
// stopbit delay and 11 cycles from the delay until the interrupt
// mask is enabled again (which _must_ happen during the stopbit).
// This delay aims at 3/4 of a bit time, meaning the end of the
// delay will be at 1/4th of the stopbit. This allows some extra
// time for ISR cleanup, which makes 115200 baud at 16Mhz work more
// reliably
_rx_delay_stopbit = subtract_cap(bit_delay * 3 / 4, (37 + 11) / 4);
#else // Timings counted from gcc 4.3.2 output
// Note that this code is a _lot_ slower, mostly due to bad register
// allocation choices of gcc. This works up to 57600 on 16Mhz and
// 38400 on 8Mhz.
_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
// Enable the PCINT for the entire port here, but never disable it
// (others might also need it, so we disable the interrupt by using
// the per-pin PCMSK register).
*digitalPinToPCICR((int8_t)_receivePin) |= _BV(digitalPinToPCICRbit(_receivePin));
// Precalculate the pcint mask register and value, so setRxIntMask
// can be used inside the ISR without costing too much time.
_pcint_maskreg = digitalPinToPCMSK(_receivePin);
_pcint_maskvalue = _BV(digitalPinToPCMSKbit(_receivePin));
tunedDelay(_tx_delay); // if we were low this establishes the end
}
...
}
コード自体は多くないが、コメントに明確に書かれている。彼らは各GCCバージョンで必要となるCPUサイクル数を計算し、それを差し引いた上でディレイをかけている。
ここの 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(); // turn off interrupts for a clean txmit
// Write the start bit
if (inv)
*reg |= reg_mask;
else
*reg &= inv_mask;
tunedDelay(delay);
// Write each of the 8 bits
for (uint8_t i = 8; i > 0; --i)
{
if (b & 1) // choose bit
*reg |= reg_mask; // send 1
else
*reg &= inv_mask; // send 0
tunedDelay(delay);
b >>= 1;
}
// restore pin to natural state
if (inv)
*reg &= inv_mask;
else
*reg |= reg_mask;
SREG = oldSREG; // turn interrupts back on
tunedDelay(_tx_delay);
return 1;
}
ここにはいくつか注目すべきコードがある:
*reg |= reg_mask:コードでは通常のdigitalWrite()ではなく、レジスタを直接操作することでHigh/Lowレベルを切り替えている。コンパイル後のアセンブリによる不要な命令サイクルを減らすためだろうか?cli():Arduinoの割り込みを無効化する。アトミックな操作やシビアなタイミング制御を行う際に使用できるが、これはデータ転送中、他の処理が一時的に動作しなくなることを意味するのだろうか?不思議なのは、なぜ公式ドキュメントにあるnoInterrupts()ではなくcli()を使っているのかという点だ。SREG = oldSREG:説明によればこれで割り込みが再度有効になるようだが、なぜ素直にinterrupts()を呼ばないのだろう?
他の実装についてはいちいち解説しないが、ソースコードを読んだことでいくつかのことが分かった:
- 単に
delayを使うだけでは精度が足りない - 正確な時間制御を行うには、サイクル数を直接計算する必要がある
- 通信処理の最中は割り込み機能が使えなくなる
さて、これで SoftwareSerial の実装の詳細が分かり、安心して使えるようになった!
SoftwareSerial
SoftwareSerial のAPIは HardwareSerial(標準で定義されているSerial)と同じインターフェースを持つ。上記の実装コードからもわかる通り、UART通信の処理はすでにライブラリ側で面倒を見てくれているので、単純に Serial.write でコマンドコードを送信すればよい。
唯一注意すべき点は、MegaやMega 2560ではすべてのピンをRXとして使えるわけではないということだ。
Not all pins on the Mega and Mega 2560 support change interrupts, so only the following can be used for 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 | Gas concentration / 二酸化炭素濃度測定 | |
| 0x87 | Calibrate zero point value / ゼロ点校正 | このセンサーには3つのゼロ点校正方法があり、コマンド送信はその1つ |
| 0x99 | Calibrate span point value / 二酸化炭素検出範囲の変更 | |
| 0x79 | Start/stop auto-calibration function of zero point value / 自動校正機能の有効化(または停止) | 工場出荷時、このセンサーはデフォルトで自動校正が有効になっている |
このコマンド一覧表の中で僕たちが最も注目したいのは 0x86 コマンドだが、データシートの記載通り、コマンド全体の送信は以下のフォーマットに準拠する必要がある:

見てみると、合計9バイトで、1バイト目は 0xff、2バイト目は 0x01、3バイト目はコマンドコード、バイト3〜7は 0x00、そして最後の値がチェックサム(check value)となっている。データシートには計算方法のサンプルコードまで親切に記載されている:
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 接腳,只要是 digital pin 都可以
void sendCommand(byte command)
{
byte commands[9] = {0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
commands[2] = command;
commands[8] = getCheckSum();
co2Serial.write(commands, 9);
}
これでMH-Z14Aへ正常にコマンドを送信できる!データシートの戻り値の記述に従い、serial.readBytes を使ってレスポンスデータを取得できる。

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 を足したものだ。また、バイト8の数値を使ってデータが正しいかどうかを検証することもできるが、ここでは横着して実装を省いた。
これで二酸化炭素の数値を正常に取得できた!次にMH-Z14Aのピン配置図を見てみよう:

このセンサーには同じ機能を持つピンが多数用意されているが、おそらくデバッグを容易にするためだろう。例えばGNDならピン2、3、12、16、22から好きなものを自由に選べる。ここではGNDをArduinoのGNDに接続し、RXDをSoftwareSerialのTX(先ほどシリアルで定義したピンであり、実物のTXピンではないことに注意)、TXDをSoftwareSerialのRXに接続した。
注:MH-Z14Aセンサーは購入時にピンヘッダがハンダ付けされていないため、そのままブレッドボードに挿すことができない。僕たちは最初、穴に直接ジャンパワイヤを差し込んでみたのだが、極めて不安定だった。みなさんは必ずハンダ付けをしてほしい!
関連リンク
まとめ
本記事ではUARTについて紹介し、SoftwareSerial の背後にある仕組みを解説した。そして最後にコマンドを MH-Z14A に送信して二酸化炭素濃度を取得した。次の記事では、実装中に直面したハマりどころを共有する予定だ!ぜひ楽しみにしていてほしい。
関連記事
- 測定が目標になるとき:窓税からPull Request数まで かつて僕は小さなツールを自作し、四半期で自分がどれだけPRに貢献したか、レビューコメントをどれだけ残したか、チケットをどれだけ消化したかを集計して、上司にアウトプットを証明しようとしたことがある。上司は淡々と、評価はアウトプットだけで見るものではないと言った。数年後、僕はようやく理解した――測定が目標になるとき、それはもはや良い測定ではなくなるのだ。英国の窓税、ハノイのネズミ駆除の報奨金から、現代のPR数による開発者評価に至るまで、そのメカニズムはまったく同じだ。
- Cloudflare Images を画像ストレージ・変換ソリューションとして使う ウェブページに画像を1枚置くのはフロントエンドにとって最も簡単なことだが、リサイズや各種フォーマットの生成、さらにはトラフィックの負荷に耐えることまで完璧にやろうとすると、実際には一つの包括的なソリューションが必要になる。僕はその後、すべて Cloudflare Images に任せるようになり、オリジナル画像1枚だけを渡すようにしている。
- もう AWS Access Key を使うのはやめよう Access Key は AWS において見落とされがちなセキュリティリスクだ。OIDC と IAM Role を組み合わせることで、GitHub Actions にシークレットを一切保持させることなく、安全に AWS リソースを操作できるようにする。
- データベース主キー:AUTO_INCREMENT、UUID、そしてUUIDv7 バックエンド開発で度々直面する主キーの決定。auto incrementを使うべきか、それともUUIDか?衝突への懸念は?UUIDv7とcreated_at + インデックスの性能差はどれほどか?実際に2,000万件のデータで検証したベンチマークと設計上の意思決定を解説する。