プロセス切り替え
この章ではOSの要となるプロセスの切り替え(コンテキストスイッチ)を実装します。
そもそもプロセスとは、実行中のプログラムのことで、OSの中では各プロセスの状態というものを保存することで自由に実行を中断したり実行を再開させるということができます。 このプロセスの状態というものをプログラムの中で定義してあげて、適切に保存するロジックを実装する、というのがこの章のテーマです。 今まではただのOSなしでのプログラミング、すなわちベアメタルプログラミングと大した違いはなかったのですが、プロセス切り替えを実現できるとだいぶ雰囲気が変わってくると思います。
ARMv7-MのCPUモードについて
OSは一章でも少し説明したように通常のアプリケーションとは異なるCPUのモードで動くことが多いです。 一般にアプリケーション用のCPUモードだとシステムレジスタへのアクセスが制限されたり、使うレジスタが異なる場合や、一部のメモリにアクセスできなかったりします。 このCPUモードの切り替えによって、アプリケーションが他のアプリケーションに影響を与えたり、OSの機能を乗っ取ることを防止しているわけです。
Cortex-Mは他のアーキテクチャのCPUモードと比べると境目がかなり曖昧で制限も弱いですが、一応ちゃんと存在しています。4章でもちらっと登場しました。 ここでしっかり見てみましょう。
ARMv7-MのB1.3章を見てみましょう。ARMv7-Mではモード、特権、使用するスタックポインタを切り替えることができ、これらがシステムレベルのアーキテクチャを把握するのに重要なキーとなります。 順番に見ていきましょう。 まず、モードですが、スレッドモードとハンドラモードの2種類があります。リセット直後だとスレッドモードとなり、このモードが通常のプログラムが実行されている状態と考えることができるでしょう。 例外が発生するとハンドラモードに移行します。このモードに突入し例外処理が終わったあと、例外復帰処理をすることでまたスレッドモードに移行できます。 例外復帰処理とは4章でちょこっと説明しましたが、ARMv7-MではLRを特殊な値にセットすることで実現されます。このときのLRの値によってモードや後述する特権や使用するスタックポインタの切り替えもできます。 ここで注意したいのが、スレッドモードもハンドラモードも1章で言及した「カーネルモード」と対応するものではないということです。このモードの違いは権限を分けるためのものではないからです。
プログラムの権限を調整するのが特権です。特権状態と非特権状態が存在します。特権状態でないと実行できない命令が存在します。 ハンドラモードでは常に特権状態になりますが、スレッドモードでは非特権状態になることが可能です。 OSが実行するときはプロセッサの機能がすべて使えるように特権状態になるべきで、そうでない通常のアプリケーションの場合は非特権状態を使うのがよさそうですね。
ARMv7-Mではスタックポインタが2種類存在して使い分けることができる、ということは4章でもちらっと言いましたが、ここでちゃんと見てみましょう。 スタックポインタにはメインとプロセスの2種類存在していて、前述したモードや特権とは独立したパラメータとして設定することができます。 ただし、ハンドラモードでは常にメインが使われることになります。
ここで1つ補足しておくと、 汎用OSが動くようなCPUだと通常、メモリ管理ユニット(MMU)というものが備わっていて、OSと通常のアプリケーションでアクセスできるメモリ領域を制限したり、 アプリケーション間で異なる物理メモリを同一のアドレスに割り当てるアドレス変換といったことができるのですが、残念ながらARMv7-MにはMMUは備わっていません。 一応、メモリ保護機構(MPU)というものは備わっていて、これを使うことでアプリケーションがアクセスできるメモリ領域を制限することはできるのですが、本書では扱いません。
CPUの状態を切り替える
それでは、実際にどのようにプロセスというものを実現するかを考えていきましょう。 各プロセスはスレッドモードで、OSに干渉しないように非特権状態で、またスタックポインタもプロセスを使うようにしましょう。 OSは今までどおりスレッドモード・特権状態・スタックポインタはメインを使うようにしましょう。
状態切り替えの方法としては一度Supervisor call例外(SVCall例外)を発生させてハンドラモードに入って、そのリターン時に切り替えるというのがいいでしょう。
このSVCall例外はsvc
というソフトウェアの命令で発生させることのできるもので、アプリケーションからOSの機能を呼び出すシステムコールを実装する際に使うものです。
この例外ハンドラの処理として、OSから発生した場合はプロセスに、プロセスから発生した場合はOSに戻る、という処理を実装しましょう。
SVCall例外が発生したとき、SVCall
という関数を呼び出すように4章ですでにベクターテーブルを設定していたので、この関数を実装しましょう。
SysTick
のときと同じようにextern "C"
で宣言しているところからSVCall
を消す必要もあります。
#[no_mangle]
pub unsafe extern "C" fn SVCall() {
llvm_asm!(
"
cmp lr, #0xfffffff9
bne to_kernel
mov r0, #1
msr CONTROL, r0
movw lr, #0xfffd
movt lr, #0xffff
bx lr
to_kernel:
mov r0, #0
msr CONTROL, r0
movw lr, #0xfff9
movt lr, #0xffff
bx lr
"
::::"volatile");
}
さて、いきなりllvm_asm!
というものが現れました。これはインラインアセンブラと呼ばれるもので、アセンブラ命令を直接埋め込むための記法です。
先頭にllvmとついていることからわかるように、この記法はRustコンパイラのバックエンドであるLLVMに依存したものです。
ただし、この記法はstableのコンパイラではサポートされておらず、このままコンパイルするとエラーが出てしまいます。
このようにstableでサポートされていない機能を用いるには、以下のような宣言を追加する必要があります。
#![feature(llvm_asm)]
これをmain.rsの冒頭に置いておきましょう。これでとりあえずコンパイルは通るようになるはずです。
なぜ今回アセンブラ命令を直接使ったかというと、LRやシステムレジスタに直接アクセスする必要があったからです。
本来、直接アセンブラ命令を書くことはあまり褒められた行為ではないです。
まず、Rustならではの型のチェックやライフタイムの判定などができなくなりミスをしやすくなり、また、アーキテクチャ間での移植性も低くなってしまいます。
ですが、今回のようなケースだとなんかしらの形でアセンブラを直接書かざるを得ないので、今回はインラインアセンブラを使っています。
アセンブラの中身ですが、cmp
というのが比較命令で、リンクレジスタの中身を見てあげることでカーネルから来たのかアプリケーションから来たのかを判定しています。
bne
は直前の比較命令で等しくない場合にラベルまでジャンプするという命令です。
msr
というのがシステムレジスタに値を書き込む命令です。CONTROL
レジスタに1を書き込むことで非特権状態にすることができます。
movw
とmovt
命令でリンクレジスタの値を書き換えることで、使うスタックを切り替え、bx
命令で例外復帰している、というような流れです。
llvm_asm
で記述するインラインアセンブラはLLVMでのインラインアセンブラの記法に直接紐付いているので、詳しい記法についてはLLVMのドキュメントを参照するといいでしょう。
アセンブラそのものの詳細についてはリファレンスマニュアルのA4章を参照するか、ネットで検索してもわかりやすい資料が結構あると思います。
さて、svc
命令でアプリケーション用の状態にしていきたいところですが、その前に、アプリケーション用のスタックポインタの初期値を決める必要があります。
さらに、SVCall
関数で例外復帰する際にスタックポインタに保存されたレジスタの値を実際のレジスタに書き戻すという処理がなされます。
そのためアプリケーション用のスタックにアプリケーションの初期状態の値を格納しないといけません。
では、まずスタック上にどのようにレジスタの値が保存されるかをちゃんと見てみましょう。
前回の章でも紹介したリファレンスマニュアルのB1.5.7章に書かれています。
スタックポインタにコンテキストステートフレームという形で8ワード(レジスタ8つ分)のデータが割り込みが発生すると保存されることが説明されています。
これを予め設定してあげます。一つ注意点ですが、このスタックは通常は8バイトにアラインされていないといけない、つまりアドレスが8の倍数になっていなければならない点です。
ここに気をつけてまずはスタック用のメモリ領域を確保するところからはじめましょう。
通常のプログラムであれば、mallocなどでの動的メモリ確保の命令でメモリ領域を確保するのですが、mallocはOSのないベアメタルプログラムでは使えないです。
そこでメモリ領域の一部を割り当てるように新しいセクションをリンカスクリプトで定義して、それをスタックとして使いましょう。
まず、リンカスクリプトに以下のように新しいブロックを足します。
...
_sidata = LOADADDR(.data);
.app_stack ALIGN(0x08):
{
*(.app_stack .app_stack.*);
} > RAM
/DISCARD/ :
...
このセクションに新しいstatic変数を配置してあげましょう。static変数というのはプログラム実行中ずっと生き残り続ける変数です。
#[link_section = ".app_stack"]
static mut APP_STACK: [u8; 1024] = [0; 1024];
0で初期化しているように見えますが、実際はbssセクションのように0初期化するプログラムを書いていないので、この配列の初期値は不定です。しかし、スタック領域は0初期化する必要がないので問題がありません。
このメモリ領域の末尾8ワードに初期状態を格納するのですが、その前にわかりやすいように、コンテキストフレーム用の構造体を定義してあげましょう。
そうすれば、後々の作業が楽になります。
まず、これを新しいモジュール上に実装するため、process.rs
という新しいファイルをつくり、その中に、以下のような構造体を宣言します。
#[repr(C)]
pub struct ContextFrame {
pub r0: u32,
pub r1: u32,
pub r2: u32,
pub r3: u32,
pub r12: u32,
pub lr: u32,
pub return_addr: u32,
pub xpsr: u32,
}
repr(C)
というアトリビュートがついています。これは通常のRustの構造体だと実際にどのように各メンバーがメモリ上に配置されるかという保証がないためつける必要があります。
詳しくはRustonomiconの2章のデータレイアウトに関する章を参考にしてください。
では、先程確保したスタック領域の末尾8ワードをContextFame
型として取り出すコードを書いてみましょう。
mod process;
use process::ContextFrame;
...
let ptr = (&APP_STACK[0] as *const u8 as usize) + 1024 - 0x20;
let context_frame: &mut ContextFrame = &mut *(ptr as *mut ContextFrame);
...
ポインタを参照に変換する処理はunsafeです。Reset関数内に書いているのであれば、Reset関数そのものがunsafeなのでunsafeブロックで囲う必要はないですが、それ以外の普通の関数としてくくりだしているときはunsafeブロックで囲ってあげましょう。
初期値ですが、汎用レジスタは0で初期化すればよさそうです。LRの値も使われないので特に指定する必要はありません。
return_addr
はプログラムカウンタに書き戻される値になります。よってこの値はアプリケーションとして実行したいプログラムのアドレスにすればいいです。
xPSR
の値ですが、各ビットの詳細の説明はリファレンスマニュアルのB1.4.2に各ビットが何を示しているかが書いてあります。
結論から言うと、EPSRに含まれる24ビットのTフィールドのみを1にすればいいです。このTというのはプログラムの命令フォーマットを示しています。
このArmというアーキテクチャには実は2つの命令フォーマットが存在していて、プログラムのアドレスの下位1ビットを使ってそれを切り替える、という仕様がじつはあります。
リファレンスマニュアルのA4.1章に詳細が書かれています。ArmにはARM命令セットとThumb命令セットという2つの命令セットがあり、これらを混在させることができます。
しかし、ARMv7-MではThumb命令しかサポートしていません。BX
などの命令でプログラムカウンタに値を書き込む際、最下位ビットの値が1ならばThumb命令として解釈されます。
しかし、この例外復帰時のスタックからプログラムカウンタに書き戻される処理はこの命令セット切り替え処理はされず、最下位ビットは無視されます。
なので、return_addr
に対して何かをする必要はないです。
とりあえず、プロセスから呼び出す関数を定義しておきましょう。適当な文字列を出力して、svc命令によってカーネル側に復帰するコードにしましょう。
extern "C" fn app_main() -> ! {
hprintln!("App").unwrap();
unsafe { asm!("svc 0"::::"volatile"); }
loop {}
}
この関数を使い以下のようなコードを書けば、初期領域ができます。
context_frame.r0 = 0;
context_frame.r1 = 0;
context_frame.r2 = 0;
context_frame.r3 = 0;
context_frame.r12 = 0;
context_frame.lr = 0;
context_frame.return_addr = app_main as u32;
context_frame.xpsr = 0x0100_0000;
あとは、svc
命令を呼び出して上げましょう。プロセススタックポインタの値を初期化するのもあわせて行う必要があります。
llvm_asm!(
"
msr psp, r0
svc 0
"
::"{r0}"(ptr):"r4", "r5", "r6", "r8", "r9", "r10", "r11":"volatile");
3つ目のコロンに続くところにr4などのレジスタが列挙されていますが、これはこのアセンブラ命令中に変更される可能性があるレジスタです。
このように書いておけば、コンパイルするときにこれらのレジスタが変更されることを考慮したコードが出力されます。
4章でも触れましたが、例外が発生したときにこれらのレジスタはスタックに保存されないため、これらはきちんと保存しなければなりません。
r7
のみ保存されていないことに気がつくかと思いますが、実はLLVMの中でr7
は特殊なレジスタでこのリストの中に入れていなくても大丈夫です。
生成されたアセンブラを見てみるとr7
も含めて保存されていることがわかると思います。
さて、ここまでできればあとはビルドして実行するだけです。 以下のような出力がOpenOCDから出てくるはずです。
Hello World
Systick init
App
Kernel
Systick
Systick
...
ここまでで、アプリケーションプロセスへのコンテキストスイッチが実装できましたが、このアプリケーションプロセスを複数動かす、ということはまだできません。 次の章で実装していきましょう。