sleep 3 が20msで返る — PCIバスとRTL8029をRustで実装したら、真犯人はx87 FPUだった【rustx86 ネットワーク編 #3】

Rust

連載「rustx86 ネットワーク編」: Rust + WebAssemblyのx86エミュレータをインターネットに繋ぐまでの開発記。#1はSLiRP backendとなるユーザーモードNATデーモン「wsslirp」(Go + gVisor netstack)#2は30年前のISAバスにNE2000を挿して1993年のFreeDOSからpingを飛ばすまで。今回は現代のLinuxをネットに乗せる

前回、16bitのFreeDOSから ping 1.1.1.1 が返った。装置はISAバスのNE2000で、番地は0x300決め打ちである。今回はその同じチップにPCIの皮を着せて、Alpine Linuxをカーネル無変更でネットワークに繋ぐ。

やることは3つに見えた。PCIバス(設定空間)を作る、RTL8029として名乗る、Linuxのドライバにbindさせる。実際そこまでは半日で通った。問題はその後で、ゲストのpingが本物のインターネットへ毎秒数百発の洪水になった。

犯人を辿ったら、ネットワークとは縁もゆかりもない x87 FPU に行き着いた。今回はその謎解きが本題である。

PCIは「装置を数える仕組み」

ISAには装置を列挙する方法が無い。「COM1は0x3F8」「NE2000は0x300」と全メーカーが定数で示し合わせ、ぶつかったらジャンパで逃げる世界だった。だからエミュレータのI/Oデコーダも match で足りる。

0x3F8..=0x3FF => IoTarget::Uart,
0x300..=0x31F => IoTarget::Net,

PCIが持ち込んだのは設定空間 (configuration space) である。バス上のどの位置に何が挿さっているかをソフトから読み出せるようになり、番地はfirmwareかOSが後から割り当てるものになった。lspci が見ているのはこの表で、現代PCの「挿せば見える」はここから始まっている。

覗き方は2つのポートだけ。機構#1 (Configuration Mechanism #1) と呼ぶ。

  0xCF8 (32bit書込)  アドレス
     bit31=有効 | bus[23:16] | dev[15:11] | fun[10:8] | reg[7:2]
  0xCFC (32bit読書)  データ = 上で指した4バイト

   ┌──────────┐  ①どこを見るか書く   ┌────────────────┐
   │   CPU    │ ───── 0xCF8 ──────→ │  ホストブリッジ  │
   │          │                      │  (設定空間の窓口) │
   │          │ ←──── 0xCFC ─────── │                │
   └──────────┘  ②そこの4バイトを読む └────────────────┘
                                              │
                        ┌─────────────────────┼─────────────────────┐
                     slot 0                 slot 3               slot 5…
                  440FX (8086:1237)   RTL8029 (10EC:8029)        (空)
                                                                → 0xFFFFFFFF

面白いのは空きスロットの返事である。何も挿さっていない場所を読むと 0xFFFFFFFF が返る。バスに誰も応答しないとプルアップ抵抗で全ビットが1になる、という電気的な事実がそのまま「不在」の意味になっている。OSはこれで在庫を数える。

BARの大きさは「全部1を書いて」測る

装置が欲しい番地の幅は、BAR (Base Address Register) に全部1を書いてから読み返すと分かる。装置は自分が使わない下位ビットだけを残して0を返すので、読めた値を反転して+1すれば大きさになる。

// 設定空間への書き込み。**受け付ける欄を明示的に選ぶ**
fn write_u32(&mut self, reg: usize, val: u32) {
    match at {
        // BAR: 幅の測定に答える。使わない下位ビットは常に0を返す
        0x10..=0x27 => {
            let bar = self.bars[(at - reg::BAR0) / 4];
            if bar.size == 0 {
                return; // 無いBARは書いても0のまま (OSはこれで不在を知る)
            }
            let mask = !(bar.size - 1);
            put(&mut self.cfg, (val & mask) | if bar.io { 1 } else { 0 });
        }
        // 身元 (ベンダ/装置/クラス) は読み取り専用。黙って捨てる
        _ => {}
    }
}

