おもむろに UEFI のお勉強。

UEFI とは

Unified Extensible Firmware Interface - Wikipedia からの引用です。

Unified Extensible Firmware Interface (ユニファイド・エクステンシブル・ファームウェア・インタフェース、UEFI)はオペレーティングシステム(OS)とプラットフォームファームウェアとの間のソフトウェアインタフェースを定義する仕様である。

UEFI の仕様にはハードウェアの初期化や OS のブートストラップといったものが含まれます。このような処理を行うプログラムとして昔は BIOS がありましたが、UEFI は BIOS の至らない点を踏まえて設計された仕様です。UEFI は最終的に BIOS を置き換えることを目的にしていると思いますが、仕様で 「Evolutionary, not revolutionary」 と表現されるように BIOS との compatibility も考慮されいます (UEFI 実装であっても BIOS によるブートが可能)。

UEFI はあくまで仕様なので細かな挙動はファームウェア実装者 (つまり各マザーボードメーカー?) で異なり得ます。UEFI をサポートしたファームウェアはマザーボード上の ROM、 フラッシュメモリ等に格納され、電源起動時に読み出され実行されます。

UEFI は特定の OS に依存しません。 UEFI をサポートする OS は 「UEFI-aware OS」 と呼ばれ、2018 年現在、主要な Linux ディストリビューションや Windows の最新バージョンといったものがあたります。 Mac に関しては一部仕様に準拠していないらしいです。

UEFI のブートプロセス

Unified Extensible Firmware Interface - ArchWiki からの引用です。

UEFI のブートプロセス

  1. システムのスイッチが入る - POST (Power On Self Test) プロセス
  2. UEFI ファームウェアがロードされます。ファームウェアは起動に必要なハードウェアを初期化します
  3. 次にファームウェアはブートマネージャのデータを読み込みどの UEFI アプリケーションをどこから (つまりどのディスク・パーティションから) 起動するか決定します
  4. ファームウェアのブートマネージャのブートエントリに定義されているように UEFI アプリケーションをファームウェアが起動します
  5. 起動した UEFI アプリケーションは設定によって他のアプリケーション (UEFI シェルや rEFInd の場合) やカーネルと initramfs (GRUB などのブートローダの場合) を起動します

以下では各ステップで気になるところを押さえていこうと思います。

1. POST プロセス と 2. ハードウェアの初期化

Power On Self Test - Wikipedia からの引用です。

Power On Self Test(POST)とは、コンピュータやプリンター、ルーターなどの電源を入れたときブートの前に行われる処理を指す

(中略)

BIOS が POST 実行中に行う処理は次のようになる。

  1. BIOSコード自体が問題ないかチェックする。
  2. POST を実行する契機が何なのかを特定する。
  3. システムのメインメモリを探し、大きさを調べ、問題ないか検証する。
  4. 全てのフロントサイドバスとデバイスを検出し、初期化し、登録する。

(後略)

細かいところはわかりませんが、OS をブートする前にハードウェアの異常が無いかをチェック、適切に初期化というぐらいの認識でいいのかなと思っています。

3. UEFI ブートマネージャとパーティション

ハードウェアの初期化が終わると、次は OS ブートローダを探して実行しなければなりません。OS のブートローダはディスクに存在するため、ブートプロセスを実行しているファームウェアは何らかの形でそれがどこにどのようなフォーマットで存在するかを知る必要があります。

UEFI では 「UEFI ブートマネージャが FAT ファイルシステムでフォーマットされた EFI System Partition (ESP) に存在する UEFI アプリケーション (この場合特に UEFI OS ローダ) を実行する」 というのを仕様として定義することでこの問題を解決しています。つまりファイルシステムと実行形式を仕様として持つことで、BIOS (下記参照) で存在したブートローダサイズの問題や OS との粗結合を実現しているといえます。

(単純なブートローダだと MBR で与えられる数百バイトで十分という話もありますが、起動する OS を選択できるようにしたりと高機能を求めると辛いはずです。現に GRUB のようなブートローダを使用するには 2, 3 段のブートローダをチェインする必要があるわけで。参考: システムのブート)

