借り初めのひみつきち

仮ブログです。

RISC-V で自作OS

じだいは RISC-V です。

github.com

RISC-V とは

Windows でよく使われている x86 は 40 年以上前から基本的に互換性を持ったまま拡張を続けた結果、現代ではほとんど必要ない機能との互換性のためにかなり複雑な仕様になっています。 また、 Arm に比べると高価で発熱が多く構成の自由度が低いので Windows 以外の用途ではあまり使われません。

Windows 以外でメジャーな Arm は組み込みからサーバーまで幅広い用途に使われますが、ライセンスやロイヤリティの関係でベンダーの自由度が制限されていて離れるベンダーが増えてきています。

ある程度の性能が必要なコンピュータは今までは x86 か Arm の実質2択でしたが、最近はシンプルで自由な RISC-V が注目されています。

RISC-V の命令セット

RISC-V の命令セットは、32bit 基本命令セットの RV32I、 64bit の RV64I、 32bit のサブセットの RV32E という主要な基本命令セットがあり、その後に数文字のアルファベットで拡張命令セットの種類を表します。 Rust は E 基本命令セットに対応していないので I 基本命令セットのチップを選択する必要があります。

M は乗除算(Multiplication)命令セット、 A はアトミック(Atomic)命令セット、 F は浮動小数(Floating-point)命令セット、 D は倍精度浮動小数(Double-precision)命令セット、 C は圧縮(Compressed)命令セット、 S は S(Supervisor) モード実装済み、 U は U(User) モード実装済みをそれぞれ表します。 (ファームウェアが動作する最上位の M(Machine) モード は全てのチップに実装されています。)

M の乗除算命令セットは多くの RISC-V チップが対応していますが、乗除算(特に除算)は回路が複雑で規模が大きくなるので組み込み向けの小規模なチップではこの命令が実装されていないことがあります。 F や D の浮動小数演算も一部の分野を除いてほとんど使わないので回路規模を抑えるために対応していないチップがあります。 このようなチップで未対応の演算が必要な場合、加算・減算・ビット演算・表引き等を組み合わせて代用する必要があります。 *1

A のアトミック命令セットは主にマルチタスク環境のスレッド間で変数を正しく同期するために必要な命令群で、特にマルチプロセッサ環境で重要です。 例によって組み込み用途では必要ないのでサポートしていません。

C の圧縮命令セットは、 Arm の Thumb のように 32bit 長の基本命令セットに対し一部のよく使われる命令を 16bit の短いフォーマットで表現できる命令セットになります。 Thumb との最大の違いは Thumb ではモード切り替えが必要なのに対し、 RISC-V は最初からオペコードに命令長を区別するビットパターンが定義されているのでモード切り替えが必要なく通常命令と圧縮命令が混在できる点が違います。 多くの RISC-V チップが C 命令セットをサポートしているので RISC-V のコードは 16bit 命令と 32bit 命令が混在していることが多いです。

IMAFD の組み合わせは汎用命令セット (General) と呼んで G の別名が付いているので、セットでよく使われる IMAFDC を省略して GC と表記することもあります。

Linux 等の汎用 OS をサポートするチップでは IMACGC(IMAFDC) の組み合わせがよく使われ、さらに S と U の両方実装されていますが SU は明記されないことも多いようです。

一方、組み込みでは RV32EC という組み合わせのチップもあります。 一口に RISC-V と言っても意外と多くの命令セットのバリエーションがあります。

ということで、 rust のターゲットは riscv64gc-unknown-none-elf で行きます。

OpenSBI

RISC-V で汎用 OS が起動するスペックのコンピュータは RISC-V Supervisor Binary Interface (SBI) という規格のファームウェアが使われているのでハローワールドは簡単に作れます。

試しに QEMU を起動してみるとこのような画面で OpenSBI が起動します。 OpenSBI は SBI のオープンソース実装です。

SBI の S は Supervisor の略で RISC-V で通常の OS が起動する S モードのことを指しています。

SBI を呼び出すには a7 レジスタに EID (extension ID) をセットして S モードで ecall 命令を実行します。*2 また、 a0 レジスタに戻り値が返ってくるので戻り値が必要ない場合でも lateouta0 レジスタを除外指定します。

ハローワールドに必要な putchar の EID は 1 なので a7 レジスタに 1 をセットして次のような関数を作ります。

fn sbi_putchar(ch: u8) {
    unsafe {
        asm!(
            "ecall",
            in("a7") 1,
            in("a0") ch as usize,
            lateout("a0")_
        );
    }
}

qemuRISC-V ハローワールド

Rust はクロスコンパイルが簡単で RISC-V にも対応しているので、 rust と qemu をインストールすればすぐに環境が整います。

