⚙️

QEMU&RISC-Vでベアメタルプログラミング

に公開

はじめに

ハードウェア直でプログラムを動かしたい!
理由? そんなものはない!

ということでRISC-Vデビューします。
GNU/Linux、Parabolaの環境で、QEMUとC言語のクロスコンパイラを使ってRISC-V(とは言っても大体C言語になるんですが…)のプログラムを動かす第一歩、をまとめてみます。

QEMUのRISC-Vエミュレーション環境を確認、起動と終了

RISC-Vのボードはいくつかありますが、お金がかかってしまうし、何なら一週間で飽きてしまうかもしれないし、そんなにハードウェア詳しくないし、と色々と一歩足を踏み出すのに障害が待ち受けているはずなので、まずはQEMUという仮想的なコンピューターを動かせるソフトウェアを導入します。

$ sudo pacman qemu-full

QEMUではRISC-Vの32bit、64bitをサポートしています。以下のコマンドでriscv用のものを確認します。32bit用と64bit用のものがあります。

$ ls /usr/bin | grep qemu-system-riscv
qemu-system-riscv32
qemu-system-riscv64

QEMUで使用する仮想的なコンピューターの構成を選択します。-machine helpで一覧が出るため、この中から選択できます。

$ qemu-system-riscv64 -machine help
Supported machines are:
amd-microblaze-v-generic AMD Microblaze-V generic platform
microchip-icicle-kit Microchip PolarFire SoC Icicle Kit
none                 empty machine
shakti_c             RISC-V Board compatible with Shakti SDK
sifive_e             RISC-V Board compatible with SiFive E SDK
sifive_u             RISC-V Board compatible with SiFive U SDK
spike                RISC-V Spike board (default)
virt                 RISC-V VirtIO board

virtは汎用的な環境として用意されているもので、特別な理由がなければvirtを使います。グラフィックなし、BIOSなしで一度起動してみます。

$ qemu-system-riscv64 -machine virt -cpu rv64 -nographic -bios none

今は何もプログラムがないため、実行しても何も起きません。QEMUはCtrl-cでは終了できないため、Ctrl-a xで終了させます。

QEMUの実行と終了が完了できたら、開発環境を用意します。

RISC-V用のクロス開発環境を用意する

QEMUと、RISC-V用のgcc、binutilsを以下のコマンドでインストールします。

$ sudo pacman -S riscv64-elf-gcc riscv64-elf-binutils

riscv64-elf-gccriscv64-elf-binutilsはx86上で動作するGNU/Linuxを開発環境としつつ、RISC-Vという異なるアーキテクチャで動かすためのプログラムをビルドする(クロス開発)ために入れるパッケージです。

binutilsには、asやld、objdumpなどの開発ツールが含まれており、gccと同様にriscv64-elf-というPrefixが付くようになっています。

/usr/bin以下を確認すると、様々なツールが導入されたことが確認できます。

$ ls /usr/bin | grep "riscv64-elf-"
riscv64-elf-addr2line
riscv64-elf-ar
riscv64-elf-as
riscv64-elf-c++
riscv64-elf-c++filt
riscv64-elf-cpp
riscv64-elf-elfedit
riscv64-elf-g++
riscv64-elf-gcc
riscv64-elf-gcc-14.1.0
riscv64-elf-gcc-ar
riscv64-elf-gcc-nm
riscv64-elf-gcc-ranlib
riscv64-elf-gcov
riscv64-elf-gcov-dump
riscv64-elf-gcov-tool
riscv64-elf-gprof
riscv64-elf-ld
riscv64-elf-ld.bfd
riscv64-elf-lto-dump
riscv64-elf-nm
riscv64-elf-objcopy
riscv64-elf-objdump
riscv64-elf-ranlib
riscv64-elf-readelf
riscv64-elf-size
riscv64-elf-strings
riscv64-elf-strip

Cコンパイラ、riscv64-elf-gccでサポートしているターゲットアーキテクチャを確認します。

$ riscv64-elf-gcc -print-multi-lib
.;
rv32i/ilp32;@march=rv32i@mabi=ilp32
rv32im/ilp32;@march=rv32im@mabi=ilp32
rv32iac/ilp32;@march=rv32iac@mabi=ilp32
rv32imac/ilp32;@march=rv32imac@mabi=ilp32
rv32imafc/ilp32f;@march=rv32imafc@mabi=ilp32f
rv64imac/lp64;@march=rv64imac@mabi=lp64
rv64imafdc/lp64d;@march=rv64imafdc@mabi=lp64d

