RISC-V OSを作ろう (11) ~ SBI対応

執筆者 : 高橋 浩和

※ 「RISC-V OSを作ろう」連載記事一覧はこちら
※ 「RISC-V OS」のコードはgithubにて公開しています。


はじめに

今回は、OSをスーパーバイザーモードで動作させることにします。マシンモードで動作するモニタプログラムの上にOSを載せます。

OSをスーパーバイザーモードで動作させると、RISC-Vの一部の機能が操作できなくなります。それらはモニタプログラムに代行してもらいます。OSとモニタのインターフェイスはRISC-V SBI仕様に合わせることにします*1

方針

OSをスーパーバイザーモードで実行させると、マシンモード用に用意されているMSTATUS, MCAUSE, MIP, MEPなどの'M'の文字から始まる名前の特権レジスタ群を操作できなくなります。代りに、スーパーバイザーモード用にはSSTATUS, SCAUSE, SIP, SEPなどの'S'の文字から始まる名前の特権レジスタ群が用意されており、これらを用いてOSを制御します。これらレジスタの用途や使い方は、マシンモード用のレジスタとほぼ同じです。

OSがマシンモードでなければ行なえない操作が必要になった時は、モニタプログラムに依頼する必要があります。マシンモードから利用することを前提としたコントローラのレジスタ群(MTIMECMPレジスタ、MSIPレジスタなど)もモニタプログラム経由で操作する必要があります。

スーパーバイザーモードでecall命令を実行すると、スーパーバイザーモードEnvironment Call例外が発生し、マシンモードに切り替わりモニタプログラムが起動します。モニタプログラムは依頼された処理を実行した後にmret命令を実行し、呼び出し元(スーパーバイザーモードで実行中のOS)に復帰します。アプリケーションがシステムコールを発行する時、ecall命令の実行によりOSを呼び出して処理を代行してもらうのと同じ関係になります。

OSからモニタへの要求手順は、先にも述べましたが、SBI: Supvervisor Binary Interfaceに従うことにします。

割り込みはモニタプログラムが一度受取り、モニタプログラムがスーパーバイザーモードに向けて割り込みを発生させることにより、OSが割り込みを受け取れるようにします。

現在のOS実装では、マシンタイマ割り込みとマシンソフトウェア割込みを利用しています。マシンタイマ割り込みを受け取ったモニタプログラムはスーパーバイザータイマ割り込みを生成し、OSに通知するものとします。同様に、マシンソフトウェア割り込みを受け取ったモニタプログラムはスーパーバイザーソフトウェア割り込みを生成するものとします。

OpenSBI上での実装

方針で述べた動作をするモニタプログラムOpenSBIが既に存在しています。まず、このOpenSBI上でRISC-V OSが動かすことにします*2

OSの起動

OSの起動はOpenSBIが行ないます。64bit RISC-V virtハードウェア環境では、OSは0x80200000番地から配置されており、その先頭にリセットエントリがあるものとされます*3

OSのリセットエントリ呼出しはブートコア1つのみが行ないます。その他のコアはOpenSBIの中で停止させられています。OpenSBIではブートコアのコア番号(HART ID)が0である保証はありません。実際、OpenSBIはブートコアをランダムに決定します。

最初のmain関数呼出し時、引数に渡されるcoreidの値はカレントコアを指していません。OSの最初の仕事はブートコアのコア番号(HART ID)を知ることです。本OSでは、全コアに対してSBI sbi_hart_get_status関数を呼び出すことでブートコアを見付けることにしました(restart_bootcore関数)。sbi_hart_get_status関数は、指定したコア(hart)の状態を取得するSBI呼び出しです。ブートコアに対応するhartidに対してのみ、戻り値としてSTARTEDが戻って来るはずです*4

static volatile CoreIdType BootCore = CORE_UNASSIGNED;

void main(CoreIdType coreid)
{
    TaskIdType task;

    SetTrapVectors((unsigned long)trap_vectors + TVEC_VECTORED_MODE);

    if (BootCore == CORE_UNASSIGNED) {
        restart_bootcore();
    }
        :
        :
}

