借り初めのひみつきち

仮ブログです。

続・WASMでPCエミュレータ作った。

neriring.hatenablog.jp

前回の記事の時点では、対応する命令も少ないしペリフェラルは UART のみでとても OS が起動する状態ではありませんでした。
その後色々実装して前回動かなかったブラウザにも対応し、ついに自作 OS の osz が起動しました!

f:id:neriring16:20190623234557p:plain

f:id:neriring16:20190623224830p:plain

基本的な操作は問題なく動作しているように見えます。

続いて FreeDOS を見てみましょう。

f:id:neriring16:20190623224957p:plain

ダメですね。

お次は PC-DOS

f:id:neriring16:20190623225015p:plain

動いてるように見えます。

最後は elks

f:id:neriring16:20190623225024p:plain

この画面のまま止まってしまいます・・・

動くプログラムが出てくるとやる気出てきますねヽ(•̀ω•́ )ゝ✧

WASMでPCエミュレータ作った。

WASM で PC エミュレータ作りました☆(ゝω・)vキャピ

f:id:neriring16:20190618000038p:plain

github.com

※ 現時点では自作のOSすら起動しません。

すでにいろんなエミュレータが存在しているので今更感あるかもしれないですが、このエミュレーターは WebAssembly を使っているのが大きな特徴です。

もともと Web ブラウザーで動作するPCエミュレーターを探していて、いくつかあることはあるのですが、どれも不満がありました。

筆者は数年前に Web で動作する PC エミュレーターを試作したことがありました。
サーバーサイドで既存のエミュレーターを動作させて WebSocket で入出力だけブラウザ側が担当する実装でした。
クライアントの処理が軽いので軽快に動作しましたが、動作するサーバーの問題があって公開前に開発中止になりました。

クライアント単体で完結できるものとして JavaScript を利用したものがよくありますが、パフォーマンス的に満足できるものはひとつもありませんでした。

WebAssembly の環境も整ってきたので自作するかーということになりました。

SharedArrayBuffer と Atomics

技術調査をしていて一点気になっていたことがありました。

一般に GUI 環境では GUI イベントを処理するためのスレッドがあります。
JavaScript の場合は GUI イベントとメインのスクリプトが同じスレッドで動作するので、重たいスクリプト処理が走っていると GUI イベントに素早く反応できずに操作性が悪くなります。かといって GUI イベントのために小刻みに動作を中断するとエミュレーターのパフォーマンスが非常に悪くなります。
こういう場合、重い計算処理は GUI スレッドとは別のスレッドで処理するのが普通です。

JavaScript では Worker という仕組みを使ってマルチスレッドを実現できます。
Worker スレッドで動作するスクリプトGUI スレッドとは別のスレッドで動作するため、 GUI スレッドは GUI イベントに、エミュレータスレッドはエミュレータ処理にそれぞれ専念することができます。

しかし一方で、エミュレーターは常に処理しているわけではありません。
全力で処理したい時もあるけど、ユーザーの操作待ちなどで休んでる時もあります。
こういう場合、 JavaScript では SharedArrayBuffer と Atomics を使って待ち合わせを行うことができます。
エミュレータースレッドで入力待ちが発生した場合は Atomics.wait を使ってスリープし、 GUI スレッドで入力イベントを受け取ったら Atomics.notify を使ってエミュレータスレッドを起こすことができます。

これで、全力で処理するときは全力で処理し、入力待ちで休んでるときはほとんど CPU を消費しないようになり、ぼくの要求するパフォーマンスを満たすことができるようになりました。

しかし、 SharedArrayBuffer はセキュリティ上の理由により Chrome 以外のほとんどのブラウザーでは無効になっているという悲しいニュースがありました。これを解決するには別の手段を探す必要があるかもしれません。

ModR/M とフラグ

実装上の問題としては、筆者は過去に 80 系 CPU のような単純なエミュレーターは自作したことあるのですが、 x86 系の本格的なエミュレーターを作るのは初めてでした。
8086 の特徴として多くの汎用命令で ModR/M と呼ばれるエンコーディングが使われています。
しかしこの ModR/M が曲者で、 8086 エミュレーターで一番面倒な場所だと思います。ここだけで1日くらい消費しました。

