RISC-V OSを作ろう (8) ~ 簡易メモリ保護

執筆者 : 高橋 浩和

※ 「RISC-V OSを作ろう」連載記事一覧はこちら


はじめに

RISC-V OSを作ろう (7)」にて、アプリケーションをユーザモードで実行できるようになりましたが、メモリ保護が実装されていないためアプリケーションからカーネル(OS本体)が管理するデータ構造を書き換えてしまうこともできます。今回は、前回登場したPMP(物理メモリ保護)の機能を用い、アプリケーションからカーネルのメモリを参照したときにメモリ保護例外が発生するようにしようと思います。

また例外ハンドラも少し強化し、上記のような例外が発生した時に例外情報を表示するようにします。

今回使うRISC-Vの機能

SCRATCHレジスタ

カーネルが自由に利用してよいレジスタとしてMSCRATCH、SSCRATCHと呼ばれるレジスタが用意されています。 割り込み・例外エントリ処理など、汎用レジスタの値を壊すことが許されない処理において、汎用レジスタの値の一時退避先として利用することができます。*1

カーネルとアプリケーションの分離

ファイルの分離

アプリケーション(タスク)のコードをmain.cから分離し、application.cとします。 application.cに記述されたテキストとデータのみユーザモードからアクセス可能なメモリに配置するためです。

タスクスタックもカーネルコード実行中に利用するカーネルスタックとアプリケーションコード実行中に利用するユーザスタックの2つに分割します。 TaskCtontrol構造体中に確保したスタック域(task_kstack)はシステムコール実行時およびプリエンプト処理時にのみ利用します。 タスクがユーザモードで動作するときに利用するスタックは、application.c内にtask_ustack域として新たに確保することにします。

main.c:

struct TaskControl {
    enum { READY, BLOCKED} state;
    void (*entry)(void);
    long time_slice;
    long remaining_time;
    int expire;
    SemIdType target_sem;
    unsigned long sp;
    unsigned long task_kstack[KSTACKSIZE];
} TaskControl[NUMBER_OF_TASKS] ;
apllication.c:

unsigned long task_ustack[NUMBER_OF_TASKS][USTACKSIZE];

物理メモリの分離

リンカディレクティブファイルにて、物理メモリをカーネル用(ram_system)とアプリケーション用(ram_app)の2つに分割します。カーネル(OS本体)をram_systemに配置し、アプリケーション(application.o)をram_appに配置します。

MEMORY
{
  ram_system (wxa!ri) : ORIGIN = 0x80000000, LENGTH = 64M
  ram_app    (wxa!ri) : ORIGIN = 0x84000000, LENGTH = 64M
}

セクション配置

カーネル用セクション

カーネルが使うテキスト・データ・スタックはram_system上のセクションに割り付け、その他(アプリケーション)をram_app上のセクションに割り付けるようにします。

セクション配置

カーネル用のセクション(system_textsystem_rodatasystem_datasystem_bsssystem_sdatasystem_sbss)をram_system上に配置します。割り込みが使うスタック用セクションもram_system上に配置します。

カーネル用のファイル(main.cprimitives.s)上にあるテキストやデータを、カーネル用のセクションに割り付けます。

