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