1から創る自作OS x86_64 01h

以前
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です。

今回はここまでにします。
次回、ページングを有効にします。

(今回作成したコードは次回まとめて公開します)

コメントを追加する