隔離

Lambda C のアクターは、そのチップにある最低の特権で走ります。カーネルへは、たった 1 つの syscall ゲートを通ってしか届きません。そして、別のアクターのメモリには触れられません。

ターゲット特権降格ゲートアクター間の隔離
x86_64iretq で ring 3 へint 0x80アクター毎のアドレス空間
riscv64sret で U-mode へecallアクター毎のアドレス空間(Sv39)
aarch64eret で 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 ハンドラがどこで走るかは、各アーキが特権モードからユーザページをどう扱うかで変わります。

漏れてはならない FP レジスタ(XMM 不変条件)

VM の C オブジェクトでは、SSE が有効です。SysV ABI が double を XMM レジスタで返すからです。つまり、浮動小数点の状態を 2 つの境界で正しく扱い、さらに 3 つ目の境界を、証明できる形で綺麗に保たねばなりません。

  1. **コンテキストスイッチ。**各タスクは 512 バイト・16 整列の FXSAVE 領域を持ちます。出ていくタスクの FP 状態を保存し、入ってくるタスクの分を復元します。
  2. 同期 syscall / FFI。int 0x80 のインラインアセンブリが XMM レジスタを clobber と宣言するので、コンパイラが syscall の前後で生きている XMM を退避します。
  3. **非同期の窓。**トラップ入口から 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 の隔離は、堅牢性の境界です。バグの影響範囲を、そのアクターの中に封じ込めます。

**マルチテナントの境界ではありません。**敵対的バイトコードの命令レベル検証器はありません。すべてのバイトコードが、運用者自身のコンパイラから信頼経路で来るからです。想定するのは、運用者自身のソフトウェアを走らせる専用機器。機構はハードウェア隔離、脅威モデルは、敵対者ではなくバグです。