static void restart_bootcore(void)
{
    struct sbiret ret;
    CoreIdType coreid;

    for (coreid = CORE0; coreid < NUMBER_OF_CORES; coreid++) {
        struct sbiret ret = sbi_hart_get_status(coreid);
        if (ret.error == SBI_SUCCESS && ret.value == STARTED) {
            BootCore = coreid;
            _secondary_start(BootCore);
        }
    }
}

ブートコアのコア番号が見付かったところで、BootCore変数にコア番号を記録し、_secondary_start関数からリセット処理を再実行します。引数にはブートコアのコア番号を渡します。ブートコアにより再度main関数が呼ばれた時のcoreid引数には、正しいコア番号が入っています。

    .section .reset,"ax",@progbits
    .option norelax
    .globl _start
    .globl _secondary_start
_start:
    mv    a0, zero

_secondary_start:
    /* a0 = mhartid */
    la    gp, __global_pointer$

    /* tp = _tls_start + mhartid*_TLS_SIZE */
    la    tp, _tls_start
            :
            :
    j     main

再起動したブートコアは、他コアの起動を行ないます(start_cores関数)。start_cores関数は、SBI sbi_hart_start関数を呼び出すことで、他コアの起動要求を行ないます。引数に起動アドレス_secondary_start関数とコア番号を渡します。起動したコアがmain関数を呼び出した時には、引数coreidにはmain関数を実行しているコアの番号が入っています。

void main(CoreIdType coreid)
{
        :
    if (ThisCore == BootCore) {
            :
        start_cores();
    }
    sync_cores();
        :
        :
}

static void start_cores(void)
{
    int coreid;

    for (coreid = CORE0; coreid < NUMBER_OF_CORES; coreid++) {
        if (coreid != BootCore) {
            struct sbiret ret = sbi_hart_start(coreid, (unsigned long)_secondary_start, coreid);
            if (ret.error) {
                _print_message("sbi_hart_start(core%x) failed. error(0x%x)\n", coreid, ret.error);
            }
        }
    }
}

タスクエントリ呼出し

タスク起動時には、スーパーバイザーモードからユーザモードに実行モードを遷移させる必要があります。

SEPCにタスクのエントリアドレス、例外からの復帰時の実行モードとしてユーザモード・割込み許可、SUMフィールド*5を設定したSSTATUSを用意したのち、sret命令を実行します。

SSTATUSには、MSTATUSのうちスーパーバイザーモードでも操作可能な情報が見えます。スーパーバイザーモードでのみ意味のあるフィールドもあります。

SSTATUSフィールド 説明
SPP (MSTATUSのMPPに相当) 0 復帰先の実行モードがユーザモード
1 復帰先の実行モードがスーババイザモード
SPIE (MSTATUSのMPIEに相当) 0 復帰先が割込み禁止
1 復帰先が割込み許可
SUM 0 ユーザモード空間にスーパーバイザーからアクセス不可
1 ユーザモード空間にスーパーバイザーからアクセス可能
    .equ   STATUS_SPIE,      (1U<<5)
    .equ   STATUS_SPP_USER,  (0<<8)         /* user mode */
    .equ   STATUS_SPP_SMODE, (1U<<8)        /* supervisor mode */
    .equ   STATUS_SUM,       (1U<<18)

    .globl TaskStart
    .type TaskStart,@function
    .balign 4
TaskStart:
    csrw  sepc, a0
    li    a0, STATUS_SPIE|STATUS_SPP_USER|STATUS_SUM
    csrw  sstatus, a0
    mv    sp, a1
    sret
    .size TaskStart,.-TaskStart

トラップベクタ

トラップベクタテーブルtrap_vectorsは、STVECレジスタに登録します。スーパーバイザーモードの例外・割込みが発生した場合、このテーブルに登録したハンドラが呼び出されます。

#define TVEC_VECTORED_MODE 0x1U

