【OS自作】littleosbook をやってみる #3

OS 自作シリーズの第三回。前回の記事は以下。

t-keita.hatenadiary.jp

不備が修正されたバージョンの littleosbook は以下。今回は5章から。

ordoflammae.github.io

5 Segmentation

セグメントはメモリ上の区間である。セグメンテーションとはセグメントを経由してメモリへアクセスする方式である。セグメントを介したアクセスは6バイトの論理アドレスを用いて行われる。上位2バイトがどのセグメントであるかの指定(segment selector)で、下位4バイトはセグメント内のオフセット(offset)であるとのこと。なんとなく IP アドレスのホスト部とネットワーク部みたいなものをイメージした。

6バイトの論理アドレスから、アクセスするアドレス(liner address)への変換は次の流れである。上位 2バイトの segment selector の値から segment descriptor を用いてセグメントを特定し base address を得る。そして、下位4バイトのオフセットと base address を足し合わせると liner address が求まる。仕組みは簡単。

5.1 Accessing Memory

プロセッサは6つのセグメントレジスタ cs, ss, ds, es, gs, fs を持っている。たとえば、cs レジスタは code segment を指す segment selector であり、プログラムの命令をフェッチするときに利用される。また ss レジスタは stack segment、ds レジスタは data segment を指す。要するにプログラムの実行状態のうちセグメンテーションに関わるものはレジスタとして保持されているということだろう。

x86アセンブリから eax レジスタを使う例が書かれている。[eax] は明示的に書くと [ds:eax] と書けるらしい。この場合は、ds がセグメントを表す segment selector になっていて eax はオフセットである理解したらいいのかな。実行するプロセスが異なれば ds に対応するベースアドレスも異なっているため、[ds:eax] でアクセスされるアドレスも異なるようになっているみたいなイメージをした。

5.2 The Global Descriptor Table (GDT)

2バイトの segment selector からベースアドレスを返す descriptor について。特に GDT は以下の画像のフィールドをもつ8バイトのエントリの配列である。ちなみに各要素は segment descriptor と呼ばれている。

f:id:t-keita:20211023232900p:plain:w700

※ 画像は Intel のマニュアル からの引用

フィールドの中にはセグメントのタイプを表すものがある。Descriptor type が 1 であるときはそのセグメントが code segment または data segment である。code/data segment や、もう少し細かな権限の設定は TYPE フィールドで設定される。code segment のときはそのセグメントの内容を実行できるが書き込むことはできない。一方で data segment はそのセグメントに書き込みができる。

他の重要フィールドとして Descriptor Privilege Level(DPL)がある。PL0 が特権モードで、PL3 がもっと弱い権限を与えるモード。この privilege level はおそらく CPU modes に関係する話。メモリにロードされたプログラムが特権モードで実行してよいのかどうかを descriptor table が管理しているのだろう。

en.wikipedia.org

チュートリアルには At least two segment descriptors (plus the null descriptor) are needed みたいに書いている。最初読んだときこの文の意味が分からなかったが、これはカーネルを実行するために2つの descriptors が必要であるという話をしているのだろう。カーネルのための code segment と data segment をそれぞれ descriptor table に追加する必要がある。もちろんカーネルなので PL0 の特権が必要である。ここの実装は下でまとめてやる。

5.3 Loading the GDT

ここからは GDT の管理を自作カーネルの一部として実装する。今回のチュートリアルでは、カーネルがすべてのアドレス範囲を特権モードで扱えるようにすることが目的である。実装のおおまかな流れを先に説明する。GDT にカーネルのための2つのエントリ(code segment とdata segment)を追加したうえで、GDT の場所をプロセッサに伝える。その後、セグメントレジスタの値を設定する。

まずは、lgdt 命令を実行するアセンブリコードと C 言語のプログラムをつなぐためのインターフェースの定義を行う。以下のようなコードを gdb.h というファイルに定義した。チュートリアルでは sizeaddress の位置が逆になっているが以下の方が正しいので注意。

struct gdt
{
    unsigned short size;
    unsigned int address;
} __attribute__((packed));

void gdtb(struct gdt gdt);

nasm の lgdt 命令の引数には上記の構造体 gdt そのもの(厳密には構造体をバイナリ表現にしたもの)が必要であるため、このように構造体を引数としている。最初、チュートリアルにある the address to such a struct という表現を読んだとき、lgdt 命令が構造体へのポインタのアドレスを引数に取るものだと理解してしまった。正しくは構造体そのもののアドレスである。(C 言語の関数の引数に与えると esp + 4 がまさにこのアドレスに相当する)。この勘違いで時間を溶かした。

そして lgdt 命令を実行し、セグメントレジスタチュートリアルの通りに設定する。以下のようなアセンブリを書いて gdt.s という名前のファイルとした。

    global gdtb

    ; gdtb - loading the Global Descriptor Table
    ; stack: [esp + 4] gdt struct
    ;        [esp    ] return address
    gdtb:
        lgdt [esp + 4]
        mov ax, 0x10
        mov ds, ax
        mov ss, ax
        mov es, ax
        mov fs, ax
        mov gs, ax
        ; code here uses the previous cs
        jmp 0x08:flush_cs   ; specify cs when jumping to flush_cs

    flush_cs:s
        ; now we've changed cs to 0x08        
        ret

