Rustのx86エミュレータにvirtio-blkを実装して、ブラウザのLinuxでgccを128MBで動かす【rustx86 ディスク編 #1】

Rust

本記事は連載「rustx86 ディスク編」の第1回である。ブラウザで動くRust + WebAssembly製のx86エミュレータ rustx86 に、ディスクを生やしていく。ネットワークを繋いだ経緯は連載「rustx86 ネットワーク編」にある。

前回までで、ブラウザの中のLinuxはDHCPでアドレスを取り、TLSでインターネットに出られるようになった。次の欲は決まっていた。このPCモドキでgccを動かして hello, world をコンパイルしたい

やってみたら動いた。ただし、そこに至る道でルートファイルシステムの構造的な限界に正面からぶつかり、ディスクを実装することになった。今回はその顛末である。

発端 — 「gcc -c は通るのにリンクだけ失敗する」機械

gccを動かすこと自体は、実はディスク無しでもできる。Linuxには initramfs という仕組みがあり、圧縮したcpioアーカイブをカーネルがRAM上のtmpfsに展開してルートFSにしてくれる。Alpine Linuxのパッケージからgcc・binutils・musl-devを依存ごと集めて詰めれば、35MBのイメージができる。

これを既定の128MB RAMで起動したら、こうなった。

~ # gcc hello.c
gcc: fatal error: cannot execute 'cc1': posix_spawnp: No such file or directory
compilation terminated.

イメージには確かに cc1 が入っている。strings で見れば環境変数の設定も焼いてある。なのにゲストの中には無い

犯人はカーネルの起動ログにいた。

[   16.024135] rootfs image is not initramfs (write error); looks like an initrd

initramfsの展開中、圧縮イメージ(35MB)と展開後の中身(84MB)は同時にRAMに載る。128MBでは足りない。そして足りないときLinuxカーネルは、エラーで止まるのではなく「じゃあ旧式のinitrd(ラムディスク)なんだろう」と解釈を変えて先へ進む。起動は成功して見えるのに、ルートFSだけが尻切れになる。

一番たちが悪かったのは192MBだった。展開はほぼ最後まで進み、欠けたのはアーカイブ末尾の4ファイルだけ。

/usr/libexec/gcc/i586-alpine-linux-musl/15.2.0/collect2   ← リンカのドライバ
/usr/libexec/gcc/i586-alpine-linux-musl/15.2.0/ld
/usr/libexec/gcc/i586-alpine-linux-musl/15.2.0/liblto_plugin.so
/usr/share/udhcpc/default.script                          ← DHCPのリース適用係

つまり「gcc -c は通るのに、リンクだけ失敗する。ついでにDHCPが黙って効かない」という、デバッグ泣かせの機械が生まれる。

gzipは自分の展開後サイズを知っている

対策として、まずローダ側で起動前に算数で断るようにした。gzipファイルは末尾4バイト(ISIZE)に展開後のサイズを持っている。読むだけでよい。

/// gzip の末尾4バイト = 展開後の大きさ (mod 2^32)。gzipでなければ None
fn gzip_isize(data: &[u8]) -> Option<u64> {
    if data.len() < 18 || data[0] != 0x1F || data[1] != 0x8B {
        return None;
    }
    let n = data.len();
    Some(u32::from_le_bytes([data[n - 4], data[n - 3], data[n - 2], data[n - 1]]) as u64)
}

/// 必要RAM = 圧縮 + 展開後 + カーネル作業域68MiB
pub fn initrd_ram_needed(initrd: &[u8]) -> u64 {
    const KERNEL_WORK: u64 = 68 << 20;
    let unpacked = gzip_isize(initrd).unwrap_or(initrd.len() as u64);
    initrd.len() as u64 + unpacked + KERNEL_WORK
}

68MiBという定数は当てずっぽうではなく、実測2点が挟み込んで決まった。gccイメージは192MBで欠け256MBで完全(K > 65.6MiB)、従来のイメージは128MBで完全(K ≤ 69.7MiB)。この帯に入るきりのいい値が68しかない。

これで「黙って欠ける」は入口で止まる。だが256MB要求は消えない。根本の問題は、initramfsが「展開した中身を全部RAMに載せる」方式であることだ。ここでディスクの出番になる。

ATA vs SATA vs virtio — エミュレータの物差しは「トラップ回数」

ディスクの口には歴史的に何段もの選択肢がある。実機なら互換性や転送モードで選ぶところだが、エミュレータには独自の物差しがある。I/Oポートへのアクセス(トラップ)こそがコストだ。ゲストがIN/OUT命令を打つたびに、エミュレータは実行ループを抜けてデコーダ→装置と往復する。実機のポートアクセスより桁違いに高くつく。

