執筆者 : 高橋 浩和
※ 「RISC-V OSを作ろう」連載記事一覧はこちら
※ 「RISC-V OS」のコードはgithubにて公開しています。
はじめに
RISC-VはMIPSアーキテクチャの流れを汲む正統派?のRISC CPUです。命令セットはシンプルですが、既存のメジャーなCPUのアーキテクチャと大きな違いがあるわけではありません。 Linux上で利用できるRISC-Vツール群も揃ってきたので、それらを使ってRISC-V用の小さなOSを実装してみようと思います。
最初は欲張らずに単純な実装を目指すことにします。
- シングルコアのみサポート
- 64bitモードを使用
- マルチタスキングを実現
- タイムシェアリングスケジューリングを実装
- 割り込みネストは無し
- 保護機能は使わない
- 既存のBIOSやbootプログラムは利用せず、リセットエントリから全て作成する
- qemuの仮想マシン上で動作させる。ターゲットアーキテクチャ(CPU以外の部分)は、virt(RISC-V VirtIO board)とする
環境の用意
Linuxディストリビューションは、Ubuntu 24.04 LTSを使うことにします*1。必要とするツール群の殆どが標準パッケージとして提供されており、苦労することなくRISC-V用のバイナリを生成し、動作させることができます。
まず、Ubuntu標準で用意されているRISC-V関連パッケージをインストールします。
パッケージ | 説明 |
---|---|
qemu-system-misc | 雑多なアーキテクチャ用のエミュレータ。RISC-V用のエミュレータも含まれる。 |
gcc-riscv64-unknown-elf | RISC-V用クロスコンパイラ |
binutils-riscv64-unknown-elf | RISC-Vバイナリファイル用のbinutilsパッケージ |
gdb-multiarch | 雑多なアーキテクチャ用のgdb |
$ sudo apt install qemu-system-misc gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf gdb-multiarch
RISC-Vアーキテクチャマニュアルも入手しておきましょう。
gccのRISC-Vアーキテクチャ固有オプションも知りたくなることがあるでしょう。
ブートプログラムを作る
今回動かすOSは、テキストもデータも全てRAM上に配置して動作させることにします。これによりブート処理が少し楽になります。
ブート処理では、GPレジスタとSPレジスタの初期化とbssセクション(初期値なしデータ域)のクリアのみを行ないます。
TPレジスタの初期化はサボります。今回作るOSはシングルコア限定としているためTPレジスタ(コアローカルなデータや仮想CPUローカルなデータを指すことに利用します)は使わないためです。
dataセクションの初期化は不要です。ROM上にdataセクションの初期値を配置するモデルでは、この初期値をRAM上にあるdataセクションにコピーする処理が必要になりますが、最初から全てをRAM上に配置するモデルでは不要です。
bssセクションのクリアは、実装を単純にするためmain
関数の中で行なうことにします。
リセットエントリに下記コードを配置します。(ファイルはstart.s とします)
.section .reset,"ax",@progbits .option norelax .globl _start _start: la gp, __global_pointer$ la sp, _stack_end j main
上記_start
関数は.reset
セクションに割り当て、リンカディレクティブファイルにてRAM域の先頭に配置します。
ショートデータ域を指すGPレジスタ(グローバルポインタ)の初期値もリンカディレクティブファイルにて定義します。
スタック領域も、リンカディレクティブファイルにて定義します。(リンカディレクティブファイルはriscv-virt.ldsとします)
次の図のセクション配置になるようにリンカディレクティブを記述し、_start
関数にてGP、SPを設定します。
OUTPUT_ARCH( "riscv" ) ENTRY( _start ) MEMORY { ram (wxa!ri) : ORIGIN = 0x80000000, LENGTH = 128M } SECTIONS { .text : { PROVIDE(_text_start = .); *(.reset) *(.text .text.*) PROVIDE(_text_end = .); } >ram AT>ram .rodata : { PROVIDE(_rodata_start = .); *(.rodata .rodata.*) *(.note.* ) PROVIDE(_rodata_end = .); } >ram AT>ram .data : { . = ALIGN(4096); PROVIDE(_data_start = .); *(.data .data.*) } >ram AT>ram .sdata : { PROVIDE( __global_pointer$ = . + 0x800 ); *(.sdata .sdata.*) PROVIDE(_data_end = .); } >ram AT>ram .sbss : { . = ALIGN(16); PROVIDE(_bss_start = .); *(.sbss .sbss.*) } >ram AT>ram .bss :{ *(.bss .bss.*) . = ALIGN(16); PROVIDE(_bss_end = .); } >ram AT>ram .stack :{ . = ALIGN(16); PROVIDE(_stack_start = .); . = . + 4096; PROVIDE(_stack_end = .); } >ram AT>ram }
グローバルレジスタの初期値となるシンボル __global_pointer$
は、.sdata
セクションと.sbss
セクションの中の変数がアクセスできる位置に設定します。
GPレジスタ相対命令は、GPレジスタが指すアドレスの前後2KBの範囲にある4KBのデータに対してのみ利用可能です。そのため.sdata
セクションと.sbss
セクションを連続して配置することは必須です。.sdata
.と.sbss
セクションを加えたサイズが4KBを超えないようにするのは、利用者の責任です。コンパイラオプション-msmall-data-limit
などを利用して調整しましょう。
_start
関数にある .option norelax
の行は非常に重要です。
RISC-V用のリンカは最適化機能を備えており、シンボル値にアクセスする命令があるとGP相対アドレスでアクセスする命令に置き換え、命令数を減らそうとします。
この指定が無いと、リンカがGPが既に初期化されている前提で la gp, __global_pointer$
を mv gp, gp
に置き換える最適化を行ないます。嵌りました。
.option norelax
により、この最適化を抑制することができます。
_start
関数から呼び出されたmain
関数の中で.sbss
セクションと.bss
セクションのゼロクリア処理を行ないます。
実装を単純にするため、リンカディレクティブファイルにて、.sbss
セクションに続けて.bss
セクションを定義し、
この2つのセクションの開始アドレスにシンボル_bss_start
、終了アドレスにシンボル_bss_end
を埋め込んでいます。
main
関数では、この _bss_start
と_bss_end
に挟まれた領域をゼロクリアします。(ファイルはmain.cとします)
static void clearbss(void) { unsigned long long *p; extern unsigned long long _bss_start[]; extern unsigned long long _bss_end[]; for (p = _bss_start; p < _bss_end; p++) { *p = 0LL; } } void main(void) { clearbss(); }
動かしてみる
main
関数を呼び出してbss領域をゼロクリアするところまでのコードをRISC-V用のqemu(CPUエミュレータ)上で動かしてみます。
コンパイル
risc-v用クロスコンパイラでコンパイルします。
-mcmodel=medany
オプションは指定してください。未指定時はシンボルアクセスに絶対アドレスで操作する命令が出力されますが、このオプションを指定するとPC相対アドレスで操作する命令が出力されます。絶対アドレス指定ではアクセスできる範囲が狭くなります。
$ riscv64-unknown-elf-gcc -O2 -mcmodel=medany -ffreestanding -g -c main.c start.s $ riscv64-unknown-elf-ld main.o start.o -T riscv-virt.lds
生成された命令列を確認してみます。
$ riscv64-unknown-elf-objdump -d a.out a.out: file format elf64-littleriscv Disassembly of section .text: 0000000080000000 <_start>: 80000000: 00002197 auipc gp,0x2 80000004: 80018193 addi gp,gp,-2048 # 80001800 <__global_pointer$> 80000008: 00002117 auipc sp,0x2 8000000c: ff810113 addi sp,sp,-8 # 80002000 <_stack_end> 80000010: 0040006f j 80000014 <main> 0000000080000014 <main>: 80000014: 00001797 auipc a5,0x1 80000018: fec78793 addi a5,a5,-20 # 80001000 <_bss_end> 8000001c: 00001717 auipc a4,0x1 80000020: fe470713 addi a4,a4,-28 # 80001000 <_bss_end> 80000024: 00e7ff63 bgeu a5,a4,80000042 <main+0x2e> 80000028: 00001717 auipc a4,0x1 8000002c: fd770713 addi a4,a4,-41 # 80000fff <main+0xfeb> 80000030: 8f1d sub a4,a4,a5 80000032: 9b61 andi a4,a4,-8 80000034: 0721 addi a4,a4,8 80000036: 973e add a4,a4,a5 80000038: 0007b023 sd zero,0(a5) 8000003c: 07a1 addi a5,a5,8 8000003e: fee79de3 bne a5,a4,80000038 <main+0x24> 80000042: 8082 ret
QEMU上で起動
生成されたオブジェクトa.outをRISC-V用qemu上で起動します。
-d in_asm
オプションを指定し、実行された命令を出力してみます。
$ qemu-system-riscv64 -d in_asm -nographic -machine virt -m 128M -kernel a.out -bios none
_start
が配置された0x80000000番地実行以前に0x1000番地から実行が始まっていることが分かります。
リセットベクタはCPU実装依存とのことなので、入手したRISC-V CPU毎に動作が異なるかもしれません。
---------------- IN: 0x0000000000001000: 00000297 auipc t0,0 # 0x1000 0x0000000000001004: 02028593 addi a1,t0,32 0x0000000000001008: f1402573 csrrs a0,mhartid,zero ---------------- IN: 0x000000000000100c: 0182b283 ld t0,24(t0) 0x0000000000001010: 00028067 jr t0 ---------------- IN: 0x0000000080000000: 00002197 auipc gp,8192 # 0x80002000 0x0000000080000004: 80018193 addi gp,gp,-2048 0x0000000080000008: 00002117 auipc sp,8192 # 0x80002008 0x000000008000000c: ff810113 addi sp,sp,-8 0x0000000080000010: 0040006f j 4 # 0x80000014 ---------------- IN: main 0x0000000080000014: 00001797 auipc a5,4096 # 0x80001014 0x0000000080000018: fec78793 addi a5,a5,-20 0x000000008000001c: 00001717 auipc a4,4096 # 0x8000101c 0x0000000080000020: fe470713 addi a4,a4,-28 0x0000000080000024: 00e7ff63 bleu a4,a5,30 # 0x80000042 ---------------- IN: main 0x0000000080000042: 8082 ret
main
関数の最後にret
命令を実行しmain
関数から復帰しようとしていますが、main
関数の呼び出し時に戻り値番地を設定していないので、ここで例外が発生しているものと思われます。今回のプログラムでは例外を捕捉するための初期化は全く行っていないので、CPUはハングしているのでしょう。
GDBで制御
qemu上のプログラムをgdbを用いて操作してみます。 まず、qemuをgdb接続待ちで立ち上げます。待ち受けるポートは10000以外でも構いません。
$ qemu-system-riscv64 -nographic -machine virt -m 128M -kernel a.out -bios none -S -gdb tcp::10000
別のターミナルウィンドウからgdbを起動し、localhostの10000ポートに接続します。
$ gdb-multiarch a.out GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403-git Copyright (C) 2024 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: <https://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from a.out... warning: File "/home/taka/1.boot/.gdbinit" auto-loading has been declined by your `auto-load safe-path' set to "$debugdir:$datadir/auto-load". To enable execution of this file add add-auto-load-safe-path /home/taka/1.boot/.gdbinit line to your configuration file "/home/taka/.config/gdb/gdbinit". To completely disable this security protection add set auto-load safe-path / line to your configuration file "/home/taka/.config/gdb/gdbinit". For more information about this security protection see the "Auto-loading safe path" section in the GDB manual. E.g., run from the shell: info "(gdb)Auto-loading safe path" 0x0000000000001000 in ?? () Function "undefined_handler" not defined. Make breakpoint pending on future shared library load? (y or [n]) [answered N; input not from terminal] (gdb) target remote localhost:10000 Remote debugging using localhost:10000 0x0000000000001000 in ?? () (gdb)
少し動かしてみます。
main
関数が呼び出された時に止め、レジスタの値を覗いてみます。
(gdb) b main Breakpoint 1 at 0x80000014: file main.c, line 9. (gdb) c Continuing. Breakpoint 1, main () at main.c:15 15 clearbss(); (gdb) info registers ra 0x0 0x0 sp 0x80002000 0x80002000 gp 0x80001800 0x80001800 tp 0x0 0x0 t0 0x80000000 2147483648 t1 0x0 0 t2 0x0 0 fp 0x0 0x0 s1 0x0 0 a0 0x0 0 a1 0x87e00000 2279604224 a2 0x1028 4136 a3 0x0 0 a4 0x0 0 a5 0x0 0 a6 0x0 0 a7 0x0 0 s2 0x0 0 s3 0x0 0 s4 0x0 0 s5 0x0 0 s6 0x0 0 s7 0x0 0 s8 0x0 0 s9 0x0 0 s10 0x0 0 s11 0x0 0 t3 0x0 0 t4 0x0 0 t5 0x0 0 t6 0x0 0 pc 0x80000014 0x80000014 <main>
現在停止している場所、main
関数を逆アセンブルしてみます。 *2
(gdb) disassemble Dump of assembler code for function main: => 0x0000000080000014 <+0>: auipc a5,0x1 0x0000000080000018 <+4>: addi a5,a5,-20 # 0x80001000 0x000000008000001c <+8>: auipc a4,0x1 0x0000000080000020 <+12>: addi a4,a4,-28 # 0x80001000 0x0000000080000024 <+16>: bgeu a5,a4,0x80000042 <main+46> 0x0000000080000028 <+20>: auipc a4,0x1 0x000000008000002c <+24>: addi a4,a4,-41 # 0x80000fff 0x0000000080000030 <+28>: sub a4,a4,a5 0x0000000080000032 <+30>: andi a4,a4,-8 0x0000000080000034 <+32>: addi a4,a4,8 0x0000000080000036 <+34>: add a4,a4,a5 0x0000000080000038 <+36>: sd zero,0(a5) 0x000000008000003c <+40>: addi a5,a5,8 0x000000008000003e <+42>: bne a5,a4,0x80000038 <main+36> 0x0000000080000042 <+46>: ret End of assembler dump. (gdb)
qemu上のプログラムもgdbで普通に操作できることが分かりました。
最後に
OSを作る前段のところまでしか辿り着けませんでした。
今回作成したプログラムはgithubにて公開しています。自由にカスタマイズしてみてください。 github.com
次回は、タスク切り替えを実装します。お楽しみに。
おまけ
RISC-Vの汎用レジスタの使われ方を載せておきます。使われ方はABIにて規定されています。
レジスタ名 | 別名 | 説明 |
---|---|---|
x0 | zero | ゼロ固定 |
x1 | ra | 関数戻りアドレス |
x2 | sp | スタックポインタ |
x3 | gp | グローバルポインタ |
x4 | tp | スレッドポインタ |
x5 | t0 | 作業レジスタ, リンクレジスタ |
x6-x7 | t1-t2 | 作業レジスタ |
x8 | s0/fp | パーマネントレジスタ、フレームポインタ |
x9 | s1 | パーマネントレジスタ |
x10-x11 | a0-a1 | 関数引数、関数戻り値 |
x12-x17 | a2-a7 | 関数引数 |
x18-x27 | s2-s11 | パーマネントレジスタ |
x28-x31 | t3-t6 | 作業レジスタ |
RISC-Vには演算結果フラグを格納するレジスタが無いのも特徴です。MIPSと同じく、比較と条件分岐を1命令で行うアーキテクチャとなっています。 コンパイラ最適化機能の命令並び替えの自由度を高めることを目的として、このアーキテクチャが採用されています。 先ほどの逆アセンブル結果でも、条件分岐命令が使われています。逆アセンブル結果ではジャンプ先が絶対アドレスで表示されていますが、実際にはPC相対ジャンプとなっています。
命令 | 意味 |
---|---|
bleu a4,a5,0x80000042 | if ( (unsigned)a4 <= (unsigned)a5 ) goto 0x80000042 |
bne a5,a4,0x80000038 | if ( a5 != a4 ) goto 0x80000038 |
*1:Ubuntu 22.04 LTSでも、「RISC-Vハイパーバイザーを作ろう」編以外は動作します。
*2:Ubuntu 20.04 LTSの時は、main関数の先頭の命令ではなく、数命令実行された後の命令にbreakが置かれるバグがありましたが、Ubuntu 24.04 LTS環境では解決されています。