この記事はComputer Society Advent Calendar 2025の4日目の記事です。
3日目
↑
この記事
↓
5日目
0. はじめに
理工学部1年の骨なしチキンです!KCSではSystem班、Web班、AI班に所属しています。最近は、先輩方が紹介してくださったOS in 1,000 Linesを読みながら、自作OSに取り組んでいます。C言語むずかしい!![]()
1. 自作OSの現状
OS in 1,000 Linesを一通り読み終え、
- プロセス切り替え(callee-savedレジスタを退避+スタックポインタを更新)
- 割り込み(sscratchレジスタ↔︎スタック領域)
- MMUを有効化(satpレジスタ↔︎ページテーブル)
- ユーザーアプリケーション(自作標準Cライブラリ、シェル)からシステムコール → カーネルモードでSBIを呼ぶ
- virtioでディスク読み書き
などの機能を実装しました。用語の確認![]()
callee-savedレジスタ
このレジスタに入る値は、呼び出された関数側(callee)でスタックに退避される。つまり、他関数を呼び出しても壊れない前提で使われる(sp, s0–s11)。
一方、caller-savedレジスタの値は、呼び出す側(caller)がスタック退避を行う。つまり、他関数呼び出しによって壊れる可能性があるので、一時計算用に使われる(ra, t0–t6, a0–a7)。
MMU
Memory Management Unit の略。CPUと物理メモリの間に位置する、仮想メモリアドレスを物理メモリアドレスに変換するハードウェアのこと。
SBI
Supervisor Binary Interface の略。RISC-Vでは、User Mode(ユーザー空間)、Supervisor Mode(カーネル空間)、Machine Mode(最高権限)の特権レベルが定義されている。カーネルがSBIにハードウェア操作の命令をするときは、U-ModeからS-Modeに切り替える。このとき、各命令を番号で管理し、システムコールを呼ぶ(syscall() → ecall → handle_trap() → handle_syscall() → sret)。
見た目は、コマンドの使えないシェル(?)がQEMU上で動いている感じです。この骨なしOSを拡張させたい!と思い、まずはファイルシステムを実装してみることにしました。OS in 1,000 Linesの第15章で、【QEMU上でVirtioデバイスをディスクとして設定+それを読み書きする機能】を実装しましたが、そこで作成したread_write_disk関数が本日の主役です。
2. ファイルシステムとは
ファイルシステムとは、コンピュータのストレージ(補助記憶装置)でデータを管理する仕組みです。
コンピュータの5台装置は 演算装置・制御装置・記憶装置・入力装置・出力装置
ファイルシステムの種類として、MS-DOS時代から使われているMicrosoftのFAT(最近のWindowsならNTFS)、AppleのAPFSなどがあります。
| 特徴 | UNIX系(ext4, UFS, XFSなど) | Windows系(FAT, NTFS) |
|---|---|---|
| パス表記 | スラッシュ(/home/user/file) | バックスラッシュ(C:\Users\User\file) |
| パーミッション | 読み書き実行権限 (rwx) | ACL (Access Control List) |
| 階層構造 | ルートから全てが繋がるツリー | ドライブごと(C:, D:)に独立したツリー |
3. FAT16の仕様
3-1. FATボリューム
FATのファイルシステムでは、ディスク上にFATボリュームという領域(イメージ)が確保されます。
上の写真から分かるように、このFATボリュームは、次のような4領域に分かれています。
3-2. FAT16の16って何
FAT領域に置かれる1つのエントリが16bit(=2Byte) なのでFAT16と呼ばれます。この「エントリ」とは、クラスタ番号のチェーンを管理する配列のことです。
一方、ルートディレクトリの各エントリのサイズは、FAT12でもFAT16でも 32Byte固定長 なので注意してください(FAT32では、ルートディレクトリ領域のサイズは可変長で、そこもデータ領域として扱われます)。
このディレクトリエントリの実体は、ファイルの情報(メタデータ?)を保持する構造体で、下記のプロパティを持ちます。
| key | value |
|---|---|
| 8.3ファイル名 | 11B |
| 属性バイト | 1B |
| 予約 | 10B |
| 時刻・日付 | 4B |
| 先頭クラスタ番号 | 2B/4B |
| ファイルサイズ | 4B |
| 合計 | 32B |
8.3ファイル名
名前部分に8バイト, 拡張子に3バイト使用するファイル名の形式。DOSや旧Windowsで用いられていた。Windows95以降で、LFN(Long File Name)がサポートされるようになった。
3-3. セクタとクラスタについて
各領域へのアクセスはセクタ単位(1セクタ=512バイトが主流)で行いますが、ファイルの中身の読み書き(=データ領域へのアクセス)は、複数のセクタをまとめたクラスタ単位で行います。この「セクタをクラスタに変換する」処理においては、次のような式が成り立ちます。
以上の仕様を満たしながら、FAT16のファイルシステムをCで実装していきます!
4. 実装
4-1. ディレクトリの切り分け
OSカーネルの処理を全てkernel.c内で収めるには限界が来るので、まずは、以下のようにディレクトリを切り分けました。
honenashiOS
├── drivers //ディスク読み書き関連
│ ├── virtio.c
│ └── virtio.h
├── filesystem //ファイルシステム関連 ←実装!
│ ├── fat16.c
│ └── fat16.h
├── kernel.c //カーネル本体
├── lib //標準Cライブラリ
│ ├── common.c
│ └── common.h
├── user.c //ユーザーアプリケーション
├── shell.c
├── run.sh //QEMUを実行するシェルスクリプト
├── kernel.ld //カーネル領域のリンカスクリプト
├── user.ld //ユーザー領域のリンカスクリプト
...
4-2. FATボリューム用のイメージを作成
QEMUが使うディスクイメージをfat16.imgという名前で作成しました。
$ qemu-img create -f raw fat16.img 16M
-f オプションでイメージファイルのフォーマットを指定でき、今回のraw形式は、生のバイナリデータをそのまま格納するという意味です(他にはqcow2、vdiなど)。
これに伴い、run.sh内の、QEMUを起動するシェルスクリプトは次のように変更します。
qemu-system-riscv32 -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-drive id=drive0,file=fat16.img,format=raw,if=none \
-device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \
-kernel kernel.elf
./run.shの実行後、QEMUモニタで info block を見ると
(qemu) info block
drive0 (#block193): fat16.img (raw)
Attached to: /machine/peripheral-anon/device[0]
Cache mode: writethrough
floppy0: [not inserted]
Removable device: not locked, tray closed
fat16.imgが正常にマウントされていることを確認できました。
4-3. BPB_xxxマクロの定義
ブートセクタに置かれる定数(BPB_xxx)を、マクロとして定義します。また、それらのマクロを用いて、FAT領域、ルートディレクトリ関連の値を計算します(∵ 3-3の公式)。
// ブートセクタ
#define BPB_BytsPerSec 512
#define BPB_SecPerClus 1
#define BPB_RsvdSecCnt 1
#define BPB_NumFATs 2
#define BPB_RootEntCnt 512
#define BPB_FATSz16 32
#define BPB_TotSec16 32768
// FAT領域
#define FAT1_START_SECTOR BPB_RsvdSecCnt
#define FAT2_START_SECTOR (FAT1_START_SECTOR + BPB_FATSz16)
#define FAT_ENTRY_NUM ((BPB_FATSz16 * BPB_BytsPerSec) / 2)
// ルートディレクトリ
#define ROOT_DIR_START_SECTOR (BPB_RsvdSecCnt + BPB_NumFATs * BPB_FATSz16)
#define ROOT_DIR_SECTORS \
((BPB_RootEntCnt * 32 + BPB_BytsPerSec - 1) / BPB_BytsPerSec)
4-4. ディスク上にFATボリュームを展開
先ほど定義したマクロ(FAT_XXX、ROOT_DIR_XXX)を用いて、ディスク上のFAT領域、ルートディレクトリ領域を0で初期化します。
void init_fat16_disk() {
uint8_t buf[SECTOR_SIZE];
for (int i = 0; i < SECTOR_SIZE; i++)
buf[i] = 0;
// FAT領域を0埋め
for (unsigned s = FAT1_START_SECTOR; s < FAT1_START_SECTOR + BPB_FATSz16 * BPB_NumFATs; s++) {
read_write_disk(buf, s, true);
}
// ルートディレクトリ領域を0埋め
for (unsigned s = ROOT_DIR_START_SECTOR; s < ROOT_DIR_START_SECTOR + ROOT_DIR_SECTORS; s++) {
read_write_disk(buf, s, true);
}
// データ領域は必要に応じて初期化
}
補足ですが、このようにRAMメモリと同じノリで(アクセス先をアドレスのように指定して)ディスクにアクセスできているのは、QEMUのMMIO方式のおかげです。
MMIO方式
Memory Mapped I/O の略。コンピュータの3要素(CPU、メモリ、入出力)のうち、メモリと入出力が同じアドレス空間を使用するので、CPUは共通の命令でこれらにアクセスできる。
4-5. FATボリュームをRAMにコピー
ディスク上のFATボリュームの各領域から取得した値・そこに書き込む値を保持するために、RAMキャッシュを行います。FAT領域なら配列を、ルートディレクトリ領域なら構造体を、次のように定義します。
extern uint16_t fat[FAT_ENTRY_NUM];
struct dir_entry {
char name[8];
char ext[3];
uint8_t attr;
uint8_t reserved;
uint8_t creation_time_tenths;
uint16_t creation_time;
uint16_t creation_date;
uint16_t last_access_date;
uint16_t high_cluster;
uint16_t last_write_time;
uint16_t last_write_date;
uint16_t start_cluster;
uint32_t size;
};
uint16_t fat[FAT_ENTRY_NUM];
struct dir_entry root_dir[BPB_RootEntCnt];
4-6. FATボリュームの各領域を読み書きする関数の実装
ディスク上のFATボリュームの読み書きは、4-5で実装したRAM上のキャッシュを介して行います。
// FAT領域の読み書き
static void read_fat_from_disk() {
for (int i = 0; i < BPB_FATSz16; i++) {
read_write_disk(&fat[i * (BPB_BytsPerSec / 2)], FAT1_START_SECTOR + i, 0);
}
}
static void write_fat_to_disk() {
// FAT1 書き戻し
for (int i = 0; i < BPB_FATSz16; i++) {
read_write_disk(&fat[i * (BPB_BytsPerSec / 2)], FAT1_START_SECTOR + i, 1);
}
// FAT2 書き戻し(ミラー)
for (int i = 0; i < BPB_FATSz16; i++) {
read_write_disk(&fat[i * (BPB_BytsPerSec / 2)], FAT2_START_SECTOR + i, 1);
}
}
// ルートディレクトリの読み書き
static void read_root_dir_from_disk() {
for (int i = 0; i < ROOT_DIR_SECTORS; i++) {
read_write_disk(&root_dir[i * (BPB_BytsPerSec / 32)], ROOT_DIR_START_SECTOR + i, 0);
}
}
static void write_root_dir_to_disk() {
for (int i = 0; i < ROOT_DIR_SECTORS; i++) {
read_write_disk(&root_dir[i * (BPB_BytsPerSec / 32)], ROOT_DIR_START_SECTOR + i, 1);
}
}
また、データ領域の読み書きは、クラスタ単位で行うため、read_write_diskの引数でcluster_to_sectorを叩きます。
// データ領域の読み書き
static inline uint32_t cluster_to_sector(uint16_t cluster) {
return DATA_START_SECTOR + (cluster - 2) * BPB_SecPerClus;
}
void read_cluster(uint16_t cluster, void *buf) {
for (int i = 0; i < BPB_SecPerClus; i++) {
read_write_disk((uint8_t *)buf + i * BPB_BytsPerSec, cluster_to_sector(cluster) + i, 0);
}
}
void write_cluster(uint16_t cluster, void *buf) {
for (int i = 0; i < BPB_SecPerClus; i++) {
read_write_disk((uint8_t *)buf + i * BPB_BytsPerSec, cluster_to_sector(cluster) + i, 1);
}
}
4-7. ファイルを作成する関数の実装
先ほど実装した、各領域を読み書きするread_xxx_from_diskおよびwrite_xxx_to_disk関数を用いて、create_file関数を作ります。手順としては
- 現在のFAT領域、ディレクトリ領域を読み込む
- ディレクトリ領域の空きエントリの番号を探して
entry_indexに入れる - FAT領域の空きクラスタを探して
free_clusterに入れる - エントリ(
root_dir配列)の値を設定する - データ領域に
*dataを書き込む - FAT領域、ディレクトリ領域を書き戻す
int create_file(const char *name, const uint8_t *data, uint32_t size) {
// FAT / root_dir 読み込み
read_fat_from_disk();
read_root_dir_from_disk();
// root_dir 空きエントリ探索
int entry_index = -1;
for (int i = 0; i < BPB_RootEntCnt; i++) {
if (root_dir[i].name[0] == 0x00 || root_dir[i].name[0] == 0xE5) {
entry_index = i;
break;
}
}
if (entry_index < 0) {
printf("[FAT16] ERROR: Root directory is full. Cannot create new file.\n");
return -1;
}
// 最初のクラスタ確保
int free_cluster = -1;
for (int i = 2; i < FAT_ENTRY_NUM; i++) {
if (fat[i] == 0x0000) {
free_cluster = i;
break;
}
}
if (free_cluster < 0) {
printf("[FAT16] ERROR: no free FAT cluster.\n");
return -1;
}
// ルートディレクトリ領域をRAMキャッシュ
struct dir_entry *de = &root_dir[entry_index];
memset(de->name, ' ', 8);
memset(de->ext, ' ', 3);
int n = 0;
while (n < 8 && name[n] && name[n] != '.') {
de->name[n] = name[n];
n++;
}
if (name[n] == '.') {
n++;
for (int e = 0; e < 3 && name[n + e]; e++) {
de->ext[e] = name[n + e];
}
}
de->start_cluster = free_cluster;
de->size = size;
// データ領域への書き込み
uint32_t remaining = size;
uint16_t cluster = free_cluster;
uint8_t cluster_buf[BPB_BytsPerSec * BPB_SecPerClus];
while (remaining > 0) {
uint32_t to_write = remaining;
if (to_write > BPB_BytsPerSec * BPB_SecPerClus)
to_write = BPB_BytsPerSec * BPB_SecPerClus;
if (data) {
memcpy(cluster_buf, data, to_write);
data += to_write;
} else {
memset(cluster_buf, 0, to_write);
}
if (to_write < BPB_BytsPerSec * BPB_SecPerClus)
memset(cluster_buf + to_write, 0, BPB_BytsPerSec * BPB_SecPerClus - to_write);
write_cluster(cluster, cluster_buf);
remaining -= to_write;
// FATエントリのクラスタチェーンの設定
if (remaining > 0) {
uint16_t next_cluster = 0;
for (uint16_t i = 2; i < FAT_ENTRY_NUM; i++) {
if (fat[i] == 0x0000) {
next_cluster = i;
break;
}
}
if (next_cluster == 0) {
printf("[FAT16] ERROR: not enough clusters.\n");
return -1;
}
fat[cluster] = next_cluster;
fat[next_cluster] = 0xFFFF;
cluster = next_cluster;
} else {
fat[cluster] = 0xFFFF;
}
}
// FAT書き戻し
write_fat_to_disk();
write_root_dir_to_disk();
printf("[FAT16] File created: %s at entry %d, cluster %d\n", name, entry_index, free_cluster);
return 0;
}
4-8. ファイル一覧を取得するlsコマンドの実装
次のlist_root_dir関数は、現在のディレクトリ領域を読み込み(=root_dir配列に値を入れ)root_dir[i].name(i < BPB_RootEntCntで、終端に達したらbreak)を取得するものです。
void list_root_dir() {
// 1. ディスクから最新の root_dir を読み込む
read_root_dir_from_disk();
printf("=== Root Directory ===\n");
for (int i = 0; i < BPB_RootEntCnt; i++) {
// 未使用エントリ → ここから先は全部空
if (root_dir[i].name[0] == 0x00) {
break;
}
// 削除済み
if (root_dir[i].name[0] == 0xE5) {
continue;
}
// 2. ファイル名(8 + 3)を組み立て
char name[13];
int p = 0;
// name(8文字)
for (int j = 0; j < 8; j++) {
if (root_dir[i].name[j] != ' ') name[p++] = root_dir[i].name[j];
}
// 拡張子
if (root_dir[i].ext[0] != ' ') {
name[p++] = '.';
for (int j = 0; j < 3; j++) {
if (root_dir[i].ext[j] != ' ') name[p++] = root_dir[i].ext[j];
}
}
name[p] = '\0';
// 3. 表示
printf("%s size=", name);
printf("%d", (int)root_dir[i].size);
printf(" cluster=");
printf("%d\n", (int)root_dir[i].start_cluster);
}
}
ユーザーアプリケーションのコマンド入力から、システムコールでこれを叩きたいので、
void main(void) {
while (1) {
prompt:
printf("> ");
char cmdline[128];
for (int i = 0;; i++) {
char ch = getchar();
putchar(ch);
if (i == sizeof(cmdline) - 1) {
printf("command line too long\n");
goto prompt;
} else if (ch == '\r') {
printf("\n");
cmdline[i] = '\0';
break;
} else {
cmdline[i] = ch;
}
}
if (strcmp(cmdline, "hello") == 0)
printf("Hello world from shell!\n");
else if (strcmp(cmdline, "exit") == 0)
exit();
else if (strcmp(cmdline, "ls") == 0)
sys_list_root_dir(); // lsコマンド追加
...
システムコールの命令番号を設定し、
#define SYS_LIST_FILE 5
void sys_list_root_dir() { syscall(SYS_LIST_FILE, 0, 0, 0); }
カーネルのhandle_syscall内で、先ほどのlist_root_dir関数を呼び出します。
void handle_syscall(struct trap_frame *f) {
switch (f->a3) {
case SYS_PUTCHAR:
putchar(f->a0);
break;
...
case SYS_LIST_FILE:
list_root_dir();
yield();
break;
...
挙動確認として、カーネルのmain関数に
create_file("test.txt", "hello", 5);
create_file("test2.txt", "hello2", 6);
を追加すると、
lsコマンドでルートディレクトリ内のファイル一覧を取得できました!
4-9. ファイルの中身を読むcatコマンドの実装
// ファイル読み込み
int read_file(uint16_t start_cluster, uint8_t *buf, uint32_t size) {
read_fat_from_disk();
if (start_cluster < 2 || start_cluster >= FAT_ENTRY_NUM)
return -1;
uint32_t remaining = size;
uint16_t cluster = start_cluster;
uint8_t cluster_buf[BPB_BytsPerSec * BPB_SecPerClus];
while (cluster != 0xFFFF && remaining > 0) {
read_cluster(cluster, cluster_buf);
uint32_t to_copy = remaining;
if (to_copy > BPB_BytsPerSec * BPB_SecPerClus)
to_copy = BPB_BytsPerSec * BPB_SecPerClus;
memcpy(buf, cluster_buf, to_copy);
buf += to_copy;
remaining -= to_copy;
cluster = fat[cluster];
}
return 0;
}
void concatenate() {
// 1. 現在のFAT領域とディレクトリ領域を読み込む
read_fat_from_disk();
read_root_dir_from_disk();
// 2. 最初の有効エントリを探す
struct dir_entry *target = NULL;
for (int i = 0; i < 16; i++) {
if (root_dir[i].name[0] == 0x00)
break; // 以降は空
if (root_dir[i].name[0] == 0xE5)
continue; // 削除済み
target = &root_dir[i];
break;
}
if (!target) {
printf("[cat] no file.\n");
return;
}
// サイズ0なら空ファイル
if (target->size == 0) {
printf("[cat] (empty file)\n");
return;
}
// 3. ファイルサイズ分のバッファを確保
uint32_t size = target->size;
uint8_t buf[size]; // ※簡易実装としてスタック確保
// 4. read_file() でデータ領域を読む
if (read_file(target->start_cluster, buf, size) < 0) {
printf("[cat] read error.\n");
return;
}
// 5. ファイル内容をそのまま表示
printf("===== cat: file content =====\n");
for (uint32_t i = 0; i < size; i++) {
putchar(buf[i]);
}
printf("\n===== end =====\n");
}
先ほどと同様に、user.c内でシステムコールを呼び出し、カーネルのhandle_syscall関数内でこのconcatenateを呼び出します。
cat <ファイル名>のような本格的なものではなく、catとだけ打つと、上記のread_file関数が実行され最初のFATエントリの中身を読む、という謎コマンドが出来上がりました。
ファイル名を指定できるようにするには、libcにstrncmp関数を実装し、cat xxxの最初の4文字(cat )を比較する、のような流れになるのでしょうか。。。
5. おわりに
ファイルシステム実装(中〜後編)では、アプリケーション側の処理を豊富にして、
- 本格的なcat, touchコマンド
- fileコマンド(拡張子だけでなくヘッダ解析)
- mkdirコマンド(サブディレクトリの実装)
を作りたいです!そこまでモチベが続いているか分からない & やること多すぎて一生終わらない自作OSのGitHubはこちらです![]()
https://github.com/74rina/HonenashiOS.git
12/4現在、中間試験という割り込みが発生したので一旦スタック領域に退避します。最後までお読みくださりありがとうございました![]()
![]()
→後編
6. 参考文献




