Writing an OS in 1000 Linesという自作OSを学ぶための教材があり、これをD言語(LDCコンパイラ)で行った記録です。
先行事例
他言語実装という枠では、RustやZigですでにやられている方がいました。
Rust
- Totsugekitai/kanios
Zig
- bokuweb/zig-os-in-1000-lines
成果物
レポジトリはココです。オリジナル同様に ./run.sh
で起動するようになっています。
記録
ハマったところ
shellプログラムの開始アドレスがおかしい
こちらの内容で、shell.elf
の .text.start
セクションが実行アドレスの先頭に配置されて 0x1000000
アドレスにstart関数がきてほしいのに、0x01000054
になってしまっている、という現象です。
$ llvm-objdump -d shell.elf shell.elf: file format elf32-littleriscv Disassembly of section .text: 01000054 <start>: 1000054: 37 05 01 01 lui a0, 4112 1000058: 13 05 85 07 addi a0, a0, 120 100005c: 13 01 05 00 mv sp, a0 1000060: 97 00 00 00 auipc ra, 0 1000064: e7 80 40 01 jalr 20(ra) 1000068: 97 00 00 00 auipc ra, 0 100006c: e7 80 80 00 jalr 8(ra) 01000070 <exit>: 1000070: 6f 00 00 00 j 0x1000070 <exit> 01000074 <main>: 1000074: 6f 00 00 00 j 0x1000074 <main>
shell.Map
をみてみると、例外情報を入れるための .eh_frame
セクションが先頭にきてしまっていることがわかりました。
VMA LMA Size Align Out In Symbol 0 0 1000000 1 . = 0x1000000 1000000 1000000 54 4 .eh_frame 1000000 1000000 14 1 shell.o:(.eh_frame+0x0) 1000014 1000014 3c 1 shell.o:(.eh_frame+0xb4) 1000054 1000054 24 4 .text 1000054 1000054 1c 4 shell.o:(.text.start) (...)
これはリンカスクリプトの側で .eh_frame
を入れることで対処しました。
(...) /DISCARD/ : { *(.eh_frame) } (...)
デバッグコンソールへの入力判定がおかしい
こちらの内容で、 sbi_call
が常に ret.error
で0になってしまう、という問題です。
long getchar() { printf("kernel: getchar called\n"); sbiret ret = sbi_call(0, 0, 0, 0, 0, 0, 0, 2); printf("sbiret.error = %d\n", ret.error); printf("sbiret.value = %d\n", ret.value); return ret.error; }
$ ./run.sh (...) Hello, World from shell! > kernel: getchar called sbiret.error = 0 sbiret.value = 0 kernel: getchar called sbiret.error = 0 sbiret.value = 0 kernel: getchar called sbiret.error = 0 sbiret.value = 0 kernel: getchar called
これはABIの解釈の問題でした。
RISC-V 32ビットのInteger ABI・ilp32
では int, long, pointers are 32bit
となっています。
一方でD言語のlongはどの環境でも64ビット固定なので、ここでズレが生じていました。
構造体を適切な型に修正することで解決しました。
struct sbiret { int error; int value; }
バッファが意図しない壊れ方をする
以下のようなシェルプログラムを実行した際に、`cmdline[1] が \0 に上書きされてしまうような現象に遭遇しました。
char[8] cmdline; for (int i = 0;; i++) { char ch = cast(char) getchar(); putchar(ch); if (i == cmdline.sizeof - 1) { printf("command line too long\n"); for (;;) {} goto prompt; } else if (ch == '\r') { printf("\n"); printf("cmdline[1]: %d\n", cmdline[1]); cmdline[i] = '\0'; printf("cmdline[%d]: %d\n", i, cmdline[i]); printf("cmdline[1]: %d\n", cmdline[1]); break; } else { cmdline[i] = ch; } } for (int j = 0; j < 8; j++) { printf("*cmdline[%d]: %d\n", j, cmdline[j]); }
Hello, World! 1 + 2 = 3, 1234abcd > hello cmdline[1]: 101 cmdline[5]: 0 <-- ここが '\0' になるのはいい cmdline[1]: 0 <-- なぜかここも '\0' になる! *cmdline[0]: 104 *cmdline[1]: 0 *cmdline[2]: 108 *cmdline[3]: 108 *cmdline[4]: 111 *cmdline[5]: 255 <-- ここは書き変わってない! *cmdline[6]: 255 *cmdline[7]: 255
これは最適化を外したら期待どおり動き、また --disable-loop-unrolling
しても期待通りに動いた点から、cmdline(というよりスタックポインタ)がアラインメントされてないときに最適化すると壊れてしまうLLVM RISC-Vバックエンドのバグではないかと疑っていました。
その後、PG_MANAさんの調査により、スタックポインタは16byte-alignedされていなければならないというRISC-Vの仕様があり、リンカスクリプトがその仕様を守っていなかったのが原因であることがわかりました。
こちらのバグはPG_MANAさんがすでに報告し、修正対応済となっています。PG_MANAさん、ありがとうございました!
strcmp関数がヌル終端文字の次の要素を比較してしまう
strcmp関数の実装は正しいはずなのに、ヌル終端文字の次の要素を比較してしまうためにコマンド名の一致判定が正しく行われないという現象が起きました。
これはmemcmp関数のバグが原因だったのだが、究明を難しくしたのがLLVMのsimplificaion of libcallsという最適化です。
LLVMは最適化オプションをつけると、よくあるライブラリ関数で可能な最適化を名前ベースでad-hocに置き換えます。そのためここではstrcmp関数をmemcmp関数に置き換えた結果、strcmp関数の実装をいくら修正しても正しく実装できませんでした。
これで --disable-simplify-libcalls
オプションを外せそうですが、新しいLLVMではprintf関数をad-hocにputs関数に置き換えるようになっており、puts関数を実装していない場合はエラーになってしまいます。
s$ ./run.sh + QEMU=qemu-system-riscv32 + LDC=/home/kubo39/dev/build-ldc/bin/ldc2 # 自分がLDC開発してる版のバイナリ + DFLAGS='--mtriple=riscv32-none-unknown --mattr=+m --mabi=ilp32 -O2 --betterC --boundscheck=off --checkaction=halt --defaultlib= -relocation-model=static -g -gcc=clang' + OBJCOPY=/usr/bin/llvm-objcopy + /home/kubo39/dev/build-ldc/bin/ldc2 --mtriple=riscv32-none-unknown --mattr=+m --mabi=ilp32 -O2 --betterC --boundscheck=off --checkaction=halt --defaultlib= -relocation-model=static -g -gcc=clang -Xcc=--target=riscv32 -Xcc=-march=rv32im -Xcc=-mabi=ilp32 -Xcc=-ffreestanding -Xcc=-nostdlib -Xcc=-Wl,-Tuser.ld -Xcc=-Wl,-Map=shell.Map -of=shell.elf shell.d user.d common.d ld.lld: error: undefined symbol: puts >>> referenced by common.d:0 >>> shell.o:(main) >>> referenced by shell.d:10 >>> shell.o:(main) >>> referenced by shell.d:10 >>> shell.o:(main) clang: error: ld.lld command failed with exit code 1 (use -v to see invocation) Error: /usr/bin/clang failed with status: 1
この手のめんどうを回避したいので(まあ今後触る機会があるかわからないが)、引き続き上記オプションをつけるようにしています。
LDC特有の話
LDC inline assemly expressions
C言語だとこんな感じでレジスタを指定して値を入れるコードが素直に書けず冗長になってしまいますが、LDCのインラインアセンブリではいい感じに書けます。
sbiret sbi_call(int arg0, int arg1, int arg2, int arg3, int arg4, int arg5, int fid, int eid) { return __asm!sbiret( "ecall", "={a0},={a1},{a0},{a1},{a2},{a3},{a4},{a5},{a6},{a7},~{memory}", arg0, arg1, arg2, arg3, arg4, arg5, fid, eid ); }
現状LDCはLLVM inline assemlyを最適化で消すようなコードにはなっていないのでvolatileの指定はないですが(注: ここでsideeffect=trueを常に指定しているが、これはvolatile相当の振る舞いになる)、将来的には変わる可能性はあります。
他の言語だとRustも独自asm構文持ってていい感じに書けてますね。
align指定
Rustと違う点として、D言語は構造体のフィールド単位でのアラインメントを指定できます。
struct virtio_virtq { align(1): virtq_desc[VIRTQ_ENTRY_NUM] descs; virtq_avail avail; align(PAGE_SIZE) virtq_used used; int queue_index; ushort* used_index; ushort last_used_index; }
まあC言語はあるわけですが、これはRustとかZigなんかだと意外とないっぽいみたいです。なくてもなんとかなるっちゃなるもんですが、今後機能追加されるんでしょうかね。
さいごに
まずはこちらの素晴らしい資料を作成していただいたseiyaさんに感謝します。 特にqemuでのデバッグについてここまで親切に書かれている入門資料はなかなかないのではないでしょうか。是非大勢の人に挑戦して欲しいなと思います。
あとは、なんやかんや全部実装終えるのに一週間くらいかかってしまいました。。もっと自作OSの練度上げたいですね。。
あとLLVMのsimplification of libcalls最適化には驚かされました(婉曲表現)。 これは自作OSデバッグの難易度を上げてくれる機能で闘いがいがありますね(白目)。