OS自作 6日目

ページテーブル

ページングやプロセスの設定も完了したので、ページテーブルを構築していきます。

ページテーブルでは仮想アドレスを物理アドレスに変換するための情報が格納されています。

仮想アドレスから物理アドレスへの変換を行えるようにすることで、プロセスごとに独立したメモリ空間を持つことができるようになります。

プロセスAとプロセスBで同じ0x00001111という仮想メモリアドレスがある場合でも、ページテーブルを参照すれば別々の物理アドレスを参照できるようになります。

RISCVのページング機構

RISCVではいくつかのページング機構がドキュメントで定義されています。
今回はRISC-Vドキュメント(The RISC-V Instruction Set Manual Volume Ⅱ)の11.3で定義されている32bit用ページング機構Sv32に則って構築していきます。

RISCVのページング機構の仕組みは以下サイトがよく参考になりました。
https://blog.rogiken.org/blog/2023/03/05/risc-v%e3%81%ab%e3%81%8a%e3%81%91%e3%82%8b%e4%bb%ae%e6%83%b3-%e7%89%a9%e7%90%86%e3%82%a2%e3%83%89%e3%83%ac%e3%82%b9%e5%a4%89%e6%8f%9b%e3%81%ae%e3%81%be%e3%81%a8%e3%82%81/

Sv32において、仮想アドレスのページテーブルは以下のように定義されています。

VPNと定義されるVirtalPageNumberがVPN[1]とVPN[0]の2つ定義されています。これはRISCVで定義されているページング機構が2段ページテーブルであるためです。

その他下位12bitでオフセットが定義されています。

また、satpレジスタというアドレス変換で使用するためのCSRレジスタがあるのでそちらも使用していきます。

MODEではページング機構に何を使用するか設定します。ドキュメント上、Sv32は1です。 ASIDの使用は必須ではないので今回は実装に使用しません。

Sv32のページテーブルに基づいて、ページテーブルを作成する関数は以下です。

#define SATP_SV32 (1u << 31)
#define PAGE_V    (1 << 0)   // 有効化ビット
#define PAGE_R    (1 << 1)   // 読み込み可能
#define PAGE_W    (1 << 2)   // 書き込み可能
#define PAGE_X    (1 << 3)   // 実行可能
#define PAGE_U    (1 << 4)   // ユーザーモードでアクセス可能

void map_page(uint32_t *table1, uint32_t vaddr, paddr_t paddr, uint32_t flags) {
    if (!is_aligned(vaddr, PAGE_SIZE))
        PANIC("unaligned vaddr %x", vaddr);

    if (!is_aligned(paddr, PAGE_SIZE))
        PANIC("unaligned paddr %x", paddr);

    uint32_t vpn1 = (vaddr >> 22) & 0x3ff;
    if ((table1[vpn1] & PAGE_V) == 0) {
        // 2段目のページテーブルが存在しないので作成する
        uint32_t pt_paddr = alloc_pages(1);
        table1[vpn1] = ((pt_paddr / PAGE_SIZE) << 10) | PAGE_V;
    }

    // 2段目のページテーブルにエントリを追加する
    uint32_t vpn0 = (vaddr >> 12) & 0x3ff;
    uint32_t *table0 = (uint32_t *) ((table1[vpn1] >> 10) * PAGE_SIZE);
    table0[vpn0] = ((paddr / PAGE_SIZE) << 10) | flags | PAGE_V;
}

また、アプリケーションではユーザモードのスタックだけでなくカーネルモードのスタックも保持する実装となるのでどちらもページングを実装します。

まずは、リンカスクリプトで__kernel_baseを定義します。

ENTRY(boot)

