RISC-V
アプリケーションを開発する際に「どのOSで動かすか」を意識するのと同じように、OSもその下のレイヤであるハードウェア (特にCPU) で動かすかを考える必要があります。本書では、RISC-V (リスク・ファイブ) を次の理由から選択しました。
- 仕様がシンプルで初学者向きである。
- 近年よく話題に上がるCPU (命令セットアーキテクチャ) である。
- 仕様書でところどころ述べられている「なぜこの設計にしたのか」の説明が面白く、勉強になる。
なお、本書では32ビットのRISC-Vを利用します。64ビットでも大体同じように実装できるのですが、ビット幅が広い分複雑になるのと、アドレスが長く読むのが億劫なので、最初は32ビットがおすすめです。
virtマシン
コンピュータはCPUだけではなく、メモリをはじめとする様々なデバイスから構成されています。例えば、iPhoneとRaspberry Piは同じArmのCPUを利用していますが、別物だと考えるのが自然です。
本書では、RISC-Vベースの中でもQEMU virtマシン (ドキュメント) に次の理由から対応することにしました。
- 名前の通り実在しない仮想的なコンピュータとはいえ、実機への対応と極めて近しい体験ができる。
- QEMU上で動くので、実機を買わなくてもすぐに無料で試せる。また、販売終了などで実機を手に入れるのが難しくなることもない。
- デバッグに困ったら、QEMUのソースコードを読んだり、QEMU自体にデバッガを繋いだりして何がおかしいのかを調べることができる。
RISC-Vアセンブリ入門の手引き
アセンブリを手っ取り早く学ぶ方法は「C言語のコードがどのようなアセンブリに変わるのか観察する」ことです。あとは、使われている命令の機能をひとつずつ辿っていくと良いでしょう。特に次の項目が基本的な知識となります。入門書で書くのもなんですが、ChatGPTに聞くと良いでしょう。
- レジスタとは何か
- 四則演算
- メモリアクセス
- 分岐命令
- 関数呼び出し
- スタックの仕組み
アセンブリを学ぶときに重宝するのが、Compiler Explorer というオンラインコンパイラです。ここでは、C言語のコードを入力すると、コンパイルした結果の逆アセンブリを見ることができます。どの機械語がC言語のコードのどの部分に対応しているかを色でわかりやすくしてくれます。
また、コンパイラオプションに -O0
(最適化オフ) や -O2
(最適化レベル2) などの最適化オプションを指定して、コンパイラがどのようなアセンブリを出力するのかを確認するのも勉強になるでしょう。
デフォルトではx86-64 CPUのアセンブリが出力されるようになっています。右のペインでコンパイラを
RISC-V rv32gc clang (trunk)
を指定すると32ビットRISC-Vのアセンブリを出力するようになります。
CPUの動作モード
CPUでは動作モードによって実行できる命令や挙動が異なります。RISC-Vでは、次の3つの動作モードがあります。
モード | 概要 |
---|---|
M-mode | OpenSBIが動作するモード。 |
S-mode | カーネルが動作するモード。 |
U-mode | アプリケーションが動作するモード。 |
特権命令
CPUの命令の中には、特権命令と呼ばれるアプリケーションが実行できない類があります。本書では、CPUの動作設定を変更する特権命令がいくつか登場します。以下の命令が本書で登場する特権命令です。
命令とオペランド | 概要 | 擬似コード |
---|---|---|
csrr rd, csr |
CSRの読み出し | rd = csr; |
csrw csr, rs |
CSRの書き込み | csr = rs; |
csrrw rd, csr, rs |
CSRの読み出しと書き込みを一度に行う | tmp = csr; csr = rs; rd = tmp; |
sret |
トラップハンドラからの復帰 (プログラムカウンタ・動作モードの復元など) | |
sfence.vma |
Translation Lookaside Buffer (TLB) のクリア |
ここで登場するCSR (Control and Status Register) とは、CPUの動作設定を格納するレジスタです。各CSRの説明は本書中にありますが、一覧を見たい場合は RISC-Vの仕様書 (PDF) を参照してください。
インラインアセンブリ
本文中では、次のような特殊なC言語の記法が登場します。
uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));
これは「インラインアセンブリ」と呼ばれる、C言語の中にアセンブリを埋め込む記法です。別途アセンブリのファイル (.S
拡張子) にアセンブリのみを書いてコンパイルする方法もありますが、インラインアセンブリを使うことで次のようなメリットがあります。
- C言語の変数をアセンブリの中で使える。また、アセンブリの結果をC言語の変数に代入できる。
- レジスタ割り当てをC言語のコンパイラに任せられる。アセンブリ中で内容を変更するレジスタの保存・復元を自分で書かなくて良い。
インラインアセンブリの書き方
インラインアセンブリは、次のような形式で書きます。
__asm__ __volatile__("アセンブリ" : 出力オペランド : 入力オペランド : アセンブリ中で破壊するレジスタ);
- アセンブリは文字列リテラルで書きます。各命令は
オペコード オペランド1, オペランド2, ...
という構造になっています。オペコードはアセンブリの命令名、オペランドは命令の引数を指します。 - アセンブリは基本的に1行に1命令記述します。一般的なプログラミング言語で例えると、関数呼び出しが連なっているようなイメージです。複数の命令を書く場合は、
"addi x1, x2, 3\naddi x3, x4, 5"
のように、改行文字を挟んで命令を書きます。 - 出力オペランドでは、アセンブリの処理結果をどこに取り出すかを宣言します。
"制約" (C言語中の変数名)
という形式で記述します。制約には大抵=r
を指定し、=
はアセンブリによって変更されること、r
はいずれかの汎用レジスタを使うことを表します。 - 入力オペランドでは、アセンブリ中で使いたい値を宣言します。出力オペランドと同じように、
"制約" (C言語での式)
という形式で記述します。制約には大抵r
を指定し、いずれかの汎用レジスタに値をセットすることを表します。 - 最後にアセンブリ中で中身を破壊するレジスタを指定します。指定し忘れると、C言語のコンパイラがそのレジスタの内容を保存・復元せず、ローカル変数が予期しない値になるバグにつながります。
- 出力・入力オペランドは、出力オペランドから順番に
%0
、%1
、%2
という形でアセンブリ中からアクセスできます。 __volatile__
は「アセンブリの内容を最適化をしてはいけない (そのまま出力せよ)」とコンパイラに指示するものです。
インラインアセンブリの例
uint32_t value;
__asm__ __volatile__("csrr %0, sepc" : "=r"(value));
上記のインラインアセンブリは、csrr
命令で sepc
レジスタの値を読み出し、value
変数に代入します。%0
が value
変数に対応しています。
__asm__ __volatile__("csrw sscratch, %0" : : "r"(123));
上記のインラインアセンブリは、csrw
命令で sscratch
レジスタに 123
を書き込みます。%0
が 123
が入ったレジスタ (r
制約) に対応し、次のように展開されます。
li a0, 123 // a0 レジスタに 123 をセット
csrw sscratch, a0 // sscratch レジスタに a0 レジスタの値を書き込み
インラインアセンブリには csrw
命令しか記述していませんが、"r"
制約を満たすために li
命令がコンパイラによって自動的に挿入されています。
インラインアセンブリは、C言語の仕様にはないコンパイラの独自拡張機能です。詳細な使い方はGCCのドキュメントで確認できます。ただし、CPUアーキテクチャによって制約の書き方が違っていたり、機能が多く複雑だったりと理解に時間を要する機能です。
初学者におすすめなのは実例に多く触れることです。例えば、HinaOSのインラインアセンブリ集が参考になるでしょう。