個人用雑記

勉強したことを書いていければなーと

自作OS、始まります! #6

ローダー自作

今になって思えば、ローダーを自作しようと思った時点で沼でした・・・
結局開始から3週間かかってなんとかローダーとして完成した、という感じです。ただ、とにかく知らない知識が多くて、逆に大量の知識を得ることができました。

メモリマップを見てみる

カーネルをロードするといっても適当にメモリに配置すればいいというわけではないので、まずはメモリマップを見て空いているメモリを探します。UEFIを使う場合、BIOS画面から選択できるBoot Manager内のUEFI Internal Shellでmemmapと入力することで確認できます。
f:id:rei_624:20200318232537p:plain
TypeがAvailableとなっている部分が使用可能なメモリ空間で、StartアドレスからEndアドレスまでで一つの空間です。見た限りでは0x100000から0x7FFFFFまでが比較的大きく使用可能なのでここにカーネルを配置することにします。また、ELFファイルの中身はそのまま0x1100000から0xBBFFFFFFに一時的に配置し、必要なもののみ0x100000にコピーすることにします。

ELF形式について学ぶ

ローダーを書く前に、まずこのELF形式というファイル形式について知っておく必要があります。(ELFでないカーネルをロードする上ではおそらく必要ありませんが。)ちなみに自分は全く知らなかったので一からお勉強することになりました。
とはいえ色々な方々の解説記事が多くある今、情報を得るのにはそこまで苦労はしません。もし書籍で探す場合は「リンカ・ローダ実践開発テクニック」が非常に詳しく書いていると思います。(他の本は読んでないのでわかりません、ごめんなさい。)
※また、主要な構造体等は下記にも書きますが必要な値を全て書くわけではないので、適宜man elf等で確認してください。

ELFとはExecutable and Linkable Formatの略で、LinuxなどのUNIX系OSで採用されている実行フォーマットです。実際の変数の値やコードのほかに、ヘッダというそれらの情報を格納したエリアがコンパイラによって挿入されます。
ヘッダにはELFヘッダ、プログラムヘッダ、セクションヘッダが存在し、それぞれ役割が違います。

ELFヘッダ

