「ゼロからのOS自作入門」メモ

開発環境の整備
VirutalBox に Ubuntu の導入する
この書籍は、Ubuntu で動作するように構成されているため、MacOS で開発できる環境を用意する必要があった。
仮想マシン VirtualBox に Ubuntu を導入して開発環境を整備する方法にした。
以下のサイトを参考に実行した。
私の環境にはもともと VirtualBox が入っていたが、OS を BigSur にアップグレードしてから、VirtualBoxが動かなくなった。
以下のサイトを参考にして対応した。
仮想環境内でホストOS(MacOS)からゲストOS(Ubuntu)にコピペできるようにする
以下のサイトを参考にして対応した。
「Guest Additions CD イメージの挿入…」でエラーが発生したため、以下のエラー対応方法で解決した。

mikanOS のセットアップをする
以下の Github で指示された順番で、開発ツールの導入を行う。
セットアップが成功したことを確認した。

ファイルマウントを導入する
ホストOS(MacOS) <--> ゲストOS(仮想マシン内Ubuntu)でフォルダ共有を有効にした。
以下のサイトを参考にした。

1.1 ハローワールド
Okteta をインストールして、例に挙げられているバイナリ値を入力してみた。
sum コマンドでチェックサムを計算しても、ファイルの内容が等しくならない。
バイナリエディタ内と少しばかりにらめっこしたが、見当がつかないため諦める。
以下のディレクトリに正しいEFIファイルが設置してあるので、こちらを使ったほうがいい。

1.4 エミュレータでのやり方
仮想マシン内の QEMU というエミュレータで実行する。
例に挙げられているコマンド郡を打つのは面倒くさいので、作者が用意してくれたシェルスクリプトを走らせる形で実行してみる。
走らせるシェルスクリプトは以下。
$DEVENV_DIR/make_image.sh $DISK_IMG $MOUNT_POINT $EFI_FILE $ANOTHER_FILE
$DEVENV_DIR/run_image.sh $DISK_IMG
シェルスクリプト内部では、ディスクイメージを作る make_image.sh を走らせ、その後に作ったディスクイメージを QEMU で起動する run_image.sh を走らせている。
結果
QEMUが起動し、バイナリエディタで書かれた Hello World が画面上に出力された。

1.9 C言語でハローワールド
例に上がっているように、hello.c をコンパイルして、生成されたオブジェクトファイルをリンクして実行ファイルを作るコマンドを打つのは大変。
hello.cがあるディレクトリ内には、Makefile が設置されているのでこれを活用する。
Makefile と hello.c があるディレクトリで、以下のコマンドを実行する。
make run
結果
C言語からコンパイルして作られた実行ファイル hello.efi からも画面に出力できた

コラム 1.1 PE と COFF と ELF
ELF は Executable and Linkable Formatの略で、実行可能ファイルとしてだけでなく、リンク可能ファイル(つまりオブジェクトファイルの意)として表すことができる。
歴史的には a.out や COM形式など、いろいろな実行可能ファイルの形式がありましたが、今は PE と ELF がほとんどです。

参考資料
Google 在籍時に LLVM lldリンカを開発していた方の講演動画。

2.2 EDK Ⅱ でハローワールド
EDK Ⅱ 付属の build コマンドでビルドしたものは、osbook で用意されている qemu 起動シェルスクリプトで実行して画面に表示させる動作テストを行った。
$HOME/osbook/devenv/run_qemu.sh Loader.efi
Main.c に書いた「Hello, Mikan World!」が出力されたことを確認した。

2.5 メモリマップの取得
以下の処理でメモリマップを取得できる。
CHAR8 memmap_buf[4096 * 4];
gBS->GetMemoryMap(
&map->map_size,
(EFI_MEMORY_DESCRIPTOR*)map->buffer,
&map->map_key,
&map->descriptor_size,
&map->descriptor_version);
第1引数として渡す map_size(おそらくUINT型)にメモリマップ全体の大きさが格納される。
第2引数として渡す静的な配列 buffer にメモリマップが格納される。
この中のデータ構造を指し示す「図2.3 メモリマップのデータ構造」を見てみる。
メモリマップは個々の行を表すメモリディスクリプタの配列構造となっていることがわかる。
// イメージ
memory_map = [mem_dsc0, mem_dsc1, mem_dsc2, mem_dsc3, mem_dsc4, mem_dsc5 ]
このメモリディスクリプタの大きさは、GetMemoryMap の第4引数 descriptor_size で取得している。
各メモリディスクリプタには、「表2.2 メモリマップの例」(p54)で挙げられた1行分の情報が格納されている。

