引き続き「ゼロからのOS自作入門」をやっていこうと思います。
この章のカーネルが動かなすぎて挫折しそうになりましたが、なんとかうごきました。
とはいっても、libc++の自力ビルドは挫折しましたが、、、
作業は基本的にこちらのリポジトリで行います。
過去記事
QEMUモニタについて
QEMUモニタはCPUの設定を表示したり、メモリの中身を読み書きしたりすることができるため、デバッグ用途で利用されます。
QEMUモニタを起動するには qemuコマンドに -monitor studio
オプションを付与します。
QEMUモニタを起動してみる
GUIありの場合
QEMUの起動コマンドに -monitor stdio オプションが付いているとQEMUを起動できる
make run
CLIでやる場合
make run-nographic
# ctrl-a + c でQEMUモニタに切り替え
QEMUモニタでレジスタの値を確認
CPUの各レジスタの値を表示してみます。
(qemu) info registers
# CPU#0
# RAX=0000000000000000 RBX=000000003df9fe20 RCX=000000003ed0eda0 RDX=000000003e657808
# RSI=000000003fe92680 RDI=000000003fe92638 RBP=0000000000000081 RSP=000000003fe925f0
# R8 =00000000000000af R9 =0000000000001000 R10=000000003dea2218 R11=0000000000000000
# R12=000000003fe93fb0 R13=000000003e98c000 R14=000000003e64e85a R15=000000003e64ed80
# RIP=000000003e64d411 RFL=00000206 [-----P-] 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= 000000003f5dc000 00000047
# IDT= 000000003f139018 00000fff
# CR0=80010033 CR2=0000000000000000 CR3=000000003f801000 CR4=00000668
# DR0=0000000000000000 DR1=0000000000000000 DR2=0000000000000000 DR3=0000000000000000
# DR6=00000000ffff0ff0 DR7=0000000000000400
# EFER=0000000000000d00
# 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=8000000000000000 4006
# XMM00=0000000000000000 0000000000000000 XMM01=0000000000000000 0000000000000000
# XMM02=0000000000000000 0000000000000000 XMM03=0000000000000000 0000000000000000
# XMM04=0000000000000000 0000000000000000 XMM05=0000000000000000 0000000000000000
# XMM06=0000000000000000 0000000000000000 XMM07=0000000000000000 0000000000000000
# XMM08=0000000000000000 0000000000000000 XMM09=0000000000000000 0000000000000000
# XMM10=0000000000000000 0000000000000000 XMM11=0000000000000000 0000000000000000
# XMM12=0000000000000000 0000000000000000 XMM13=0000000000000000 0000000000000000
# XMM14=0000000000000000 0000000000000000 XMM15=0000000000000000 0000000000000000
RIPレジスタは次に実行される予定の機械語命令の位置を示します。
RIPレジスタに格納されているアドレスをメモリダンプ(xコマンド)で確認してみます。
# x /fmt addr
# /fmt は /[個数][フォーマット][サイズ] 形式
# - 個数 : 何個分表示するか
# - フォーマット : x=16進数表記, d=10進数表記, i=機械語命令を逆アセンブル
# - サイズ : 何バイトを1単位として解釈するか (b=1バイト, h=2バイト, w=4バイト, g=8バイト)
(qemu) x /4xb 0x000000003e64d411
# 000000003de82411: 0xeb 0xfe 0x48 0x83
RIPレジスタに登録されている機械語命令を逆アセンブルしてみます。
(qemu)x /2i 0x000000003deb2411
0x3deb2411: Asm output not supported on this arch
x86_64でやっているのだがなぜか動かない、、、
GDB(GNU Debugger)で逆アセンブルしてみる
CやC++のプログラムのデバッガで、ステップ実行、ブレークポイント設定、変数・メモリの監視・変更、関数呼び出し、逆アセンブルなどができます。
qemuの起動コマンドに -s
(-gdb tcp::1234
のショートカット) を指定すると QEMUをGDBサーバーモードで起動でき、GDBで接続できるようになります。
GDBでよく使うコマンドには以下のようなものがあります。
コマンド | 説明 |
---|---|
info registers |
全レジスタの値を表示 |
x/10i $pc |
現在の命令ポインタから10命令を逆アセンブル表示 |
break <関数名> |
指定した関数にブレークポイントを設定 |
continue |
実行を再開 |
step / next
|
ステップ実行(関数内に入る/入らない) |
bt |
スタックトレースを表示 |
quit |
GDBを終了 |
# OSの起動
sudo qemu-system-x86_64 \
-m 1G \
-drive if=pflash,format=raw,file=/usr/share/OVMF/OVMF_CODE_4M.fd,readonly=on \
-drive if=pflash,format=raw,file=build/OVMF_VARS_4M.fd \
-drive if=ide,index=0,media=disk,format=raw,file=build/disk.img \
-nographic \
-s
別のターミナルからqemuのgdbサーバー(tcp::1234)に接続します。
gdb
# QEMUのGDBサーバーに接続 (QEMU実行時に-sオプションを指定すると接続できる)
(gdb) target remote :1234
# Remote debugging using localhost:1234
# warning: No executable has been specified and target does not support
# determining executable automatically. Try using the "file" command.
# 0x000000003deb2411 in ?? ()
# RIPレジスタの示すメモリアドレスから4命令を逆アセンブル
(gdb) x /4i 0x000000003deb2411
# => 0x3de82411: jmp 0x3de82411
# 0x3de82413: sub $0x28,%rsp
# 0x3de82417: call 0x3de82240
# 0x3de8241c: mov %rdi,%rax
# 現在の命令ポインタから4命令を逆アセンブル
(gdb) x/4i $pc
# => 0x3de82411: jmp 0x3de82411
# 0x3de82413: sub $0x28,%rsp
# 0x3de82417: call 0x3de82240
# 0x3de8241c: mov %rdi,%rax
(gdb) exit
jmp 0x3de82411
は 0x3de82411
にジャンプするという命令ですが、 0x3de82411
はその命令がある場所そのものなので、結局は同じ場所をぐるぐると回ることになります。これは while (1);
をコンパイルした結果です
レジスタとは
CPUには一般に汎用レジスタ と 特殊レジスタ が搭載されています。
-
汎用レジスタ
一般の演算に使えるレジスタ -
特殊レジスタ
CPUの設定を行うためのものや、タイマーなどのCPUに内蔵された機能を制御するためのものがあります。
汎用レジスタ
x86_64の汎用レジスタは次の16個です。
RAX, RBX, RCX, RDX, RBP, RSI, RDI, RSP, R8 ~ R15
これらの汎用レジスタは以下のようにCPUの演算対象に指定できます。
add rax, rbx
; オペコード オペランド1, オペランド2
一般に x86-64の演算命令はオペコード(アセンブリ命令)が2つのオペランド(引数)を取り、左が書き込み先、右が読み込み元となります。
add rax, rbx
は RAX
に RBX
を加えるという意味になるため、ちょうど rax += rbx
のようになります。
x86-64の汎用レジスタはすべて8バイトですが、charやintなど8バイトよりも小さいデータ型を扱うために小さなレジスタが欲しい場合は、汎用レジスタの一部を小さなレジスタとしてアクセスできるようになっています。
特殊レジスタ
特殊レジスタは種類が多いため、一部紹介します。
レジスタ | 説明 |
---|---|
RIP | CPUが次に実行する命令のメモリアドレスを保持するレジスタ IPはInstruction Pointerの略 |
RFLAGS | 命令の実行結果によって変化するフラグを集めたレジスタ ビット0(CF: キャリーフラグ) 加算がオーバーフローするとCFが1になる。 ビット6(ZF: ゼロフラグ) 命令の実行結果が0になるとZFが1になる。 |
CR0 | CPUの重要な設定を集めたレジスタ ビット0 (PE) 1にするとCPUは保護モードに遷移 ビット31(PG) 1にするとページングが有効になる |
フラグレジスタの普通の使い方
演算系の命令やcmpなどのフラグレジスタに影響を与える命令の直後にjzやcmovzなどのフラグレジスタの内容によって動作が変わる命令を配置します。
次のプログラムはRAXから1を引いた結果が0でなければループします。
-
dec
: レジスタの値を1だけ減らします -
jnz
: Jump if Not Zeroの略で、RFLAGSのビット6(ZF: ゼロフラグ)が0の場合にのみジャンプします。
loop:
dec rax
jnz loop
※ RFRAGSレジスタは上記のプログラムに明示的には登場しませんが暗黙的に利用されています。
環境整備
この章に来て、ライブラリのバージョン違いなどでエラーが連発したので一旦環境を整備しました。
必要なライブラリのインストールはansibleで行います。 (Dockerでやればよかったかも)
必要なコマンド関連はすべてMakefile にまとめました
make build-bootloader MikanLoaderPkg を edk2 でビルドします
make build-image OSのイメージファイル(build/disk.img) を作成します
make build-kernel kernel/ 配下のソースコードをビルドし、build/kernel/kernel.elf を作成します。
make clean ビルドしたファイルを削除します
make help HELP表示
make mount-image OSのイメージファイル(build/disk.img) を build/mnt にマウントします
make run-legacy OSイメージをQEMUで起動します (OVMF_CODE.fd, OVMF_VARS.fd を利用)
make run-nographic-legacy OSイメージをQEMUのノーグラフィックモードで起動します (OVMF_CODE.fd, OVMF_VARS.fd を利用)
make run-nographic OSイメージをQEMUのノーグラフィックモードで起動します (OVMF_CODE_4M.fd, OVMF_VARS_4M.fdを利用)
make run OSイメージをQEMUで起動します (OVMF_CODE_4M.fd, OVMF_VARS_4M.fdを利用)
make umount-image OSのイメージファイル(build/disk.img) をbuild/mnt からアンマウントします
初めてのカーネル
ブートローダーはUEFIアプリとして、カーネルはELFバイナリとして別々に開発し、ブートローダーからカーネルを呼び出す形式にします。
UEFIアプリはUEFIの規格で決められたとおりに作らなければなりません。カーネルをブートローダーから分けることでUEFIの制約を気にせず自由に作ることができます。
カーネルの実装
一番最初のカーネルはhltを無限ループするカーネルです。
// extern "C" はC言語からこの関数呼び出すためマングリングを行わないようにする記述 (c++だと関数はデフォルトでマングリングされてしまう)
extern "C" void KernelMain() {
// __asm__() はインラインアセンブリ。C言語からアセンブリ命令を呼び出すことができる
// hlt はCPUを停止させる命令で省電力状態になり、割り込みがあると動作が再開します。(永久ループにするとCPUが100%に張り付いてしまう)
while (1) __asm__("hlt");
}
カーネルのコンパイルとリンク
コンパイルとリンクの違い
- コンパイル
文法チェック、最適化、関数の中身の機械語化などを行い、.o
(オブジェクトファイル)を生成します。
※ ただし、この時点では外部関数や変数の定義は未解決のままで、実行はできません。 - リンク
外部関数や変数の実態を標準ライブラリなどから探して解決し、実行可能ファイルを生成します。
カーネルをコンパイル & リンクする
# コンパイル (オブジェクトファイルの生成)
# -O2 レベル2の最適化を行う
# -Wall 警告をたくさん出す
# -g デバッグ情報付きでコンパイルする
# --target=x86_64-elf x86_64向けの機械語を生成する。出力ファイルの形式をELFとする
# -ffreestanding フリースタンディング環境(OSがない環境)向けにコンパイルする
# -mno-red-zone Red Zone機能を無効にする (OSを作るときはとりあえずつけておく)
# -fno-exceptions C++の例外機能を使わない (OSのサポートが必要な言語機能は無効にする)
# -fno-rtti C++の動的型情報を使わない (OSのサポートが必要な言語機能は無効にする)
# -std=c++17 C++のバージョンをC++17とする
# -c コンパイルのみする。リンクはしない。
# -o build/kernel/main.o 出力先を指定
mkdir -p build/kernel
clang++-18 \
-O2 \
-Wall \
-g \
--target=x86_64-elf \
-ffreestanding \
-mno-red-zone \
-fno-exceptions \
-fno-rtti \
-std=c++17 \
-c \
-o build/kernel/main.o \
kernel/main.cpp
# リンク (オブジェクトファイルから実行ファイルを生成。オブジェクトファイルは単体では実行できない)
# --entry KernelMain KernelMain()をエントリーポイントとする
# -z norelro リロケーション情報を読み込み専用にする機能を使わない
# --image-base 0x100000 出力されたバイナリのベースアドレスを0x100000番地とする
# -o build/kernel/kernel.elf 出力先を指定
# --static 静的リンクを行う
ld.lld-18 \
--entry KernelMain \
-z norelro \
--image-base 0x100000 \
-static \
-o build/kernel/kernel.elf \
build/kernel/main.o
# kernel.elfという実行ファイルが生成されます
ll build/kernel/kernel.elf
# -rwxrwxr-x 1 ktamido ktamido 1968 5月 24 10:50 build/kernel/kernel.elf*
ELF(Executable and Linkable Format)ファイルとは
ELFについてくわしく調べてみました。
kernel.elfのヘッダ
ELFヘッダ
readelf -h build/kernel/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: 0x101120
# Start of program headers: 64 (bytes into file)
# Start of section headers: 1072 (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
プログラムヘッダ
readelf -l build/kernel/kernel.elf
# Elf file type is EXEC (Executable file)
# Entry point 0x101120
# There are 4 program headers, starting at offset 64
#
# Program Headers:
# Type Offset VirtAddr PhysAddr
# FileSiz MemSiz Flags Align
# PHDR 0x0000000000000040 0x0000000000100040 0x0000000000100040
# 0x00000000000000e0 0x00000000000000e0 R 0x8
# LOAD 0x0000000000000000 0x0000000000100000 0x0000000000100000
# 0x0000000000000120 0x0000000000000120 R 0x1000
# LOAD 0x0000000000000120 0x0000000000101120 0x0000000000101120
# 0x0000000000000013 0x0000000000000013 R E 0x1000
# GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
# 0x0000000000000000 0x0000000000000000 RW 0x0
#
# Section to Segment mapping:
# Segment Sections...
# 00
# 01
# 02 .text
# 03
セクションヘッダ
readelf -S build/kernel/kernel.elf
# There are 14 section headers, starting at offset 0x430:
#
# 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 0000000000101120 00000120
# 0000000000000013 0000000000000000 AX 0 0 16
# [ 2] .debug_abbrev PROGBITS 0000000000000000 00000133
# 000000000000002d 0000000000000000 0 0 1
# [ 3] .debug_info PROGBITS 0000000000000000 00000160
# 000000000000002f 0000000000000000 0 0 1
# [ 4] .debug_str_o[...] PROGBITS 0000000000000000 0000018f
# 0000000000000018 0000000000000000 0 0 1
# [ 5] .debug_str PROGBITS 0000000000000000 000001a7
# 0000000000000069 0000000000000001 MS 0 0 1
# [ 6] .debug_addr PROGBITS 0000000000000000 00000210
# 0000000000000010 0000000000000000 0 0 1
# [ 7] .comment PROGBITS 0000000000000000 00000220
# 0000000000000042 0000000000000001 MS 0 0 1
# [ 8] .debug_frame PROGBITS 0000000000000000 00000268
# 0000000000000038 0000000000000000 0 0 8
# [ 9] .debug_line PROGBITS 0000000000000000 000002a0
# 000000000000005e 0000000000000000 0 0 1
# [10] .debug_line_str PROGBITS 0000000000000000 000002fe
# 0000000000000037 0000000000000001 MS 0 0 1
# [11] .symtab SYMTAB 0000000000000000 00000338
# 0000000000000048 0000000000000018 13 2 8
# [12] .shstrtab STRTAB 0000000000000000 00000380
# 0000000000000097 0000000000000000 0 0 1
# [13] .strtab STRTAB 0000000000000000 00000417
# 0000000000000015 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),
# D (mbind), l (large), p (processor specific)
ブートローダーからカーネルを起動する (day03a)
この章のソースコードはこちら
カーネルを起動できるように、ブートローダーに機能を追加していくのですが、第3章のブートローダーのコードだとカーネルが起動しません。
この問題はissueでも上がっていますが、本の環境(Ubuntu18.04, リンカ: lld-7)と私の環境(Ubuntu24.04, リンカ: lld-18)が異なることに起因しており、lld-18でリンクした実行ファイルではLOAD領域を正確にメモリにロードしなければならないようです。
day03a以降、カーネルの起動まで進まない - issue | Github
この対応は、第4章の「ローダーを改良する」という項に書かれていますのでそちらも合わせて対応していきます。
ELFファイル(カーネル)のロードの流れ
ELFファイルのロードは以下の手順で行います。
- カーネルファイル(
kernel.elf
) を一時領域に読み込む - 一時領域に読み込んだ カーネルファイルの.ELFヘッダーの解析
- ELFヘッダを読み取りプログラムヘッダーテーブルの位置(
e_phoff
) 、エントリサイズ(e_phentsize
)、要素数(e_phnum
)を取得します。
- ELFヘッダを読み取りプログラムヘッダーテーブルの位置(
- プログラムヘッダーテーブルの読み込みセグメントを最終目的地のメモリにロード
-
p_type
がPT_LOAD
のセグメントを対象とする -
p_offset
からp_filesz
バイトまでをメモリ上のp_vaddr
にロード -
p_memsz
がp_filesz
よりも大きい場合は残りの領域を0
で初期化
-
- エントリーポイントへのジャンプ
- ELFヘッダーの
e_entry
のアドレスに制御を移し、プログラムの実行を開始
- ELFヘッダーの
ELFヘッダとプログラムヘッダの構造体を定義
ELFファイルのヘッダ関連(ELFヘッダ・プログラムヘッダ)の構造を定義します。
#pragma once
#include <stdint.h>
// NOTE: uintptr_t, uint64_tなどは stdint.h で定義されている
typedef uintptr_t Elf64_Addr;
typedef uint64_t Elf64_Off;
typedef uint16_t Elf64_Half;
typedef uint32_t Elf64_Word;
typedef int32_t Elf64_Sword;
typedef uint64_t Elf64_Xword;
typedef int64_t Elf64_Sxword;
#define EI_NIDENT 16
// 64bit ELFファイルのヘッダ
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;
// 64bit ELFファイルのプログラムヘッダの要素
typedef struct {
Elf64_Word p_type; // PHDR, LOADなどのセグメント種別
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;
#define PT_NULL 0
#define PT_LOAD 1
#define PT_DYNAMIC 2
#define PT_INTERP 3
#define PT_NOTE 4
#define PT_SHLIB 5
#define PT_PHDR 6
#define PT_TLS 7
カーネルファイルを一時領域に読み込み
/**
* カーネルファイルを読み取り専用で開く
*/
EFI_FILE_PROTOCOL* kernel_file;
root_dir->Open(
root_dir,
&kernel_file,
L"\\kernel.elf",
EFI_FILE_MODE_READ,
0
);
/**
* カーネルファイルのファイルサイズを取得
*/
// EFI_FILE_INFO型を十分格納できる大きさのメモリを確保
// FileNameは可変長なのでファイル名が収まるくらいのメモリを追加で確保する必要がある
//
// EFI_FILE_INFO: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Guid/FileInfo.h#L19
// UINT64 Size;
// UINT64 FileSize;
// UINT64 PhysicalSize;
// EFI_TIME CreateTime;
// EFI_TIME LastAccessTime;
// EFI_TIME ModificationTime;
// UINT64 Attribute;
// CHAR16 FileName[1];
UINTN file_info_size = sizeof(EFI_FILE_INFO) + sizeof(CHAR16) * 12;
// UINT8: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/X64/ProcessorBind.h#L167
// 1byte符号なし整数
UINT8 file_info_buffer[file_info_size];
// kernel_file を file_info_buffer に展開
// EFI_FILE_GET_INFO: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Protocol/SimpleFileSystem.h#L287
kernel_file->GetInfo(
kernel_file, // IN EFI_FILE_PROTOCOL *This
&gEfiFileInfoGuid, // IN EFI_GUID *InformationType
&file_info_size, // IN OUT UINTN *BufferSize
file_info_buffer // OUT VOID *Buffer
);
EFI_FILE_INFO* file_info = (EFI_FILE_INFO*)file_info_buffer;
// カーネルファイルのファイルサイズを取得
UINTN kernel_file_size = file_info->FileSize;
/**
* カーネルファイルを一時領域に読み込む
*/
VOID* kernel_buffer;
// EFI_ALLOCATE_POOL: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Uefi/UefiSpec.h#L268
gBS->AllocatePool(
EfiLoaderData, // IN EFI_MEMORY_TYPE PoolType,
kernel_file_size, // IN UINTN Size,
&kernel_buffer // OUT VOID **Buffer
);
kernel_file->Read(
kernel_file, // IN EFI_FILE_PROTOCOL *This
&kernel_file_size, // IN OUT UINTN *BufferSize
(VOID*)kernel_buffer // OUT VOID *Buffer
);
カーネルファイルの最終ロード先のメモリ領域の確保
/**
* コピー先のメモリを確保
*/
Elf64_Ehdr* kernel_ehdr = (Elf64_Ehdr*)kernel_buffer;
UINT64 kernel_first_addr, kernel_last_addr;
CalcLoadAddressRange(kernel_ehdr, &kernel_first_addr, &kernel_last_addr);
UINTN num_pages = (kernel_last_addr - kernel_first_addr + 0xfff) / 0x1000;
// EFI_ALLOCATE_PAGES: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Uefi/UefiSpec.h#L186
status = gBS->AllocatePages(
AllocateAddress, // IN EFI_ALLOCATE_TYPE Type : https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Uefi/UefiSpec.h#L29
// AllocateAnyPages: どこでもいいから空いている場所に確保
// AllocateMaxAddress: 指定したアドレス以下で空いている場所に確保
// AllocateAddress: 指定したアドレスに確保
EfiLoaderData, // IN EFI_MEMORY_TYPE MemoryType : https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Uefi/UefiMultiPhase.h#L38
// UEFI アプリケーションやドライバがメモリを割り当てる際、どのような目的で使用するメモリかを指定する
// EfiLoaderCode: ロードされたアプリケーションのコードセクション
// EfiLoaderData: ロードされたアプリケーションのデータセクション
num_pages, // IN UINTN Pages
&kernel_first_addr // IN OUT EFI_PHYSICAL_ADDRESS *Memory
);
CalcLoadAddressRangeの実装
最終ロード先のメモリ領域を特定する CalcLoadAddressRange を実装します。
- プログラムヘッダテーブルの要素を1つづつ走査します。
- LOADセグメントを見つけたら、そのセグメントの先頭アドレスと末尾アドレスを取得します。
-
first
よりも先頭アドレスが小さければfirst
を更新、last
よりも末尾アドレスが大きければlast
を更新します。
この作業を繰り返していくと、全LOADセグメントを収めるのに必要なメモリ領域の最初と最後のアドレスが分かります。
/**
* カーネルファイル内のすべてのLOADセグメント(p_type が PT_LOADであるセグメント)を走査し、
* アドレス範囲を更新します。
*/
VOID CalcLoadAddressRange(Elf64_Ehdr* ehdr, UINT64* first, UINT64* last) {
Elf64_Phdr* phdr = (Elf64_Phdr*)((UINT64)ehdr + ehdr->e_phoff);
*first = MAX_UINT64;
*last = 0;
for (Elf64_Half i = 0; i < ehdr->e_phnum; ++i) {
if (phdr[i].p_type != PT_LOAD) {
continue;
}
*first = MIN(*first, phdr[i].p_vaddr);
*last = MAX(*last, phdr[i].p_vaddr + phdr[i].p_memsz);
}
}
最終ロード先にカーネルファイルをロード
CopyLoadSegments(kernel_ehdr);
Print(L"Kernel: 0x%0lx - 0x%lx\n", kernel_first_addr, kernel_last_addr);
// EFI_FREE_POOL: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Uefi/UefiSpec.h?utm_source=chatgpt.com#L285
status = gBS->FreePool(kernel_buffer);
CopyLoadSegmentsの実装
一次領域にロードしたカーネルファイルのロードセグメントを最終目的地のメモリアドレスにコピーします。
- LOADセグメントに対して2つの処理を行います
- segm_in_fileが指す一時領域から p_vaddr が指す最終目的地へデータをコピーします
- セグメントのメモリ上のサイズがファイル上のサイズより大きい場合(remain_bytes > 0)、残りを0で埋めます(SetMem())
/**
* カーネルファイルのLOADセグメントを一時領域から最終目的地のメモリアドレスにデータをコピーします
*/
VOID CopyLoadSegments(Elf64_Ehdr* ehdr) {
Elf64_Phdr* phdr = (Elf64_Phdr*)((UINT64)ehdr + ehdr->e_phoff);
for (Elf64_Half i = 0; i < ehdr->e_phnum; i++) {
if (phdr[i].p_type != PT_LOAD) {
continue;
}
// 一時領域から最終目的地へデータをコピー
UINT64 segm_in_file = (UINT64)ehdr + phdr[i].p_offset;
// CopyMem: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Library/BaseMemoryLib.h#L33
CopyMem(
(VOID*)phdr[i].p_vaddr, // IN VOID *Destination
(VOID*)segm_in_file, // IN VOID *Source
phdr[i].p_filesz // IN UINTN Length
);
// 残りのメモリを0埋め
UINTN remain_bytes = phdr[i].p_memsz - phdr[i].p_filesz;
// SetMem: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Library/BaseMemoryLib.h#L55
SetMem(
(VOID*)(phdr[i].p_vaddr + phdr[i].p_filesz), // IN VOID *Buffer
remain_bytes, // IN UINTN Size
0 // IN UINT8 Value
);
}
}
ブートサービスの停止とカーネルの起動
ブートサービスを停止して、カーネルを起動し、処理をOSに移譲します。
/**
* カーネルの起動
*/
// UINT64: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/X64/ProcessorBind.h#L180
// メモリ上でエントリーポイントがおいてあるアドレス
// ELF形式の仕様では64bit用のELFのエントリポイントアドレスはオフセット24byteの位置から8バイト整数として書かれる事になっている
// ELFの情報は readelf -h build/kernel/kernel.elf で確認できる
UINT64 entry_addr = *(UINT64*)(kernel_first_addr + 24);
// エントリポイントをC言語の関数として呼び出すために、関数ポインタにキャスト
typedef void EntryPointType(void);
EntryPointType* entry_point = (EntryPointType*)entry_addr;
Print(L"entry_point: 0x%p\n", entry_point);
/**
* カーネル起動前にUEFI BIOSのブートサービスを停止
*/
// EFI_EXIT_BOOT_SERVICES: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Uefi/UefiSpec.h#L983
// ブートサービスを停止させる。この関数が成功した場合、以降にブートサービスの機能を使うことはできない。
status = gBS->ExitBootServices(
image_handle, // IN EFI_HANDLE ImageHandle
memmap.map_key // IN UINTN MapKey 最新のメモリマップのマップキーを要求。マップキーが最新のメモリマップに紐づく値でない場合は失敗する
);
if (EFI_ERROR(status)) { // 停止に失敗した場合はリトライ
// 最新のメモリマップを取得
status = GetMemoryMap(&memmap);
if (EFI_ERROR(status)) {
Print(L"failed to get memory map: %r\n", status);
while (1);
}
// 停止をリトライ
status = gBS->ExitBootServices(image_handle, memmap.map_key);
if (EFI_ERROR(status)) {
Print(L"Could not exit boot service: %r\n", status);
while (1);
}
}
// 関数ポインタを実行
entry_point();
起動
Makefile で カーネルのビルド、ブートローダーのビルド、イメージ作成、QEMUでイメージの起動を一括して行います。
make run-nographic
# BdsDxe: loading Boot0001 "UEFI QEMU HARDDISK QM00001 " from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)
# BdsDxe: starting Boot0001 "UEFI QEMU HARDDISK QM00001 " from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)
# Hello, MikanOS!
# map->buffer = 3FE92610, map->map_size = 00001860
# Kernel: 0x100000 - 0x101133
# entry_point: 0x101120
gdbでhltが無限ループしているかを確認
gdb
# QEMUモニタに接続
(gdb) targetremote :1234
# Remote debugging using :1234
# warning: No executable has been specified and target does not support
# determining executable automatically. Try using the "file" command.
# 0x0000000000101131 in ?? ()
# 現在のRIPレジスタを確認
(gdb) x /2i $pc
# => 0x101131: jmp 0x101130
# 0x101133: add %al,(%rax)
# 1バイト遡ってみると hlt が確認できる
(gdb) x /2i 0x101130
# 0x101130: hlt
# => 0x101131: jmp 0x101130 ★ hltにjmpして無限ループしている
ブートローダからピクセルを描く (day03b)
この章のソースコードはこちら
UEFIでピクセル単位で画面を描画するにはGOP(Graphics Output Protocol) という機能を利用します。
ピクセル描画には次の情報が必要になります。
- フレームバッファの先頭アドレス
-
フレームバッファの表示領域の幅と高さ
いわゆる解像度 -
フレームバッファの非表示領域を含めた幅
フレームバッファには、表示領域の右側に表示されない余分な横幅が存在する場合がある -
1ピクセルのデータ形式
フレームバッファの中で1ピクセルが何バイトで表現されているか。RGBの3色が何ビットずつどんな順番で並んでいるかという情報。1ピクセルがRGB各8bitであれば、256階調の3乗で1677万色を表示可能
※ フレームバッファ(Frame Buffer)
ピクセルに描画するためのメモリ領域。
フレームバッファの各メモリに値を書き込むとそれがディスプレイのピクセルに反映されます。
実装
gopの取得しそれを使ってピクセルを描画する処理をUefiMainに実装します。
/**
* GOPを取得して画面描画する
*/
// EFI_GRAPHICS_OUTPUT_PROTOCOL: https://github.com/tianocore/edk2/blob/edk2-stable202208/MdePkg/Include/Protocol/GraphicsOutput.h#L258
EFI_GRAPHICS_OUTPUT_PROTOCOL* gop;
OpenGOP(image_handle, &gop);
// EFI_GRAPHICS_OUTPUT_PROTOCOL_MODE: https://github.com/tianocore/edk2/blob/edk2-stable202208/MdePkg/Include/Protocol/GraphicsOutput.h#L224
Print(L"Resolution: %ux%u, Pixel Format: %s, %u pixels/line\n",
gop->Mode->Info->HorizontalResolution, // 水平方向のピクセル数
gop->Mode->Info->VerticalResolution, // 垂直方向のピクセル数
GetPixelFormatUnicode(gop->Mode->Info->PixelFormat), // フレームバッファで1ピクセルを表すデータ形式
gop->Mode->Info->PixelsPerScanLine // ビデオメモリラインあたりのピクセル数
);
Print(L"Frame Buffer: 0x%0lx - 0x%0lx, Size: %lu bytes\n",
gop->Mode->FrameBufferBase, // フレームバッファの先頭アドレス
gop->Mode->FrameBufferBase + gop->Mode->FrameBufferSize, // フレームバッファの末尾アドレス
gop->Mode->FrameBufferSize // フレームバッファの全体サイズ
);
UINT8* frame_buffer = (UINT8*)gop->Mode->FrameBufferBase;
for (UINTN i = 0; i < gop->Mode->FrameBufferSize; i++) {
frame_buffer[i] = 255;
}
OpenGOP
と GetPixelFormatUnicode
の実装
EFI_STATUS OpenGOP(EFI_HANDLE image_handle, EFI_GRAPHICS_OUTPUT_PROTOCOL** gop) {
UINTN num_gop_handles = 0;
EFI_HANDLE* gop_handles = NULL;
// EFI_LOCATE_HANDLE_BUFFER: https://github.com/tianocore/edk2/blob/edk2-stable202208/MdePkg/Include/Uefi/UefiSpec.h#L1570
// リクエストされたプロトコルをサポートするハンドルの配列を取得する
gBS->LocateHandleBuffer(
ByProtocol, // IN EFI_LOCATE_SEARCH_TYPE SearchType 返却されるハンドルの指定
&gEfiGraphicsOutputProtocolGuid, // IN EFI_GUID *Protocol OPTIONAL 検索するプロトコルを指定(SearchType=ByProtocolの場合のみ有効)
NULL, // IN VOID *SearchKey OPTIONAL SearchType に応じた検索キーを指定
&num_gop_handles, // OUT UINTN *NoHandles 取得したハンドルの数
&gop_handles // OUT EFI_HANDLE **Buffer バッファから返却されたハンドルの配列
);
// EFI_OPEN_PROTOCOL: https://github.com/tianocore/edk2/blob/edk2-stable202302/MdePkg/Include/Uefi/UefiSpec.h#L1330
// ハンドルが指定されたプロトコルをサポートしているかを問い合わせ、 サポートしている場合、呼び出し元のエージェントに代わってプロトコルをオープンする
gBS->OpenProtocol(
gop_handles[0],
&gEfiGraphicsOutputProtocolGuid,
(VOID**)gop,
image_handle,
NULL,
EFI_OPEN_PROTOCOL_BY_HANDLE_PROTOCOL
);
return EFI_SUCCESS;
}
/**
* EFI_GRAPHICS_PIXEL_FORMATを文字列に変換する
*/
const CHAR16* GetPixelFormatUnicode(EFI_GRAPHICS_PIXEL_FORMAT fmt) {
// EFI_GRAPHICS_PIXEL_FORMAT: https://github.com/tianocore/edk2/blob/edk2-stable202208/MdePkg/Include/Protocol/GraphicsOutput.h#L28
switch (fmt) {
case PixelRedGreenBlueReserved8BitPerColor:
return L"PixelRedGreenBlueReserved8BitPerColor";
case PixelBlueGreenRedReserved8BitPerColor:
return L"PixelBlueGreenRedReserved8BitPerColor";
case PixelBitMask:
return L"PixelBitMask";
case PixelBltOnly:
return L"PixelBltOnly";
case PixelFormatMax:
return L"PixelFormatMax";
default:
return L"InvalidPixelFormat";
}
}
イメージの起動
make run
カーネルからピクセルを描く (day03c)
この章のソースコードはこちら
libc,libc++のビルド
カーネルでC++の標準ライブラリを利用するため、libc++を自力でビルドしようと思ったのですが、知識が足りなすぎて断念、、、
一応、このサイトを参考にやろうとしました。
Newlib
libcのビルドでNewlibというものをビルドしていたので、気になって調べてみると、組み込みシステム向けに設計されたC標準ライブラリの実装で、自作OSでC標準ライブラリが必要なときはよく使われているようです。
- 公式? (ページがめちゃめちゃそっけない)
- リポジトリ (GitHubじゃないんだ、、、)
結局Dockerでビルド
著者の方がビルド用のイメージを出してくれてました。
docker pull uchannos/stdlib-builder:1.1
docker run -v ./build/x86_64-elf:/usr/local/x86_64-elf uchannos/stdlib-builder:1.1
実装
Main.cでカーネルのエンドポイントにフレームバッファのアドレスとサイズを渡すように修正します。
diff --git a/MikanLoaderPkg/Main.c b/MikanLoaderPkg/Main.c
index b2cc87c..696a913 100644
--- a/MikanLoaderPkg/Main.c
+++ b/MikanLoaderPkg/Main.c
@@ -438,7 +438,7 @@ EFI_STATUS EFIAPI UefiMain(EFI_HANDLE image_handle, EFI_SYSTEM_TABLE* system_tab
UINT64 entry_addr = *(UINT64*)(kernel_first_addr + 24);
// エントリポイントをC言語の関数として呼び出すために、関数ポインタにキャスト
- typedef void EntryPointType(void);
+ typedef void EntryPointType(UINT64, UINT64);
EntryPointType* entry_point = (EntryPointType*)entry_addr;
Print(L"entry_point: 0x%p\n", entry_point);
@@ -467,7 +467,10 @@ EFI_STATUS EFIAPI UefiMain(EFI_HANDLE image_handle, EFI_SYSTEM_TABLE* system_tab
}
// 関数ポインタを実行
- entry_point();
+ entry_point(
+ gop->Mode->FrameBufferBase,
+ gop->Mode->FrameBufferSize
+ );
Print(L"All done\n");
カーネル側では、渡されたフレームバッファのアドレスとサイズを利用してピクセルを描画します。
#include <cstdint>
// extern "C" はC言語からこの関数呼び出すためマングリングを行わないようにする記述
extern "C" void KernelMain(uint64_t frame_buffer_base, uint64_t frame_buffer_size) {
// reinterpret_cast:
// ある型のビットパターンをそのまま別の型として再解釈するキャスト。C言語の(uint8_t*)frame_buffer_baseと効果は全く同じ
uint8_t* frame_buffer = reinterpret_cast<uint8_t*>(frame_buffer_base);
for (uint64_t i = 0; i < frame_buffer_size; i++) {
frame_buffer[i] = i % 256;
}
// __asm__() はインラインアセンブリ。C言語からアセンブリ命令を呼び出すことができる
// hlt はCPUを停止させる命令で省電力状態になる。割り込みがあると動作が再開する。(永久ループにするとCPUが100%に張り付いてしまう)
while (1) __asm__("hlt");
}
実行
#include <cstdint>
のためにlibc++が必要なので、Makefileにlibc++のビルドとlibc++のインクルードを設定します。
diff --git a/Makefile b/Makefile
index 5728134..6aa9b31 100644
--- a/Makefile
+++ b/Makefile
@@ -6,6 +6,10 @@ build/BOOTX64.EFI: $(MIKAN_LOADER_PKG)
cp edk2/Build/MikanLoaderX64/DEBUG_CLANG38/X64/Loader.efi $@
chmod +x $@
+build/x86_64-elf/include/c++/v1:
+ docker pull uchannos/stdlib-builder:1.1
+ docker run -v ./build/x86_64-elf:/usr/local/x86_64-elf uchannos/stdlib-builder:1.1
+
# -O2 レベル2の最適化を行う
# -Wall 警告をたくさん出す
# -g デバッグ情報付きでコンパイルする
@@ -17,9 +21,13 @@ build/BOOTX64.EFI: $(MIKAN_LOADER_PKG)
# -std=c++17 C++のバージョンをC++17とする
# -c コンパイルのみする。リンクはしない。
# -o build/kernel/main.o 出力先を指定
-build/kernel/main.o: kernel/main.cpp
+build/kernel/main.o: kernel/main.cpp build/x86_64-elf/include/c++/v1
mkdir -p build/kernel
clang++-18 \
+ -Ibuild/x86_64-elf/include/c++/v1 -Ibuild/x86_64-elf/include -Ibuild/x86_64-elf/include/freetype2 \
+ -Iedk2/MdePkg/Include -Iedk2/MdePkg/Include/X64 \
+ -nostdlibinc -D__ELF__ -D_LDBL_EQ_DBL -D_GNU_SOURCE -D_POSIX_TIMERS \
+ -DEFIAPI='__attribute__((ms_abi))' \
-O2 \
-Wall \
-g \
OSの起動
make run
エラー処理 (day03d)
この章のソースコードは こちら
https://github.com/ng3rdstmadgke/mikanos/tree/day03
edk2の関数は基本的に戻り値に EFI_STATUS(実態はRETURN_STATUS) を返します。
このステータスは EFI_ERROR関数(実態はRETURN_ERROR) で失敗かどうかを判定できます。
ここでは、edk2の関数の戻り値のステータスを判定して、エラーステータスであれば、while (1) __asm__("hlt");
でプログラムを停止するという処理を追加していきます。
実装
ソースコードはこちら
ブートローダーを停止する Halt
関数
void Halt(void) {
while (1) __asm__("hlt");
}
エラーハンドリング
※ %r
フォーマット指定子は RETURN_STATUS
の値を展開します。(参考: Print関数のフォーマット指定子の仕様 | edk2)
status = OpenRootDir(image_handle, &root_dir);
if (EFI_ERROR(status)) {
Print(L"failed to open root directory: %r\n", status);
Halt();
}
実行
OSの起動
make run