執筆者 : 高橋 浩和
※ 「RISC-V OSを作ろう」連載記事一覧はこちら
※ 「RISC-V OS」のコードはgithubにて公開しています。
はじめに
「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_text
、system_rodata
、system_data
、system_bss
、system_sdata
、system_sbss
)をram_system
上に配置します。割り込みが使うスタック用セクションもram_system
上に配置します。
カーネル用のファイル(main.c
、primitives.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 : : :
アプリケーション用セクション
アプリケーション用のセクション(text
、rodata
、data
、bss
)は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
ショートデータ用セクション
ショートデータ用のセクション(sdata
、sbss
)は、gpレジスタ相対でアクセスできる領域に固めて配置する必要があります。
カーネルとアプリケーション双方がショートデータを利用するとした場合、それらショートデータを一箇所にまとめて配置する必要がありますが、メモリ保護の都合上それぞれram_system
とram_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 |
今回の話は、メモリ保護のために物理メモリをカーネル用メモリとアプリケーション用メモリに単純に分割するだけの話だったのですが、 実際に実装するとなると結構大変でした。メモリ保護の話は一旦ここまでとし、次回の連載では少し別の話題に触れたいと思います。