2.7 メモリマップの確認
用意した Main.c でビルドして作成した実行ファイル Loader.efi を QEMU で起動する。
QEMU で以下のような文字列が出力される。
メモリマップ情報が表示されているようで、メモリマップの取得が成功していると思われる。
取得したメモリマップのファイル保存しているため、そのファイルの中身を確認する。
ファイル内は以下のようなメモリマップ情報となっていた。
kkamashima@kkamashima-VirtualBox:~/mikanos/mnt$ cat memmap
Index, Type, Type(name), PhysicalStart, NumberOfPages, Attribute
0, 3, EfiBootServicesCode, 00000000, 1, F
1, 7, EfiConventionalMemory, 00001000, 9F, F
2, 7, EfiConventionalMemory, 00100000, 700, F
3, A, EfiACPIMemoryNVS, 00800000, 8, F
4, 7, EfiConventionalMemory, 00808000, 8, F
5, A, EfiACPIMemoryNVS, 00810000, F0, F
6, 4, EfiBootServicesData, 00900000, B00, F
7, 7, EfiConventionalMemory, 01400000, 3AB36, F
8, 4, EfiBootServicesData, 3BF36000, 20, F
9, 7, EfiConventionalMemory, 3BF56000, 270C, F
10, 1, EfiLoaderCode, 3E662000, 2, F
11, 4, EfiBootServicesData, 3E664000, 219, F
12, 3, EfiBootServicesCode, 3E87D000, B7, F
13, A, EfiACPIMemoryNVS, 3E934000, 12, F
14, 0, EfiReservedMemoryType, 3E946000, 1C, F
15, 3, EfiBootServicesCode, 3E962000, 10A, F
16, 6, EfiRuntimeServicesData, 3EA6C000, 5, F
17, 5, EfiRuntimeServicesCode, 3EA71000, 5, F
18, 6, EfiRuntimeServicesData, 3EA76000, 5, F
19, 5, EfiRuntimeServicesCode, 3EA7B000, 5, F
20, 6, EfiRuntimeServicesData, 3EA80000, 5, F
21, 5, EfiRuntimeServicesCode, 3EA85000, 7, F
22, 6, EfiRuntimeServicesData, 3EA8C000, 8F, F
23, 4, EfiBootServicesData, 3EB1B000, 4DA, F
24, 7, EfiConventionalMemory, 3EFF5000, 4, F
25, 4, EfiBootServicesData, 3EFF9000, 6, F
26, 7, EfiConventionalMemory, 3EFFF000, 1, F
27, 4, EfiBootServicesData, 3F000000, A1B, F
28, 7, EfiConventionalMemory, 3FA1B000, 1, F
29, 3, EfiBootServicesCode, 3FA1C000, 17F, F
30, 5, EfiRuntimeServicesCode, 3FB9B000, 30, F
31, 6, EfiRuntimeServicesData, 3FBCB000, 24, F
32, 0, EfiReservedMemoryType, 3FBEF000, 4, F
33, 9, EfiACPIReclaimMemory, 3FBF3000, 8, F
34, A, EfiACPIMemoryNVS, 3FBFB000, 4, F
35, 4, EfiBootServicesData, 3FBFF000, 201, F
36, 7, EfiConventionalMemory, 3FE00000, 8D, F
37, 4, EfiBootServicesData, 3FE8D000, 20, F
38, 3, EfiBootServicesCode, 3FEAD000, 20, F
39, 4, EfiBootServicesData, 3FECD000, 9, F
40, 3, EfiBootServicesCode, 3FED6000, 1E, F
41, 6, EfiRuntimeServicesData, 3FEF4000, 84, F
42, A, EfiACPIMemoryNVS, 3FF78000, 88, F
43, 6, EfiRuntimeServicesData, FFC00000, 400, 1

2.8 ポインタ入門
ポインタの理解を助けるものとして、以下の本がおすすめ。

