借り初めのひみつきち

仮ブログです。

地獄のデバッグ

ある日家にあった 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 命令で電源が落ちました。

x86の割り込みが遅いワケ

CPUが現在実行中のプログラムを中断して処理しなければならない事象が発生した時、一般に「割り込み」というメカニズムを使ってその事象を処理します。

広義の割り込みは実際には以下の3種類に分類できます。

  • 例外

CPUが命令の実行を継続できない事象が発生したときにOSに判断を委ねるために例外が発生します。
多くの場合はプログラムのミスによるエラーや悪意あるプログラムによる不正な特権操作を行おうとした場合に例外が発生してアプリケーションが終了しますが、全ての例外が必ずしもエラー終了となるわけではなく、ページフォールトのようにOSの処理によって命令実行が続行可能な場合もある例外もあります。

  • 外部割り込み

外部デバイスのデータ転送が終了したり、データ転送要求があったり、タイマーの状態が変化したときなどにCPUに通知するために割り込みが発生します。狭義の割り込みはこれを指します。
x86は歴史的事情により通常のマスク可能割り込み(IRQ)とマスク不可能割り込み(NMI)の2種類に大別されます。マスク可能割り込みはフラグレジスターのIFフラグで割り込みをマスク(禁止)することができることからこの名前で呼ばれます。IRQはデバイスから直接通知される場合とPICやAPICを介して割り込みが通知される場合があります。Local APICを使ってIPIを送信する場合もマスク可能割り込みの一種として通知されます。

  • ソフトウェア割り込み

専用の命令を使って任意のタイミングでソフトウェア的に割り込みを発生させる場合に使います。
多くの場合はOSを呼び出すために使われるのでシステムコールと同一視されることもあります。

それぞれの詳細については今回の記事では重要ではないので省略します。

x86の場合、ほとんどの割り込みは割り込みベクターと呼ばれる0番〜255番までの256種類の番号で割り込みの要因を区別します。*1割り込みベクターの0番〜31番まではIntelによって例外のために予約されているため、通常のOSは外部割り込みやソフトウェア割り込みをこの範囲外のものを割り当てます。なお、レガシーBIOSが使用する割り込みは例外が多数定義されて予約のルールが徹底される前に設計されたので定義済みの例外と被っているものがたくさんあります。

さて、割り込みが発生するとCPUはまずメモリ上のIDTというテーブルからゲートディスクリプターというものを探します。
ゲートディスクリプターには割り込み先のCS,xIPレジスタの値やディスクリプターの属性などの情報が記述されています。
ゲートディスクリプターには割り込みで使えない種類のものも存在するので、CPUはディスクリプターが本当に存在するか、割り込みに適合したものかなどをチェックします。このチェックで不適切なディスクリプターと判断されると一般保護例外が発生します。一般保護例外も処理できないとダブルフォールトやトリプルフォールトという擬似的な例外が発生してCPUが停止し、通常はトリプルフォールトが発生するとリセットがかかるようにハードウェア設計されています。開発途中のOSでは例外がうまく処理できないとよくリセット地獄に陥りデバッグが困難です。

ゲートディスクリプターのチェックが終わると、そこに記述されているコードセグメントをロードしようとします。
セグメントのロードはメモリにあるGDTやLDTというテーブルに記述されたセグメントディスクリプターというものを読み込みます。
ここでもIDTのときのようにディスクリプターが適切なものかどうかチェックされ、不適切だった場合は一般保護例外などの例外が発生します。

多くの場合、割り込みが発生した時のコンテキストはユーザーモードのアプリケーションプログラムであるのに対し、割り込みを処理するプログラムはカーネルモードのシステムプログラムであるケースがほとんどです。
カーネルモードとユーザーモードを切り替えるときはお互いのスタックに不適切にアクセスされないようにスタックを自動的に切り替える機能がCPUにあります。このときTSSという構造体から新しいスタックセグメントとスタックポインターを読み込みます。
スタックセグメントはコードセグメントと同様にGDTやLDTからセグメントディスクリプターをロードしてチェックが行われ、不適切だった場合はスタック例外が発生します。

