Rust製x86エミュレータでLinuxのフレームバッファを映す — vesafbは居なかった、efifbで描く【rustx86 グラフィックス編 #2】

Rust

連載案内: Rust + WebAssembly で書いている x86 エミュレータ rustx86 の開発記。これまでの連載は ネットワーク編 #1ディスク編 #1JIT編 #1。グラフィックス編の 前回 (#1) は VGA mode 13h で DOS のボールを跳ねさせた。今回は Linux の番。

前回、フレームバッファの入口は2つあると書いた。16bit の DOS が使う VGA mode 13h と、32bit の Linux に使わせる リニアフレームバッファ (LFB) の申告。前者は前回片づけたので、今回は後者 — zero page の screen_info に「ここにフレームバッファがある」と書き、Linux のカーネル自身に画素を描かせる。

ロードマップにはこう書いてあった。「build_zero_page は既に screen_info を埋めている。同じ構造体に LFB の欄がある。我々が firmware 側なのだから、自分で用意したフレームバッファを申告すればよい。要実走確認 (ここは推測なので、着手時に必ず実物で確かめる)」。推測だった箇所を実物で確かめたら、前提が1つ違っていた。

Linux (フレームバッファ) — fbcon がカーネル自身のフォントで描いた起動ログと ls /

vesafb は居なかった

ロードマップは「LFB を申告すれば vesafb が bind する」と書いていた。実装の前に、使っているカーネル (Alpine linux-lts 6.18) に何が焼き込まれているか strings で数えた。

vesafb                 0
efifb:                13
simplefb / simpledrm   0
simple-framebuffer     1    ← sysfb が作る装置の名前
fbcon                 16
Console: switching     1

vesafb は居ない。居るのは efifb。それに sysfb (screen_info を読んで platform device を作る層) と fbcon。Alpine のカーネルは EFI 起動を前提に efifb を焼き込み、VESA 系はモジュールに追い出している — 我々の initramfs にモジュールは無い。

efifb は名前のとおり EFI の GOP が用意したフレームバッファ用だが、ドライバ自身が見るのは screen_info.orig_video_isVGA == VIDEO_TYPE_EFI と LFB の欄だけで、EFI ランタイムが居るかは見ない。つまり BIOS 起動 (我々は BIOS すら無いが) でも EFI 型で申告すれば掴む

sysfb の罠 — 表にある形式を申告すると誰も掴まない

もう1つ、strings が教えてくれたことがある。x8r8g8b8 a8r8g8b8 r5g6b5 といった形式名が焼き込まれている。これは sysfb の simplefb 経路の表で、申告された画素形式がこの表にあれば sysfb は simple-framebuffer 装置を作る。その装置を掴むのは simplefb か simpledrm — どちらも焼き込まれていない。

つまり「普通の」32bpp を申告すると、sysfb は simple-framebuffer を作り、誰も掴まず、efifb の出番は来ない。画面は永久に黒である。

screen_info の申告はどこへ落ちるか

逃げ道は表に無い形式で申告すること。24bpp で赤を下位に置く (b8g8r8) は表に無い (表の r8g8b8 は赤が上位)。efifb は screen_info の red/green/blue の位置をそのまま fb_var_screeninfo に写すので、どんな並びでも描ける。

// core/src/boot/bzimage.rs — screen_info の LFB 欄
if let Some(l) = lfb {
    zp[zp::ORIG_VIDEO_ISVGA] = zp::VIDEO_TYPE_EFI;   // 0x70: efifb が掴む型
    zp[zp::LFB_WIDTH..zp::LFB_WIDTH + 2].copy_from_slice(&l.width.to_le_bytes());
    zp[zp::LFB_HEIGHT..zp::LFB_HEIGHT + 2].copy_from_slice(&l.height.to_le_bytes());
    zp[zp::LFB_DEPTH..zp::LFB_DEPTH + 2].copy_from_slice(&l.bpp.to_le_bytes());
    zp[zp::LFB_BASE..zp::LFB_BASE + 4].copy_from_slice(&l.base.to_le_bytes());
    zp[zp::LFB_LINELENGTH..zp::LFB_LINELENGTH + 2]
        .copy_from_slice(&(l.line_bytes() as u16).to_le_bytes());
    // 24bpp・赤が下位 (b8g8r8)。sysfb の simplefb 表に無い形式で素通りさせ、
    // efi-framebuffer → efifb へ落とす
    zp[zp::RED_SIZE] = 8;   zp[zp::RED_POS] = 0;
    zp[zp::GREEN_SIZE] = 8; zp[zp::GREEN_POS] = 8;
    zp[zp::BLUE_SIZE] = 8;  zp[zp::BLUE_POS] = 16;
}

