連載「rustx86 ネットワーク編」: Rust + WebAssemblyのx86エミュレータをインターネットに繋ぐまでの開発記。#1はSLiRP backend「wsslirp」(Go + gVisor netstack)、#2はISAバスのNE2000で1993年のDOSからping、#3はPCI + RTL8029でLinuxをネットに乗せ、真犯人のx87 FPUを追った。
前回、ブラウザの中のAlpine Linuxが wget http://example.com/ で本物のHTMLを引いた。残るはTLSである。httpsが通れば、このエミュレータは現代のWebに参加できる。
事前の切り分けでは、壁は2枚だと思っていた。MMX未実装(libcryptoが踏む)と、ゲストの時計が2026-01-01固定であること(証明書の有効期間検証)。
実際に越えてみたら6枚あった。しかも最後の1枚は、TLSが完全に成立した後に待っていた。
壁1: SSE2を名乗った石は、MMXも踏まれる
まず素直に叩いてみる。
~ # wget https://pocraft.net/.../image.png
[ 20.78] traps: ssl_client[431] trap invalid opcode ip:b7b98dd0
in libcrypto.so.3[179dd0,b7a63000+21f000]
#UD (無効オペコード) で即死である。落ちた番地のバイト列を実物のlibcryptoから引くと、こうだった。
$ xxd -s 0x179dc0 -l 16 usr/lib/libcrypto.so.3
00179dc0: efbc fc10 0100 0024 0f0f 73f3 38c1 ed04
^^^^
0F 6F = movq mm, mm/m64
0F 6F は MMXの movq である。うちのCPUIDはSSE2を名乗っているが、MMXビット(EDX bit 23)は立てていなかった。それでもlibcryptoは踏んでくる。
i686のABIでは「SSE2があるならMMXもある」が常識で、実際そういうCPUしか存在しない。ライブラリはMMXビットをわざわざ確認しない。名乗っていないものを使われるという、CPUID実装の落とし穴だった。
mmレジスタは x87 の別名である
MMXの実装で厄介なのは、mm0〜mm7 が x87 の物理レジスタと同じ場所だという点である。mm(i) は R(i) の仮数64bitで、MMX命令を1つ実行するたびに TOP=0・タグ全valid になり、書いたレジスタの指数部は全1になる。
前回f64で裏打ちしたx87に、そのままMMXを載せるとビットが落ちる。f64の仮数は52bitで、MMXが要求する64bitに足りない。
そこで80bitの原本をサイドバンドとして持つことにした。
pub struct Fpu {
/// 物理レジスタ (f64裏打ち)
pub regs: [f64; 8],
/// 80bitの原本 (仮数, 符号+指数)。f64に落とすと壊れる値のための控えで、
/// **MMXレジスタはここに住む** (仮数64bit・指数0xFFFF がMMXの実機表現)。
/// x87の演算がレジスタを書いた瞬間に消え、f64が原本に戻る
pub raw: [Option<(u64, u16)>; 8],
pub empty: u8,
pub top: u8,
pub cond: u16,
}
これでMMXの値がビット同一で往復する。さらに大事なのがFXSAVE/FXRSTORで、カーネルはコンテキストスイッチのたびにここを通る。原本ごと保存しないと、スライスをまたぐたびにMMXの計算が壊れる。
// FXSAVEのST域: 80bit原本ごと保存する — MMX値 (指数全1) もビット同一で残る
for i in 0..8 {
let (mant, se) = m.cpu.fpu.st_f80(i);
...
}
単体テストは「f64で表せないビット列」を積んで往復させる。0xDEAD_BEEF_CAFE_BABE を movq で書き、FXSAVE→破壊→FXRSTOR して、ビット同一で戻ることを釘で打った。
壁2: 「AがあればBもある」— SSE2の語彙の歯抜け
MMXを実装して再挑戦。今度は別の番地で #UD。
00179dd0: 0fc4 145e 020f ef7c c410 c1c2 080f efb4
^^^^
0F C4 = pinsrw (ワードの差し込み)
pinsrw を実装。また #UD。
001024f0: 0666 0f70 c0cc 660f 6e4e 0466 0f7f 0366
00102500: 0ff4 c766 0f6e 5608 660f 70c9 cc66 0f7f
^^^^
66 0F F4 = pmuludq (XMM版)
もぐら叩きが始まった。ここでユーザーから鋭い指摘が入る。
もしかしてAと言う命令があればBと言う命令もあるという前提じゃね?
32BITかつHTTPS時代にあった拡張命令全部無いとダメでは?
その通りである。踏まれてから実装するやり方では、libcryptoが選ぶコードパスが変わるたびに落ち続ける。しかも暗号ライブラリは実行時にCPU機能を見てベンチの速い実装を選ぶので、どの命令を踏むかは環境で変わる。
方針を変えた。命令表と実装の突き合わせ監査をして、名乗った機能の語彙を一括で埋める。
$ for op in 0x6B 0xD5 0xE1 0xE2 0xE4 0xE5 0xE8 0xE9 0xEA 0xEC 0xED 0xEE 0xF4 0xF5 0xE6 0xD0; do
grep -q " $op " core/src/cpu/sse.rs && echo "$op あり" || echo "$op 無し"
done
0x6B あり
0xD5 無し ← pmullw
0xE1 無し ← psraw
0xE4 無し ← pmulhuw
0xF4 無し ← pmuludq
...
歯抜けだらけだった。整数乗算・飽和加減算・min/max・平均・シフトの一族、変換命令 (cvtpi2ps / cvttpd2dq…)、MMXとXMMを渡す橋 (movq2dq / movdq2q)、暗黙の DS:EDI へ書く maskmovq まで、まとめて実装した。
これは「名乗ったものは実装する」という、このプロジェクトの原則の再確認でもあった。CPUIDで機能ビットを立てるのは約束であって、その約束は使う側の都合で好きなところから履行を求められる。
壁3: 時計が狂っていると証明書が検証できない
命令が揃ったら、エラーが変わった。
error:0A000086:SSL routines:tls_post_process_server_certificate:
certificate verify failed
これもユーザーの読みが当たっていた。
時計が狂ってるから時計もそれもなおさないとダメじゃね?
~ # date
Thu Jan 1 00:02:49 UTC 2026
ゲストの時計は2026-01-01固定である。証明書の有効期間は「この期間内か」で判定されるので、時計が嘘だと検証は通らない。
ここで設計上の緊張がある。このエミュレータのcoreは意図的に時計を読まない。
/// 起動時の日時 (既定値・固定)。**coreは自分ではホストの時計を読まない**
/// - 決定的でなければスナップショットが再現しない。同じ状態から再開したら
/// 同じ時刻でなければ困る
/// - core は時計を持てない。std::time::Instant は wasm32 では動かない
const EPOCH: Time = Time { year: 2026, month: 1, day: 1, ... };
決定性はこのプロジェクトの土台で、CIは「同じイメージなら起動命令数がビット同一」を毎回検査している。ホストの時計を読み始めたら、その定規が壊れる。
折り合いは入力にすることだった。MACアドレスと同じ扱いで、電源投入時にホストが渡す。渡さなければ従来どおり固定のまま。
/// 時計をUNIX時刻 (UTC秒) に合わせる。ホストが起動時に呼ぶ入力口で、
/// core自身は時計を読まない
pub fn set_clock_unix(&mut self, secs: u64) {
let days = (secs / 86400) as i64;
// proleptic Gregorian (Howard Hinnant の civil_from_days)
let z = days + 719468;
let era = z.div_euclid(146097);
...
}
ブラウザ側が Date.now() / 1000 を渡す。CIは渡さないので決定的なまま。同じコードが、用途によって決定的にも実時刻にもなる。
壁4: busyboxのwgetはTLSを自分で喋らない
busyboxの wget はhttpsを外部ヘルパ ssl_client に投げる。ゲストのinitramfsにはそれが入っていなかった。
Alpineの initramfs-lts から借りて同梱する。
| もの | 大きさ | 役割 |
|---|---|---|
| ssl_client | 13KB | wgetのTLS口 |
| libssl / libcrypto.so.3 | 4.5MB (gz 2.1MB) | OpenSSL本体。一番重いがTLSの実体 |
| ca-certificates.crt | 179KB | 信頼の根。全束のまま |
initramfsが 1.4MB → 4.1MB になった。起動命令数も 580M → 770M に増える。これは意味の後退ではなく積んだ荷物の重さなので、CIの上限もその分引き直した。数字が動いたら理由を書いて定規を直す、という運用にしている。
壁5: CAの束はあるのに、置き場所が違う
同梱して、時計も直して、再挑戦。同じエラーが出た。
certificate verify failed
時計を直したのに証明書が検証できない。ここで少し悩んだ。CA束は /etc/ssl/certs/ca-certificates.crt に確かに入っている。
Alpineの実物を覗いて分かった。
$ ls -la etc/ssl/
lrwxrwxrwx cert.pem -> certs/ca-certificates.crt
OpenSSLの既定CAファイルは /etc/ssl/cert.pem (OPENSSLDIR直下) で、certs/ に束を置いただけでは見つけてくれない。Alpineはsymlinkで両方から見えるようにしていた。
ではなぜsymlinkが消えたのか。犯人は自作のcpio書き出しスクリプトだった。
# 展開側 (--extract) のコメント:
# シンボリックリンクは実体のコピーとして展開する — Windowsでリンクを
# 作るには権限が要るし、この後の cp も実体を運ぶので同じことになる
展開時にsymlinkを実体コピーにしていて、書き出し側にはsymlinkを書く機能がそもそも無かった。だから cert.pem が(実体としても)initramfsに入っていなかった。
書き出し側に S_IFLNK エントリを実装した。
if os.path.islink(full):
# symlinkはリンクのまま運ぶ (本文=リンク先)。実体を複製すると
# /etc/ssl/cert.pem のようなCA束の別名で179KBが二重になる
out = entry(out, rel, stat.S_IFLNK | 0o777, os.readlink(full).encode())
continue
壁6: TLSは通った。そして /tmp が無かった
再挑戦。TLSハンドシェイクが成立し、証明書が検証され、HTTPSでデータが流れ始めた。
そして最後にこう出た。
wget: can't open '/tmp/i.png': No such file or directory
ミニinitramfsに /tmp を作っていなかった。6枚目の壁は、暗号でもCPUでも時計でもなく、空のディレクトリ1個だった。
mkdir を1つ足して、ようやくこうなった。
~ # wget -q -O /tmp/i.png https://pocraft.net/.../wsslirp-eyecatch-s1.png \
&& head -c4 /tmp/i.png | od -An -tx1 | tr -d ' \n'
89504e47
89 50 4E 47 — PNGのシグネチャである。ブラウザのタブの中で動く自作エミュレータの中のLinuxが、TLSで暗号化された通信路を通して、実物の画像ファイルを取ってきた。
おまけ: 壁を越えた後の落とし穴
TLSの検証中に、16bit側が壊れていることが発覚した。ユーザーからの報告はこうだった。
16BIT側がデグレッテルから確認して
FreeDOSでパケットドライバが名乗るMACが FF:FF:FF:FF:FF:FF に化け、DHCPが沈黙していた。原因はPCIバスを足した日(#3)の見落としである。
ブラウザ版の from_disk (フロッピー起動) がPCI付きのプロファイルで機械を作っていた。その機械でNICを挿すとPCIスロット側に入り、ISAの0x300窓が閉じる。FreeDOSのパケットドライバは0x300しか叩かないので、カードが丸ごと消えた。
見逃した理由がこの型の核心だった。coreのテストは16bit機のプロファイルしか見ておらず、ブラウザだけが別の機械を作っていた。 ネイティブの最小再現は最初から正しく動いていて、コアには非がなかった。不具合は「テストが見ている世界」と「利用者が触る世界」の隙間に住んでいた。
/// フロッピーから起動する機械 (ELKS / FreeDOS / test386.img)。
///
/// **PCIは積まない。** フロッピーで起動する世代の機械にPCIは無く、
/// NICはISAの0x300に挿さる — FreeDOSのパケットドライバはそこしか叩かない
pub fn pc_floppy(mb: usize) -> Self {
Self { name: "floppy PC", has_pci: false, ..Self::pc_32bit(mb) }
}
CIで見張れないものは、見張れないと書く
この一件を受けて、CIにネットワークの関門を常設した。ただし設計に一工夫要った。
毎回のCIから本物のインターネットへ出るのは弱い。相手のサイトが落ちればこちらが赤くなるし、ICMPには権限が要るし、他人のサーバーに毎push負荷をかける。
そこで宛先を内側に畳んだ。
ping 10.0.2.2 → SLiRPのゲートウェイ宛。wsslirpのnetstackが自分で答える
(外にもICMPの権限にも依存しない)
http://<host> → CIジョブが立てた自分のHTTPサーバ
ゲストが通る道 (NE2000/RTL8029 → WebSocket → netstack) は実物と同じなので、「NICが見えるか・DHCPが通るか・ICMPが往復するか・TCPが流れるか・ゲストの時計が実時間か」の回帰はそのまま守れる。GitHub Actionsのランナー上で、1993年のFreeDOSと現代のAlpine Linuxが同じ仮想NICから線に出る。
ホストの番地: 10.1.0.224:8199 (ゲストはここへ取りに行く)
### 16bit (FreeDOS + mTCP)
ping: 10.0.2.2 から応答が返った
### 32bit (Linux + udhcpc/wget)
✓ DHCPでアドレス取得 (10.0.2.15)
✓ pingの間隔は実時間 (3発 4.4s)
✓ http://10.1.0.224:8199/ から中身が引けた
ただしTLSだけはここで見張れない。閉じた世界に信頼できる証明書は置けないからで、自己署名を足せばCA束が本番と別物になり検証の意味が変わる。
だから、そう書いた。
TLS (https) はここでは見ない — 閉じた世界に信頼できる証明書が無い。
実インターネット向けの手動E2Eが持つ。自動で見張れていない穴として明記しておく。
見張れていないものを「見張れていない」と書いておくのは、緑のCIが与える安心の解像度を上げる作業だと思っている。
まとめ
TLSの壁は6枚あった。
- MMX — SSE2を名乗った時点で、MMXビットを見ずに踏まれる
- SSE2の語彙の歯抜け — もぐら叩きをやめ、命令表と突き合わせて一括で埋める
- RTCの実時刻 — 決定性を壊さずに、入力として注入する
- ssl_client + OpenSSL + CA束 — 4.5MBを同梱し、起動の代価を測って定規を引き直す
- CAの置き場所 —
/etc/ssl/cert.pem。symlinkがcpioの書き出しから落ちていた - /tmp が無い — 暗号でもCPUでもない、空のディレクトリ1個
面白いのは、6枚のうちCPUの話は2枚だけだったことである。残りは時計・ファイル配置・ディレクトリという、地味な現実の側にあった。エミュレータを「動く」から「使える」に持っていく作業は、命令セットの実装よりも、こういう積み木を1つずつ揃えることの方が多いのかもしれない。
この時点をタグとして切ってある: 32bit-network。zipを落として START.command をダブルクリックすれば、同じものが手元で動く(OSイメージは同梱していないので、付属のスクリプトで取得する)。
次回
ネットワークは通った。残るは本測定である。WebSocket (TCP) の上にゲストのTCPを流しているので、TCP over TCP の病理が出るはずで、その破綻の仕方とMTUの押し出し、レイテンシの内訳を測りたい。ゲストがLinuxのフルスタックなので、最初から本気の道具で測れる。
連載: #1 wsslirp (Go + gVisor netstack) / #2 ISAバスのNE2000で1993年のDOSからping / #3 PCI + RTL8029 と、真犯人のx87 FPU / #4 (この記事) / #5 装置を速くしても0.6%だった — 犯人はNE2000の受信リング


コメント