(OS との疎結合というのは OS から見れば ESP 内任意のパスにブートローダを置けばよく、UEFI ブートマネージャからすればブートエントリ (後述) に従って UEFI アプリケーションを実行すればよい、という仕組みを指して表現しています。この仕組みは BIOS-MBR に比べてデュアルブートを設定しやすくなっていたりと柔軟性があると思います。)

ESP の場所自体はこれも UEFI で定義されている GPT というパーティションテーブルを使用することで解決するようです (これ違うかも。ブートエントリに従って実行するだけ?)。

UEFI ブートを行っている PC でパーティションを確認しました。

$ parted /dev/sda print
Model: ATA SAMSUNG MZHPV256 (scsi)
Disk /dev/sda: 256GB
Sector size (logical/physical): 512B/512B
Partition Table: gpt
Disk Flags: 

Number  Start   End     Size    File system  Name                          Flags
 1      1049kB  274MB   273MB   fat32        EFI system partition          boot, esp
 2      274MB   408MB   134MB                Microsoft reserved partition  msftres
 3      408MB   84.6GB  84.2GB  ntfs         Basic data partition          msftdata
 4      84.6GB  152GB   67.1GB  ext4         Basic data partition          msftdata
 5      152GB   255GB   103GB   ext4         Basic data partition          msftdata
 6      255GB   256GB   1074MB  ntfs         Basic data partition          hidden, diag

Partition Table 行から gpt であることが、Number 1 の行からこのパーティションが FAT32 フォーマットされた ESP であることがわかるかと思います。

(参考) BIOS のブートストラップ

BIOS の場合は MBR (Master Boot Record) というパーティションテーブルを使用します。MBR はディスクの最初のセクタ 512 byte 領域に格納されます。この領域にブートローダ自体のコードや各パーティションについての情報が存在します。

BIOS が (単純な) OS ブートローダを実行するまでのざっくりとした動作は

  1. MBR 上のブートストラップローダ (プライマリブートローダ) を実行する
  2. ブートストラップローダはブート可能フラグが立っているパーティションを探し、そのパーティションのブートローダをロード、制御を移す

のようになるみたいです。GRUB 等高機能なブートローダを使用する場合はもっと複雑になります。

参考:

4. ブートエントリ

ブートエントリはブートを行う情報をまとめたもので、デバイス、パーティション、実行する UEFI アプリケーションへのパスといったものが含まれます。通常 NVRAM (Non-volatile RAM) に格納されています。

ブートエントリは複数定義することができ、それらの優先順を BootOrder で指定します。UEFI ブートマネージャは BootOrder を見て順にブート処理を移譲し、最初に成功したものでブートプロセスを進めます。

UEFI-aware OS ではブートエントリに関する情報を OS レベルから確認することができるみたいです。例えば Linux の場合 efibootmgr コマンドがそれにあたります。

$ efibootmgr -v
BootCurrent: 0001
Timeout: 0 seconds
BootOrder: 0001,0012,0000,0011,0013
Boot0000* Windows Boot Manager	HD(1,GPT,<guid1>,0x800,0x82000)/File(\EFI\Microsoft\Boot\bootmgfw.efi) ...
Boot0001* Linux Boot Manager	HD(1,GPT,<guid1>,0x800,0x82000)/File(\EFI\systemd\systemd-bootx64.efi)
Boot0012* USB Device	VenMsg(bc7838d2-0f82-4d60-8316-c068ee79d25b,69049f3f9e9c644ab637bad01f534f5b00)
...

この表示 (一部省略) を見ると、

  • BootCurrent から現 OS が Boot0001 によりブートされたこと
  • BootOrder から最も優先度が高いのが Boot0001 であること
  • Boot0001 は systemd-boot というブートローダであり、実行ファイルがESP 内の \EFI\systemd\systemd-bootx64.efi に存在すること

等がわかります。

systemd-bootx64.efi はもちろん UEFI アプリケーションです (for MS Windows というのは謎)。

