借り初めのひみつきち

仮ブログです。

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:ファームウェアを作る人にとってはこれらのサービスが起動する前の環境もあったりしますが