シリーズ一覧
| Part | タイトル | 内容 |
|---|---|---|
| Part1 | 令和にCでOS書く狂人の記録 | 本記事 |
| Part2 | アセンブリとCの橋渡し | リンカスクリプト |
| Part3 | メモリ管理とmalloc | 動的メモリ |
| Part4 | printfを自作する | フォーマット出力 |
| Part5 | Rustで書き直したくなった | 移行検討 |
はじめに
「令和にもなってCでOS書くとか、頭おかしいんじゃない?」
うん、おかしいかもしれない。Rustがある。Zigがある。でも、Cで書きたいんだ。
なぜなら:
- 教科書的なOS本はだいたいC(またはC++)
- メモリの挙動が直接見える
- 「なんでこのコード動くの?」が全部わかる
- 何より楽しい
ということで、令和の世にCでOSを書く狂人の記録、始まります。
開発環境
- WSL2 (Ubuntu 22.04)
- GCC (gcc-multilib)
- NASM (アセンブラ)
- QEMU (エミュレータ)
- GRUB (ブートローダー)
sudo apt install gcc-multilib nasm qemu-system-x86 grub-pc-bin grub-common
プロジェクト構成
~/c-os/
├── boot/
│ └── boot.asm # エントリポイント(アセンブリ)
├── src/
│ └── kernel.c # カーネル本体
├── linker.ld # リンカスクリプト
└── build.sh # ビルドスクリプト
Multibootって何?
GRUBからカーネルを起動するための規格。カーネルの先頭に「マジックナンバー」を置いておくと、GRUBが「お、これカーネルだな」と認識してくれる。
Multiboot Header:
┌──────────────────────────────────────┐
│ Magic Number (0x1BADB002) │
├──────────────────────────────────────┤
│ Flags │
├──────────────────────────────────────┤
│ Checksum (magic + flags + checksum = 0) │
└──────────────────────────────────────┘
ブートローダー(アセンブリ部分)
カーネルのエントリポイントはアセンブリで書く。Cから始めることもできるけど、スタックの初期化とかはアセンブリのほうが確実。
; Multiboot header for GRUB
MBALIGN equ 1 << 0 ; ページ境界アライメント
MEMINFO equ 1 << 1 ; メモリ情報を要求
FLAGS equ MBALIGN | MEMINFO
MAGIC equ 0x1BADB002 ; Multibootマジックナンバー
CHECKSUM equ -(MAGIC + FLAGS) ; チェックサム(合計が0になるように)
section .multiboot
align 4
dd MAGIC
dd FLAGS
dd CHECKSUM
section .bss
align 16
stack_bottom:
resb 16384 ; 16KB のスタック領域を確保
stack_top:
section .text
global _start
extern kernel_main
_start:
; スタックポインタを設定
mov esp, stack_top
; Cのカーネルメイン関数を呼び出し
call kernel_main
; カーネルから戻ってきたら停止
cli
.hang:
hlt
jmp .hang
ポイント:
-
_startがエントリポイント(リンカスクリプトで指定) - スタックは
.bssセクションに確保(初期化不要) -
kernel_mainを呼んだ後はhltで停止
カーネル本体(C言語)
ここからがCの世界!
// kernel.c - 令和に書くCのカーネル
// VGAテキストモードのアドレス
// 0xB8000 はVGAテキストバッファの物理アドレス
volatile unsigned short* vga_buffer = (unsigned short*)0xB8000;
int cursor_x = 0;
int cursor_y = 0;
// VGA色定義
enum vga_color {
VGA_BLACK = 0,
VGA_BLUE = 1,
VGA_GREEN = 2,
VGA_CYAN = 3,
VGA_RED = 4,
VGA_MAGENTA = 5,
VGA_BROWN = 6,
VGA_LIGHT_GREY = 7,
VGA_DARK_GREY = 8,
VGA_LIGHT_BLUE = 9,
VGA_LIGHT_GREEN = 10,
VGA_LIGHT_CYAN = 11,
VGA_LIGHT_RED = 12,
VGA_LIGHT_MAGENTA = 13,
VGA_YELLOW = 14,
VGA_WHITE = 15,
};
VGAテキストモードの仕組み
VGAテキストモードでは、画面上の各文字は2バイトで表現される:
1文字 = 2バイト
┌────────────────┬────────────────┐
│ 属性 (1byte) │ 文字 (1byte) │
├────────────────┼────────────────┤
│ BBBB FFFF │ ASCII code │
└────────────────┴────────────────┘
背景色 前景色
画面サイズ: 80列 × 25行 = 2000文字 = 4000バイト
画面クリアと文字出力
// 画面クリア
void clear_screen(void) {
for (int i = 0; i < 80 * 25; i++) {
// 背景=黒、前景=白、文字=スペース
vga_buffer[i] = (VGA_BLACK << 12) | (VGA_WHITE << 8) | ' ';
}
cursor_x = 0;
cursor_y = 0;
}
// 1文字出力
void putchar(char c) {
if (c == '\n') {
cursor_x = 0;
cursor_y++;
} else {
int index = cursor_y * 80 + cursor_x;
vga_buffer[index] = (VGA_BLACK << 12) | (VGA_LIGHT_GREEN << 8) | c;
cursor_x++;
if (cursor_x >= 80) {
cursor_x = 0;
cursor_y++;
}
}
// スクロール処理
if (cursor_y >= 25) {
// 1行分上にずらす
for (int i = 0; i < 80 * 24; i++) {
vga_buffer[i] = vga_buffer[i + 80];
}
// 最終行をクリア
for (int i = 80 * 24; i < 80 * 25; i++) {
vga_buffer[i] = (VGA_BLACK << 12) | (VGA_WHITE << 8) | ' ';
}
cursor_y = 24;
}
}
// 文字列出力
void print(const char* str) {
while (*str) {
putchar(*str++);
}
}
カーネルメイン
// カーネルメイン
void kernel_main(void) {
clear_screen();
print("=====================================\n");
print(" C-OS: Written in C, Running Wild\n");
print(" Version 0.1.0\n");
print("=====================================\n");
print("\n");
print("Hello from the kernel!\n");
print("This OS is written in pure C.\n");
print("\n");
print("Features:\n");
print(" - VGA text mode output\n");
print(" - Multiboot compliant\n");
print(" - Running on bare metal\n");
print("\n");
print("Kernel initialized successfully.\n");
// 無限ループ
while (1) {
__asm__ volatile ("hlt");
}
}
リンカスクリプト
アセンブリとCのオブジェクトファイルを結合するためのルール:
ENTRY(_start)
SECTIONS
{
/* カーネルは1MBから配置(下位メモリはBIOSが使用) */
. = 1M;
/* テキストセクション(コード) */
.text BLOCK(4K) : ALIGN(4K)
{
*(.multiboot) /* Multibootヘッダを先頭に */
*(.text)
}
/* 読み取り専用データ */
.rodata BLOCK(4K) : ALIGN(4K)
{
*(.rodata)
}
/* 初期化済みデータ */
.data BLOCK(4K) : ALIGN(4K)
{
*(.data)
}
/* 未初期化データ(BSS) */
.bss BLOCK(4K) : ALIGN(4K)
{
*(COMMON)
*(.bss)
}
}
ポイント:
-
. = 1Mでカーネルを1MBの位置に配置 -
.multibootを先頭に(GRUBが認識できるように) - 4KB境界でアライメント(ページングのため)
ビルドスクリプト
#!/bin/bash
set -e
echo "=== Building C-OS ==="
# アセンブル
echo "[1/4] Assembling boot.asm..."
nasm -f elf32 boot/boot.asm -o boot/boot.o
# コンパイル
echo "[2/4] Compiling kernel.c..."
gcc -m32 -ffreestanding -fno-pie -c src/kernel.c -o src/kernel.o -Wall -Wextra
# リンク
echo "[3/4] Linking..."
ld -m elf_i386 -T linker.ld -o c-os.bin boot/boot.o src/kernel.o
# 確認
echo "[4/4] Verifying multiboot..."
if grub-file --is-x86-multiboot c-os.bin; then
echo "Multiboot confirmed!"
else
echo "ERROR: Not multiboot compliant"
exit 1
fi
echo ""
echo "=== Build successful! ==="
echo "Run with: qemu-system-i386 -kernel c-os.bin"
GCCのオプション解説:
-
-m32: 32ビットコード生成 -
-ffreestanding: 標準ライブラリなし -
-fno-pie: 位置独立実行ファイルを無効化 -
-Wall -Wextra: 警告を厳しく
ビルドと実行
$ cd ~/c-os
$ chmod +x build.sh
$ ./build.sh
=== Building C-OS ===
[1/4] Assembling boot.asm...
[2/4] Compiling kernel.c...
[3/4] Linking...
[4/4] Verifying multiboot...
Multiboot confirmed!
=== Build successful! ===
Run with: qemu-system-i386 -kernel c-os.bin
QEMUで実行:
$ qemu-system-i386 -kernel c-os.bin
画面にこんな感じで表示される:
=====================================
C-OS: Written in C, Running Wild
Version 0.1.0
=====================================
Hello from the kernel!
This OS is written in pure C.
Features:
- VGA text mode output
- Multiboot compliant
- Running on bare metal
Kernel initialized successfully.
動いた! 緑色の文字で表示されてるはず。
なぜCなのか(改めて)
正直、Rustで書いたほうが安全だし、最近の自作OS界隈はRust派が多い。でも:
- デバッグがわかりやすい - ポインタの中身が直接見える
- リソースが多い - 古い本も参考にできる
- 「なぜ動くか」がわかる - 抽象化が少ない
- 達成感 - 危険と隣り合わせで動かす緊張感
あと、Cで一度作っておくと、Rustに移植するときに「あ、Rustのこの機能はCのこれを安全にしたやつか」ってわかるようになる。
まとめ
Part1では、最小限のカーネルを作った:
- Multibootヘッダ - GRUBから起動できるように
- ブートストラップ - スタック初期化してCに制御を渡す
- VGA出力 - 画面に文字を表示
次回Part2では、リンカスクリプトをもっと詳しく見て、アセンブリとCの連携を深掘りします。