@marchは、RISC-VのISA(命令セット)の種類で、rv32rv64に大きく分れますが、その後に続くアルファベットで命令セットの細かな種類の違いを示しています。

  • i : Integer - 整数命令セット
  • m : Multiply - 乗算・除算命令セット
  • f : Float - 単精度浮動小数点数命令セット
  • d : Double - 倍精度浮動小数点数命令セット
  • a : Atomic - アトミック命令セット
  • c : Compressed - Compressed命令セット。命令長を半分の16bitに圧縮

@mabiは、ABI(Application Binary Interface)と呼ばれるもので、以下の指定が可能です。

  • ilp32 : Int、Long、Pointerが32bit
  • ilp32f : ilp32に単精度浮動小数点数を追加
  • ilp32d : ilp32に単精度、倍精度浮動小数点数を追加
  • lp64 : Long, Pointerが64bit
  • lp64f : lp64に単精度浮動小数点数を追加
  • lp64d : lp64に単精度、倍精度浮動小数点数を追加

ISAとABIは、決められた組み合わせで使う必要があります。例えばrv64imafdcをISAとして選ぶ場合、以下の結果からABIはlp64dを選択する必要があります。

rv64imafdc/lp64d;@march=rv64imafdc@mabi=lp64d

起動コード

OSも、C標準ライブラリも、何も存在しないということを思い出してください。まず、Cのmain関数を呼び出すための起動コードを記述する必要があります。

start.sというファイル名で、以下のコードを記述します。

.align 2

.equ UART_BASE, 0x10000000
.equ REG_RBR, 0x00
.equ REG_THR, 0x00
.equ REG_IIR, 0x02
.equ LSR_RX_RDY, 0x01
.equ LSR_TX_RDY, 0x20

.section .text
.globl boot_entry
.globl stack_top

boot_entry:
  la sp, stack_top
  call main

loop:
  j loop

ここで記述するアセンブリが、RISC-VのCPUが最初に処理するコードになります。GNU AS(GNUアセンブラ)固有のディレクティブについてまずは見ていきます。

  • ALIGN
    ALIGN nは命令をメモリに配置する際の境界を設定します。2のn乗バイトを境界とし、命令を配置する設定になります。RISC-Vの標準命令は32bitのため、nには2を指定します。

  • EQU
    定数の定義に使用します。

  • SECTION
    次の別のSECTIONが定義されるまでにあるラベルやコードを対象に、名前を付けます。この名前は外部、Linker Scriptから参照します。

  • GLOBAL
    他のファイル、Linker Scriptから参照できるシンボルを作成します。

EQUの部分を見ていきます。実際にはこれらの定義はまだ未使用のため、記述しなくても動作に支障はありません。

.equ UART_BASE, 0x10000000
.equ REG_RBR, 0x00
.equ REG_THR, 0x00
.equ REG_IIR, 0x02
.equ LSR_RX_RDY, 0x01
.equ LSR_TX_RDY, 0x20
  • UART_BASE
    UARTはユニバーサル非同期送受信機 (Universal Asynchronous Receiver/Transmitter)を意味し、これを用いることでシリアル通信を行うことができます。QEMUのvirtでは0x10000000にマップされており、このアドレスを用いることで入出力を行うため、UART_BASEというわかりやすい名前で定義します。

  • REG_RBR, REG_THR
    RBR(Receive Buffer Register)とTBR(Transmit Holding Register)は、シリアル通信の受信と送信のためのもので、どちらもオフセット0で指定します。

  • REG_IIR
    IIR(Interrupt Identification Register)は、UARTで発生した割り込みの種類を示すレジスタです。

  • LSR_TX_RDY, LSR_RX_RDY
    TX(送信)とRX(受信)の準備が完了しているかどうかを意味します。
    LSR_RX_READYはRBRにデータがある場合(受信可能)で、LSR_TX_RDYは書込みが可能であることを示します。

SECTIONを見ていきます。ここでは、.textという名前のSECTIONを定義し、GLOBAL(他のファイルから参照できるシンボル定義)にboot_entrystack_popという2つのシンボルを定義しています。

.section .text
.globl boot_entry
.globl stack_top

