はじめに
IoT開発において、マイコンとサーバー間の通信にJSON(HTTP/1.1)を使用するのは一般的ですが、リソースの限られた環境では「シリアライズ負荷」「ペイロードの肥大化」「型定義の曖昧さ」が無視できない課題となります。
本連載では、Googleが開発したバイナリ形式のシリアライズ規格 Protocol Buffers (Protobuf) をマイコン向けに最適化した Nanopb を用い、ESP32-S3からローカルPCへ軽量なデータを送信・デコードするまでの工程を解説します。
理論的背景の解説はこちら
実装に入る前に、gRPCの仕組みやJSONとの比較を詳しく知りたい方は、以下の概念図解記事を併せてご覧ください。
マイコン開発の新常識?ESP32でgRPCを動かすための「理論のハブ」
1. Nanopbとは何者か?:リソース制限下での「最適解」
gRPCは本来リッチなリソースを前提としていますが、ESP32で動作させるにはフルスタックのgRPCライブラリは重すぎます。そこで、マイコン向けに特化し、静的なメモリ割り当てを基本とする Nanopb を採用します。
Nanopbは、.proto ファイルからC言語の構造体と、エンコード/デコード用のメタデータ(フィールド情報)を生成するライブラリです。
Nanopbのアイデンティティ
Nanopbは、ANSI C実装のProtobufライブラリです。Google公式のProtobufはC++がメインであり、動的メモリ割り当てを多用するため、数KBのRAMをやりくりする埋め込みシステムには贅沢すぎます。
- 極小のフットプリント: ROM使用量はわずか2〜10KB、RAM使用量にいたっては1KB以下に抑えることが可能です。
- 実績: 実は非常に身近なところで使われており、Android、iPhone、Garmin製品などの内部通信でも採用されています。
- 静的アロケーション: すべてのフィールドサイズをビルド時に確定させ、ランタイムでのメモリ断片化(Heapの食いつぶし)を完全に防ぎます。
2. インターフェース定義:.proto と .options
2.1. センサーデータの定義 (sensor.proto)
まず、通信するデータの構造を言語非依存の形式で定義します。本構成では、デバイスID、温度、湿度の3項目を定義しました 。
syntax = "proto3";
message SensorData {
string device_id = 1;
float temperature = 2;
int32 humidity = 3;
}
2.2. Nanopb特有の制約を制御する (sensor.options)
Nanopbにおいて、string 型の最大サイズを指定しないと、デフォルトでは「コールバック形式」で生成され、実装が非常に煩雑になります。これを防ぐために、.options ファイルでサイズを固定し、静的なchar配列 として生成させるのが「マイコン流」です 。
SensorData.device_id max_size:32
3. ESP-IDF ビルドシステムへの統合 (CMake)
idf.py build を叩いた際、自動的にPythonスクリプトが走り、最新の .proto から .c/.h を生成してプロジェクトにリンクさせる設定を main/CMakeLists.txt に記述します 。
ESP-IDFのビルドシステム(CMake)は非常に強力ですが、独自の挙動を知らないと今回のエラーのように「昨日まで動いていたのに急にビルドできなくなった」という罠に陥ります。
特に重要な 「早期展開(Early Expansion)」 の仕組みと、なぜこの条件分岐が必要なのかについて、技術的な詳細を深掘りして解説します。
🛠️ CMakeLists.txt の解説
ESP-IDFのビルドは、通常のCMakeとは異なり 「2段階」 でスクリプトを読み込みます。この特性を理解することが、nanopbをスムーズに統合する鍵です。
1. 「早期展開(Early Expansion)」という門番
ESP-IDFは、プロジェクト全体のコンパイルを始める前に、すべてのコンポーネントをスキャンして「どのコンポーネントが何に依存しているか」というグラフを作成します 。
-
課題: このスキャン中、CMakeは「スクリプトモード」として動作します。このモードでは、ビルドターゲットを定義する
add_custom_commandなどの命令を実行することが禁止されており、無視して記述すると 「command is not scriptable」 という致命的なエラーで停止します 。 -
対策: そこで
if(NOT CMAKE_BUILD_EARLY_EXPANSION)を使い、「今は依存関係を調べているだけ(早期展開)なら、重いビルド処理は読み飛ばしてね」と明示的に指示する必要があります 。
2. ディレクトリ作成のタイミング問題
ビルドシステムは、idf_component_register の INCLUDE_DIRS に指定されたパスが 「実在するか」 を厳格にチェックします 。
-
落とし穴: コード生成先である
generatedフォルダをビルド時(ifの中)に作ろうとすると、その前の「早期展開」の段階で「指定されたディレクトリが見つからない!」とエラーが出てしまいます 。 -
解決策: したがって、
file(MAKE_DIRECTORY ...)だけは条件分岐の外に出し、スキャンが始まる瞬間に「空でもいいから箱だけは作っておく」という前処理が必要になります 。
3. 生成ファイルと main.c の結合
Protobufで生成される .pb.c と .pb.h は、ビルドが始まるまで存在しません。これらを安全にコンパイルに組み込むために、以下のテクニックを使っています 。
-
target_sources: コンポーネントのライブラリ(${COMPONENT_LIB})に対して、動的に生成されるソースファイルを後付けで追加します。 -
OBJECT_DEPENDS:main.cのプロパティとして設定します。これにより、「main.cをコンパイルする前に、必ず.pb.hの生成(Pythonスクリプトの実行)を完了させてね」という依存関係をCMakeに教え込み、ビルド順序の破綻を防ぎます。
💡 修正後の CMakeLists.txt 全容
これらを踏まえた、構成がこちらです。
# 1. パスの確定とディレクトリの強制作成
# idf_component_register のパスチェックをパスするために、ifの外で即座に実行する
set(GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated")
file(MAKE_DIRECTORY "${GEN_DIR}")
# 2. コンポーネントの登録
# 依存関係(REQUIRES)を定義し、生成先をインクルードパスに含める
idf_component_register(
SRCS "main.c"
INCLUDE_DIRS "." "${GEN_DIR}"
REQUIRES nanopb nvs_flash esp_wifi esp_http_client esp_event
)
# 3. 実際のビルドロジック(実際のコンパイルフェーズのみ実行)
if(NOT CMAKE_BUILD_EARLY_EXPANSION)
set(NANOPB_GEN "${CMAKE_SOURCE_DIR}/components/nanopb/generator/nanopb_generator.py")
set(PROTO_DIR "${CMAKE_CURRENT_LIST_DIR}/proto")
set(PROTO_FILE "${PROTO_DIR}/sensor.proto")
set(OPTIONS_FILE "${PROTO_DIR}/sensor.options")
set(GEN_SRC "${GEN_DIR}/sensor.pb.c")
set(GEN_HDR "${GEN_DIR}/sensor.pb.h")
# Pythonによる自動コード生成コマンド
add_custom_command(
OUTPUT "${GEN_SRC}" "${GEN_HDR}"
# -I でプロトファイルのディレクトリを指定し、protocのパス解決を助ける
COMMAND python "${NANOPB_GEN}" -I "${PROTO_DIR}" -D "${GEN_DIR}" "${PROTO_FILE}"
# .proto だけでなく .options が更新された時も再生成をトリガーする
DEPENDS "${PROTO_FILE}" "${OPTIONS_FILE}"
VERBATIM
)
# 生成されたCソースをビルド対象に追加
target_sources(${COMPONENT_LIB} PRIVATE "${GEN_SRC}")
# main.c が生成ヘッダーに依存していることを明示(コンパイル順序の制御)
set_source_files_properties("main.c" PROPERTIES OBJECT_DEPENDS "${GEN_HDR}")
endif()
このように「ESP-IDFのビルドプロセスの多段性」を考慮した設計にすることで、開発者は .proto ファイルを更新して Build ボタンを押すだけで、常に最新のインターフェースが C言語と Python の両方に反映される、モダンな開発環境を手に入れることができます。
この CMake の挙動は、ESP-IDF で自作のコード生成ツール(画像変換やフォント生成など)を組み込む際にも応用できる、一生モノのテクニックと言えるでしょう。
4. ESP32側:実装とエンコード処理
main.c では、30秒ごとに のランダムな温度データなどを生成し、NanopbでシリアライズしてHTTP POST送信します。
シリアライズのポイント
必ず SensorData_init_default で初期化を行い、pb_ostream_from_buffer で出力ストリームを作成します。
uint8_t buffer[128];
SensorData message = SensorData_init_default; // 必須:デフォルト値で初期化
// データのセット
snprintf(message.device_id, sizeof(message.device_id), "ESP32-S3-GRPC-LAB");
message.temperature = temperature;
message.humidity = humidity;
// シリアライズ(バイナリ化)
pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
if (!pb_encode(&stream, SensorData_fields, &message)) {
ESP_LOGE(TAG, "Encoding failed!");
}
5. 受信側:uv + Flask によるモダンな検証環境
検証サーバーは Python の高速パッケージマネージャー uv を使用します。sensor_pb2.py を通じて受信したバイナリをデコードします。
# Python用コード生成(依存関係をその場だけ解決)
uv run --with grpcio-tools python -m grpc_tools.protoc -I=main/proto --python_out=. main/proto/sensor.proto
# サーバー起動(Flask + protobuf)
uv run --with flask --with protobuf python server.py
6. まとめ:26バイトに凝縮されたデータ
Windowsやキュリティソフトなどのファイアウォール設定(ポート5000の解放)をクリアすれば、PC側で見事にデータが復元されます。
検証結果:
--- Received Binary (Size: 26 bytes) ---
Hex: 0a 11 45 53 50 33 32 2d 53 33 2d 47 52 50 43 2d 4c 41 42 15 66 66 c6 41 18 37
Device ID: ESP32-S3-GRPC-LAB
Temp: 24.8 °C
Humidity: 55 %
JSONでは60バイトを超えていたペイロードが、型情報を維持したままわずか 26バイト に凝縮されました。この効率性と開発体験こそが、gRPCをマイコンに導入する最大のメリットです。
次回はステージをクラウドへ移し、Azure Functions でこのバイナリを受け取ります!
📦 ソースコード
本プロジェクトの全ソースコードは GitHub で公開しています。
GitHub: esp32-grpc-lab-sample
