動機
- いわゆるみかん本(ゼロからの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時間ほどかかり、ずっとネットの情報を探していたが、以外にもヒントは手元にあるコードでした、というオチだった。