💾

ベアメタルプログラミングに入門して自作CPUでプログラムを実行しよう

2022/12/21に公開

この記事はEEIC Advent Calendar 2022の17日目の記事です。

自作CPUにありがちな悩み「で、これどうやって使うん」

自作CPU作ったものの自分の作ったプログラムの入れ方わからない問題ありがち

いきなりですが、人間誰もが1度はCPUを自作したいと思ったことがあると思います。CPUを自作するときはざっくりと以下のような流れでやることが多いはずです。

  1. 命令セットを作る。あるいは命令セットをどこからか持ってくる
  2. 命令セットにあった形で各種パーツを作る
  3. 単体テストをする
  4. 結合する
  5. 結合テストをする
  6. 完成して喜ぶ😊

完成したCPUでは、大概なんらかのプログラムを実行することになると思いますが、これに焦点を当てた記事はあまり見かけない気がします。

EEICの実験に関連した余談

EEICのマイクロプロセッサ実験では命令セットはRISC−Vを使用して、これらを10日間(の講義日と残業時間)で行います。この実験では、FPGAを用いて、実際にCoremarkを動かすところまでを行いますが、学生が行うのはCPUを作って焼くところまでで中で動かすプログラムのあれこれや、実行ファイルの書き込みはTAの用意してある非常に便利なツール郡に依存しています。(東大理情では3年冬学期の半年間をかけてCPUからコンパイラまでを作る)
幸いにも、自作CPUでプログラムを実行させる方法がわからない!ということが実験の範囲では起こらないわけですが、自分のプログラムの実行のさせ方は解説されないので中で何がどうなっているのかはブラックボックスのままだったりします。

工学部徒としては、作ったものは使われなければ意味がないし使うために作っていることが多いです。また、

  • 自作CPU
  • 自作OS
  • 自作コンパイラ

といったものの内容の良質な記事や書籍は増えている一方でハードウェアとソフトウェアの中間を埋める存在であるベアメタルプログラミングに関する情報はなんか見つけづらくね??と思ったのでこの記事をかきました。(自作OSの記事で読むと結構書いてあることが多いので、それを読めば良いという話もある)

またまた余談

まずそもそも無知の状態からベアメタルという言葉が出てくることはないのが辛いし、検索の仕方も難しい。
baremetalという言葉がわかっていればセキュキャンの資料などで優良な資料こそ手には入りますが…。実際、この資料の中でも低レイヤー資料の探し方は困難で、最初は最低限を人に教えてもらうのが良いと書かれています。

なおこの記事の本質は最後2つの わからないことが合った時に使えそうな低レイヤ検索ワードおよびおすすめ資料たちにあると思っています。この記事を読んでくれても嬉しいですが、とりあえず資料だけでも見るか、だけでもかなりの情報が手に入ると思います。

実行可能ファイルを生成するまでの流れ

ここではC言語と仮定してプログラムを実行可能ファイルにコンパイルする流れをたどると以下のようになります。

  1. プリプロセス
    a. マクロの展開や #include の展開
  2. コンパイル
    a. アセンブラへの変換
  3. アセンブル
    a. 機械語への変換
  4. リンク
    a. 複数のオブジェクトファイルやメモリの情報を合わせて実行ファイルを生成する

この流れで以上です。自作CPU(あるいはマイコンなどにベアメタルでプログラムを書く場合)、リンクの過程が問題になってきます。なぜなら、実行ファイルをメモリのどこに置くか、やLoadStore命令におけるメモリのアドレスは(直接指定しない限り)デバイスによって動的に変化するはずだからです。

1回アセンブルで機械語にしてるのになんでそのときにいらんのや!!って疑問に思った人向け詳細

僕も詳しくないので調べました。wikipedia (英語版)で object fileと調べると、色々有意義な情報が出てきます。僕的に要約すると

  • オブジェクトファイルは異なるコンピュータで使用できるような再配置可能なファイルで通常直接実行は出来ない。
  • 再配置を可能にするためのシンボル情報やコメントなどが含まれる

ということです。ここでいう再配置 (reallocation)とはロードストアのメモリアドレスや、定数の置き場所、他のファイルに含まれる関数などをリンカスクリプト(後述)に応じて動的に再配置することとのことです。一般に、アセンブルでは決め打ちで適当にアドレスを決めていて、そのあとリンクの段階でさらに正しい位置に配置しなおしているようです。こうすることでデバイスごとの差異を吸収するレイヤーを変えられていいですね。実際、以下のようなコードのobject fileとelf fileを見比べると結構違います。

int main () {
  return 0;
}
gcc- c test.c #こっちはオブジェクトファイルを作る
gcc -o test test.c #こっちは実行ファイルを作る

ファイルサイズをそれぞれ見てみるとこんな感じで、結構サイズが違うのがわかります。

-rwxrwxr-x  1 hoge hoge  17K 1220 22:56  test*
-rw-rw-r--  1 hoge hoge 1.4K 1220 22:55  test.o
それぞれをバイナリエディタで見てみる

objectファイルの場合

objectfile
最初の30行でgccのバージョンや、ファイル名、関数名が書いてあることがわかる

実行ファイルの場合

executablefile
最初の30行はELF形式が書いてある以外はよくわからない
executablefile2
途中には指示していない静的ライブラリがリンクされているっぽいのが見える

これらから、objectファイル -> 実行ファイルも結構色々やることがあるのがわかる。

そうだ、リンカスクリプト書こう