void main(CoreIdType coreid)
{
             :
    SetTrapVectors((unsigned long)trap_vectors + TVEC_VECTORED_MODE);
             :
    .global SetTrapVectors
    .type SetTrapVectors,@function
SetTrapVectors:
    csrw  stvec, a0
    ret
    .size SetTrapVectors,.-SetTrapVectors

タイマ制御

起動した各コアは、それぞれタイマの起動を行ないます(StartTimer関数)。StartTimer関数は、現在時刻(_get_time関数)からINTERVAL時間後にタイマ割り込みを発生させるようにSBI sbi_set_timer関数で要求します。MTIMECMPレジスタの更新は、OpenSBIに行なってもらいます。

static __thread unsigned long nexttime;

static void StartTimer(void)
{
    nexttime = _get_time() + INTERVAL;
    sbi_set_timer(nexttime);
}

現在時刻の取得もMTIMEレジスタを直接参照せず、rdtime命令を使って行ないます。

    .globl _get_time
    .type _get_time,@function
    .balign 4
_get_time:
    rdtime a0
    ret
    .size _get_time,.-_get_time

タイマハンドラも次のタイマ割込み発生をSBI sbi_set_timer関数で要求します。

int Timer(void)
{
        :
    do {
        nexttime += INTERVAL;
        sbi_set_timer(nexttime);
    } while ((long)(_get_time() - nexttime) >= 0);
        :
        :
}

SBI sbi_set_timerで要求したタイマ割り込みは、スーパーバイザータイマ割り込み(割込み番号5)としてRISC-V OSに通知されます。trap_vectorsの5番に割込みエントリint_handlerを登録します。OSをマシンモードで動かしていた時は、マシンタイマ割り込み(割込み番号7番)が発生していました。

int_handlerの中で利用する特権レジスタは、'M'から始まるものから'S'から始まるものに置き換えます。int_handler最後のトラップからの復帰命令もmretからsretに置き換えます。

OS実行時には、スーパーバイザータイマ割り込みを許可しておく必要があります。SIEレジスタのSTIEビットを立てておきます(EnableInterrupts関数)。SIEは、マシンモードのMEIレジスタに相当するものです。

コア間割込み

他コアに再スケジュール要求を行なうbroardcast_IPI関数では、SBI sbi_send_ipi関数を呼び出し、自コア以外の全コアにソフトウェア割込み発生を要求します。MSIPレジスタの操作はOpenSBIに任せます。

void broardcast_IPI(void)
{
    unsigned long core_mask = 0U;
    CoreIdType core = ThisCore + 1;
    do {
        core_mask |= (1U << core);
        core = (core + 1) % NUMBER_OF_CORES;
    } while (core != ThisCore);

    sbi_send_ipi(core_mask, 0U);
}

SBI sbi_send_ipiで要求したソフトウェア割り込みは、スーパーバイザーソフトウェア割り込み(割込み番号1)としてRISC-V OSに通知されます。trap_vectorsの1番に割込みエントリint_handlerを登録します。OSをマシンモードで動かしていた時は、マシンソフトウェア割り込み(割込み番号3番)が発生していました。

OS実行時には、スーパーバイザーソフトウェア割り込みも許可しておく必要があります。SIEレジスタのSSIEビットを立てます(EnableInterrupts関数)。

メモリ保護

スーパーバイザーモードではPMPは利用できないため、VMA(仮想メモリ)の機能を利用してメモリ保護設定を行なうことにします。本連載では、Sv39と呼ばれるモードを選択し、アドレス変換は行なわずアクセス権の設定のみを行ないます。OS用物理メモリはスーパーバイザーモードからのみアクセス可能な空間としてマップし、アプリケーション用物理メモリはユーザモードとスーパーバイザーモードのどちらからもアクセス可能な空間としてマップします。どちらも2メガバイトページを利用してマップすることにします。

SetupPageTable関数にてページテーブルを生成し、EnableMMU関数にてSATPレジスタにページテーブルを登録します。この図にはありませんが、uartコントローラのあるI/O空間もページテーブルを使ってマップします。

このトピックは今回の記事の本題では無いので詳しく説明しませんが、興味のあるは下記おまけを眺めた後で、RISC-V Specificatio Volume 2, Privileged Specificationを参照してください。

動かしてみよう

OpenSBIをインストールします。

$ sudo apt install opensbi

ビルドします。

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

qemuの-biosオプションにOpenSBI(fw_jump.elf)を指定して、RISC-V OSを起動します*6

$ qemu-system-riscv64 -smp 3 -nographic -machine virt -m 128M -kernel sophia -bios /usr/lib/riscv64-linux-gnu/opensbi/generic/fw_jump.elf

OpenSBI起動メッセージに続いて、今迄の連載で見慣れた「食事をする哲学者」の出力が続いています。

OpenSBI v1.4
   ____                    _____ ____ _____
  / __ \                  / ____|  _ \_   _|
 | |  | |_ __   ___ _ __ | (___ | |_) || |
 | |  | | '_ \ / _ \ '_ \ \___ \|  _ < | |
 | |__| | |_) |  __/ | | |____) | |_) || |_
  \____/| .__/ \___|_| |_|_____/|____/_____|
        | |
        |_|