LFB の置き場所 — RAM の末尾を e820 で「予約」に変える

フレームバッファの実体をどこに置くか。RAM の外に別のバッファを持つと、メモリの読み書き経路 (TLB・JIT の高速路・スナップショット) の全部に「RAM の外にも読める場所がある」という分岐が増える。そこで RAM の末尾 1MB を切り出して、e820 でその窓を「予約」にする。バッキングは普通の mem のままなので、エミュレータのメモリ経路には1行も手が入らない。カーネルから見れば「RAM の外にある装置のメモリ」で、ioremap して使う。

LFB は RAM の末尾1MB

// core/src/boot/bzimage.rs — e820
// LFBを申告するなら、その窓はRAMの地図から外して予約にする。
// usable のままだと ioremap が「RAMには張れない」と断る
let usable_end = match lfb {
    Some(l) => l.base as u64,
    None => ram_bytes,
};
entries.push(E820 { base: 0x0010_0000, size: usable_end - 0x0010_0000, kind: 1 });
if let Some(l) = lfb {
    entries.push(E820 { base: l.base as u64, size: ram_bytes - l.base as u64, kind: 2 });
}

「usable のままだと ioremap が断る」のは Linux の決まりで、System RAM として知っている物理アドレスに ioremap はできない。予約に変えておけば、efifb は何の疑いもなく張る。

これで初めて起動したときの dmesg がこれである。一発で掴んだ。

[    0.000000] BIOS-e820: [mem 0x0000000000100000-0x0000000007efffff] usable
[    0.000000] BIOS-e820: [mem 0x0000000007f00000-0x0000000007ffffff] reserved
[    3.573712] fbcon: Taking over console
[    5.833773] efifb: probing for efifb
[    5.836381] efifb: framebuffer at 0x7f00000, using 900k, total 900k
[    5.836777] efifb: mode is 640x480x24, linelength=1920, pages=1
[    5.837510] efifb: Truecolor: size=0:8:8:8, shift=0:0:8:16
[    5.856145] Console: switching to colour frame buffer device 80x30
[    6.170334] fb0: EFI VGA frame buffer device

fbsetrgba 8/0,8/8,8/16,0/0 と、申告したとおりの並びを返した。

申告は起動時の opt-in

ここで設計上の判断が1つ。rustx86 は「同じイメージなら起動までの命令数がビット同一」を門番にしている。LFB を申告すると fbcon が起動ログを描く分だけ命令が増え、970M が 1160M になる。これを既定にすると定規が動く。

そこで LFB の申告は NIC やディスクと同じ「挿さっていなければビット同一」の不変条件に入れた。Machine::lfb_enable() を起動前に呼んだときだけ申告し、呼ばなければ zero page は従来のまま。ブラウザの wasm 口は from_bzimage(..., lfb: Option<bool>) で、省略すれば従来どおりである。

// core/src/mem/mod.rs
/// Linuxへ申告するリニアフレームバッファを挿す (起動前に呼ぶ)。
/// RAMの末尾1MBを切り出し、e820で予約して efifb に掴ませる
pub fn lfb_enable(&mut self) {
    self.lfb = Some(crate::boot::bzimage::Lfb::at_top_of(self.mem.len() as u64));
}

/// LFBの中身 (申告していなければ空)。FB同様**ただのRAMの窓**で、表示側が毎フレーム読む
pub fn lfb_frame(&self) -> &[u8] {
    match self.lfb {
        Some(l) => &self.mem[l.base as usize..l.base as usize + l.frame_bytes() as usize],
        None => &[],
    }
}

素の Linux の回帰は 970M のまま、JIT 決定性ゲートもビット同一のまま通った。

「Linux の GUI はどう確認するか」— 4段の階段

