競プロ備忘録

競プロerの備忘録

UEFIアプリケーションでHello, Worldするのに苦労した話

動機

  • いわゆるみかん本(ゼロからのOS自作入門)を読んで、自分でもOSを作りたくなった。
  • いきなり大層なOSが作れるわけもないので、まずはブートローダを作成すべく、手始めにUEFIでHello, Worldしようと思った。

過程

まず、どんなツールを使ってアプリケーションを作るか。
みかん本ではedk2を利用したが、どうにも定義ファイルの複雑さやわかりにくさが気になり、今回はgnu-efiを使ってみようと決めた。
参考にしたのはOSDevのページ(GNU-EFI - OSDev Wiki)
まずはダウンロードとビルド

$ git clone https://git.codegit.code.sf.net/p/gnu-efi/code gnu-efi
$ cd gnu-efi
$ make

続いて、サイトの通りにコードを書き、ビルドするためのMakefileを作る。あまりMakefileを書いたことがなかったので、汚いのはご容赦ください。

// main.c
#include <efi.h>
#include <efilib.h>

EFI_STATUS
EFIAPI
efi_main(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE *SystemTable) {
  InitializeLib(ImageHandle, SystemTable);
  Print(L"Hello, world!\n");
  while(1);
  return EFI_SUCCESS;
}
# Makefile
TARGET  = ./loader.efi
DISKIMG = ./disk.img
RUNENV  = ./mnt

CC      = gcc
LD      = ld

SRC     = ..
TOOLS   = $(SRC)/tools
RUNDIR  = $(SRC)/runenv
INCDIR  = $(TOOLS)/gnu-efi/inc
LIBDIR  = $(TOOLS)/gnu-efi/lib
LDLIBDIR= $(TOOLS)/gnu-efi/x86_64
OVMFDIR = $(TOOLS)/edk2/Build/OvmfX64/DEBUG_GCC5/FV

LSCR = $(TOOLS)/gnu-efi/gnuefi/elf_x86_64_efi.lds

CFLAGS = -I$(INCDIR) -I$(INCDIR)/x86_64 -fpic -ffreestanding -fno-stack-protector -fno-stack-check -fshort-wchar -mno-red-zone -maccumulate-outgoing-args
LDFLAGS = -shared -Bsymbolic -L$(LDLIBDIR)/gnuefi -L$(LDLIBDIR)/lib -T$(LSCR)

OBJS = main.o $(LDLIBDIR)/gnuefi/crt0-efi-x86_64.o
LIBS = -lgnuefi -lefi

.PHONY : all
all : $(TARGET)

%.o : %.c
    $(CC) $(CFLAGS) -o $@ -c $<

loader.so : $(OBJS)
    $(LD) $(LDFLAGS) $< -o $@ $(LIBS)

$(TARGET) : loader.so
    objcopy -j .text -j .sdata -j .data -j .dynamic -j .dynsym -j .rel -j .rela -j .rel.* -j .rela.* -j .reloc --target efi-app-x86_64 --subsystem=10 $< $@

$(DISKIMG) : $(TARGET)
    qemu-img create -f raw $@ 200M
    mkfs.fat -n 'HELLO' -s 2 -f 2 -R 32 -F 32 $@

$(RUNENV) : $(DISKIMG)
    mkdir $@ -p
    sudo mount -o loop $< $@
    sudo mkdir $@/EFI/BOOT -p
    sudo cp $(TARGET) $@/EFI/BOOT/BOOTX64.EFI
    sleep 0.5
    sudo umount $@

.PHONY : image
image : $(DISKIMG)

.PHONY : runenv
runenv : $(RUNENV)

.PHONY : boot
boot : $(RUNENV)
    sudo qemu-system-x86_64 \
        -m 1G \
        -boot menu=on \
        -drive if=pflash,format=raw,readonly,file=$(OVMFDIR)/OVMF_CODE.fd \
        -drive if=pflash,format=raw,file=$(OVMFDIR)/OVMF_VARS.fd \
        -drive if=ide,index=0,media=disk,format=raw,file=$(DISKIMG) \
        -device nec-usb-xhci,id=xhci \
        -device usb-mouse \
        -device usb-kbd \
        -monitor stdio

