STM32 OS のない環境での非同期プログラミング

前回の記事に書いたように、I2C 制御の OLED ディスプレイをモジュールに使ってみようと検討を始めています。しかし、画面の更新は目で見てわかるほど遅いです。取り掛かりとしてライブラリ stm32-ssd1306 を使っていますが、そのコードを読む限り SSD1306 は

  • 画面はピクセルでできている。128 x 64 なので 8192 画素。これは 1024 = 1K バイト分のデータ
  • 全画面分のデータは 1024 バイト だが、画面データは 128 バイトずつ 8 ページに分割されている
  • 画面の更新はページごとに行う

つまり全画面を更新する場合ページの書き込みを 8 回行う必要があります。必要なページだけ更新するようにした場合もっと速くできるかもしれませんが、ライブラリはシンプルに作られておりそういった機能はありません。そういう最適化は必要に応じて後で試みるとして、基本的な話、全ページを更新するのにどれぐらい時間がかかるでしょうか?

これを測定したのが以下の図です。描画の直前にデバッグピンを立ち上げ、描画完了時にピンを L に落としたものを観測しました。チャネル 0 と 1 が I2C 通信、チャネル 2 がデバッグピンです。結果、描画には 11 ms 程の時間がかかることがわかりました。ライブラリは同期処理の API だけ用意されているので描画している 11ms の間コントローラを占有することになります。11ms は楽器にとって大変に長い時間なのでこれはいけません。楽器用途に使うにはライブラリの改変が必要です。

画面更新の関数はとてもわかりやすくて以下のようになっています。

// Send a byte to the command register
void ssd1306_WriteCommand(uint8_t byte) {
    HAL_I2C_Mem_Write(&SSD1306_I2C_PORT, SSD1306_I2C_ADDR, 0x00, 1, &byte, 1, HAL_MAX_DELAY);
}

// Send data
void ssd1306_WriteData(uint8_t* buffer, size_t buff_size) {
    HAL_I2C_Mem_Write(&SSD1306_I2C_PORT, SSD1306_I2C_ADDR, 0x40, 1, buffer, buff_size, HAL_MAX_DELAY);
}

/* Write the screenbuffer with changed to the screen */
void ssd1306_UpdateScreen(void) {
    // Write data to each page of RAM. Number of pages
    // depends on the screen height:
    //
    //  * 32px   ==  4 pages
    //  * 64px   ==  8 pages
    //  * 128px  ==  16 pages
    for (uint8_t i = 0; i < SSD1306_HEIGHT / 8; i++) {
        ssd1306_WriteCommand(0xB0 + i); // Set the current RAM page address.
        ssd1306_WriteCommand(0x00 + SSD1306_X_OFFSET_LOWER);
        ssd1306_WriteCommand(0x10 + SSD1306_X_OFFSET_UPPER);
        ssd1306_WriteData(&SSD1306_Buffer[SSD1306_WIDTH*i],SSD1306_WIDTH);
    }
}
C

ページごとに1バイトのコマンドを 3 回送り、そのあと 1 ページ分のデータを送っています。I2C のデータ送付には同期関数版が使われていて、これは最速のデータ送信ができる代わりに送信中はコントローラを占有してしまいます。この「待ち」の間、他の処理を行うためにコントローラを明け渡したいところです。

これを行うには、まず I2C 送信は同期関数版ではなく DMA 版を使うのが良いと思います。DMA なら、データをメモリに置いた後はペリフェラルが送信処理をハードウェア的にやってくれるので、コントローラの占有時間は最短になるはずです。そして DMA 版の送信関数は非同期版なので、例えばこの使用例のように一回の I2C 通信が完了次第すぐに次の通信を開始したい場合適切な送信完了処理が必要になります。完了処理は複雑かつ頻繁に起こるので、これを組織的に行える非同期処理実行の仕組みが必要になってきました。

非同期実行にはコルーチンが使えるとコーディングが楽でありがたいので、まずは C++20 コルーチンが頭に浮かびましたが、正月を潰して C++20 コルーチンの勉強をしたところ、どうも中でヒープを使うようで組み込みに使うには心配、しかも STM32 用の GCC では動作がどうも不安定で、コンパイラのバグでは?と疑いたくなる誤動作が連発、一旦 C++20 コルーチンを使う優先度を下げました。

ということで、次に伝統的なイベントループを使う検討をしました。以前から仕事で libeventSPDK などイベントループライブラリを多用してきましたが、どれも OS がなくメモリも小さいマイクロコントローラで使うには大規模すぎます。もうすこしコンパクトで必要最低限でも十分なライブラリはないか探したらありました。André Medeiros さんの書いた uevloop

https://github.com/andsmedeiros/uevloop

