借り初めのひみつきち

仮ブログです。

最小ステップで作る UEFI OS v0.4

そろそろ GUI の実装に入りたい感じですね。

github.com

メモリの拡張

現在実装されているメモリ管理はコンパイル時に静的に割り当てたブロックを切り取って分配する手抜き実装でした。
今まではそこまで大量のメモリを必要としていなかったのでこれでよかったのですが、グラフィックス関係の処理を実装すると MB 単位のメモリが必要になるのでこの方法だとうまく動かなくなってきました。
かといって本格的なメモリ管理を実装するのも大変なので、 GetMemoryMap からもらったメモリマップから使えそうな大きさのメモリブロックを見つけてきて分配するように変更しました。
この変更によって実行時に確保するメモリ領域は EFI アプリケーション領域と完全に別の領域から割り当てられるようになったので、ポインタの値が直接見える場所では今までと大きく異なる値が表示されるようになります。

Window Managerの実装

まだちゃんとした Window までは実装できてませんが、今まで描画系の関数はすべて画面に直接描画していたのを「デスクトップウィンドウ」に描画するように変更しました。
マウスカーソルも独立したウィンドウに描画するように変更したので、マウスの移動で画面が崩れなくなりました!

また、今までコンソールのスクロールは直接画面を読み込んでコピーしていたので実機で動かすととても遅かったですが、スクロール処理と画面描画が完全に分離されたのでスクロールが早くなっています。

f:id:neriring16:20181111211210p:plain

だいぶ動くようになってきました。
次は複数のウィンドウの重ね合わせ描画とかですかね?

最小のEXEファイル?

EXEファイルにはいろいろ形式がありますが、現在主流なのはPEという形式です。

これはもともとUN*X方面で使われていたCOFFという実行ファイルの形式にWindowsのために必要な機能を拡張したもので、PE-COFFなどの名称で呼ばれることもあります。
本家UN*X系OSではCOFFでは機能が不十分になったために現在ではELF形式が主流となっていますが、Windowsの世界ではCOFFを独自拡張したPE形式をそのまま使い続けています。
そもそもEXE形式はMS-DOS用の実行ファイル形式だったMZ形式から始まり、いくつかの追加ヘッダをつけた亜種があり、最終的に追加でPEヘッダを付けたPE形式が現在の主流となっています。

そういう経緯で生まれた形式のため、MZからPEに辿るまでの互換性のための項目があったり、元々のCOFFにあった今では使われていない機能があったり、PEで拡張された項目にもあまり使われていない機能があったり・・・よく見るとけっこうスカスカです。

そして、スカスカのヘッダーの使われていない部分を切り詰めて小さい EXE ファイルを作る遊びが一時期流行りました。
世界レコードが何バイトかは知らないですが、ハローワールドが256バイト切れた時代もありました。

Windows XP SP2 がでたとき、実は密かにヘッダーのチェックが厳しくなっていて、それまで使えたテクニックのいくつかは修正が必要でした。その後、遊びも下火になってぼく自身もほとんど忘れたまま何年も経ちました。

EXE ファイルはどこまで小さくできるか限界を探すという懐かしい話題を見かけ、昔を懐かしんで古い最小バイナリたちを見てみると Windows 10 ではどれも動かなくなっていました。

そこで現在の最小らしいバイナリを改良していったところ、ある程度小さくすることには成功しました。
しかし、いじっていくと突然バイナリを生成した瞬間マルウェアに感染しましたと出てきてバイナリが消滅しました。

どうもマルウェアの中にはヘッダの値がでたらめなものがあるため、最近はチェックが厳しくなっているのだそうな・・・

ぼくはただゴルフを遊んでいただけなのに、突然警察が来てこの場所は違法になったってボールを没収して行くのです。
ぼくの目にはこないだまで普通に遊べたただの空き地にしか見えないのに。

最小ステップで作る UEFI OS v0.3.1

前回はコンテキストスイッチを実装しましたが FPU(SSE) のコンテキストスイッチには未対応だったので実装します。

Lazy FPU Context Switch

FPU や SSE はレジスタが多く汎用レジスタに比べてコンテキストスイッチのコストが高いわりにほとんどのプログラムではこれらの機能をほとんど使用しません。
ほとんど使われていない大量のデータを毎回メモリに出し入れするのは壮大な無駄なので Lazy FPU Context Switch という手法がよく使われます。