方式 1セクタ(512B)の運び方 トラップ数
ATA (PIO) insw を256回 256回
ATA (UDMA) バスマスタIDE + PRD表 数回(ただしPIIXの癖が付いてくる)
SATA (AHCI) HBA + コマンドリスト + FIS 数回(コマンド層が厚い)
virtio-blk 記述子にゲスト物理アドレスが載る 通知1回(何セクタでも)

ATA PIOとvirtioのトラップ数の違い

virtioは「相手がエミュレータだとゲストが知っている」前提の規格で、転送の単位が「レジスタを叩く」ではなく「メモリに置いた表を渡す」になっている。ゲストが物理アドレスを書いた記述子の表を用意し、ホストはそれを読んでmemcpyするだけ。しかも複数の要求を1回の通知でまとめられる。

ReactOSや古いUNIXはvirtioを知らないので、いずれATAも要る。だが速さはvirtioで取り、互換はATAで後から取ると役割を分ければ、どちらも無理をしない。前回のPCI実装(RTL8029)で土台はできているし、Alpineのinitramfsに virtio_blk.ko 一式が入っていることも確認済み。カーネルと同じ荷物から借りるので、モジュールのvermagicが必ず合う。

実装 — リングの機構と要求の解釈を分ける

virtio-blk(legacy 0.9.5)を、素子と基板の2层に分けて実装した。リングの機構(virtqueue)はどのvirtio装置でも同じなので dev/chip/virtio.rs に、「先頭16バイトがヘッダで、type 0なら読み」というブロック装置の解釈は dev/card/virtio_blk.rs に置く。

virtqueueは3枚の表でできている。

desc[N]   {addr u64, len u32, flags u16, next u16} ×N   買い物袋の中身
avail     {flags u16, idx u16, ring[N] u16}             「袋を置いたよ」(ゲスト→ホスト)
used      {flags u16, idx u16, ring[N] {id,len}}        「済んだよ」(ホスト→ゲスト)

ホスト側でavailから未読の袋を取り出す部分はこうなる。ポイントはゲストの表を信用しないこと。表を書くのはゲストで、ゲストのバグでホストが死ぬのは筋が通らない。

/// availに未読の袋があれば1つ取り出す。返すのは (先頭desc番号, 鎖の中身)。
pub fn pop_avail(&mut self, ram: &[u8]) -> Option<(u16, Vec<Seg>)> {
    let avail_idx = r16(ram, self.avail_base() + 2)?;
    if self.last_avail == avail_idx {
        return None; // 新しい袋は無い
    }
    let slot = u64::from(self.last_avail % self.qsize);
    let head = r16(ram, self.avail_base() + 4 + slot * 2)?;
    self.last_avail = self.last_avail.wrapping_add(1);

    // 鎖を歩く。**上限はqsize** — nextが輪になっていても止まれる
    let mut segs = Vec::new();
    let mut i = head;
    for _ in 0..self.qsize {
        let d = self.desc_base() + u64::from(i % self.qsize) * 16;
        let addr = r32(ram, d)?;
        let len = r32(ram, d + 8)?;
        let flags = r16(ram, d + 12)?;
        segs.push(Seg { addr, len, write: flags & DESC_WRITE != 0 });
        if flags & DESC_NEXT == 0 {
            return Some((head, segs));
        }
        i = r16(ram, d + 14)?;
    }
    None // 鎖が輪 — 袋ごと捨てる (仕様上ドライバのバグ)
}

ブロック要求は1つの鎖に3役が乗る形をしている。ヘッダ16バイト(装置は読む)、データ、status 1バイト(装置が書く)。読み要求(type 0)の本体は、ディスクの中身からゲストRAMへのmemcpyだ。

req::IN => 'io: {
    for s in data {
        let (a, n) = (s.addr as usize, s.len as usize);
        let (Some(src), Some(dst)) =
            (self.image.get(at..at + n), ram.get_mut(a..a + n))
        else {
            break 'io status::IOERR; // ディスクの外 or RAMの外
        };
        dst.copy_from_slice(src);
        note_write(s.addr, s.len); // DMAの申告 (後述)
        at += n;
        moved += s.len;
    }
    status::OK
}

note_write は見逃せない1行だ。virtio-blkはこのエミュレータ初のバスマスタ(装置側からゲストRAMへ書く装置)で、CPUの書き込み経路に仕掛けてある自己書き換え検出の横を通ってしまう。デコード済み命令キャッシュがコードを控えたページにDMAが黙って書くと、写しが腐る。書いた場所を必ず申告する。

insmodの順は直感と逆

ゲスト側は、ミニinitramfsのinitにモジュールを挿す行を足すだけ……のはずが、初回は /dev/vda が生えなかった。

[   12.089597] virtio_blk: Unknown symbol virtio_check_driver_offered_feature (err -2)
[   12.355371] virtio_blk: Unknown symbol virtio_reset_device (err -2)