すでにMakefileに登場してしまっているが、実行環境はQEMUファームウェアはOVMFを利用して、UEFIで動作する環境を作ろうとした。
QEMUはみかん本ですでにインストール済みだったので、OVMFをインストール。本当はaptで捕まえたOVMFを使うつもりだったが、どういうわけかOVMF_CODEとOVMF_VARに分離していない。大して問題ではない気がするが、情報は分離しているものが多く、自分は微妙に情報が少ないツールを使ってトラブることが多いので、おとなしくedk2でビルド。
参考にしたのはこのサイト(OVMFをビルドする – Ideal Reality)

$ git clone https://github.com/tianocore/edk2.git
$ cd edk2
$ git submodule update --init
$ OvmfPkg/build.sh -a X64 -n 4

これでツールもコードも揃ったので、あとはmake bootすればHello, Worldを拝むことができ、OS開発へのスタートダッシュを切れるはず。
しかし、そううまくはいかなかった。

動かない

立ち上がったQEMUの画面には、以下のようなエラーメッセージが、設定されている起動ディスクごとに現れ、何も起動することなく、Boot Orderの最後のUEFI Shellが起動してしまった。

BdsDxe: loading Boot0005 "MY BOOT" from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)/\EFI\BOOT\BOOTX64.EFI
BdsDxe: failed to load Boot0005 "MY BOOT" from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)\EFI\BOOT\BOOTX64.EFI: Not Found

どういう意味のメッセージなのかさっぱりわからなかったが、要するに起動できるプログラムが見つからなかったということだと考えた。
とりあえず、立ち上がったUEFI Shellから直接立ち上げられないか試す。

Shell> FS0:
FS0:\> cd EFI/BOOT
FS0:\EFI\BOOT> BOOTX64.EFI
Command Error Status: Not Found

起動できなかったので、ひとまずQEMUを落としてから原因を探ることにした。

実行形式合っていない説

真っ先に疑ったが、これは違った。作成したディスクイメージをマウントし、BOOTX64.EFIのファイル形式を確かめた。

$ file BOOTX64.EFI
loder.efi: PE32+ executable (EFI application) x86-64 (stripped to external PDB), for MS Windows

初めての経験ゆえ、すべてのことに確信が持てないが、流石にこれは間違っていないだろうと判断。他の原因を考えた。

パーティション形式がおかしい説

うろ覚えだが、確かUEFIで何か動かすときはGPTじゃないとダメじゃなかったっけ?という記憶があったので、パーティション形式を確かめることにした。

$ sudo parted disk.img print
(少し省略)
パーティションテーブル:loop
ディスクフラグ:

番号 開始  終了  サイズ ファイルシステム フラグ
  1  0.00B  210MB    210M      fat32

FAT32でフォーマットはされているが、やはりパーティション形式はGPTではない。OSDevの記事を見ると、Bootable Diskという記事があったので、これに合わせてディスクイメージの形式をいじることにした。
しかし、これがなかなかクセモノで、記事のとおりに編集すると、パーティションがぶっ壊れたり、ループバックファイルのマウントでエラーが出たりと、今までまともにディスクイメージを作成したことのない自分には困難な問題が起こり続けた。
結果的には、以下のような修正をすることで、上手く行った。

$ sudo dd if=/dev/zero of=disk.img bs=1048576 count=300        # countが300なのは単に気分
$ bash make_disk.sh disk.img
$ sudo mkfs.vfat -n "HELLO" -s -S 512 --offset 2048 -R -32 -F 32 disk.img
$ mkdir mnt -p
$ sudo mount -t vfat -o loop,offset=1048576 disk.img mnt/
$  # 以下同様
#!/bin/bash
#make_disk.sh
disk_name=$1
expect -c "
  set timeout -1
  spawn sudo gdisk $disk_name
  expect \"sudo\"
  send \"mypassword\n\"
  expect \"Command\"
  send \"n\n\"
  expect \"Partition\"
  send \"1\n\"
  expect \"First\"
  send \"2048\n\"
  expect \"Last\"
  send \"409600\n\"
  expect \"GUID\"
  send \"ef00\n\"
  expect \"Command\"
  send \"n\n\"
  expect \"Partition\"
  send \"2\n\"
  expect \"First\"
  send \"411648\n\"
  expect \"Last\"
  send \"614366\n\"
  expect \"GUID\"
  send \"8300\n\"
  expect \"Command\"
  send \"w\n\"
  expect \"(Y/N)\"
  send \"y\n\"
