はじめに
この本は組込みシステムやOSのような低レイヤーシステムの開発経験がないプログラマーのような人が、自作OSをはじめるため解説本です。 著者自身、本職はウェブプログラマーですが、趣味で組込みOSの自作をしています。この本はそのノウハウ集のようなものです。 このような低レイヤーシステムはC言語で開発されることが多いのですが、今回はRustを使います。 RustはC言語と比較して、様々なモダンな機能やツール郡を取り揃えている上に、C言語の長所である直接のメモリ制御ができ、パフォーマンスも高いとして組込みシステム開発でつかえるとして注目されています。 Rustそのもの解説は控えめですが、低レイヤーシステム開発特有のテクニックは必要に応じて解説します。
想定読者
ある程度はプログラミング経験があることを前提としています。 また、レジスタやメモリなどコンピュータアーキテクチャに関する基礎用語の説明もちゃんとはしていないので、このあたりの大まかな理解もあるといいでしょう (不安があればヘネシー&パターソン「コンピュータの構成と設計」などに代表されるコンピュータアーキテクチャの教科書を併用するとよいかもしれません)。 Rustに関してですが、基本的な文法事項などは説明しませんが、一通りの文法になんとなく触れている程度の経験があれば十分です。
他の知識・経験、特に組込み開発に関する知識については必要ありません。初歩のマニュアルの読み方・各種ツールの使い方を解説します。
自作組込みOSとは
オペレーティングシステム(OS)とはそもそもなんでしょうか。これは自作OSをつくるさいに誰もがぶつかる疑問でしょう。 OSの教科書として有名なModern Operating Systemの第四版を見てみましょう。
It is hard to pin down what an operating system is other than saying it is the software that runs in kernel mode--and even that is not true.
カーネルモードという通常よりも権限の強いCPUのモードで動くソフトウェアとくらいしか定義しようがない、そのうえそれすらも正しくないものかもしれない。と言っています。 その原因のひとつとして、OSには本質的には関係のない2つの機能を持っているからと書かれています。 その2つの機能とは、プログラマーのためにきれいなインターフェースを提供するというものと、ハードウェアのリソースを管理するというものです。
順番に見ていきましょう。まず、インターフェースとしての機能です。CPUや付属するデバイスをそのままの形で直接叩くというのは非常に複雑で難しいです。 例えば、みなさんはプログラミングの一番始めの一歩として「Hello World」などの文字列を出力したと思うのですが、そのとき、「画面に文字を出力する機能」というのは直接実装することはなかったのではないでしょうか? この「画面に文字を出力する機能」というものを提供しているのはOSです(厳密にはそのOSの機能を利用する言語の標準ライブラリを使うことが多いでしょう)。 このOSがこのような機能を提供してくれるので、例えばモニターを別のメーカーのものや違う規格の接続方法のものに付け替えたとしても、それらの違いというのはOSが吸収してくれる、というわけです。
次に、このようなCPUやデバイスなどを管理するというリソースマネージャーとしての機能です。 パソコンで作業しているときに、作業のためのエディタを立ち上げつつ、BGMを流すための音楽プレーヤーソフトを同じパソコンで立ち上げる、といった複数のアプリケーションを同時に立ち上げるということをすることは多いと思います。 しかし、実際に使えるCPUは限られていて、近年はマルチコアが当たり前になっていますが、その個数を超えてアプリケーションを実行するということも珍しくないと思います。 これを実現しているのがOSのリソースマネージャーとしての機能です。それぞれのアプリケーションに対してCPUをどの程度使っていいかを制限し、CPUで動くアプリケーションを素早く切り替えることによってあたかも複数のアプリケーションが同時に何個も動いているように見せているというわけです。
筆者はこの2つの機能のうち少なくとも片方を満たす、というのが定義にはならなくてもOSとしての必要条件になってくるのではと考えています。
本書でつくる組込みOS
OSと言われて、多くの人はWindowsやmacOS、Linuxなどの各種デスクトップPC向けのOSや、AndroidやiOSのようなスマートフォン向けのOSを思い浮かべると思います。 しかし、実際には様々な家電製品やセンサーノードなどの小さな電子機器にも組込みOSと言われるOSが入っていることが多くあります。他のWindowsなどのOSは汎用OSと言われます。 組込みOSは小型な機器にも使えるように汎用OSとは異なる設計になっていて以下のような傾向があります。
- 使える機能が汎用OSに比べると少ない
- リアルタイム性、つまり一定周期毎、あるいは特定のイベントに対して遅れることなくタスクを実行することが重視される
- メモリやCPU性能を多くは使わない
もっとも汎用OSと組込みOSの厳密な区別はなく、最近は強力な組込み機器向けのCPUも多く存在するため、Linuxが組込み機器に使われるケースもあります。 そのため、必ずしも上記のような性質が常に当てはまるわけではなく、汎用OSと大差がないという場合もあります。
今回、自作しようとしている組込みOSはArmのCortex-MというCPUを対象としたものです。 Armはスマートフォンやゲーム機にも使われていますが、こちらはCortex-AというCPUがメインです。 Cortex-MはAよりも低価格・省電力なのが特徴です。その分、性能面や使えるメモリが少ないです。 どういうところに使われるかというと、ネットワークにつながるセンサーやモーターなどの制御です。
今回作ろうとするOSは例えば画面の上にきれいな画像を出すとかマウスやキーボードの操作を受け付ける、というものではありません。 CPUそのものがそういう用途ではないからです。
最終的につくるのは、簡単なスケジューラと割り込み制御を備えた非常に簡易なOSです。 先程上げたOSとしての2つの機能のうち、後者の側面がつよいものになっています。 そのため、完成形は少し物足りないものになっているかもしれません。 しかしながら、自作OSでは最初の一歩が非常にハードルが高いと思うので、その最初の一歩を助けるという意識で書きました。
フィードバック
本書はこちらのGithubレポジトリで更新しています。 もし、何か誤字脱字や誤りがあった場合は、このレポジトリのissueとして報告していただけると助かります。 それ以外にももっとここを説明してほしいといった要望でも構いません。
その他、筆者のTwitterでも各種フィードバックを受け付けています。
また、本書を一通り終えたあとに出来上がると期待されるコードは以下からダウンロードできます。
https://github.com/garasubo/bookos
環境構築
筆者はUbuntu 18.04で動作確認をしていますが、基本的にはWindowsやMac環境でも構築可能です。
Rust
rustup
を使う方法が公式でも全プラットフォーム共通で推薦されているので、これに従います。
[https://rustup.rs/]の方法に従って、rustupをインストールします。LinuxやMacのようなUnixライクなOSの場合、以下のようにインストールします。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustupはRustコンパイラのインストール・アップデートやバージョン管理をしてくれるツールです。
今回は特にnightly
と呼ばれる、いわゆるベータ版のようなRustコンパイラを使うことになるのですが、nightlyと安定版であるstable
の切り替えもコマンド一つで行えて大変便利です。
今回はnightlyの2020-10-25のバージョンを使います。以下のようにしてnightly-2020-10-25
をインストールしましょう。
$ rustup install nightly-2020-10-25
さらにArmのクロスコンパイル用の環境を入れるために以下のコマンドを実行します
$ rustup target add thumbv7em-none-eabihf
使用ボード
STマイクロエレクトロニクス社のNUCLEO-F429ZIボードを使用します。 ただし、この本では多くのペリフェラルは使わないので、ST社のCortex-Mの他のボードでも十分に再現可能かと思われます。
仕様書・ドキュメント
ハードウェア関連
低レイヤーシステムは仕様書をちゃんと読み理解する、ということがとても重要になります。 マイコンボードの種類によっては、仕様書を手に入れるためには面倒な契約を結ぶ必要があるケースもありますが、今回使うNucleoボード及びそのCPUの仕様書はすべてオンラインで入手可能です。 この本では、実際に仕様書を参照しつつ、OSを書いていきますのでこれらもダウンロードしてください。 なお、以下のリンクにある仕様書は基本的に英語版です。日本語版もある場合がありますが、最新版ではないため英語版を参照するほうが安全です。
-
Arm®v7-M Architecture Reference Manual ボードに乗っているCPUの基本的な仕様書です。アセンブラ命令の解説や、割り込みが発生したときの挙動、システムレジスタの仕様などが書かれています。
-
ARM Cortex-M4 Processor Technical Reference Manual Revision r0p1 Documentation Arm v7-Mの仕様に基づいたアーキテクチャであるCortex-M4のマニュアルです。日本語版も多少古いバージョンですが存在します。 割り込みコントローラやメモリ保護機構などの解説があります。また、Arm v7-Mについても簡単な説明があります。
-
STM32F42xxxのリファレンスマニュアル 今回使うボードのCPUのペリフェラル(周辺機器)の仕様書です。例えばLEDを光らせるにはGPIOというペリフェラルから信号を送るのですが、そういうものの使い方が知りたい場合はこのマニュアルを基本的に読むことになります。
-
STM32F429xxのデータシート CPUについての詳細です。CPUの仕様はリファレンスマニュアルだけでなく、こちらのデータシートも併せて読むことになります。
-
Nucleo-144ボードのユーザーマニュアル ボードそのものについてのドキュメントです。どの端子やピンがどうCPUに結びついているかなどの使い方を調べるために使います。
Rust関連
Rustそのものについてのドキュメントもしっかりと見ていく必要があります。 まず、Rustに関する知識が一切ない場合、TRPLと呼ばれるチュートリアルを一通り眺めてみるのが良いでしょう。
- The Rust Programming Language
- 日本語版 本書ではRustに関する初歩的な構文の知識などには触れないため、もしわからないことがあればこちらを参照してください。
低レイヤーのプログラミングを行うにあたって、Rustの詳細な実装を知る必要がある場面があります。 そのための本としてThe Rustonomiconというのがあります。
- Ther Rustonomicon こちらは必要になったら参照することになりますが、すべてを理解しておく必要はありません(正直なところ筆者もすべての項目を把握していないです)。
ベアメタルでHello World
この章の内容はThe Embednomiconにちょっと詳しい解説をつけただけです。すでにこちらのドキュメントを読んでいるのであればこの章は飛ばして次の章に進んでください。
プロジェクトのセットアップ
rustup
でRustコマンドをインストールするとcargo
というコマンドが使えるようになります。
このコマンドはRust標準のビルドシステム兼パッケージマネージャーです。
まずはOS用の新規プロジェクトをつくりましょう。今回のプロジェクト名はbookos
で行きます。もし自分の気に入ったプロジェクト名があれば、それでも構いません。
$ cargo new bookos
Created binary (application) `bookos` package
$ cd bookos
$ ls
Cargo.toml src
$ ls src
main.rs
src/main.rs
をこれから書き換えてHello Worldするプログラムを書いていきます。
その前に、このプロジェクトで使うRustのバージョンを指定するためにrust-toolchain
というファイルをこのプロジェクトにおいておきます。
$ echo "nightly-2019-09-19" > rust-toolchain
このファイルがプロジェクトのトップディレクトリにあることで、rustupにどのバージョンのRustを使えばいいかを自動的に教えて、バージョンを切り替えてくれるようになります。
プログラムの実行
OSのある環境下では、たとえばターミナルから実行コマンドを打つことでOSがプログラムをロードして実行してくれますが、
OSのないベアメタル環境ではどのように自分の実行したいプログラムを実行すればいいのでしょうか?
電源投入時のCPUの動作について、ARMv7-MのアーキテクチャリファレンスマニュアルのB1.5.5 Reset behaviorに書いてあります。
この疑似コードによると、最後にtmp
と0xFFFF_FFFE
のandを取った値にブランチする、つまりtmp
の値の最下位ビットを0にしたメモリアドレス上に置かれたプログラムが実行される、ということのようです。
このtmp
はvectortable
+4の値になるようですが、このvectortable
の説明はB1.5.3にあります。Vector tableは例外が発生したときにどのアドレスにジャンプするべきかを指す配列です。
先頭エントリーはリセット時のスタックポインタの値になるので、4バイト先のエントリがリセット時に飛ぶべきアドレスとなっています。
このvector tableですが、どこに置かれるべきかはチップの実装依存となっているため、今回はSTM32F42xxxのリファレンスマニュアルを見る必要があります。
2.4 Boot configurationの章を見てみましょう。本来であれば0番地がコードの最初のエリアになるのですが、設定によってこれを変更できることが書いてあります。
BOOT0
とBOOT1
の値で制御されるのですが、Nucleoボードのユーザーマニュアルによるとどうやらデフォルト値はともに0のようなので、メインのフラッシュメモリから読み出されることになります。
このフラッシュメモリのアドレスはというとSTMのリファレンスマニュアル3.4章の表に0x800_0000
番地から始まる、と書いてあるので、ここにvector tableを配置してあげれば自分のプログラムを実行できるということになりそうです。
このフラッシュメモリ領域がいわゆるROMの領域で、ここに自分のプログラムを書き込んであげることになります。
no_std環境下でのRust
自分のプログラムをボード上で実行させる方法がなんとなくわかったと思いますが、問題はどうやったらこのフォーマットに従ったプログラムをRustで書いてバイナリにすればいいか、です。 それを解説したのがThe Embednomiconだったわけです。
通常、Rustのプログラムを書くときstdクレートと呼ばれる言語の標準ライブラリを利用してプログラムをコンパイルすることになります。
しかし、このstdクレートは様々なOSの機能を利用することを前提として実装されているので、OSのないベアメタル環境下では当然動きません。
また、コンパイルしたときのフォーマットもOS上で実行するためのフォーマットになっているので、それも変更しなければなりません。
前者の問題を解決するための手段が#![no_std]
アトリビュートです。これを使うとstdクレートの代わりに、OSの存在なしでも利用できる機能を提供するcore
クレートを使ってプログラムをコンパイルしてくれます。
では、no_stdでの最小プログラムをThe Embednomiconから引用します。
#![no_main]
#![no_std]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_panic: &PanicInfo<'_>) -> ! {
loop {}
}
#![no_main]
というアトリビュートがついていて、main関数がないこと、#[panic_handler]
というアトリビュートがついたpanic
関数が存在することが普通のプログラムとは異なっています。
main
関数が通常であれば最初に実行されるプログラムとなるのですが、これもOSが存在して事前準備をしてくれることを前提としたものになっています。
main
関数に相当するレイヤーは後々つくっていくことになりますが、今回は最小の、なので省略です。
代わりにパニック時の動作についてはきちんと指定する必要があります。例えばOption
のNone
に対してunwrap
を呼び出すと通常のプログラムならば異常終了するはずですが、OSのないベアメタル環境ではこの異常終了時の動作を定義してあげる必要があります。
これが#![panic_handler]
アトリビュートがついているpanic
関数、というわけです。今回は単に無限ループさせるだけですね。
このプログラムをビルドしてみましょう。 普通にビルドしてしまうと、実行しているPC向けのバイナリをつくってしまいますので、ターゲットを指定してあげる必要があります。
$ cargo build --target thumbv7em-none-eabihf
Compiling bookos v0.1.0 (/home/garasubo/workspace/bookos)
Finished dev [unoptimized + debuginfo] target(s) in 0.25s
thumb
というのはArmの命令セットの名前で、命令長が16ビットになっているという特徴があります。ARM命令と呼ばれる命令セットも存在してそちらは命令長が32ビットなのですが、ARMv7-Mではこちらはサポートされていません。
後ろのhf
というのはハードウェアの浮動小数点演算器が存在することを示しています。一部のARMv7-Mのチップでは浮動小数点演算器がないことがあり、コンパイラでそれらをソフトウェア演算にしなければならないことがあります。
もっとも、今回は浮動小数点の絡む処理を書く予定はないので、間違えてつけなくても(あるいは違うボードを使っていて浮動小数点演算器がないにもかかわらずhf
をつけたとしても)問題にはならないでしょう。
Vector tableを定義する
stdが使えない問題はクリアしましたが、まだなにもプログラムが実行できない状態なのでこれを解決していきましょう。
このArmマイコンではVector tableに従って最初に実行されるプログラムが決定されることがマニュアルからわかっているので、このVector tableを組み込んだプログラムをコンパイルできるようにすればこの問題は解決できます。
プログラムをコンパイルしたとき、各関数や変数がどのようなフォーマットでバイナリとして配置されるかを定義するにはリンカースクリプトというものを使います。
これはC言語など他の言語でベアメタルプログラミングするときも使うものです。
The Embednomiconの2.Memory layoutの章で使われているリンカースクリプトを見てみましょう。このリンカスクリプトはlink.ld
としてプロジェクトのトップディレクトリにおいておきましょう。
* Memory layout of the LM3S6965 microcontroller */
/* 1K = 1 KiBi = 1024 bytes */
MEMORY
{
FLASH : ORIGIN = 0x00000000, LENGTH = 256K
RAM : ORIGIN = 0x20000000, LENGTH = 64K
}
/* The entry point is the reset handler */
ENTRY(Reset);
EXTERN(RESET_VECTOR);
SECTIONS
{
.vector_table ORIGIN(FLASH) :
{
/* First entry: initial Stack Pointer value */
LONG(ORIGIN(RAM) + LENGTH(RAM));
/* Second entry: reset vector */
KEEP(*(.vector_table.reset_vector));
} > FLASH
.text :
{
*(.text .text.*);
} > FLASH
/DISCARD/ :
{
*(.ARM.exidx .ARM.exidx.*);
}
}
MEMORY
というのがマイコンのメモリのレイアウトを記述するセクションになっていて、SECTIONS
でプログラムをそのメモリにどう配置するかを定義しています。
MEMORY
の中身はハードウェア依存で、本書で使っているマイコンのレイアウトとは異なるので、あとで修正する必要があります。
.vector_table
というのがVector tableを配置するところです。FLASH
の先頭に置くようにしています。
一番最初のエントリーはスタックポインタのアドレスの初期値を格納する必要があります。
次のエントリーはリセット時に呼び出される関数へのアドレスになっています。これをRust側で定義してあげればいいというわけですね。
このスクリプトではRAM領域の末尾を指定してます。スタックポインタは末尾から先頭に向かって伸びていくので、通常は利用可能なRAM領域の末尾をしてしておけばよいでしょう。
.text
セクションが実際のプログラムを置く場所です。
.ARM.exidx
は標準ライブラリでのみ利用するセクションなので破棄するように指示しています。
では、reset_vector
を定義してあげるところを引用します。
// The reset vector, a pointer into the reset handler
#[link_section = ".vector_table.reset_vector"]
#[no_mangle]
pub static RESET_VECTOR: unsafe extern "C" fn() -> ! = Reset;
#[link_section = ".vector_table.reset_vector"]
というアトリビュートで、メモリ上の配置をしてあげることができます。
RESET_VECTOR
という変数をそこにおいてあげるというわけですが、これがReset
関数への関数ポインタになっています。
extern "C"
というのがついていると思いますが、これはC言語の関数と同じ形式で呼び出せるようにするというものです。
関数の型ですが、() -> !
となっています。この返り値の!
というのはこの関数を実行したら決して終了することはない、ということを意味しています(発散する関数とも言います)。
C言語の関数とRustの関数はコンパイルしたときにフォーマットが異なり、C言語形式でないとマイコンはそこにジャンプしてそのまま実行ということができません。
#[no_mangle]
というアトリビュートもついています。Rustでコンパイルしたとき、変数や関数の名前はリンカースクリプト内では別の名前(シンボル)に置き換えられるマングリングという処理が行われます。これを防ぐのがこのアトリビュートの役割です。
しかし、今回はこの変数を直接指定するということをしていないので、なくても動くでしょう。
あとはReset
関数を定義すれば完成です。
#[no_mangle]
pub unsafe extern "C" fn Reset() -> ! {
let _x = 42;
// can't return so we go into an infinite loop here
loop {}
}
さて、上述のリンカスクリプトを使うためには、コンパイル時にオプションとして渡してあげる必要があります。
RUSTFLAGS
を環境変数としてセットすることで、rustc
コマンドにオプションを追加することができます。
$ RUSTFLAGS="-C link-args=-Tlink.ld" cargo build --target thumbv7em-none-eabihf
Compiling bookos v0.1.0 (/home/garasubo/workspace/bookos)
Finished dev [unoptimized + debuginfo] target(s) in 0.15s
さて、毎回このオプションを渡すのは少々面倒くさいです。.cargo/config
というファイルをつくっておくと、デフォルトのパラメータをセットできます。詳しくは公式のドキュメントを参照してください。
[target.thumbv7em-none-eabihf]
rustflags = [
"-C", "link-arg=-Tlink.ld",
]
[build]
target = "thumbv7em-none-eabihf"
こうしておけば単にcargo build
とすれば前の様々なオプションをつけたコマンドと同じ結果が得られるはずです。
プログラムをボードで実行する
ここで一度ボードにプログラムを書き込んであげたいと思います。
まずは、link.ld
の中のアドレスを修正するところから始めます。STMのリファレンスマニュアルの3.4章によるとFLASHの領域は0x800_0000
から0x81f_ffff
までの2MB領域に広がっていることがわかります。
RAM領域はリファレンスマニュアルの2.3.1を見ると0x2000_0000
から256KBの領域にあるとわかります。
しかし、STMのデータシートの方を3.6章を見ると、64KBはcore coupled memoryで使うことができず、5章のメモリマップを見ると0x2003_0000
以降の領域は使えないことがわかります。
よって、以下のように書き換えましょう。
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 2M
RAM : ORIGIN = 0x20000000, LENGTH = 192K
}
書き換えたら再度cargo build
しましょう。
プログラムをフラッシュROMに書き込むにはST-Linkというこのボードに内蔵されたデバッグ用インターフェースを利用することで可能です。
ボードには2つmicro USBの端子がついていると思いますが、切れ込みで別れた小さいエリアについている方の端子(CN1)がST-Linkと通信するための端子です。
PC側はこのインターフェースを利用するためにOpen OCDを使います。Ubuntuであればapt
コマンド経由でインストールできます。
IMAGE=target/thumbv7em-none-eabihf/debug/bookos openocd -f interface/stlink-v2-1.cfg -f target/stm32f4x.cfg -c "init; reset halt; flash write_image erase $IMAGE; verify_image $IMAGE; reset; shutdown"
-f
オプションでデフォルトで用意された設定ファイルを利用することができます。-c
オプションでコマンドを実行しています。このコマンドでボードを初期化した後、$IMAGE
で指定されたファイルを書き込んだ後、ちゃんと書き込めたかの確認もしています。
cargo build
で生成されたバイナリはtarget/<アーキテクチャ名>/debug/<アプリケーション名>
にあります。
しかし、もとのプログラムが無限ループするだけで何もしていないプログラムのため、これでは動いているかどうかすらよくわかりません。
そこで、デバッガを利用することでどのようにプログラムが動いているか覗いてみましょう。デバッガはGDB
を利用します。
Ubuntu 18.04以降ではapt
よりgdb-multiarch
を、それ以前の場合はgdb-arm-none-eabi
をインストールしましょう。
GDBには別の環境で動いているプログラムと通信してデバッグする機能があります。Open OCDはGDBと通信するためのサーバーとしての機能もあります。
ターミナルを2つ開いて、片方のターミナルでは
openocd -f interface/stlink-v2-1.cfg -f target/stm32f4x.cfg
を実行しておくとこれがGDBのサーバーとなります。 別のターミナルで
gdb-multiarch target/thumbv7em-none-eabihf/debug/bookos
を実行するとGDBが立ち上がりますが、この状態ではまだサーバーとは通信していません。target remote
コマンドでサーバーを指定したのち、プログラムを読み込んでみましょう。
(gdb) target remote :3333
Remote debugging using :3333
0x00000000 in ?? ()
(gdb) load
Loading section .vector_table, size 0x4 lma 0x8000000
Start address 0x0, load size 4
Transfer rate: 7 bytes/sec, 4 bytes/write.
(gdb) break Reset
Breakpoint 1 at 0x800000c: file src/main.rs, line 13.
これでReset関数にブレークポイントが仕掛けられました。 あとは普通にステップ実行できます。
(gdb) continue
Continuing.
Note: automatically using hardware breakpoints for read-only addresses.
Breakpoint 1, Reset () at src/main.rs:13
13 let _x = 42;
(gdb) step
16 loop {}
(gdb)
^C
Program received signal SIGINT, Interrupt.
0xe7ff9000 in ?? ()
最後は無限ループになっているので、強制的に止めました。
いざ、Hello World
プログラムは書き込めたのでHello Worldを出力していきたいと思います。
しかし、今回のボードにはディスプレイはついていません。どこにこの文字を出力すればいいのでしょうか。
一般的に、このような組込みボードと通信するインターフェースとしてUARTというモジュールがあります。これを使えばPCとマイコン間で比較的簡単に通信ができます。
しかし、今回はこれを使わずもっと手軽なセミホスティングというデバッガを介した出力でこれをやろうと思います。
この方法はあくまでデバッグ用の機能なので、デバッガをつけられない実際の製品版で用いることはできず、UARTと比べるとかなり通信速度は遅いのですが、
今回の目的はあくまでプログラムがちゃんと動作しているかの確認のためのHello Worldなので、セミホスティングを使うことにしましょう。
このセミホスティングの機能を全部理解するのも大変なので、今回は既存のライブラリを使ってしまいましょう。
RustのEmbeddedワーキンググループの提供しているクレートであるcortex-m-semihosting
を使います。
Cargo.toml
のdependency
としてこのクレートを追加します。
[dependencies]
cortex-m-semihosting = "0.3.5"
その後、src/main.rs
内のReset関数を以下のように書き換えます。
use cortex_m_semihosting::hprintln;
#[no_mangle]
pub unsafe extern "C" fn Reset() -> ! {
hprintln!("Hello World").unwrap();
loop {}
}
これでビルドをしてみると、リンクのところで失敗してしまいます。
$ cargo build
...
= note: rust-lld: error: no memory region specified for section '.rodata'
rust-lld: error: no memory region specified for section '.bss'
error: aborting due to previous error
error: Could not compile `bookos`.
To learn more, run the command again with --verbose.
.rodata
と.bss
セクションがないと言われています。ざっくり説明すると.rodata
は定数を保存しておく領域、.bss
は初期値が0のグローバル変数を保存する領域です。
これ以外にも.data
という初期値が0でないグローバル変数のための領域も実は定義をサボってきました。
これらのセクションはリンカースクリプトで定義しないといけないのはもちろんなのですが、これをちゃんとプログラムで使うためにはいくつか処理をする必要があります。
.bss
セクションは書き換えの必要があるため、RAM領域に配置されるのですが、RAM領域の初期値が0である保証はないのでこれを0にプログラム側で初期化してあげる必要があります。
.data
セクションは初期値がROM領域にあって実際の変数はRAM領域に配置されることになるので、ROM領域の初期値をRAM領域側にコピーする必要があります。
.rodata
はリードオンリーなので何もしなくて大丈夫です。
Embedonomiconと同様にReset関数の先頭で以下のような処理をしておきましょう。
use core::ptr;
pub unsafe extern "C" fn Reset() -> ! {
extern "C" {
static mut _sbss: u8;
static mut _ebss: u8;
static mut _sidata: u8;
static mut _sdata: u8;
static mut _edata: u8;
}
let count = &_ebss as *const u8 as usize - &_sbss as *const u8 as usize;
ptr::write_bytes(&mut _sbss as *mut u8, 0, count);
let count = &_edata as *const u8 as usize - &_sdata as *const u8 as usize;
ptr::copy_nonoverlapping(&_sidata as *const u8, &mut _sdata as *mut u8, count);
...
link.ld
は以下のようなセクションを加えます。
SECTIONS
{
...
.rodata :
{
*(.rodata .rodata.*);
} > FLASH
.bss (NOLOAD):
{
_sbss = .;
*(.bss .bss.*);
_ebss = .;
} > RAM
.data : AT(ADDR(.rodata) + SIZEOF(.rodata))
{
_sdata = .;
*(.data .data.*);
_edata = .;
} > RAM
_sidata = LOADADDR(.data);
/DISCARD/ :
...
link.ldで定義した定数をReset関数でextern C
で読み込んで使っています。
まず_sbss
から_ebss
の領域をptr::write_bytes
で0で初期化します。
次に.data
の初期値は_sidata
の値から始まるROM領域に存在しますがROM領域にはプログラムでは書き換え不能なので、プログラム上の配置は_sdata
から始まるRAM領域になっています。
これをptr::copy_nonoverlapping
でコピーします。
さて、これで今度はcargo buildでのコンパイルが通るはずです。
さっきと同様に、Open OCDとGDBを立ち上げてプログラムを実行すると、Open OCD側にHello Worldが出現するのが期待される結果ですが、そのためにはOpen OCDのセミホスティング機能を有効化する必要があります。
GDBを立ち上げtarget remote :3333
を実行したあとに、
(gdb) monitor arm semihosting enable
というコマンドを実行してから、continue
を実行してプログラムを再開すると、以下のようにOpen OCD側にHello Worldが出力されるはずです。
...
xPSR: 0x01000000 pc: 0x08000008 msp: 0x2001c000
in procedure 'arm'
semihosting is enabled
Hello World
割り込み制御
割り込みはOSをつくる上で重要な項目の1つです。 ペリフェラルが割り込み信号を送ることで、OSは現在の実行処理を中断してペリフェラルからの処理をすることができます。
Armでは現在の実行を中断して非同期に発生するイベントを「例外処理」と呼んでいて、割り込みもその1つです。 今回はSysTickというArmに内蔵されているタイマーモジュールを例として扱ってみようと思います。 SysTickはシステムタイマーといわれていて、OSで一定周期ごとに行う処理などを実装するモジュールです。 このモジュールはARMv7-Mでは必須のモジュールであるため、今回使うNucleoのボード以外のARMv7-Mのボードでも存在するはずです。
ARMv7-Mの例外モデル
ARMv7-MのリファレンスマニュアルB1.5章に例外処理の詳細が書かれています。 例外が発生すると、前の章で説明したVector Tableに従って処理が決定されます。 割り込みを含む例外が発生すると、例外に割り振られたIDによってVector Tableに書かれたアドレスにジャンプします。 今回扱うSysTickモジュールではB1.5.2章によると15番の例外が発生します。つまり、Vector Tableの15番目のエントリーとして呼びたい関数をおいておくことになります。
例外が発生した際、後にもとのプログラムの実行に戻るため、プロセッサの状態を一部保存する必要があります。具体的にはArmのレジスタをメモリに退避させることになります。 ここでArmのレジスタについて簡単に説明します。詳しくはB1.4章に書かれています。 ARMv7-MではR0からR15までのレジスタが使用可能です。R0からR12レジスタが汎用レジスタと呼ばれるもので、プログラム中で自由に使うことができます。 R13からR15も算術命令などで使うこともできるのですが、それぞれ特殊な意味を持つレジスタになっています。 R13がスタックポインタ(SP)になっています。現在のスタック領域のメモリアドレスを指すものです。モードによって実は2つのスタックポインタレジスタが使い分けられています。 つまり、プロセッサのモードによって一見アセンブラ命令上は同じSPでも異なるSPを指すことがあるということです。詳しくは次の章で説明します。 R14はリンクレジスタ(LR)です。これは関数の呼び出しをおこなった場合に使われるもので、その関数呼び出しから戻る先のアドレスを保存するためのレジスタです。 R15はプログラムカウンタ(PC)で、現在、実行中の命令のメモリアドレスが格納されているレジスタです。 これ以外のレジスタとしていくつかシステムレジスタが存在しています。これは通常の算術命令などではアクセスできず、特別な命令でしかアクセスできません。 そのうちの1つがプログラムスタータスレジスタ(PSR)というものです。このレジスタは32ビットのレジスタなのですが、用途に合わせて3つのレジスタに分解されるというものです。詳細はここでは割愛します。
例外が発生するとき、ハードウェアがシステムの状態を自動的にスタック領域に退避してくれます。B1.5.6章を見てみましょう。 スタックにはPSR、例外処理が完了したあとに戻るべきアドレス、LR(R14)、R12、R3からR0までが退避されます。また、浮動小数点拡張が有効になっている場合、さらに浮動小数点に関するレジスタも退避されますが、今回は有効にしていないので気にしなくても大丈夫です。 例外処理から復帰する際、これらのスタックに保存されたものが自動的にレジスタに書き戻され、スタックポインタの位置も戻される、という処理もなされます。 しかし、スタック領域に保存されない汎用レジスタが存在することに注意が必要です。これらは通常のプログラムで使用される可能性が十分にあります。 スタックポインタは通常の関数呼び出しをして戻ってくる際に元の値に戻されるはずなので問題ないでしょう。 プログラムカウンタも完了したあと戻るべきアドレスがスタック上に保存されていて、それがプログラムカウンタに書き戻されるのでこれも大丈夫です。 残りのR4からR11のレジスタが使われる可能性があります。しかし、これらのレジスタはArmの関数の呼び出し規約により、関数を呼び出しても関数側で値を戻す必要があるのでこれも保存しなくても大丈夫です。 よって、割り込みハンドラとして関数を呼び出して戻るだけなら、ハードウェア側で必要なレジスタはすべてスタックに保存される、ということになります。
割り込みハンドラ関数から通常の処理に復帰する際にはPCに通常のアドレスではなく、特殊な値を書き戻すことによって通常モードに戻ることができます。 これがB1.5.8で説明されていることです。しかし、B1.5.6章を見ると、割り込みが発生する際、LRの値がもとのモードに戻るように設定されることがわかります。 つまり、特に何もしなくても通常の処理に戻れそうです。
SysTickの割り込みハンドラを定義する
では、SysTickの割り込みハンドラを定義しましょう。今回は割り込みハンドラの中で特に特別なことはせず、適当な文字列をセミホスティングで表示させるだけにしましょう。
Embedonomiconの4章にあるコードをmain.rs
に貼り付けてそのまま使ってしまいましょう。
pub union Vector {
reserved: u32,
handler: unsafe extern "C" fn(),
}
extern "C" {
fn NMI();
fn HardFault();
fn MemManage();
fn BusFault();
fn UsageFault();
fn SVCall();
fn PendSV();
fn SysTick();
}
#[link_section = ".vector_table.exceptions"]
#[no_mangle]
pub static EXCEPTIONS: [Vector; 14] = [
Vector { handler: NMI },
Vector { handler: HardFault },
Vector { handler: MemManage },
Vector { handler: BusFault },
Vector {
handler: UsageFault,
},
Vector { reserved: 0 },
Vector { reserved: 0 },
Vector { reserved: 0 },
Vector { reserved: 0 },
Vector { handler: SVCall },
Vector { reserved: 0 },
Vector { reserved: 0 },
Vector { handler: PendSV },
Vector { handler: SysTick },
];
#[no_mangle]
pub extern "C" fn DefaultExceptionHandler() {
loop {}
}
extern C
によってSysTick関数やそれ以外の例外ハンドル用の関数を宣言しています。
これはC言語などで書かれた関数を呼び出すものです。よって、リンクする際にどこかから引っ張ってこなければなりません。
そのため、Embedonomiconでは以下のようなセクションをリンカスクリプトに加えています。
PROVIDE(NMI = DefaultExceptionHandler);
PROVIDE(HardFault = DefaultExceptionHandler);
PROVIDE(MemManage = DefaultExceptionHandler);
PROVIDE(BusFault = DefaultExceptionHandler);
PROVIDE(UsageFault = DefaultExceptionHandler);
PROVIDE(SVCall = DefaultExceptionHandler);
PROVIDE(PendSV = DefaultExceptionHandler);
PROVIDE(SysTick = DefaultExceptionHandler);
これは、該当する関数が見つからなかった場合、DefaultExceptionHandler
を代わりに使うという文になっています。
さらに、EXCEPTIONS
をRESET_VECTOR
直後に置くため、.vector_table
セクションも以下のように書き換える必要があります。
.vector_table ORIGIN(FLASH) :
{
LONG(ORIGIN(RAM) + LENGTH(RAM));
KEEP(*(.vector_table.reset_vector));
KEEP(*(.vector_table.exceptions));
} > FLASH
これで、とりあえず、ビルドは通りますが、実行してもSysTick
関数を定義していないので、ただの無限ループであるDefaultExceptionHandler
を使うだけなのでなにもわかりません。
そのうえ、まだSysTick割り込みを発生させるためのSysTickモジュールの設定もしていないので、これも行う必要があります。
それではまず、SysTick関数を定義しておきましょう。この関数はあとでリンカスクリプトから参照され、割り込みが発生したときC言語の関数呼び出しのように呼ばれるので、
DefaultExceptionHandler
と同様にno_mangle
とextern "C"
をつけておく必要がある点に注意しましょう。
#[no_mangle]
pub extern "C" fn SysTick() {
hprintln!("Systick").unwrap();
}
この関数を定義したあと、先程加えたextern "C"
で各種例外ハンドル用関数を宣言していたところからSysTick
の部分を取り除く必要もあります。
SysTickモジュールを定義する
SysTickの例外ハンドラが定義できたところで、実際のSysTick例外を発生させるところにチャレンジしましょう。
ところで、今まですべての関数をすべてmain.rs
に書いてきてしまいました。Embedonmiconではいくつかのサブプロジェクトにわけてクレートとして分離する、という方法をとってコードを整理しています。
同じようにクレートとして分離するのも悪くないのですが、今回はお手軽にモジュールという形で分離しましょう。
詳しくはTPRLの7章が参考になると思います。
このモジュールのシステムは2018 Editionで大きく変更がされているため、個人のブログなどで2015 Editionをベースに解説してる場合があることに注意が必要です。
ただし、後方互換性はあるので、2015 Editionに沿った方法でモジュールを分割しても大きな問題にはなりません。
src
ディレクトリ以下にsystick.rs
という以下のような関数を含んだファイルをつくりましょう。
use cortex_m_semihosting::hprintln;
pub fn init() {
hprintln!("Systick init").unwrap();
}
続いて、main.rs
内のReset
関数を以下のように書き換えます。
systick
モジュールの宣言と、systick
モジュール内のinit
関数を呼び出しています。
mod systick;
#[no_mangle]
pub unsafe extern "C" fn Reset() -> ! {
extern "C" {
static mut _sbss: u8;
static mut _ebss: u8;
static mut _sidata: u8;
static mut _sdata: u8;
static mut _edata: u8;
}
let count = &_ebss as *const u8 as usize - &_sbss as *const u8 as usize;
ptr::write_bytes(&mut _sbss as *mut u8, 0, count);
let count = &_edata as *const u8 as usize - &_sdata as *const u8 as usize;
ptr::copy_nonoverlapping(&_sidata as *const u8, &mut _sdata as *mut u8, count);
hprintln!("Hello World").unwrap();
systick::init();
loop {}
}
ここまでで一度ビルドして実行すると新たにSystick init
という文字列が表示されるはずです。
ここからSysTickに関係するコードはこのsystick.rs
内に書いていき、systick
モジュールとしてmain.rs
から使いましょう。
SysTickのレジスタを設定する
SysTickを制御するにはSysTickのレジスタを叩いてあげる必要があります。 ArmではこのようなモジュールやペリフェラルのレジスタをメモリマップドIOという形で読み書きします。 プログラム側からは普通のメモリアクセスと同じようにできるという点で便利な仕組みです。 メモリマップドIOとは、このような外部のデバイスのレジスタをメモリアドレスに割り当てて、メモリアクセスのようにレジスタを読み書きする仕組みです。 ただし、これらのレジスタの値の読み書きは本物のメモリとは違って例えば、一部のビットが書き込み不可であるアドレスに書き込んだあとすぐに読み込んだら値が一致しないとか、何も書き込んでいないのに時間が立つと値が変わっている、などの現象が発生します。 そのため、コンパイラが普通のメモリアクセスと同じような最適化をかけてしまうと正しく動作しない場合があります。ここには注意しましょう。
SysTickの詳しい説明はリファレンスマニュアルのB3.3章にまとまっています。 SysTickを使うにはコントロールレジスタ(CSR)、リロードバリューレジスタ(RVR)、カレントバリューレジスタ(CVR)、キャリブレーションバリューレジスタ(CALIB)の3つを触ることになります。 CSRはこのSysTickを有効化させたり、割り込み発生をさせるかどうかの制御などをするためのものです。 CVRは現在のタイマーの値で時間経過とともに値が減っていきます。この値が0になると割り込みを発生させることができます。 0になったあとはRVRで設定された値になります。つまり、RVRの値が割り込みの周期ということになります。 さて、このCVRがどのくらい時間で値が減っていくかですが、CALIBレジスタにその値が記されています。 CALIBには10ミリ秒ごとにどの程度値が減るかの値が設定されるリードオンリーレジスタです。この下位24ビットの値を見てあげれば、RVRやCVRを適切に設定できます。 これらのレジスタがどこにマップされているかもこのリファレンスマニュアルのB3.3.2章に書かれています。
さて、あとはこれらのレジスタを使うだけです。これらのメモリアドレスにアクセスするには標準ライブラリのcore::ptr
内にあるread_volatile
とwrite_volatile
を使います。
volatileというのは揮発性のという意味で、C言語などでは変数の修飾子としてつけることで、その変数へのアクセスの最適化をさせないようにすることができます。
同様にread_volatile
やwrite_volatile
も最適化によって変更されてほしくないアクセス、つまり今回のようなメモリマップドIOに使える関数です。
今回は1秒毎に割り込みが発生するように設定してみましょう。systick.rs
内のinit
関数を以下のように書き換えましょう。
use cortex_m_semihosting::hprintln;
use core::ptr::{read_volatile, write_volatile};
const CSR_ADDR: usize = 0xE000_E010;
const RVR_ADDR: usize = 0xE000_E014;
const CVR_ADDR: usize = 0xE000_E018;
const CALIB_ADDR: usize = 0xE000_E01C;
pub fn init() {
hprintln!("Systick init").unwrap();
unsafe {
write_volatile(CVR_ADDR as *mut u32, 0);
let calib_val = read_volatile(CALIB_ADDR as *const u32) & 0x00FF_FFFF;
write_volatile(RVR_ADDR as *mut u32, calib_val * 100);
write_volatile(CSR_ADDR as *mut u32, 0x3);
}
}
read_volatile
及びwrite_volatile
はともにunsafe
な関数なのでunsafe
ブロックで囲う必要があります。
なお、アドレスを渡す際にusize
型をポインタにキャストしている部分がありますが、この操作自体はRustでは安全とされています。
ただし、このアドレスをいざ読み書きする段階になると、この先にあるアドレスがちゃんとしたものになっているかの保証もないですし、ライフタイムのチェックもないのでunsafe
となるわけです。
さて、これでコンパイルすると、今までのメッセージに加えてSysTickハンドラが1秒毎に呼び出されてメッセージが表示されているはずです。
また、デバッガで動作を止めると、Reset
関数自体は最後の無限ループで止まっているのがわかります。
プロセス切り替え
この章では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
...
ここまでで、アプリケーションプロセスへのコンテキストスイッチが実装できましたが、このアプリケーションプロセスを複数動かす、ということはまだできません。 次の章で実装していきましょう。
スケジューラを実装する
前回の章でアプリケーションプロセスへの切り替えを実装しました。 しかし、現状だと再びアプリケーションに突入したり、複数のアプリケーションを動かしたり、といったことができていません。 この章では、簡単なラウンドロビン型のスケジューラを実装して複数のアプリケーションを動かすことを目標としましょう。
スケジューラを実装するには前章まででやってきたアプリケーションプロセスへの切り替えに加えて以下のことが必要です。
- 次に実行可能なプロセスを指すための構造体を実装する
- プロセスの状態をメモリ上に保管する構造体とその保存ロジックの実装
順番にやっていきましょう
連結リストを実装する
スケジューラを実装するに当たって、実行中のプロセスの情報を連結リストの形式で持っておきたいです。
今回実装するスケジューラでは、実行が終わったプロセスを末尾に追加して、先頭から次に実行するプロセスを取ってくるため連結リストを利用したいです。
しかしながら、no_std
のプログラミングではお馴染みの動的配列であるVec
やLinkedList
は使えないので似たような構造体がほしいです。
では、連結リストをlinked_list.rs
内に実装してみましょう。
C言語などで実装したことがある人がいるとは思いますが、普通はおおよそこんな感じになるでしょう。
- リスト内に入る各アイテムは、そのアイテムの実際の値と次のアイテムへのポインタを持つ
- リスト構造体そのものは先頭要素へのポインタと末尾要素へのポインタを持つ
- 新しいアイテムを追加するときは現在の末尾要素の次に追加して、リストの持つ末尾要素ポインタを更新
- 先頭から要素を取り出すには先頭要素ポインタから取り出して、次の要素をリストの持つ先頭要素ポインタに更新
Rustでは生ポインタもありますが、通常であればポインタよりも参照を用いた実装が好まれます。 しかし、先程あげたような機能を持つ連結リストは参照では実装は困難です。実際に見てみましょう。
まず、リストそのものと、リストに格納される各要素の定義は以下のような感じになるでしょう。
pub struct ListItem<'a, T> {
value: T,
next: Option<&'a mut ListItem<'a, T>>,
}
pub struct LinkedList<'a, T> {
head: Option<&'a mut ListItem<'a, T>>,
last: Option<&'a mut ListItem<'a, T>>,
}
試しに末尾に新しい要素を追加するというメソッドを実装してみましょう。素直に実装するとこんな感じでしょうか。
impl<'a, T> LinkedList<'a, T> {
pub fn push(&mut self, item: &'a mut ListItem<'a, T>) {
if self.last.is_none() {
self.last = Some(item);
self.head = Some(item);
} else {
let prev_last = self.last.replace(item);
prev_last.map(|i| i.next = Some(item));
}
}
}
しかし、これはコンパイルエラーになります。なぜなら、item
はミュータブルな参照なのにもかかわらず、
self.last
とself.head
ないし1つ手前のListItem
のnext
の2つのメンバーに保持される必要があります。
ミュータブルな参照は複数存在できないので、このような形では素直には実装できないです。
では、標準ライブラリではどのように実装されているかというと、生ポインタを使うことで解決しています。
確かに生ポインタを使うことは安全ではないのですが、標準ライブラリ内部ではしばしばunsafe
な部分が存在しています。
その代わり提供するインターフェスは生ポインタを使うことがないようにして安全でない部分を限定している、というわけです。
そういうことで、ここでは生ポインタを使うことにしましょう。その代わり、このモジュールにはテストを書いておき、きちんとバグがないよう確認しておきましょう。
まず、テストとしては以下のようなものを書いておきましょう。
#[cfg(test)]
mod test {
use ListItem;
use LinkedList;
#[test]
fn test_list() {
let mut item1 = ListItem::new(1);
let mut item2 = ListItem::new(2);
let mut item3 = ListItem::new(3);
let mut list = LinkedList::new();
list.push(&mut item1);
list.push(&mut item2);
list.push(&mut item3);
assert_eq!(Some(&mut 1), list.head_mut());
let result1: &u32 = list.pop().unwrap();
assert_eq!(Some(&mut 2), list.head_mut());
let result2: &u32 = list.pop().unwrap();
assert_eq!(Some(&mut 3), list.head_mut());
let result3: &u32 = list.pop().unwrap();
assert_eq!(1, *result1);
assert_eq!(2, *result2);
assert_eq!(3, *result3);
assert!(list.is_empty());
let mut item4 = ListItem::new(4);
let mut item5 = ListItem::new(5);
list.push(&mut item4);
list.push(&mut item5);
let result4: &u32 = list.pop().unwrap();
let result5: &u32 = list.pop().unwrap();
assert_eq!(4, *result4);
assert_eq!(5, *result5);
assert!(list.is_empty());
}
}
最低限のテストではありますが、とりあえず必要なものは見えてきたと思います。
push
:リストの末尾に要素を追加するpop
:先頭要素を取り出す。返り値はOption
でラップしてリストが空ならNone
が返るhead_mut
: 先頭要素のミュータブルな参照をOption
でラップして返す。リストそのものは変更しない。is_empty
:リストの中に要素が存在するかどうか
list.pop().unwrap()
の結果を&u32
として変数に束縛していますが、これはListItem<'a, T>
に対してDeref
を実装していることを想定しています。
以降、生ポインタを使っての実際の実装を見せていきますが、ある程度すでにRustに習熟しているのであれば、この仕様を満たすように自力で実装するのもいいでしょう。
以下に示すのはあくまで一例です。また、実装の踏み込んだ解説はあまりしないでおきます。
まずは構造体そのもの定義を変えましょう。参照はすべてポインタに置き換えてしまうのが一番愚直なやりかただと思うので、そうします(もちろん一部を参照を残すこともできます)。
use core::ptr::NonNull;
use core::marker::PhantomData;
pub struct ListItem<'a, T> {
value: T,
next: Option<NonNull<ListItem<'a, T>>>,
marker: PhantomData<&'a ListItem<'a, T>>,
}
pub struct LinkedList<'a, T> {
head: Option<NonNull<ListItem<'a, T>>>,
last: Option<NonNull<ListItem<'a, T>>>,
marker: PhantomData<&'a ListItem<'a, T>>,
}
NonNull
とPhantomData
という構造体を使っています。
NonNull
はnon-nullでないポインタを意味します。普通の*mut T
に対して0ではないという制約がついているわけですが、これによりOption
でくくったときに0という値をNone
として使うようにコンパイルされるため、Option<NonNull<T>>
と*mut T
のコンパイル後のサイズが同じという利点が生まれます。
すべてをポインタに置き換えてしまうとライフタイムの情報がなくなってしまいます。コンパイル時にこれらの情報を失わないようにPhantomData
を使ってます。
これはコンパイルされるとサイズが全くないものになるのですが、実際のメンバーのように振る舞うことで型引数をさも要求しているようにコンパイラに見せかけるための構造体です。
詳しくは公式ドキュメントを参照してください。
関数の実装は以下のようにしてみました。
impl<'a, T> ListItem<'a, T> {
pub fn new(value: T) -> Self {
ListItem {
value,
next: None,
marker: PhantomData,
}
}
}
impl<'a, T> LinkedList<'a, T> {
pub fn new() -> Self {
LinkedList {
head: None,
last: None,
marker: PhantomData,
}
}
pub fn push(&mut self, item: &'a mut ListItem<'a, T>) {
let ptr = unsafe { NonNull::new_unchecked(item as *mut ListItem<T>) };
let prev_last = self.last.replace(ptr);
if prev_last.is_none() {
self.head = Some(ptr);
} else {
prev_last.map(|mut i| unsafe {
i.as_mut().next = Some(ptr);
});
}
}
pub fn is_empty(&self) -> bool {
self.head.is_none()
}
pub fn head_mut(&mut self)-> Option<&mut T> {
self.head.map(|ptr| unsafe { &mut *ptr.as_ptr() }.deref_mut())
}
pub fn pop(&mut self) -> Option<&'a mut ListItem<'a, T>> {
let result = self.head.take();
let next = result.and_then(|mut ptr| unsafe {
ptr.as_mut().next
});
if next.is_none() {
self.last = None;
}
self.head = next;
result.map(|ptr| unsafe { &mut *ptr.as_ptr() })
}
}
pop
の返り値がT
の参照でなくListItem
の参照なのはpop
で返ってきた構造体を再度リストに追加する、ということができるようにするためです。
push
がT
でなくListItem
の参照を引数に取っているのは少々美しくないようにも思えます。しかし、現状動的なメモリ確保の方法を実装していません。
そのため、ListItem
のためのメモリ領域を確保することができないのでほかから渡してもらう必要があるというわけです。
構造体のメンバーをすべてプライベートにしてしまっているので、ListItem
からvalueの値を取り出すことがこのままではできません。
新たなメソッドを追加するのもいいですが、Deref
を実装するという方法で実現してみましょう。
use core::ops::{Deref, DerefMut};
impl<'a, T> Deref for ListItem<'a, T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl<'a, T> DerefMut for ListItem<'a, T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.value
}
}
ここまで書いたところでテストを走らせたいのですが、このプロジェクト全体が標準ライブラリなしでArmへのクロスコンパイルを前提として書かれていることもあり、このままcargo test
を実行しても正常に動きません。
しかし、このモジュール単体はアーキテクチャへの依存がないため、このプロジェクトは切り離してのテストが可能です。
手軽な方法としてはrustc --test
でこのファイルのみをテストしてしまうことでしょうか。その際、このファイルの冒頭に#![no_std]
をつける必要があります。
ただし、モジュールに本来クレートレベルのアトリビュートであるno_std
をつけてしまうと、プロジェクト全体をコンパイルしたときに警告が出てしまいます。
他の方法としては、これを別クレートとして新しいプロジェクトに切り出してしまうことです。これならば、その新しくつくったプロジェクト上でcargo test
を実行すればいいです。
この方法ならば、コンパイル時の警告もないので、よりまっとうな方法になるでしょう。
プロセスの状態を保存する
次にプロセス状態を保存する構造体を定義しましょう。 プロセスの状態としてまず、そのプロセスで使うスタックポインタは保存しなければなりません。 さらに保存しなければならないのは、スタックに退避されないレジスタたちです。
スタックポインタの値はusizeとして、退避されていないレジスタはまとめてu32
型の配列として持っておきましょう。
pub struct Process<'a> {
sp: usize,
regs: [u32; 8],
marker: PhantomData<&'a u8>,
}
プロセスをつくる関数もつくりましょう。スタック用の領域とプロセスの中で実行したい関数を渡したらプロセス構造体が返ってくるとよさそうですね。 その際、スタック領域の初期化処理もしてしまいましょう。
impl<'a> Process<'a> {
pub fn new(stack: &'a mut [u8], app_main: extern "C" fn() -> !) -> Self {
let sp = (&stack[0] as *const u8 as usize) + stack.len() - 0x20;
let context_freame: &mut ContextFrame = unsafe { &mut *(sp as *mut ContextFrame) };
context_freame.r0 = 0;
context_freame.r1 = 0;
context_freame.r2 = 0;
context_freame.r3 = 0;
context_freame.r12 = 0;
context_freame.lr = 0;
context_freame.return_addr = app_main as u32;
context_freame.xpsr = 0x0100_0000;
Process {
sp: sp as *mut u8,
regs: [0; 8],
marker: PhantomData,
}
}
}
このプロセスを実行させるコードも実装しましょう。
プロセスは割り込みが発生すると中断する可能性があります。それを考慮して実装する必要があります。
前章で実装したプロセススタックポインタの書き込みの他に、ContextFrame
の中に保存されていないレジスタの書き戻しをする必要があります。
これは中断されたプロセスを再開するためのものです。
また、カーネルに復帰した際、その時点でのプロセススタックポインタとContextFrame
の中に保存されていないレジスタを保存する処理も必要です。
pub fn exec(&mut self) {
unsafe {
llvm_asm!(
"
msr psp, r0
ldmia r1, {r4-r11}
svc 0
stmia r1, {r4-r11}
mrs r0, psp
"
:"={r0}"(self.sp)
:"{r0}"(self.sp), "{r1}"(&self.regs)
:"r4", "r5", "r6", "r7", "r8", "r9", "r10", "r11"
:"volatile"
);
}
}
ldmaia
とstmia
は複数のレジスタの値をまとめてメモリからロード・ストアするための命令です。
スタックポインタ上にレジスタを退避させたりするのに使われます。r1
がメモリアドレスになり、この命令後に本来は書き換えられるのですが、
ldmai
後にstmia
を実行しているので変化した分がすぐに戻されているため、変化が打ち消されているため値の変化はトータルではありません。
mrs
がmsr
の逆でシステムレジスタを読み出すための命令です。
ここまでできたら、前回実装したプロセス切り替え部分のコードをこれらのコードで置き換えてみましょう。
APP_STACK
を定義したあとのコードを以下のように書き換えてみましょう。
#[link_section = ".app_stack"]
static mut APP_STACK: [u8; 2048] = [0; 2048];
let mut process = Process::new(&mut APP_STACK, app_main);
process.exec();
hprintln!("Kernel").unwrap();
ここまでできたら一度コンパイルしてみて動作確認してみましょう。
スケジューラ本体の実装
必要なパーツは全て揃ったところで、これらを組み合わせてスケジューラを実装しましょう。
今回実装するのは登録されたプロセスを順番に実行していくだけのラウンドロビン型のスケジューラです。
なお、プロセスが終了してメモリが開放されることは今回は考えないことにします。
scheduler.rs
の中に実装していきましょう。
まず、スケジューラの構造体のメンバーとして保持しておきたいのは実行したいプロセスの連結リストです。
use crate::linked_list::{LinkedList, ListItem};
use crate::process::Process;
pub struct Scheduler<'a> {
list: LinkedList<'a, Process<'a>>,
}
実装する関数としては、コンストラクタの他にプロセスの追加関数と、スケジューラ内の関数を実行し続ける関数を実装しましょう。
impl<'a> Scheduler<'a> {
pub fn new() -> Self {
Scheduler {
list: LinkedList::new(),
}
}
pub fn push(&mut self, item: &'a mut ListItem<'a, Process<'a>>) {
self.list.push(item);
}
fn schedule_next(&mut self) {
let current = self.list.pop().unwrap();
self.list.push(current);
}
pub fn exec(&mut self) -> ! {
loop {
let current = self.list.head_mut();
if current.is_none() {
unimplemented!();
}
current.map(|p| {
p.exec();
});
self.schedule_next();
}
}
}
exec
が実行用の関数です。リストの先頭要素を実行して、実行が中断されるとリストから一度取り出され、末尾に再度追加されます。
各プロセスが実行を中断するには現状はプロセスが自発的にsvc
命令を発行するしかありません。
本来であれば、OSが何らかの例外が発生したら切り替えるなどの処理を実装することが多いです(例えばタイマー割り込みで一定時間以上実行していたら切り替える)。が、今回は何もしないことにしましょう。
では、このスケジューラの動作確認のために、簡単なプロセスを3つ用意して、それらをスケジューリングしてみましょう。
まず、各プロセス用の関数ですが、app_main
と同じように適当な文字を出力させる関数を追加で2つ用意しましょう。
ただし、プロセスが再開したあとなにもしない無限ループを実行されるとなにもわからなくなるので、文字出力とsvc
をひたすら繰り返す、というものにしましょう。
extern "C" fn app_main2() -> ! {
loop {
hprintln!("App2").unwrap();
unsafe { asm!("svc 0"::::"volatile"); }
}
}
extern "C" fn app_main3() -> ! {
loop {
hprintln!("App3").unwrap();
unsafe { asm!("svc 0"::::"volatile"); }
}
}
また、app_main
も以下のように書き換えましょう。
extern "C" fn app_main() -> ! {
let mut i = 0;
loop {
hprintln!("App: {}", i).unwrap();
unsafe { asm!("svc 0"::::"volatile"); }
i += 1;
}
}
あとは、Reset
関数の末尾を書き換えてスケジューラにプロセス3つを追加しましょう。
まずは、モジュールから使うものを以下のように宣言しておきます。
mod linked_list;
use linked_list::ListItem;
use process::Process;
mod scheduler;
use scheduler::Scheduler;
あとは、Process
をListItem
でラップしたものを3つつくり、これをスケジューラの中に入れて実行するだけです。
なお、Scheduler::exec
自身が発散する関数なので、最後の無限ループも取り除くことができます。
#[link_section = ".app_stack"]
static mut APP_STACK: [u8; 2048] = [0; 2048];
#[link_section = ".app_stack"]
static mut APP_STACK2: [u8; 2048] = [0; 2048];
#[link_section = ".app_stack"]
static mut APP_STACK3: [u8; 2048] = [0; 2048];
let process1 = Process::new(&mut APP_STACK, app_main);
let mut item1 = ListItem::new(process1);
let process2 = Process::new(&mut APP_STACK2, app_main2);
let mut item2 = ListItem::new(process2);
let process3 = Process::new(&mut APP_STACK3, app_main3);
let mut item3 = ListItem::new(process3);
let mut sched = Scheduler::new();
sched.push(&mut item1);
sched.push(&mut item2);
sched.push(&mut item3);
sched.exec();
}
こうしてビルドすれば以下のような実行結果が得られるはずです。
Hello World
Systick init
App: 0
App2
App3
App: 1
App2
App3
App: 2
App2
App3
App: 3
App2
...
なお、Systick割り込みがなかなか表示されないことに気がつくと思いますが、これはSystickのタイマーがデバッグ状態では値が減らないためであり、
デバッグ状態を利用するセミホスティングを利用した出力をたくさんしていると現実の時間が進んでいても、Systickのタイマーが減らないという事態が起きているからです。
試しにhprintln
命令を取り除いてあげるとちゃんとSystickが呼び出されるようになるはずです。
このドキュメントを執筆するにあたってハードウェアのマニュアル以外に参考にしたもの、 参考にはしていないが、この先自作OSをつくりこむにあたって参考になるであろう書籍やドキュメントを紹介します。
The Embednomicon
Rust Embeddedワーキンググループが公開しているRustでベアメタルプログラミングをするための解説ドキュメントです。 このドキュメントもこのドキュメントがベースになっています。より踏み込んだロギングやDMAのためのテクニックも解説しています。
The Embedded Rust Book
こちらもRust Embeddedワーキンググループが公開しているRustでベアメタルプログラミングをするための解説ドキュメントですが、Rust Embeddedワーキンググループが用意したクレートを活用してのプログラミングです。 今回はあえてこれらのクレートは使わないような実装にしましたが、特にペリフェラルのドライバを書くのに参考になると思います。 組込みプログラミングそのものの経験があまりないようであればDiscovery Bookがいいかもしれません。 使うボードがDiscoveryボードという、このドキュメントでつかったものとは少々違うのですが、同じSTM32のマイコンであるため、ペリフェラルの仕様も似通っています。
Andrew S. Tanenbaum、Herbert Bos 「Modern Operating Systems: Global Edition」
マイクロカーネルで有名なタネンバウム先生らが書いたOSの有名な教科書です。古いエディションであれば日本語版もあります。 かなり量も多いのですが、OSの基本的な概念を幅広く網羅されています。
Writing an OS in Rust
x86系のプロセッサを対象にしたものですが、RustでOSを書くチュートリアルになっています。 x86系固有の知識以外にも、エミュレータを組み合わせた統合テストの書き方や動的なメモリ確保のアルゴリズムの実装なども解説されているので、それ以外のプロセッサでも参考になります。
組込み/ベアメタルRustクックブック
Rustで組込みプログラミングをするのに有用なテクニックが幅広く紹介されています。日本語のドキュメントです。
この先の開発
このドキュメントで扱ったのは、スケジューリングの初歩の初歩まででです。 すでに世の中に出回っているOSと比べると機能はまだまだお粗末なものです。 また、実装の仕方もあまり洗練されたものとは言えないでしょう。 では、この先よりOSらしいものを実装するとなるとどういう機能を実装していけばいいのか。 そのヒントとなるトピックをいくつか取り上げてみます。
リアルタイムスケジューリング
組込みアプリケーションで大事なことのひとつとして「リアルタイム性」というものがあります。 これは決められた処理を一定の時間内に行う、という性質です。 このリアルタイム性にはスケジューリングが重要な役割を果たします。 通常のデスクトップOSでは細かいデッドラインが各アプリケーションに対して決まっている、というわけではなく、 複数のアプリケーションが均等に実行できるようなことが重視され(もちろん優先度を決めたりすることでリアルタイム性を担保する仕組みもありますが)、 組込みシステムではこのリアルタイム性がより重視されることが多く、スケジューリングのアルゴリズムもデスクトップのものとは異なってきます。
いくつかリアルタイム性を担保する上での課題を紹介していきます。
まず、プロセスにこれは最優先で処理すべきものと、そうでもないもの、のような差が存在します。 例えば、歩行ロボットがバランスを取るために駆動部を動かすための計算をして指示を出す、というプロセスはきちんと決まった時間内で実行しないと転倒してしまうことになりますが、 目を光らせるとか、人と対話をするといったためのプロセスは多少の遅れがあっても深刻な影響はでないであろう、といった感じです。 この歩行ロボットの例ではプロセスに固定された優先度を付与しておき、優先度が高い用のプロセスのリストと低い用のリストを持っておき、前者のリストが空になってから後者のリストからプロセスをスケジュールする、といった方法を取れば解決できそうですね。
ロボットの例は静的に優先度が決定できる、というものですが、実行状況や時刻によってこの優先度が動的に変動する場合もあります。 リアルタイムシステムにはタイマーなどにより定期的に実行されるプロセスと、ボタンを押したあとに発生するなど外部からのイベントによって不定期に実行する必要があるプロセス、というものが存在します。 つまり、プロセスのスタートするタイミングや締め切りは変動しうるということです。 そのことを踏まえ例えば、締切が3分後だが実行には2分かかるプロセスAと締切が10分後だが実行には7分かかるプロセスBが同時に発生した場合、後者のプロセスBから実行してしまうとAの締切に間に合いません。 このような場合、締切が近いプロセスAから実行することによってすべてのプロセスが締め切りに間に合うようにしてあげる必要があります。 しかし、このプロセスAがプロセスBの締め切り2分前で実行が残り1分かかる、という段階で発生した場合、まずプロセスBを終わらせてからプロセスAを実行してもプロセスAの締切には間に合うので このような場合はプロセスBから実行しなければなりません。
プロセス間で資源を取り合っていると問題はもっと複雑になることもあります。 例えばネットワーク通信が必要な締め切りが厳しいプロセスAと、ネットワーク通信を使うが締め切りを守らなくてもいいプロセスB、ネットワーク通信は使わないが締め切りが厳しいプロセスCが存在するとします。 プロセスBが実行してネットワーク通信をおこなっているときにプロセスCを実行する必要が出てきたとします。 この場合、ネットワーク通信を一時中断してプロセスCを実行しなければなりません。ここでプロセスAを実行しなければならなくなり、しかもプロセスCよりも締め切りが近いので最優先に実行したいとします。 プロセスAはネットワーク通信をしたいのですが、そのためにはまず、中断されている通信を終了させる必要があります。そうすると、本来優先度が低いはずのプロセスCから実行しなければならない、ということになってしまいます。 そのため、このようにデバイスなどの共有資源を持っている場合、共有先のプロセスの優先度を継承して実行させる、といった仕組みを持つ場合もあります。
このように、リアルタイム性という性質を満たすにはこのドキュメントで実装したラウンドロビン型のスケジューラでは解決できない課題が山のようにあるわけです。 また、これらの課題を解決するために長い時間をかけて計算するわけにはいかないので、OS内で実装するアルゴリズムの計算量にも気をつかう必要もあります。
デバイスドライバ
このドキュメントではほとんど使いませんでしたが、本来はマイコンボードについているペリフェラルを使ってシステムを構築します。 例えば、マイコンボードでのプログラミングの代名詞であるLチカにはLEDを動かすためのGPIOというモジュールを操作しなければなりませんが、 OSはこれらのペリフェラルへの簡単なプログラミングインターフェースを提供することも期待されています。
これらペリフェラルのためのデバイスドライバを書くにあたって、参考文献でも紹介していますが、Rust EmbeddedワーキンググループのDiscovery Bookなどのドキュメントは参考になるかもしれません。 これはRust Embeddedグループの提供するクレートやツールを用いてRustの性質を活かしたデバイスへのアクセスの仕方を解説しています。
ヒープアロケータ
ベアメタルプログラミングではmallocのようなプログラム実行時に動的にメモリを確保することができず、スタック領域や静的に確保した領域を利用してプログラミングする必要があります。 動的なメモリ確保は本来OSの仕事だからです。そのため、自作OSにおいても動的メモリ確保のための機能を実装するのは大事なトピックです。 この動的メモリ確保用のメモリ領域をヒープと呼び、ここからメモリ領域を確保したり使い終わったメモリをヒープに戻すプログラムをヒープアロケーターといいます。
ヒープアロケーターの実装方法はいろいろありますが、代表的なアロケーターの実装を参考文献でものせたWriting an OS in Rustで紹介されています。 実はRustはこのヒープアロケータを言語標準のものでなく自前で実装したものに置き換えるための仕組みが用意されています。 詳しくはRustの公式ドキュメントのGlobalAllocを参考にしてください。 この方法に従いヒープアロケータを実装しておくことで、stdで使える一部の構造体が含まれるallocクレートも利用可能になります。
OS上で動かすアプリケーション以外でもOSそのものの中でヒープアロケーションの機能を利用することは当然できますが、 動的なメモリ確保は失敗する可能性があり、またアルゴリズムによっては実行時間が長くなる危険性もあることには注意しましょう。
テスト
TRPLのテストの章で紹介されているように、Rustには自動テストのための機能が標準で備わっています。
しかしながら、少し触れたようにこの仕組みをno_std
環境でそのまま用いることはできません。
テストを書く方法はいくつか存在します。1つはクレートとして一部のモジュールを別プロジェクトに切り出してしまうことです。
このドキュメントの例で言うと連結リストモジュールは別クレートとして切り出すことができるので、これに対してテストを書くということができます。
しかし、このOSプロジェクトそのものにテストを書くのもnightlyのcustom_test_frameworks
機能を使えば可能です。
x86系のアーキテクチャをベースとした解説ですが、Writing an OS in RustのTestingの章で詳しく解説されています。
Cortex-MのようなアーキテクチャでもQEMUを用いたエミュレーションができるので、同様の方針である程度テストを書くことができるでしょう。
外部クレートの活用
自作OSというと、様々なコンポーネントを自分の手で書いていく、というのが醍醐味の1つでもあります。 しかしながら、Rustにはcargoを用いて外部のライブラリを簡単に導入できる仕組みがあるので、これを利用して既存のコードに依存してしまうのも1つの手だと筆者は考えています。 もちろん、実際のOSだと信頼性の担保が難しいということで、外部のコンポーネントの導入に消極的であったりという事情はありますが、趣味の範囲であればそこまで深く考える必要はないでしょう。
ただし、実際の多くのクレートはstdの存在を仮定するのもが多く、導入する際は"No Standard Library"のカテゴリがついているかに注意が必要です。
このドキュメントでも利用したcortex-m-semihosting
やEmbeddedグループの公開しているデバイスドライバなどのクレート、ヒープアロケータを提供するクレートなどは組み込んでみるとできることが一気に増えるでしょう。
その他にもいくつか自作OSの作成に役に立つクレートを紹介します。
- heapless:ヒープ領域を必要としない、いくつかの著名なデータ構造を提供するためのライブラリ
- volatile-register:メモリ領域をvolatileとしてアクセスするためのインターフェースを提供するライブラリです。デバイスドライバを自作する際に重宝するでしょう
- tock-register:Tockという組込みOSでの成果物でもある、MMIO領域のより強力な抽象化を提供してくれるライブラリ
- aligned:アラインメントが保証されたデータタイプを提供するライブラリ
更新履歴
メジャーな更新についてのリストです。誤字・脱字などの軽微な修正は含みません
2021/01/07
コンパイラのバージョンを更新し、それに合わせてインラインアセンブラについての記述も更新
2021/01/06
「更新履歴」の章を追加
2020/05/14
「参考文献」、「この先の開発」の章を追加
2020/05/11
スケジューラの章を追加
2020/05/02
公開開始