表示するメッセージを bytes() イテレーターで1バイトずつ分解し、さっき作った sbi_putchar() を呼び出せばハローワールドの主要な部分は完成です。

    for ch in "Hello, World!\n".bytes() {
        sbi_putchar(ch);
    }

これにベアメタルで動くためのおまじないの #![no_std]#![no_main]、パニックハンドラーを追加するととりあえず最小限のハローワールドは動きます。

しかし、これをベースに拡張することを考慮するとスタックポインタの設定とBSSの初期化もしておいた方が良いでしょう。

関数の途中でスタックポインタを変更するとコンパイラが作るプロローグやエピローグのスタックの処理で不整合が起きる可能性があるので、プロローグ・エピローグを生成しない naked 属性の関数が必要です。 しかし、執筆時点の rust は RISC-V の naked 関数をサポートしていないので、今回は global_asm で代用します。

スタックポインタを設定してメイン関数を呼び出す処理を global_asm で実装し、この関数をエントリーポイントにします。

global_asm!("
.section .text.init
.global start
start:
    la sp, __stack_top
    j os_main
");

また、エントリーポイントを .text セクションの先頭に配置しないと正しく動作しない環境があったので専用のセクション (.text.init) を作って後でリンカースクリプトで配置します。

次に、メイン関数の最初にBSSを初期化します。

BSS は初期値が 0 のデータをまとめたセクションで、主に初期値のビット値が 0 になるグローバル変数やスタティック変数などに使われます。 BSSセクションの内容は全部 0 になることが期待されているので、バイナリファイルにはアドレスとサイズのヘッダ情報のみ記載すれば良く、バイナリファイルのサイズを削減できます。 しかし、組み込みやOS起動時などの簡易的なプログラムローダーの場合はBSSが初期化されていないことがあるので、メイン関数の一番最初に BSS の場所とサイズを求めて 0 で初期化します。

    unsafe {
        let bss = &__bss as *const _ as *mut u8;
        let bss_end = &__bss_end as *const _;
        bss.write_bytes(0, bss_end as usize - bss as usize);
    };

メインプログラムが完成したら、既知のターゲットではないのでリンカースクリプトを作ります。

qemuRISC-V では RAM が 0x80000000 から始まり SBI が配置されるので、次のページ境界の 0x80200000 からプログラムが配置されるようにします。 スタックトップやBSSの位置を指すラベルもリンカースクリプトで定義します。

ENTRY(start)

SECTIONS {
    . = 0x80200000;

    .text : {
        KEEP(*(.text.init));
        *(.text .text.*);
    }

    .rodata : ALIGN(4) {
        *(.rodata .rodata.*);
    }

    .data : ALIGN(4) {
        *(.data .data.*);
    }

    .bss : ALIGN(4) {
        __bss = .;
        *(.bss .bss.* .sbss .sbss.*);
        __bss_end = .;
    }

    . = ALIGN(4096);
    . += 128 * 1024;
    __stack_top = .;
}

リンカースクリプトができたら、これを読み込むように .cargo/config.toml を編集します。 ついでにターゲットの指定もここでしておくと楽です。

[build]
target = "riscv64gc-unknown-none-elf"
rustflags = ["-C", "link-args=-Tsrc/riscv.lds"]

これをビルドして実行します。

OpenSBI の動作ログが表示されたあと最後にプログラムで指定したメッセージ (Hello, World!) が表示されています。

It works!

実機 (VisionFive2, U-Boot) でハローワールド

執筆時点で RISC-V で動作し簡単に入手可能なパソコンはありません。 Linux が動作する SBC は何種類か出ていて比較的簡単に入手できるので、ここでは StarFive の VisionFive2 を使います。

おそらく Linux をサポートする機種では U-Boot というブートローダーが主に使われています。

VisionFive2 には Raspberry Pi 互換の 40 ピン GPIO 端子があり、シリアルポート (UART) の信号が出ています。 6pin GND、8pin TX(GPIO14)、10pin RX(GPIO15) を USB シリアルポートに変換するアダプタに繋ぐとパソコンと通信できます。

パソコンでシリアル通信できるアプリを起動してボードの電源を入れると OpenSBI のロゴが表示された後に U-Boot が起動しているのが確認できます。 Mac ならターミナルで screen /dev/tty.usbserial-XXX 115200 と入力すれば起動します。(XXXは環境によって変わるので /dev/ から似たようなファイルを探してください)

U-Boot が起動したらコンソールからカーネルを読み込んで bootelf コマンドを実行すると qemu と同じようにハローワールドが動作します。

U-Boot はSDカードなどからカーネルを起動できますが、テストのために何度もビルドしてコピーして繋ぎ直すのがめんどくさかったので、ここでは Ethernet で接続して tftpboot コマンドを使って TFTP 経由で起動してみました。

U-Boot は自動的にメモリを割り当ててファイルを読み込んでくれる機能がないため、 ELF ファイルを読み込む場合は本来のロード先と別のアドレスを指定して一旦ファイルを読み込んだ後、 bootelf コマンドを実行する時に U-Boot が ELF ヘッダーにある本来のアドレスに配置します。

次の一歩

OpenSBI はコンソールの入出力(通常はシリアルポート)、タイマーの設定やマルチコアの初期化に必要な処理などをサポートしているものの、その他のハードウェア構成情報は Device Tree を参照する必要があります。 *3

qemu で起動した場合エントリポインタの引数の a1 レジスタから Flattened Device Tree (FDT/DTB) がそのまま取得できるので、これをパースして virtio に対応すれば一般的な OS としての機能は大体実現できそうです。 *4

U-Boot で bootelf コマンドで起動した場合は Device Tree が取得できません。 U-Boot に Linux カーネルとして認識させれば Device Tree が取得できますが、その場合は ELF 形式から https://www.kernel.org/doc/Documentation/riscv/boot-image-header.rst のドキュメントに準拠した形式のバイナリに変換し、 booti コマンドで起動するように変える必要があります。

Device Tree に対応してもハードウェアの構成がわかるだけで、それぞれの機能のドライバは自分で実装する必要があります。 世の中のすべてのデバイスに対応するのは無理なので、実際には特定機種専用の OS になって Device Tree の構成情報はあまり必要ないかも知れません。

実機 (Milk-V Duo, U-Boot)

Milk-V Duo の場合はどうでしょうか。 Milk-V Duo は非常に安価で Raspberry Pi Pico とほぼ同じサイズですが RV64IMAFDC と標準的な命令セット *5 を一通りサポートして Linux が動作する興味深い SBC です。

Milk-V Duo の UART は 16pin と 17pin が TX/RX になっており、 GND はすぐ隣の 18pin を使うと便利です。

スペックは控えめになっており、メモリ消費を抑えるためか公式が提供している U-Boot も機能が大幅に削減されていて bootm コマンドでしか起動できません。

bootm コマンドで起動できるイメージはカーネル・Device Tree・initrdを専用のツールでひとつのファイルにまとめたもので、ビルドのためにツールが必要でちょっとめんどくさいです。

そもそも、 Milk-V Duo は買ってきたそのままの状態だと OpenSBI も U-Boot も起動しません。 公式の提供するファームウェアイメージを SD カードにインストールする必要があります。

github.com

公式イメージの最初のパーティション (FAT) にある fip.bin というファイルがファームウェア (FSBL + OpenSBI + U-Boot) のイメージファイルになります。 公式のビルドツールをダウンロードして設定をカスタマイズした後ビルドしてできた fip.bin を置き換えることで U-Boot を含んだファームウェアのカスタマイズができます。 また、同じパーティションにある boot.sd はデフォルト起動する Linux のイメージなので、自作 OS で使う SD カードの場合は削除した方が便利です。

U-Boot をカスタマイズして booti コマンドで起動するイメージを作ることに成功しました。

VisionFive2 と Milk-V Duo のメモリレイアウトは異なるため、同じイメージで動かそうとすると起動時一番最初にメモリレイアウトを調整するようなコードがないとうまく動かないと思います。

いかがでしたか?

RISC-V の SBC はハローワールドが簡単に動作することがわかりました。

しかし、 RISC-V は x86 PC のような典型的なハードウェア構成というものが存在しないため、全ての環境で動作する OS を作るのは容易ではありません。

また、 x86 や Arm に比べると市場全体の成熟度が低く様々な問題に突き当たることがあります。 成熟にはまだまだ時間を要するでしょう。

その先には自由が広がっています。自由な RISC-V の世界を開拓しましょう!

*1:なお、8bit時代のCPUのほとんどは乗除算に未対応で、浮動小数演算がハードウェアでサポートされるようになったのも32bit以降が多いので、古いチップや組み込み環境では今でも使われているテクニックです。

*2:ecall 命令は通常エスカレーションが発生するので現在の動作モードによって動作が異なります。

*3:例えば、 SBI で考えてもタイマーの設定に必要な値は Device Tree で timebase-frequency を取得しないと計算できません

*4:実際のところ、 qemu 専用なら virtio は 0x1000_1000, 0x1000_2000 ... 0x1000_8000 のどこか、 virtio-vga は 0x3000_8000 に PCI コンフィグレーションレジスタが固定で存在するようなので、 Device Tree の依存度はそれほど多くありません。

*5:EC や IMAC ではない!!