この記事で学べること
- ✅ OSの基本構造(ブート、メモリ管理、プロセス管理)
- ✅ RISC-Vアーキテクチャの基礎
- ✅ ページングによる仮想メモリ管理
- ✅ システムコールの実装方法
- ✅ virtio-blkデバイスドライバの仕組み
- ✅ 簡易ファイルシステムの実装
- ✅ QEMUを使ったカーネルデバッグ手法
所要時間: 初心者で3〜5日、経験者なら1〜2日
難易度: 中級(C言語の基礎知識が必要)
はじめに
本記事は、公式サイト「1000行でOSを作ってみよう」の内容を基に作成した学習ガイドです。
重要な免責事項
- このガイドは主要な部分のみを抜粋した教育用補助教材です
- 完全に動作するコードは公式GitHubを参照してください
- virtio-blk、ファイルシステム、システムコールの詳細実装は省略されています
前提知識
必須
- C言語の基礎(ポインタ、構造体、関数ポインタ)
- UNIXコマンドライン操作(bash, make等)
- 基本的なメモリの概念(スタック、ヒープ)
あると良い
- アセンブリ言語の基礎知識
- コンピュータアーキテクチャの基礎
- makefileやビルドシステムの知識
不要
- RISC-Vの詳しい知識(本記事で学べます)
- OS理論の深い理解(実装を通じて学べます)
実装の全体像
┌─────────────────────────────────┐
│ アプリケーション │
│ (shell.c) │
└──────────────┬──────────────────┘
│ システムコール
┌──────────────┴──────────────────┐
│ カーネル │
│ ┌─────────────────────────┐ │
│ │ プロセス管理・スケジューラ │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ メモリ管理・ページング │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ デバイスドライバ(virtio) │ │
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ ファイルシステム(tar) │ │
│ └─────────────────────────┘ │
└──────────────┬──────────────────┘
│ SBI呼び出し
┌──────────────┴──────────────────┐
│ OpenSBI │
│ (ファームウェア) │
└──────────────┬──────────────────┘
│
┌──────────────┴──────────────────┐
│ QEMU (RISC-V エミュレータ) │
└─────────────────────────────────┘
目次
1. 開発環境のセットアップ
macOS
brew install llvm lld qemu
export PATH="$PATH:$(brew --prefix)/opt/llvm/bin"
clangのパス確認:
ls $(brew --prefix)/opt/llvm/bin/clang
Ubuntu
sudo apt update && sudo apt install -y clang llvm lld qemu-system-riscv32 curl
OpenSBIのダウンロード(必須):
curl -LO https://github.com/qemu/qemu/raw/v8.0.4/pc-bios/opensbi-riscv32-generic-fw_dynamic.bin
注意: opensbi-riscv32-generic-fw_dynamic.binは実行時のカレントディレクトリに配置する必要があります。
2. リンカスクリプト
kernel.ld
ENTRY(boot)
SECTIONS {
. = 0x80200000;
.text : {
KEEP(*(.text.boot));
*(.text .text.*);
}
.rodata : ALIGN(4) {
*(.rodata .rodata.*);
}
.data : ALIGN(4) {
*(.data .data.*);
}
.bss : ALIGN(4) {
__bss = .;
*(.bss .bss.* .sbss .sbss.*);
__bss_end = .;
}
. = ALIGN(4);
. += 128 * 1024;
__stack_top = .;
. = ALIGN(4096);
__free_ram = .;
. += 64 * 1024 * 1024;
__free_ram_end = .;
}
重要: __free_ramと__free_ram_endはリンカスクリプトで定義されます。
user.ld
ENTRY(start)
SECTIONS {
. = 0x1000000;
.text : {
KEEP(*(.text.start));
*(.text .text.*);
}
.rodata : ALIGN(4) {
*(.rodata .rodata.*);
}
.data : ALIGN(4) {
*(.data .data.*);
}
.bss : ALIGN(4) {
*(.bss .bss.* .sbss .sbss.*);
. = ALIGN(16);
. += 64 * 1024;
__stack_top = .;
}
}
3. 共通ヘッダとライブラリ
common.h
#pragma once
// 型定義
typedef int bool;
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;
typedef uint32_t size_t;
typedef uint32_t paddr_t;
typedef uint32_t vaddr_t;
// 定数
#define true 1
#define false 0
#define NULL ((void *) 0)
#define PAGE_SIZE 4096
// マクロ
#define align_up(value, align) __builtin_align_up(value, align)
#define is_aligned(value, align) __builtin_is_aligned(value, align)
#define offsetof(type, member) __builtin_offsetof(type, member)
#define va_list __builtin_va_list
#define va_start __builtin_va_start
#define va_end __builtin_va_end
#define va_arg __builtin_va_arg
// 関数宣言
void *memset(void *buf, char c, size_t n);
void *memcpy(void *dst, const void *src, size_t n);
char *strcpy(char *dst, const char *src);
int strcmp(const char *s1, const char *s2);
void printf(const char *fmt, ...);
common.c
#include "common.h"
void putchar(char ch);
void *memset(void *buf, char c, size_t n) {
uint8_t *p = (uint8_t *) buf;
while (n--)
*p++ = c;
return buf;
}
void *memcpy(void *dst, const void *src, size_t n) {
uint8_t *d = (uint8_t *) dst;
const uint8_t *s = (const uint8_t *) src;
while (n--)
*d++ = *s++;
return dst;
}
char *strcpy(char *dst, const char *src) {
char *d = dst;
while (*src)
*d++ = *src++;
*d = '\0';
return dst;
}
int strcmp(const char *s1, const char *s2) {
while (*s1 && *s2) {
if (*s1 != *s2)
break;
s1++;
s2++;
}
return *(unsigned char *) s1 - *(unsigned char *) s2;
}
void printf(const char *fmt, ...) {
va_list vargs;
va_start(vargs, fmt);
while (*fmt) {
if (*fmt == '%') {
fmt++;
switch (*fmt) {
case '\0':
putchar('%');
goto end;
case '%':
putchar('%');
break;
case 's': {
const char *s = va_arg(vargs, const char *);
while (*s) {
putchar(*s);
s++;
}
break;
}
case 'd': {
int value = va_arg(vargs, int);
unsigned magnitude = value;
if (value < 0) {
putchar('-');
magnitude = -magnitude;
}
unsigned divisor = 1;
while (magnitude / divisor > 9)
divisor *= 10;
while (divisor > 0) {
putchar('0' + magnitude / divisor);
magnitude %= divisor;
divisor /= 10;
}
break;
}
case 'x': {
unsigned value = va_arg(vargs, unsigned);
for (int i = 7; i >= 0; i--) {
unsigned nibble = (value >> (i * 4)) & 0xf;
putchar("0123456789abcdef"[nibble]);
}
}
// 注意: 元の実装ではbreakが無い(switch文の最後なので問題なし)
}
} else {
putchar(*fmt);
}
fmt++;
}
end:
va_end(vargs);
}
4. カーネルヘッダ(主要定義)
kernel.h(抜粋)
#pragma once
#include "common.h"
// 定数定義
#define PROCS_MAX 8
#define PROC_UNUSED 0
#define PROC_RUNNABLE 1
#define PROC_EXITED 2
#define SATP_SV32 (1u << 31)
#define SSTATUS_SPIE (1 << 5)
#define SSTATUS_SUM (1 << 18)
#define SCAUSE_ECALL 8
// ページテーブルフラグ
#define PAGE_V (1 << 0)
#define PAGE_R (1 << 1)
#define PAGE_W (1 << 2)
#define PAGE_X (1 << 3)
#define PAGE_U (1 << 4)
// アドレス
#define USER_BASE 0x1000000
#define VIRTIO_BLK_PADDR 0x10001000
// CSRマクロ
#define READ_CSR(reg) \
({ \
unsigned long __tmp; \
__asm__ __volatile__("csrr %0, " #reg : "=r"(__tmp)); \
__tmp; \
})
#define WRITE_CSR(reg, value) \
do { \
uint32_t __tmp = (value); \
__asm__ __volatile__("csrw " #reg ", %0" ::"r"(__tmp)); \
} while (0)
#define PANIC(fmt, ...) \
do { \
printf("PANIC: %s:%d: " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__); \
while (1) {} \
} while (0)
// 構造体
struct sbiret {
long error;
long value;
};
struct trap_frame {
uint32_t ra, gp, tp, t0, t1, t2, t3, t4, t5, t6;
uint32_t a0, a1, a2, a3, a4, a5, a6, a7;
uint32_t s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, s10, s11;
uint32_t sp;
} __attribute__((packed));
struct process {
int pid;
int state;
vaddr_t sp;
uint32_t *page_table;
uint8_t stack[8192];
};
// その他の構造体(virtio、tar、file)は公式参照
5. カーネル実装(主要部分)
kernel.c(主要な関数のみ)
#include "kernel.h"
#include "common.h"
extern char __bss[], __bss_end[], __stack_top[];
extern char __free_ram[], __free_ram_end[];
extern char _binary_shell_bin_start[], _binary_shell_bin_size[];
struct process procs[PROCS_MAX];
struct process *current_proc;
struct process *idle_proc;
// ブート処理
__attribute__((section(".text.boot")))
__attribute__((naked))
void boot(void) {
__asm__ __volatile__(
"mv sp, %[stack_top]\n"
"j kernel_main\n"
:
: [stack_top] "r" (__stack_top)
);
}
// SBI呼び出し
struct sbiret sbi_call(long arg0, long arg1, long arg2, long arg3, long arg4,
long arg5, long fid, long eid) {
register long a0 __asm__("a0") = arg0;
register long a1 __asm__("a1") = arg1;
register long a2 __asm__("a2") = arg2;
register long a3 __asm__("a3") = arg3;
register long a4 __asm__("a4") = arg4;
register long a5 __asm__("a5") = arg5;
register long a6 __asm__("a6") = fid;
register long a7 __asm__("a7") = eid;
__asm__ __volatile__("ecall"
: "=r"(a0), "=r"(a1)
: "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(a4), "r"(a5), "r"(a6), "r"(a7)
: "memory");
return (struct sbiret){.error = a0, .value = a1};
}
void putchar(char ch) {
sbi_call(ch, 0, 0, 0, 0, 0, 0, 1);
}
// メモリ割り当て(公式実装通り)
paddr_t alloc_pages(uint32_t n) {
static paddr_t next_paddr = (paddr_t) __free_ram;
paddr_t paddr = next_paddr;
next_paddr += n * PAGE_SIZE;
if (next_paddr > (paddr_t) __free_ram_end)
PANIC("out of memory");
memset((void *) paddr, 0, n * PAGE_SIZE);
return paddr;
}
// ページマッピング
void map_page(uint32_t *table1, uint32_t vaddr, paddr_t paddr, uint32_t flags) {
if (!is_aligned(vaddr, PAGE_SIZE))
PANIC("unaligned vaddr %x", vaddr);
if (!is_aligned(paddr, PAGE_SIZE))
PANIC("unaligned paddr %x", paddr);
uint32_t vpn1 = (vaddr >> 22) & 0x3ff;
if ((table1[vpn1] & PAGE_V) == 0) {
uint32_t pt_paddr = alloc_pages(1);
table1[vpn1] = ((pt_paddr / PAGE_SIZE) << 10) | PAGE_V;
}
uint32_t vpn0 = (vaddr >> 12) & 0x3ff;
uint32_t *table0 = (uint32_t *) ((table1[vpn1] >> 10) * PAGE_SIZE);
table0[vpn0] = ((paddr / PAGE_SIZE) << 10) | flags | PAGE_V;
}
// 例外ハンドラ(レジスタ保存部分は省略)
__attribute__((naked))
__attribute__((aligned(4)))
void kernel_entry(void) {
__asm__ __volatile__(
"csrrw sp, sscratch, sp\n"
"addi sp, sp, -4 * 31\n"
// レジスタ保存(省略: 全31個のレジスタ)
"sw ra, 4 * 0(sp)\n"
"sw gp, 4 * 1(sp)\n"
// ... 他のレジスタ ...
"csrr a0, sscratch\n"
"sw a0, 4 * 30(sp)\n"
"addi a0, sp, 4 * 31\n"
"csrw sscratch, a0\n"
"mv a0, sp\n"
"call handle_trap\n"
// レジスタ復元(省略)
"lw ra, 4 * 0(sp)\n"
// ... 他のレジスタ ...
"lw sp, 4 * 30(sp)\n"
"sret\n"
);
}
void handle_trap(struct trap_frame *f) {
uint32_t scause = READ_CSR(scause);
uint32_t stval = READ_CSR(stval);
uint32_t user_pc = READ_CSR(sepc);
if (scause == SCAUSE_ECALL) {
handle_syscall(f);
user_pc += 4;
WRITE_CSR(sepc, user_pc);
} else {
PANIC("unexpected trap scause=%x, stval=%x, sepc=%x\n",
scause, stval, user_pc);
}
}
// コンテキストスイッチ
__attribute__((naked))
void switch_context(uint32_t *prev_sp, uint32_t *next_sp) {
__asm__ __volatile__(
"addi sp, sp, -13 * 4\n"
"sw ra, 0 * 4(sp)\n"
"sw s0, 1 * 4(sp)\n"
"sw s1, 2 * 4(sp)\n"
"sw s2, 3 * 4(sp)\n"
"sw s3, 4 * 4(sp)\n"
"sw s4, 5 * 4(sp)\n"
"sw s5, 6 * 4(sp)\n"
"sw s6, 7 * 4(sp)\n"
"sw s7, 8 * 4(sp)\n"
"sw s8, 9 * 4(sp)\n"
"sw s9, 10 * 4(sp)\n"
"sw s10, 11 * 4(sp)\n"
"sw s11, 12 * 4(sp)\n"
"sw sp, (a0)\n"
"lw sp, (a1)\n"
"lw ra, 0 * 4(sp)\n"
"lw s0, 1 * 4(sp)\n"
"lw s1, 2 * 4(sp)\n"
"lw s2, 3 * 4(sp)\n"
"lw s3, 4 * 4(sp)\n"
"lw s4, 5 * 4(sp)\n"
"lw s5, 6 * 4(sp)\n"
"lw s6, 7 * 4(sp)\n"
"lw s7, 8 * 4(sp)\n"
"lw s8, 9 * 4(sp)\n"
"lw s9, 10 * 4(sp)\n"
"lw s10, 11 * 4(sp)\n"
"lw s11, 12 * 4(sp)\n"
"addi sp, sp, 13 * 4\n"
"ret\n"
);
}
// スケジューラ
void yield(void) {
struct process *next = idle_proc;
for (int i = 0; i < PROCS_MAX; i++) {
struct process *proc = &procs[(current_proc->pid + i) % PROCS_MAX];
if (proc->state == PROC_RUNNABLE && proc->pid > 0) {
next = proc;
break;
}
}
if (next == current_proc)
return;
__asm__ __volatile__(
"sfence.vma\n"
"csrw satp, %[satp]\n"
"sfence.vma\n"
"csrw sscratch, %[sscratch]\n"
:
: [satp] "r" (SATP_SV32 | ((uint32_t) next->page_table / PAGE_SIZE)),
[sscratch] "r" ((uint32_t) &next->stack[sizeof(next->stack)])
);
struct process *prev = current_proc;
current_proc = next;
switch_context(&prev->sp, &next->sp);
}
// プロセス作成(簡略版)
struct process *create_process(const void *image, size_t image_size) {
// プロセス検索
struct process *proc = NULL;
for (int i = 0; i < PROCS_MAX; i++) {
if (procs[i].state == PROC_UNUSED) {
proc = &procs[i];
break;
}
}
if (!proc)
PANIC("no free process slots");
// スタック初期化
uint32_t *sp = (uint32_t *) &proc->stack[sizeof(proc->stack)];
*--sp = 0; *--sp = 0; *--sp = 0; *--sp = 0;
*--sp = 0; *--sp = 0; *--sp = 0; *--sp = 0;
*--sp = 0; *--sp = 0; *--sp = 0; *--sp = 0;
*--sp = (uint32_t) user_entry;
// ページテーブル作成
uint32_t *page_table = (uint32_t *) alloc_pages(1);
// カーネルページをマッピング(省略: 元サイト参照)
// ユーザープログラムをマッピング(省略: 元サイト参照)
proc->pid = i + 1;
proc->state = PROC_RUNNABLE;
proc->sp = (uint32_t) sp;
proc->page_table = page_table;
return proc;
}
// メイン関数
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
printf("\n\nHello World!\n");
WRITE_CSR(stvec, (uint32_t) kernel_entry);
// メモリ割り当てテスト
paddr_t paddr0 = alloc_pages(2);
paddr_t paddr1 = alloc_pages(1);
printf("paddr0=%x, paddr1=%x\n", paddr0, paddr1);
// 以降、virtio_blk_init()、fs_init()、プロセス作成など
// 詳細は元サイト参照
for (;;) {
__asm__ __volatile__("wfi");
}
}
6. ビルドスクリプト
run.sh
#!/bin/bash
set -xue
QEMU=qemu-system-riscv32
CC=clang # macOS: /opt/homebrew/opt/llvm/bin/clang
OBJCOPY=llvm-objcopy # macOS: /opt/homebrew/opt/llvm/bin/llvm-objcopy
CFLAGS="-std=c11 -O2 -g3 -Wall -Wextra --target=riscv32 -ffreestanding -nostdlib"
# カーネルをビルド
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf \
kernel.c common.c
# QEMUを起動
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-kernel kernel.elf
実行:
chmod +x run.sh
./run.sh
7. 省略されている実装
このドキュメントでは以下が省略されています。必ず元サイトを参照してください:
- 例外ハンドラの完全版(31個のレジスタ保存/復元)
- ユーザーモード実装(user_entry、プロセスマッピング)
- virtio-blkドライバ(完全実装が必要)
- ファイルシステム(tar解析、読み書き)
- システムコールハンドラ(全ケース)
- シェル実装(shell.c、user.c)
8. デバッグ方法
QEMUモニタ
QEMUモニタへの切り替え: Ctrl+A → C
便利なコマンド:
-
info registers- レジスタの内容を表示 -
info mem- ページテーブルの内容を表示 -
xp /x 物理アドレス- 物理メモリの内容を16進数で表示 -
x /x 仮想アドレス- 仮想メモリの内容を16進数で表示 -
stop- 実行を一時停止 -
cont- 実行を再開 -
q- QEMUを終了
QEMUモニタから抜ける: Ctrl+A → C
QEMUを即座に終了: Ctrl+A → X
LLVMツール
# 逆アセンブル
llvm-objdump -d kernel.elf
# アドレスからソース行を特定
llvm-addr2line -e kernel.elf 0x80200010
# シンボル一覧
llvm-nm kernel.elf
# ELFファイルの情報
llvm-readelf -a kernel.elf
9. よくある問題
ページフォルト (scause=0xd)
原因:
- ページテーブルの設定ミス
-
SSTATUS_SUMビットの未設定 - 物理ページ番号と物理アドレスの混同
確認方法:
(qemu) stop
(qemu) info mem
(qemu) info registers
システムコールから戻らない
原因: sepcを+=4していない
修正:
if (scause == SCAUSE_ECALL) {
handle_syscall(f);
user_pc += 4; // 重要!
WRITE_CSR(sepc, user_pc);
}
OpenSBIが見つからない
エラー:
qemu-system-riscv32: Unable to load the RISC-V firmware
解決法:
curl -LO https://github.com/qemu/qemu/raw/v8.0.4/pc-bios/opensbi-riscv32-generic-fw_dynamic.bin
カレントディレクトリに配置する必要があります。
メモリ不足エラー
エラー: PANIC: out of memory
確認:
llvm-nm kernel.elf | grep __free_ram
リンカスクリプトの__free_ram_endの値を確認・調整してください。
10. FAQ
Q1: macOSでclangが見つからない
A: Homebrewのllvmパッケージをインストールしてください。
brew install llvm
export PATH="$(brew --prefix)/opt/llvm/bin:$PATH"
Q2: ページフォルトが頻発する
A: 次を確認してください:
-
SSTATUS_SUMビットの設定 - ページテーブルの物理ページ番号(アドレスではない)
-
info memでマッピング状態を確認
(qemu) info mem
Q3: QEMUが起動しない
A: opensbi-riscv32-generic-fw_dynamic.binがカレントディレクトリにあることを確認してください。
ls opensbi-riscv32-generic-fw_dynamic.bin
Q4: ビルドエラー: undefined reference to '__stack_top'
A: kernel.ldのリンカスクリプトが正しく指定されているか確認してください。
$CC $CFLAGS -Wl,-Tkernel.ld -Wl,-Map=kernel.map -o kernel.elf kernel.c common.c
Q5: コンパイルは通るが何も表示されない
A: QEMUモニタ(Ctrl+A → C)でinfo registersを実行し、pcの値を確認してください。
(qemu) info registers
pcがkernel_main付近を指しているか確認します。
11. さらに学ぶには
本記事で基礎を学んだ後、以下にチャレンジしてみましょう:
初級
- 複数のシェルコマンドを追加(ls, cat, echo等)
- より多くのプロセスを同時実行
- プロセスの優先度実装
- メモリ使用量の表示機能
中級
- タイマー割り込みの実装
- プリエンプティブマルチタスク
- プロセス間通信(パイプ、メッセージキュー)
- より高度なファイルシステム(ディレクトリ対応)
- 動的メモリ割り当て(malloc/free)
上級
- マルチコアCPU対応
- ネットワークドライバ実装
- メモリ保護の強化
- ELFローダーの実装
- デバッガの組み込み
12. 参考資料
書籍
- 📕 『自作OSで学ぶマイクロカーネルの設計と実装』(エナガ本)
- 📗 『ゼロからのOS自作入門』
- 📘 『オペレーティングシステム 第3版』
オンライン資料
コミュニティ
- 💬 自作OS Advent Calendar
- 💬 OS自作入門 Discord(非公式)
13. 関連リンク
14. 実装のヒント
ヒント1: デバッグの基本
OS開発ではprintfデバッグが最強の武器です:
printf("DEBUG: reached line %d\n", __LINE__);
printf("DEBUG: value=%x\n", some_value);
ヒント2: ページテーブルのデバッグ
ページング関連の問題はinfo memで確認:
(qemu) stop
(qemu) info mem
vaddr paddr size attr
-------- ---------------- -------- -------
80200000 0000000080200000 00001000 rwx----
ヒント3: レジスタダンプの読み方
(qemu) info registers
pc 80200014 # プログラムカウンタ
sp 80220018 # スタックポインタ
a0 00000000 # 引数/戻り値レジスタ
pcの値をllvm-addr2lineで確認:
llvm-addr2line -e kernel.elf 0x80200014
ヒント4: メモリダンプの活用
仮想アドレスの内容を確認:
(qemu) x /10x 0x80200000
80200000: 0x80220537 0x01850513 0x0000812a 0x0060006f
物理アドレスの内容を確認:
(qemu) xp /10x 0x80200000
15. ライセンスと謝辞
ライセンス
本記事の内容は、元サイト「1000行でOSを作ってみよう」に準じます:
- 本文: CC BY 4.0
- ソースコード: MIT License
謝辞
本記事はnuta氏の「1000行でOSを作ってみよう」を参考に作成しました。素晴らしい教材を公開していただき、ありがとうございます。
まとめ
このガイドは、RISC-V OSの基本的な骨組みを提供する教育用教材です。
✅ このガイドで学んだこと
- OSのブート処理とメモリ管理
- ページングによる仮想メモリ
- プロセス管理とコンテキストスイッチ
- 例外処理とシステムコール
- デバイスドライバの基礎
- QEMUを使ったデバッグ技術
⚠️ 次にやるべきこと
完全な実装には以下が必須です:
- 公式サイト: https://operating-system-in-1000-lines.vercel.app/ja/
- GitHubソースコード: https://github.com/nuta/operating-system-in-1000-lines
特に以下の章は公式で必ず確認してください:
- 09章: メモリ割り当て
- 11章: ページテーブル
- 12章: ユーザーモード
- 14章: システムコール
- 15章: virtio-blk
- 16章: ファイルシステム
- 17章: シェル
🎉 最後に
OS自作は難しいですが、動いたときの感動はひとしおです。ぜひ挫折せずに完成させてください!
質問や問題があれば、コメント欄や公式GitHubのIssuesで質問してみましょう。
Happy OS Hacking! 🚀