RISC-V入門

本章はエナガ本4章 (RISC-V入門)の内容に対応しています。

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-modeOpenSBIが動作するモード。
S-modeカーネルが動作するモード。
U-modeアプリケーションが動作するモード。

特権命令

CPUの命令の中には、特権命令と呼ばれるアプリケーションが実行できない類があります。本書では、CPUの動作設定を変更する特権命令がいくつか登場します。以下の命令が本書で登場する特権命令です。

命令とオペランド概要擬似コード
csrr rd, csrCSRの読み出しrd = csr;
csrw csr, rsCSRの書き込みcsr = rs;
csrrw rd, csr, rsCSRの読み出しと書き込みを一度に行うtmp = csr; csr = rs; rd = tmp;
sretトラップハンドラからの復帰 (プログラムカウンタ・動作モードの復元など)
sfence.vmaTranslation 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 変数に代入します。%0value 変数に対応しています。

__asm__ __volatile__("csrw sscratch, %0" : : "r"(123));

上記のインラインアセンブリは、csrw 命令で sscratch レジスタに 123 を書き込みます。%0123 が入ったレジスタ (r 制約) に対応し、次のように展開されます。

li    a0, 123        // a0 レジスタに 123 をセット
csrw  sscratch, a0   // sscratch レジスタに a0 レジスタの値を書き込み

インラインアセンブリには csrw 命令しか記述していませんが、"r" 制約を満たすために li 命令がコンパイラによって自動的に挿入されています。

インラインアセンブリは、C言語の仕様にはないコンパイラの独自拡張機能です。詳細な使い方はGCCのドキュメントで確認できます。ただし、CPUアーキテクチャによって制約の書き方が違っていたり、機能が多く複雑だったりと理解に時間を要する機能です。

初学者におすすめなのは実例に多く触れることです。例えば、HinaOSのインラインアセンブリ集が参考になるでしょう。