「virtioが土台で、virtio_ringはその上の部品だろう」と直感で insmod virtio → virtio_ring の順に書いたら、実物は逆だった。.ko に埋まっている依存情報が正解を教えてくれる。

$ strings virtio.ko | grep depends=
depends=virtio_ring        ← ringが土台。virtioがその上

ringを先に挿すよう直したら、/dev/vda が生えた。ホスト側でディスクに乱数4MBを書いてmd5を控え、ゲストで md5sum /dev/vda — 一致。書き込みも200セクタの往復で一致。ディスクは通った。

rootfsをディスクへ — squashfs + overlay + switch_root

ディスクができたので、gccの木を丸ごとsquashfsに焼いてrootfsにする。squashfsを選んだのは、rootfsは読み専用が正しい姿だからだ(Live CDと同じ)。イメージは圧縮したまま置かれ、カーネルは読んだブロックだけ展開してページキャッシュに載せる。「起動時に全部展開」が消える — これがRAM節約の正体である。

書けないと gcc hello.c がカレントにa.outを置けないので、tmpfsをoverlayfsで上に重ねて「書ける読み専用」にする。initramfs側のinitに足したのはこれだけだ。

# ディスクがあれば、そちらを根にして移り住む (squashfs + tmpfsのoverlay)
if [ ! -e /.rustx86-disk ] && [ -b /dev/vda ]; then
  if /bin/busybox mount -t squashfs -o ro /dev/vda /disk 2>/dev/null; then
    /bin/busybox mount -t tmpfs tmpfs /overlay
    /bin/busybox mkdir -p /overlay/up /overlay/work /overlay/root
    /bin/busybox mount -t overlay overlay \
      -o lowerdir=/disk,upperdir=/overlay/up,workdir=/overlay/work \
      /overlay/root
    echo "  rootfs: /dev/vda (squashfs + tmpfsのoverlay)"
    exec /bin/busybox switch_root /overlay/root /init
  fi
fi

/.rustx86-disk はディスク側の木に焼いてある印で、これが無いとディスクの中のinit(同じスクリプト)がまたディスクを探しに行って輪になる。switch_rootした先で同じinitが走る設計なので、二周目を印で断つ。

squashfs+overlay+switch_rootの構成

ミニinitramfs(4MB)は「ディスクを見つけて移る係」に戻った。実機のLinuxディストリビューションと同じ役割分担である。効果は数字がそのまま語る。

方式 中身のRAM負担 必要RAM
initramfs(gcc入り 34MB) 展開後84MBが全部tmpfsに 256MB
ディスク(同じ中身のsquashfs) 読んだ分だけページキャッシュ 128MB

128MBで起動してgccを走らせたときの free はbuff/cacheが3MBしかない。本当に読んだ分しか載っていない。

イメージを焼く道具はDockerの中のLinuxに任せる

squashfsを焼くには mksquashfs が要るが、開発機はmacOSで、そんなものは無い。移植を探すか自前で書くかの二択に見えて、第三の道があった。Alpineのコンテナから本物を借りる