使用している Ubutu のバージョン
使用している Ubuntu のバージョンによって、初期設定がうまくいかない。
ubuntu 22.04 でトライしたところ、llvm-7-dev
が見当たらないエラーが発生して、ansibleで行う環境構築がうまくいかない。
この llvm-7-dev
を探してもインストールすることができない。
参考資料: https://zenn.dev/tutti/scraps/fcd11cd6465f1f
ubuntu 22.04.4 だとうまくいくことを確認した。

3.1 QEMUモニタ
CPUの書くレジスタの値を確認した。
(qemu) info registers
RAX=00000000000004ff RBX=000000000001c000 RCX=000000003e65a018 RDX=000000003feac820
RSI=0000000000001000 RDI=000000000000072e RBP=000000003f2e58d0 RSP=000000003feac7c8
R8 =00000000ffc66000 R9 =000000003feac820 R10=0000000000000004 R11=000000003fad4e86
R12=000000003f2e5028 R13=000000003f2e5018 R14=000000003e5f3018 R15=000000003feac820
RIP=000000003fbc72c7 RFL=00000287 [--S--PC] CPL=0 II=0 A20=1 SMM=0 HLT=0
ES =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
CS =0038 0000000000000000 ffffffff 00af9a00 DPL=0 CS64 [-R-]
SS =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
DS =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
FS =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
GS =0030 0000000000000000 ffffffff 00cf9300 DPL=0 DS [-WA]
LDT=0000 0000000000000000 0000ffff 00008200 DPL=0 LDT
TR =0000 0000000000000000 0000ffff 00008b00 DPL=0 TSS64-busy
GDT= 000000003fbee698 00000047
IDT= 000000003f306018 00000fff
CR0=80010033 CR2=0000000000000000 CR3=000000003fc01000 CR4=00000668
DR0=0000000000000000 DR1=0000000000000000 DR2=0000000000000000 DR3=0000000000000000
DR6=00000000ffff0ff0 DR7=0000000000000400
EFER=0000000000000500
FCW=037f FSW=0000 [ST=0] FTW=00 MXCSR=00001f80
FPR0=0000000000000000 0000 FPR1=0000000000000000 0000
FPR2=0000000000000000 0000 FPR3=0000000000000000 0000
FPR4=0000000000000000 0000 FPR5=0000000000000000 0000
FPR6=0000000000000000 0000 FPR7=0000000000000000 0000
XMM00=00000000000000000000000000000000 XMM01=00000000000000000000000000000000
XMM02=00000000000000000000000000000000 XMM03=00000000000000000000000000000000
XMM04=00000000000000000000000000000000 XMM05=00000000000000000000000000000000
XMM06=00000000000000000000000000000000 XMM07=00000000000000000000000000000000
XMM08=00000000000000000000000000000000 XMM09=00000000000000000000000000000000
XMM10=00000000000000000000000000000000 XMM11=00000000000000000000000000000000
XMM12=00000000000000000000000000000000 XMM13=00000000000000000000000000000000
XMM14=00000000000000000000000000000000 XMM15=00000000000000000000000000000000
以下のようなコマンドで、アドレス付近の値を表示(メモリダンプ)できる。
x /fmt address
試しに、RIP=0x03fbc72c7のアドレスから 4 バイトを16進数で表示してみた。
(qemu) x /4xb 0x03fbc72c7
000000003fbc72c7: 0x41 0xc6 0x04 0x38
4命令分を逆アセンブルしてみた。
(qemu) x /4i 0x03fbc72c7
0x3fbc72c7: 41 c6 04 38 10 movb $0x10, (%r8, %rdi)
0x3fbc72cc: 8a 04 39 movb (%rcx, %rdi), %al
0x3fbc72cf: 41 88 04 38 movb %al, (%r8, %rdi)
0x3fbc72d3: 48 ff c7 incq %rdi

3.2 レジスタ
以前にアセンブラでプログラムを書いた際に、以下のサイトを参考に使用するレジスタを選んだ。