$ file /boot/EFI/systemd/systemd-bootx64.efi
/boot/EFI/systemd/systemd-bootx64.efi: PE32+ executable (EFI application) x86-64 (stripped to external PDB), for MS Windows

また (動作を確認していないので半分憶測ですが) File が指定されていないエントリについてはデフォルトの \EFI\BOOT\BOOTX64.efi (X64 は CPU アーキテクチャ依存) を使用することになるみたいです。

5. OS のブートローダ

これ以降の挙動は UEFI ではなく OS 毎の話になるのですが、ついでに見ていきます。まず以後の Linux におけるブートの流れを 2 つの引用でざっくり把握します。

ブートプロセス、init、シャットダウン - Red Hat Customer Portal からの引用です。

  1. システムがブートローダーをロードして、実行します (略)
  2. ブートローダーがカーネルをメモリーにロードすると、すべての必要なモジュールをロードして、読み取り専用の root パーティションをマウントします。
  3. カーネルはブートプロセスの制御を /sbin/init プログラムに渡します。
  4. /sbin/init プログラムはすべてのサービスとユーザースペースツールをロードし、/etc/fstab にリストしてあるすべてのパーティションをマウントします。
  5. ユーザーに対して、新しく起動した Linux システムのログイン画面が表示されます。

ここでは特に 2. の部分、UEFI ブートマネージャにより実行された OS ブートローダが Linux カーネルに制御を渡すまでに注目します。

ブートローダー - Red Hat Customer Portal の引用が参考になります。

次に /boot/ ディレクトリ内の対応するカーネルバイナリを見付けます。このカーネルバイナリは以下の形式を使用して名前を付けています。 /boot/vmlinuz-<kernel-version> ファイル (<kernel-version> はブートローダー設定に指定してあるカーネルバージョンに相当します)

(中略)

次に、ブートローダーは、適切な initramfs イメージをメモリーに配置します。カーネルは、initramfs を使用してシステムのブートに必要なドライバーとモジュールをロードします。

カーネルと initramfs イメージがメモリーにロードされるとブートローダーはブートプロセスの制御をカーネルに渡します。

ここでの主な登場人物は vmlinux と初期 RAM ディスクです。

vmlinux は内部に Linux カーネルイメージを含む実行ファイルであり、実際にはそれを圧縮した形式 (vmlinuz と呼ばれる) で扱われます。現 Linux 環境で使用しているイメージを file コマンドで見ると以下のような出力が得られます (bzImage というのはあまり聞かない形式ですが、Linux カーネルが独自に使用しているものでしょうか)。

$ file /boot/vmlinuz-linux 
/boot/vmlinuz-linux: Linux kernel x86 boot executable bzImage, version 4.17.5-1-ARCH (builduser@heftig-19059) #1 SMP PREEMPT Sun Jul 8 17:27:31 UTC 2018, RO-rootFS, swap_dev 0x5, Normal VGA

初期 RAM ディスク (initrd または initramfs) はロード可能なカーネルモジュールが含まれた圧縮ファイルであり、 OS ブートローダにより RAM にロードされます。カーネルイメージと別に用意されているのは、システム毎に必要なものが変わるであろうカーネルモジュール群を外すことでカーネルイメージを小さく (そしてできるだけ汎用的に) するためのようです。わかりやすい例でいうと、ルートファイルシステムをマウントするにはそのファイルシステムを読めないといけませんが、そのためのモジュールを初期 RAM ディスクに持たせることで、カーネルイメージ自身は同一のまま異なるファイルシステムに対応することができます。

現環境では mkinitcpio コマンドにより gzip 圧縮された cpio フォーマットのファイルを生成し、それを initramfs (tmpfs の一種?) としてブートプロセスの一時的なルートファイルシステムにしていました。

$ file /boot/initramfs-linux.img
/boot/initramfs-linux.img: gzip compressed data, last modified: Sat Jul 14 13:17:09 2018, from Unix, original size 20551168

カーネルモジュールのロードや初期化が終わった後、真のルートファイルシステムがマウントされ、以後 init プロセスの開始… と続きます。

参考:

Hello, UEFI アプリケーション