最後に、C 言語のプログラムから GDT のエントリの設定をする。エントリの設定内容はチュートリアルや上で説明した segment descriptor の仕様に従うようにする。

#include "gdt.h"

struct gdt_entry
{
    unsigned short limit_low;
    unsigned short base_low;
    unsigned char base_middle;
    unsigned char access_byte;
    unsigned char limit_and_flags;
    unsigned char base_high;
} __attribute__((packed));

struct gdt table;
struct gdt_entry entries[3];

void load_gdt_table()
{
    //null descriptor
    entries[0].limit_low = 0x0000;
    entries[0].limit_and_flags = 0x00;
    entries[0].base_low = 0x0000;
    entries[0].base_middle = 0x00;
    entries[0].base_high = 0x00;
    entries[0].access_byte = 0x00;

    // kernel code segment
    // 0x00CF9A00 = 0000 0000 1100 1111 1001 1010 0000 0000
    // 0x0000FFFF = 0000 0000 0000 0000 1111 1111 1111 1111
    entries[1].limit_low = 0xFFFF;
    entries[1].limit_and_flags = 0xCF;
    entries[1].base_low = 0x0000;
    entries[1].base_middle = 0x00;
    entries[1].base_high = 0x00;
    entries[1].access_byte = 0x9A;

    // kernel data segment
    // 0x00CF9200 = 0000 0000 1100 1111 1001 0010 0000 0000
    // 0x0000FFFF = 0000 0000 0000 0000 1111 1111 1111 1111
    entries[2].limit_low = 0xFFFF;
    entries[2].limit_and_flags = 0xCF;
    entries[2].base_low = 0x0000;
    entries[2].base_middle = 0x00;
    entries[2].base_high = 0x00;
    entries[2].access_byte = 0x92;

    // load table
    table.address = (unsigned int)entries;
    table.size = sizeof(struct gdt_entry) * 3;

    gdtb(table);
}

GDT の設定が上手くできていることの動作確認をしてみた。まずはブレークポイントを設定するために lgdt 命令の場所を特定する。

$ objdump -M intel -S kernel.elf  | grep lgdt 
  100640:   0f 01 54 24 04          lgdtd  [esp+0x4]

100640 番地に lgdt 命令があることが分かったので、ここに bochsブレークポイントを置いて実行してみた。

<bochs:1> lb 0x100640
<bochs:2> c
(0) Breakpoint 1, 0x0000000000100640 in ?? ()
Next at t=1311390526
(0) [0x000000100640] 0008:0000000000100640 (unk. ctxt): lgdt ss:[esp+4]           ; 0f01542404

lgdt 命令を実行する直前の GDT の内容を info gdt コマンドで確認する。何かよく分からない値がすでに設定されている。

<bochs:3> info gdt
Global Descriptor Table (base=0x0000000000008f5c, limit=39):
GDT[0x0000]=??? descriptor hi=0x00000000, lo=0x0000fff0
GDT[0x0008]=Code segment, base=0x00000000, limit=0xffffffff, Execute/Read, Non-Conforming, Accessed, 32-bit
GDT[0x0010]=Data segment, base=0x00000000, limit=0xffffffff, Read/Write, Accessed
GDT[0x0018]=Code segment, base=0x00000000, limit=0x0000ffff, Execute/Read, Conforming, Accessed, 16-bit
GDT[0x0020]=Data segment, base=0x00000000, limit=0x0000ffff, Read/Write, Accessed
You can list individual entries with 'info gdt [NUM]' or groups with 'info gdt [NUM] [NUM]'

そして lgdt 命令を実行してみる。1命令だけ実行するには s コマンドを使う。

<bochs:4> s
Next at t=1311390527
(0) [0x000000100645] 0008:0000000000100645 (unk. ctxt): mov ax, 0x0010            ; 66b81000

lgdt 命令の実行後の GDT の中身を見てみる。意図通りに設定できていることが分かった。

<bochs:5> info gdt
Global Descriptor Table (base=0x0000000000103008, limit=24):
GDT[0x0000]=??? descriptor hi=0x00000000, lo=0x00000000
GDT[0x0008]=Code segment, base=0x00000000, limit=0xffffffff, Execute/Read, Non-Conforming, 32-bit
GDT[0x0010]=Data segment, base=0x00000000, limit=0xffffffff, Read/Write

所感

これでセグメンテーションの章は終わり。カーネルの機能なのかプロセッサの機能なのかややこしいが、今回の場合はメモリ空間をセグメントに分割し、セグメントごとに管理する機能をプロセッサが提供していることを意識したい。カーネルは GDT という形で具体的な設定値を与えているにすぎない。今回は lgdt 命令の仕様にハマったが、そのおかげで bochs の実行時にブレークポイントを置いたり、シリアルポートを経由したログ機能を活用したり、デバッグのコツが少し分かった気がする。