前回は、ブートローダーからカーネルを直接呼び出すことを学びました。
今回は、Makefile を利用したビルドと、カーネルでのピクセル描画の効率化を勉強していきます。
学生時代に Linux を使っていたので Makefile の存在自体は知っているものの、実際に自分で書いたことはないので雰囲気だけ知ってる状態。
なるほどー。
前回までの知見を踏まえて、書籍のソースコード類に少し手を入れながらビルドを実行していきます。
具体的には、lld@11 でもカーネルが起動するように、リンク時に elf ファイルのゼロ埋めオプション(-z separate-code)をつけることと、 EDK で SystemV AMD64 ABI になるようにマクロを指定します。
--- a/Makefile 2021-04-08 19:36:25.000000000 +0900
+++ b/Makefile 2021-04-08 20:30:05.000000000 +0900
@@ -3,7 +3,7 @@ OBJS = main.o
CXXFLAGS += -O2 -Wall -g --target=x86_64-elf -ffreestanding -mno-red-zone \
-fno-exceptions -fno-rtti -std=c++17
-LDFLAGS += --entry KernelMain -z norelro --image-base 0x100000 --static
+LDFLAGS += --entry KernelMain -z norelro --image-base 0x100000 --static -z separate-code
.PHONY: all
--- a/Main.c 2021-04-08 19:36:25.000000000 +0900
+++ b/Main.c 2021-04-08 19:47:47.000000000 +0900
@@ -297,7 +297,7 @@ EFI_STATUS EFIAPI UefiMain(
UINT64 entry_addr = *(UINT64*)(kernel_base_addr + 24);
- typedef void EntryPointType(UINT64, UINT64);
+ typedef void __attribute__((sysv_abi)) EntryPointType(UINT64, UINT64);
EntryPointType* entry_point = (EntryPointType*)entry_addr;
entry_point(gop->Mode->FrameBufferBase, gop->Mode->FrameBufferSize);
あとは、ブートローダーとカーネルをビルドして QEMU で実行と。(ちゃんと写経するために自分用のディレクトリをようやく作りました。)
# 自分の写経用のディレクトリ
$ pwd
/Users/roca/Code/mikanos-local
$ ll
total 0
drwxr-xr-x 4 roca staff 128 4 8 20:44 BIOS
drwxr-xr-x 5 roca staff 160 4 8 19:44 DISK
drwxr-xr-x 6 roca staff 192 4 8 19:35 MikanLoaderPkg
drwxr-xr-x 6 roca staff 192 4 8 19:46 kernel
# ビルドしてバイナリをいい感じにを配置して…。
$ tree BIOS DISK
BIOS
├── OVMF_CODE.fd
└── OVMF_VARS.fd
DISK
├── EFI
│ └── BOOT
│ └── BOOTX64.EFI
└── kernel.elf
# QEMU実行
$ qemu-system-x86_64 \
-drive if=pflash,file=$HOME/Code/mikanos-local/BIOS/OVMF_CODE.fd \
-drive if=pflash,file=$HOME/Code/mikanos-local/BIOS/OVMF_VARS.fd \
-hda fat:rw:$HOME/Code/mikanos-local/DISK -monitor stdio
よーし、day04a = day03c + Makefile が再現できた!
day03c & day04a では EFI の GOP をそのままカーネルに渡してフレームバッファを描画していたが、 day04b は独自に定義したフレームバッファ用の構造体定義(frame_buffer_config.hpp)に必要な情報を詰めて参照を渡す方式に変更となった。
// day04a ローダー側
EFI_GRAPHICS_OUTPUT_PROTOCOL* gop;
entry_point(gop->Mode->FrameBufferBase, gop->Mode->FrameBufferSize);
// day04a カーネル側
extern "C" void KernelMain(uint64_t frame_buffer_base,
uint64_t frame_buffer_size) { /* 略 */ }
// ↓
// day04b ローダー側
struct FrameBufferConfig config = { /* 略 */ }
entry_point(&config);
// day04b カーネル側
extern "C" void KernelMain(const FrameBufferConfig& frame_buffer_config) { /* 略 */ }
day04b の注目ポイント。
WritePixel() では、指定された (x, y) 座標に対して、PixelColor 型(RGB を各 1byte で表現したもの)で指定された色を書き込む関数。
任意の座標 (x,y) で表される点は、フレームバッファのバイト列上では 4 * (config.pixels_per_scan_line * y + x)
と表されるので、
その地点のアドレスから、uint8_t のサイズで各色のアドレスにアクセスできる。
(※ 係数 4 は 1 ピクセルの大きさで、つまり 3 色 * 1 バイト + 未使用の 1 バイトの意味)
int WritePixel(const FrameBufferConfig& config,
int x, int y, const PixelColor& c) {
const int pixel_position = config.pixels_per_scan_line * y + x;
if (config.pixel_format == kPixelRGBResv8BitPerColor) {
uint8_t* p = &config.frame_buffer[4 * pixel_position];
p[0] = c.r;
p[1] = c.g;
p[2] = c.b;
} else if (config.pixel_format == kPixelBGRResv8BitPerColor) {
uint8_t* p = &config.frame_buffer[4 * pixel_position];
p[0] = c.b;
p[1] = c.g;
p[2] = c.r;
} else {
return -1;
}
return 0;
}
では、いつものようにローダーとカーネルをビルドして実行。
おー。これで任意のピクセルに任意の色で画像を書けるようになった!
仮想関数とは「サブクラスに実装を強制させるため」の仕組み。
特にベースクラス内のメソッドの内、virtual の修飾子がついて、= 0
となっているものを実装を持っていない 純粋仮想関数 と呼び、サブクラスで上書き実装(overwrite/オーバーライド)する必要がある
(ざっくりいうと、インタフェースを定義しているような感じ)。
class Base {
public:
virtual ~Base();
void Func();
virtual void VFunc1();
virtual void VFunc2();
};
class Sub: public Base {
public:
~Sub();
void Func();
void VFunc1() override;
private:
int x;
};
まだ OS 自体にメモリ確保の機能がなく、また外部ライブラリに依るメモリ確保の機能もないので、クラスを使用する場合には独自に「メモリの確保&初期化」を行う必要がある。
今回のケースだと、配置 new という C++の機能をつかて、予め確保したバイト配列に対してクラスの初期化処理を行うことでクラスを利用可能にしている。
変数定義の種類 | 配置領域 | メモリ確保 | コンストラクタ |
---|---|---|---|
通常の配置int a = 1; | スタック領域 (=関数を抜けたら破棄される) | する | - |
malloc による配置(C)User *user = malloc(sizeof(User)); | ヒープ領域 (=関数を抜けても破棄されない) | する | 呼ばれない |
new による配置(C++)User user = new User(); | ヒープ領域 (=関数を抜けても破棄されない) | する | 呼ばれる |
配置 new による配置(C++)char user_buf[sizeof[User]]; User *user = new(user_buf) User(); | スタック領域? (≒グローバルだから破棄されないだけ?) | しない | 呼ばれる |
ナルホドな。このあたりはちゃんと C++の言語仕様を勉強しないとこの後つらそうだなぁ。後で勉強しよう。
「仮想関数(virtual function)のポインタ表(table)」 のこと。クラス継承の際に親子間の仮想関数の解決に使われる対応表みたいなものらしい。
仮想関数を 1 つ以上含むクラス(&それを継承したクラス)のインスタンスの戦闘には vtable のポインタが埋め込まれる。
関数名 | 値 |
---|---|
~Base | Base::~Base |
VFunc1 | Base::VFunc1 |
VFunc2 | Base::VFunc2 |
関数名 | 値 |
---|---|
~Sub | Sub::~Sub |
VFunc1 | Sub::VFunc1 |
VFunc2 | Base::VFunc2 |
Base* ptr = new Sub;
ptr->Func(); // vtable を使わない呼び出し。Base::Func が呼ばれる。
ptr->VFunc1(); // vtable 経由の呼び出し。Sub::VFunc1 が呼ばれる。
ptr->VFunc2(); // vtable 経由の呼び出し。Base::VFunc2 が呼ばれる。
delete ptr;
ピクセル描画の関数を抽象化したカーネルを実行する。
おー。
今までは、カーネルイメージ全体をそのままメモリ上に配置してエントリポイントのアドレスを参照していたが、 この節では ELF 内の各 LOAD セクションの構造を解析して適切にメモリ上に配置したうえでカーネルを実行する。
つまるところ、llvm@10 以降で -z separate-code
をつけてゼロ埋めを復活せて、辻褄を合わせていた部分が不要となる。
まず、ELF ファイルの構造は下記の通り。
構造 | 補足 |
---|---|
ファイルヘッダ | ファイルの先頭に存在し、ELF 識別子、アーキテクチャ情報および、他の 2 つのヘッダへの情報を持つ。 |
プログラムヘッダ | ファイル上のどの部分(セグメント)がどのような属性で何処に読み込まれるかを保持するヘッダ。 |
セクション本体 | .text .data .rodata .bss .dynamic など。 |
セクションヘッダ | オブジェクトファイルの論理的な構造を記述する部分。 |
=> 2つ目の LOADセクションの offset が 0x1000 (=ゼロ埋め分が考慮されている)であり、--image-base を 0x100000 にしたので、エントリポイントの仮想アドレスの 0x0000000000101000 と一致して実行できた。
$ readelf -l kernel/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
0x00000000000001c9 0x00000000000001c9 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
=> 2つ目の LOADセクションの offset が 0x1b0 (=ゼロ埋め分が考慮されてない)であり、--image-base を 0x100000 にしたが、エントリポイントの仮想アドレスの 0x0000000000101000 と一致していないので実行できない。
$ readelf -l kernel/kernel.elf
Elf file type is EXEC (Executable file)
Entry point 0x1011d0
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 0x00000000000001b0 0x00000000001011b0 0x00000000001011b0
0x00000000000001c9 0x00000000000001c9 R E 0x1000
LOAD 0x0000000000000380 0x0000000000102380 0x0000000000102380
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
// ファイルヘッダ を表す構造体
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[EI_NIDENT];
Elf64_Half e_type;
Elf64_Half e_machine;
Elf64_Word e_version;
Elf64_Addr e_entry;
Elf64_Off e_phoff;
Elf64_Off e_shoff;
Elf64_Word e_flags;
Elf64_Half e_ehsize;
Elf64_Half e_phentsize;
Elf64_Half e_phnum;
Elf64_Half e_shentsize;
Elf64_Half e_shnum;
Elf64_Half e_shstrndx;
} Elf64_Ehdr;
// プログラムヘッダを表す構造体
typedef struct {
Elf64_Word p_type; // PHDR, LOADER などのセグメント種別
Elf64_Word p_flags; // フラグ
Elf64_Off p_offset; // オフセット
Elf64_Addr p_vaddr; // 仮想ADDR
Elf64_Addr p_paddr;
Elf64_Xword p_filesz; // ファイルサイズ
Elf64_Xword p_memsz; // メモリサイズ
Elf64_Xword p_align;
} Elf64_Phdr;
なるほどなー!!
だいぶ、理解するのが難しくなってきた…! とりあえず細かいところはさておいて先に進もう! ゴーゴー!