以前 1から創る自作OS ということで x86向けでGRUBから起動できるプログラムについて 記事を書いていたことがあるのですが、当時、アクセス解析等で 確認したところ、あまり反応が宜しくなく、事実上の打ち切りになっていました。。。
ところが最近この古い記事に地味にアクセスが増えているようなので 久しぶりに このカテゴリの記事です。
以前の続きからコードを書くという手もあったのですが、 せっかくなので記事タイトルの通り、x86_64仕様にして 書きなおしてみようと思います。 (書き直すというほどコード量書いていませんでしたが…)
GRUBから64bitモードへ
今回もカーネルの読み込みまでは GRUBさんに一任します。 前回は multiboot.h を使用しましたが今回は multiboot2.h を 使用します。 規格通りのヘッダを用意すれば読み込みまでは簡単です。
読み込み直後は 32bitプロテクトモードになっているので、 まずは 64bitロングモードに移行、さらにC言語のカーネルを 呼び出す流れです。
起動までの具体的な手順は以下の通りです
●GRUBからのデータを一旦退避させる ↓ ●スタックの初期化(Lower) ↓ ●A20ゲート有効化(一応) ↓ ●一時的なGDTの設定 ●セグメントレジスタの設定 ↓ ●pagingの有効化 ●PAEの有効化 ↓ ●ロングモードへ!! ↓ ●スタックの初期化(Higher) ●セグメントレジスタ設定 ●GRUBの退避データを引数に設定 ↓ C言語のコードを呼び出す
以前に比べてアセンブリの量がだいぶ増えてしまいました。。。
multiboot2 header
まずは multiboot2のヘッダです。 こちら からダウンロードできます。
boot.S冒頭
#define ASM_FILE 1
#include <multiboot2.h>
#define MBH_MAGIC MULTIBOOT2_HEADER_MAGIC
#define MBH_ARCH MULTIBOOT_ARCHITECTURE_I386
#define MBH_LENGTH (mbhdr_end - mbhdr)
#define MBH_CHECKSUM -(MBH_MAGIC + MBH_ARCH + MBH_LENGTH)
.section .boot_text, "ax"
.code32
.global mbhdr
.align MULTIBOOT_INFO_ALIGN
mbhdr:
# Basic
.long MBH_MAGIC
.long MBH_ARCH
.long MBH_LENGTH
.long MBH_CHECKSUM
# End tag
.word 0,0
.long 0x8
mbhdr_end:
これでOKです。 .section .boot_text については linker.ldにて
KVMA_BASE = 0xFFFFFFFF80000000;
KLNA_BASE = 0x100000;
ENTRY(start)
SECTIONS
{
. = KLNA_BASE;
_kernel_start = .;
.boot : {
*(.boot_text)
*(.boot_data)
}
. += KVMA_BASE;
.text : AT(ADDR(.text) - KVMA_BASE) {
*(.text)
_rodata_start = .;
*(.rodata)
_rodata_end = .;
}
.data : AT(ADDR(.data) - KVMA_BASE) {
_data_start = .;
*(.data)
_data_end = .;
}
.bss : AT(ADDR(.bss) - KVMA_BASE) {
_bss_start = .;
*(.bss)
_bss_end = .;
}
.heap_early : AT(ADDR(.heap_early) - KVMA_BASE)
{
_heap_early_start = .;
. += 0x8000;
_heap_early_end = .;
}
. = ALIGN(0x1000);
_kernel_end = . - KVMA_BASE;
}
このように宣言してあります。 このメモリマップはいわゆるHigherHalfな配置です。
ロングモードでは広大な仮想メモリ空間を扱えますが、 メモリ空間の先頭から大部分をユーザープログラムのために開けておいて 0xFFFFFFFF80000000 以降をカーネルが使用するようにします。
マルチタスクを行うときにカーネルが存在する仮想のメモリ空間を 固定します。これは、アプリからのシステムコールなどの効率を上げるためや、 0x0からのアドレスをユーザープログラムで使えることで、 先頭1MBのメモリしか使用できないリアルモードのプログラムなどを 動かすとき、 64bit カーネル上で 32bitのアプリを動かすときなどにも 都合がいいため、多くのカーネルで採用されている配置方法です。
GRUBはカーネルをリニアアドレスで 0x100000 に配置するので ページングを有効にして 仮想アドレスで 0xFFFFFFFF80000000 以降を リニアアドレスの 先頭0x0と対応させるようにします。
少し脱線したので起動処理に戻ります。 前述のとおり、現在GRUBから呼び出されたので 0x100000 からはじまる .bootセクションにいます。 ページングが有効になったら 仮想アドレス 0xFFFFFFFF80000000 でカーネルを動かすように ジャンプします。
プロテクトモード
.global start
.extern stack
.extern pre_gdt_p
start:
# Store GRUB data
movl %eax, %edi
movl %ebx, %esi
# Setup Stack
movl $(stack+STACK_SIZE), %esp
# Enable A20 line via System Port A
in $0x92, %al
cmpb $0xff, %al
jz no92
or $2, %al
and $0xFE, %al
out %al, $0x92
no92:
# Setup pre-GDT
lgdt pre_gdt_p
# Set Segment Register
ljmp $GDT_KC32, $1f
1:
# Setup data segment selectors
mov $GDT_KD32, %eax
mov %ax, %ds
mov %ax, %es
mov %ax, %ss
xorl %eax, %eax
mov %ax, %fs
mov %ax, %gs
起動処理の先頭はこんなかんじです。 順に説明します。 まずは GRUBが Multiboot Magic と multiboot_info構造体へのポインタを それぞれ EAX,EBXレジスタに格納してくれているので、これを EDI ESI レジスタに退避させます。
EDI,ESIレジスタに入れたのは こちら(pdf) によると、GCCでは
C言語の関数で整数引数をスタックに積むのではなくレジスタに 格納して直接読み出すようにしているらしいので、 第一引数 EDI 第二引数ESI にこの時点で移しておきます。
次にスタックを初期化します。 別のヘッダファイルで #define STACK_SIZE 0x4000 が宣言されています。
またスタックの本体は boot.S とは別に data.S というソースファイルを 用意して
.section .boot_data, "aw", @progbits
(略)
stack:
.space STACK_SIZE
このように確保しておきます。 データを分けたのは、スタックの他に、一時的なGDTやページングに必要な データを .section .boot_data に格納するため、boot.Sにすべて記述すると ごちゃごちゃしてしまうためです。
続いて A20ラインの有効化です。 GRUBがすでに有効にしてくれているらしいのですが、 参考にいくつかのお手製カーネルを見て回ったところ 実行しているコードが多かったので、一応やっておきます。
昔のCPUはメモリにアクセスすためのアドレスバスが19本しか使用できなかったため、 互換性を守るために現在でもリアルモードでは1MBまでしかアクセスできないように なっています。 A20制限を解除することでより大きなメモリアドレスを指定可能に なります。 A20を開放する方法はいくつかありますが、一番簡単な システムコントロールポートA(0x92) を経由する方法をとっています。 > 参考
次にGDTを設定します。 C言語のカーネルが起動したらまた細かく設定しなおしますが とりあえず暫定的なGDTを設定します。 data.Sに GDT用のデータを記録しておいて それを lgdtで読み込みます。
GDTはセグメンテーションを扱うために必要なデータです。
セグメント方式とは、 簡単にいうとメモリをいくつかの範囲に分割して 範囲の先頭アドレス + 先頭からの距離 で物理メモリにアクセスする方法です。
先頭からの距離 という相対的な数値でメモリにアクセスします。
これによって プログラムが物理メモリ上のどこに配置されていても そのプログラムは 0x00000000 から始まることにしてプログラミングしておき 実際は先頭アドレスからの距離として足し算することで実アドレスを 算出することができます。 > 参考 それぞれのプログラムは実アドレスを考慮しなくてもよくなるので、 セグメントをうまく使うことでマルチタスク機能を効率良く作ることができるわけです。
ただし、今回は仮想メモリの管理にセグメント方式ではなくページング方式を 使うので、GDTには 0x00000000 からメモリ空間すべてを1つの範囲として 設定してしまい実際にはページングでメモリの管理を行います。 こういった方法を フラットメモリモデル と言います。
GDTには セグメントディスクリプタ を登録でき、このディスクリプタが メモリの範囲やアクセス制限などの情報を保持しています。 セグメンテーションを行うときは GDTに登録された複数のセグメントティスクリプタの内、何番目のデータを使うか
を指定して使用します。> 参考
pre-gdt ということで最低限のセグメントティスクリプタを data.Sに保存しておきます。 セグメントティスクリプタの構造は この 通り少々複雑なので
#define GDT_SEG_64 0xA0
#define GDT_SEG_32 0xC0
#define GDT_KERNEL 0x90
#define GDT_USER 0xf0
#define GDT_DS 0x3
#define GDT_CS 0xb
#define GDT_ENTRY_NULL .quad 0x0
#define GDT_ENTRY_SET(arch, mode, type, base, limit)\
.word (((limit) >> 12) & 0xFFFF); \
.word ((base) & 0xFFFF); \
.byte (((base) >> 16) & 0xFF); \
.byte (mode | (type)); \
.byte ((arch) | (((limit) >> 28) & 0xF)); \
.byte (((base) >> 24) & 0xFF)
このようなマクロを用意しました。 あとは
data.S にて
.p2align 3
pre_gdt:
GDT_ENTRY_NULL
GDT_ENTRY_SET( GDT_SEG_32, GDT_KERNEL, GDT_CS, 0x0, 0xFFFFFFFF)
GDT_ENTRY_SET( GDT_SEG_32, GDT_KERNEL, GDT_DS, 0x0, 0xFFFFFFFF)
GDT_ENTRY_SET( GDT_SEG_64, GDT_KERNEL, GDT_CS, 0x0, 0xFFFFFFFF)
GDT_ENTRY_SET( GDT_SEG_64, GDT_KERNEL, GDT_DS, 0x0, 0xFFFFFFFF)
pre_gdt_end:
pre_gdt_p:
.word pre_gdt_end - pre_gdt + 1
.quad pre_gdt
このように記述すればマクロが展開されてセグメントディスクリプタは完成です。 また GDTをCPUに登録すときは GDTRというレジスタに GDTのサイズとベースアドレスを与える必要があるので pre_gdt_p にその情報も書いておきます。
そしてこの pre_gdt_p を lgdt 命令でロードすればOKです。
GDTの設定が済んだので早速セグメント関係のレジスタを設定します ljmp命令で直下にジャンプしています。
CPUは賢いので、効率よく命令を実行するために命令の "先読み" を行っています。 このためせっかく GDTを設定して セグメントレジスタを変更した時にはもう次の命令が読み込まれて しまっておりパイプライン内でアドレスが不整合になってしまう場合が あります。 そこで 分岐など動的な動作が予想される jmp命令では機械的な先読みが できないためCPUがパイプラインキャッシュをクリアすることを利用して この問題を避けています。 また ljmpで同時にCSレジスタも設定できます。
ちなみに #define GDT_KC32 0x08 #define GDT_KD32 0x10 と宣言されています。これは
0x08 = 0b00001000 = 0001,0,00 = index 1, TI=0 RPL=00 0x10 = 0b00010000 = 0010,0,00 = index 2, TI=0 RPL=00 0x18 = 0b00011000 = 0011,0,00 = index 3, TI=0 RPL=00 ・・・
TIは 0ならGDT 1ならLDT のデータであることを示しています。 RPLは Level0 が設定されています。
が指定されています CSに GDT_KC32(0x8) が読み込まれると RPL=0 で GDTのインデックス1番 つまり
GDT_ENTRY_SET( GDT_SEG_32, GDT_KERNEL, GDT_CS, 0x0, 0xFFFFFFFF)
が読まれるわけです。
CSの次はDS、ES,SS等 その他セグメントレジスタも初期化します、 フラットメモリモデルを採用しているのですべて同じ値でOKです。
今回はここまでにします。 次回、ページングを有効にします。
(今回作成したコードは次回まとめて公開します)