1993年のDOSから ping 1.1.1.1 — ISAバスのNE2000をRustで実装する【rustx86 ネットワーク編 #2】

16bit

連載「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が本物のインターネットへ洪水になった。犯人を辿ると浮動小数点演算に行き着く。

コメント

タイトルとURLをコピーしました