Raspberry Pi Zeroでベアメタルコンテキストスイッチ

2018-12-27

Raspberry PiでRaspbianを入れて動かすと普通にLinuxとして使えるけどまあそれじゃ特に普通のPCと変わらないよね… ということでゆくゆくはオレオレOSを…などと考えてコンテキストスイッチをベアメタルでやってみることにした。

環境構築

Linux(Ubuntu)にARM用のクロスコンパイラをインストールする

$ sudo apt install gcc-arm-none-eabi

元にするプロジェクト

Raspberry Pi Zero WでベアメタルLチカ – 楽しくやろう。で公開されている led_blynkがシンプルでわかりやすかった。 ビルドするとkernel.imgが作成される。 (Makefileのクロスコンパイル設定がarm-eabi-なのでarm-none-eabi-に変更する)

このブログなどをいろいろ見たところによると、Raspberry Pi Zero(ARM)はファームウェアの起動後、kernel.imgが0x8000から実行されるらしい。

SD Cardに書き込んでRaspberry Pi Zeroで動かす

ブログ記事によると

makeするとkernel.imgが作成されますので、これをbootcode.binおよびstart.elfと共にSDカードへコピーするとLチカが動作します。
最新のbootcode.binとstart.elfはRaspberry PiのGitHubからダウンロードして下さい。

と書かれているが、まっさらの状態からのSD Cardの書き込み方がよくわからなかった…。

フォーマットした状態のSD CardにいったんRaspbianのイメージを書き込んで起動させるとあれこれとファームとなるファイルたち?が作られるので、その状態でから始める。 MacでSD Cardの口に挿入すると /Volumes/boot/ に見えるので、ビルドした kernel.img をコピーしてやる:

$ cp kernel.img /Volumes/boot/  # kernel.imgをSD Cardにコピー
$ diskutil umount /Volumes/boot/ # アンマウントしてからSD Cardを取り出す

書き込んだらSD CardをRaspberry Pi Zeroに差し替えて起動するとLチカが動いた。

QEMUで動かす

最終的に実機で動かすというのはいいんだけど、コードを修正して試すたびにSD Cardにコピーして差し替えて起動させるというのを毎回行うのは面倒だから、エミュレータ環境で動かせると便利。 led_blynkのリポジトリにある別ディレクトリのqemu-armがQEMUのARMエミュレータを使って動かせるものになっている。 rpi.ldを比べてみると、エミュレータでは開始アドレスが0x8000じゃなくて0x10000になるらしい。

ARMをエミュレートするMac用のQEMUのインストールは brew install qemu だけでいいのかな?忘れた…。 qemu-system-arm が動けばよし。 起動は:

$ qemu-system-arm \
-kernel kernel.img \
-append "qemu" \
-cpu arm1176 \
-M versatilepb \
-m 256 \
-no-reboot \
-nographic \
-monitor null \
-serial stdio \

UARTの出力をターミナルで受け取れる、便利。 ただターミナルからの入力はエコーバックされなかった…なぜだ。 エコーバックされていた、自然すぎてターミナルが行っているものかと思った。

また実機ではUARTのアドレスが違うかも?ということと、またどう接続すればやりとりできるのかわかってないので今のところUARTはエミュレータでしか試せてない。

ベアメタルコンテキストスイッチ

さて動かせる環境が整ったので、ベアメタルでコンテキストスイッチさせてみる。 語句の厳密の定義はともかく、ここではベアメタルというのはOS上じゃなくてCPU直接のまっさらな状態、 コンテキストスイッチは1つのCPUだけど複数のCPUがあるかのように実行を切り替えて並行に動かす、程度の意味。

コンテキストスイッチは本当に実行を切り替えるだけで、仮想アドレスとかカーネル/ユーザモードの切替、メモリの保護とかの処理はまったくしないことにする。 またコンテキストスイッチは自動には行われず、ある関数を明示的に呼び出して行うことにする(ノンプリエンプティブ、協調的)。

起動ルーチン

ブートして呼び出される関数:

void _start(void) {
uart0_init();

for (int i = 0; i < 3; ++i)
spawn(childfunc, i);

for (;;) {
uart_print("Parent\n");

wait();
}
}

spawnでプロセス(的なもの)を作成し、あとは無限ループ。 waitでコンテキストスイッチする。

子プロセス