"
timeout=5
exit 0

すると、以下のようになった。

$ sudo gdisk disk.img -l
(少し省略)
Number  Start(sector)   End(sector)   Size      Code   Name
   1          2048        409600     199.0MiB   EF00   EFI sytstem partition
   2        411648        614366     99.0 MiB   8300   Linux filesystem
$ sudo parted disk.img print
(少し省略)
パーティションテーブル:gpt
ディスクフラグ:

番号 開始   終了  サイズ  ファイルシステム  名前                 フラグ
 1   1049kB  210MB  209MB     fat32         EFI system partition  boot,esp
 2   211MB   312MB  104MB                   Linux filesystem

いい感じの見た目になった。2つ目のパーティションを作る意味はなかったと思うが、お勉強がてらパーティションを作る練習をしたかった。 これでmake bootするが、残念ながら起動はしなかった。これだけ苦労してやったのに、非常に悲しい。
しかしよく考えると、みかん本のときにはパーティションをいじったりはせず、UEFIが読み取れるようにFATファイルシステムにフォーマットしたくらいだった。そこで、みかん本で作成した別のdisk.imgでpartedを打ってみると、

$ sudo parted disk.img print
(少し省略)
パーティションテーブル:loop
ディスクフラグ:

番号  開始  終了  サイズ ファイルシステム フラグ
 1   0.00B  210MB   210M      fat32

案の定で、非常に時間を無駄にした気分だった。正直知識が浅く、なぜこれでもいけるのか全くわかっていない。詳しい人がいれば教えてほしい。

コンパイラオプションおかしい説

こうなってくると、コード自体がおかしい説が濃厚な気がしてくる。どういうわけか、ファイル形式はPE32+でも、UEFIが認識せず起動できない。
となると、コードかコンパイラオプションのどちらかが怪しい気がしてならない。
よく見ると、ダウンロードしたgnu-efiのフォルダには、サンプルのアプリケーションと、それをビルドするためのMakefileが入っていて、手軽に試せるようになっている。なので、これをビルドして、自分の開発環境にefiファイルを移し、起動してみることにした。
実のところ、UEFIのオプションや、QEMUのオプションについてもあまり自信ががない。なので、これで起動ができればコードかコンパイラオプションに絞れる。そこで起動してみると、少なくともUEFI Shellから起動できることを確認できた。その後ブートオプションをゴニョゴニョいじると、自動で起動されるようになった。 なので、まずは用意されているMakefileを読み解くことでコンパイラオプションを探り出そうとしたが、ファイルが分割されていて読みにくい。そこで、make時の出力をファイルに打ち出し、実行されているコマンドを見た。

$ make clean
$ make all > log.txt

結果、以下のようなオプションを導き出した。

# Makefile

FORMAT      = --target efi-app-x86_64

GNUEFI  = $(SRC)/tools/gnu-efi
LIBDIR  = $(SRC)/tools/gnu-efi/gnuefi

CFLAGS      = -I. -I$(GNUEFI)/inc -I$(GNUEFI)/inc/x86_64 -I$(GNUEFI)/inc/protocol -Wno-error=pragmas -mno-red-zone -mno-avx -fpic -g -O2 -Wall -Wextra -Werror -fshort-wchar -fno-strict-aliasing -ffreestanding -fno-stack-protector -fno-stack-check -fno-stack-check -fno-merge-all-constants -DCONFIG_x86_64 -DGNU_EFI_USE_MS_ABI -maccumulate-outgoing-args --std=c11 -D__KERNEL__ -I/usr/src/sys/build/include
LDFLAGS     = -nostdlib --warn-common --no-undefined --fatal-warnings --build-id=sha1 -shared -Bsymbolic -L$(GNUEFI)/lib -L$(LIBDIR) $(LIBDIR)/crt0-efi-x86_64.o 
LDFLAGS     += -fPIC
LDLIBS      += -lefi -lgnuefi /usr/lib/gcc/x86_64-linux-gnu/9/libgcc.a -T $(LIBDIR)/elf_x86_64_efi.lds