Platform Name             : riscv-virtio,qemu
Platform Features         : medeleg
Platform HART Count       : 3
Platform IPI Device       : aclint-mswi
Platform Timer Device     : aclint-mtimer @ 10000000Hz
Platform Console Device   : uart8250
      :
      :
      :
Boot HART MHPM Info       : 16 (0x0007fff8)
Boot HART MIDELEG         : 0x0000000000001666
Boot HART MEDELEG         : 0x0000000000f0b509
core0: Core0 started.
core2: Core2 started.
core2: Task2 Eating
core0: Task1 Meditating
core0: Task4 Eating
core1: Core1 started.
core0: Timer
core2: Timer
core1: Timer
core2: Timer
core1: Timer
core0: Timer
core2: Inter-Core Interrupt
core1: Inter-Core Interrupt
core1: Task1 Meditating
core2: Inter-Core Interrupt
core0: Task5 Eating
core1: Inter-Core Interrupt
core1: Task4 Meditating
core2: Task3 Eating
core1: Inter-Core Interrupt
core1: Task2 Meditating
core0: Timer

最後に

本OSで利用したSBI呼び出しをまとめておきます。本OSでは、レガシー拡張と呼ばれる非推奨SBIの利用は避けました。実際、UbuntuのKVMではレガシー拡張は外されています。

実は、qemuの中にも古いOpenSBIのコードが取り込まれており、-biosオプションを指定せずにqemuを起動した場合にはこのコードが動きます*7。ただし実装が古いため、比較的最近追加されたデバッグコンソール拡張(DBCN)などの機能はまだ存在しません。

SBI仕様は拡張が続いており、最近追加された仕様で興味深いのはハイパーバイザーのネストを支援するための拡張(NACL)でしょうか*8

SBI EID FID 説明
struct sbiret sbi_set_timer(uint64_t stime_value) 0x54494D45 0 stime_valueで指定した時刻にタイマ割込みを発生させる
struct sbiret sbi_send_ipi(unsigned long hart_mask, unsigned long hart_mask_base) 0x735049 0 mart_maskのビットに対応する全てのhartにコア間割り込みを送る
struct sbiret sbi_hart_start(unsigned long hartid, unsigned long start_addr, unsigned long opaque) 0x48534D 0 hartidで指定したコアを起動する。opaqueを引数としてstart_addr関数を呼び出す。
struct sbiret sbi_hart_get_status(unsigned long hartid) 0x735049 2 hartidで指定したコアの状態を取得する

SBI呼び出し時、EIDはa7レジスタに、FIDはa6レジスタに設定します。a0-a5レジスタは、SBI実装と引数や戻り値をやりとりするために使われます。本OSのsbi.sの実装と、RISC-V SBI仕様の説明も参照してください。

次回からは、本連載RISC-V OSを作ろう の番外編「RISC-V ハイパーバイザーを作ろう」にて実際にハイパーバイザーを実装します。マシンモードで動作するOpenSBIと、スーパーバイザーモードで動作するSophia OSの間に、HSモードで動作するハイパーバイザーを挟み込みます。

おまけ

RISC-V64では、仮想メモリ空間の大きさが異る幾つかのモードを選択できます。ここでは、本連載で採用したSv39モードでの仮想空間の機能(VMA機能)を見て行きます。Sv39モードでの仮想空間の大きさは512Gバイトとなります*9

VMA機能は、ページという単位で物理メモリを仮想メモリ空間にマップします。Sv39モードでは、4Kバイトページの他に2Mバイトページ・1Gバイトページを扱うこともできます。

Sv39モードの仮想アドレスは39ビット長です。39ビット長の仮想アドレスを、3段のページテーブルでアドレス変換します(仮想アドレスに対応する物理アドレスを求める)。