自分で書くことにならずに済んで助かりました。コードを読んだところシンプルで必要十分な感じです。OS 機能を全く必要としないのが素晴らしいです。イベントハンドラ、タイマ、モニタなど使いそうな機能はすべてそろっています。promise も付いていて助かりますが、これには少しバグがあるようで使える機能は限定的でした。

このライブラリを使って、SSD1306 の画面更新の実行を非同期化しました。結構複雑で二日かかってしまいました。この AI 時代に我ながらのんびりしてます。

非同期処理にした結果、実行中にどれぐらいコントローラに処理を返せるか測定しました。以下の図は、非同期処理実行のためにコントローラを占有している間だけデバッグピンを立ち上げてそれを観測したものです。0, 1 チャネルが I2C で 2 チャネルがデバッグピンです。8ページあるので I2C が 8 回データ送信で忙しくなる様が見て取れますが、送信中はコントローラはフリーで他の処理に使うことができます。結局全体で 14 ms ほどかかっています。DMA ではどうしても直接送信より遅延が出がちなので多少は仕方がないところですが、11ms が 14ms なら十分でしょう。非同期処理のオーバーヘッドによる遅延も無視できる程度だと思われます。

全体的にはこの方針で良い感じですが、最長の占有時間がどれぐらいになるかもまだ気になります。ということで、非同期処理の実行中の部分を拡大してみると…

150μS 前後ぐらい占有するようです。これは 0.15ms で、もう一段短くなってほしいところです。150μS の占有をしている間行っているのは 1 バイトのコマンド送信ですから少しかかりすぎな印象です。DMA 送信関数の中で何かの待ちが発生しているのかもしれません。さらに潜って調査したほうがよさそうです。

あともう一点気になるのが、イベントループを使った非同期処理のコーディングは絶望的に煩雑だということ。これは何とかならないかと思案しています。UpdateScreen のオリジナルのコードは上に書きましたがコメントを除くと 8 行。一方、今回書いた非同期版は以下のような感じで信じられないほど圧倒的に長くなってしまいます。この調子でアプリケーション全体を書くとなると、気が遠くなってしまいます。

typedef struct UpdateScreenContext {
  uel_promise_t *parent_promise;
  uel_promise_t *page_promise;
  uel_event_t *completion_observer;
  uel_closure_t completion_task;
  uint8_t in_use;
  uint8_t num_pages;
  uint8_t current_page;
  uint8_t command;
} update_screen_context_t;

static update_screen_context_t update_screen_context;
volatile unsigned int i2c_completed;

void ssd1306_RequestWriteCommand(uint8_t *byte) {
  if (HAL_I2C_Mem_Write_DMA(&SSD1306_I2C_PORT, SSD1306_I2C_ADDR, 0x00, 1, byte, 1) != HAL_OK) {
    Error_Handler();
  }
}

void ssd1306_RequestWriteData(uint8_t* buffer, size_t buff_size) {
    if (HAL_I2C_Mem_Write_DMA(&SSD1306_I2C_PORT, SSD1306_I2C_ADDR, 0x40, 1, buffer, buff_size) != HAL_OK) {
      Error_Handler();
    }
}

void *ssd1306_CompleteI2CTransmit(void *context, void *params) {
  uel_promise_t *promise = (uel_promise_t *)context;
  uel_promise_resolve(promise, NULL);
  return NULL;
}

static void *InitiatePageUpdate(void *context, void *params);

static void *FinishPageUpdate(void *context, void *params) {
  update_screen_context_t *ctx = (update_screen_context_t *)context;
  uel_promise_t *promise = (uel_promise_t *)params;

  ++ctx->current_page;
  if (ctx->current_page < ctx->num_pages) {
    // repeat the page update
    uel_promise_t *next_page_promise = uel_promise_create(&store, uel_closure_create(InitiatePageUpdate, ctx));
    uel_promise_then(next_page_promise, uel_closure_create(FinishPageUpdate, ctx));
  } else {
    // done all pages
    uel_promise_resolve(ctx->parent_promise, NULL);
  }

  uel_closure_t destroyer = uel_promise_destroyer(promise);
  uel_closure_invoke(&destroyer, NULL);
  return NULL;
}

static void *InitiateCommandStage(void *context, void *params, uint8_t command) {
  update_screen_context_t *ctx = (update_screen_context_t *)context;
  uel_promise_t *promise = (uel_promise_t *)params;

  ctx->command = command;
  ssd1306_RequestWriteCommand(&ctx->command);

  ctx->completion_task = uel_closure_create(ssd1306_CompleteI2CTransmit, promise);
  ctx->completion_observer = uel_evloop_observe(&my_app.event_loop, &i2c_completed, &ctx->completion_task);

  return NULL;
}