上述した UEFI ブートプロセスの中で、OS ローダが UEFI アプリケーションであるという記述をしましたがこの逆は成り立ちません。 UEFI アプリケーションは UEFI が定める実行ファイルというだけなので、UEFI アプリケーションとしては OS ローダに限らず任意のツールやゲームといったものも作成できます。

ということで最後に UEFI アプリケーション版 Hello World を作成してみました。手順は EDK II で UEFI アプリケーションを作る を全力で参考にしており、以下はメモ程度に残しているだけなのでリンク先を見るのがグッドです。

大まかな手順としては EDK II という UEFI 開発環境を使用し、QEMU で作成したアプリケーションの動作を確認するということをやっています。

EDK II (Efi Development Kit II) の用意:

# Get edk2 source codes
$ git clone https://github.com/tianocore/edk2.git
$ cd edk2

# Set up environment variables to build
$ source edksetup.sh

# Prepare a new package for hello world...

# Said to execute the below comand in the error message while building
$ make -C /home/nm/workspace/edk2/BaseTools/Source/C
$ build

Hello World する UEFI アプリケーションのソースコード:

#include <Uefi.h>
#include <Library/UefiLib.h>
#include <Library/UefiBootServicesTableLib.h>

EFI_STATUS
EFIAPI
UefiMain (
        IN EFI_HANDLE ImageHandle,
        IN EFI_SYSTEM_TABLE *SystemTable
        )
{
    Print(L"Hello EDK II.\n");
    // Sleep 5 seconds
    gBS->Stall(5000000);
    return 0;
}

QEMU による動作確認:

#
# build OVMF
#

$ vim Conf/target.txt
$ grep ACTIVE_PLATFORM !$
ACTIVE_PLATFORM       = OvmfPkg/OvmfPkgX64.dsc

$ build

$ ls Build/OvmfX64/RELEASE_GCC5/FV | grep -e '^OVMF.*'
OVMF_CODE.fd
OVMF.fd
OVMF_VARS.fd

#
# Execute by QEMU
#

# Prepare directory (to use as ESP)
$ mkdir -p image/EFI/BOOT
$ cp Build/GetStartedPkgX64/RELEASE_GCC5/X64/Hello.efi image/EFI/BOOT/BOOTX64.efi
$ cp Build/OvmfX64/RELEASE_GCC5/FV/OVMF_VARS.fd ./

# Start qemu
$ qemu-system-x86_64 \
> -drive if=pflash,format=raw,readonly,file=Build/OvmfX64/RELEASE_GCC5/FV/OVMF_CODE.fd \
> -drive if=pflash,format=raw,file=./OVMF_VARS.fd \
> -drive if=ide,file=fat:image,index=0,media=disk

これで QEMU が起動され、’Hello EDK II.’ メッセージが表示されれば成功です。以下作業ログの細かい補足です。

  • OVMF の build 中 iasl コマンドが無いと怒られたので、インストールして再実行した
  • OVMF は Arch Linux であれば公式リポジトリにも存在するので このような 手順でもいけそう

実機での動作確認のためには efibootmgr で boot entry を追加します。

# Copy hello.efi to ESP
$ mkdir /boot/EFI/hello
$ cp <path_to_built_efi> /boot/EFI/hello/HELLOX64.efi

# Create a new boot entry
# Note that the created entry comes at the first position in the boot order.
$ efibootmgr -c -d '/dev/sda' -p 1 -L Hello -l '\\EFI\\hello\\HELLOX64.efi'

$ efibootmgr -v
...
Boot0002* Hello	HD(1,GPT,c0e22a3f-1543-4010-a911-3d63c34a1f52,0x800,0x82000)/File(\EFI\hello\HELLOX64.efi)
...

# Specify boot order (to give the lowest precedence to the new entry)
$ efibootmgr -o <list_of_boot_entry>

# Use boot entry 2 in the next boot
$ efibootmgr -n 2

(reboot and confirm the hello world)

# Delete boot entry 2
$ efibootmgr -b 2 -B

実機では Hello World 実行後、boot order 2 番目の OS ブートローダが実行されいつも通り起動処理が行われたようでした。