ここまで準備が終わると、割り込み処理のためのCS,xIPの内容がゲートディスクリプターからロードされ、割り込み発生前のCS,xIP,フラグレジスターなどがスタックにPUSHされ、やっと割り込み処理プログラムに制御が移ります。
割り込みを終了するときはIRET命令を実行するとスタックからCS,xIP,フラグレジスターを復元し、必要な場合は追加でSS,xSPも復元します。このときもセグメントレジスターは前述のようにセグメントディスクリプターから読み込んでチェックが行われます。

とても複雑ですね。

x86IDTには割り込みゲート、トラップゲート、タスクゲートを置くことができ、それぞれに16bitと32bitのバージョンが存在しました。また、大昔はメモリ空間の保護にセグメンテーション機構が使われており、これらのディスクリプターの内容が非常に重要だったため厳密にチェックする必要がありました。

x64になるとIDTに配置できるディスクリプターの種類が減り、64ビット固定の割り込みゲートとトラップゲートのみになりました。この2つの違いは自動的に割り込みを無効に設定するかどうかだけなので実質的にはほとんど同じものです。また、セグメンテーションも大幅に機能縮小され、通常の状態ではほぼ意識する必要がなくなりました。
にも関わらず、x64の割り込みのメカニズムはx86の時代とほとんど変わっていません。
拡張を繰り返した結果ゲートディスクリプターの構造は複雑奇怪になっています。
本来割り込みゲートに必要な情報は割り込み先のRIPとわずかな追加情報、セグメントの切り替えもカーネルモードかユーザーモードかのフラグを1ビットを切り替えるだけで必要十分なはずですが、互換性のためかx86の複雑な機構をほぼそのまま継承して引きずっています。

システムコールに関しては現代のOSでは割り込み命令を使わず専用の命令(sysenter,syscallなど)を使ってディスクリプターのロードやチェックなどを省略できるものが主流になっています。

通常の割り込みでもこの辺りが改良されると高速に割り込み処理できるようになると思うのですが…

*1:SMIやLocal APICのStartup IPIなどのようにベクター番号の存在しない特殊な割り込みも一部存在します。

ACPI BGRT

