きっかけ
春節の帰省で乗った高速鉄道の中、知乎のGoプログラムをベアメタルで動かすという記事を読みました。システムインターフェースを実装し直して、Goプログラムのシステムコールや割り込みを引き受ける発想です。とても面白いと思いました。著者はGoでx86 OSのeggosも作っていて、かなりの完成度です。ランタイムを下から改造しているのでユーザープログラムは意識せずに済み、Goのサードパーティーライブラリもそのまま使えます。TCP/IPスタックまであり、ネットワークライブラリも動きます。読んでいて胸が高鳴りました。
先行事例を調べると、ずいぶん前からある発想でした。OSDI 2018には高級言語でOSを実装する利点とコストを論じた論文があり、スライドはこちらです。近年の実装には、GoでOSを作れることを示す実証用カーネルgopher-osがあります。MITの博士論文プロジェクトBiscuitはコンパイラを改造してベアメタルへ出力します。こちらはより完成度が高く、POSIXの一部に対応し、Redisやnginxまで動きます。
調べるうちに、すべてx86向けという共通点に気づきました。私は以前、Cで小さなRISC-Vカーネルを書いたことがあります。RISC-Vのアセンブリや各種の仕組みはシンプルで、書いていて気持ちがよいものです。そこで、GoでRISC-VのOSを作ろうと思いました。
思い立ったら即行動。帰省した翌日から始めました。
とにかく作る!
プロジェクトで大事なのは、名前を付けることです。いや、冗談ですが。
でも、実際に最初に思いついたのは最高の名前でした。gooseです。