ここを素通しにすると、OSは「書いた値がそのまま読める」ので幅を測れなくなる。書ける欄を選ぶことがPCI実装の肝である。

RTL8029は「NE2000の皮違い」

Realtek RTL8029AS は、中身がDP8390のままPCIの顔だけを着けたチップである。NE2000互換であることを売りにした廉価品で、Linuxのドライバ (ne2k-pci) もISA版とコアの lib8390.c を共有している。

つまりエミュレータ側も実体を作り直す必要がない。前回作った8390コアに、設定空間の名乗りを着せるだけでいい。

/// RTL8029ASの設定空間の顔を作る。実体はISA版と同じDP8390
pub fn rtl8029(irq_line: u8) -> PciFunction {
    // class 02 = ネットワーク、subclass 00 = Ethernet
    PciFunction::new(0x10EC, 0x8029, 0x02, 0x00, 0x00)
        .with_bar(0, Bar { size: 32, io: true }, NET_IO_BASE)
        .with_irq(irq_line, 1) // INTA#
        .with_subsystem(0x10EC, 0x8029)
}

ここで1つだけ実体側に影響が出た。PROM (MACアドレスが書いてある領域) の並べ方である。ISAの8bitデータ経路では各バイトが2度ずつ並ぶのが慣例で、16bitカードは偶数バイトだけを拾う。PCI版のドライバは連続バイトをそのまま読む。

倍幅のまま渡したらどうなるか。52:54:00:12:34:5652:52:54:54:00:00 に化けた。ドライバは律儀に先頭6バイトを読んだだけである。

/// PROMを平らにする (PCIの皮 = RTL8029 用)
pub fn flatten_prom(&mut self) {
    self.mem[..32].fill(0);
    self.mem[..6].copy_from_slice(&self.mac);
    self.mem[14] = 0x57;  // NE2000判定の印 'W' も位置が移る
    self.mem[15] = 0x57;
}

これでLinuxが eth0 を生やし、udhcpc が DHCP を投げ、wsslirp がリースを返した。ブラウザのタブの中のLinuxに 10.0.2.15 が付いた。

そして ping が洪水になった

ping 1.1.1.1 を打つ。応答が返る。ここまでは良かった。

wsslirpd のログを見た瞬間、血の気が引いた。

icmp 10.0.2.15 -> 1.1.1.1 (id=1 seq=1)
icmp 10.0.2.15 -> 1.1.1.1 (id=1 seq=2)
icmp 10.0.2.15 -> 1.1.1.1 (id=1 seq=3)
...

秒間数百行。ping は1秒に1発のはずが、実時間では毎秒数百発を本物のCloudflareへ叩き込んでいた。慌てて止めた。

原因は「ゲストの時計が実時間より速く回っている」で確定である。問題はどこが速いのか。3層あった。

第1層: PITのワンショットを周期扱いしていた

Linuxの高分解能タイマは 8254 PIT をモード4 (ソフトウェアトリガのワンショット) で使う。i8253.cset_oneshot0x38 を書く。

うちの実装はモードを見ずに、鳴ったらカウンタを再ロードして鳴り続けていた。つまり1発だけ鳴るはずのタイマが周期タイマになっていた

// モード0/1/4/5はワンショット。鳴ったらそこで止まる
if matches!(self.mode, 0 | 1 | 4 | 5) {
    self.running = false;
}

第2層: 早送りがPICの挙手を見ていなかった

HLT中は次のタイマ割り込みまで時間を飛ばす最適化 (アイドル早送り) が入っている。その脱出条件にPICに保留中の割り込みがあるかが入っていなかった。割り込みが待っているのに寝続け、起きたときには時間が飛んでいる。

if self.halted && !self.devices.pic[0].has_pending() { /* 早送り */ }

ここまで直して、ようやく sleep 5 がだいたい5秒待つようになった。だいたい、である。

第3層 (真犯人): sleep 3 が20msで返る

ユーザーからの報告はこうだった。

このバグって、sleepコマンドが効かないのが関係してないか?タイマー関連実装している?

実測すると、sleep 5 は5秒待つのに sleep 3 は一瞬で返る。整数秒なのに挙動が違う。数字によって違うというのが手がかりだった。

