隔離
Lambda C のアクターは、そのチップにある最低の特権で走ります。カーネルへは、たった 1 つの syscall ゲートを通ってしか届きません。そして、別のアクターのメモリには触れられません。
| ターゲット | 特権降格 | ゲート | アクター間の隔離 |
|---|---|---|---|
| x86_64 | iretq で ring 3 へ | int 0x80 | アクター毎のアドレス空間 |
| riscv64 | sret で U-mode へ | ecall | アクター毎のアドレス空間(Sv39) |
| aarch64 | eret で EL0 へ | svc | アクター毎のアドレス空間 |
| RV32(MMU 無し) | U-mode へ | ecall | アクター毎の PMP 領域 |
| ARMv6-M(MMU 無し) | 非特権 Thread モードへ | svc | アクター毎の MPU 領域 ¹ |
¹ Cortex-M0+ では、隔離アクターを載せたイメージで(ノードイメージと飛行制御イメージの両方)。
アクター毎に、1 つのアドレス空間(MMU を持つターゲット)
x86_64・riscv64・aarch64 では、アクターがそれぞれ自分のページテーブルを持ちます。カーネルのテーブルをアクター毎に複製し、ほかのアクターの VM コンテキストとユーザスタックからユーザビットを落とす。そしてスケジューラが、コンテキストスイッチのたびに、そのアクターのルート(CR3 / satp / TTBR0)に切り替えます。
兄弟アクターのページは、特権側からは present かつ writable のままです。だから、どのアドレス空間が載っていてもカーネルは動き続けます。タイマトラップも、syscall ゲートも、別のアクターのメールボックスへメッセージを届ける FFI も、そのメモリに届く。触れられなくなるのは、非特権側だけです。
起動時のプローブが、これを確かめます。あるアクターが兄弟のコンテキストへ書き込むと、ハードウェアが拒否する——x86 はページフォルト(#PF err=0x7 — present | write | user)、riscv64 は U-mode のストアアクセスフォルト、aarch64 は EL0 のフォルト。閉じ込めを外すと、同じ書き込みが通ります。
**VM 自身のコア毎の状態も、同じやり方で閉じ込めます。**VM が全コアで走るようになると、コア毎の実行状態ブロックの配列は、まさに兄弟のメモリです。アクターが、自分の走っていないコアのブロックへ書き込んで、別のアクターの実行中の VM を壊せてしまう。アクターは 1 つのコアに固定されているので、そのアドレス空間は、非特権側にちょうどそのコアのブロックだけを許し、残りを拒みます。ブロックは 1 ページ分に広げてあります。そうしないと、複数のブロックが 1 ページを共有し、ユーザビットで区別できないからです。**syscall は 1 つも増えません。**VM はそのブロックを毎命令書くので、そこにゲートを置くのは論外です。
**アクター層の共有帳簿は、いま MMU を持つ 3 ターゲットすべてでカーネルメモリにあります。**名前レジストリ、pid→コンテキスト表、リモート返信の返信先アドレス、そしてロックワード。FFI は ring 0 からそこへ届きますが、非特権側は届きません。x86 は、3 つのうち最後にこれを移しました。
MMU の無い隔離
安いマイコンには MMU がありません。アドレス空間はフラットで物理(VA == PA)です。だから隔離は、領域ベースのハードウェア保護でかけます——RISC-V は PMP、Cortex-M は MPU。行き着く先は同じです。アクターは、兄弟に触れられません。
**bare RV32。**アクターは非特権(U-mode)で生成され、それぞれ PMP で、自分の VM コンテキスト・自分のスタック・共有インタプリタのコードとデータに閉じ込められます。メールボックスはカーネルのメモリにあり、FFI はそこへも、タイマへも、コンソールへも、ecall ゲートを通してしか届きません。PMP は、コンテキストスイッチのたびに組み直します。起動時の自己テストが、あるアクターから別のアクターのコンテキストへ書き込み、ストアアクセスフォルトを取ります。閉じ込めを外すと、同じ書き込みが通ります。
**Cortex-M0+、実機で。**アクターを非特権の Thread モードへ、それぞれ自分のスタックの上に降ろし、MPU 領域で 1 つずつ閉じ込めます。メールボックスも、マイクロ秒タイマも、コンソールも、SVC ゲート経由でしか届きません。起動時の自己テストが、あるアクターから別のアクターのコンテキストへ書き込み、HardFault を取ります(ARMv6-M に MemManage フォルトはありません)。**閉じ込めを外すと、同じ書き込みが通ります。**実機で確認済みです。
MMU 無しの閉じ込めの限界
実機の MMU 無し部品では、使える領域の数が少ない——RP2350 の PMP は NAPOT のみ、RP2040 の MPU は数個です。そこでは、閉じ込めが「広く許してから、兄弟を拒む」形になります。deny-by-default で「そのアクターの領域だけを与える」形ではありません。
その部品では、アクターは兄弟のメモリには届きませんが、カーネルの帳簿には届きます。(bare RV32 の QEMU ビルドの PMP は TOR 範囲を使い、deny-by-default です。アクター自身の 4 スパンだけを許し、カーネルのメールボックスを含む残りをすべて拒みます。)
ARM の MPU は、さらに 2 つの妥協を強います。どちらも、領域サイズが 2 のべき乗であることから来ます。アクターの領域は、1 つの領域をサブリージョンに切って使い(兄弟のサブリージョンを無効にする)、しかも read-execute に分けず read-write で与えます。これは共有インタプリタに対する W^X の妥協で、アクター間の分離そのものを弱めるものではありません。
MMU の無い 2 つのチップ、どちらも 1 イメージで
Raspberry Pi Pico W(RP2040、Cortex-M0+、SRAM 264 KB)と Pico 2 W(RP2350、RISC-V、520 KB)。どちらも実機で、隔離・無線・分散が 1 つのイメージに入っています。
非特権・ハードウェア隔離のアクターの隣で、ネットワークスタックが特権タスクとして走ります。
264 KB のチップでも収まります。**実測で 256 KB 中 208 KB、48 KB の余り。**カーネルは 107 KB で、MPU 分割が要求する 128 KB の下。無線のファームウェア(224 KB)は flash からチップへ直接ストリームされるので、そもそもホストの SRAM に載りません。
Pico W の実機で確認済みです。クロスアクターのストアが HardFault を取り、WPA2 に join し、DHCP で IP を取り、DNS を解決し、x86 の Lambda OS とメッセージを交換しました。
制御ループと隔離アクターも、同じイメージで
Cortex-M0+ の上で、カスケード PID 制御ループと安全包絡線がネイティブに特権で回り、その隣で、ミッション——飛行計画——が 1 つの非特権・MPU 隔離アクターとして走ります。
実機で、Wi-Fi 越しに、HIL シミュレータを相手に、基地 → 巡航 → 島(90 m)→ 貨物投下 → 帰投 → 着陸の全行程を完走しました。**ミッションのバグは、安全包絡線にも制御ループにも届きません。**カーネル境界の向こう側にあり、単なる別アクターのメモリではないからです。アクター対アクターより強い隔離が、しかも境界はもともと在るので、ただで手に入ります。
境界は、ミッションを武装する前に実証される
RP2350 は、飛ぶ前に、非特権からカーネルメモリへのストアを試みます。ハードウェアが、それを拒否しなければなりません。ストアが通れば、基板はミッションを一切起動しない。拒否を見て初めて、ミッションが非特権・隔離状態で走ります——だからここでの隔離は、実際に飛ぶそのビルドで、仮定でなく実証されています。
制御ループはチップの 2 コアの片方で、無線と HIL リンクはもう片方で回ります。ミッションとインタプリタとそのコンテキストプールが、MPU の許せる 128 KB を分け合い、ビルド全体がチップの 256 KB に 254 KB で収まります。安全包絡線を、バイトコードで隔離せずネイティブに保つ——これがより強い隔離であり、その枠をミッションに残すことでもあります。
デモの全体——段階的なフォールト応答、プリフライトの手順、HIL リグ——は 飛行制御 のページにあります。
FFI ハンドラがどこで走るか、アーキごとに
FFI ハンドラがどこで走るかは、各アーキが特権モードからユーザページをどう扱うかで変わります。
- **x86。**ring 0 はユーザページを実行できるので、FFI ハンドラをユーザ領域に置いて、ring 0 で直接走らせられます。ゲートはモード切替、それだけ。
- aarch64。EL0 から書けるページは、EL1 では暗黙に実行不可です(SMEP に似た規則)。だから VM のコードは読み取り専用に、データは読み書き可能にマップします。EL1 は EL0 のデータを読み書きできるので、カーネル側でコピーの手間は要りません。
- **riscv。**S-mode は U ページを一切実行できません。だからカーネル常駐の VM コンテキストヘルパを Zig で書き直し、
memcpy/memsetすらカーネル側の複製に分けて、カーネルコードをユーザページから外します。
漏れてはならない FP レジスタ(XMM 不変条件)
VM の C オブジェクトでは、SSE が有効です。SysV ABI が double を XMM レジスタで返すからです。つまり、浮動小数点の状態を 2 つの境界で正しく扱い、さらに 3 つ目の境界を、証明できる形で綺麗に保たねばなりません。
- **コンテキストスイッチ。**各タスクは 512 バイト・16 整列の FXSAVE 領域を持ちます。出ていくタスクの FP 状態を保存し、入ってくるタスクの分を復元します。
- 同期 syscall / FFI。
int 0x80のインラインアセンブリが XMM レジスタを clobber と宣言するので、コンパイラが syscall の前後で生きている XMM を退避します。 - **非同期の窓。**トラップ入口から FP の保存までの間、カーネルコードは XMM に触れてはなりません。触れると、退避される前にプリエンプトされたタスクの、生きた FP を壊します。
3 つ目の境界は、構造として閉じてあります。Zig カーネルは、SSE と MMX をオフ(汎用レジスタのみ)でビルドします。カーネルは完全に整数なので、これはコスト 0。そしてコンパイラは、プリエンプト窓で XMM 命令を出せません。将来の float も、紛れ込んだ自動ベクトル化の構造体コピーも、ビルドエラーになります。
ビルド時のガードが、シンボル付き(strip していない)カーネルオブジェクトと、FP 復元の直後に走る SSE 有効の C ヘルパの中で、XMM 命令ゼロを確かめます。シンボル付きの成果物で確かめるので、シンボルが欠けていれば、素通りせず、はっきり失敗します。
ハードウェア FP の RV32 ビルドでは、同じ非同期窓を、まっすぐ閉じます。タスク毎のトラップが、プリエンプトを跨いで f0..f31 を退避・復元する。ソフトフロートで FPU の無いチップには浮動小数レジスタがそもそも無く、トラップはコンパイル時に FP の有無へ合わせて、FP ブロックが消えます。
**riscv64 と aarch64 では、プリエンプトを跨いで FP コンテキストを退避していません。**退避・復元のフックはスタブです。プリエンプトを跨いで浮動小数を保持するタスクが 1 つも無い限り、安全です。いまはその状態です。設計ではなく、制約です。
隔離とは何で、何でないか
Lambda OS の隔離は、堅牢性の境界です。バグの影響範囲を、そのアクターの中に封じ込めます。
**マルチテナントの境界ではありません。**敵対的バイトコードの命令レベル検証器はありません。すべてのバイトコードが、運用者自身のコンパイラから信頼経路で来るからです。想定するのは、運用者自身のソフトウェアを走らせる専用機器。機構はハードウェア隔離、脅威モデルは、敵対者ではなくバグです。