ModR/M デコーダーを実装して多くの命令が動作するようになりましたが、今度はフラグの動作にハマりました。
OF や CF が実機と結果が異なるのです。
これらのフラグは条件分岐でよく使われるフラグなので正しく実装しないとほとんどのプログラムが動作しません。
自作 VM なら比較命令と条件分岐命令を統合させてフラグの動きを考えないようにできますが、実際の CPU をエミュレーションするので真面目に実装する必要があります。

そんなこんなでとりあえず簡単なタイプライター的動作をする BIOS が動く程度にはなったので公開します:;(∩´﹏`∩);:

フォントエディタ作った。

フォントエディタにいろいろと不満があったので自作することにしました☆(ゝω・)v

f:id:neriring16:20190505144802p:plain

現在は base64 エンコードした ASCII 形式の FONTX2 のみ入出力できます。
オリジナルフォーマットも考えてるけど実現可能性は未知数です。

Electron で作ったので Web でもほとんどの機能が利用できます。
https://nerry.jp/fedit95/src/
iPad でどこでもフォント編集できるぜ(?)

ソースはこちらにあります。
github.com

きんきょうてきな?

また更新間隔が空いてしまいました。

フォントのあれ

以前公開すると宣言していた megos フォントについてあえず現場を公開しておきます。
(0610と0608は字形が一緒じゃないかとかまだ気になるところはあるのですが...)

github.com

moe で使用しているフォントの方も submodule で呼び出すように分離しました。
また、思うところあって moe のフォントを変更することにしました。

f:id:neriring16:20190422031956p:plain

ページング、PEローダー、ブートシーケンス

これらは密接な関係があります。

もともと moe は必要になるまでなるべく省略する方向性で作っていたので、ページングは UEFI が設定したものをそのまま使っていました。
その一方で、ページングは正しく構成すればメモリ保護とデバッグ性が向上するので早く独自テーブルに移行したいと考えていました。
ということで、まずは独自のページマネージャを実装しました。

次に、カーネルをページングで保護された領域にロードするためには UEFI に頼らず自力の PE ローダーを実装する必要がありました。
そのため、ブートシーケンスが変更されて EFI/PE ローダーとカーネルが物理的に分離されました。

ここまで実装したらもう UEFI に頼らなくても起動できるよねってことで、少しブートローダーを改良して 32bit UEFI からも起動できるようになりました。
実質 Atom Z3000 番台専用ですが。

ここまでの変更で kernel.efi は見た目が UEFI アプリケーションですが UEFI から直接起動できなくなりました。
UEFI に対応した OS を作るという当初の目的から少し離れた気がします。

Local APIC タイマー

moe は HPET を使っていましたが、HPET をサポートしない環境も存在します。
HPET が使えない環境で次の候補となるタイマーソースは Local APIC タイマーです。
タスクスイッチのタイマーを Local APIC タイマーに変更する対応自体はそんなに難しくなかったですが、 moe は HPET に特化したスケジューラーを持っているので CPU 使用率が正確に反映できませんでした。

色々試した結果、 Local APIC タイマーで HPET の完全な代替は無理と判断しました。

その一方で、 moe のスケジューラーの CPU 使用率測定がもともとオーバースペックだった気もしているので改修を考えています。

ここまでの変更で以前失敗した hyper v から起動ができるようになると期待していたのですが、黒い画面のまま進みませんでした。

地獄のデバッグ

ある日家にあった Surface 3 で moe を起動してみたら、画面が真っ黒のまま進みませんでした。

現在の moe はウィンドウシステムが起動する前は基本的に何も画面出力しないので何が起こっているのかさっぱりわかりません。
こういう時はどうしたらいいでしょうか?

まずは適当な間隔で画面に進捗がわかるように何かを表示するデバッグコードを配置して実行します。
進捗表示が途中で止まったり期待と違う表示がされたら、その付近に問題があることがわかります。
最初は大雑把に配置し、怪しい関数がわかったらその中に更に配置し、その関数の更に奥に配置し...というのを繰り返してどこで問題が発生しているか突き止めます。

printfデバッグの結果、 SIPI という SMP 初期化時に送信する割り込みに問題があるらしいところまで目星がつきました。
そこで、 SIPI 送信処理をコメントアウトして実行してみるとシングルプロセッサ状態で普通に起動したので SMP の初期化に問題があることは特定できました。

次に、 SIPI の送信方法がおかしいのか、それとも SIPI 受信側に問題があるのか特定するために SIPI 側にデバッグコードを挿入します。
しかし、 SIPI を受け取るハンドラはリアルモードで起動するので、ロングモードで動作する printf 関数はそのままでは動作しませんし、 VRAM はリアルモードからアクセスできないアドレスにあるので何も表示することができません。
そこで、どこまで正常に動くか試すためにまずは SIPI を受け取った直後に無限ループするコードを追加して起動してみます。
moe のスケジューラーは SIPI ハンドラの初期化が終わって割り込みを受け取れるようになったコアにしかスレッドを分配しないので、シングルプロセッサ状態で起動しました。
SIPI を受け取って初期化する処理に問題があることがわかります。

次に、 SIPI ハンドラ内の無限ループの位置をあちこち移動してみます。これを繰り返して起動しなくなった箇所に問題があることがわかります。
最終的に SIPI ハンドラからロングモードに遷移するために EFER MSR に書き込む WRMSR 命令の前後に問題があることがわかりました。

しかし、この WRMSR 命令で EFER を変更しないとロングモードに遷移できません。 WRMSR 命令自体に問題はないように思います。
よくみると EFER に設定する内容がロングモードに遷移するための LME フラグの他に NX ビットを有効にする NXE フラグもセットしています。
NX ビットというのは、ページングに実行禁止属性をつけることで不正な実行を防いでセキュリティを向上させるための仕組みです。
試しに NXE フラグをはずして起動してみると、見事に起動しました。犯人は NXE フラグだったのです!

これを証明するために moe で cpuid の情報が見れるプログラムを実行してみます。
確かに cpuid の NX feature bit は 0 でした。この CPU は NX ビットをサポートしていないということですね。
サポートされていない NXE フラグを EFER に書き込もうとして例外が発生し、ロングモードに遷移前なので例外処理が実装されていなくてそのままトリプルフォルトでシステム停止したということですね。

・・・本当かな?

そもそも同じ型番の Atom を搭載している GPD では普通に NX ビットが使えますし、さいきんの Windows は NX ビットをサポートしていない CPU では起動しないはずです。
ということで、 Windows を起動して cpuid の情報が見れるアプリケーションを実行してみます。

NX サポートしてますね。

意味がわかりません。

釈然としませんでしたが、その日はそれ以上わかりませんでした。

後日、 MSR にこんなフラグがあるという情報をいただきました。

f:id:neriring16:20190316145854p:plain

XD bit というのは NX bit の Intel 方言で、実体は同じものです。
このフラグを操作するコードを追加して起動してみると、 cpuid の NX feature bit がセットされた状態で起動して EFER MSR の NXE フラグをセットしても例外が発生することなく動作するようになりました。

地獄のデバッグを経て一連の問題は全て解決しました。

tl;dr

cpuid の feature bits はモデル固定ではありません。 MSR 等を操作すると実行中に変化することがあります。

ACPI で電源を切るの補足

前回の ACPI 記事が当初の予定より長くなってしまって説明を省略した部分の補足したいと思います。

neriring.hatenablog.jp

FADT

FADT というのは Fixed ACPI Description Table の略で、 ACPI のもっとも基本的で重要なテーブルのひとつで、電源制御などの多くの情報が記述されています。 ACPI ではこのようなテーブルが他にも多数登場します。

FADT を実際に取得するにはまず RSDP (RSD PTR) という構造体を探します。

f:id:neriring16:20190311202535p:plain

RSDP の取得方法は efi_mainの引数の EFI System Tableの中にある ConfigurationTable という配列をスキャンします。
ここには ACPI 以外のファームウェアのテーブルも含まれており ACPI 1.x 系と ACPI 2.x 系でも内容が異なるので、例えば以下のように GUID が一致するかどうか調べながらスキャンします。
なお、 ExitBootServices API を呼び出すと ConfigurationTable の内容は無効になります。ExitBootServices API を呼び出す前に RSDP のポインターを保存しておきましょう。

#define EFI_ACPI_TABLE_GUID \
    {0x8868e871,0xe4f1,0x11d3, {0xbc,0x22,0x00,0x80,0xc7,0x3c,0x88,0x81}}

void* efi_find_config_table(EFI_SYSTEM_TABLE *st, CONST EFI_GUID* guid) {
    for (int i = 0; i < st->NumberOfTableEntries; i++) {
        EFI_CONFIGURATION_TABLE* tab = st->ConfigurationTable + i;
        if (IsEqualGUID(&tab->VendorGuid, guid)) {
            return tab->VendorTable;
        }
    }
    return NULL;
}

EFI_STATUS EFIAPI efi_main(IN EFI_HANDLE image, IN EFI_SYSTEM_TABLE *st) {
    rsdp = efi_find_config_table(st, &efi_acpi_20_table_guid);
}

レガシーBIOSのやり方はここでは省略します。(UEFI の方が簡単です!)

RSDP を取得できたらその中にある RSDT または XSDT のポインターを取得します。 RSDP は以下のような構造になっています。

typedef struct {
    char       signature[8];
    uint8_t    checksum;
    char       oemid[6];
    uint8_t    revision;
    uint32_t   rsdtaddr;
    uint32_t   length;
    uint64_t   xsdtaddr;
    uint8_t    checksum2;
    uint8_t    reserved[3];
} __attribute__((packed)) acpi_rsd_ptr_t;

レガシーBIOSの時代はメモリを広範囲にスキャンする必要があったので signature を確認したあと checksum や revision を確認する必要がありましたが、 UEFI では最初から GUID で ACPI のバージョンを特定しているので細かいチェックは省略して構いません。
rsdtaddr が RSDT のアドレス、 xsdtaddr が XSDT のアドレスとなります。

RSDT というのは Root System Description Table の略で、他のテーブルへのポインターの配列が格納された ACPI のすべてのテーブルの起点となるテーブルです。
XSDT は RSDT を64ビットに拡張したもので、以下のような構造になります。
これ以降のほとんどの ACPI テーブルは先頭に Header という共通構造があります。

typedef struct {
    char        signature[4];
    uint32_t    length;
    int8_t      revision;
    uint8_t     checksum;
    char        oemid[6];
    char        oem_table_id[8];
    uint32_t    oem_rev;
    uint32_t    creator_id;
    uint32_t    creator_rev;
} __attribute__((packed)) acpi_header_t;

typedef struct {
    acpi_header_t   Header;
    uint64_t    Entry[];
} __attribute__((packed)) acpi_xsdt_t;

古い ACPI 1.x の時代は RSDT しかサポートされていませんでしたが、現代の PC は ACPI 2.x になり XSDT をサポートしています。特別な事情がない限り通常は XSDT を使いましょう。

XSDT が見つかったら Entry の中には ACPI Header を持ったテーブルのポインターが並んでいるので、必要なテーブルのシグネチャをチェックしながら探します。

void* acpi_find_table(const char* signature) {
    for (int i = 0; i < n_entries_xsdt; i++) {
        acpi_header_t* entry = (acpi_header_t*)xsdt->Entry[i];
        if (is_equal_signature(entry->signature, signature)) {
            return entry;
        }
    }
    return NULL;
}

fadt = acpi_find_table("FACP");

注意点として Entry の要素数は XSDT では直接定義されておらず、 Header にある Length フィールドからオフセット (36) や要素のサイズ (64bitなので8バイト) を考慮して計算する必要があります。また、 64bit 値の配列ですが直前のヘッダーが 64bit でアラインされていないので注意が必要な場合があります。
なお、 ACPI のテーブルはほとんどが名称とシグネチャが一致しますが、 FADT は歴史的な事情で名称が「FADT」でもシグネチャは「FACP」という異なる名前になるので注意が必要です。

ここまでの手順で FADT が取得できました。

S5 オブジェクト

S5 オブジェクトには SLP_TYPx に出力するべき値が格納されたパッケージになっているというのはすでに書いた通りですが、これを AML にすると「08 5F 53 35 5F 12」 という並びから始まるバイト列になります。 ACPI では名前に「_」(0x5F) を詰めて強引に4文字にするルールがあるようです。
FADT にある DSDT というテーブルに格納されている AML から該当するバイト列パターンを検索します。ほとんどのオブジェクトはもっと複雑なツリー構造になっているのでこのような手段でアクセスできるオブジェクトは限られています。
なお、 0x08 と 「_」 (0x5F) の間に「\」(0x5C) が入ることもあるようです。(筆者はまだ見たことないです)

S5 のパースに必要な AML バイトコードは 0x08 (NameOP) 0x12 (PackageOP) 0x00 (ZeroOP) 0x0A (BytePrefix) の4種類です。

いくつかの S5 オブジェクトの例を見てみましょう。

実機では以下のパターンがよく見られるようです。

08 5F 53 35 5F 12 07 04 0A 07 00 00 00

これを展開すると以下のようになります。

08 (NameOP)
  5F 53 35 5F (名前 "_S5_")
12 (PackageOP)
  07 (以下の長さ)
  04 (要素数)
    0A (BytePrefix)
      07 (値1)
    00 (ZeroOP)
    00 (ZeroOP)
    00 (ZeroOP)

オブジェクト名「S5」で { 0x07, 0x00, 0x00, 0x00 } のパッケージということがわかります。

QEMU では以下のような内容になります。

08 5F 53 35 5F 12 06 04 00 00 00 00

これを展開すると以下のようになります。

08 (NameOP)
  5F 53 35 5F (名前 "_S5_")
12 (PackageOP)
  06 (以下の長さ)
  04 (要素数)
    00 (ZeroOP)
    00 (ZeroOP)
    00 (ZeroOP)
    00 (ZeroOP)

オブジェクト名「S5」で { 0x00, 0x00, 0x00, 0x00 } のパッケージということがわかります。

VirtualBox では以下のような内容になります。

08 5F 53 35 5F 12 06 02 0A 05 0A 05

これを展開すると以下のようになります。

08 (NameOP)
  5F 53 35 5F (名前 "_S5_")
12 (PackageOP)
  06 (以下の長さ)
  02 (要素数)
    0A (BytePrefix)
      05 (値1)
    0A (BytePrefix)
      05 (値2)

オブジェクト名「S5」で { 0x05, 0x05 } のパッケージということがわかります。
なぜか要素数が2になっています。

このように環境によってたった3ビットの SLP_TYPx の値は異なることがわかります。

tl;dr

たったふたつの OUT 命令にたどり着くまでにはこんなに色々あるのでした。

ACPI で電源を切る

いまどきの PC はすべて ACPI に対応しています。

ACPI について学習する大きなモチベーションのひとつが、 ACPI による電源切断だと思います。
これを覚えれば ACPI に対応した全てのコンピューターで電源を切断することができるようになり、自作 OS に shutdown コマンドが実装できるようになります!

ふたつの OUT 命令

その方法はこちらです。

out dx, al
out dx, ax

なんと、ACPI ではたった2つの OUT 命令で電源を切断することができます。
もちろん、実際には前処理があるので本当に OUT 命令だけで電源を切ることはできませんが。。

では、このふたつの OUT 命令は一体何なのでしょうか。

ひとつめの OUT 命令

ひとつめの OUT 命令はレガシーモードから ACPI モードに移行するために ACPI ENABLE というコマンドを SMI_CMD というポートに出力しています。
SMI_CMD という名前の通り SMM で動作しているファームウェアに対して通知するコマンドです。このコマンドが正常に終了すると ハードウェアがレガシーモードから ACPI モードに移行し、すべての ACPI 機能が OS の制御下になります。
HW reduced ACPI の機種は常に ACPI モードで動作しているためこの操作は定義されていません。

よくあるわかりやすい変化が、レガシーモードのときは電源ボタンを軽く押してすぐに電源切れたのが ACPI モードになると長押ししないと電源が切れなくなります。これは、 ACPI の電源ボタンが実は単なるボタンで、 ACPI モードでは押下時の処理がファームウェアから OS に移譲されるためです。
ACPI aware OS では起動時に実行されるコマンドですが、そうでない場合は電源を切る直前に実行した方が良いでしょう。
ACPI ENABLEを実行した後実際に有効になるまで PM1 Control Register の SCI_EN ビットが1かどうか調べます。

実際の SMI_CMD のアドレスや ACPI ENABLE コマンドの値は FADT テーブルに記述されています。

つまり、実際にはひとつめの OUT 命令は以下のようなシーケンスになります(これでも端折っていますが)

    mov dx, [FADT+SMI_CMD]
    mov al, [FADT+ACPI_ENABLE]
    out dx, al

    mov dx, [FADT+PM1a_CNT_BLK]
.loop:
    in ax, dx
    and ax, 0x0001 ; SCI_EN
    jz .loop

なお、 SMM で実装されている USB HID の PS/2 エミュレーションなども ACPI ENABLE を実行すると終了してしまいます。

ふたつめの OUT 命令

ふたつめの OUT 命令は、 PM1 Control Register に対してこれから S5 状態に遷移することを指示しています。
ACPI ではいろいろな電源状態が定義されていて例えば S0 は通常動作している状態で S5 は Soft Off 、つまり実際に電源を切断するということになります。

PM1 Control Register の実際のポートアドレスは FADT の PM1a_CNT_BLK から取得できます。 PM1a と PM1b の2種類定義するようになっていますが通常は PM1a の方を使います。 PM1b の存在理由はよくわかりません。

PM1 Control Register のフィールドは以下のように仕様書に定義されています。(抜粋)

f:id:neriring16:20190307235813p:plain

SLP_EN ビットを1にし、 SLP_TYPx を S5 ステートに相当する値にして PM1 Control Register に出力すれば S5 ステートに移行できることがわかります。
HW reduced ACPI の機種には PM Control Register が存在しない代わりに SLP_EN ビットとSLP_TYPx ビットのみ独立した Sleep Control Register というものがありますが、基本的な考え方はほとんど同じです。

しかし、ここまで細かく決まっているのに S5 のときに SLP_TYPx に具体的に何の値を出力すればいいのかは仕様では決まっていません。FADT にもこれ以上の情報はありません。
SLP_TYPx はたったの3ビットで取り得る値も8種類しかないので総当りしてみたくなるところですが、 S5 以外の電源状態でもこのコントロールポートを使用するため、よくわからない値を適当に出力すると誤動作の原因となります。

じゃあどこから情報を探せばいいのかというと、ここで AML が登場します。

AML というのは ACPI Machine Language の略で、 ACPI で管理された各種デバイスの構成情報や ACPI で決められた操作に対するメソッドの定義などが収められた規格です。
主に仕様書にテキストで表記されている ASL に対し、 AML は ASL をバイナリに翻訳して ACPI テーブルに実際に格納されているデータになります。

AML はツリー構造で PC に接続されているあらゆるデバイスの情報が定義された巨大なデータ構造です。
真面目に自力でパースすると大変すぎて死んでしまいますが、今は電源を切断したいだけなので「S5」という名前のオブジェクトを単純にスキャンするだけで十分です。

実際の AML では「S5」オブジェクトは「_S5_」という名前で記述されています。 S5 オブジェクトが見つかったら、それに続く中身は4つのバイト値のパッケージになっていることが規格で決まっています。
1つ目の値が PM1a に出力するべき値、2つめの値が PM1b に出力するべき値となっているのでこれをデコードします。

S5 を探すコードはこんな感じになります。

uint8_t *p = (uint8_t*)&dsdt->Structure;
size_t maxlen = (dsdt->Header.length - 0x24);
for (size_t i = 0; i < maxlen; i++) {
    if (!is_equal_signature(p+i, "_S5_")) continue;
    if ((p[i+4] != 0x12) || ((p[i-1] != 0x08) && (p[i-2] != 0x08 || p[i-1] != '\\'))) continue;
    i += 5;
    i += ((p[i] & 0xC0) >> 6) + 2;

    if (p[i] == 0x0A) i++;
    SLP_TYP5a = 0x07 & p[i++];

    if (p[i] == 0x0A) i++;
    SLP_TYP5b = 0x07 & p[i++];

    break;
}

S5 に切り替えるための値がわかったら、実際に OUT 命令で出力すると電源が落ちます。

mov dx, [FADT+PM1a_CNT_BLK]
mov ax, [SLP_TYP5a]
shl ax, 10
or ax, 0x2000
out dx, ax

なお、PM1b が存在する機種では同じことを PM1b に対しても実行する必要があるようです。

値を求めるまでが長いだけで、実際の処理はたったひとつの OUT 命令で電源が落ちました。