連載案内: Rust + WebAssembly で書いている x86 エミュレータ rustx86 の開発記。これまでの連載は ネットワーク編 #1・ディスク編 #1・JIT編 #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つ違っていた。

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 の出番は来ない。画面は永久に黒である。

逃げ道は表に無い形式で申告すること。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 して使う。

// 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
fbset も rgba 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段にした。
- dmesg —
efifb: framebuffer at 0x7f00000とConsole: switching to colour frame buffer device 80x30。文字列なので決定的、CI に載る - LFB の画素 — fbcon が起動ログを描いたので真っ黒ではない (非ゼロのバイトが1万以上)。加えてユーザー空間から画素を置いて、LFB にそのまま現れるかを見る
- ブラウザの目視
- この先: 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/fb0 を hexdump すると、赤い画素が 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= の順を変えても、シェルはシリアルから動かない。

直し方は素直に、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。

やっていることは 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が開くまで


コメント