SECTIONS
{
  .system_text : {
    PROVIDE(_text_start = .);
    *(.reset)
    main.o(.text .text.*)
    primitives.o(.text .text.*)
    PROVIDE(_text_end = .);
  } >ram_system AT>ram_system

  .system_rodata : {
    main.o(.rodata .rodata.*)
    primitives.o(.rodata .rodata.*)
    *(.note.* )
  } >ram_system AT>ram_system

  .system_data : {
    . = ALIGN(4096);
    main.o(.data .data.*)
    primitives.o(.data .data.*)
  } >ram_system AT>ram_system
            :
            :
            :

アプリケーション用セクション

アプリケーション用のセクション(textrodatadatabss)はram_app上に配置します。 カーネル用セクションに配置されなかったテキストやデータすべてを、アプリケーション用のセクションに割り付けます。

  .text : {
    *(.text .text.*)
  } >ram_app AT>ram_app

  .rodata : {
    *(.rodata .rodata.*)
  } >ram_app AT>ram_app

  .data : {
    . = ALIGN(4096);
    *(.data .data.*)
  } >ram_app AT>ram_app

  .bss :{
    . = ALIGN(16);
    PROVIDE(_bss_start = .);
    *(.bss .bss.*)
    . = ALIGN(16);
    PROVIDE(_bss_end = .);
  } >ram_app AT>ram_app

ショートデータ用セクション

ショートデータ用のセクション(sdatasbss)は、gpレジスタ相対でアクセスできる領域に固めて配置する必要があります。 カーネルとアプリケーション双方がショートデータを利用するとした場合、それらショートデータを一箇所にまとめて配置する必要がありますが、メモリ保護の都合上それぞれram_systemram_appという離れた領域に配置しなければならず、これらは両立させることができません*2

今回は、アプリケーションはショートデータを使えないという制約を付けることでこの問題を回避することにします。 アプリケーション用のファイルのコンパイル時に-msmall-data-limit=0オプションを指定することにより制御します。

$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -msmall-data-limit=0 -c application.c

初期値無しデータセクション(bssセクション)の初期化

bss属性のセクションはカーネル起動時にゼロクリアする必要があります。 今回の修正でbss属性のセクションは、カーネル用のものとアプリケーション用のものが別々に配置されるようになりました。 カーネル起動時、双方のbss属性のセクション域をゼロクリアします。

static void clearbss(void)
{
    unsigned long long *p;
    extern unsigned long long _system_bss_start[];
    extern unsigned long long _system_bss_end[];
    extern unsigned long long _bss_start[];
    extern unsigned long long _bss_end[];

    /* カーネル用bss属性セクションのクリア */
    for (p = _system_bss_start; p < _system_bss_end; p++) {
        *p = 0LL;
    }
    /* アプリケーション用bss属性セクションのクリア */
    for (p = _bss_start; p < _bss_end; p++) {
        *p = 0LL;
    }
}

例外スタックセクション

割込みスタックとは別に例外処理で使用するスタック(例外スタック exc_stackセクション)を用意します。割込みハンドラ実行中にも例外が発生する可能性があるため、割込みスタックとは別に確保します。

カーネルスタックとユーザスタックの切り替え

アプリケーションコードを実行時のタスクには、ユーザモードスタックを利用させます。 カーネルコード実行中のタスク(システムコール実行中、割り込みからの再スケジュール処理を実行中)には、カーネルスタックを使用させます。 ユーザモードで実行しているタスクは、カーネルスタックを参照することはできません。

MSCRATCHレジスタの初期化

カーネル起動時に、例外スタックの先頭アドレスをMSCRATCHレジスタに登録しておきます。 この領域(例外スタックの先頭域)は、割り込みエントリおよび例外エントリにおける一時的な作業領域(スクラッチ領域と呼ぶことにします)として利用することにします。

Environment Call例外

Environment Call例外発生時には、exc_handler関数にてスタックをカーネルスタックへ切り替えた上で、 SvcHandler関数(システムコール処理本体)を呼び出します。

例外種別がユーザモードからのEnvironment Call例外(例外種別8)を示しているか確認する処理では、汎用レジスタの値を壊さない(書き換えない)ようにします。 不正例外であった場合、汎用レジスタは1本も壊してはなりません。 この時、タスクのユーザスタックも利用してはなりません。ユーザスタックに空きがない可能性がありますし、スタックポインタ自体が壊れている可能性もあります。

スクラッチ領域を指すMSCRATCHレジスタとtpレジスタの値を交換し、スクラッチ領域(tpレジスタ相対でアクセス可能)にt0レジスタの値を保存し、 t0レジスタを作業レジスタとして利用してEnvironment Call例外(例外種別8)であるか否かを判別します。 Environment Call例外(例外種別8)であった場合はシステムコール処理(SvcHandler関数)を、それ以外であった場合は不正例外処理(undefined_handler関数)を行ないます。

exc_handler:
    csrrw tp, mscratch, tp
    sd    t0, 5*8(tp)

    csrr  t0, mcause
    add   t0, t0, -EXC_ECALL
    bne   t0, zero, 1f

    csrrw tp, mscratch, tp

    /* switch to the kernel stack */
    /* sp = &TaskControl[CurrentTask].task_kstack[KSTACKSIZE]; */
    lwu   t0, CurrentTask
    la    t1, TaskControl
    addi  t0, t0, 1    /* t0 = CurrentTask + 1 */
    li    t2, 0x4030   /* t2 = sizeof(struct TaskControl) */
    mul   t2, t2, t0   /* t2 = sizeof(struct TaskControl)*(CurrentTask + 1) */
    mv    t3, sp
    add   sp, t2, t1   /* &TaskControl[CurrentTask].task_kstack[KSTACKSIZE] */

    addi  sp, sp, -8*4
    sd    s0, 1*8(sp)
    sd    s1, 2*8(sp)
    sd    ra, 0*8(sp)
    sd    t3, 3*8(sp)   /* previous sp */

    csrr  s0, mepc
    addi  s0, s0, 4
    csrr  s1, mstatus
    csrw  mstatus, zero

    jal   SvcHandler

          :
          :

システムコール処理では、スタックをカーネルスタックに切り替えます。&TaskControl[CurrentTask].task_kstack[KSTACKSIZE]の値を求めspレジスタに設定する処理を アセンブリコードで記述します。sizeof(struct TaskControl)の値は、とりあえず直値で記述しています*3

SvcHandler関数終了後は、スタックを切り戻したうえでmret命令を実行し、システムコール発行元に復帰します。

割り込み発生

割り込み発生時も即座にカーネルスタックへの切り替え、カーネルスタック上にタスクコンテキストを保存します。

このスタック切り替え操作において、汎用レジスタは1本も壊してはなりません。 タスクのユーザスタックが利用できないのもEnvironment Call例外と同様です。

スクラッチ領域を指すMSCRATCHレジスタとtpレジスタの値を交換し、スクラッチ領域(tpレジスタ相対でアクセス可能)にt0-t2レジスタの値を保存し、 これらレジスタを作業レジスタとして利用してスタック切り替えを行ないます。 スタック切り替え後は、t0-t2、tp、MSCRATCHのレジスタ値を元に戻します。

その後の処理は、割込みハンドラから復帰する時にスタックを切り戻す処理が追加されている点を除き RISC-V OSを作ろう (7)と同様の処理を行ないます。

timer_handler:
    csrrw tp, mscratch, tp
    sd    t0, 5*8(tp)
    sd    t1, 6*8(tp)
    sd    t2, 7*8(tp)

    /* switch to the kernel stack */
    /* sp = &TaskControl[CurrentTask].task_kstack[KSTACKSIZE]; */
    lwu   t0, CurrentTask
    la    t1, TaskControl
    addi  t0, t0, 1
    li    t2, 0x4030   /* sizeof(TaskControl) */
    mul   t2, t2, t0
    mv    t0, sp
    add   sp, t2, t1   /* &TaskControl[CurrentTask].task_kstack[KSTACKSIZE] */

    addi  sp, sp, -8*19
    sd    t0, 18*8(sp)  /* save the previous sp */

    ld    t0, 5*8(tp)
    ld    t1, 6*8(tp)
    ld    t2, 7*8(tp)
    csrrw tp, mscratch, tp

          :
          :

メモリ保護設定

PMPの初期化処理で、アプリケーション用のメモリのみをマップするようにします。 シンボルram_app_start,ram_app_sizeはリンカディレクティブファイルで生成しています。 それぞれアプリケーション用RAMの先頭アドレス、アプリケーション用RAMの大きさを表します。

static void SetupPMP(void)
{
    extern unsigned char ram_app_size[];  /* The size must be a power of 2 */
    extern unsigned char ram_app_start[]; /* The address must be multiples of the size */
    /* map the whole application ram region */
    InitPMP(((unsigned long)ram_app_start >> 2U) + ((unsigned long)ram_app_size >> 3U) - 1U);
}

不正例外

例外発生時に例外情報を出力できるようにします。 例外発生時の汎用レジスタの値を全てメモリ上に退避し、例外ハンドラ(ExcHandler関数)に渡すようにします。

不正例外のエントリ(undefined_handler)にて、MSCRATCHレジスタとtpレジスタの値を交換します。 これにより、tpレジスタ相対のメモリアクセス命令でスクラッチ領域にアクセスできるようになります。 まず、汎用レジスタの値をスクラッチ領域に保存します。 続けてMEPC・MCAUSE・MSTATUS・MTVALの値も保存します。 MSCRATCHレジスタとtpレジスタの値を再度交換し、元々のtpレジスタの値もスクラッチ領域に保存します。

最後にspレジスタ(スタックポインタ)が例外スタックの底(_exc_stack_end)を指すように設定し、例外ハンドラ(ExcHandler関数)を呼び出します。

例外ハンドラ

例外ハンドラ(ExcHandler関数)は、例外情報を表示するだけの単純なものです。 例外ハンドラには、汎用レジスタ保存域(スクラッチ領域)へのポインタ(ctx)と、MEPC・MCAUSE・MSTATUS・MTVALの値が渡るようにしました。

int ExcHandler(unsigned long* ctx, unsigned long mepc, unsigned long mcause, unsigned long mstatus, unsigned long mtval)
{
    _print_message("Exception: mepc(0x%x) mcause(0x%x) mstatus(0x%x) mtval(0x%x)\n", mepc, mcause, mstatus, mtval);
    _print_message("           sp(0x%x) ra(0x%x) t0(0x%x) t1(0x%x)\n", ctx[2], ctx[1], ctx[5], ctx[6]);
}

これまでのprint_message関数は文字列の出力のみできる簡単なものでしたが、%xフォーマットを指定できるように拡張しました。 例外ハンドラから例外詳細情報を出力したくなり実装しました*4

動かしてみる

いつものようにビルドします。アプリケーションコードをコンパイル時には、-msmall-data-limit=0オプションを指定します。

$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -c main.c
$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -c primitives.s
$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -c start.s
$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -c syscall.s
$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -msmall-data-limit=0 -c application.c
$ riscv64-unknown-elf-ld main.o primitives.o start.o syscall.o application.o -T riscv-virt.lds

qemu上でカーネルとアプリケーションを動作させます。 RISC-V OSを作ろう (7)と同じように動作します。

$ qemu-system-riscv64 -nographic -machine virt -m 128M -kernel a.out -bios none a.out
Task1    Eating
Task3    Eating
Task5    Meditating
Timer
Task5    Meditating
Timer
Task2    Meditating
Task5    Eating
Timer
Timer
Task2    Meditating
Timer
Task2    Meditating
Task4    Meditating
Task3    Eating
Timer
Task3    Eating
Timer

不正メモリアクセス

アプリケーション(タスク)からカーネル内変数を参照できないことを確認します。 タスクコンテキストからカーネル内変数CurrentTaskを参照する下記のコードを埋め込んでみましょう。

extern TaskIdType CurrentTask;
print_message("Task[%x] is running\n", CurrentTask);

qemu上でa.outを実行すると、下記のような例外情報が表示されます。

Exception: mepc(0x840001c6) mcause(0x5) mstatus(0xa00000080) mtval(0x80019138)
           sp(0x84028fa8) ra(0x840001b6) t0(0x800002b0) t1(0x0)
  • MCAUSEレジスタの値は5、ロードアクセス例外が発生したことを示しています。
  • MSTATUSレジスタは、例外発生時の実行モードがユーザモード(MPPフィールドが0)であったことと、割り込み許可状態(MPIEフィールドが1)であったことを示しています。
  • MTVALレジスタは例外発生アドレスを保持しています。例外発生アドレス(0x80019138)を調べてみると、CurrentTask変数を指していることが確認できます。
$ riscv64-unknown-elf-nm a.out | egrep 80019138
0000000080019138 B CurrentTask
  • MEPCレジスタは、例外発生時に実行していた命令を指しています。a.outを逆アセンブル(riscv64-unknown-elf-objdump -d a.out)すると、CurrentTask変数の値をロードする命令を指していることが分かると思います。
  • ctx引数は、汎用レジスタの保存域を指しています。汎用レジスタの番号順に全てのレジスタの値を保存してあります。必要なレジスタ値を参照することができます。

最後に

説明の中で出てきた次の命令ですが、MSCRATCHとtpレジスタの値を交換することができました。

csrrw tp, mscratch, tp

RISC-Vの特権レジスタ更新命令はなかなか高機能です。1命令で特権レジスタを不可分に更新できます。 利用できる汎用レジスタが限られる例外・割り込みのエントリのコードを記述する時にはありがたい味方です。 下記の表は、汎用レジスタx1、x2を利用した場合の説明です。

命令 意味
csrrw x1, CSR名, x2 x1 = CSR; CSR = x2
csrrs x1, CSR名, x2 x1 = CSR; CSR |= x2
csrrc x1, CSR名, x2 x1 = CSR; CSR &= ~x2

指定できる値の範囲は限られますが、x2レジスタの代わりに直値immを指定することもできます。 更に、x1レジスタの代わりにx0(zeroレジスタ)を指定することもでき、汎用レジスタを1つも壊さずに特権レジスタの更新を行なうことも可能です。

命令 意味
csrrwi x1, CSR名, imm x1 = CSR; CSR = imm
csrrsi x1, CSR名, imm x1 = CSR; CSR |= imm
csrrci x1, CSR名, imm x1 = CSR; CSR &= ~imm

今回の話は、メモリ保護のために物理メモリをカーネル用メモリとアプリケーション用メモリに単純に分割するだけの話だったのですが、 実際に実装するとなると結構大変でした。メモリ保護の話は一旦ここまでとし、次回の連載では少し別の話題に触れたいと思います。

*1:例外や割込み発生時に自動的にコンテキストの保存をしてくれるアーキテクチャのCPUや、汎用レジスタの一部をカーネルからのみ利用できるとABIで決めただけのアーキテクチャのCPUもあります。

*2:カーネルとアプリケーションを別オブジェクトとしてコンパイルし、マシンモードとユーザモード間の遷移時にgpレジスタも切り替えるという実装を行なえば可能となる

*3:メンテナンス性が良くないので、構造体サイズを計算しマクロを生成する外部プログラムなどの助けを借りると良いでしょう

*4:コンパイラ付属libcライブラリのprintf文は大きなスタック域を要求するため利用していません