static void *TerminateCommandStage(void *context, void *params,
                                   void *(*next_initiator)(void*, void*), void *(*next_terminator)(void*, void*)) {
  update_screen_context_t *ctx = (update_screen_context_t *)context;
  uel_promise_t *promise = (uel_promise_t *)params;

  uel_event_observer_cancel(ctx->completion_observer);
  ctx->completion_observer = NULL;
  i2c_completed = 0;

  uel_promise_t *next_promise = uel_promise_create(&store, uel_closure_create(next_initiator, ctx));
  uel_promise_then(next_promise, uel_closure_create(next_terminator, ctx));

  uel_closure_t destroyer = uel_promise_destroyer(promise);
  uel_closure_invoke(&destroyer, NULL);

  return NULL;
}


static void *InitiateDataStage(void *context, void *params) {
  update_screen_context_t *ctx = (update_screen_context_t *)context;
  uel_promise_t *promise = (uel_promise_t *)params;

  ssd1306_RequestWriteData(&SSD1306_Buffer[SSD1306_WIDTH * ctx->current_page], SSD1306_WIDTH);
  ctx->completion_task = uel_closure_create(ssd1306_CompleteI2CTransmit, promise);
  ctx->completion_observer = uel_evloop_observe(&my_app.event_loop, &i2c_completed, &ctx->completion_task);

  return NULL;
}

static void *TerminateDataStage(void *context, void *params) {
  update_screen_context_t *ctx = (update_screen_context_t *)context;
  uel_promise_t *promise = (uel_promise_t *)params;

  uel_event_observer_cancel(ctx->completion_observer);
  ctx->completion_observer = NULL;
  i2c_completed = 0;

  uel_promise_resolve(ctx->page_promise, NULL);

  uel_closure_t destroyer = uel_promise_destroyer(promise);
  uel_closure_invoke(&destroyer, NULL);

  return NULL;
}

static void *InitiateCommand3Stage(void *context, void *params) {
  return InitiateCommandStage(context, params, 0x10 + SSD1306_X_OFFSET_UPPER);
}

static void *TerminateCommand3Stage(void *context, void *params) {
  return TerminateCommandStage(context, params, InitiateDataStage, TerminateDataStage);
}

static void *InitiateCommand2Stage(void *context, void *params) {
  return InitiateCommandStage(context, params, 0x10 + SSD1306_X_OFFSET_LOWER);
}

static void *TerminateCommand2Stage(void *context, void *params) {
  return TerminateCommandStage(context, params, InitiateCommand3Stage, TerminateCommand3Stage);
}

static void *InitiateCommand1Stage(void *context, void *params) {
  update_screen_context_t *ctx = (update_screen_context_t *)context;
  uel_promise_t *promise = (uel_promise_t *)params;
  return InitiateCommandStage(ctx, promise, 0xB0 + ctx->current_page);
}

static void *TerminateCommand1Stage(void *context, void *params) {
  return TerminateCommandStage(context, params, InitiateCommand2Stage, TerminateCommand2Stage);
}

static void *InitiatePageUpdate(void *context, void *params) {
  update_screen_context_t *ctx = (update_screen_context_t *)context;
  uel_promise_t *page_promise = (uel_promise_t *)params;
  ctx->page_promise = page_promise;

  uel_promise_t *command1_promise = uel_promise_create(&store, uel_closure_create(InitiateCommand1Stage, ctx));
  uel_promise_then(command1_promise, uel_closure_create(TerminateCommand1Stage, ctx));

  return NULL;
}

static void *InitiateUpdateScreen(void *context, void *params) {
  update_screen_context_t *ctx = (update_screen_context_t *)context;
  uel_promise_t *promise = (uel_promise_t *)params;
  ctx->in_use = 1;
  // Write data to each page of RAM. Number of pages
  // depends on the screen height:
  //
  //  * 32px   ==  4 pages
  //  * 64px   ==  8 pages
  //  * 128px  ==  16 pages
  ctx->num_pages = SSD1306_HEIGHT / 8;
  ctx->current_page = 0;
  ctx->parent_promise = promise;
  uel_promise_t *page_promise = uel_promise_create(&store, uel_closure_create(InitiatePageUpdate, ctx));
  uel_promise_then(page_promise, uel_closure_create(FinishPageUpdate, ctx));
  return NULL;
}

/* Write the screenbuffer with changed to the screen asynchronously */
uel_promise_t *ssd1306_UpdateScreenAsync(void) {
  if (update_screen_context.in_use) {
    Error_Handler();
  }
  uel_promise_t *promise = uel_promise_create(&store, uel_closure_create(InitiateUpdateScreen, &update_screen_context));
  return promise;
}
C

Comments

No comments yet. Why don’t you start the discussion?

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください