ファイルシステム

本章はエナガ本13章 (ファイルシステム)の内容に対応しています。

ディスクの読み書きができるようになったので、ファイルの読み書きを実装しましょう。

tarファイルシステム

本書では、ちょっぴり面白いアプローチでファイルシステムを実装します。それは「tarファイルをファイルシステムとして使う」というものです。

tarファイルは、複数のファイルをまとめるアーカイブファイルです。tarファイルの中には、ファイルの内容とファイル名、作成日時などファイルシステムとして必要な情報が含まれています。FATやext2などの一般的なファイルシステム形式に比べ非常に簡素なデータ構造であるのと、馴染み深いであろうtarコマンドを使ってファイルシステムイメージを操作できるので、教育用にはもってこいのファイル形式なのです。

ディスクイメージの作成

まずはファイルシステムの内容を用意しましょう。diskディレクトリを作成し、その中に適当なファイルを作成します。一つはhello.txtという名前にしておきます。

$ mkdir disk
$ vim disk/hello.txt
$ vim disk/meow.txt

ビルドスクリプトにtarファイルの作成コマンドを追加し、それをディスクイメージとしてQEMUに渡すようにします。

run.sh
(cd disk && tar cf ../disk.tar --format=ustar ./*.txt)

$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
    -d unimp,guest_errors,int,cpu_reset -D qemu.log \
    -drive id=drive0,file=disk.tar,format=raw \
    -device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \
    -kernel kernel.elf

ここで使われているtarコマンドのオプションは次のとおりです:

  • cf: tarファイルを作成する
  • --format=ustar: ustar形式のtarファイルを作成する

なお、tarコマンドの行が丸括弧 (...) で囲われているのは、囲われたコマンドを独立したシェルで実行する サブシェル という機能です。これを使うことで、cd コマンドでのディレクトリ移動が括弧外に影響しないようにできます。

tarファイルの構造

tarファイルは、次のような構造をしています。

+----------------+
|   tar ヘッダ    |
+----------------+
|  ファイルデータ  |
+----------------+
|   tar ヘッダ    |
+----------------+
|  ファイルデータ  |
+----------------+
|      ...       |

つまり、「tarヘッダ」と「ファイルデータ」のペアがファイルの数だけ続いたものがtarファイルです。tarにはいくつかの種類がありますが、本書では ustar形式 (Wikipedia) を使います。

今回は、このファイル構造をそのままファイルシステムのデータ構造として利用します。

ファイルシステムの読み込み

まずはファイルシステム関連のデータ構造を定義します。kernel.hに次のように定義します。

kernel.h
#define FILES_MAX      2
#define DISK_MAX_SIZE  align_up(sizeof(struct file) * FILES_MAX, SECTOR_SIZE)

struct tar_header {
    char name[100];
    char mode[8];
    char uid[8];
    char gid[8];
    char size[12];
    char mtime[12];
    char checksum[8];
    char type;
    char linkname[100];
    char magic[6];
    char version[2];
    char uname[32];
    char gname[32];
    char devmajor[8];
    char devminor[8];
    char prefix[155];
    char padding[12];
    char data[];      // ヘッダに続くデータ領域を指す配列 (フレキシブル配列メンバ)
} __attribute__((packed));

struct file {
    bool in_use;      // このファイルエントリが使われているか
    char name[100];   // ファイル名
    char data[1024];  // ファイルの内容
    size_t size;      // ファイルサイズ
};

本書のファイルシステム実装では、全てのファイルを起動時にディスクからメモリへ読み込みます。各ファイルのtarヘッダ (struct tar_header) と、それに続くファイルの内容をfile構造体へ読み込みます。FILES_MAXが読み込む最大ファイル数、DISK_MAX_SIZEがディスクイメージの最大サイズです。

実際にファイルを読み込む処理が、次のfs_init関数です。

kernel.c
struct file files[FILES_MAX];
uint8_t disk[DISK_MAX_SIZE];

int oct2int(char *oct, int len) {
    int dec = 0;
    for (int i = 0; i < len; i++) {
        if (oct[i] < '0' || oct[i] > '7')
            break;

        dec = dec * 8 + (oct[i] - '0');
    }
    return dec;
}

void fs_init(void) {
    for (unsigned sector = 0; sector < sizeof(disk) / SECTOR_SIZE; sector++)
        read_write_disk(&disk[sector * SECTOR_SIZE], sector, false);

    unsigned off = 0;
    for (int i = 0; i < FILES_MAX; i++) {
        struct tar_header *header = (struct tar_header *) &disk[off];
        if (header->name[0] == '\0')
            break;

        if (strcmp(header->magic, "ustar") != 0)
            PANIC("invalid tar header: magic=\"%s\"", header->magic);

        int filesz = oct2int(header->size, sizeof(header->size));
        struct file *file = &files[i];
        file->in_use = true;
        strcpy(file->name, header->name);
        memcpy(file->data, header->data, filesz);
        file->size = filesz;
        printf("file: %s, size=%d\n", file->name, file->size);

        off += align_up(sizeof(struct tar_header) + filesz, SECTOR_SIZE);
    }
}

この関数では、まずread_write_disk関数を使ってディスクイメージをメモリ上 (disk変数) に読み込みます。disk変数はローカル変数 (スタック上) ではなく、わざと静的変数 (static) として宣言しています。スタックの大きさには限りがあるので、このようなデータ領域はなるべくスタックの利用を避けることが望ましいです。

ディスクの内容を読み込んだあとは、それをtarファイルと同じように順番にfiles変数のエントリとしてコピーしていきます。注意点として tarヘッダの数値は8進数表記 です。oct2int関数で、8進数表記の文字列を整数に変換しています。

最後に、fs_init関数をkernel_main関数から呼び出すようにして完了です。

kernel.c
void kernel_main(void) {
    memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
    WRITE_CSR(stvec, (uint32_t) kernel_entry); 
    virtio_blk_init(); 
    fs_init();

    /* 省略 */
}