3.3 初めてのカーネル
コンパイルして、カーネルの実行ファイルを作る。
$ clang++ -O2 -Wall -g --target=x86_64-elf -ffreestanding -mno-red-zone -fno-exceptions -fno-rtti -std=c++17 -c main.cpp
$ ls
$ main.cpp main.o // main.o というオブジェクトファイルが生成されたことを確認した。
$ ld.lld --entry KernelMain -z norelro --image-base 0x100000 --static -o kernel.elf main.o
$ ls
$ kernel.elf main.cpp main.o // kernel.elf という実行ファイルが生成されたことを確認した。
readelf コマンドを使って、カーネルファイルの内部形式を確認する。
$ readelf -h kernel.elf
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x101000
Start of program headers: 64 (bytes into file)
Start of section headers: 8896 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 4
Size of section headers: 64 (bytes)
Number of section headers: 14
Section header string table index: 12
以下のコマンドを実行して、ブートローダからカーネルファイルを読み込ませるように実行した。
$ $HOME/osbook/devenv/run_qemu.sh Build/MikanLoaderX64/DEBUG_CLANG38/X64/Loader.efi ../mikanos/kernel/kernel.elf
qemuモニタ内で、RIP(次に実行する命令のメモリアドレスを保持したレジスタ)に表示されているアドレスを逆アセンブルしてみた結果、hlt 命令があることを確認した。
(qemu) x /2i 0x101011
0x00101011: eb fd jmp 0x101010
0x00101013: cc int3
(qemu) x /2i 0x101010
0x00101010: f4 hlt
0x00101011: eb fd jmp 0x101010

3.5 カーネルからピクセルを描く
以下のコマンドで、frame_buffer に色の設定をしたカーネル実行ファイルを生成する。
$ clang++ $CPPFLAGS -O2 --target=x86_64-elf -fno-exceptions -ffreestanding -c main.cpp
$ ld.lld $LDFLAGS --entry KernelMain -z norelro --image-base 0x100000 --static -o kernel.elf main.o
qemuに描写してみる。
$ cd $HOME/edk2
$ build // ブートローダの実行ファイルを更新する
$ $HOME/osbook/devenv/run_qemu.sh Build/MikanLoaderX64/DEBUG_CLANG38/X64/Loader.efi ../mikanos/kernel/kernel.elf

4.1 make入門
make実行
書籍通りに make コマンドを実行すると、以下のようなエラーが発生する。
$ cd kernel/
$ ls
main.cpp Makefile
$ make
clang++ -O2 -Wall -g --target=x86_64-elf -ffreestanding -mno-red-zone -fno-exceptions -fno-rtti -std=c++17 -c main.cpp
main.cpp:1:10: fatal error: 'cstdint' file not found
#include <cstdint>
^~~~~~~~~
1 error generated.
make: *** [Makefile:20: main.o] Error 1
原因は、cstdintライブラリを読み込めるように設定していなかったから。
P.84 に以下を実行するように書いてある。
自作OSで<cstdint>を使うためには<cstdint>のありかを Clang に伝える必要があります。それを手軽に行えるように buildenv.sh というファイルを準備しました。
source $HOME/osbook/devenv/buildenv.sh
buildenv.sh を読み込ませたあとに、make 実行するとコンパイルできた。
$ make
clang++ -I/home/kotaro/osbook/devenv/x86_64-elf/include/c++/v1 -I/home/kotaro/osbook/devenv/x86_64-elf/include -I/home/kotaro/osbook/devenv/x86_64-elf/include/freetype2 -I/home/kotaro/edk2/MdePkg/Include -I/home/kotaro/edk2/MdePkg/Include/X64 -nostdlibinc -D__ELF__ -D_LDBL_EQ_DBL -D_GNU_SOURCE -D_POSIX_TIMERS -DEFIAPI='__attribute__((ms_abi))' -O2 -Wall -g --target=x86_64-elf -ffreestanding -mno-red-zone -fno-exceptions -fno-rtti -std=c++17 -c main.cpp
ld.lld -L/home/kotaro/osbook/devenv/x86_64-elf/lib --entry KernelMain -z norelro --image-base 0x100000 --static -o kernel.elf main.o
$ ls
kernel.elf main.cpp main.o Makefile
$ make clean
rm -rf *.o
$ ls
kernel.elf main.cpp Makefile
PHONY宣言について
PHONY宣言を形式として記述していたため、自戒を込めてメモ書き。
ターゲットのうち、all と clean は実際のファイル名ではなく、単にルールを表す名前として使っています。デフォルトのビルドを行う all 、build の中間ファイルを削除する clean といったように。これらのターゲットのことを、本当のファイル名でへはないという意味で偽物のターゲット(phony target)と呼び、.PHONY 宣言を行います。
Phony 宣言は、all や clean といったファイルが実在する場合に効果を発揮します。それらのファイルがなければ、Phony 宣言しなくても問題が起きることはありません。
参考資料: https://www.ecoop.net/coop/translated/GNUMake3.77/make_4.jp.html#SEC32
ルールに Makefile が含まれていることについて
依存関係のルールに Makefile を含めるのは初めて見た。その理由を見て納得した。
レシピで使うファイルだけを必須項目に書くのが基本ですが、これだけ例外になっています。その理由は、Makefile の内容が更新された場合にはビルドし直すべきという考えがあるからです。

