RISC-V OSを作ろう (7) ~ マシンモードとユーザモード

執筆者 : 高橋 浩和

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


はじめに

アプリケーションのコードをユーザモードで、OSコードをマシンモードで動作させることにします。アプリケーションからはI/Oコントローラのレジスタを直接制御できないように簡易的なメモリ保護設定を行なうこととします。

環境の用意

Ubuntu 22.04LTSまたは24.04LTS環境を用意してください。Ubuntu 20.04LTSのRISC-V用ツール群にあった問題が解決されています。新たに始める方は、 「RISC-V OSを作ろう (1)」の手順を参照し、ツール群をインストールしてください。

最新のRISC-Vアーキテクチャマニュアルも入手しておきましょう。

今回利用するRISC-Vアーキテクチャ

Environment Call例外 ー システムコール発行

RISC-Vではシステムコール発行用の命令としてecall命令が用意されています。 ecall命令を実行するとEnvironment Call例外が発生し、マシンモード(最も権限の高い実行モード)に切り替わります*1。 ecall命令を実行したコンテキストの実行モードにより、異なる例外種別(8番、9番、11番)の例外が発生します。 この例外種別はMCAUSEレジスタに入ります。

例外種別 説明
8 ユーザモードでのecall実行による例外
9 スーパバイザモードでのecall実行による例外
11 マシンモードでのecall実行による例外

Environment Call例外が発生すると、トラップベクタテーブルの先頭に登録されている例外エントリの実行を開始します。 この例外エントリにおいてMCAUSEレジスタにある例外種別を参照し、例外種別に応じた例外処理を行なう必要があります。 例外処理終了時にはmret命令を実行し、例外発生元に復帰させます。

RISC-V OSを作ろう (3)」執筆時にはあったユーザモード割込み機能がRISC-Vアーキテクチャ仕様から削除されたため、例外発生時の動作の見通しが少しだけ良くなっています。

注意

Environment Call例外発生時、MEPCはecall命令を指しています。例外復帰前(mret実行前)にMEPCが次の命令を指すように書き換える必要があります。システムコール命令による例外は、例外復帰アドレスとしてシステムコール命令の次の命令を自動的に指すようになっているアーキテクチャのCPUは多いですが、RISC-Vではそのような配慮はされません。

PMP (physical-memory protection) ー 物理メモリ保護

RISC-Vアーキテクチャでは、マシンモード動作時はあらゆるアドレス空間(メモリ、I/Oコントローラ空間)にアクセスすることができますが、 ユーザモード・スーパバイザモードで動作する時は、PMPにて許可されたアドレス空間にしかアクセスが許されません。リセット直後はユーザモード・スーパバイザモードからのメモリアクセスは全て禁止されています*2

ユーザモード・スーパバイザモードの利用を始める前に、ユーザモード・スーパバイザモードからメモリ空間をアクセスできるよう予めPMP設定を行なっておく必要があります。

PMP仕様

PMP設定は2つのレジスタ(pmpaddr, pmpcfg)で行います。このレジスタペアは最大64組用意されており、64区画のメモリ領域(またはI/O空間)のアクセス権設定を行なうことができます。Qemuは16組のレジスタを持ちます。

レジスタ名 説明
pmpaddrN (N = 0, 1, 2,...) PMP区画アドレス指定
pmpcfgN (N = 0, 1, 2,...) PMP区画アクセス権設定、アドレスマッチングモード指定

pmpcfgNではR/W/X(読み書き実行許可)設定の他に、アドレスマッチングモードの設定が必要です。アドレスマッチングモードはpmpaddrNレジスタの意味を変更するものです。

今回はNAPOTというモードのみを利用することにします。pmpcfgNレジスタにNAPOTモードを指定した場合、 pmpaddrNの上位ビット群がメモリ区画の先頭アドレスを示し、下位ビット群がメモリ区画の大きさを表すことになります。pmpaddrNには、「区画の先頭アドレス/4 + 区画の大きさ/8 - 1」を設定します。メモリ区画は、その大きさが2のべき乗であることと、その区画の先頭アドレスが区画のサイズでアラインされていることが求められます。そういうものだと思っておけば十分です。

