「レベル42」はどこでビットになるのか
第1回では、2行LCDしかない装置へWeb UIを足す話を書きました。第2回と第3回ではブラウザ側の状態表示、第4回ではPHPのexec()から先へ渡す境界を扱いました。ここまでは、いわば装置の手前の話です。
この回では境界の向こう側へ入ります。Web UIから来た「レベル42」という数字が、device_ctlの中でどんな姿に変わりながらデバイスへ届くのか。そして、その変換をどこに置くかで、あとから来る変更がどこまで波及するのかを見ます。
例示用の割り当てです
この記事に出てくるレジスタ名、アドレス、ビット配置は、説明のために新しく決めたもので、実製品のものではありません。
公開サンプルは、WebからC言語の境界までを動かすため、装置状態を/tmpのファイルで模擬しています。本記事のファームウェア層、HAL層、SPI転送、LEVEL/CTRLレジスタは、その境界の先を説明するための設計例です。公開サンプルへ実装済みの機能ではありません。
この記事で分かること
- ユーザーが入力した
42が、層を降りるたびにどんな表記へ変わるか - 単位の変換(
%→ デバイス値)をどの層に置くべきか、そしてなぜHALに置かないか - レジスタが2本あるとき、「書く順序」を持つのはどの層か
- 境界を守った場合と崩した場合で、ひとつの変更がどこまで波及するか
- 「層が多いほど良い」ではないこと。差の本質は層数ではなく、確認に必要な範囲であること
「42」が姿を変えていく
上から順に、同じひとつの操作が別の言葉へ翻訳されていきます。
| 層 | そこでの「レベル42」 | その層が知っていること |
|---|---|---|
| Web UI / 2行LCD | 42 |
人が入力した数。装置の内部表現は知らない |
| アプリケーション層 | set-level 42 |
装置機能の言葉。範囲検証とログ |
| ファームウェア層 | 42% → 0x6B |
機能をデバイス仕様へ変換する。設定順序とリトライ |
| HAL層 | hal_write(0x12, 0x6B) |
レジスタ1本の読み書き。バスの差を吸収 |
| デバイスドライバ / SPI | SPI転送 2バイト | spidev、CS制御、クロック、転送 |
| デバイス | LEVEL = 0110 1011 |
受け取ったビットがそのまま回路の状態 |
換算はこれだけです。フルスケール255に対する42%なので、
42 × 255 / 100 = 107.1 → 107 = 0x6B = 0b0110_1011
大した計算ではありません。問題は、この1行をどこに書くかです。
なぜ換算をファームウェア層に置くのか
候補は3つあります。アプリケーション層、ファームウェア層、HAL層。
アプリケーション層に置くと、「レベルとは0〜100の値である」という装置機能の言葉と、「フルスケールは255である」というデバイスの都合が同じ関数の中で混ざります。デバイスを10bit品へ替えたとき、機能の記述を触ることになります。
HAL層に置くと、もっと具合が悪くなります。HALの仕事は「レジスタ1本を読み書きする」ことだけであってほしい。ここに%という単位の知識が入ると、hal_write()は「アドレスと値」を受ける汎用の関数ではなく、「レベルという概念を知っている関数」になります。次にゲイン設定を足したくなったとき、HALにゲインの単位も足すことになり、レジスタの数だけHALが太っていきます。
残るのがファームウェア層です。ここは「機能をデバイス仕様へ変換する」のが仕事だと最初から決めてある層なので、%もフルスケールも、置き場所として自然です。デバイスを替えるときに書き換えるのはこの層だけになります。
/* ファームウェア層:機能の言葉をデバイス仕様へ変換する */
#define FS_LEVEL 255 /* デバイスのフルスケール */
int fw_set_level(int percent)
{
unsigned char raw;
if (percent < 0 || percent > 100) {
return -1;
}
raw = (unsigned char)((percent * FS_LEVEL) / 100);
/* ここから下は「アドレスと値」しか知らない */
return hal_write(REG_LEVEL, raw);
}
FS_LEVELがこの層にしか現れない、というのが判定基準になります。grepしてこの定数がHALやアプリ側にも出てきたら、境界が漏れています。
レジスタが2本あるとき、順序を持つのは誰か
実際のデバイスは、値を1本書けば動くとは限りません。この記事の例では2本あることにします。
| アドレス | 名前 | 役割 |
|---|---|---|
0x12 |
LEVEL | レベル値そのもの(8bit) |
0x10 |
CTRL | 有効化、モード、エラークリア |
CTRLのビット割り当てはこうです。
| ビット | 名前 | 例 | 意味 |
|---|---|---|---|
| 7 | EN | 1 |
有効 |
| 6:5 | MODE | 01 |
動作モード |
| 4 | ERR_CLR | 0 |
書くとエラーフラグをクリア |
| 3:0 | 予約 | 0000 |
0を書く |
ここで「LEVELを書いてからCTRLでENを立てる」のか、「ENを立ててからLEVELを書く」のかは、デバイスの仕様で決まります。中間状態が回路に出てしまう順序なら、前者しか選べません。
この順序の知識をどこに置くかが、この記事のもうひとつの分かれ目です。
- アプリケーション層に置くと、
set-levelという機能の記述の中にレジスタ2本の話が出てきます - HAL層に置くと、「1本の読み書き」という約束が壊れます
- ファームウェア層に置けば、
fw_set_level()の内側に閉じます
リトライも同じ場所です。書いたあとに読み返して一致を確認する、失敗したら決まった回数だけやり直す、という手順は、デバイス仕様に属する知識なので、ファームウェア層が持ちます。アプリケーション層は「成功したか失敗したか」だけを受け取ります。
予約ビットに0を書く、という約束も同様に、この層から外へ出しません。上位が0b1010_0000のような生の値を組み立て始めた時点で、ビット配置の知識が漏れています。
境界を守った場合と、崩した場合
デバイスのリビジョン更新で、LEVELが8bitから12bitになり、CTRLのビット配置も変わったとします。ただし、上位へ見せる装置機能は変わりません。画面では引き続き0〜100%を扱い、操作名もstart、stop、set-levelのままです。
境界を守っている側では、直すのはFS_LEVEL、レジスタ値の組み立て、書き順を持つファームウェア層です。変更を知るのは1層に閉じます。HALのhal_write(addr, value)という約束と、Web UIやPHPが使う装置機能の言葉は変えません。
崩している側、つまりブラウザのJavaScriptが直接ビット列を組み立て、PHPがレジスタアドレスやマスクを持ち、HALにも同じ知識が漏れている構成では、レジスタ仕様を知る場所が3層に散ります。Web UI/JS、PHP、HALです。
ここは正直に書いておきたいのですが、「5箇所」とは書きません。ファームウェア層は崩した側ではPHPが兼ねていて層として存在していないので、独立に数えられません。SPIドライバは、値が何であれ2バイト転送することに変わりがないので、この変更では触りません。数を大きく見せると、かえって話が疑わしくなります。3対1で十分に差が出ています。
本当の差は、層の数ではない
1対3という数え方は分かりやすいのですが、実務で効いてくるのはむしろ確認に必要な範囲のほうです。
境界を守っている側で換算を直したときは、Web UIとPHPを起動せず、まずdevice_ctl以下だけで変更を確認できます。
$ ./device_ctl set-level 42
{"ok":true,"level":42,"running":true}
最初の切り分けに、ブラウザもPHPもlighttpdも要りません。ターミナルから下位層を確認したあと、必要な結合範囲だけを広げられます。
崩している側では、同じ変更を確認するのに、ブラウザを開き、PHPを通し、Cを動かす必要があります。3つの実行環境を同時に立ち上げないと、直したかどうかが分からない。デバッグの1周が数十秒から数分になり、原因の切り分けも「どこで値が化けたか」から始まります。
変更のたびに払うのはこのコストです。修正箇所が3つあることより、確認のたびにスタック全体が要ることのほうが、日々の作業を重くします。
バスが変わったらどうなるか
もうひとつ、境界の効き目が出る変更があります。SPIで繋がっていたデバイスを、次の機種ではI2Cで繋ぐ、というものです。
ここでは、デバイスの論理的なレジスタ仕様は同じまま、接続バスだけがSPIからI2Cへ変わる場合を考えます。HAL層の役目が「レジスタ1本の読み書きへ落とす。バスの差をここで吸収する」と決まっていれば、hal_write(addr, value)という呼び出しの形は変わりません。差し替わるのはHALの実装だけで、ファームウェア層から上はそのまま使えます。デバイス自体も別品種へ変わり、レジスタ仕様まで変わるなら、前節のとおりファームウェア層も変更対象です。
逆に、ファームウェア層がioctl(SPI_IOC_MESSAGE(1), ...)を直接呼んでいると、バスの変更がレベル設定の記述まで届いてしまいます。バスの都合と機能の都合は、変わるタイミングが違うので、分けておく価値があります。
この記事に無いもの
- 実製品のレジスタ名、アドレス、ビット配置。すべて例示用に決めた値です
- 実測したタイミングや転送レート。測っていないので書きません
- 割り込みを使う設計。ここでは同期的な読み書きだけを扱いました
- 複数プロセスから同時にデバイスを触る場合の排他。これは第4回の
flockの話に寄せています
まとめ
- 同じ「42」が、層を降りるたびに
set-level 42→0x6B→hal_write(0x12, 0x6B)→ SPI 2バイト →LEVEL = 0110 1011と姿を変える -
%からデバイス値への換算はファームウェア層に置く。アプリ層に置くと機能の記述にデバイスの都合が混ざり、HALに置くとHALがレジスタの数だけ太る - フルスケール、レジスタアドレス、ビット配置がファームウェア層に閉じていることが、境界を守れている印になる
- レジスタを書く順序、リトライ、予約ビットの扱いは、すべてファームウェア層の内側に閉じる
- 境界を崩すと、レジスタ仕様を知る場所が3層に散る。ただし「5層」と盛らない。存在しない層は数えない
- 効いてくるのは修正箇所の数より、確認に必要な範囲。守っていれば
device_ctlの単体実行で済み、崩していればブラウザ・PHP・Cの3つが要る - バスがSPIからI2Cへ変わっても、HALの約束が保たれていれば上の層は動かさなくてよい
2行LCDしかない装置にWeb UIを足す、という小さな話から始めて、ブラウザの状態表示、exec()の境界、そして装置制御層の内側まで来ました。全体を通してやっていたことはひとつで、同じ操作を表す言葉を、層ごとにひとつだけにするということです。境界が一本に保たれていれば、変更を知る層を限定でき、確認も小さな範囲から始められます。
関連記事
シリーズで扱ったサンプルコード一式はGitHubに公開しています。→ embedded-web-control-demo