便宜上「子」と呼んでいるが、親子関係があるわけではない:

void childfunc(int no) {
for (;;) {
uart_print("Child ");
uart_putint(no);
uart_print("\n");

wait();
}
}

spawnされる子プロセスも大本のプロセスと内容は変わらず、無限ループで自分の処理を行える。

プロセス情報とテーブル

プロセスが管理する情報と、そのテーブル:

typedef struct {
uintptr_t sp;
} Proc;

#define PROCMAX (16)

Proc ptable[PROCMAX];
int nproc = 1; // 0 is reserved for original stack.
int curProcIdx;

プロセス情報としては単にスタックポインタだけを保持するが、実際にはプロセスごとにスタック領域を割り当てる。 そしてコンテキスト情報の保存・復帰にスタックが利用される。

テーブルはプロセスの配列と個数、現在実行中の番号。

プロセスの生成

プロセスを生成するspawn関数:

extern void swtch(Proc* curr, Proc *next);

void spawn(void (*func)(), uintptr_t arg) {
int i = nproc++;
Proc* proc = &ptable[i];
const int STACK_SIZE = 1024;
void* stack = malloc(STACK_SIZE);
uintptr_t stackbottom = ((uintptr_t)stack) + STACK_SIZE;
uintptr_t* sp = (uintptr_t*)stackbottom;
*(--sp) = (uintptr_t)func; // Set function pointer as a return address.
for (int i = 0; i < 8; ++i) // R4~R11
*(--sp) = 0;
*(--sp) = arg; // R0
proc->sp = (uintptr_t)sp;
}

スタックを確保してけつに呼び出す関数を積む。 また初期のコンテキスト(退避レジスタ)と初期の引数を積んで、最終的なスタックポインタをプロセス情報として保持。

ベアメタルにmalloc関数は存在しないので自作する必要があるが、本題とは外れるので適当に:

static char heap[65536] __attribute__ ((aligned (16)));
static char* heapp = heap;

void* malloc(int size) {
const int ALIGN = 16;
void* p = heapp;
heapp += (size + ALIGN - 1) & -ALIGN; // アライメント
return p;
}

プロセス(コンテキスト)切り替え

プロセスを切り替えるwait関数:

void wait(void) {
int nextIdx = curProcIdx + 1;
if (nextIdx >= nproc)
nextIdx = 0;
Proc* curr = &ptable[curProcIdx];
Proc* next = &ptable[nextIdx];
curProcIdx = nextIdx;
swtch(curr, next);
}

単純に配列の順に進めてプロセスを切り替える。 コンテキスト切り替えのswtch関数に現在と次のプロセスを渡して切り替える。

swtchはCレベルでは書けないので、ARM用にアセンブラで書く:

// swtch.s
.globl swtch

// void swtch(Proc* curr, Proc* next);
swtch:
push {r0, r4, r5, r6, r7, r8, r9, r10, r11, lr} // Save context and return address to the current stack.
str sp, [r0] // Save stack pointer to the current proc.
ldr sp, [r1] // Restore stack pointer from the next proc.
pop {r0, r4, r5, r6, r7, r8, r9, r10, r11, pc} // Restore context and return to the saved address.

今回のコンテキストスイッチはノンプリエンプティブ、関数呼び出して切り替えるので、保存する必要があるレジスタはARMの呼び出し規約によればr4~`r11。 ARMは関数を呼び出す際に戻りアドレスを保存するのにスタックではなくlr`レジスタを使用するので、それもスタックに対比する。 その時点のスタックポインタを現在のプロセス情報として保存する。

そして次のプロセスに切り替える。 次のプロセスのスタックポインタを取り出してpopでコンテキストを復帰して、またlrpcpopすることで実行を再開する。

r0は保存する必要はないが、spawnで生成したプロセスが最初に呼び出す関数への引数として渡すためだけに追加している。

まとめ

ベアメタルでもコンテキストスイッチだけなら結構簡単に書ける。 C言語に慣れているとスタックポインタをいじってしまうなんてことしていいの?と感じるんだけど、CPUにとってみれば別にタブーではないのであった。

余談

*nix標準のfork形式にしようと思ったが、仮想アドレスを実装して分岐したプロセスが同じアドレスを指せるようじゃないとアドレスをずらす必要があって面倒なことになるため見送った。