RISC-V OS実装方針

次の方針で実装します。

  • アプリケーションタスクをユーザモードで動作させる。
  • カーネル(OS本体)はマシンモードで動作させる。
  • 割込みハンドラもマシンモードで動作させる。
  • スーパバイザモードは使わない。
  • ユーザモードからマシンモードへの切り替えはEnvironment Call例外を利用する。
  • 物理メモリはマシンモード、ユーザモードの両方からアクセスできる。
    ユーザモードからは全ての物理メモリをアクセス可能とするが、I/O空間および何も割りついていないアドレス空間へのアクセスは禁止する。 将来的にはユーザモードからアクセス可能なメモリ領域に制限をつけたい。
  • I/O空間(各種コントローラ)はマシンモードからのみアクセス可能。
    ユーザモードからデバイスを操作したい時は、マシンモードで動作するカーネルに依頼する。特権レジスタを操作する処理を行ないたい場合も同様。

システムコール実装

システムコール処理を、アプリケーションへのインターフェイスとなるシステムコールライブラリ関数とシステムコール処理本体となるカーネル内関数に分割します。

システムコールライブラリ関数

システムコールライブラリ関数の役目は、実行モードをマシンモードに切り替えることと、システムコールAPIに指定された引数をカーネルにそのまま引き渡すことです。

  • a7レジスタをシステムコール番号を格納するレジスタとして使うこととする*3。システムコール番号は、カーネルに実行してもらいたい処理を表す。
  • システムコールの引数はa0-a6レジスタを使う。アプリケーションはシステムコールライブラリ関数を経由して、カーネル内関数に7つまでの引数を渡すことができる。
  • ecall命令を実行する。
  • ecall命令から復帰。a0-a1レジスタにシステムコールの戻り値が格納されている。
  • ret命令でシステムコール呼び出し元に復帰。戻り値(a0-a1レジスタ)はそのままアプリケーションに渡る。

RISC-Vの汎用レジスタの使い方については、RISC-V ABIの関数呼び出し規約を参照してください。

Schedule関数、AcquireSemaphore関数などのインターフェイス部分をシステムコールライブラリ関数として分離します(syscall.sファイルに記述しました)。

    .equ  SYS_SCHEDULE,                0
    .equ  SYS_ACQUIRESEMAPHORE,        1
    .equ  SYS_TRYTOACQUIRESEMAPHORE,   2
    .equ  SYS_RELEASESEMAPHORE,        3
    .equ  SYS_SNOOZE,                  4
    .equ  SYS_PRINTMSG,                5
    .equ  SYS_GETTIME,                 6

    .global Schedule
    .type Schedule,@function
Schedule:
    li a7, SYS_SCHEDULE
    ecall
    ret
    .size Schedule,.-Schedule

    .global AcquireSemaphore
    .type AcquireSemaphore,@function
AcquireSemaphore:
    li a7, SYS_ACQUIRESEMAPHORE
    ecall
    ret
    .size AcquireSemaphore,.-AcquireSemaphore

        :
        :

システムコール発行

システムコール例外エントリ

トラップベクタテーブル(trap_vectors)の先頭エントリが例外用のエントリです。ここにexc_handler関数を呼び出すコードを登録します。 ecall命令実行によるEnvironmet Call例外により、動作モードはマシンモードに切り替わり、割込み禁止状態で例外エントリが呼び出されます。例外エントリから呼び出したexc_handler関数では、例外種別が 8 (ユーザモードでのecall実行によるEnvironment Call例外)の時にシステムコール発行があったものとしてSvcHandler関数(システムコール処理本体)を呼び出します。

    .text
    .globl trap_vectors
    .type trap_vectors,@function
    .balign 256
trap_vectors:
    j   exc_handler
    .balign 4
    j   undefined_handler
    .balign 4
    j   undefined_handler
    .balign 4
         :
         :

    .equ   EXC_ECALL, 8

    .balign 4
