RISC-V OSを作ろう (1) ~ブート処理

執筆者 : 高橋 浩和

※ 「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アーキテクチャマニュアルも入手しておきましょう。

riscv.org

gccのRISC-Vアーキテクチャ固有オプションも知りたくなることがあるでしょう。

gcc.gnu.org

ブートプログラムを作る

今回動かす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環境では解決されています。