busyboxの sleep は引数を parse_duration_str() で解釈する。中身は musl の strtod である。そして muslのstrtodは x87 の長倍精度 (80bit) で計算する

当時のうちのx87は「検出と初期化にだけ答えるスタブ」だった。CPUIDでFPUを名乗り、FNINITFNSTSW には応じるが、演算のESC命令 (0xD8〜0xDF) は黙って読み飛ばしていた

黙って流すと何が起きるか。

  "3"  ──strtod──→  x87で計算  ──→  スタブが何もしない
                                      ↓
                              スタック上のゴミがそのまま返る
                                      ↓
                        3.0 のはずが 0.02 くらいの何か
                                      ↓
                   sleep 3 → nanosleep(20ms) → 即座に戻る
                                      ↓
              「1秒に1回」のはずのpingが毎秒50発

printf '%f' 3 も試したら、今度は無限ループした。dtoaが収束しないのである。

未実装を黙って流したことが、ネットワークの洪水として現れた。 直接の被害者はpingだが、犯人は浮動小数点演算だった。

x87をf64で裏打ちする

実装方針は QEMU-tiny や v86 と同じ割り切りにした。レジスタをf64で持つ。実機の80bit拡張倍精度より仮数が11bit短いが、strtod・printf・libmの実用には足りる。80bitのロード/ストアだけ境界で変換する。

pub struct Fpu {
    /// 物理レジスタ。論理 st(i) は regs[(top + i) & 7]
    pub regs: [f64; 8],
    /// 空きビットマスク (物理番号)。1 = 空
    pub empty: u8,
    /// スタックトップ (物理番号)
    pub top: u8,
    /// 条件コード C0-C3 (SWのビット位置のまま保持)
    pub cond: u16,
}

そして未実装のESCは黙って流さず trap で止めるようにした。黙って流した結果が今回の事故なので、二度と同じ穴には落ちない。

sleep 3 が3秒待つようになり、pingが1秒に1発になった。

Ctrl+C が効かない、Ctrl+Z も効かない

洪水は止まったが、今度はユーザーからこう来た。

Pingが止められない

^C を押してもpingが走り続ける。これはエミュレータのバグではなく、ゲストのinitの作りの問題だった。

シグナルを配る相手はプロセスではなくプロセスグループで、その宛先は制御端末 (controlling terminal) が持っている。うちのミニinitramfsはinitから直接シェルをexecしていたので、そのシェルには制御端末が無かった。^C を打っても配る先が無い。

# setsidで新セッションを起こし、そのリーダーに実体の /dev/ttyS0 を開かせる
# (/dev/console は制御端末になれない。cttyhackはAlpineのbusyboxに無い)
busybox setsid busybox sh -c 'exec busybox sh </dev/ttyS0 >/dev/ttyS0 2>&1'

これで ^C が効くようになった。次はこう来た。

こんどはCtrl+Zがきかないね

^Z (SIGTSTP) だけが効かない。^C は効くのに。

犯人はLinuxカーネルの孤児プロセスグループの判定だった。孤児と判定されたプロセスグループには TSTP/TTIN/TTOU が仕様として配られない(止めた後に誰も再開させられないため)。そして判定式にはこうある。

/* 親が global init のメンバーは「外の親」として数えない */
if (is_global_init(p->real_parent)) continue;

うちのシェルは PID 1 だった。すると全ジョブの親がPID 1になり、全部が孤児扱いになる。SIGSTOPだけは仕様上例外なので効く、という奇妙な姿になっていた。

直し方は単純で、シェルをforkで起こしてPID 1から降ろす

while :; do
  busybox setsid busybox sh -c 'exec busybox sh </dev/ttyS0 >/dev/ttyS0 2>&1'
done

initはPID 1のまま子を待ち、シェルはPID 2以降になる。^Z が効くようになった。

FXSAVEがx87を保存していなかった

x87を実装したので、ついでにシグナル周りを全部見直した。するとFXSAVE/FXRSTOR がXMMしか保存していないことに気づいた。

これはコンテキストスイッチとシグナル配送の経路である。カーネルはプロセスを切り替えるたびにFPUの状態を退避する。x87を保存していないと、スイッチをまたぐたびに浮動小数点の計算が壊れる