exc_handler:
    addi  sp, sp, -8*3
    sd    s0, 1*8(sp)
    sd    s1, 2*8(sp)
    csrr  s0, mcause
    li    s1, EXC_ECALL
    bne   s0, s1, 1f
    sd    ra, 0*8(sp)
    csrr  s0, mepc
    addi  s0, s0, 4
    csrr  s1, mstatus
    csrw  mstatus, zero

    jal   SvcHandler

    csrw  mepc, s0
    csrw  mstatus, s1
    ld    ra, 0*8(sp)
    ld    s0, 1*8(sp)
    ld    s1, 2*8(sp)
    addi  sp, sp, 8*3
    mret
1:
    ld    s0, 1*8(sp)
    ld    s1, 2*8(sp)
    addi  sp, sp, 8*3
    j     undefined_handler
    .size exc_handler,.-exc_handler

Environment Call例外はシステムコールライブラリ関数内のecall命令実行時に同期的に発生するため、割り込み発生時の割込みエントリとは異なり「関数呼び出し元退避レジスタ(Caller saved registers)群」を保存する必要はありません。コンパイラはシステムコールライブラリ関数を跨いでそれらレジスタの値が壊れることが前提のコードを生成するためです。詳しくは、RISC-V ABIの関数呼び出し規約を参照してください。

SvcHandler関数では、システムコール番号(引数sysno、a7レジスタに入っている)に対応したカーネル内関数を呼び出します。 カーネル内関数には引数としてa0-a6レジスタがそのまま渡ります。

long SvcHandler(long a0, long a1, long a2, long a3, long a4, long a5, long a6, long sysno)
{
    typedef long (*syscall_t)(long a0, long a1, long a2, long a3, long a4, long a5, long a6);
    const syscall_t systable[] = {
        (syscall_t)_Schedule,
        (syscall_t)_AcquireSemaphore,
        (syscall_t)_TryToAcquireSemaphore,
        (syscall_t)_ReleaseSemaphore,
        (syscall_t)_Snooze,
        (syscall_t)_print_message,
        (syscall_t)_get_time,
    };

    return systable[sysno](a0, a1, a2, a3, a4, a5, a6);
}

Schedule関数、AcquireSemaphore関数の本体部分を_Schedule関数、_AcquireSemaphore関数というカーネル内関数に変更し、systableテーブルに登録します。 systableテーブルに登録するカーネル内関数で割込みの禁止操作を行なう必要はありません。Environment Call例外発生時に自動的に割込み禁止状態になっています。

システムコール処理が終了したらMEPCを例外発生時の値から4進めた値に書き直し、mret命令を実行します。実行モードがユーザモードに切り替わり、ecall命令の次の命令から実行を再開します。

その他の例外の扱い

今回は特に処理せず無限ループ(ハングアップ)させることにします。

簡易メモリ保護

SetupPMP関数にて、物理メモリ全体をマップするようにPMPレジスタを設定します。 ram_size, ram_startのシンボルは、リンカディレクティブファイル(riscv-virt.lds)で生成するようにしています。

PMP設定手順はこんなものだと思っておけば十分です。

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

「読み・書き・実行」全てを許可します。

    .equ  PMP_R,  0x1
    .equ  PMP_W,  0x2
    .equ  PMP_X,  0x4
    .equ  PMP_NAPOT,  0x18

    .globl InitPMP
    .type InitPMP,@function
    .balign 4
InitPMP:
    li     t0, PMP_NAPOT | PMP_R | PMP_W | PMP_X
    csrw   pmpaddr0, a0
    csrw   pmpcfg0, t0
    ret
    .size InitPMP,.-InitPMP

タスクの起動

RISC-V OSを作ろう (6)のOSではタスクはマシンモードのまま動作するため、TaskEntry関数からそのタスクが実行する関数(タスクエントリ関数)を呼び出すだけで十分でした。しかし、今回は実行モードをマシンモードからユーザモードに遷移しつつ関数を呼び出す必要があります。