ファイルシステムの読み込みテスト

実際に動かしてみましょう。diskディレクトリに用意したファイル名とその大きさが表示されれば成功です。

$ ./run.sh

virtio-blk: capacity is 2560 bytes
file: world.txt, size=0
file: hello.txt, size=22

ディスクへの書き込み戻し

ファイルシステムを読み込めるようになったので、次はファイルの書き込みを実装しましょう。ファイルの書き込みは、files変数の内容を、tarファイルの形式でディスクに書き込むことで実現します。

kernel.c
void fs_flush(void) {
    // files変数の各ファイルの内容をdisk変数に書き込む
    memset(disk, 0, sizeof(disk));
    unsigned off = 0;
    for (int file_i = 0; file_i < FILES_MAX; file_i++) {
        struct file *file = &files[file_i];
        if (!file->in_use)
            continue;

        struct tar_header *header = (struct tar_header *) &disk[off];
        memset(header, 0, sizeof(*header));
        strcpy(header->name, file->name);
        strcpy(header->mode, "000644");
        strcpy(header->magic, "ustar");
        strcpy(header->version, "00");
        header->type = '0';

        // ファイルサイズを8進数文字列に変換
        int filesz = file->size;
        for (int i = sizeof(header->size); i > 0; i--) {
            header->size[i - 1] = (filesz % 8) + '0';
            filesz /= 8;
        }

        // チェックサムを計算
        int checksum = ' ' * sizeof(header->checksum);
        for (unsigned i = 0; i < sizeof(struct tar_header); i++)
            checksum += (unsigned char) disk[off + i];

        for (int i = 5; i >= 0; i--) {
            header->checksum[i] = (checksum % 8) + '0';
            checksum /= 8;
        }

        // ファイルデータをコピー
        memcpy(header->data, file->data, file->size);
        off += align_up(sizeof(struct tar_header) + file->size, SECTOR_SIZE);
    }

    // disk変数の内容をディスクに書き込む
    for (unsigned sector = 0; sector < sizeof(disk) / SECTOR_SIZE; sector++)
        read_write_disk(&disk[sector * SECTOR_SIZE], sector, true);

    printf("wrote %d bytes to disk\n", sizeof(disk));
}

この関数では、まずfiles変数の内容をtarファイル形式でdisk変数に書き込み、その後disk変数の内容をディスクに書き込みます。tarヘッダの各フィールドの値は8進数の文字列であるため、strcpy関数など文字列を扱う処理がみられます。

ファイルの読み書きAPI

ファイルシステムの読み書きを実装したところで、アプリケーションからファイルの読み書きを行えるようにしましょう。本書ではファイルの読み込みを行うreadfile、ファイルの書き込みを行うwritefileというシステムコールを用意します。どちらもファイル名、読み書きに使うメモリバッファ、そしてバッファのサイズを引数に取ります。