ELFヘッダはファイルの先頭に挿入され、そのプログラム全体の情報が格納されます。試しに、自分のカーネルのELFヘッダを以下に貼り付けます。これはLinux環境の場合端末でreadelf -h ○○.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:                              DYN (Shared object file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          64 (bytes into file)
  Start of section headers:          171776 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         5
  Size of section headers:           64 (bytes)
  Number of section headers:         15
  Section header string table index: 13

また、自分で書いたコード内で上記の内容を得たい場合、以下の構造体を使うことで解決します。

typedef struct
{
    unsigned char e_ident[EI_NIDENT];
    uint16_t e_type;
    uint16_t e_machine;
    uint32_t e_version;
    Elf64_Addr e_entry;
    Elf64_Off e_phoff;
    Elf64_Off e_shoff;
    uint32_t e_flags;
    uint16_t e_ehsize;
    uint16_t e_phentsize;
    uint16_t e_phnum;
    uint16_t e_shentsize;
    uint16_t e_shnum;
    uint16_t e_shstrndx;
} Elf64_Ehdr;

0x1100000アドレスにELFの全てのデータをReadした場合、上記の構造体を用いて以下のようなコードで取得できます。

unsigned long long kernel_addr = 0x100000lu;
unsigned long long kernel_tmp_addr = 0x1100000lu;
/* ここでkernel_tmp_addrにカーネルデータを読み込み */
Elf64_Ehdr *elf_header = reinterpret_cast<Elf64_Ehdr *>(kernel_tmp_addr);

プログラムヘッダ

プログラムヘッダはELFヘッダの後ろに挿入され、各セグメントの属性や読み込み先が格納されています。readelf -l ○○.ELFで見ることができます。

Elf file type is DYN (Shared object file)
Entry point 0x0
There are 5 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000001000 0x0000000000000000 0x0000000000000000
                 0x00000000000008e0 0x00000000000008e0  R E    0x1000
  LOAD           0x00000000000018e0 0x00000000000108e0 0x00000000000108e0
                 0x0000000000028120 0x0000000000028120  RW     0x1000
  DYNAMIC        0x0000000000029940 0x0000000000038940 0x0000000000038940
                 0x00000000000000c0 0x00000000000000c0  RW     0x8
  GNU_RELRO      0x0000000000019918 0x0000000000028918 0x0000000000028918
                 0x00000000000100e8 0x0000000000011000  R      0x1
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x0

 Section to Segment mapping:
  Segment Sections...
   00     .text .dynsym .gnu.hash .hash .dynstr .rela.dyn .eh_frame
   01     .data .got .dynamic
   02     .dynamic
   03     .got .dynamic
   04

対応する構造体は次の構造体です。

typedef struct
{
    uint32_t p_type;
    uint32_t p_flags;
    Elf64_Off p_offset;
    Elf64_Addr p_vaddr;
    Elf64_Addr p_paddr;
    uint64_t p_filesz;
    uint64_t p_memsz;
    uint64_t p_align;
} Elf64_Phdr;

ELFヘッダを取得できると、その内部のe_phoffを用いてプログラムヘッダの位置がわかるため、プログラムヘッダ一覧を取得することができます。

Elf64_Phdr *elf_program_headers = 
                  reinterpret_cast<Elf64_Phdr *>(kernel_tmp_addr + elf_header->e_phoff);

この時、elf_program_headersは複数のプログラムヘッダの先頭を指している状態なので、それぞれのプログラムヘッダを見たい場合は以下のようにして取り出すことができます。

Elf64_Phdr program_header = elf_program_headers[i];

これで全てのプログラムヘッダを見る準備が整ったので、それぞれのセグメントを必要に応じてカーネル本体を置く予定のメモリ番地に移動させます。ここで配置するべきセグメントはp_typeがPT_LOADとなっているものです。

for (unsigned int i = 0; i < elf_header->e_phnum; ++i)
{
    Elf64_Phdr program_header = elf_program_headers[i];
    if (program_header.p_type != PT_LOAD)
        continue;
    memcpy(reinterpret_cast<void *>(kernel_addr + program_header.p_vaddr), 
        reinterpret_cast<void *>(kernel_tmp_addr + program_header.p_offset), 
        program_header.p_filesz);
    memset(
        reinterpret_cast<void *>(kernel_addr + program_header.p_vaddr + 
                                 program_header.p_filesz),
        0,
        program_header.p_memsz - program_header.p_filesz);
}

ELFヘッダ内のe_phnumがプログラムヘッダの数を格納しているため、その値だけforループを回し、それぞれのプログラムヘッダを取得します。そして、各プログラムヘッダのtypeを確認し、必要に応じてメモリに配置します。
また、ELFではファイルサイズが必ずしもメモリサイズと同じ値になっているとは限らないため、ファイルサイズ分memcpyした後、メモリサイズ-ファイルサイズ分memsetで0を埋めてます。ちなみにmemcpyとmemsetの引数は以下です。

memset(void *dst, void *src, int size);
memcpy(void *dst, int value, int size);

これでプログラムヘッダ周りは終了です。(多分)

セクションヘッダ

最後はセクションヘッダです。これはプログラムヘッダとは違い、セクションごとに存在し、ファイル内の末尾に挿入されます。readelf -S ○○.ELFで確認できます。今回は主にグローバル変数を処理する際に必要となる再配置で使います。

There are 15 section headers, starting at offset 0x29f00:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00001000
       0000000000000683  0000000000000000  AX       0     0     16
  [ 2] .dynsym           DYNSYM           0000000000000688  00001688
       0000000000000018  0000000000000018   A       5     1     8
  [ 3] .gnu.hash         GNU_HASH         00000000000006a0  000016a0
       0000000000000018  0000000000000000   A       2     0     8
  [ 4] .hash             HASH             00000000000006b8  000016b8
       0000000000000010  0000000000000004   A       2     0     4
  [ 5] .dynstr           STRTAB           00000000000006c8  000016c8
       0000000000000001  0000000000000000   A       0     0     1
  [ 6] .rela.dyn         RELA             00000000000006d0  000016d0
       0000000000000078  0000000000000018   A       2     0     8
  [ 7] .eh_frame         PROGBITS         0000000000000748  00001748
       0000000000000198  0000000000000000   A       0     0     8
  [ 8] .data             PROGBITS         00000000000108e0  000018e0
       0000000000008037  0000000000000000 WAMS       0     0     16
  [ 9] .got              PROGBITS         0000000000028918  00019918
       0000000000000028  0000000000000000  WA       0     0     8
  [10] .dynamic          DYNAMIC          0000000000038940  00029940
       00000000000000c0  0000000000000010  WA       5     0     8
  [11] .comment          PROGBITS         0000000000000000  00029a00
       0000000000000049  0000000000000001  MS       0     0     1
  [12] .symtab           SYMTAB           0000000000000000  00029a50
       0000000000000240  0000000000000018          14     2     8
  [13] .shstrtab         STRTAB           0000000000000000  00029c90
       0000000000000072  0000000000000000           0     0     1
  [14] .strtab           STRTAB           0000000000000000  00029d02
       00000000000001fc  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

対応する構造体は次の構造体です。

typedef struct
{
    uint32_t sh_name;
    uint32_t sh_type;
    uint64_t sh_flags;
    Elf64_Addr sh_addr;
    Elf64_Off sh_offset;
    uint64_t sh_size;
    uint32_t sh_link;
    uint32_t sh_info;
    uint64_t sh_addralign;
    uint64_t sh_entsize;
} Elf64_Shdr;

今回は以下のようなコードで使用しています。

Elf64_Shdr *elf_section_headers = reinterpret_cast<Elf64_Shdr *>(kernel_tmp_addr + elf_header->e_shoff);

Elf64_Shdr symtab_sections;
for (unsigned int k = 0; k < elf_header->e_shnum; ++k)
{
    Elf64_Shdr search_section = elf_section_headers[k];
    if (search_section.sh_type != SHT_SYMTAB)
        continue;
    symtab_sections = search_section;
    break;
}
for (unsigned int i = 0; i < elf_header->e_shnum; ++i)
{
    Elf64_Shdr section_header = elf_section_headers[i];
    if (section_header.sh_type != SHT_RELA)
        continue;
    for (unsigned int j = 0; j < section_header.sh_size / section_header.sh_entsize; ++j)
    {
        Elf64_Rela *rela = (Elf64_Rela *)(kernel_tmp_addr + section_header.sh_offset + section_header.sh_entsize * j);
        Elf64_Sym *target_sym = nullptr;
        for (unsigned int k = 0; k < symtab_sections.sh_size / symtab_sections.sh_entsize; ++k)
        {
            Elf64_Sym *search_sym = (Elf64_Sym *)(kernel_tmp_addr + symtab_sections.sh_offset + symtab_sections.sh_entsize * k);
            if (search_sym->st_value != static_cast<unsigned long long>(rela->r_addend))
                continue;
            target_sym = search_sym;
            break;
        }
        unsigned long long *memto = (unsigned long long *)(kernel_addr + rela->r_offset);
        *memto = kernel_addr + target_sym->st_value;
    }
}

プログラムヘッダの時と同様にそれぞれのセクションヘッダをforループで取得し、sh_typeを確認します。先に、後で使うために.symtabセクションを取得しておきます。このセクションには再配置する際に使うシンボル名やそのアドレスが入っています。 次に、.rel.○○セクションまたは.rela.○○セクションが再配置する必要があるため、この部分だけを選びます。(なお、上記で再配置しているのはrelaだけです。)そして、そのセクションのサイズを各要素のサイズで割ることでRELAセクションの要素数を計算します。その後、RELAに対応するシンボルを先ほど取得しておいた.symtabセクションから探します。シンボル名が見つかったらあとは再配置してあげればOKです。
再配置で行うことは、.dataセクション内に存在するグローバル変数の実体に対するアドレスを適切な位置に格納することです。アドレスはkernel_addr + target_sym->st_value で、格納先はkernel_addr + rela->r_offsetで取得できるので、後はポインタを使って値を格納すれば終了です。

ELF形式のカーネルを作成する

さて、今回ロードするカーネルとして、ELF形式でPIC(Position-Independent Code)というメモリ上の位置に依存しないで実行可能なバイナリを用います。
自分の環境(clang++)では--target=x86_64-unknown-none-elf-fPIC-Wl,pieのオプションをつけると上記のバイナリとしてコンパイルできました。(色々試してたらできたので、これらすべてが必要かは定かではありません・・・) また、以下のようなリンカスクリプトも用意しました。といっても各セクション間に適当なメモリ容量を開けておき、関数のエントリーを指定するだけのものですが。(実際bss領域は今のところ使っていませんけど、気にしないでください)

ENTRY(kernel_start)

SECTIONS
{
    .text : {
        *(.text)
    }
    . += 0x10000;
    .data : {
        *(.data)
        *(.rodata)
    }
    . += 0x10000;
    .got : {
        *(.got)
    }
    . += 0x10000;
    .bss : {
        *(.bss)
    }
}

ロードする

ここまででローダーもELF形式のカーネルも用意できたので、前回のようにExitBootServices関数を呼び出してエントリポイントへジャンプすればカーネルが起動するはずです。
f:id:rei_624:20200319020524p:plain
見た目は何も変わってないですが、内部では大きな進歩です。
ちなみにエントリポイントへのジャンプは以下のアセンブリ関数で行っています。

void kernel_start(BootStruct* boot_struct); // 呼び出したいカーネルのエントリー

jump_entry(&boot_struct, entry_point, stack_pointer); // C++でのアセンブリ呼び出し

.global jump_entry
jump_entry:
    push rbp
    mov rbp, rsp
    mov rsp, r8
    mov rdi, rcx
    call rdx
    mov rsp, rbp
    pop rbp
    ret

ローダーの時とカーネルの時でアーキテクチャが異なるので、引数を入れるレジスタが変わります。具体的にはローダー時にはrcx, rdx, r8, r9という順であったレジスタが、カーネル時にはrdi, rsi, rdx, rcxという順になります。
そこで、kernel_startに渡したいboot_structはmov命令でrcxからrdiレジスタへ移し、stack_pointerはそのままrspに移しています。そして、エントリポイントのアドレスは渡す必要が無いため、そのままcall命令で使うことでカーネルにジャンプしています。ちなみにstack_pointerの値は適当に決めました。

次回以降の目標

目標も何も、ようやく開発の開始ですね。
実はブート時にやるべきことはまだまだ残っていますが、開発していくうえで必要に応じてやっていきましょう・・・
とりあえず当面の目標ははりぼてOSの進み方に合わせようかなとは思っています。