SECTIONS {
    . = 0x80200000;
    __kernel_base = .;

また、プロセス管理構造体で1段目のページテーブルを指すポインタを設定します。

struct process {
    int pid;
    int state;
    vaddr_t sp;
    uint32_t *page_table;
    uint8_t stack[8192];
};

次に、create_processの時点でカーネルのページングを設定して、kernel_baseからfree_ram_endまですべてをマッピングすることでアクセス可能にさせます。

extern char __kernel_base[];

struct process *create_process(uint32_t pc) {
    /* 省略 */

    uint32_t *page_table = (uint32_t *) alloc_pages(1);

    // カーネルのページをマッピングする
    for (paddr_t paddr = (paddr_t) __kernel_base;
         paddr < (paddr_t) __free_ram_end; paddr += PAGE_SIZE)
        map_page(page_table, paddr, paddr, PAGE_R | PAGE_W | PAGE_X);

    proc->pid = i + 1;
    proc->state = PROC_RUNNABLE;
    proc->sp = (uint32_t) sp;
    proc->page_table = page_table;
    return proc;
}

最後にスイッチコンテキスト時にページテーブルを切り替える実装です。

satpにページテーブルを設定しています。
また、sfence.vma命令で「ページテーブルへの変更をきちんと完了させることを保証する」「ページテーブルエントリのキャッシュを消す」ということを行っています。

void yield(void) {
    /* 省略 */

    __asm__ __volatile__(
        "sfence.vma\n"
        "csrw satp, %[satp]\n"
        "sfence.vma\n"
        "csrw sscratch, %[sscratch]\n"
        :
        // 行末のカンマを忘れずに!
        : [satp] "r" (SATP_SV32 | ((uint32_t) next->page_table / PAGE_SIZE)),
          [sscratch] "r" ((uint32_t) &next->stack[sizeof(next->stack)])
    );

    switch_context(&prev->sp, &next->sp);
}

OS自作 5日目 スケジューラ

スケジューラ

switch_context関数が作成されたことで、スケジューラを作成する準備が整いました。

プロセス管理構造体で待機しているプロセスにコンテキストをスイッチするスケジューラ関数となるyield関数を作成します。

struct process *current_proc; // 現在実行中のプロセス
struct process *idle_proc;    // アイドルプロセス

void yield(void) {
    // 実行可能なプロセスを探す
    struct process *next = idle_proc;
    for (int i = 0; i < PROCS_MAX; i++) {
        struct process *proc = &procs[(current_proc->pid + i) % PROCS_MAX];
        if (proc->state == PROC_RUNNABLE && proc->pid > 0) {
            next = proc;
            break;
        }
    }

    // 現在実行中のプロセス以外に、実行可能なプロセスがない。戻って処理を続行する
    if (next == current_proc)
        return;

    // コンテキストスイッチ
    struct process *prev = current_proc;
    current_proc = next;
    switch_context(&prev->sp, &next->sp);
}

現在実行中のプロセスとアイドルプロセスを定義しておきます。

yield関数では、next関数を用意して、プロセス管理構造体のPROC_RUNNABLE状態となっているプロセスを探索します。 PROC_RUNNABLE状態となっている他プロセスが発見したら、switch_context関数で変更するというシンプルなスケジューラ関数です。

以下のようにproc_a_entryとproc_b_entryの中身でyieldを定義してあげることで、プロセス管理構造体の中で、待機状態となっているA or Bプロセスにコンテキストがスイッチされることになります。

void proc_a_entry(void) {
    printf("starting process A\n");
    while (1) {
        putchar('A');
        yield();

        for (int i = 0; i < 30000000; i++)
            __asm__ __volatile__("nop");
    }
}

void proc_b_entry(void) {
    printf("starting process B\n");
    while (1) {
        putchar('B');
        yield();

        for (int i = 0; i < 30000000; i++)
            __asm__ __volatile__("nop");
    }
}

例外ハンドラの修正

例外ハンドラでは、実行中のレジスタをそのまま保存していましたが、プロセスごとに別のカーネルスタックを使用する流れにあわせて処理を変更する必要があります。

変更の要点としては、カーネルスタックをプロセス構造体に含まれるカーネルスタックの初期値の設定です。

yield関数でコンテキストスイッチを行う際に、スイッチ先プロセスのカーネルスタック初期値をsscratchレジスタに登録します。kernel_entry関数で、sscratch関数から取り出したカーネル初期値を使って例外ハンドラを動かします。

その方針に従って、以下のようにkernel_entry関数を更新します。

void kernel_entry(void) {
    __asm__ __volatile__(
        // 実行中プロセスのカーネルスタックをsscratchから取り出す
        // tmp = sp; sp = sscratch; sscratch = tmp;
        "csrrw sp, sscratch, sp\n"

        "addi sp, sp, -4 * 31\n"
        "sw ra,  4 * 0(sp)\n"
        "sw gp,  4 * 1(sp)\n"
        "sw tp,  4 * 2(sp)\n"
        "sw t0,  4 * 3(sp)\n"
        "sw t1,  4 * 4(sp)\n"
        "sw t2,  4 * 5(sp)\n"
        "sw t3,  4 * 6(sp)\n"
        "sw t4,  4 * 7(sp)\n"
        "sw t5,  4 * 8(sp)\n"
        "sw t6,  4 * 9(sp)\n"
        "sw a0,  4 * 10(sp)\n"
        "sw a1,  4 * 11(sp)\n"
        "sw a2,  4 * 12(sp)\n"
        "sw a3,  4 * 13(sp)\n"
        "sw a4,  4 * 14(sp)\n"
        "sw a5,  4 * 15(sp)\n"
        "sw a6,  4 * 16(sp)\n"
        "sw a7,  4 * 17(sp)\n"
        "sw s0,  4 * 18(sp)\n"
        "sw s1,  4 * 19(sp)\n"
        "sw s2,  4 * 20(sp)\n"
        "sw s3,  4 * 21(sp)\n"
        "sw s4,  4 * 22(sp)\n"
        "sw s5,  4 * 23(sp)\n"
        "sw s6,  4 * 24(sp)\n"
        "sw s7,  4 * 25(sp)\n"
        "sw s8,  4 * 26(sp)\n"
        "sw s9,  4 * 27(sp)\n"
        "sw s10, 4 * 28(sp)\n"
        "sw s11, 4 * 29(sp)\n"

        // 例外発生時のspを取り出して保存
        "csrr a0, sscratch\n"
        "sw a0,  4 * 30(sp)\n"

        // カーネルスタックを設定し直す
        "addi a0, sp, 4 * 31\n"
        "csrw sscratch, a0\n"

        "mv a0, sp\n"
        "call handle_trap\n"


void yield(void) {
    /* 省略 */

    __asm__ __volatile__(
        "csrw sscratch, %[sscratch]\n"
        :
        : [sscratch] "r" ((uint32_t) &next->stack[sizeof(next->stack)])
    );

    // コンテキストスイッチ
    struct process *prev = current_proc;
    current_proc = next;
    switch_context(&prev->sp, &next->sp);
}

OS自作5日目 プロセス

プロセス構造体

OS自作もプロセス作成まで来ました。 プロセスを管理するための構造体を作成します。

プロセス構造体に必要なものを用意するべく、プロセス管理構造体を定義します。

#define PROCS_MAX 8       // 最大プロセス数
#define PROC_UNUSED   0   // 未使用のプロセス管理構造体
#define PROC_RUNNABLE 1   // 実行可能なプロセス

struct process {
    int pid;             // プロセスID
    int state;           // プロセスの状態
    vaddr_t sp;          // コンテキストスイッチ時のスタックポインタ
    uint8_t stack[8192]; // カーネルスタック
};

stateでPROC_UNUSEDやPROC_RUNNABLEなどそのプロセスの状態がわかるようにしています。

spによってそのプロセスが使用しているスタックポインタを保持します。コンテキストスイッチ時にカーネルスタックに元のレジスタの状態など復元できるようにしておきます。

コンテキストスイッチ

以下、コンテキストスイッチの関数をインラインアセンブリで定義します。

__attribute__((naked)) void switch_context(uint32_t *prev_sp,
                                           uint32_t *next_sp) {
    __asm__ __volatile__(
        "addi sp, sp, -13 * 4\n"
        "sw ra,  0  * 4(sp)\n"
        "sw s0,  1  * 4(sp)\n"
        "sw s1,  2  * 4(sp)\n"
        "sw s2,  3  * 4(sp)\n"
        "sw s3,  4  * 4(sp)\n"
        "sw s4,  5  * 4(sp)\n"
        "sw s5,  6  * 4(sp)\n"
        "sw s6,  7  * 4(sp)\n"
        "sw s7,  8  * 4(sp)\n"
        "sw s8,  9  * 4(sp)\n"
        "sw s9,  10 * 4(sp)\n"
        "sw s10, 11 * 4(sp)\n"
        "sw s11, 12 * 4(sp)\n"
        "sw sp, (a0)\n"
        "lw sp, (a1)\n"
        "lw ra,  0  * 4(sp)\n"
        "lw s0,  1  * 4(sp)\n"
        "lw s1,  2  * 4(sp)\n"
        "lw s2,  3  * 4(sp)\n"
        "lw s3,  4  * 4(sp)\n"
        "lw s4,  5  * 4(sp)\n"
        "lw s5,  6  * 4(sp)\n"
        "lw s6,  7  * 4(sp)\n"
        "lw s7,  8  * 4(sp)\n"
        "lw s8,  9  * 4(sp)\n"
        "lw s9,  10 * 4(sp)\n"
        "lw s10, 11 * 4(sp)\n"
        "lw s11, 12 * 4(sp)\n"
        "addi sp, sp, 13 * 4\n"
        "ret\n"
    );
}

上記コンテキストスイッチ関数では、コンテキストスイッチ前のプロセス時のレジスタををスタックに格納し、次に使用するプロセスの状態をレジスタに復元します。

次にプロセスを作成する際の関数です。

struct process *create_process(uint32_t pc) {
    // 空いているプロセス管理構造体を探す
    struct process *proc = NULL;
    int i;
    for (i = 0; i < PROCS_MAX; i++) {
        if (procs[i].state == PROC_UNUSED) {
            proc = &procs[i];
            break;
        }
    }

    if (!proc)
        PANIC("no free process slots");

    // switch_context() で復帰できるように、スタックに呼び出し先保存レジスタを積む
    uint32_t *sp = (uint32_t *) &proc->stack[sizeof(proc->stack)];
    *--sp = 0;                      // s11
    *--sp = 0;                      // s10
    *--sp = 0;                      // s9
    *--sp = 0;                      // s8
    *--sp = 0;                      // s7
    *--sp = 0;                      // s6
    *--sp = 0;                      // s5
    *--sp = 0;                      // s4
    *--sp = 0;                      // s3
    *--sp = 0;                      // s2
    *--sp = 0;                      // s1
    *--sp = 0;                      // s0
    *--sp = (uint32_t) pc;          // ra

    // 各フィールドを初期化
    proc->pid = i + 1;
    proc->state = PROC_RUNNABLE;
    proc->sp = (uint32_t) sp;
    return proc;
}

最初のfor文までで、プロセスIDを決定しています。プロセス構造体の空きがなければプロセスは作成せず、カーネルパニックとなります。

次にプロセスのレジスタ状態を保持するスタックを作成しています。 s11からpcまでスタックを積み上げています。

最後にプロセスid、状態、スタックポインタを格納して完成したプロセスを返します。

テストとして、下記のようにプロセスAとプロセスbを作成し、互いにコンテキストスイッチを行わせます。

struct process *proc_a;
struct process *proc_b;

void proc_a_entry(void) {
    printf("starting process A\n");
    while (1) {
        putchar('A');
        switch_context(&proc_a->sp, &proc_b->sp);

        for (int i = 0; i < 30000000; i++)
            __asm__ __volatile__("nop");
    }
}

void proc_b_entry(void) {
    printf("starting process B\n");
    while (1) {
        putchar('B');
        switch_context(&proc_b->sp, &proc_a->sp);

        for (int i = 0; i < 30000000; i++)
            __asm__ __volatile__("nop");
    }
}

void kernel_main(void) {
    memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);

    WRITE_CSR(stvec, (uint32_t) kernel_entry);

    proc_a = create_process((uint32_t) proc_a_entry);
    proc_b = create_process((uint32_t) proc_b_entry);
    proc_a_entry();

実行結果は以下のように、コンテキストスイッチが行われていることが確認できます。

starting process A
Astarting process B
BABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABAQE

OS自作 4日目 メモリ割り当て(ページング)

少し期間が空きましたが、メモリ割り当てを実装します。

リンカスクリプト修正

メモリを割り当てるにあたって、自由に割り当てて良いメモリ領域を定義します。

   __stack_top = .;

    . = ALIGN(4096);
    __free_ram = .;
    . += 64 * 1024 * 1024; /* 64MB */
    __free_ram_end = .;

目印としてstack_topを載せていますが、更新しているのはstack_top以下の4行です。

_free_ramとして、メモリ割り当て用領域のスタート位置を確保しています。

その前に4096バイト(4KB)境界にするためのアライメントを行っています。

4KB境界である理由は調査したところ以下のようです。 * 典型的なページサイズが4KB * ページ途中をまたがるメモリフラグメンテーションを防ぐことができる。

ページングアルゴリズム

extern char __free_ram[], __free_ram_end[];

paddr_t alloc_pages(uint32_t n) {
    static paddr_t next_paddr = (paddr_t) __free_ram;
    paddr_t paddr = next_paddr;
    next_paddr += n * PAGE_SIZE;

    if (next_paddr > (paddr_t) __free_ram_end)
        PANIC("out of memory");

    memset((void *) paddr, 0, n * PAGE_SIZE);
    return paddr;
}

上記ページングアルゴリズムでは、next_paddrによって指定される物理アドレスの位置からページ割り当てを行います。 next_paddrは__free_ramの位置を定義していますが、staticで定義されているのでalloc_pages関数の呼び出しごとに、未割り当てのメモリにページ割り当てを行うことができます。

ページングのスタート位置から、割り当てるメモリをすべて0クリアするというロジックです。PAGE_SIZEは4KBととして、kernel.hで定義する必要があります。

実験として、2つのページ割り当てを行い、ページングの開始アドレスを確認しましたが、以下のようになっています。

alloc_pages test: paddr0=80231000
alloc_pages test: paddr1=80235000

paddr0に4ページ割り当てましたが、4KB * 4 メモリ分先の位置にページ割り当てが行われていることが確認できました。

OS自作3日目

カーネルパニック

カーネルパニック機能を実装します。

#define PANIC(fmt, ...)                                                        \
    do {                                                                       \
        printf("PANIC: %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__);  \
        while (1) {}                                                           \
    } while (0)

上記コードでは、カーネルパニックをマクロとして実装します。 マクロとして実装している理由について、明確な理由はわかっていませんが関数呼び出しとしてのオーバーヘッドを避けることとして考えています。

例外処理

OSの例外処理機能を追加します。

RISC-Vの仕様として、例外処理の際はstvecレジスタのアドレスに飛ぶので、例外処理として用意した関数のアドレスをstvecレジスタに格納します。

一部改変した部分のkernel_main関数です。 WRITE_CSR関数(CSRはRISCVのシステム制御用レジスタのこと)で、後述するkernel_entry関数(例外処理用関数)のアドレスを格納しています。

void kernel_main(void) {
    memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);

    WRITE_CSR(stvec, (uint32_t) kernel_entry);
    __asm__ __volatile__("unimp"); // 無効な命令

以下、例外処理用の関数です。

__attribute__((naked))
__attribute__((aligned(4)))
void kernel_entry(void) {
    __asm__ __volatile__(
        "csrw sscratch, sp\n"
        "addi sp, sp, -4 * 31\n"
        "sw ra,  4 * 0(sp)\n"
        "sw gp,  4 * 1(sp)\n"
        "sw tp,  4 * 2(sp)\n"
        "sw t0,  4 * 3(sp)\n"
        "sw t1,  4 * 4(sp)\n"
        "sw t2,  4 * 5(sp)\n"
        "sw t3,  4 * 6(sp)\n"
        "sw t4,  4 * 7(sp)\n"
        "sw t5,  4 * 8(sp)\n"
        "sw t6,  4 * 9(sp)\n"
        "sw a0,  4 * 10(sp)\n"
        "sw a1,  4 * 11(sp)\n"
        "sw a2,  4 * 12(sp)\n"
        "sw a3,  4 * 13(sp)\n"
        "sw a4,  4 * 14(sp)\n"
        "sw a5,  4 * 15(sp)\n"
        "sw a6,  4 * 16(sp)\n"
        "sw a7,  4 * 17(sp)\n"
        "sw s0,  4 * 18(sp)\n"
        "sw s1,  4 * 19(sp)\n"
        "sw s2,  4 * 20(sp)\n"
        "sw s3,  4 * 21(sp)\n"
        "sw s4,  4 * 22(sp)\n"
        "sw s5,  4 * 23(sp)\n"
        "sw s6,  4 * 24(sp)\n"
        "sw s7,  4 * 25(sp)\n"
        "sw s8,  4 * 26(sp)\n"
        "sw s9,  4 * 27(sp)\n"
        "sw s10, 4 * 28(sp)\n"
        "sw s11, 4 * 29(sp)\n"

        "csrr a0, sscratch\n"
        "sw a0, 4 * 30(sp)\n"

        "mv a0, sp\n"
        "call handle_trap\n"

        "lw ra,  4 * 0(sp)\n"
        "lw gp,  4 * 1(sp)\n"
        "lw tp,  4 * 2(sp)\n"
        "lw t0,  4 * 3(sp)\n"
        "lw t1,  4 * 4(sp)\n"
        "lw t2,  4 * 5(sp)\n"
        "lw t3,  4 * 6(sp)\n"
        "lw t4,  4 * 7(sp)\n"
        "lw t5,  4 * 8(sp)\n"
        "lw t6,  4 * 9(sp)\n"
        "lw a0,  4 * 10(sp)\n"
        "lw a1,  4 * 11(sp)\n"
        "lw a2,  4 * 12(sp)\n"
        "lw a3,  4 * 13(sp)\n"
        "lw a4,  4 * 14(sp)\n"
        "lw a5,  4 * 15(sp)\n"
        "lw a6,  4 * 16(sp)\n"
        "lw a7,  4 * 17(sp)\n"
        "lw s0,  4 * 18(sp)\n"
        "lw s1,  4 * 19(sp)\n"
        "lw s2,  4 * 20(sp)\n"
        "lw s3,  4 * 21(sp)\n"
        "lw s4,  4 * 22(sp)\n"
        "lw s5,  4 * 23(sp)\n"
        "lw s6,  4 * 24(sp)\n"
        "lw s7,  4 * 25(sp)\n"
        "lw s8,  4 * 26(sp)\n"
        "lw s9,  4 * 27(sp)\n"
        "lw s10, 4 * 28(sp)\n"
        "lw s11, 4 * 29(sp)\n"
        "lw sp,  4 * 30(sp)\n"
        "sret\n"
    );
}

処理の流れです。

1. sscratchレジスタ(例外処理用として好きに使っていいレジスタ)に例外発生時のスタックポインタを格納する

2. 例外発生時の汎用レジスタの状態をスタックに格納していく

3. a0レジスタに例外発生時のスタックポインタを格納(sscratchレジスタ)、上から順に格納しているが、これは後述する構造体の順番通りに格納している。

4. スタックポインタ(実質構造体の先頭アドレス)をa0に格納して、handle_trap関数(後述)を呼び出し

5. レジスタをもとの値に戻す

6. sret(例外処理用のret)

以下、汎用レジスタを格納する際の構造体です。 また、kernel.hに後のtrap_handle関数用にCSRレジスタの読み込み様マクロ、stvecレジスタ用に書き込み用マクロを定義します。

struct trap_frame {
    uint32_t ra;
    uint32_t gp;
    uint32_t tp;
    uint32_t t0;
    uint32_t t1;
    uint32_t t2;
    uint32_t t3;
    uint32_t t4;
    uint32_t t5;
    uint32_t t6;
    uint32_t a0;
    uint32_t a1;
    uint32_t a2;
    uint32_t a3;
    uint32_t a4;
    uint32_t a5;
    uint32_t a6;
    uint32_t a7;
    uint32_t s0;
    uint32_t s1;
    uint32_t s2;
    uint32_t s3;
    uint32_t s4;
    uint32_t s5;
    uint32_t s6;
    uint32_t s7;
    uint32_t s8;
    uint32_t s9;
    uint32_t s10;
    uint32_t s11;
    uint32_t sp;
} __attribute__((packed));

#define READ_CSR(reg)                                                          \
    ({                                                                         \
        unsigned long __tmp;                                                   \
        __asm__ __volatile__("csrr %0, " #reg : "=r"(__tmp));                  \
        __tmp;                                                                 \
    })

#define WRITE_CSR(reg, value)                                                  \
    do {                                                                       \
        uint32_t __tmp = (value);                                              \
        __asm__ __volatile__("csrw " #reg ", %0" ::"r"(__tmp));                \
    } while (0)

以下、handle_trap関数です。kernel_entryから呼び出されます。 下記レジスタの値を読み込んで、カーネルパニックとして出力する関数です。

scause:例外の種類コード。

stval:例外の負荷情報。例外の原因となったメモリアドレスなどが格納される(らしい)。

sepc:例外発生個所のプログラムカウンタ・

void handle_trap(struct trap_frame *f) {
    uint32_t scause = READ_CSR(scause);
    uint32_t stval = READ_CSR(stval);
    uint32_t user_pc = READ_CSR(sepc);

    PANIC("unexpected trap scause=%x, stval=%x, sepc=%x\n", scause, stval, user_pc);
}

これで、例外処理として、PANICで動きを停止し、エラー原因を出力させることができます。

OS自作2.5日目

ライブラリ実装

自作OS作成に当たり、便利なライブラリを作成します。

common.hで追加するものを整理し、common.cで追加する関数の中身を作成していきます。

今回追加する関数はイメージが付きやすい要素が多いので追加要素の羅列のみとします。

以下、common.hの中身です。

typedef int bool;
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;
typedef uint32_t size_t;
typedef uint32_t paddr_t;
typedef uint32_t vaddr_t;

#define true  1
#define false 0
#define NULL  ((void *) 0)
#define align_up(value, align)   __builtin_align_up(value, align)
#define is_aligned(value, align) __builtin_is_aligned(value, align)
#define offsetof(type, member)   __builtin_offsetof(type, member)
#define va_list  __builtin_va_list
#define va_start __builtin_va_start
#define va_end   __builtin_va_end
#define va_arg   __builtin_va_arg

void *memset(void *buf, char c, size_t n);
void *memcpy(void *dst, const void *src, size_t n);
char *strcpy(char *dst, const char *src);
int strcmp(const char *s1, const char *s2);
void printf(const char *fmt, ...);

以下、common.cで作成している関数の中身です。

void *memset(void *buf, char c, size_t n) {
    uint8_t *p = (uint8_t *) buf;
    while (n--)
        *p++ = c;
    return buf;
}

void *memcpy(void *dst, const void *src, size_t n) {
    uint8_t *d = (uint8_t *) dst;
    const uint8_t *s = (const uint8_t *) src;
    while (n--)
        *d++ = *s++;
    return dst;
}

char *strcpy(char *dst, const char *src) {
    char *d = dst;
    while (*src)
        *d++ = *src++;
    *d = '\0';
    return dst;
}

int strcmp(const char *s1, const char *s2) {
    while (*s1 && *s2) {
        if (*s1 != *s2)
            break;
        s1++;
        s2++;
    }

    return *(unsigned char *)s1 - *(unsigned char *)s2;
}

今回は以上です。

OS自作2日目

1日目のリンクです。

kishirasuku.hateblo.jp

1 SBIの機能を利用する

カーネルコードを書き直して、SBIの機能を使用できるひな形の関数を作成します。

それにともなって文字出力ができる関数を作成していきます。

記載するコードの理解にあたって、ハードウェアレベルの役割を担っているOpenSBIの機能を理解する必要があります。

OpenSBIでは以下構造体を用いた呼び出し規約が設定されています。

この本から読み取った要点です。 * ECALL使用の際に構造体として使用 * 特定のOpenSBIの機能を呼び出す際、定められているEIDをa7レジスタ、FIDをa6レジスタに設定する * a0とa1を除くすべてのレジスタが渡される必要があり、error応答をa0に応答のvalueをa1レジスタに返す

sbiret構造体をkernel.hとして記載しました。

#pragma once

struct sbiret {
    long error;
    long value;
};

2 OpenSBIの機能を利用する関数のひな形

以下がkernel本体のコードです。

#include "kernel.h"

extern char __bss[], __bss_end[], __stack_top[];

struct sbiret sbi_call(long arg0, long arg1, long arg2, long arg3, long arg4,
                       long arg5, long fid, long eid) {
    register long a0 __asm__("a0") = arg0;
    register long a1 __asm__("a1") = arg1;
    register long a2 __asm__("a2") = arg2;
    register long a3 __asm__("a3") = arg3;
    register long a4 __asm__("a4") = arg4;
    register long a5 __asm__("a5") = arg5;
    register long a6 __asm__("a6") = fid;
    register long a7 __asm__("a7") = eid;

    __asm__ __volatile__("ecall"
                         : "=r"(a0), "=r"(a1)
                         : "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(a4), "r"(a5),
                           "r"(a6), "r"(a7)
                         : "memory");
    return (struct sbiret){.error = a0, .value = a1};
}

void putchar(char ch) {
    sbi_call(ch, 0, 0, 0, 0, 0, 0, 1 /* Console Putchar */);
}

void kernel_main(void) {
    const char *s = "\n\nHello World!\n";
    for (int i = 0; s[i] != '\0'; i++) {
        putchar(s[i]);
    }

    for (;;) {
        __asm__ __volatile__("wfi");
    }
}

まずは、sbi_call関数についてです。

sbicall関数では8つの値をa0-7レジスタに格納する値として用意します。

インタインアセンブリでそれぞれ格納したレジスタを用いて、ecall命令を呼び出します。a6,a7レジスタで呼び出すsbiの機能を指定しています。

以下のインラインアセンブリの記載を初めて知りました。

__asm__ __volatile__("ecall"
                         : 出力レジスタ
                         : 入力レジスタ
                         : 変更される恐れがあり、実行前後で保存、復元するもの
                         );

本コードでは、sbiret関数で返す値としているa0,a1を出力レジスタ。規約によってすべて引数として指定しなければならないa0-a7レジスタを指定しています。メモリも復元対象として指定していますが、今回は特になくても問題ないとは思っています。

次にputchar関数です。

sbi_callで引数を指定しています。

a0:引数(今回はchar型で指定)

a0-a5:規約に従って渡しているだけ

a6:fid

a7:eid

仕様書に沿って、putcharではFIDを0、EIDを1に指定しています。これで引数chを出力することが可能になりました。

kernel_main関数で、putcharを1文字ずつ出力して再現をしています。

3 printf関数の作成

出力できるようになったので、putcharを使ってprintf関数を作成します。

#include "common.h"

void putchar(char ch);

void printf(const char *fmt, ...) {
    va_list vargs;
    va_start(vargs, fmt);

    while (*fmt) {
        if (*fmt == '%') {
            fmt++;
            switch (*fmt) {
                case '\0':
                    putchar('%');
                    goto end;
                case '%':
                    putchar('%');
                    break;
                case 's': {
                    const char *s = va_arg(vargs, const char *);
                    while (*s) {
                        putchar(*s);
                        s++;
                    }
                    break;
                }
                case 'd': {
                    int value = va_arg(vargs, int);
                    if (value < 0) {
                        putchar('-');
                        value = -value;
                    }

                    int divisor = 1;
                    while (value / divisor > 9)
                        divisor *= 10;

                    while (divisor > 0) {
                        putchar('0' + value / divisor);
                        value %= divisor;
                        divisor /= 10;
                    }

                    break;
                }
                case 'x': {
                    int value = va_arg(vargs, int);
                    for (int i = 7; i >= 0; i--) {
                        int nibble = (value >> (i * 4)) & 0xf;
                        putchar("0123456789abcdef"[nibble]);
                    }
                }
            }
        } else {
            putchar(*fmt);
        }

        fmt++;
    }

end:
    va_end(vargs);
}

おおまかな構造はシンプルで出力する文字列がfmt、引数として渡されています。 その後は1文字ずつ読み取って出力しますが、formatとなる%dのような値が渡されたときにそれぞれ出力を分岐させてるという流れです。

このサンプルコードで私がわからなかったのがva_argなどの関数や構造体です。 こちら定義されているのが以下のcommon.hです。

#pragma once

#define va_list  __builtin_va_list
#define va_start __builtin_va_start
#define va_end   __builtin_va_end
#define va_arg   __builtin_va_arg

void printf(const char *fmt, ...);

defineで定義されている構造体については、可変長引数として扱うための構造体で、数値でも文字列でも受け取ることができる引数としての構造体のようです。

それぞれの構造体の役割について調査しておおまかに理解しました。

va_list: 可変長引数のリストを保持するための型

va_start,va_end:可変長引数のやりとりのスタート、終了の合図。メモリのキープ、解放をするために定義

va_arg:va_arg(引数、フォーマット)として指定のフォーマットとして渡すための関数。

これを利用して、様々な型の引数を受け取れるようになっています。

これでコンパイルすると、printfデバッグが行えるようになりました。 kenrel_main中でprintfを使用してHello worldや計算出力を出しています。

printf("\n\nHello %s\n", "World!");
printf("1 + 2 = %d, %x\n", 1 + 2, 0x1234abcd);