「ゼロからのOS自作入門」 04日目

参考文献


試行錯誤メモ

前回は、ブートローダーからカーネルを直接呼び出すことを学びました。

今回は、Makefile を利用したビルドと、カーネルでのピクセル描画の効率化を勉強していきます。

Makefile

学生時代に Linux を使っていたので Makefile の存在自体は知っているものの、実際に自分で書いたことはないので雰囲気だけ知ってる状態。

  • ビルドに使用する変数を定義できる(今回は TARGET/OBJS/CXXFLAGS/LDFLAGS が変数)。
  • 実行部分は「ターゲット」「必須項目」「レシピ」から構成される。
  • make コマンドを実行するとデフォルトでは all が走る。
  • ターゲットや必須項目は基本的にファイル名を指定するが .PHONY によって疑似ターゲットも指定できる。
  • 依存関係やファイルの更新状態によって必須項目がチェックされて、再帰的にターゲットが実行される。
  • ファイル名のパターンマッチング(%.o や %.cpp)もできる。
  • 定義済み変数(必須項目の先頭を表す $< やターゲット全体を表す $@ など)もある。

なるほどー。

day04a のビルド

前回までの知見を踏まえて、書籍のソースコード類に少し手を入れながらビルドを実行していきます。

具体的には、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

よーし、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 の注目ポイント。

  1. C++ 固有文法の「参照型」を使っている点
  2. WritePixel()の実装をしている点

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;
}

では、いつものようにローダーとカーネルをビルドして実行。

day04b

おー。これで任意のピクセルに任意の色で画像を書けるようになった!

C++ の「仮想関数」

仮想関数とは「サブクラスに実装を強制させるため」の仕組み。

特にベースクラス内のメソッドの内、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;
};

C++ の「配置 new」

まだ 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++の言語仕様を勉強しないとこの後つらそうだなぁ。後で勉強しよう。

C++(Clang)の「vtable」

「仮想関数(virtual function)のポインタ表(table)」 のこと。クラス継承の際に親子間の仮想関数の解決に使われる対応表みたいなものらしい。

仮想関数を 1 つ以上含むクラス(&それを継承したクラス)のインスタンスの戦闘には vtable のポインタが埋め込まれる。

関数名
~BaseBase::~Base
VFunc1Base::VFunc1
VFunc2Base::VFunc2
関数名
~SubSub::~Sub
VFunc1Sub::VFunc1
VFunc2Base::VFunc2
Base* ptr = new Sub;
ptr->Func();   // vtable を使わない呼び出し。Base::Func が呼ばれる。
ptr->VFunc1(); // vtable 経由の呼び出し。Sub::VFunc1 が呼ばれる。
ptr->VFunc2(); // vtable 経由の呼び出し。Base::VFunc2 が呼ばれる。
delete ptr;

ピクセル描画の関数を抽象化したカーネルを実行する。

day04c

おー。


ローダーの改良

今までは、カーネルイメージ全体をそのままメモリ上に配置してエントリポイントのアドレスを参照していたが、 この節では ELF 内の各 LOAD セクションの構造を解析して適切にメモリ上に配置したうえでカーネルを実行する。

つまるところ、llvm@10 以降で -z separate-code をつけてゼロ埋めを復活せて、辻褄を合わせていた部分が不要となる。

まず、ELF ファイルの構造は下記の通り。

構造補足
ファイルヘッダファイルの先頭に存在し、ELF 識別子、アーキテクチャ情報および、他の 2 つのヘッダへの情報を持つ。
プログラムヘッダファイル上のどの部分(セグメント)がどのような属性で何処に読み込まれるかを保持するヘッダ。
セクション本体.text .data .rodata .bss .dynamic など。
セクションヘッダオブジェクトファイルの論理的な構造を記述する部分。

参考資料

(補足) 以前の記事

-z separate-data ありの場合。

=> 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    
-z separate-date なし

=> 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;

カーネル実行までの手順

  • 一時領域にカーネルファイルを読み込む
  • カーネルファイル内のプログラムヘッダーを解析
  • LOAD セクションの仮想アドレスの最小値と最大値を調べる(=実際に実行する部分を配置するメモリ量を計算するため)
  • メモリの確保
  • メモリ内に LOAD セクションをコピーし、セグメント上のメモリサイズがファイルサイズより大きい場合はゼロ埋め
  • 実行部分の先頭アドレスをエントリポイントに受け渡して実行!

なるほどなー!!


だいぶ、理解するのが難しくなってきた…! とりあえず細かいところはさておいて先に進もう! ゴーゴー!

© 2021 czu.jp