.section .textの、.textという名前は自由に名前をつけることができますが、一般に「実行コード」を配置する際の名前として取り扱われます。実際にどのような実行コードが配置されるかは、Linker Scriptによって決められます。

※.sectionと.globalというそれぞれのディレクティブは独立しているもので、ひとまとめに書く必要はありません。

SECTIONの名前として良く使われるものとして、以下のようなものがあります。

  • .text : 実行コード
  • .data : 初期化済み変数
  • .bss : 初期化変数
  • .rodata : 読み取り専用データ
  • .init : 初期化専用コード
  • .trap : 割り込みベクタ専用コード
  • .stack : スタック領域

.globl boot_entryは後述のboot_entry:というラベル関係し、外部のLinker Scriptから参照できるようにするためGLOBALを使用しています。また、.global stack_topはスタックの先頭アドレスを示すもので、これもLinker Scriptから参照するため、GLOBALを使用します。

boot_entry:ラベルの箇所を見ます。ここは、インデントがあるようにまとまりのある部分で、この部分が処理として最初に実行されます。

boot_entry:
  # スタック初期化
  la sp, stack_top

  # メイン関数呼び出し
  call main

la(Load Address)命令は、一番目に指定するスタックポインタ(sp)に対して、2番目に指定するシンボルのアドレスをロードします。ここではstack_topを指定することで、スタックの先頭アドレスを設定しています。
start.s内にはstack_topに対してアドレスを設定しているようには見えませんが、これは後述するLinker Scriptのところで解決されるため、現時点ではstack_topはスタックの先頭アドレスということだけ考えていれば問題ありません。

call mainは、Cのmain関数を呼び出す指定になります。

続いて記述しているloopは、call main後の処理について記述します。何もしない場合プログラムの暴走を引き起こす可能性があるため、j(jump)命令でloopというラベルに行くという処理を記述しています。これによりmain関数終了後は、永遠にループする状態になります。

loop:
  j loop

.section .text以降にあるboot_entry:loop:とコードは、全て.textセクションに属しているということに留意して、次に進みます。

Linker Script

start.sで記述したstack_topや、.section .textboot_entryに対して、Linker Scriptで追加の設定を記述します。最初に全体像を記載します。以下の内容をlinker.ldという名前で作成します。

OUTPUT_ARCH("riscv")
ENTRY(boot_entry)

SECTIONS
{
  . = 0x80000000;

  .text : {
    *(.text)
  }

  . += 0x8000;
  stack_top = .;
  program_end = .;
}

OUTPUT_ARCH("riscv")は、どのアーキテクチャ向けなのかを明示する記述です。次のENTRYは、start.sのどのラベルを最初に実行するかを指定するもので、boot_entryを指定しています。ここで参照する必要があるため、start.sではGLOBAL宣言をしました。

SECTIONSの中を見ていきます。SECTIONSは、プログラムやデータ、スタック領域などをメモリ上にどのように配置するかを決めるものです。

.= 0x80000000;

最初の行は、配置を開始するメモリのアドレスを指定します。QEMUのvirtマシンでは、メモリ(RAM)の開始アドレスが0x80000000のため、その値を設定しています。

.text : {
  *(.text)
}

*(.text)は、全てのオブジェクトファイル(*.o)の.textセクションにあるコードを対象とするという意味になります。start.sstart.oになりますし、main.cmain.oになるため、start.omain.oの、.textセクションが対象になります。

.text : {
}

囲っているこの部分は、最終的に出力するELFファイルに、括弧内で検出した対象を.textセクション(コード実行セクション)を作成し出力するよう指定するものです。

※main.cをmain.oにした際、自動的に.section .textが指定されている状態になります。main.oを作成した後、以下のコマンドで確認することができます。

$ riscv64-elf-objdump -h main.o

スタックポインタの部分に移ります。

. += 0x8000;
stack_top = .;

SECTION開始時点で0x80000000のアドレスから開始しており、.textで実行コードが配置されている状態ということを前提に、. += 0x8000は、実行コードが配置された最後の部分から32KB分進んだところを現在のアドレスにするという指示になります。

stack_top = .により、32KB分進んだところのアドレスを、スタックポインタの先頭アドレスとして設定しています。これは、start.sのGLOBALにより定義しているstack_topに対応しています。

最後に終端を示すために、ここではスタックポインタの先頭アドレスと同じですが、現在のアドレスをprogram_endに設定します。この名前は特別定められたものではありません。