TaskEntry関数はアセンブリ関数TaskStartを呼び出すだけにします。引数としてタスクエントリ関数のアドレスを渡します。

static void TaskEntry(void)
{
    TaskStart(TaskControl[CurrentTask].entry);
}

TaskStart関数が処理の本体です。 まず、「ユーザモードかつ割込み許可のコンテキストがタスクエントリ関数の先頭で例外を発生させ、その例外ハンドラを実行中である」という状態を作り出します。

  • タスクエントリ関数のアドレスをMEPCレジスタに設定します。タスクエントリ関数の先頭で例外が発生したことを表します。
  • 例外発生前のコンテキストの状態をMSTATUSレジスタに設定します。
    • 例外発生前の実行モードはユーザモード(MSTATUS_MPP_USER)であったとします。
    • 例外発生前は割込みが許可(MSTATUS_MPIE)されていたとします。

その上でmretを実行することにより、タスクエントリ関数がユーザモード・割込み許可状態で呼び出されます。

    .equ   MSTATUS_MPIE, 0x80
    .equ   MSTATUS_MPP_USER, 0x0           /* user mode */

    .globl TaskStart
    .type TaskStart,@function
    .balign 4
TaskStart:
    csrw  mepc, a0
    li    a0, MSTATUS_MPIE|MSTATUS_MPP_USER
    csrw  mstatus, a0
    mret
    .size TaskStart,.-TaskStart

MSTATUSレジスタ

動かしてみよう

コードをコンパイルし、qemu上で動作させます。 RISC-V OSを作ろう (6)のOSと同じ動作になっていることが分かります。

この動作結果だけからは分かりませんが、タスクコンテキストから直接uartコントローラやmtimeレジスタを操作すると、PMPによる保護機能が働き例外が発生します。またタスクコンテキストから特権レジスタにアクセスした場合、不正命令例外が発生します。

$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -c main.c primitives.s start.s syscall.s
$ riscv64-unknown-elf-ld main.o primitives.o start.o syscall.o -T riscv-virt.lds
$ qemu-system-riscv64 -nographic -machine virt -m 128M -kernel a.out -bios none
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

最後に

今回はアプリケーション部分をユーザモードで動作させることに成功しました。ここで紹介したプログラムはgithubにて公開しています。実際にqemu上で動作させてみてください。

例外復帰アドレスがPMPでアクセス禁止の領域にある状態でmretを実行しマシンモードからユーザモードへ遷移させようとすると、復帰命令mret実行時に命令アクセス例外が発生します*4。MEPCはmret命令を指しており、MSTATUSのMPPフィールドはマシンモードで例外が発生したことを示しています。mretからユーザモードへ遷移した上で、復帰アドレスにある命令のフェッチに対して例外が発生することを期待していましたが、そうはなりませんでした。mret実行時のマシンモードでの例外発生が、Qemuの実装が間違っていることによるものかRISC-Vアーキテクチャとしての正しい挙動なのか分かりませんでした。例外復帰アドレスが壊れている時、ユーザモードコンテキストでの例外ではなくマシンモードコンテキストでの例外となるのは、セキュリティの穴となりそうな予感がします。

次回は、物理メモリをアプリケーション用(ユーザモード用)とカーネル用(マシンモード用)の2つに分割し、アプリケーションからはカーネル用のメモリ域を参照できないようにメモリ保護設定を行ないたいと考えています。

*1:リセット直後のCPUのデフォルト設定状態時の動作

*2:CPUの実装によっては、PMPの初期設定値を変更できるらしい

*3:システムコール番号を渡すレジスタとして作業レジスタ(t0-t6)を使うことにより、a0-a7全てをシステムコール引数として利用する実装も可能です。ただしその場合、カーネル側でアセンブリコード記述しなければならない部分が増えます。記述を簡単にするため今回はa7レジスタを利用します

*4:Ubuntu 22.04LTSのqemu v6.2.0では不正命令例外が発生する