連載「rustx86 ネットワーク編」: Rust + WebAssemblyのx86エミュレータをインターネットに繋ぐまでの開発記。#1はSLiRP backendとなるユーザーモードNATデーモン「wsslirp」(Go + gVisor netstack)、#2はゲスト側の仮想NIC — ただし予告していたvirtio-netではなくISAバスのNE2000になった。その路線変更の顛末から始める。#3はPCIバスとRTL8029でLinuxを繋ぐ回を予定している。
予告を裏切るところから始める
前回の末尾で「#2はRustでvirtio-netを実装する」と書いた。書いた直後に偵察へ出て、その計画は崩れた。
virtio-mmioは「PCIを実装せずにvirtioデバイスを生やす」ための便利な口で、Firecrackerもこれを使っている。ところが偵察の結果:
- Alpine Linuxのltsカーネルは
CONFIG_CMDLINE_DEVICESが無効で、カーネル引数からvirtio-mmioデバイスを発見できない。発見にはACPIかデバイスツリーが要る — どちらも今のrustx86には無い - virtフレーバーのカーネルなら行けるかというと、こちらはPAE(3段ページング)必須で、現行coreでは起動すらしない
つまり「PCIを避ける近道」のはずのvirtio-mmioが、ACPIかPAEという別の山を連れてきた。近道ではなかった。
ここで方針を切り替えた。装置は「仮想化専用の便利デバイス」ではなく本物のハードウェアに寄せ、バスの歴史の順 — ISA、それからPCI — で行く。理由は単純で、
「16bitでpingを飛ばしてみたい。それだけ。PCIで16bitはありえないしなぁ」
という一点に尽きる。1993年のFreeDOSがインターネットにpingを打つ絵は、virtio-netでは絶対に撮れない。virtioを喋る16bit OSは存在しないからだ。
打算もある。ISAのNE2000に載っているコントローラ DP8390 は、PCI時代に RTL8029(PCI版NE2000)として皮だけ替えて生き延びた。Alpine ltsには NE2K_PCI=m が入っている。つまり今DP8390を書けば、次のPCI回で同じコアをそのまま使ってカーネル無変更のLinuxが繋がる。書き捨てにならない(ADR-0017)。
DP8390という石
NE2000はNovellが1988年に出したISAカードで、心臓部はNational SemiconductorのDP8390。以後10年、「NE2000互換」はPCのネットワークカードの共通語だった。プログラミングモデルはこうなっている。
CPU (I/O 0x300-0x31F)
│
│ レジスタ窓 (CRのビットでページ0/1を切り替え)
│ データポート 0x310 (リモートDMAの窓口)
▼
┌─ DP8390 ──────────────────────────┐
│ CR ISR IMR DCR TCR RCR ... │
│ │
│ ┌─ 16KB SRAM ─────────────┐ │
│ │ 0x4000 送信バッファ │ │
│ │ 0x4600 ┌──────────────┐ │ │
│ │ │ 受信リング │←┼──── 受信フレーム
│ │ 0x6000 └──────────────┘ │ │
│ └─────────────────────────┘ │
└───────────────────────────────────┘
CPUはSRAMを直接見られない。リモートDMAという仕組みで、RSAR(開始番地)とRBCR(バイト数)を積んでからデータポートを連打して読み書きする。受信はカードがリングバッファに4バイトヘッダ付きでフレームを積み、ISR(割り込み状態)のPRXビットで知らせる。
Rust側の受信はこうなる。境界ページをまたぐ巻き戻しと、4バイトヘッダ(状態・次ページ・長さ)がDP8390の作法だ:
// 実機は60バイト未満を受け取らない (コリジョンの破片と区別できない)。
// 短いフレームはパディングして通す — wsslirpのARP応答は42バイトで来る
let len = frame.len().max(60);
let header = [
0x01, // 受信状態: 正常
next, // 次のフレームが始まるページ
(total & 0xFF) as u8, // 長さ (ヘッダ込み)
(total >> 8) as u8,
];
PROMには仕掛けがある。MACアドレスの各バイトを2度ずつ置き、末尾に 0x57 0x57(ASCIIで “WW”)の印を刻む。8bitデータ経路の名残で、ドライバはこの印を見て「NE2000だ」と判定する。ここを1バイトでも横着すると、1993年のドライバは黙ってカードを無視する。
coreの境界は「フレームの出し入れ」だけ
rustx86のcoreは決定的に作ってある。同じイメージなら起動までの命令数がビット単位で毎回同じで、CIがこれを門番にしている。ネットワークは本質的に非決定(いつ届くか分からない)なので、境界を厳密に引いた:
- coreが持つのは
net_inject_frame(受信リングへ積む)とnet_take_frame(送信キューから取る)だけ。WebSocketも再接続も時計も知らない - フレームの注入はシリアル入力と同じくスライス境界で行う。同じ列を同じタイミングで入れれば実行は決定的
- NICを挿さなければ装置そのものが存在しない。NIC無し起動のビット同一は不変条件として、既存のCI門番がそのまま見張る
wsslirpとの結線は前回定めた境界プロトコル「1 WebSocketバイナリメッセージ = 1 Ethernetフレーム」そのままで、ブラウザ側は運び屋に徹する。
FreeDOS (mTCP) ── NE2000.COM ── 仮想DP8390 ── wasm境界 ── WebSocket ── wsslirpd ── インターネット
1993年 1993年 Rust JS Go/gVisor
謎解き1: DHCPが「Sending…」で凍る
結線して、FreeDOSにCrynwrのパケットドライバ(NE2000.COM)とmTCPを入れ、DHCP.EXE を叩く。凍った。
まずI/Oトレースの計器をカードに付けた。カードへのI/Oがゼロ。ドライバはロードされ、機械は生きているのに、誰もカードを触っていない。ステップ実行で追うと、mTCPは送信前に1秒待つループに入っていて、その監視変数が永遠に増えない。
正体はCPU側、それもBIOSにあった。mTCPはINT 1Ch(タイマーチック割り込み)をフックして自前の時計を進める。rustx86のBIOSはHLE(高位エミュレーション)でINT 08hを肩代わりしているが、実BIOSのINT 08hが最後にやるINT 1Chの連鎖呼び出しを省略していた。誰にも呼ばれない1Chフック、進まない時計、明けない1秒。
修正は「1Chがフックされているときだけゲストへ実配送」。未フックなら従来どおり1命令も変えない — 決定性の門番(起動命令数ビット同一)を壊さないためだ。
謎解き2: 直してもまだ凍る — 犯人は計測器
INT 1Chを直してもDHCPが完了しない。今度はトレースが送信中を318件記録していて、wsslirpのログには
slirp: [g3] dhcp Discover -> Offer: 10.0.2.15 leased to 52:54:00:12:34:56
Offerは返っている。送信も応答も生きていて、ゲストへの受信だけが死んでいる。
犯人はE2Eスクリプト自身だった。Node.jsでエミュレータを回す検証ハーネスが同期ループでスライスを回し続けていて、WebSocketの受信コールバックが走る隙を与えていなかった。Nodeのonmessageはイベントループが回って初めて呼ばれる。スライスごとに await setImmediate() で制御を返して解決。
観測装置がバグっていると、健全な系が壊れて見える。物理の実験ノートみたいな教訓だが、エミュレータ開発では日常である。
謎解き3: 「IP address conflict!」
受信が通ると、今度はmTCPが
IP address conflict detected!
と言って自爆した。DHCPでもらったばかりの10.0.2.15が「使用中」だと言う。
これはRFC 5227のアドレス衝突検査だ。リースを受けたクライアントは「このIP、誰か使ってる?」とARP probeを打ち、誰も答えないことを確認してから使い始める。ところがwsslirp側のARP応答器が几帳面すぎて、ゲスト自身のIPへの照会にまで代返していた。「使ってますよ(あなたがね)」。ゲストから見れば衝突である。
wsslirp側で「ゲスト自身のIPへのARPには沈黙する」よう修正(wsslirp PR #5)。ネットワークの礼儀作法は、答えることより黙ることが難しい。
謎解き4 (ELKS編): データシートの1行
FreeDOSが通ったので、次はELKS(フロッピー1枚で動く16bit UNIX)。ktcp(ELKSのTCP/IPデーモン)の起動が凍った。
追うと、ISRハンドラが0x80(RSTビット)を延々読み続けていた。RSTは「カードが停止中」の印で、実チップではSTARTコマンドを書いた瞬間に自動的に下りるとデータシートに書いてある。うちの実装はSTOPで立てるだけ立てて、下ろし忘れていた。ELKSのハンドラは「処理すべき割り込みが残っている限り回る」作りなので、誰にも下ろせない0x80を見て無限ループしたわけだ。
// ISRのRSTは「停止中」の印: STOPで立ち、**STARTで自動的に下りる**
// (データシートの仕様)。ここを忘れるとELKSのISRハンドラが
// 「誰も処理しない0x80」を延々読み続けて無限ループする (実話。
// Crynwrは行儀よくISRに0xFFを書いて掃除するので気づけなかった)
if val & CR_STA != 0 {
self.isr &= !ISR_RST;
} else if val & CR_STP != 0 {
self.isr |= ISR_RST;
}
面白いのは、FreeDOSのCrynwrドライバでは露見しなかったことだ。Crynwrは初期化時にISRへ0xFFを書いて全ビットを掃除する行儀なので、下ろし忘れたRSTも一緒に消えていた。ドライバごとに行儀が違うから、複数のゲストOSで検証する価値がある — この開発で何度も出てくる教訓の、いちばん分かりやすい実例だと思う。
ついでにCPU側も1つ。1993年のドライバはデータポート転送に rep insw / rep outsw を使うのが定石だが、rustx86はINS/OUTS(0x6C-0x6F)を実装していなかった。これまで動かしてきたOSが誰も使わなかった命令が、ネットワークドライバで初めて呼ばれる。ゲストを増やすたびに、CPUの空白地帯が埋まっていく。
結果
FreeDOS + mTCP:
A:\>PING 1.1.1.1
Packet sequence number 0 received from 1.1.1.1 in 20.40 ms, ttl=64
...
Packets sent: 4, Replies received: 4, Replies lost: 0
4送4受0損、平均11.26ms。 1993年のパケットドライバと16bitのTCPスタックが、Rustで書いたDP8390とGoで書いたNATを通って、2026年のCloudflareに届いて返ってきた。
ELKSはpingコマンドを持っていない(ktcpはTCPのみ)ので、疎通の絵はHTTPになった:
# urlget http://example.com
<!doctype html>...<title>Example Domain</title>...
DNSも効いている。フロッピー1枚のUNIXが名前を引いてHTMLを取ってくる。
決定性の門番も無事だ。NIC無し起動は580M命令×10走で完全一致、速度も交互A/B 5周でワッシュ(7.52s vs 7.38s)。ネットワークを足しても、機械の時計は1命令も狂っていない。
次回
PCIバスを実装して、同じDP8390をRTL8029の皮で包み、カーネル無変更のLinuxをインターネットに乗せる。lspciに10ec:8029が見え、ne2k-pciがbindして、DHCPが自動で走るまで — なのだが、その道中でとんでもない釜の蓋が開いた。sleep 3 が0.01秒で明ける機械で、pingを打ったら何が起きるか。次回はその大捕物になる予定である。
前回: Go + gVisor netstackでユーザーモードNAT「wsslirp」を書く【rustx86 ネットワーク編 #1】
リポジトリ: yoshiharu-ishii/rustx86 / yoshiharu-ishii/wsslirp
次回: sleep 3 が20msで返る — PCIバスとRTL8029をRustで実装したら、真犯人はx87 FPUだった【rustx86 ネットワーク編 #3】。同じ8390コアにPCIの皮を着せて現代のLinuxをネットに乗せたら、ゲストのpingが本物のインターネットへ洪水になった。犯人を辿ると浮動小数点演算に行き着く。


コメント