// FXSAVEのST域 (+32から16バイト刻み)
for i in 0..8 {
    let (mant, se) = m.cpu.fpu.st_f80(i);
    let at = addr.wrapping_add(32 + i as u32 * 16);
    m.write32(at, mant as u32);
    m.write32(at.wrapping_add(4), (mant >> 32) as u32);
    m.write16(at.wrapping_add(8), se);
}

検証は「同じ計算を複数プロセスで同時に回して、結果が全部正しいか」で行った。awkを並行して走らせ、どれも正確な答えを返すことを確認した。

wgetがフリーズする — 1命令も進んでいなかった

DHCPもpingも通ったので wget http://example.com/ を試したら、固まった

止まった場所を知りたいが、このイメージには strace も ptrace も無い。ユーザーの指摘が的確だった。

ptraceとstraceなしのイメージだと限界じゃないかな

そこでホスト側から覗いた。エミュレータなのだから、ゲストの中に道具を持ち込まなくても、外から見ればいい。1命令ずつ走らせてEIPを3万回サンプリングした。

30000 / 30000 サンプルが同じ番地
EIP = c1234567 (udp_queue_rcv_one_skb+0x89)
そこのバイト列: 0f 18 86 98 00 00 00

3万回サンプリングして、1回も動いていない。 0F 18prefetchnta — キャッシュに先読みを促すヒント命令である。

未実装だったのでIPが進まず、同じ命令を永久にデコードし続けていた。ヒント命令は何もしないのが正解だが、「何もしない」にもModRMを読んでIPを進める仕事がある。

// 0F 18-0x1F: prefetch (0x18) と多バイトNOP群 (0x1F ほか)。
// **どれもヒントで、ModRMを読んで進めるだけ。演算はしない。**
0x18..=0x1F => {
    let _ = modrm(m, d); // 実効アドレスを読み飛ばす (IPを正しく進める)
}

DNS応答の処理でカーネルがこれを踏んでいた。3バイト書いたら wget が通った。

結果

ブラウザのタブの中のAlpine Linuxが、本物のインターネットからHTMLを引く。

~ # ifconfig eth0 | grep "inet addr"
          inet addr:10.0.2.15  Bcast:10.0.2.255  Mask:255.255.255.0
~ # ping -c 3 1.1.1.1
64 bytes from 1.1.1.1: seq=0 ttl=64 time=11.2 ms
--- 1.1.1.1 ping statistics ---
3 packets transmitted, 3 packets received, 0% packet loss
~ # wget -q -O - http://example.com/ | grep -o "<title>.*</title>"
<title>Example Domain</title>

pingの間隔が実時間で1秒であることは、E2Eテストで機械に見張らせている。3発が2秒未満で終わったら「時計の轡が外れた」として落ちる。一度やらかした事故は、二度と静かには起きない。

今回の教訓

未実装を黙って流すな。 これに尽きる。

x87スタブは「検出には答えるが演算は無視する」という、一見無害な手抜きだった。それが strtod を壊し、sleep を壊し、最終的に他人のサーバーへのDoSまがいの行為として表に出た。しかも症状はネットワークの層に現れるので、原因のある浮動小数点の層まで辿るのに時間がかかった。

エミュレータの実装で「まだ要らない」と判断するときは、要らないなら止める方が安全である。止まれば場所が分かる。黙って流すと、遠く離れた場所で別の顔をして現れる。

次回

TLSに挑む。wget https:// が通れば、ブラウザの中のLinuxが現代のWebに参加できる。壁は「MMXと時計の2枚」だと踏んでいた。

実際には6枚あった。


連載: #1 wsslirp (Go + gVisor netstack) / #2 ISAバスのNE2000で1993年のDOSからping / #3 (この記事)

リポジトリ: rustx86 / wsslirp


次回: wget https:// が通るまでの壁は6枚あった — MMX・SSE2の語彙・RTC・CA束【rustx86 ネットワーク編 #4】。事前の切り分けでは2枚のはずだった。最後の1枚は、TLSが完全に成立した後に待っていた。

コメント

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