kubo39's blog

ただの雑記です。

Writing OS in 1000 lines をD言語でやった

Writing an OS in 1000 Linesという自作OSを学ぶための教材があり、これをD言語(LDCコンパイラ)で行った記録です。

先行事例

他言語実装という枠では、RustやZigですでにやられている方がいました。

Rust

Zig

成果物

レポジトリはココです。オリジナル同様に ./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
    );
}

現状LDCLLVM 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デバッグの難易度を上げてくれる機能で闘いがいがありますね(白目)。