nanopbによりマイコンでProtocol Buffersを使用する方法


記事の概要

Protocol Buffersとは何か?

データ構造をGoogleの策定した形式に従ってシリアライズしたものをProtocol Buffersと呼びます。

このProtocol Buffersを使う利点は、様々な機器間で送受信するデータを共通のデータ構造で扱えることです。

例えば、サーバとアプリ、アプリとマイコン、マイコンとマイコンでそれぞれ別のデータ構造を用いて通信していては管理が煩雑になります。
共通のデータ構造を持ち、共通の形式でシリアライズされたProtocol Buffersを使えば、間違いが起きにくいというわけです。

Protocol Buffersの意義について、詳しくは以下の記事をご参照ください。

今さらProtocol Buffersと、手に馴染む道具の話

環境

本記事では以下の環境で作業しています。
本記事では以下の開発環境の構築方法については解説しません。

マイコンはSTMでも、ルネサスでも、他のどれでも同様に使えます。

  • OS
    • Windows 10
  • Python3
    • anaconda3
  • マイコン
    • Nordic nRF52832(ARM系)

nanopbのダウンロード

マイコンに組み込める小サイズなライブラリnanopbを使用します。
以下をダウンロードしてください。

protoファイルの作成

データ構造を定義します。これは自分の作成するアプリケーションの仕様に合わせて自由に作成できます。

今回は以下のmy_test.proptを作成しました。
3つのデータ構造体です。
これらは長さやid、そしてデータ配列から成ります。

syntax = "proto3";
import "nanopb.proto";

message TestOne {
  int32 length = 1;
  bytes first_data = 2 [(nanopb).max_size = 5, (nanopb).fixed_length = true];
}

message TestTwo {
  int32 length = 1;
  bytes second_data = 2 [(nanopb).max_size = 8, (nanopb).fixed_length = true];
}

message TestThree {
  int32 length = 1;
  int64 id = 2;
  bytes third_data = 3 [(nanopb).max_size = 16, (nanopb).fixed_length = true];
}

ここでデータ配列はあらかじめサイズを設定しました。 (nanopb).max_size で配列のサイズを、 (nanopb).fixed_length でサイズは固定であることを指定してあります。
他のデータ型の設定方法の詳細は以下をご参照ください。
https://jpa.kapsi.fi/nanopb/docs/concepts.html#data-types

また、protoファイルにおいて (nanopb) を使用するためには、以下の例のように import "nanopb.proto"; が必要になります。
これがないと、 Option "(nanopb)" unknown. というエラーメッセージが表示されます。

コンパイル

マイコンに組み込むファイルを自動生成します。
そのために、まずは以下のツールをインストールしてください。

python -m pip install protobuf grpcio-tools

anaconda3のコマンドプロンプトにおいて python ../../generator/nanopb_generator.py YOUR_FILE.proto を実行します。
階層の場所とファイル名は各位の環境に合わせて修正ください。

(base) D:\nanopb\examples\my_test>python ../../generator/nanopb_generator.py my_test.proto
Writing to my_test.pb.h and my_test.pb.c

成功すれば、*.pb.h と *.pb.c が生成されます。

マイコンに組み込む

今回はNordicのnRF52382を例に説明しますが、手順は他のマイコンでも同様です。

上で自動生成されたファイルmy_test.pb.h と my_test.pb.c、およびnanopbの階層にあるファイル、pb.h、pb_common.c、pb_common.h、pb_decode.c、pb_decode.h、pb_encode.c、pb_encode.hを自分のマイコン開発環境にコピーします。

今回は適当なサンプルプロジェクトの中に放り込んでみました。

エンコーダ

送信データをシリアライズします。

nanopbのexampleフォルダにあるサンプルプログラムのsimple.cを参照して以下を作成しました。

送受信するメッセージのデータは message1_buffer[] に格納します。
メッセージのデータサイズは message1_length に格納します。

    uint8_t message1_buffer[128];
    size_t message1_length;
    bool result;

    /* Encode our message */
    {
        /* Allocate space on the stack to store the message data. */
        TestOne message1 = TestOne_init_zero;
        TestTwo message2 = TestTwo_init_zero;
        TestThree message3 = TestThree_init_zero;

        /* Create a stream that will write to our buffer. */
        pb_ostream_t stream1 = pb_ostream_from_buffer(message1_buffer, sizeof(message1_buffer));

        /* Fill in the data */
        uint8_t sample_data1[5] = "Hello";
        message1.length = 11;
        for(int i=0; i<5; i++)
        {
            message1.first_data[i] = sample_data1[i];
        }

        /* Now we are ready to encode the message! */
        result = pb_encode(&stream1, TestOne_fields, &message1);
        message1_length = stream1.bytes_written;

        /* Then just check for any errors.. */
        if (!result)
        {
            printf("Encoding failed: %s\n", PB_GET_ERROR(&stream1));
            return 1;
        }
    }