common.h
#define SYS_READFILE  4
#define SYS_WRITEFILE 5
user.c
int readfile(const char *filename, char *buf, int len) {
    return syscall(SYS_READFILE, (int) filename, (int) buf, len);
}

int writefile(const char *filename, const char *buf, int len) {
    return syscall(SYS_WRITEFILE, (int) filename, (int) buf, len);
}
user.h
int readfile(const char *filename, char *buf, int len);
int writefile(const char *filename, const char *buf, int len);

一般的なOSのシステムコールの設計を読んでみて、何が省略されているのかを比較すると面白いでしょう。

システムコールの実装

前節で定義したシステムコールを実装しましょう。

kernel.c
struct file *fs_lookup(const char *filename) {
    for (int i = 0; i < FILES_MAX; i++) {
        struct file *file = &files[i];
        if (!strcmp(file->name, filename))
            return file;
    }

    return NULL;
}

void handle_syscall(struct trap_frame *f) {
    switch (f->a3) {
        /* 省略 */
        case SYS_READFILE:
        case SYS_WRITEFILE: {
            const char *filename = (const char *) f->a0;
            char *buf = (char *) f->a1;
            int len = f->a2;
            struct file *file = fs_lookup(filename);
            if (!file) {
                printf("file not found: %s\n", filename);
                f->a0 = -1;
                break;
            }

            if (len > (int) sizeof(file->data))
                len = file->size;

            if (f->a3 == SYS_WRITEFILE) {
                memcpy(file->data, buf, len);
                file->size = len;
                fs_flush();
            } else {
                memcpy(buf, file->data, len);
            }

            f->a0 = len;
            break;
        }
        default:
            PANIC("unexpected syscall a3=%x\n", f->a3);        
    }
}

ファイルの読み書き処理は共通する処理が多いので、同じところにまとめています。fs_lookup関数でファイル名からfiles変数のエントリを探し出し、読み込みであれば、ファイルエントリからデータを読み込み、書き込みであればファイルエントリの内容を書き換え、最後にfs_flush関数でディスクに書き込みます。

簡単のため、アプリケーションから渡されたポインタ (ユーザーポインタ) をそのまま参照していますが、これはセキュリティ上の問題があります。ユーザーが任意のメモリ領域を指定できてしまうと、システムコール経由でカーネルのメモリ領域を読み書きできてしまいます。

ファイルの読み書きコマンド

システムコールを実装したところで、シェルからファイルの読み書きを試してみましょう。シェルはコマンドライン引数のパースを実装していないので、とりあえずhello.txtを決めうちで読み書きするreadfilewritefileコマンドを実装します。

shell.c
        else if (strcmp(cmdline, "readfile") == 0) {
            char buf[128];
            int len = readfile("hello.txt", buf, sizeof(buf));
            buf[len] = '\0';
            printf("%s\n", buf);
        }
        else if (strcmp(cmdline, "writefile") == 0)
            writefile("hello.txt", "Hello from shell!\n", 19);

実行してみると、次のようにページフォルトが発生してしまいます。

$ ./run.sh

> readfile
PANIC: kernel.c:561: unexpected trap scause=0000000d, stval=01000423, sepc=8020128a

sepcの値をllvm-addr2lineで見てみると、strcmp関数でページフォルトが発生していることがわかります。

$ llvm-objdump -d kernel.elf
...

80201282 <strcmp>:
80201282: 03 46 05 00   lbu     a2, 0(a0)
80201286: 15 c2         beqz    a2, 0x802012aa <.LBB3_4>
80201288: 05 05         addi    a0, a0, 1

8020128a <.LBB3_2>:
8020128a: 83 c6 05 00   lbu     a3, 0(a1) ← ここでページフォルト: a1は第2引数
8020128e: 33 37 d0 00   snez    a4, a3
80201292: 93 77 f6 0f   andi    a5, a2, 255
80201296: bd 8e         xor     a3, a3, a5
80201298: 93 b6 16 00   seqz    a3, a3

ページテーブルの内容を確認してみると、0x1000423のページ (vaddr = 01000000) は確かに読み・書き・実行可能 (rwx) なユーザーページ (u) としてマップされています。

QEMU 8.0.2 monitor - type 'help' for more information
(qemu) info mem
vaddr    paddr            size     attr
-------- ---------------- -------- -------
01000000 000000008026c000 00001000 rwxu-a-