画素が出るようになると、検証の方法も変わる。文字列の一致では済まない。4段にした。

  1. dmesgefifb: framebuffer at 0x7f00000Console: switching to colour frame buffer device 80x30。文字列なので決定的、CI に載る
  2. LFB の画素 — fbcon が起動ログを描いたので真っ黒ではない (非ゼロのバイトが1万以上)。加えてユーザー空間から画素を置いて、LFB にそのまま現れるかを見る
  3. ブラウザの目視
  4. この先: fbdev に直描きするアプリ (links2 -g / Nano-X / SDL)

2段目にはちょうどいい道具があった。busybox の fbsplash は PPM 画像を /dev/fb0 に描く。4×2 画素の PPM を printf で作って描かせ、LFB のバイト列を探す。

// core/examples/regress.rs — OS起動回帰の4本目
let cmd = concat!(
    "printf 'P6\\n4 2\\n255\\n' > /tmp/p.ppm; ",
    "printf '\\377\\0\\0\\0\\377\\0\\0\\0\\377\\377\\377\\377",
    "\\0\\0\\0\\377\\377\\0\\377\\0\\377\\0\\377\\377' >> /tmp/p.ppm; ",
    "fbsplash -s /tmp/p.ppm; printf 'LFB%s\\n' DONE\\n"
);
m.devices.uart.feed(cmd.as_bytes());
let ok = run_until_serial(&mut m, "LFBDONE", 300_000_000).is_some();
let fb = m.lfb_frame();
let line = 640 * 3;
let pattern_found = (0..fb.len() - line - 12).step_by(3)
    .any(|o| fb[o..o + 12] == row0 && fb[o + line..o + line + 12] == row1);

最初に書いたときは R,G,B の順で探して見つからなかった。ゲストの中で /dev/fb0hexdump すると、赤い画素が 00 00 ff — B,G,R の順で置かれていた。busybox の fbsplash は 24bpp を BGR 決め打ちで書く (fb_var_screeninfo の red/blue の位置を見ない)。fbcon と efifb は申告どおり赤を下位に描いているので、カーネル側は正しい。見たいのは「ユーザー空間の書き込みが LFB に届くか」なので、テストは fbsplash が実際に書く並びで照合することにした。実物を当てると、こういう「正しいはずの相手の癖」が出てくる。

Worker の向こうから画素を運ぶ

ブラウザの Linux は Web Worker の中で回っている。前回の mode 13h はメインスレッドの 16bit 機だったのでゼロコピーの窓で済んだが、今回は Worker 境界を画素が越える必要がある。

まずは素直に、30fps で一枚 (921KB) を transferable で送ることにした。Worker は wasm のリニアメモリからコピーを取り、所有権ごとメインへ渡す。921KB × 30 = 28MB/s はメモリ帯域の誤差で、OffscreenCanvas に移すのはこれで足りなくなってからでいい。

// web/linux-worker.js
if (emu.lfb_on()) {
  const now = performance.now();
  if (now - lastLfbAt >= 33) {
    lastLfbAt = now;
    const view = new Uint8Array(wasmExports.memory.buffer, emu.lfb_ptr(), emu.lfb_len());
    const copy = view.slice();
    postMessage({ type: 'lfb', bytes: copy.buffer, width: emu.lfb_width(), height: emu.lfb_height() }, [copy.buffer]);
  }
}

受け取る側の描き手は ImageData に詰めて putImageData するだけ。最初は端末の升目 (730×384) に縮めて収めていたが、それだとカーネルの 8×16 フォントが潰れて読めない。canvas をゲストの解像度 (640×480) に張り替え、CSS で 4:3・最大 960px・image-rendering: pixelated にした。画素の絵は画素のまま大きくする。

「FB だと触れない」の謎解き

起動ログが画面に流れ、rustx86 mini initramfs - busybox shell のバナーも出た。ところがプロンプトが出ない。キーを打っても何も起きない。

答えは2つ重なっていた。1つは Linux の決まりで、console= を複数書いたとき /dev/console になるのは最後の1つ。カーネルのログ (printk) は全部の console に出るが、init が開く /dev/console は最後のものだ。ブラウザは console=tty0 console=ttyS0 と書いていたので、シェルは見えないシリアルに居た。

もう1つは自分たちの initramfs。init はシェルを setsid sh </dev/ttyS0 >/dev/ttyS0 と起こしていた。/dev/console は制御端末になれないので実体の tty を開く必要があり、その実体をシリアルに決め打ちしていたのである。これでは console= の順を変えても、シェルはシリアルから動かない。

シェルはどの tty に居るか