これはコンテキストスイッチの時点では FPU の状態はそのままにしておいて、はじめて FPU 命令を実行しようとした時に CPU に例外を生成してもらって FPU のコンテキストを切り替えする方法で、 FPU を使わないスレッドに切り替えた場合は例外が発生しないので何もしくて済みます。
具体的には CR0 の TS フラグを 1 にセットすると 例外 07 (#NM) が発生するので、例外ハンドラで TS フラグをリセットした後 FPU のレジスタ退避・復帰を行います。
x86 のタスク機能を使って切り替えた場合は TS フラグが自動で 1 になりますが、 x64 ではこの方法が使えないのでコンテキストスイッチの時に手動で TS フラグをセットする処理を入れます。

SSE に対応する場合は初期化時に CR4 の OSFXSR ビットをセットし、レジスタの退避・復帰には FXSAVE 命令や FXRSTOR 命令を使います。
ちなみにこのフラグの名前 SSE と関連がさっぱり不明ですが、実は SSE 対応 CPU は SSE モードの有効無効を切り替えるというわけではなく、 SSE は常に有効であるが OS が FXSave/FXRstor 命令で責任を持ってコンテキストスイッチの処理をするから拡張レジスタにアクセスできることを CPU に通知するフラグとなっています。

0.3 までは何も考えず SSE が使えていましたが、これは x64 が ABI レベルで SSE の存在を保証しているため UEFI が自動で OSFXSR フラグをセットしていたからで、それでコンテキストスイッチで問題が発生しなかったのはメインスレッド以外 SSE を使用していなかったためです。

とつぜんの #UD

SSE のコンテキストスイッチに対応したので SSE を利用するスレッドを並べてデモを作っていたところ、謎のタイミングで #UD 例外が・・・

例外の発生するアドレスを調べてみると longjmp 関数から戻ってきたアドレスに longjmp 関数自体に noreturn 属性がついているために ud2 命令でトラップがかけられていた例外でした。
そもそも longjmp 関数は jmpbuf の中身を展開してジャンプしてるのでこの関数が戻ることは決してないはずですが・・・

調べた結果、コンテキストスイッチで longjmp 関数が呼び出されてる間に setjmp 関数が重複して呼び出されて jmpbuf が破壊されてるようでした。割り込みが怪しかったので CLI 命令で割り込み禁止してもかわらず。

試行錯誤の結果、

ということで、コンテキストスイッチそのものを排他制御して同時にコンテキストスイッチが発生しないようにすることでこのバグは収拾し、無事に複数のスレッドで動作するようになりました。
排他制御大事

f:id:neriring16:20181029000053p:plain

この対応で数日かかったので何をしようとしていたのか忘れてしまいました。

最小ステップで作る UEFI OS v0.3

もう UEFI とあんまり関係なくなってきましたが OS の開発を続けていこうと思います。

github.com

スレッディングの実装

まずはスレッディングを実装します。

コンテキストスイッチには setjmp/longjmp を使います。
これによって setjmp/longjmp の中身とスレッドを起動した最初の状態を作るための関数のみアセンブリ言語で記述し、それ以外の処理は C 言語で書けるようになります。

ハードウェアマルチスレッディングには対応していません。
現時点ではまじめにスケジューリングしていないので全てのスレッドが同じキューを共有します。
SSE のコンテキスト切り替え対策を入れていないので SSE のコード実行するとクラッシュすることがあります。

PS/2 マウスの実装

スレッディングを実装したことによって HID 関係のタスクをメインスレッドで気にしなくてよくなったので、 HID スレッドを実装してマウスを処理します。
現時点ではカーソルが動き回ると画面が破壊されます。

f:id:neriring16:20181021205842p:plain

次はマウスカーソルで画面壊れない実装ですかね?

世界一有名な人。

世界で最も有名な人は誰だろう?

候補をあげるなら一人目は Phil Katz だと思う。
彼は PKZIP を開発し、彼のイニシャルはたくさんの ZIP ファイルの先頭に刻まれている。
また、 ZIP ファイルはしばしば名前を隠して至る所に存在している。
何億台だかの PC で走るのに客先の PC ではさっぱり走らないあのソフトウェアとかね。

もう一人候補を挙げるなら Mark Zbikowski かな。
MS-DOS version 2 に数々の革新的機能を実装した彼のイニシャルはたくさんの EXE ファイルの先頭に刻まれている。
アンチ M$ 教徒のあなたも安心してほしい。
ROM-BIOS やブートパーティションにある EFI ファイルには確かに彼のイニシャルが刻まれている。

他に有名な人は誰がいるだろう。

最小ステップで作る UEFI OS v0.2.1

v0.2 で忘れていたことがあったので補足します。

EFI Runtime Services の構成

ExitBootServices を呼び出した後も EFI Runtime Services を使うためには GetMemoryMap で取得したメモリマップの VirtualStart の値を適切に設定し、一度だけ SetVirtualAddressMap を呼び出す必要があります。
現在作っている OS では当面 Identity Mapped Paging を採用するので VirtualStart = PhysicalStart で設定しますが、本格的にページングを使う場合は他の設定にかえる必要があるかもしれません。

EFI Runtime Services には再起動や電源を切る API 、時計にアクセスする APIEFI 変数にアクセスする API などがあります。
時計は起動時に一度取得したらあとはタイマー割り込みで本体の時計と独立してカウントしていくのが主流ですし、再起動や電源を切る API も最後に1回呼び出すだけですが、実際には多くの機種では UEFI に頼らなくても再起動したり電源を切ることができます。
EFI 変数はコンピュータの起動に関わるものが多く、下手に触ると文鎮になって起動しなくなるものもあるので知らないうちはあまり触らないほうがいいかもしれません。
こうして考えると Runtime Services の API はあまり使う機会が多くありません。

さて、ここまで対応すると「UEFI Aware な OS」と名乗れそうな感じになるわけですが、これ以降は Legacy BIOS だろうが UEFI aware だろうがやることはほとんど変わりません。ただの普通の OS 開発になってしまいます。
OS の開発自体はまだ続けていくつもりですが、「最小ステップで UEFI から OS を起動する」という目的は達成できたのでこの辺で一区切りかなって思ってます。

最小ステップで作る UEFI OS v0.2

前回は IDT の初期化が終わって割り込みが処理できるようになりましたが、まだ外部割り込み (IRQ) の処理ができなかったので実装します。

github.com

ほぼ全ての OS はタイマー割り込みをサポートし、タスクスイッチなどの重要な役割を持っています。
これらは i8259 (PIC) と i8253 (PIT) という 8bit の時代から存在したチップで実現されていまたが、現代の OS とは相性が悪いためほとんど使われていません。レガシー OS では核となる最重要デバイスのためになかなか置き換えが進まず、最後まで残っているレガシーデバイスのひとつでした。
今後は BIOS を廃止して UEFI の時代がくることが決定しているので、おそらくそのうちなくなる機能だと思います。

というわけで、今回作る OS では PIC/PIT の機能を使わず、より現代的な APIC/HPET を使います。

1. APIC の初期化

APIC は PIC よりも拡張された割り込みコントローラーです。
ACPI から MADT テーブルを取得し、その情報を元に初期化します。

void apic_init() {
    acpi_madt_t* madt = acpi_find_table(ACPI_MADT_SIGNATURE);
    if (madt) {
        ....
    }
}

APICマルチプロセッサと密接な関係にあり、シングルソケットでもマルチコアに対応していて実質2〜4個のマルチプロセッサ構成になっている現代の PC ではマルチプロセッサに対応できない PIC は機能が不十分です。
APIC には IO APIC と Local APIC の2種類あり、これらは全く別物です。 Local APIC は名前の通り各 CPU コアに1つ内蔵されているの対し、 IO APIC は CPU の外にあって IRQ 割り込みを各 CPU の Local APIC に分配するためにあります。

マルチプロセッサのための分配機能、レベルトリガーとエッジトリガーの設定ができるようになったなどなど PIC から拡張されてる面もありますが、シングルプロセッサで使う分には PIC と基本的な概念はあまり変わりません。

その他大きな違いとして、 PIC は I/O 空間にあったので I/O のための専用命令でしかアクセスできませんでしたが、 APICMMIOバイスなので見た目は普通のメモリアクセスと同じようにアクセスします。また、新しい CPU には APIC を拡張した x2APIC 等があり、そちらは MSR 経由でアクセスします。

2. HPET の初期化

HPET は PIT を代替する高精度タイマーです。
ACPI から HPET テーブルを取得し、その情報をもとに初期化します。

void hpet_init() {
    acpi_hpet_t* hpet = acpi_find_table(ACPI_HPET_SIGNATURE);
    if (hpet) {
        ....
    }
}

カウンタが増えたり高精度になったりしましたが、できることは PIT とあまり変わってません。
HPET も APIC 同様に MMIOバイスなので見た目は普通のメモリアクセスと変わりません。

仕様上レベルトリガーでも動作するように見えますが、手元の環境でうまく動作しなかったのでエッジトリガーモードで使うことにします。

3. PS/2 の初期化

>突然のレガシーデバイス!<

APIC と HPET が動いて OS ぽいものの動作に必要な最小限のデバイスの初期化が終わったのでこの辺でユーザーが操作できるものが作りたいと思いましたが、 USB スタックを実装するにはまだまだやることがたくさんあるので簡単に対応できる PS/2 を実装します。

残念ながら PS/2 ポートは現在進行形で消え行く運命のポートで最近の PC だとサポートしていない機種も多いです。
PS/2 ポートの存在は ACPI の FADT テーブルの IAPC_BOOT_ARCH 内のフラグでかんたんに確認できる予定でしたが、いくつかの環境でこのフラグが嘘をついている実装を見つけてしまいました。
より確実な判別方法は AML を辿って PS/2バイスが存在するかどうか見ていけばよいのですが、流石に ACPI の分厚い仕様書の大半を占めている AML を実装するのは大変なので諦めることにします。*1

f:id:neriring16:20181013182935p:plain

とりあえず、一部の機種だけではありますが、ユーザーの入力を受けて何か出力できる最小限の環境 (Minimal Operating Environment) が整ったので手抜きな簡易シェル作ってみました。手抜きシェルなのでいろいろ残念仕様です。

キーボードの入力して表示するだけってここまで何行もかけて PS/2 しか対応できなかったのに、 UEFI なら数行で PS/2 でも USB でも対応してるし、さらにもっといろんな機能サポートしてる UEFI すごいですね。

*1:ACPICAを使うといいです