アドレス変換は次のように行なわれます。

  1. 仮想アドレス(のページ番号、38~12ビットの値)とASID(SATPレジスタが持つ)に対応するエントリがTLBに載っていないか検索します。ヒットした時は、それが求める物理ページです。この物理ページのアドレスに、仮想アドレスの11~0ビットの値をオフセットとして加えたものが、実際にアクセスすべき物理アドレスとなります。
  2. TLBにヒットしなかった時は、SATPレジスタが指すルートページテーブルから検索を始めます。
  3. 仮想アドレスの38~30ビットの値が、ルートページテーブル内のエントリ(PTE)の位置(ページテーブル内のオフセット)を表します。
  4. このエントリ(PTE)には、2段目のページテーブルの物理ページ番号が格納されています。
  5. 次に、仮想アドレスの29~21ビットの値をオフセットとして、2段目のページテーブル内のエントリ(PTE)を見付けます。
  6. このエントリ(PTE)には、3段目のページテーブルの物理ページ番号が格納されています。
  7. 次に、仮想アドレスの20~12ビットの値をオフセットとして、3段目のページテーブル内のエントリ(PTE)を見付けます。
  8. このエントリ(PTE)には、目的の物理ページの番号が格納されています。物理ページの大きさは4Kバイトです。この物理ページのアドレスに、仮想アドレスの11~0ビットの値をオフセットとして加えたものが、実際にアクセスすべき物理アドレスとなります。
    アクセスする時はエントリ(PTE)にはアクセス権設定(有効無効、読み・書き・実行の許可設定、ユーザモードアクセス権)が確認され、許可されないアクセスであった場合はページフォルト例外が発生します。
  9. 今回のアドレス変換に登場した情報(仮想ページ番号、ASID、物理ページ番号、ページサイズ、アクセス権など)の組がTLBに登録されます。

3段目のページテーブルを利用しない設定も可能です。2段目のページテーブル内のエントリ(PTE)に、終端(leaf)であることを示す設定*10がある場合、このエントリ(PTE)に格納されている物理ページ番号は目的のページそのものを指します。ページの大きさは2Mバイトとなります。

RISC-Vでは、どの段のページテーブルのPTEでもleafとなり物理ページを指すことができます。leaf PTEには読み・書き・実行の許可、ユーザモードからのアクセス許可を設定できます。

ASID(アドレス空間ID)は、現在どの空間用のページテーブルを利用しているかを示すIDです。同じ仮想アドレスであっても、ASIDが異なれば別の空間であると認識されます。このASIDはSATPレジスタに設定します。

ページテーブルを変更した時およびASIDの値を再利用した時には、sfence.vma命令を実行し、CPUコア内のTLB(アドレス変換テーブル)をフラッシュしなければなりません。アドレス空間(ASID)とアドレス範囲を指定することにより、必要最小限のフラッシュで済ませられるよう考慮されています。

ただし、今回の記事の例では仮想空間のマッピング情報を書き換えることは無いため、sfence.vma命令の出番はありません。

*1:SBIに合わせておけば、Berkeley Boot LoaderやOpenSBI、さらにはLinux KVM上でもRISC-V OSを動作させることができるはずです。

*2:後ほど、本連載の独自実装のものも用意することを考えています。

*3:Linuxの場合は、一般には0x80200000にはu-bootプログラムを置き、u-bootからLinuxカーネルを起動します。

*4:これより上手い方法が思い付かなったのですが、他に良いアイデアがあれば教えてください。Linuxカーネルは、ブートコア番号が書き込まれたデバイスツリー情報を受け取ることで対応しているようです。

*5:後に述べる仮想メモリを有効にしたとき必要となる

*6:実は、-biosオプションを省略してもqemuがOpenSBIを自動起動するようになっています。

*7:-kernelオプションで指定したプログラムをマシンモードで起動するには、-bios noneと明示的に指定する必要があります。-biosオプションを指定しなかった場合は、スーパーバイザーモードで起動されてしまいます。

*8:ハイパーバイザー上で別のハイパーバイザーが動き、更にその上でゲストOSが動く

*9:執筆時点でのRISC-Vアーキテクチャでは、Sv57モードを選択することで128ペタバイト空間の生成も可能です。Sv39モードのままで空間を2TBに拡張する機能もあります。

*10:読み・書き・実行の許可設定があるPTEは、leafとなる