PicoシリーズのサブCPUに関するTips
PicoシリーズでUSB接続キーパッドを実装する一連の解説記事を、2025年3月で終了となったエンジニア向け情報サイトfabcrossに掲載しました。2025年4月から閲覧できなくなってしまうので、fabcrossの許可を得てブログ記事として4月1日ごろより再掲します。
このアーティクルでは、主題から外れるためにfabcrossでは取り上げなかったUSB接続キーパッドのディスプレイサブシステムに関して補足説明を行っておきます。ソースを見ての通り、ディスプレイサブシステムではPicoシリーズのサブCPUを使用し、メインCPUとのやり取りにC SDK標準のqueueとハードウェアFIFO(Multicore FIFO)を使用しています。それぞれを使用する意味や、ちょっとした注意点を簡単にまとめておきます。
USB接続キーボードのディスプレイサブシステム
キーボードではキースイッチの処理が最優先で、その他の処理はキースイッチの処理を阻害しないよう実装しなければなりません。付加機能であるディスプレイパネルの表示に時間を取られれば、キーの反応がその分だけ遅れ本末転倒です。そこで、USB接続キーパッドでは表示処理にサブCPUを利用することで、USB HIDを処理しているメインCPUの負担を軽減しています。結果、PIOによるキースキャン、サブCPUのディスプレイ表示、そしてメインCPUのUSB HIDという3つの構成要素が、それぞれ非同期的に動作している格好です。
使用しているディスプレイモジュールは、SSD1306を使用したモノクロOLEDモジュールです。ポピュラーなOLEDモジュールで、公式のPico Examplesにもサンプルがあります。USB接続キーパッドでは、公式サンプルのssd1306_i2c.cからmain()
を取り去りヘッダを付加することでライブラリとして流用しました(ss1306.cおよびsd1306.h)。
SSD1306では組み込み向けのディスプレイモジュールによくある図1のようなフレームバッファを採用しています。各バイトのビットはY軸方向のピクセルに対応します。X軸方向をカラム、Y軸方向をページと呼び、解像度128×32ドットのモジュールではフレームバッファが128カラム×4ページです。SSD1306を採用するモジュールの最大解像度は128×64ドットですが、その解像度のモジュールではページ数が8になっています。
図1:SSD1306のフレームバッファ
解像度128×32ドットのモジュールでフレームバッファのサイズは128バイト×4ページ=512バイトです。メインメモリ中に無理せずに確保できるサイズなので、USB接続キーパッドでは、display.cに512バイトの配列display_buffer
を用意し、その配列をサブCPUのメインループでSSD1306のフレームバッファに転送し続けています。display.cの該当部分を抜粋します。
uint8_t display_buffer[OLED_BUF_LEN]; // OLED_BUF_LEN=512
...
void display_main(void)
{
// I2C初期化
i2c_init(i2c_default, 800 * 1000); // 800kbps
...
// ディスプレイメインループ
while(true) {
...
// display_bufferをフレームバッファに丸ごと転送
render(display_buffer, &frame_area);
...
}
}
// ディスプレイサブシステム初期化
void display_init(void)
{
...
// display_main起動
multicore_launch_core1(display_main);
...
}
display_main()
がサブCPUで実行されるので、メインCPUがdisplay_buffer
を書き換えるだけで即座にディスプレイに書き換えが反映されます。このようにしてメインCPUを表示から開放しているわけです。
ディスプレイへの文字表示
SSD306はモノクロビットマップディスプレイですが、USB接続キーパッドの表示は文字が主体です。そこでフリーの8x8ドットフォントをdisplay_buffer
に転送して文字表示を行っています。
ちなみに、8x8ドットフォントは一般的なラスタデータで、図1のようなフレームバッファを横長方向に使うのであればフォントを横倒しに変換しないとなりません。あらかじめ横倒しに変換したフォントを使う手もありますが、フォントの方向を色々変えたい場合もあるだろうということで、フォントを描画している_d_putchar()
でフォントの縦横方向を変換しています。
という事情もあり文字列の描画にはそこそこのCPU時間が必要ですから、文字列描画もサブCPUに分担させたいところです。
サブCPUとメインCPUの間の情報の受け渡しを行うAPIとして、C SDKにはqueueとハードウェアFIFOが用意されています。queueは高レベルライブラリとして実装されています。一方のFIFOはRP2xxxシリーズが持つハードウェアで、リソースが限られていることに注意が必要になります。
世間的には(なぜか)FIFOのほうが利用例が多いようですが、ドキュメントにはSDKやリアルタイムOSがFIFO使うためqueueの利用を勧める的なことが書かれています。FIFOは32bit値の受け渡しができるだけなので、サブCPUに文字列を書かせるならqueueのほうが柔軟性があるでしょう。
queueの使い方は簡単です。
queue_t my_queue; // queue
data_t line, up; // なにかのデータ
// my_queueをsizeof(data_t)の要素数8で初期化する
queue_init(&my_queue, sizeof(data_t), 8);
// queueに放り込む(ノンブロッキング)
// line->queueにコピー
queue_try_add(&my_queue, &line);
// queueから出す(ブロッキング)
// queue->upにコピー
queue_remove_blocking(&my_queue, &up);
標準C SDKのqueueはスレッドセーフです。また、queueに出し入れするたびにコピーするのでメモリブロックのライフタイムを考慮する必要もありません。したがって、メインCPUとサブCPU間で比較的安全にデータのやり取りができます。
欠点は出し入れする度にメモリコピーが発生することで、メモリコピーのオーバーヘッドが問題になるような箇所では利用しづらいでしょう。ここで課題になっている文字列描画の場合、データとして少なくとも描画座標と文字列が必要です。これらのデータを格納したメモリブロックのポインタをqueueでサブCPUに渡せば、queueの出し入れに伴うメモリコピーはポインタだけで済むので、メモリコピーのオーバーヘッドは最小で済みます。
その場合、メインCPU側で確保したメモリブロックのライフタイムを考慮する必要があります。少なくともスタックは、関数から抜ければライフタイムが終わるので使えないでしょう。malloc()
でヒープに確保したメモリブロックは理想的ですが、メインCPU側でmalloc()
したメモリブロックをサブCPU側でfree()
することができません。したがって、サブCPU側からメモリブロックが不要になったことをメインCPU側に知らせる仕組みが必要です。
USB接続キーパッドの実装
そこでUSB接続キーパッドでは、表示すべき文字列をサブCPUに渡すのにqueueを利用し、描画が終わったらFIFOでメインCPUに通知することにしました。FIFOはなかなか便利で、FIFOにデータが入ったときに相手のCPUに割り込みを発生させることが出来ます。サブCPU側で文字列描画が終わったらFIFOにメモリブロックのアドレスを渡し、メインCPU側が割り込みの形でそれを受け取ることができるわけです。
display.hで次のような構造体などを定義しています。
// コマンド構造体
typedef struct {
uint16_t command;
void *data;
} display_cmd_t;
// 文字列データの構造体
typedef struct {
uint8_t col;
uint8_t row;
bool reverse;
char str[OLED_WIDTH/FONT_WIDTH + 1];
} putstr_t;
// コマンドの定義
#define DISPLAY_CMD_NONE 0x0000
#define DISPLAY_CMD_CLEAR 0x0001
#define DISPLAY_CMD_PUTSTR 0x0002
display_cmd_t
のメンバdata
は、command
に応じたデータを格納したポインタを格納します。文字列描画であればputstr_t
のポインタを渡すわけです。command
は将来的に拡張することが可能で、たとえばポリゴンの描画などを実装することもできるでしょう。
メインCPUから呼び出す文字描画関数display_putstr()
は次のような実装です。
bool display_putstr(char *str, uint8_t col_x, uint8_t col_y, bool reverse)
{
display_cmd_t cmd;
putstr_t *p;
cmd.command = DISPLAY_CMD_PUTSTR;
p = (putstr_t *)malloc(sizeof(putstr_t));
p->col = col_x; // 座標
p->row = col_y;
p->reverse = reverse;
strcpy(p->str, str); // 文字列
cmd.data = (void *)p;
return queue_try_add(&command_queue, &cmd);
}
このようにしてqueueに入れられたデータは、サブCPUdisplay_main()
のメインループで読み出され、command
に応じて処理されます。
inline void disposer(void *garbage)
{
// FIFOでメインCPUに値を渡す
multicore_fifo_push_blocking((uint32_t)garbage);
}
void display_main(void)
{
....
// ディスプレイメインループ
while(true) {
display_cmd_t cmd;
render(display_buffer, &frame_area);
// queueを読み取る
while(! queue_is_empty(&command_queue)) {
queue_remove_blocking(&command_queue, &cmd);
// commandに応じた処理を行う
switch(cmd.command) {
case DISPLAY_CMD_CLEAR:
_d_clear();
break;
case DISPLAY_CMD_PUTSTR:
// 文字列を描画
putstr_t *p = (putstr_t *)cmd.data;
_d_putstr(p->str, p->col, p->row, p->reverse);
// メモリが不要になったことをメインCPUに通知
disposer((void *)p);
break;
default:
break;
};
}
sleep_us(4*1000);
}
}
サブCPUで使い終わったメモリブロックのポインタはdisposer()
でFIFOを使ってメインCPUに戻されます。メインCPU側に割り込みが発生するので、そこでfree()
します。
// FIFO割り込み
void sio_irq_handler(void)
{
while(multicore_fifo_rvalid()) {
void *garbage = (void *)multicore_fifo_pop_blocking();
free(garbage);
}
// 割り込みクリア
multicore_fifo_clear_irq();
}
// ディスプレイサブシステム初期化
void display_init(void)
{
//queue初期化
queue_init(&command_queue, sizeof(display_cmd_t), 8);
// display_main起動
multicore_launch_core1(display_main);
// FIFO割り込みEnable
irq_set_exclusive_handler(SIO_FIFO_IRQ_NUM(0), sio_irq_handler);
irq_set_enabled(SIO_FIFO_IRQ_NUM(0), true);
}
SIO_FIFO_IRQ_NUM(num)
マクロはnum
に指定したCPU番号の割り込み番号を返します。ドキュメントにも書かれていますが、irq_set_exclusive_handler(SIO_FIFO_IRQ_NUM(0), handler)
とirq_set_enabled(SIO_FIFO_IRQ_NUM(0), true)
は、サブCPU起動後に呼び出す必要があるようです。
これでサブCPU側からFIFOに不要になったメモリブロックのアドレスが格納されると、メインCPU側でsio_irq_handler()
が呼び出され、無事にfree()
できるというわけです。少々込み入っているかもしれませんが、サブCPUを活用する参考にしてください。
Discussion