直し方は素直に、init が /proc/cmdline の最後の console= を見て tty を選ぶ。tty0 なら /dev/tty1 (fbcon の仮想端末) で TERM=linux、それ以外は従来どおり /dev/ttyS0

# initramfs の /init (tools/images/sh/make-mini-initramfs.sh)
SHELL_TTY=/dev/ttyS0
for w in $(/bin/busybox cat /proc/cmdline); do
  case "$w" in console=*) last_console=${w#console=};; esac
done
if [ "$last_console" = tty0 ]; then
  SHELL_TTY=/dev/tty1
  export TERM=linux
  # バナーはシリアルにも1行流す — ブラウザ/headless/回帰は「busybox shell」を
  # シリアルで見て起動完了を知るので、画面にシェルを出すときも定規を残す
  echo "  rustx86 mini initramfs - busybox shell (shell on $SHELL_TTY)" > /dev/ttyS0
fi
while :; do
  /bin/busybox setsid /bin/busybox sh -c "exec /bin/busybox sh <$SHELL_TTY >$SHELL_TTY 2>&1"
done

キーの経路も変わる。シェルが tty1 に居るなら、入力はシリアルの文字ではなく PS/2 キーボードからカーネルの VT へ入るのが実機の道だ。カーネルは起動時から AT Translated Set 2 keyboard as /devices/platform/i8042/serio0/input/input0 と 8042 を見つけていたので、受け皿はあった。ブラウザの端末は画素の顔が出ている間、KeyboardEvent.code (キーの位置) を Worker へ渡し、Worker が emu.key(code, down) で 8042 に入れる。前回 mode 13h の DOS でやったことと同じ経路が、今度は Linux の atkbd を通る。

これで ~ # ls / が画面に出た。色付きで。

Linux を2台に分けた

最後に UI の話。最初は「画面: シリアル / フレームバッファ」のトグルをツールバーに置いたが、同じカーネル・同じルート FS で、違うのは画面装置の有無だけなのに、起動の命令数が変わる (970M → 1160M)。これは設定ではなく別の機械だ。OS ライブラリに「Linux (コンソール)」と「Linux (フレームバッファ)」を並べ、説明文に2つがある意味を書いた。

ついでに、initramfs のバナーから日本語を消した。fbcon のフォントは日本語を持たないので、「ゲーム: snake エディタ: vi」が □ の列になる。道具の案内はブラウザ側の説明文が担えばいい。

Linux 版 bounce — 同じボール、違う入口

前回 DOS で跳ねさせたボールを、Linux でも跳ねさせた。tools/guest/bounce-fb/bounce.c、i386 の musl で静的にリンクして initramfs に同梱してある。「Linux (フレームバッファ)」機で ~ # bounce

Linux 版 bounce — /dev/fb0 を mmap して描いた8色のボール

やっていることは DOS 版と同じで、違うのは画素の置き場所の見つけ方だけだ。

DOS (mode 13h) Linux (fbdev)
画面に入る INT 10h AX=0013h open("/dev/fb0")
解像度・画素形式 320×200×8bpp と決まっている ioctl(FBIOGET_VSCREENINFO) で聞く
画素を置く 0xA0000 に直書き mmap した先に書く
テンポ 0x3DA の垂直帰線を待つ nanosleep で 70Hz
終わる INT 16h でキーを覗く 端末を raw にして poll

画素形式は ioctl が返した値から組み立てる。fbsplash の件があったので、決め打ちはしない。

/* tools/guest/bounce-fb/bounce.c — (r,g,b) を var の並びで画素値にする */
static uint32_t pix(int r, int g, int b) {
    return ((uint32_t)r >> (8 - var.red.length)) << var.red.offset |
           ((uint32_t)g >> (8 - var.green.length)) << var.green.offset |
           ((uint32_t)b >> (8 - var.blue.length)) << var.blue.offset;
}

static void put(int x, int y, uint32_t v) {
    uint8_t *p = fb + y * fix.line_length + x * (var.bits_per_pixel / 8);
    for (unsigned i = 0; i < var.bits_per_pixel / 8; i++) p[i] = v >> (8 * i);
}

OS 起動回帰にもそのまま載せた。`bounce

次回: 【rustx86 グラフィックス編 #3】Rust製x86エミュレータの上でXorgを立てる — PS/2マウスとfbdevで、ブラウザにxtermが開くまで

コメント

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