メッセージ実体の作成

まずはデータを格納する実体を作成します。

        TestOne message1 = TestOne_init_zero;
        TestTwo message2 = TestTwo_init_zero;
        TestThree message3 = TestThree_init_zero;

データ型は自動生成したmy_test.pb.hに定義されています。
my_test.proptで設定した通りのデータ構造体が作成されているはずです。

my_test.pb.h
/* Struct definitions */
typedef struct _TestOne {
    int32_t length;
    pb_byte_t first_data[5];
} TestOne;

typedef struct _TestThree {
    int32_t length;
    int64_t id;
    pb_byte_t third_data[16];
} TestThree;

typedef struct _TestTwo {
    int32_t length;
    pb_byte_t second_data[8];
} TestTwo;

初期化定数の *_init_zero は自動生成したmy_test.pb.hに作成されています。
0で初期化する以外にも、初期値を設定する*__init_default も用意されています。

my_test.pb.h
/* Initializer values for message structs */
#define TestOne_init_default                     {0, {0}}
#define TestTwo_init_default                     {0, {0}}
#define TestThree_init_default                   {0, 0, {0}}
#define TestOne_init_zero                        {0, {0}}
#define TestTwo_init_zero                        {0, {0}}
#define TestThree_init_zero                      {0, 0, {0}}

送信用stereamの作成

stremを作成します。
message1_buffer[]を引数に設定することで、これにシリアライズしたデータが格納されるようになります。

        /* Create a stream that will write to our buffer. */
        pb_ostream_t stream1 = pb_ostream_from_buffer(message1_buffer, sizeof(message1_buffer));

送信データの準備

送信データを用意します。

        /* Fill in the data */
        uint8_t sample_data1[5] = "Hello";
        message1.length = 11;
        for(int i=0; i<5; i++)
        {
            message1.first_data[i] = sample_data1[i];
        }

送信データのシリアライズ

stream を用いてmessage1_buffer[]にシリアライズした送信データを格納します。

        /* Now we are ready to encode the message! */
        result = pb_encode(&stream1, TestOne_fields, &message1);
        message1_length = stream1.bytes_written;

以下のようにpb_encode の実行前後を比較することで、message1_buffer[]にシリアライズされたデータが格納されていることが分かります。


デコーダ

受信したデータをデシリアライズします。
このサンプルプログラムでは、先にエンコードしたデータを使用していますが、実際には送信元から受信したシリアライズ・データをmessage1_buffer[]に格納します。

nanopbのexampleフォルダにあるサンプルプログラムのsimple.cを参照して以下を作成しました。

    {
        /* Allocate space for the decoded message. */
        TestOne message1 = TestOne_init_zero;
        TestTwo message2 = TestTwo_init_zero;
        TestThree message3 = TestThree_init_zero;

        /* Create a stream that reads from the buffer. */
        pb_istream_t stream1 = pb_istream_from_buffer(message1_buffer, message1_length);

        /* Now we are ready to decode the message. */
        result = pb_decode(&stream1, TestOne_fields, &message1);

        /* Check for errors... */
        if (!result)
        {
            printf("Decoding failed: %s\n", PB_GET_ERROR(&stream1));
            return 1;
        }
    }

メッセージ実体の作成

まずはデータを格納する実体を作成します。
これはエンコーダと同様なので説明は省略します。

受信用straemの作成

streamを作成します。
受信データを格納したmessage1_buffer[]をstreamに渡すことで、デシリアライズの準備をします。

        /* Create a stream that reads from the buffer. */
        pb_istream_t stream1 = pb_istream_from_buffer(message1_buffer, message1_length);

受信データのデシリアライズ

streamを介して受信データを構造体に代入します。

        /* Now we are ready to decode the message. */
        result = pb_decode(&stream1, TestOne_fields, &message1);

自動的に構造体の定義通りにデータが格納されるので、バグが減らせて、コード作成も容易になります。

以下のようにpb_decode の実行前後を比較することで、構造体のmessage1にデシリアライズされたデータが自動的に格納されていることが分かります。


今回は構造体を1つしか試しませんでしたが、他の作成した2つについても同様のことができます。

まとめ

以上のようにnanopbを使用することで、簡単にProtocol Buffersをマイコンに導入できることが分かりました。

IoTが盛んになり様々な機器やサーバ同士を連結するようになったことで、送受信データの管理が複雑化しています。
データ管理を容易にするProtocol Buffersは、今後に使用する機会が増えていくと思われます。