%.o : %.c
    $(CC) $(CFLAGS) -c $< -o $@

loader.so : $(OBJS)
    $(LD) $(LDFLAGS) $< -o $@ $(LDLIBS)

$(TARGET) : loader.so
    $(OBJCOPY) -j .text -j .sdata -j .data -j .dynamic -j .dynsym -j .rel \
            -j .rela -j .rel.* -j .rela.* -j .rel* -j .rela* \
            -j .reloc $(FORMAT) $< $@

やはり、OSDevで紹介されているものと全然違う。オプションがとても多く、どれがなんのオプションなのかまだ読み解けていないが、個人的に目につくのは-DGNU_EFI_MS_ABIオプション。そういえばこれもまたうろ覚えだが、UEFIアプリケーションはABIが普通のEFL形式と異なり、MSの規格に則っているとかなんとか聞いた気がした。
これでビルドを行い、make bootした。結果的には動作した。しかし、肝心のHello, Worldが表示されず、ずっと画面が固まっている。時間が立ってもUEFI Shellが起動せず、起動し直してUEFI Shellから実行しても、やはり同様固まっている。
そのため、実行はされているが、どういうわけか上手く動作していないという状態だと判断した。

ソースコードおかしい説

これもまたサンプルプログラムと見比べると、efi_mainの定義が違う。具体的には、EFIAPI(呼び出し規約と言うんだっけか、またこれもうろ覚え)がない。

// 手元のmain.cのefi_main
EFI_STATUS
EFIAPI
efi_main(EFI_HANDLE ImageHandler, EFI_SYSTEM_TABLE *SystemTable) {
.......
// サンプルのmain.cのefi_main
EFI_STATUS
efi_main(EFI_HANDLE image_handle, EFI_SYSTEM_TABLE *systab)
{
 ........

他にも、直接Print()を使わずuefi_call_wrapper()を使うとか、細かい差はあったが、動作に影響を与える差はEFIAPIの有無だった。
これを修正してmake bootすると、見事にHello, Worldさせることができた。なぜこれで差が出るのかは未だにわかっていない。ちなみに、EFIAPI自体の定義はgnu-efi/inc/x86_64/efibind.hにあった。読み解きたいと思ったが、おびただしいプリプロセッサ命令の羅列に圧倒されてひるんでしまったため、なぜEFIAPIの有無が動作に影響を与えるのかはわからなかった。
みかん本でもネットのどの記事でもefi_mainの定義はEFI_STATUS EFIAPI efi_main(EFI_HANDLE, EFI_SYSTEM_TABLE)だったので、まさかこんなところに罠が潜んでいるとは思わなかった。これもコンパイラオプションによってでる違いなのか、またはgnu-efiのライブラリの仕様なのか、それすらもわからない。
これを解決するのにのべ15時間ほどかかり、ずっとネットの情報を探していたが、以外にもヒントは手元にあるコードでした、というオチだった。

まとめ

  • ディスクパーティションはGPTじゃなくても、(少なくともQEMUで動作させる上では)大した問題はなかった。
  • コンパイラオプションは、OSDevに記載のものだけでは明らかに足りない。
    • EFIAPIの有無を直したあと、コンパイラオプションを以前のものに戻してビルドしてみたが、やはり動作はしなかった。
  • EFIAPIは、gnu-efiを使ったUEFIアプリ制作において、efi_mainの定義に含めてはいけない。
    • 本当にそうなのか?という疑問はあるので、情報があれば非常にほしい…今後も調査、検証したい。
  • 困ったときはサンプルコードをしっかり読み込むのが吉。