自作OS、始まります! #6
ローダー自作
今になって思えば、ローダーを自作しようと思った時点で沼でした・・・
結局開始から3週間かかってなんとかローダーとして完成した、という感じです。ただ、とにかく知らない知識が多くて、逆に大量の知識を得ることができました。
メモリマップを見てみる
カーネルをロードするといっても適当にメモリに配置すればいいというわけではないので、まずはメモリマップを見て空いているメモリを探します。UEFIを使う場合、BIOS画面から選択できるBoot Manager内のUEFI Internal Shellでmemmapと入力することで確認できます。
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関数を呼び出してエントリポイントへジャンプすればカーネルが起動するはずです。
見た目は何も変わってないですが、内部では大きな進歩です。
ちなみにエントリポイントへのジャンプは以下のアセンブリ関数で行っています。
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の進み方に合わせようかなとは思っています。