試しに仮想アドレスでメモリダンプ (xコマンド) をしてみましょう。

(qemu) x /10c 0x1000423
01000423: 'h' 'e' 'l' 'l' 'o' '.' 't' 'x' 't' '\x00' 'r' 'e' 'a' 'd' 'f' 'i'
01000433: 'l' 'e' '\x00' 'h' 'e' 'l' 'l' 'o' '\x00' '%' 's' '\n' '\x00' 'e' 'x' 'i'
01000443: 't' '\x00' 'w' 'r' 'i' 't' 'e' 'f'

ページテーブルの設定が正しくない場合、xコマンドはエラーを表示します。ここでは、ページテーブルが正しく設定されており、ポインタは確かにhello.txtの文字列を指していることがわかります。

答えを言ってしまうと「sstatusレジスタのSUMビットがセットされていない」ことが原因です。

ユーザーポインタへのアクセス

RISC-Vでは、sstatusレジスタによってS-Mode (カーネル) の振る舞いを変更できます。その中の一つが SUM (permit Supervisor User Memory access) ビット です。これがセットされていない場合、S-Modeのプログラム (カーネル) はU-Mode (ユーザー) のページにアクセスできません。

意図せずユーザーのメモリ領域を参照しないようにする、一種の安全策です。 ちなみにIntelのCPUにも「SMAP (Supervisor Mode Access Prevention)」という名前で実装されています。

SUMビットの位置を次のように定義します。

kernel.h
#define SSTATUS_SUM  (1 << 18)

あとはユーザー空間に入る時にsstatusレジスタにセットすれば修正完了です。

kernel.c
__attribute__((naked)) void user_entry(void) {
    __asm__ __volatile__(
        "csrw sepc, %[sepc]\n"
        "csrw sstatus, %[sstatus]\n"
        "sret\n"
        :
        : [sepc] "r" (USER_BASE),
          [sstatus] "r" (SSTATUS_SPIE | SSTATUS_SUM)
    );
}

ここでは「SUMビットが原因」とさらっと説明していますが「自分でこれを見つけられるか?」というのは難しい問題です。ページフォルトが起きていることは分かっても、その具体的な原因は分からないことがほとんどです。CPUは困ったことに細かいエラーコードすら出してくれないのです。筆者がなぜ気づいたかというと「SUMビットを知っていたから」です。

このような「上手く動かない」場合のデバッグ方法は次のようなものがあります。

  • RISC-Vの仕様書をよく読む。「SUMビットが立っていると、S-ModeでもU-Mode用ページにアクセスできる」と一応書いてある。
  • QEMU本体の実装を読む。前述のページフォルトの原因はココで実装されている。ただし仕様書をよく読むのと同等かそれ以上に大変。
  • ChatGPTとかに上手く聞き出す (成功例)。

これが「ゼロからOSを作る」のが時間泥棒で挫折しやすい大きな理由のひとつです。ただ、辛い分だけ解決した時の達成感は他のソフトウェア開発では味わえないものがあります。辛い思いをするのがゼロからのOS自作の醍醐味とも言えるでしょう。

ファイルの読み書きテスト

SUMビットをセットしたところで、ファイルの読み書きを試してみましょう。次のようにhello.txtに書き込んでおいた文章が表示されたら成功です。

$ ./run.sh

> readfile
Can you see me? Ah, there you are! You've unlocked the achievement "Virtio Newbie!"

ファイルの書き込みも試してみましょう。書き込みが成功すると、次のように書き込んだバイト数が表示されます。

> writefile
wrote 2560 bytes to disk

QEMUを終了して、disk.tarを展開してみましょう。disk.tarvirtio-blkのディスクイメージとして指定しているので、ディスクへの書き込みがあり次第、QEMUがそのファイルを更新します。ファイルシステムとvirtio-blkを正しく実装できていれば、writefileシステムコールで書き込んだ文章が表示されます。

$ mkdir tmp
$ cd tmp
$ tar xf ../disk.tar
$ ls -alh 
total 4.0K
drwxr-xr-x  4 seiya staff 128 Jul 22 22:50 .
drwxr-xr-x 25 seiya staff 800 Jul 22 22:49 ..
-rw-r--r--  1 seiya staff  26 Jan  1  1970 hello.txt
-rw-r--r--  1 seiya staff   0 Jan  1  1970 meow.txt
$ cat hello.txt
Hello from shell!

これでOSの基本機能である「ファイルシステム」を手に入れました!