FROM alpine:3.24    # ゲストと同じ版 — 道具の癖がカーネルの世代と揃う
RUN apk add --no-cache squashfs-tools e2fsprogs cpio tar python3 \
 && cp /usr/share/apk/keys/x86/* /etc/apk/keys/

イメージ焼きのスクリプトは、先頭で自分がコンテナの中かを見て、外なら自分をコンテナ内で呼び直す。

[ -f /.dockerenv ] || exec tools/images/in-linux.sh sh "$0" "$@"

こうすると副産物として、「道具が無いから自前で書いた」層を丸ごと返せる。Alpineのパッケージ依存を解決する自前Python(APKINDEXを読んでBFS、110行)は apk --arch x86 --root の1コマンドに、/dev/consoleノードを非rootで作るための自前cpio実装(130行)は、コンテナ内ならrootなので mknod + 本物のcpioになった。

# 依存の閉包も署名検証もapk本人の仕事。こちらはパッケージ名を3つ言うだけ
apk --arch x86 --root "$work/pkg" --initdb -U --no-scripts \
  --keys-dir /etc/apk/keys \
  -X https://dl-cdn.alpinelinux.org/alpine/v3.24/main \
  -X https://dl-cdn.alpinelinux.org/alpine/v3.24/community \
  add gcc musl-dev binutils

本物を借りたら方言も2つ踏んだ。apkは署名鍵を組み立て先のroot側から読む(空なので --keys-dir で母艦の鍵輪を指す)。find . -depth 1 はBSD(macOS)の方言で、busyboxのfindは知らない。ホストを移ると、慣れたコマンドの半分は方言だと思い知る。

なお、コンテナに入れたのはイメージ焼きだけである。Rust/wasmのビルドと速度測定はネイティブのまま — コンテナ(VM)越しの測定は配置ノイズが乗って定規にならない。

ブラウザに載せる — ルートFSを選んでから電源を入れる

ネイティブで通ったので、ブラウザにも載せた。wasm側の追加は blk_attach の1メソッドだけで、NICと同じ「電源を入れる前に挿す」流儀に乗る。

UI側は、ルートFSをツールバーで選べるようにした。「ミニ(RAM)」「gcc入り(ディスク)」「gcc入り(RAM)」の3択で、どの挙動になるかはデータが決める — 選択肢の定義に disk フィールドがあればディスク型、なければメモリ型。選ぶ側のコードに分岐は無い。

export const ROOTFS = [
  { name: 'initramfs-mini', label: 'ミニ (RAM)',        initrd: 'initramfs-mini' },
  { name: 'disk-gcc',       label: 'gcc入り (ディスク)', initrd: 'initramfs-mini',
    disk: 'disk-gcc.img' },
  { name: 'initramfs-gcc',  label: 'gcc入り (RAM)',     initrd: 'initramfs-gcc' },
];

あわせて、Linuxの機械に電源OFF/ONの状態を入れた。これまでは一覧で選んだ瞬間に起動が始まり、ルートFSの選択は「次の電源ONから効く」という後出しの運用だった。実機で組んでから電源を入れる、あの順に直した。選ぶ→電源OFFで待つ→電源ON。OFFにしても機械は選ばれたままで、構成を替えてまた入れられる。

ブラウザでの実測: 「gcc入り(ディスク)」を選んで電源ON、49.5秒でシェル。RAMは自動判定で128MB。

virtio_blk virtio0: [vda] 68800 512-byte logical blocks (35.2 MB)
squashfs: version 4.0 (2009/01/31) Phillip Lougher
  rootfs: /dev/vda (squashfs + tmpfsのoverlay)

~ # cd /tmp && cp /hello.c . && gcc hello.c -o hello && ./hello
hello, world

ブラウザの中のPCに、ディスクからルートFSが生えて、gccが走った。

おまけの謎解き — LEAの1命令でホストごと落ちた話

仕上げに一つ、拾いものがあった。ユーザー環境での初回起動が、カーネルログの途中でこう落ちた。

thread 'main' panicked at core/src/cpu/onebyte.rs:710:36:
LEA with register operand

LEA命令は「実効アドレスを計算してレジスタに入れる」命令で、メモリオペランドを取るのが仕事だ。lea eax, ebx のようなレジスタオペランドのLEAは実CPUでは#UD(無効命令例外)になる。コンパイラは絶対に出さないが、データ領域に飛び込んだプログラムなどが踏むことはある。

rustx86には「ユーザー空間の未実装命令は#UDに変換してOSに裁かせる(OSはそのプロセスだけSIGILLで殺して先へ進む)」という仕組みが既にある。ところがLEAのこの分岐だけ、初期に書いた panic! が残っていた。パニックはこの仕組みを素通りして、ゲストのプロセス1つの行儀の悪さでホストごと落とす

// 修正前
Operand::Reg(_) => panic!("LEA with register operand"),
// 修正後 — 実CPUと同じ#UDへ。ユーザー空間ならSIGILLでそのプロセスだけ死ぬ
Operand::Reg(_) => m.trap("LEA with register operand (#UD)".into()),

エミュレータを書いていると「未実装は早く大きく壊れてほしい」(panic)と「ゲストの世界の出来事はゲストの中で閉じてほしい」(#UD)の間に線を引くことになる。線の位置は明確で、実CPUが例外を定義しているものは例外に、こちらの実装が追いついていないだけのものはpanicに。LEAのレジスタオペランドは前者だった。

まとめと次回

  • initramfsは「展開した中身が全部RAMに載る」構造的限界がある。カーネルは足りないとき黙って途中でやめるので、gzipのISIZEを読んで起動前に断るようにした
  • ディスクの口はvirtio-blkから。エミュレータの物差しはI/Oトラップ数で、ATA PIOの256回/セクタに対しvirtioは通知1回
  • rootfsはsquashfs + overlayfs + switch_root。gcc一式が256MB→128MBになった
  • イメージ焼きはDockerの中のAlpineに任せ、自前ツール240行を削除
  • ブラウザはルートFS選択+電源OFF/ONで「組んでから電源」の順に

次回はディスクの最適化を予定している。いまの実装は「動く」を最短で取りに行った形で、リングの深さも通知の頻度も素朴なままだ。大きなファイルの読み書きで帯域を測り、どこが詰まるのかを定規から作る — ネットワーク編と同じ進み方で、ディスクの実効速度を追いかける。

リポジトリ: github.com/yoshiharu-ishii/rustx86(判断の記録は docs/adr/0019、仕組みの説明は docs/explanation/disk.md)

コメント

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