はじめに
小型LCDを持つ組み込み機器では、画面のイベントハンドラからASICやFPGAのレジスタ操作を呼びたくなる場面がよくあります。
たとえば、Spinの値が変わったらレジスタへ書く、Buttonが押されたら設定を保存する、といった実装です。最初の1機種では短く書けますが、LCD、制御デバイス、通信方式が変わる機種展開では、画面コードまでハードウェア差分へ巻き込まれます。
私が実務で小型LCD向けUIを作ったときは、Widgetとイベントを共通化するだけでなく、LCD描画と装置機能の制御経路を分けました。
この記事では、その経験を一般化し、View、イベントハンドラ、機能サービス、ハードウェアアダプタへ分ける構成を示します。UI、ハードウェア制御、LCD描画の速度差と実機調整は、次回に独立して扱います。
この記事でいう「なんちゃってMVC」の範囲
特定のMVCフレームワークを移植したという意味ではありません。小さな組み込み環境で責務を分けた結果、MVCに似た境界になった、という程度の呼び方です。
本文とコードは、設計経験を説明するために新しく作成しました。実製品のレジスタ、通信コマンド、画面名、ソースコードは使用していません。
この記事は6本シリーズの5本目です。第2回・第3回でWidgetとイベントの土台を、第4回で物理キーを含めたUI設計を扱いました。型名はシリーズを通して共通で、第3回で置いたGuiDisplayPortが、ここで交換対象として本領を発揮します。
この記事でわかること
- UIイベントからレジスタを直接操作する問題
- 1画面の中を「小さなMVC」として整理する方法
- ハードウェア境界を、Ports and Adaptersに近い形で置く方法
-
GuiComboClass/GuiSpinClassのChange/Submit/Cancelを画面側へつなぐ書き方 - LCDやASIC/FPGAが変わる機種展開への備え方
- 実機なしでUIロジックを試験するための境界
先に結論
Widgetのイベントハンドラが扱うのは、レジスタ値ではなく、「通信速度を1段階上げる」「現在の編集を確定する」といった装置機能の言葉にします。
UIとハードウェアの間に小さなインターフェースを置けば、次の2つを別々に交換できます。
- LCDが変わる:
GuiDisplayPortを交換する - ASIC/FPGAや制御方法が変わる:
DeviceControlPortを交換する
2つの整理を混ぜない
先に用語を整理します。この記事では、性質の違う2つの整理を同時に行います。混ぜて説明されがちなので、分けて扱います。
| 小さなMVC | Ports and Adaptersに近い境界 | |
|---|---|---|
| 対象 | 1つの画面の内側 | UIライブラリとハードウェアの間 |
| 目的 | 表示・操作・値の責務を分ける | 実装を差し替え可能にする |
| 現れ方 |
GuiPage派生の構造体メンバ |
GuiDisplayPort、DeviceControlPort
|
| 交換単位 | 交換しない(画面ごとに書く) | 機種ごと、テストごとに交換する |
MVCは画面の中の整理、Ports and Adaptersは外との境界の置き方で、同じ概念ではありません。以降、前半で画面の中を、後半で外との境界を扱います。
レジスタへ直結したイベントハンドラ
最初は、次のようなコードでも動きます。
static void on_level_changed(GuiSpin *spin, int16_t candidate, void *user_data)
{
uint32_t reg = fpga_read(REG_SIGNAL_CONFIG);
(void)spin;
(void)user_data;
reg &= ~LEVEL_MASK;
reg |= encode_level(candidate);
fpga_write(REG_SIGNAL_CONFIG, reg);
}
しかし、この関数は多くの事情を知りすぎています。
- UI上の値
- レジスタのアドレス
- ビット配置
- 値のエンコード方法
- 読み出し変更書き戻しの手順
- FPGAが直接接続されているという構成
次機種で制御先が別のデバイスや別プロセスになれば、画面コードを書き換えることになります。
2本の経路を分ける
小型LCD機器には、大きく2本の経路があります。
表示の経路
Widgetの状態を、文字、カーソル、外字へ変換し、LCDへ描画する経路です。
Widget → GuiWidgetClassのshow → GuiDisplayPort → LCD Adapter → LCD
機能制御の経路
UI操作の意味を、装置の状態変更へ変換する経路です。
Widget → GuiComboClass/GuiSpinClassのchange・submit・cancel → 画面側ハンドラ → DeviceControlPort → Hardware Adapter → ASIC/FPGA
同じキー入力から始まっても、LCDへ何を描くかと、装置へ何を指示するかは別の責務です。2本の線は途中で交わりません。
画面の中を小さなMVCとして見る
1つの画面の内側を、次のように割り当てます。
| 役割 | この設計での担当 | 実体 |
|---|---|---|
| View | 画面部品と描画 |
GuiLabel、GuiButton、GuiCombo、GuiSpin
|
| Controller相当 | Widgetイベントを装置機能へ翻訳する |
on_change/on_submit/on_cancelハンドラ |
| Model相当 | 確定値、編集中の値、編集開始時の値 |
GuiCombo/GuiSpinのvalue/editing/original
|
厳密なMVCへ合わせる必要はありません。境界を置く目的は、変更理由の異なるコードを分けることです。
注目したいのは、Model相当をわざわざ別の構造体にしていない点です。第2回のGuiComboとGuiSpinは、最初から3つの値を持っています。
/* 第2回で定義済み */
struct GuiSpin {
GuiNamedItem named; /* 必ず先頭 */
int16_t value; /* 確定値 */
int16_t editing; /* 編集中の候補 */
int16_t original; /* 編集開始時のスナップショット */
int16_t min;
int16_t max;
int16_t step;
void (*on_change)(GuiSpin *spin, int16_t candidate, void *user_data);
void (*on_submit)(GuiSpin *spin, int16_t value, void *user_data);
void (*on_cancel)(GuiSpin *spin, int16_t original, void *user_data);
void *user_data;
};
小さな画面では、Modelを別の層へ切り出すより、Widgetが自分の3値を持つ方が読みやすく、RAMも増えません。「なんちゃって」と呼んでいるのは、この割り切りのことです。
Change、Submit、Cancelを分ける
値を変更するWidgetでは、「画面上の値が変わった」と「設定を確定した」は同じではありません。
| イベント | UI上の意味 | 装置側の意味 |
|---|---|---|
| Change | 候補値を動かした | 必要なら一時的にプレビュー |
| Submit | Enterで確定した | 確定値として反映、必要なら保存 |
| Cancel | 編集を取り消した | 編集開始前の値へ復元 |
たとえば映像や音声に関する値は、操作中に結果を見聞きできる方が調整しやすいことがあります。その場合はChangeで一時反映します。
一方、ネットワーク設定や再起動を伴う値は、Changeでは画面内だけを更新し、Submitで初めて装置へ反映する方が安全です。
この3つは、第2回のクラス関数表に最初から並んでいます。
/* 第2回で定義済み */
typedef struct {
GuiNamedItemClass parent_class; /* 必ず先頭 */
void (*change)(GuiSpin *spin, int16_t candidate);
void (*submit)(GuiSpin *spin);
void (*cancel)(GuiSpin *spin);
} GuiSpinClass;
GuiComboClassも同じ形で、changeがuint8_t candidateを受け取ります。
クラス側のchange/submit/cancelは、Widget共通の処理(editingの更新、GUI_FLAG_DIRTYの設定、originalの巻き戻し)を行い、最後に画面側のon_change/on_submit/on_cancelを呼びます。この2段構えが、Viewと画面ハンドラの分かれ目です。
上下キーからハンドラまで
編集中のSpinで上キーを押したとき、呼び出しは次の順にたどります。
-
gui_page_dispatch_key_down()が、選択中のWidgetへkey_downを渡す -
GuiSpinClassが継承したkey_downが、編集中なのでGUI_KEY_UPを消費する -
klass->change(spin, spin->editing + spin->step)を呼ぶ -
changeの実装がeditingを範囲内へ丸め、GUI_FLAG_DIRTYを立てる -
spin->on_change(spin, spin->editing, spin->user_data)を呼ぶ - 画面側のハンドラが
DeviceControlPortのプレビューを呼ぶ - 制御が戻り、
gui_page_repaint()がDIRTYなWidgetだけを描き直す
キーが消費されたので、フォーカスは動きません(第3回のディスパッチャの分岐です)。
UIから見えるハードウェア境界
ここからが後半、Ports and Adaptersに近い部分です。
公開用の例では、UIが使う装置機能を次のように定義します。GuiDisplayPortと同じ「関数表+context」の形にそろえます。
typedef struct {
bool (*load_rate)(void *context, uint8_t *value);
bool (*preview_rate)(void *context, uint8_t value);
bool (*commit_rate)(void *context, uint8_t value);
bool (*restore_rate)(void *context, uint8_t value);
} DeviceControlPortOps;
typedef struct {
const DeviceControlPortOps *ops;
void *context;
} DeviceControlPort;
UIは、対象がメモリマップドI/Oなのか、別プロセスへのメッセージなのかを知りません。
contextを渡す形にしておくと、グローバル変数へ固定せず、実機アダプタとテスト用モックを同じ関数形で扱えます。
イベントハンドラは翻訳に徹する
画面側のハンドラは、Widgetイベントを装置機能へ翻訳します。シグネチャは第2回のGuiCombo/GuiSpinのメンバどおりです。
typedef struct {
GuiPage page; /* 必ず先頭 */
GuiLabel title;
GuiCombo rate;
GuiSpin level;
GuiButton apply;
DeviceControlPort device;
bool allow_live_preview;
bool preview_applied;
} SettingsPage;
static void rate_on_change(GuiCombo *combo, uint8_t candidate, void *user_data)
{
SettingsPage *sp = (SettingsPage *)user_data;
(void)combo;
if (!sp->allow_live_preview) {
return;
}
if (sp->device.ops->preview_rate(sp->device.context, candidate)) {
sp->preview_applied = true;
}
}
static void rate_on_submit(GuiCombo *combo, uint8_t value, void *user_data)
{
SettingsPage *sp = (SettingsPage *)user_data;
(void)combo;
(void)sp->device.ops->commit_rate(sp->device.context, value);
sp->preview_applied = false;
}
static void rate_on_cancel(GuiCombo *combo, uint8_t original, void *user_data)
{
SettingsPage *sp = (SettingsPage *)user_data;
(void)combo;
if (sp->preview_applied) {
(void)sp->device.ops->restore_rate(sp->device.context, original);
sp->preview_applied = false;
}
}
ここにはレジスタのビット位置も、IPCのメッセージ番号もありません。user_dataに画面自身を渡しているので、グローバル変数も要りません。
Cancelで戻す範囲を決める
取消処理で戻す対象は、画面表示だけとは限りません。
- Widgetの表示値(
editingをoriginalへ戻す。これはクラス側のcancelが行う) - 一時反映したハードウェア状態(画面側の
on_cancelが行う) - 複数項目の組み合わせ
ハードウェアへプレビュー反映した後に画面値だけ戻すと、表示と実機が食い違います。
反対に、まだハードウェアへ反映していない値に対して復元I/Oを行うのも無駄です。preview_appliedのような状態を持ち、必要な場合だけ復元します。
Pageの表示前イベントで状態を読み込む
画面を開くたびに、前回表示時の値をそのまま使えるとは限りません。別の操作経路や装置側の状態変化があるためです。
第3回で用意したGuiPageClass.before_showからDeviceControlPortを呼び、最初の描画前に現在値を読み込みます。
static void settings_before_show(GuiPage *page)
{
SettingsPage *sp = (SettingsPage *)page;
uint8_t current;
if (sp->device.ops->load_rate(sp->device.context, ¤t)) {
sp->rate.value = current;
sp->rate.editing = current;
sp->rate.original = current;
}
sp->preview_applied = false;
}
value、editing、originalを同じ値へそろえてから描画することで、表示、編集候補、取消時の戻り先が同じ時点から始まります。
画面固有のGuiPageClassはstatic constで定義し、before_showだけを差し替えます。クラス関数表をROMへ置くための初期化子は、公開用サンプルのGUI_PAGE_WIDGET_CLASS_INITIALIZERへまとめています。第2回で示した二重の先頭埋め込みにより、GuiPage *から画面固有のSettingsPage *へ戻せます。
ハードウェアアダプタへ閉じ込めるもの
次の情報は、UI側へ漏らさずアダプタ内部へ閉じ込めます。
- レジスタアドレスとビット配置
- エンディアン
- 読み出し変更書き戻し
- デバイスファイル
- ioctlやIPCメッセージ
- タイムアウトとリトライ
- 機種別の値変換
- 排他制御
UIへ返すエラーも、必要に応じて「読込失敗」「範囲外」「装置応答なし」程度の論理エラーへ変換します。
LCDが変わる場合
LCDの差はGuiDisplayPortの下へ閉じ込めます。第3回と同じ定義です。
/* 第3回で定義済み。シリーズ共通。 */
typedef struct {
void (*clear)(void *context);
void (*draw_text)(void *context, uint8_t x, uint8_t y, const char *text);
void (*draw_icon)(void *context, uint8_t x, uint8_t y, GuiIcon icon);
void (*flush)(void *context);
} GuiDisplayPortOps;
typedef struct {
const GuiDisplayPortOps *ops;
void *context;
} GuiDisplayPort;
キャラクタLCDから小型グラフィックLCDへ変わる場合、文字セル座標を完全に共通化できるとは限りません。それでもWidgetイベント、編集モデル、装置制御ポートをそのまま残せれば、変更範囲を表示側へ寄せられます。
ASICやFPGAが変わる場合
機種AはメモリマップドI/O、機種Bは別CPUへのメッセージで制御する、とします。
両方が同じDeviceControlPortOpsを実装すれば、画面側は共通です。
完全に無変更で機種展開できるとは限りません。装置機能そのものが変われば、画面とインターフェースも変わります。
狙いは差分をゼロにすることではなく、差分がどの層に属するかを明確にすることです。
モックでUIを先に試験する
2つのポートを差し替えられると、PC上で次の確認ができます。
-
GuiDisplayPortを20文字×4行のテキストバッファへ描画する -
DeviceControlPortをメモリ上のモックへ差し替える - キーイベント列を順番に投入する
- Changeでプレビューが呼ばれたか確認する
- Submitで確定値が残るか確認する
- Cancelで元の値とハードウェア状態へ戻るか確認する
実機I/Oが必要な試験は残りますが、フォーカス移動や編集状態のバグを、実機完成前に見つけやすくなります。
シリーズの公開用サンプルは、まさにこの構成でPC上のgccだけを使って動きます。画面出力はテキストで、>がカーソル、*が編集中、#が操作不可を表します。
+--------------------+
|Device Settings |
|>Rate:115k [Apply] |
| Lvl:5 Port:P1 |
| |
+--------------------+
分けすぎないための基準
小さな機器へWebアプリと同じ数の層を持ち込む必要はありません。
次のどれかに当てはまる境界だけを置きます。
- 機種によって実装が変わる
- 実機なしで試験したい
- 変更と確定を分ける必要がある
- 画面とハードウェアで担当者や変更理由が異なる
- 1つの機能を複数の操作経路から使う
1画面、1機種、使い捨ての評価コードなら、直接呼び出しの方が読みやすい場合もあります。
メリット
- LCD変更と制御デバイス変更を別々に扱える
- UIコードへレジスタ定義が広がらない
- Change/Submit/Cancelの意味が揃う
- 実機なしで画面ロジックを試験しやすい
- 別の操作経路から同じ装置機能を再利用できる
- 機種差分をアダプタへ集めやすい
デメリット
- 小規模な機器では関数ポインタと型が増える
- どこまでを装置機能として抽象化するか判断が必要になる
- 非同期処理では完了通知とエラー表示の設計が増える
- アダプタが単なる巨大ラッパーになると効果が薄い
- ハードウェア差が大きすぎる場合、共通インターフェースが不自然になる
まとめ
小型LCD向けUIライブラリの役割は、画面部品を再利用することだけではありません。
- 画面の中は、View(Widget)、Controller相当(ハンドラ)、Model相当(
value/editing/original)の小さなMVCとして見る - 外との境界は、
GuiDisplayPortとDeviceControlPortという交換可能なポートとして置く - UIイベントを装置機能の言葉へ翻訳する
- レジスタやIPCを
Hardware Adapterへ閉じ込める - Change、Submit、Cancelで一時変更と確定を分ける
-
before_showで装置の現在状態を読み込む
特定ハードウェアと密になりやすい組み込み機器でも、UIと制御の間へ小さな境界を置くことで、機種展開時の変更を局所化できます。
私はこれを厳密なMVCとは呼びません。ただ、Viewと装置制御を直結させず、イベントハンドラを翻訳役にした設計は、小型機器でも十分に効果がありました。
次回は、UI、専用ハードウェア、LCDの速度差をどう操作感として整えたかを扱います。長押し加速、キーを離したときの確定、LCD画面切り替えの間合いを、実機で調整した経験です。
参考資料
- Hexagonal Architecture — Alistair Cockburn — Ports and Adaptersの提唱者本人による解説。UIとハードウェアを同じ「外側」として扱い、ポートで差し替える考え方
- Models–Views–Controllers(1979年)— Trygve Reenskaug — MVCという言葉を定義した最初の技術メモ。Zenodoに著者名つきで保存されており、CC BY 4.0で全文を読めます