昔同じタイトルの記事を書いたところ結構googleしてる人がいるみたいなので、ちゃんとした記事のせときますね:;(∩´﹏`∩);:

win8くらいの時からPCが起動する時にメーカーロゴが表示された状態のままwindowsが起動してることにお気づきでしょうか?

この仕組みは ACPI にある BGRT (Boot Graphics Resource Table) というテーブルで実現されています。

このテーブルは以下のような構造になっています。

typedef struct {
    acpi_header_t   Header;
    uint16_t    Version;
    uint8_t     Status, Image_Type;
    uint64_t    Image_Address;
    uint32_t    Image_Offset_X, Image_Offset_Y;
} __attribute__((packed)) acpi_bgrt_t;

この中で重要なのは Image_Address, Image_Offset_X, Image_Offset_Y の3つです。
Image_Address の指しているアドレスには BMP ファイルがそのまま格納されています。
Image_Offset_X/Y は実際にイメージを表示する左上の座標になります。解像度を変更した場合は不正な座標になります。
残りの項目は ACPI 共通のお作法だったり、将来拡張があった時のためだったりします。(たぶん使われないでしょう)

実際にこれを使う場合、 EFI_SYSTEM_TABLE の ConfigurationTable から ACPI テーブル(RSD Ptr)を検索し、 XSDT から BGRT を探し、 BGRT から画像を取り出すという手順になります。
ということで表示するプログラムを作ってみました。

github.com

f:id:neriring16:20181220212425p:plain

BIOS で消耗するな、 UEFI で消耗せよ。

この記事は 自作OS Advent Calendar 2018 - Adventar の記事です。

未来ある若者よ、 BIOS で消耗するな UEFI で消耗せよ。

Intel は 2020 年までに CSM (BIOS 互換機能) を撤廃して UEFI に完全移行する方針を発表しました。つまり、もう BIOS に未来はありません。
すでに UEFI Class 3 と呼ばれる BIOS 互換機能をサポートしていない PC も増えてきているので決して楽観視できる出来事ではありません。
近い将来 BIOS に依存した OS は実機で起動しなくなり、起動する PC を求めて秋葉原の裏路地を彷徨う必要があるかもしれません。

彼の者の名は UEFI

BIOS は40年近く前からあって設計が古く問題も多いです。 BIOS にあった根本的な問題を解決するために UEFI は生まれました。

BIOS モードで起動すると CPU は歴史ある 16bit モードで起動します。
このモードでは CPU 本来の機能のいくつかが実質的に使えない状態になっていて、メモリもたったの 640KB しか通常の方法ではアクセスできません。 640KB がどれくらい少ないかというといまどきの CPU キャッシュよりも少ないです。
この状態から 64bit モードに移行するには PC の歴史を辿るかのようにいくつかのステップが必要です。
しかし、 BIOS は 16bit モードを前提としているのでモード遷移すると多くの BIOS にアクセスできなくなる問題もあります。
一方 UEFI なら最初から 64bit で最低限の設定された状態で起動し、 64bit モードから API にアクセスできます。

BIOS モードは16色で解像度の荒いテキストしか表示できない画面モードで起動します。
これには合理的な理由も存在していて、 たったの 640KB のメモリしか扱えない 16bit モードではそもそもまともなグラフィックス処理をする余裕がありません。
一方 UEFI は最初から美しいグラフィックスモードで起動し、多くの場合はディスプレイ本来の解像度がそのまま使えます。
テキストモードは存在しませんが、テキスト BIOS 相当の機能を提供するグラフィカルコンソール API があります。

BIOS モードで OS を起動するにはたったの 512 バイトしかないローダーを通常のファイルシステムからアクセスできない特殊な場所に書き込む必要があります。
BIOSファイルシステムに関与しないので自力でファイルシステムを解釈してシステムに必要なファイルをロードする必要があります。
ほとんどの OS はこの処理をたったの 512 バイトでは書ききれないので、一旦小さなローダーをロードし、そこから改めてシステムファイルをロードしたり別のチェインローダーをロードしたり複雑な起動処理が必要になります。
また、複数の OS を切り替えて起動する用途は想定していない構造になっていたり、最近の大容量ストレージ技術には適合しないなどの問題もあります。
一方 UEFI は少なくとも FAT ファイルシステムをサポートしており、通常の OS でファイルアクセスを行うのと同じような感覚で API を使ってシステムに必要なファイルをロードすることができます。
また、起動する際のファイルパスの指定の仕方なども仕様として決まっているので複数の OS を切り替えて起動するのが容易にできます。

BIOSx86 CPU と IBM PC アーキテクチャに強く依存していましたが、 UEFI はそれらに依存しないようになっていて ARM などにも移植されています。
拙作のブートローダーもコンパイルオプションを変えるだけで ARM などでも動く事が確認できています。

このように、 BIOS は設計が古いのでモダンな OS を起動するにはたくさんのステップを踏む必要がありますが、 UEFI なら最初から考えなくていい問題が多いです。未来のない BIOS で消耗するより UEFI で解決して OS 本体を作る時間に費やした方がお得ですよね。

UEFI 最初のステップ

さて、 UEFI でハローワールドを調べるとこんな感じのソースがよく出てくるかと思います。

#include "efi.h"

EFI_STATUS EFIAPI efi_main(IN EFI_HANDLE image, IN EFI_SYSTEM_TABLE *st)
{
  st->ConOut->OutputString(st->ConOut, L"Hello, world!\r\n");
  for (;;)
    ;
}

まあ、ハローワールドといえばハローワールドなんですが、ここからどうやって OS に繋げていけばいいのかよくわからないですよね。ぼくはわかりません。

efi_main の引数のひとつめはプログラム自身のハンドルで、これは UEFI は他にもデバイスドライバーなどのサービスがロードされているのでいくつかの API を呼び出す際に区別するために必要になります。
ふたつめの引数は UEFI API を呼び出すための重要なテーブルで、この中に API を呼び出すポインタなど UEFI のすべての情報が入っています。

システムテーブルの定義はこんな感じになります。

typedef struct {
	EFI_TABLE_HEADER Hdr;
	CHAR16 *FirmwareVendor;
	UINT32 FirmwareRevision;
	EFI_HANDLE ConsoleInHandle;
	EFI_SIMPLE_TEXT_INPUT_PROTOCOL *ConIn;
	EFI_HANDLE ConsoleOutHandle;
	EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *ConOut;
	EFI_HANDLE StandardErrorHandle;
	EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL *StdErr;
	EFI_RUNTIME_SERVICES *RuntimeServices;
	EFI_BOOT_SERVICES *BootServices;
	UINTN NumberOfTableEntries;
	EFI_CONFIGURATION_TABLE *ConfigurationTable;
} EFI_SYSTEM_TABLE;

この中にある BootServices と RuntimeServices が UEFI のメイン API に当たりますが、2つあるのは UEFI の動作環境が関係しています。

BIOSMS-DOSデバイスドライバを起源としているので OS が起動した後も動き続けていて明確な終了の概念がありませんでしたが、 UEFI では OS 起動前と起動後が明確に区別され、 BootServices という環境と RuntimeServices という大きな2つの環境があります。*1
BootServices 環境は OS が起動する前に UEFI が簡易的な OS となってさまざまなサービスを提供する環境で、一般に UEFI という言葉でイメージされる環境が BootServices になります。
一方、 RuntimeServices 環境は UEFI から OS に完全に制御を受け渡した環境で、この状態では UEFI のほとんどのサービスが終了していて使える UEFI API はあまり多くありません。
BootServices 環境から RuntimeServices 環境へは OS の起動時に一度だけ移行でき、その逆の移行は再起動するしかありません。大昔のリアルモードとプロテクトモードの関係に少し似ています。

なお、以後 EFI_SYSTEM_TABLE は ST、EFI_BOOT_SERVICES は BS、EFI_RUNTIME_SERVICES は RT と略する事があります。

ExitBootServices

BootService 環境と RuntimeServices 環境を切り替えるための UEFI で最も重要な APIBootServices.ExitBootServices です。

BootServices.ExitBootServices は MapKey という引数をとり、実行に失敗する可能性のある API です。
MapKey は BootServices.GetMemoryMap を呼び出すと取得できますが、メモリマップ情報が更新されるたびにこの値は変化します。
OS が最新のメモリマップ情報を正しく取得していないとデタラメなメモリマップ情報を元に起動してしまう危険性があります。
そこで、最新のメモリマップ情報を取得しているか確認するために ExitBootServices の実行時に GetMemoryMap の戻り値の MapKey をチェックしています。
なお、 ExitBootServices は一度実行したら失敗しても元の UEFI BootServices 環境に戻ることはできず BootServices 環境と RuntimeServices 環境の中途半端な状態になってしまいます。実は正しい手順で ExitBootServices を呼び出しても初回にメモリマップが更新されて必ず失敗する環境もあるので適切なリトライ処理が必要です。
また、 GetMemoryMap に必要なバッファーのサイズも実行前にわからないのでリトライ処理が必要になります。これらを考慮するとこんな感じのコードになります。

EFI_MEMORY_DESCRIPTOR* mmap = NULL;
UINTN mmapsize = 0;
UINTN mapkey, descriptorsize;
UINT32 descriptorversion;

do {
    status = bs->GetMemoryMap(&mmapsize, mmap, &mapkey, &descriptorsize, &descriptorversion);
    while (status == EFI_BUFFER_TOO_SMALL) {
        if (mmap) {
            bs->FreePool(mmap);
        }
        status = bs->AllocatePool(EfiLoaderData, mmapsize, (void**)&mmap);
        status = bs->GetMemoryMap(&mmapsize, mmap, &mapkey, &descriptorsize, &descriptorversion);
    }
    status = bs->ExitBootServices(image, mapkey);
} while (EFI_ERROR(status));

UEFI で OS を実行する最低限必要な処理は以上になりますが、 ExitBootServices を実行してしまうとシステムテーブルの内容はクリアされてしまってアクセスできなくなります。
対象になるハードウェアを完全に把握していればこれだけでも OS を作れそうな感じもしますが、 普通の PC を対象とするならハードウェアの情報を集める必要があります。

EFI_GRAPHICS_OUTPUT_PROTOCOL

まずは画面表示のためにグラフィックスの情報を取得します。
最低限必要なコードは以下のようになります。

    EFI_GRAPHICS_OUTPUT_PROTOCOL* gop = NULL;
    status = bs->LocateProtocol(&EfiGraphicsOutputProtocolGuid, NULL, (void**)&gop);

UEFI ではシステムテーブルにない API はこのサンプルのように BootServices.LocateProtocol などの API を使って GUID を指定して取得する事が多いです。似たような API は何種類かあります。
また取得した構造体を UEFI では Protocol と呼びますが中身は大抵関数ポインタの集まった構造体で、GUID と Protocol の定義は UEFI 仕様書にたくさんのっています。
実は SDK やバージョンによって同じ Procotol でも微妙に名前が違う事がありますが、重要なのは GUID の値と Protocol 構造体の定義です。

EFI_GRAPHICS_OUTPUT_PROTOCOL の内容は以下のような感じになります。
EFI_GRAPHICS_OUTPUT_PROTOCOL は GOP と略して呼ぶ事が多いです。

typedef struct {
	EFI_GRAPHICS_OUTPUT_PROTOCOL_QUERY_MODE QueryMode;
	EFI_GRAPHICS_OUTPUT_PROTOCOL_SET_MODE SetMode;
	EFI_GRAPHICS_OUTPUT_PROTOCOL_BLT Blt;
	EFI_GRAPHICS_OUTPUT_PROTOCOL_MODE *Mode;
} EFI_GRAPHICS_OUTPUT_PROTOCOL;

最初の3つは関数ポインタになっていて BootServices 環境で呼び出すことができる API ですが、 RuntimeServices 環境で必要なのは最後の Mode の中身で、以下のような構造体になっています。

typedef struct {
	UINT32 MaxMode;
	UINT32 Mode;
	EFI_GRAPHICS_OUTPUT_MODE_INFORMATION *Info;
	UINTN SizeOfInfo;
	EFI_PHYSICAL_ADDRESS FrameBufferBase;
	UINTN FrameBufferSize;
} EFI_GRAPHICS_OUTPUT_PROTOCOL_MODE;

typedef struct {
	UINT32 Version;
	UINT32 HorizontalResolution;
	UINT32 VerticalResolution;
	EFI_GRAPHICS_PIXEL_FORMAT PixelFormat;
	EFI_PIXEL_BITMASK PixelInformation;
	UINT32 PixelsPerScanLine;
} EFI_GRAPHICS_OUTPUT_MODE_INFORMATION;

2つの構造体に分かれているのでちょっと複雑になりますが、画面サイズは GOP->Mode->Info->HorizontalResolution と GOP->Mode->Info->VerticalResolution 、実際の VRAM の開始アドレスは GOP->Mode->FrameBufferBase、座標から VRAM アドレスを計算する時に必要になるのが GOP->Mode->Info->PixelsPerScanLine、VRAMのサイズは GOP->Mode->FrameBufferSize などが主な情報です。
PixelsPerScanLine は HorizontalResolution と同じ値になることが多いですが、稀に異なる場合があるので考慮する必要があります。
PixelFormat は通常の PC では PixelBlueGreenRedReserved8BitPerColor という値になり、これは 32bit の RGB (ARGB) 値を VRAM に書き込めばそのとうりの色が表示されます。
つまり UEFI では 32bit フルカラーグラフィックスが事実上の標準となっていて、他のモードはほぼ存在しません。

BIOS の世界では VGA 互換グラフィックスが標準となっていますが、 UEFI の世界では VGA 互換モードは利用できません。

ACPI

現代の PC のハードウェア構成情報は基本的に ACPI から取得できます。
UEFI では以下のようにシステムテーブルの ConfigurationTable から GUID が一致するテーブルを検索する事で ACPI テーブルを取得することができます。
EFI_CONFIGURATION_TABLE の中には ACPI 以外のテーブルも入っているので、そのうち必要になったら取得しやすいように関数を分けています。

main {
    ...
    acpi = efi_find_config_table(st, &efi_acpi_20_table_guid);
    ...
}

static 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;
}

取得した ACPI テーブルの中身の扱い方は BIOS の時代と変わりません。
ACPI は今の PC にとってとても重要ですが、とても巨大な仕様なのでここでは深く触れません。

システムファイルの読み込み

本格的な OS はローダーとカーネルが分離している場合が多いかと思います。
その場合 EFI_SIMPLE_FILE_SYSTEM_PROTOCOL などを使ってファイルを読み込んだりします。

BIOS 時代は他に選択肢がなかったのですが、 UEFI ではそもそもファイルが直接読み出せるので適切な場所に配置すれば UEFI が勝手に読み込んでくれます。
正確な上限はわからないですが、個人で作れる OS の規模なら十分ひとつのファイルにおさめることができます。
よってこの処理は省略できます。

適切な場所というのは具体的には PC の場合 /EFI/BOOT/BOOTX64.EFI というパスでファイルを配置すると、起動メニューから認識されます。
なお、例えば ARM64 の場合は BOOTX64 の部分が BOOTAA64 とかに変わります。

UEFI は不揮発メモリに特殊な変数を保存する機能があり、そこに起動ファイルのパスを設定すると上記の規則以外のファイルからでも起動する事ができます。おそらく変数を扱う機能自体はすべての機種にありますが、機種によって UI で設定できたりできなかったり、メニューから選択できたりできなかったり、若干差異があります。
よく Windows が勝手に起動するのはこの設定のためです。

また、 UEFI シェルを使えば UEFI からアクセスできるすべての場所から自由に起動する事ができます。
機種によっては BIOS ROM の中に UEFI シェルがインストール済みでメニューから起動できるものもあります。
簡単なファイル管理などもシェル上でできるようになるので、コンパイル済みバイナリを入手しておくと便利です。

最小ステップで作る UEFI OS ローダー

ここまでのステップで UEFI のモード切り替えと収集した情報をカーネルに渡すまでの最小限の OS ローダーを作ってみるとこんな感じになります。

https://github.com/neri/moe/blob/v0.5/src/kernel/efistub.c

拙作 OS プロジェクトで実際に使われているコードです。
なお、カーネル起動後は RuntimeServices に少しアクセスする以外は UEFI はもうほぼ登場しなくなります。

UEFI の光と闇

ここまで UEFI を紹介してきましたが、自作 OS で UEFI を導入する事で新しい問題が増えるのも事実です。

古き良き BIOS の時代は誰かが勝手に決めた謎のアドレスの一覧がハードウェアマニュアルに記載されていて、それを直接アクセスする事でハードウェアを操作する事ができました。
例えば 0x60 番のポートからデータを読み込むとキーコードが取れるような単純なものが多くありました。

今のコンピューターは多種多様な構成になっていて固定のアドレスに単純にアクセスするハードウェアはあまり多くありません。
複雑な決まりごとでアドレスが決まったり、アドレスが決まった後のアクセスも昔より多くの決まりごとが増えています。

UEFI Class 3 のコンピューターの中には IBM PC と互換性のあるハードウェアがほとんど残っていないものもあります。
BIOS の時代は MS-DOS が動く程度の互換性はずっと維持され続けてきましたが、今後は新しい規格に対応していないハードウェアはどんどん切り捨てられていくことになります。

また、 BIOS の時代は明確な終了の概念がなかったので OS 起動後も BIOS はずっと生き続けていて必要であればデバイスドライバとして利用する事ができました。 UEFI の場合はほとんどのドライバが ExitBootService で使えなくなってしまうので別にドライバを用意する必要があります。

自作 OS にとって最大の壁はこの膨大なハードウェアへの対応だと思います。

とはいえ、これから低レイヤーを学ぶ人が未来のない BIOS を学ぶ意味はあまりないと思います。
「むかしは大変だったなー」と軽く済ませ、 BIOS で消耗するくらいなら UEFI を学びましょう。

*1:ファームウェアを作る人にとってはこれらのサービスが起動する前の環境もあったりしますが

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

ウィンドウの基本的な部分はできましたが、本格的なウィンドウシステムとしては全然機能が足りてなかったので色々実装しました。

半透明のウィンドウもほぼチラつきなしで動くようになりました!

そして、自作OS界隈では元ネタより有名な noiz2bg なんかも移植してみました。
noiz2bg はビットマップ表示さえできれば簡単に動かせて見た目が一気に華やかになるのでオススメです。

実はシェルやコンソールは「どうせそのうち GUI 実装するから適当でいい」という考えでかなり手抜きでした。
スクロールできなかったりコマンドが1文字だったり・・・
今回の修正ではシェルもちゃんとコマンドを認識するようになりました。

まだファイルシステムもないのに普通に OS が動いてるみたいですね!

なお、ボタン類はまだ動作しません。

f:id:neriring16:20181204003859p:plain

f:id:neriring16:20181204003923p:plain