リンクの重要性がわかったところで、リンクのためにどうすればいいかですが、端的にリンカスクリプトを書けば良いです。ざっくり言うと、プログラムがメモリの使用可能な領域を教えてくれるのがリンカスクリプトです。プログラムはコンパイル時にこれを参照しながら、他のオブジェクトファイルや、ライブラリと合わせて実行ファイルをリンクしながら再配置します。
一般にLinuxやWindowsでこれを書くことはないですが、

ld --verbose

とすると、いつも使われているリンカスクリプトが見れます。リンカスクリプトの書き方はあちこちで解説されていますが、ゼロから書こうとせずに一旦既存のものを見てどんなもんかなとやるのがいいと思います。例えばラズパイのLinkerScriptはGitHubで検索するといっぱい出てきます。

簡単なものだとこんなものがありました。
https://github.com/s-matyukevich/raspberry-pi-os/blob/master/src/lesson01/src/linker.ld

ここではゼロから書くのではなく、どんなものがいるのか(必ずしも必須でない場合も多い)だけ説明しておこうかなと思います。

OUTPUT_FORMAT/OUTPUT_ARCH

その名の通り、実行ファイルのバイナリ形式や、実行するアーキテクチャを書きます。

ENTRY

このENTRYは最初に実行されるというよりは、この関数がエントリポイント(=ここから必要な関数が導かれるで)というのを伝えるためにあるらしいです。

MEMORY

メモリのスタート、長さ、属性(executable/writable/readable)を定義できます。ROM(FLASH)とRAMを書くことが多いと思います。

SECTION

1番大事です。どのセクションをどこに置くのかを書きます。以下の4つが最低限のもので、マイコンとかだと割り込みベクタとして .isr_vector がよくあるイメージ。あとはstack領域などを定義する場合も書かれると思います。

名前 役割
.text プログラムを置く場所
.data 初期値のある変数を置く場所
.rodata 定数を置く場所
.bss グローバル変数とか初期値を持たない変数の置き場所 最初に0で初期化する

ALIGN

メモリのアライメントを設定するやつです。結構大事らしい

ちなみに、リンカスクリプトで定義した変数はCから参照出来ます。

extern int _sdata;

みたいな感じです。
他にも中身は色々あると思いますが、全部僕も知らないしキリがなさそうだし、ここまできたらある程度は検索出来る気がするのでこの程度にしておきます。

自作CPUで動く実行ファイルの作り方

今、命令メモリが0x8000から確保されていて、リンカスクリプトが以下のようになっているとします。(このリンカスクリプトは実際のEEICの実験で用いられたものです。作成者はTAの方です。ありがとうございます。)

OUTPUT_FORMAT("elf32-littleriscv");
OUTPUT_ARCH("riscv")

ENTRY(_start);
MEMORY {
    ROM(rxai) : ORIGIN = 0x8000, LENGTH = 32k
    RAM(wa) : ORIGIN = 0x10000, LENGTH = 32k
}

SECTIONS
{
    .text : { *(.text) } > ROM
    .rodata : { *(.rodata) } > RAM
    .data : { *(.data) } > RAM
    . = ALIGN(4);
    __bss_start = .;
    .bss : {*(.bss)} > RAM
    __bss_end = . ;
    .comment : { *(.comment) } > RAM
}

復習をすると、

  • elf32-littleriscv形式で実行ファイルを作る
  • riscvで動かす
  • エントリポイントは_start
  • ROMは0x8000番から32k
  • RAMは0x100000番から32k
  • .textをROMに置く
  • .rodata, .data, .bss, .commentはRAMに置く。
  • __bss_start, __bss_endは取得しておく(0にリセットするときに使うため)

という内容になっています。
あとはこれに従ってプログラムを書いてあげれば良いです。すなわち_startという名の関数を先頭に配置するようにプログラムを書き、リンクすれば良いです。例えば

  • _start でレジスタの初期化を行い、_bssresetにjumpする
  • _bssresetでbss領域を0に初期化(Cで書けば良い)してmainにjumpする
  • mainにやりたいことを色々かく

というふうにしてあげれば良いでしょう。
あとは、それぞれのプログラムからオブジェクトファイルを生成してからリンカスクリプトとオブジェクトファイルを使って実行ファイルを生成します。例えば

riscv32-unknown-elf-gcc -o main.o -c main.c #実際にはライブラリのリンクなどをする必要があるかも
riscv32-unknown-elf-gcc -o startup.o -c startup.s
riscv32-unknown-elf-ld -o main.elf startup.o main.o -T link.ld #ここでリンク

とすれば実行ファイルが出来るでしょう。CPUや、マイコンによってはこれをさらにobjcopyするなどする必要があるかもしれませんが基本的にここまでの流れは同じだと思います。あとは自分のプログラムを実際に動かすだけです!

まとめ

CPUを自作したときに、そのCPUで実行するファイルを作る方法を述べるためにベアメタルプログラミングの方法について書きました。これを通じてEEICの人はマイクロプロセッサ実験でCPUを動かすのにどんなことを裏でしていたのか、そうじゃない人はベアメタルでまっさらなCPUでプログラムを実行する方法について少しでも興味をもってもらえれば嬉しいです。おまけで検索ワード集と資料集も作っておいたので、よかったら見ていってください。あとアドベントカレンダー遅刻してすいませんでした(課題が悪い)

わからないことがあったときに使えそうな低レイヤ検索ワード

検索ワード どんなときに使いやすい?
リンカスクリプト リンカスクリプトの作り方がわからんときに直球ワード
組み込みOS マイコンなどでベアメタルする方法がよく書いてあるので使いやすいワード
ベアメタル とりあえずこれで知識を手に入れるのに使おう
ラズパイ ベアメタル ラズパイは使用人口が多いため、簡単に情報が入手できる

おすすめ資料たち

Discussion