これは天才的でしょう!
まずGoは64ビットRISC-V実行ファイルへのクロスコンパイルに標準対応しています。go buildの前にGOOS=linux GOARCH=riscv64を付けるだけで、便利です。
仮想マシンはいつものQEMU、プラットフォームもvirtです。メモリ配置は0x80000000以上が物理RAM、それ未満がMMIOです。後者は機器のメモリがマップされ、そこを操作すると機器を操作することになります。virtの起動時はPCが0x80000000になります。
一方、通常のGo実行ファイルはユーザー空間の仮想アドレスで動くので、入口は0x10000付近の低いアドレスです。幸い、GoにはTEXTセグメントの開始を指定するリンクフラグ-Tがあり、コード全体を高いアドレスに置けます。-Eで入口シンボルも指定できるので、Goの起動処理を引き受ける関数を書けます。Goの入口はmainではなく、初期化を行う_entryです。
ただし重大な問題が残ります。入口関数は指定できても、その関数の開始アドレスは指定できません。0x80000000へ配置できなければ、virt起動時にそこに何のコードがあるか分かりません。Cならリンカースクリプトで入口のアドレスを1行指定するだけです。しかし相手はGoです。
調べてStack Overflowの質問を見つけました。内蔵リンカーではなく外部リンカーを使えばスクリプトを指定できるそうです。ただ試すと現実的ではありません。Goの実行ファイルにはtext、bss、rodata、data以外にも独自のいろいろなセクションがあり、すべてスクリプトで明示する必要があります。ほぼ無理でした。
そこで方針転換です。入口をCで書き、Goの入口を動的に調べて飛べばよさそうです。入口情報はELFファイルにしかなく、ロード後のメモリ像にはありません。ならばELF全体をバイナリとしてCプログラムのdataへ埋め込みます。始点と終点を_binary_kernel_elf_start、_binary_kernel_elf_endと名付ければ、Cからすぐ見つけられます。C側はそのELFを解析し、各ロード対象セグメントを対応するアドレスへコピーして、ELFが指定する入口へ飛びます。
入口のアセンブリは次のとおりです。スタックを設定してC関数へ飛び、dataの2つのシンボルの間にコンパイル済みGo実行ファイルを埋め込みます。
.section .text.entry
.globl _start
# 仅仅是设置了 sp 就跳转到 main
_start:
la sp, bootstacktop
call bootmain
# 启动线程的内核栈 bootstack 放置在 bss 段的 stack 标记处
.section .bss.stack
.align 12
.global bootstack
bootstack:
# 以下 16K 字节的空间作为 OS 的启动栈
.space 0x4000
.global bootstacktop
bootstacktop:
.section .data
.globl _binary_kernel_elf_start
.globl _binary_kernel_elf_end
_binary_kernel_elf_start:
.incbin "kernel.elf"
_binary_kernel_elf_end:
C関数bootmainも単純です。ELFとプログラムヘッダーテーブルを読み、各セグメントを必要な物理メモリへロードします。
void
bootmain()
{
struct elfhdr *elf;
struct proghdr *ph, *eph;
void (*entry)(void);
uchar *pa;
elf = (struct elfhdr *)(_binary_kernel_elf_start);
if (elf->magic != ELF_MAGIC)
return;
ph = (struct proghdr *)((uchar *)elf + elf->phoff);
eph = ph + elf->phnum;
for (; ph < eph; ph++)
{
pa = (uchar *)ph->paddr;
readseg(pa, ph->filesz, ph->off);
if (ph->memsz > ph->filesz)
clearMem(pa + ph->filesz, ph->memsz - ph->filesz);
}
entry = (void (*)(void))(elf->entry);
entry();
}
最後のentryはELFヘッダーから読んだGoの入口関数のアドレスです。そこへ飛ぶだけです。
Go側の入口rt0はアセンブリ関数です。Goが使うのは古のOS、Plan 9に由来するPlan 9アセンブリです。複数の命令セットに対応しますが、各アーキテクチャで使える命令を示す公式資料がなぜか見つかりません。例の大半がx86なのでそちらは多少情報があるものの、RV64は跡形もなく、完全に勘頼みでした。
試行錯誤の末、ようやく入口を書けました。
#include "textflag.h"
TEXT ·rt0(SB),NOSPLIT|NOFRAME,$0
CALL ·kernelStackTop(SB)
MOV 0(SP), A1
MOV A1, SP
CALL ·kmain(SB)
UNDEF
RET
この書式もなかなか異様です。やることは同じで、kernelStackTopから確保済みスタックのトップのアドレスを取得し、SPをそこへ向けてGo側のkmainを呼びます。唯一のGoファイルも簡単です。
type stack [16 * 4096]byte
type virtualAddress uintptr
var (
kstack stack
)
//go:nosplit
func (s *stack) top() virtualAddress {
stackTop := uintptr(unsafe.Pointer(&s[0])) + unsafe.Sizeof(*s)
// Align to 16 bytes.
stackTop = stackTop &^ 0xf
return virtualAddress(stackTop)
}
//go:nosplit
func kernelStackTop() uint64 {
return uint64(kstack.top())
}
//go:nosplit
func rt0()
//go:nosplit
func kmain() {
for {
}
}
stack配列をカーネルスタックとして先に確保し、kmainは無限ループするだけです。各関数には//go:nosplitが付き、スタックオーバーフロー検査の挿入を止めています。さらにGCチェックポイントの挿入を防ぐ暗黙の役割もあります。何も実装していないベアメタル環境ではGCは動かせません。そもそもGCはカーネルでなく、主にユーザー空間のヒープを扱うものですが。
Makefileはこう書けます。
Image: kernel.elf
$(CC) $(CFLAGS) -fno-pic -O -nostdinc -I. -c boot/boot.c
$(CC) $(CFLAGS) -fno-pic -nostdinc -I. -c boot/boot_header.S
$(LD) $(LDFLAGS) -T image.ld -o Image boot.o boot_header.o
kernel.elf:
GOOS=linux GOARCH=riscv64 go build -o kernel.elf -ldflags '-E goose/kernel.rt0 -T 0x80200000' -gcflags "-N -l" ./kmain
kernel.elfではGoのELFを作り、入口をgoose/kernel.rt0、TEXTの開始を0x80200000にします。Imageは前述のカーネル読み込みコードをビルドします。image.ldでは入口関数をTEXTの先頭に置き、TEXT自体を0x80000000に配置します。
/* 执行入口 */
ENTRY(_start)
/* 数据存放起始地址 */
BASE_ADDRESS = 0x80000000;
SECTIONS
{
/* . 表示当前地址(location counter) */
. = BASE_ADDRESS;
/* start 符号表示全部的开始位置 */
kernel_start = .;
text_start = .;
/* .text 字段 */
.text : {
/* 把 entry 函数放在最前面 */
*(.text.entry)
/* 要链接的文件的 .text 字段集中放在这里 */
*(.text .text.*)
}
...
}
よし!
あまりに夢中で、春節中の親戚回りもろくにできませんでした。一日中部屋で資料を集め、外でもぼんやり手法を考えていて、取りつかれたようでした。
大失敗
じゃじゃーん!
QEMUにカーネルを読み込んでデバッグすると、セグメントをメモリにロードするところで固まっていました。go buildのELFをreadelfで調べたところ、奇妙なものがありました。
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x00000000801ff040 0x00000000801ff040
0x0000000000000188 0x0000000000000188 R 0x10000
NOTE 0x0000000000000f9c 0x00000000801fff9c 0x00000000801fff9c
0x0000000000000064 0x0000000000000064 R 0x4
LOAD 0xffffffffffff1000 0x00000000801f0000 0x00000000801f0000
0x0000000000063300 0x0000000000063300 R E 0x10000
LOAD 0x0000000000060000 0x0000000080260000 0x0000000080260000
0x000000000006adb8 0x000000000006adb8 R 0x10000
...
3つ目のOffsetが0xffffffffffff1000という巨大な値です。Offsetはファイル先頭からセグメント内容までの位置ですが、このELFは数十KBしかありません。こんなオフセットがどこから出るのでしょう。メモリに載せてもvirtの標準RAMは128MBなので、即座に破綻します。
分からずに実験した結果、リンク引数-Tを付けると必ず起きると判明しました。しかし省くこともできません。低アドレスはMMIOなので、そこにロードできないからです。GoのGitHubにcmd/link: wrong program header offset when cross-compile to riscv64 when setting -T text alignmentを立て、状況を説明すると次の返事が来ました。

どうやらRV64の-T対応が不完全なようです……。
こうしてプロジェクトは今まで棚上げです。せっかくのいい名前がもったいない /(ㄒo ㄒ)/ Go公式の修正を待つしかありませんが、RV64にはあまり力が入っていない気もします。RV64への標準クロスコンパイルがメインラインに入ったのも、ここ数年ですし……。
腹が立つので、Rustに行きます!