4.2 ピクセルを自在に描く
用意された main.cpp で作成された実行ファイルをブートローダに渡して実行する。
$HOME/osbook/devenv/run_qemu.sh $HOME/edk2/Build/MikanLoaderX64/DEBUG_CLANG38/X64/Loader.efi kernel.elf

4.3 C++ の機能を使って書き直す
配置 New
通常の new 演算子はメモリ確保時に OS カーネルに対してメモリ(ヒープ領域)確保要求を出す。しかし、MikanOS プロジェクトでは作成中の OS にはメモリ管理が完成していないため、new 演算子によるメモリ確保要求に対応できない。
そのため、スタック領域に用意した静的配列のメモリ領域に配置 new を使って、Pixel_writer のインスタンスを生成するようにする。
char pixel_write_buf[sizeof(RGBResv8BitPerColorPixelWriter)];
void *operator new(size_t size, void *buf) {
return buf;
}
operator delete
では、ヒープ領域にメモリを確保していないため、中では何も実行しない。
しかしながら、著者によると「これ(delete)がないとリンク時にエラーとなるので仕方く定義」しているとのこと。
qemu への描写
C++で作ったクラスを使って Pixel_writer->Write でピクセル描写した結果が以下となる。

4.4 vtable
vtable: 仮想環境テーブル
以下のサイトで紹介されている図が分かりやすい。
1つ以上の virtual function(仮想関数)を持っているクラスは、vtableも1つ作られる。
vtable は仮想関数を持つクラスを継承したクラスの関数が実行される際に、コンパイラが関数元を追っかけていく際に使われるポインタという理解。

4.5 ローダを改良する
生成した実行ファイル kernel.elf 内のヘッダを確認する。
$ readelf -l kernel.elf
Elf file type is EXEC (Executable file)
Entry point 0x101020
There are 5 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000100040 0x0000000000100040
0x0000000000000118 0x0000000000000118 R 0x8
LOAD 0x0000000000000000 0x0000000000100000 0x0000000000100000
0x00000000000001a8 0x00000000000001a8 R 0x1000
LOAD 0x0000000000001000 0x0000000000101000 0x0000000000101000
0x00000000000001b9 0x00000000000001b9 R E 0x1000
LOAD 0x0000000000002000 0x0000000000102000 0x0000000000102000
0x0000000000000000 0x0000000000000018 RW 0x1000
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x0
Section to Segment mapping:
Segment Sections...
00
01 .rodata
02 .text
03 .bss
04
上記の情報のうち、3 つのLOADセグメントに着目する。
この LOAD セグメントは、ローダが読み込み処理のために参照するセグメント。
LOAD 0x0000000000000000 0x0000000000100000 0x0000000000100000
0x00000000000001a8 0x00000000000001a8 R 0x1000
LOAD 0x0000000000001000 0x0000000000101000 0x0000000000101000
0x00000000000001b9 0x00000000000001b9 R E 0x1000
LOAD 0x0000000000002000 0x0000000000102000 0x0000000000102000
0x0000000000000000 0x0000000000000018 RW 0x1000
3 番目の LOAD セグメントでは、ファイルサイズとメモリサイズに乖離がある。
ファイルサイズ メモリサイズ
0x0000000000000000 0x0000000000000018
その理由は、3番目の LOAD セグメントが .bssセクションメモリも含んでいるため。この .bssセクションいは、初期値なしのグローバル変数のメモリが配置される。
今回の例では、2 つの初期値なしのグローバル変数が .bss セクションに定義される。初期値がないためファイルに記録するものがなく、ファイルサイズが 0 となる。