program_end = .;

起動コードとなるstart.sと、コードをどのように配置するか(どのようにELFに出力するか)、どこから実行するのかを定めるlinker.ldが用意できたので、Cのプログラムに移ります。

Cのコード

ベアメタルでは、stdio.hなどの標準入出力関数が最初から使えない状態です。そのため、Hello, Worldなどの文字を出力するためだけでも、自分で処理を用意する必要があります。

先に、Cのコード、main.cの内容を記載します。

#define UART_BASE   0x10000000
#define UART_RBR    (*(volatile unsigned char *)(UART_BASE + 0x00)) // 受信
#define UART_THR    (*(volatile unsigned char *)(UART_BASE + 0x00)) // 送信
#define UART_LSR    (*(volatile unsigned char *)(UART_BASE + 0x05)) // ステータス

#define LSR_RX_READY 0x01  // 受信バイトあり
#define LSR_TX_READY 0x20  // 送信バッファ空きあり

// 1バイト受信(受信バッファにデータが来るまで待つ)
char
getchar()
{
  while ((UART_LSR & LSR_RX_READY) == 0);
  return UART_RBR;
}

// 1バイト送信(送信バッファが空くまで待つ)
void
putchar(char c)
{
  while ((UART_LSR & LSR_TX_READY) == 0);
  UART_THR = c;
}

void
print_string(const char *s)
{
  while (*s != '\0') {
    putchar(*s);
    s++;
  }
}

void
main(void)
{
  print_string("Hello,World\n");
}

アドレス

以下のdefineは、start.sでも定義したUARTのアドレス、送受信に使うアドレスに加えて、送受信が可能かどうかを確認するためのLSRを定義しています。

#define UART_BASE   0x10000000
#define UART_RBR    (*(volatile unsigned char *)(UART_BASE + 0x00)) // 受信
#define UART_THR    (*(volatile unsigned char *)(UART_BASE + 0x00)) // 送信
#define UART_LSR    (*(volatile unsigned char *)(UART_BASE + 0x05)) // ステータス

#define LSR_RX_READY 0x01  // 受信バイトあり
#define LSR_TX_READY 0x20  // 送信バッファ空きあり

getchar、putchar

UARTに対しての送受信を行うことで、getchar、putcharを実装します。

char
getchar()
{
  while ((UART_LSR & LSR_RX_READY) == 0);
  return UART_RBR;
}

void
putchar(char c)
{
  while ((UART_LSR & LSR_TX_READY) == 0);
  UART_THR = c;
}

whileで、送信が可能になるまで、受信が可能になるまでループで待機し、受信の場合にはUART_RBRを返し、送信の場合にはUART_THRに値を設定します。どちらも1バイトずつの単位になります。

1文字ずつだと大変なので、文字列を文字に分解してputcharに送るためのprint_stringという関数を作ります。

void
print_string(const char *s)
{
  while (*s != '\0') {
    putchar(*s);
    s++;
  }
}

終端文字\0が現れるまでwhile内で1バイトずつUARTに送ります。

main関数

最後にmain関数で、print_stringを呼び出します。

void
main(void)
{
  print_string("Hello,World\n");
}

ビルド

  • start.s
  • linker.ld
  • main.c

の3つのファイルが用意できたら、ビルドを行います。ビルドにはMakefileを使います。

CC=riscv64-elf-gcc
LD=riscv64-elf-ld

CFLAGS=-nostdlib -nostartfiles -fno-builtin -march=rv64g -mabi=lp64d -fPIC

all: main.elf

main.elf: start.o main.o
	$(LD) -T linker.ld -o $@ \
		start.o \
		main.o

%.o: %.c
	$(CC) $(CFLAGS) -c $<

%.o: %.s
	$(CC) $(CFLAGS) -c $<

run: main.elf
	qemu-system-riscv64 \
		-machine virt \
		-nographic \
		-bios none \
		-kernel main.elf

clean:
	rm -f *.o *.elf

以下のコマンドでビルド、実行が行われます。

$ make run
qemu-system-riscv64 \
	-machine virt \
	-nographic \
	-bios none \
	-kernel main.elf
Hello,World

Hello,Worldが表示されれば成功です。Ctrl+a xQEMU: Terminatedという表示とともにQEMUが終了します。

Discussion