皆さんは、世界で最も使われているオープンソースOSであるLinuxの最初のバージョンがどのようなものだったか知っていますか?1991年9月17日、当時21歳の大学生だったLinus Torvaldsが公開したLinux 0.01は、約1万行(実測10,239行)のコードからなる最小限のOSでした。
Linuxは今日、企業のサーバーから個人のデスクトップ、スマートフォンから巨大なスーパーコンピューターまで、あらゆる場所で動作しています。インターネットの大部分はLinuxサーバー上で動作し、世界の金融システムや通信インフラを支えています。しかし、この壮大な物語は、フィンランドの一人の学生が「ただの趣味」として始めたプロジェクトから始まりました。
本記事では、このLinux 0.01のソースコード解析を通じてOSの基本的な仕組みを理解し、さらにQEMUを使って実際に動作させる方法まで詳しく解説します。最初の1万行のコードには、現代のLinuxにも受け継がれている重要な設計思想が込められています。
なお、本記事のソースコードに関する記述は、kernel.orgのHistoricアーカイブに保存されている実際のLinux 0.01のソースコードと照合しています。動作検証には、0.01のソースコードを極力そのまま保ちつつ現代の環境で起動できるようにした移植版を使用しています(理由は後述します。オリジナルの0.01は、そのままでは現代のツールチェーンでビルドできません)。
Linuxとは何か - そしてなぜ生まれたのか
UNIXの遺産
Linuxを理解するには、まずUNIXの歴史を知る必要があります。UNIXは1970年代にBell Labsで開発され、その設計の優雅さと強力さから、世界中のコンピューター科学者やエンジニアに愛されてきました。しかし、UNIXには大きな問題がありました――それは高価で、ライセンスが制限的だったことです。
多くのハッカーたちは、UNIXこそが「正しいもの」であり、「唯一の真のオペレーティングシステム」だと感じていました。そのため、自分たちのシステムで手を汚したいと願うUNIXハッカーのグループが拡大し、フリーなUNIXクローンを求める声が高まっていました。
MINIXとの出会い
1991年、ヘルシンキ大学でコンピュータサイエンスを学んでいたLinus Torvaldsは、Andrew S. Tanenbaum教授が開発した教育用OS「MINIX」を使っていました。MINIXは優れた教材でしたが、いくつかの制限がありました。
- 16ビット設計 - Intel 386の32ビット機能を活用できない
- 商用利用の制限 - 改変や再配布に制約があった
- 教育目的の設計 - 実用的な機能拡張が制限されていた
Torvaldsは、新しく購入した386マシンの能力を最大限に活用したいと考えていました。特に、386のプロテクトモードとタスクスイッチング機能に魅了されていました。
「ただの趣味」から始まったプロジェクト
1991年8月25日、Torvaldsは後に歴史的となるメッセージをcomp.os.minixニュースグループに投稿しました。
"Hello everybody out there using minix - I'm doing a (free) operating system (just a hobby, won't be big and professional like gnu) for 386(486) AT clones."
(MINIXを使っている皆さん、こんにちは。私は386(486)AT互換機用の(フリーな)OSを作っています(ただの趣味で、GNUのように大きくプロフェッショナルなものにはならないでしょう))
個人的な学習欲求
Torvaldsは後に次のように書いています。
「その後、順調に進みました。相変わらず厄介なコーディングでしたが、いくつかのデバイスがあり、デバッグは簡単になりました。この段階でC言語を使い始め、開発速度が確実に上がりました。この時期から、『Minixよりも優れたMinixを作る』という私の誇大妄想的なアイデアに真剣に取り組み始めました。いつかLinux上でgccを再コンパイルできるようになることを望んでいました...」
彼の主な動機は、次の3つでした。
- Intel 386の機能を学びたい - タスクスイッチング、メモリ管理、保護モードなど、386プロセッサの「本物の」機能を使いたかった
- 実用的なOSが欲しい - MINIXの制限を超えたもの、自分が使いたいシステム
- プログラミングの楽しさ - 「基本的なセットアップに2ヶ月かかりましたが、その時までに私は夢中になり、Minixを捨てるまで止めたくありませんでした」
開発の経緯 - 最初の公開版まで
Linuxバージョン0.01については、何の発表も行われませんでした。Torvaldsは次のように書いています。
「0.01を公開しました。それは見栄えも悪く、フロッピードライバもなく、ほとんど何もできませんでした。誰もそのバージョンをコンパイルしたことはないと思います。」
1991年4月 - Torvaldsが386プロセッサの勉強を兼ねてOSの開発を開始
1991年7月3日 - POSIXについて質問(最初の公開された痕跡)
1991年8月25日 - 有名な「just a hobby」投稿
1991年9月17日 - Linux 0.01リリース(ソースコードのみ)
「誰もコンパイルしたことがない」という言葉は誇張ではありません。0.01のビルドには当時のMINIX環境と特定バージョンのGCCが前提で、公開されたのはソースコードだけでした。後述する動作検証で移植版を使うのは、まさにこのためです。
Torvaldsは後に振り返って、「もしGNU HurdかBSDが当時利用可能だったら、Linuxは書かなかっただろう」と述べています。しかし、まさにそのタイミングでLinuxが登場したことが、後の爆発的な普及につながりました。
オープンソース文化の萌芽
当初、Torvaldsは商用利用を禁止する独自ライセンスでLinuxを公開しました。しかし、コミュニティからのフィードバックと貢献を受けて、1992年にGNU GPLライセンスへ移行しました。この決断について、彼は「LinuxをGPLにしたことは、私がこれまでにした最高の決断だった」と述べています。
Tanenbaum-Torvalds論争
1992年1月、MINIXの作者であるTanenbaumが「LINUX is obsolete(Linuxは時代遅れだ)」という挑発的なタイトルで批判を投稿しました。主な論点は、
- モノリシックカーネル vs マイクロカーネル - Tanenbaumはマイクロカーネルが優れていると主張
- 移植性の欠如 - Intel 386に特化しすぎていると批判
- 設計思想の違い - 理論的な美しさ vs 実用性
この論争は、異なるOS設計哲学を明確にし、Linuxの実用主義的アプローチを際立たせる結果となりました。
検証環境について
本記事の技術的な検証を再現可能にするため、使用した環境を記載します。GitHub Codespacesを使用することで、誰でも同じ環境で動作確認ができます。
| 項目 | 詳細 |
|---|---|
| プラットフォーム | GitHub Codespaces(2 vCPU) |
| ホストOS | Ubuntu 24.04.4 LTS |
| カーネル | Linux 6.8.0-1052-azure |
| QEMU | QEMU emulator version 8.2.2 |
| コンパイラ | GCC 13.3.0(移植版カーネルのビルド検証用) |
| アセンブラ | bin86 0.16(as86/ld86。0.01のboot.sはas86構文) |
| デバッガ | GDB 15.0 |
エミュレーション環境の詳細
| 項目 | 詳細 |
|---|---|
| エミュレートCPU | QEMUの既定32ビットCPU(QEMUに純粋な386モデルは存在せず、最古でも486相当) |
| 割り当てメモリ | 8MB(0.01が管理できる上限。後述) |
| ディスク | 起動用ISO(約4.3MB)+ ルートFS用RAWイメージ(40MB、MINIX v1) |
| 起動時間 | 数秒以内(厳密な計測はしていません) |
なぜ今、Linux 0.01を学ぶのか
現代のLinuxカーネルは数千万行という巨大なコードベースになっており、初学者がOSの仕組みを理解するには複雑すぎます。一方、Linux 0.01は以下の特徴を持っています。
- コード量が約1万行 - 週末で読み切れる規模
- 基本機能のみ実装 - OSの本質的な機能に集中できる
- 実際に動作する - 理論だけでなく実践的な学習が可能(ただし移植版が必要。後述)
- 歴史的価値 - 現代のLinuxとオープンソース文化の原点を知ることができる
さらに重要なのは、Linux 0.01のコードには、Torvaldsの設計哲学が純粋な形で現れていることです。商用製品のような妥協や、後方互換性の制約がない、理想的なOSの実装を見ることができます。
実際に起動してみる
理論だけでなく、実際にLinux 0.01が動作する様子を確認してみましょう。30年以上前のOSのコードが、現代のエミュレータ上で対話的に動くのは感動的な体験です。
最短ルート:3ステップで起動
オリジナルの0.01をそのまま動かすのは困難なので(理由は次節)、まずは0.01のソースコードを極力保ったまま現代の環境向けに移植した linux-0.01-still-runs のビルド済みイメージを使います。
# 1. QEMUのインストール(Ubuntu/Debian、GitHub Codespacesも同じ)
sudo apt-get install -y qemu-system-x86
# macOSの場合: brew install qemu
# 2. ビルド済みのカーネルISOとルートファイルシステムを取得
curl -LO https://github.com/FermiHart/linux-0.01-still-runs/releases/latest/download/linux-0.01-still-runs.iso
curl -LO https://github.com/FermiHart/linux-0.01-still-runs/releases/latest/download/root.img.gz
gunzip root.img.gz
# 3. 起動
qemu-system-i386 -cdrom linux-0.01-still-runs.iso \
-drive file=root.img,format=raw,if=none,id=hd0 \
-device ide-hd,drive=hd0,bus=ide.0,unit=0,cyls=977,heads=5,secs=17 \
-boot d -m 8M -no-reboot
数秒でシェルのプロンプトが表示され、コマンドが実行できます。
fermihart@linux01:/$ ls
bin dev etc home tmp
fermihart@linux01:/$ mkdir test
fermihart@linux01:/$ cd test
GitHub CodespacesのようなGUIのないヘッドレス環境では、QEMUモニタから画面をキャプチャして確認できます。
qemu-system-i386 ...(上と同じオプション)... -display none -monitor stdio
(qemu) screendump screen.ppm # VGA画面をファイルに保存
なお、この移植版はブートローダーに現代のLimineを使っており、シェルなどのユーザーランドも現代のツールチェーンで再ビルドされたものです(1991年当時のバイナリではありません)。カーネル本体のC言語部分は0.01のソースコードが元になっています。オリジナルのブートコード(boot.s)の動作は、後述のremake版で別途確認しています。
実際の起動メッセージ
Linux 0.01のカーネルが起動時に出力するメッセージは、以下がすべてです。それぞれの出力元をソースコードで確認できます。
Loading system ... ← boot/boot.s(フロッピーブート時)
Partition table ok. ← kernel/hd.c
980/1024 free blocks ← fs/super.c(ルートFSマウント時)
224/256 free inodes ← fs/super.c
1446 buffers = 1480704 bytes buffer space ← init/main.c(initプロセス)
Ok. ← init/main.c
(数値は環境によって変わります)
現代のLinuxで見られるLinux version x.xのようなバージョンバナーや、Calibrating delay loop(BogoMIPS計測)はまだ存在しません。BogoMIPSが導入されたのは1993年の0.99系からです。ログイン処理もなく、initプロセスが/bin/shを直接起動します(init/main.cのinit()参照)。
なぜオリジナルの0.01はそのまま動かないのか
「ソースが公開されているのだから、ビルドして起動すればいいのでは?」と思うかもしれません。実際にやってみると、以下の壁に当たります。これ自体がOSと開発環境の30年の変化を体感できる良い教材です。
-
ツールチェーンの断絶 - 0.01のCコードは当時のGCC 1.40と
extern inlineのGNU89セマンティクスに依存し、アセンブリはas86構文です。現代のGCCでは-m32 -std=gnu89 -fcommonなどを付けてもそのままでは通らず、インラインアセンブリの制約指定("=r"→"=q")やシンボル可視性の修正が必要です。筆者が実際にGCC 13.3.0でremake版をビルドした際も、複数箇所の修正が必要でした。 -
2枚構成のディスクが前提 - 0.01はフロッピーからブートし(
boot.sはフロッピーのジオメトリsectors = 18を決め打ちで読みます)、ルートファイルシステムはハードディスク上のMINIX v1です。ルートデバイスはconfig.hのROOT_DEVとしてコンパイル時に固定されており、オリジナルの既定値0x306はTorvalds自身のマシンの「2台目のハードディスクの第1パーティション」を指します(remake版では0x301=1台目の第1パーティションに変更されています)。よく見かける「イメージ1つを-hdaに挿すだけ」では起動しません。 -
HDジオメトリもコンパイル時定数 - ハードディスクの自動検出はなく、
include/linux/config.hのHD_TYPE(例:{4,20,1024,...}=ヘッド4・セクタ20・シリンダ1024)とディスクイメージの実ジオメトリが一致している必要があります。 - 割り込みタイミングの相性 - 現代のQEMU(8.x)はIDEの完了割り込みをほぼ同期的に発生させるため、1991年当時の実機(ミリ秒単位で応答するディスク)を前提としたドライバが「Unexpected HD interrupt」でカーネルパニックすることがあります。筆者の検証では、この割り込みを無視するパッチを当てることで先に進めました。
-
ルートFSの中身も必要 - MINIX v1ファイルシステムに
/bin/sh、/bin/update、/dev/tty0などを配置する必要があります。当時はMINIXシステムからバイナリをコピーしていました。現代では0.01のシステムコールABIに合わせて再ビルドしたユーザーランド(remake版ではdietlibcベース)を使います。
より当時に近いブート経路で動かす(発展)
オリジナルのboot.s(フロッピーブート→自己再配置→A20→保護モード移行)を通る経路を体験したい場合は、Abdel Benamrouche氏による「linux-0.01 remake」(GCC 4.x対応化パッチ)が出発点になります。配布サイトは現存しませんが、Wayback Machineにソース・ビルド済みイメージ・当時のGCC 1.40まで一式が保存されています。
- 解説ページ(アーカイブ):
http://web.archive.org/web/2018/http://draconux.free.fr/os_dev/linux0.01_news.html - GitHubミラー: zugwan/linux-0.01 など
筆者がQEMU 8.2.2で検証した際の注意点です。
- カーネルイメージ(フロッピー)は1.44MB(1474560バイト)にパディングしないと、QEMUがジオメトリを認識できず無言で止まります(
truncate -s 1474560 Image) - このremakeの
hd_out()はドライブ選択に0xB0(スレーブ)を使うため、ルートFSイメージは-hdaではなく**-hdb**に接続します - それでも上記4の割り込みタイミング問題があり、無修正では「Partition table ok.」(ルートディスクの読み取り成功)までで止まるか、パニックします。完走にはドライバ側の追加パッチが必要です
この「動かすまでの苦労」も含めて、0.01が前提としていたハードウェア環境を学べます。
Linux 0.01の基本構造
ソースコードの構造を理解することは、OSの全体像を把握する第一歩です。Linux 0.01のディレクトリ構成は、現代のLinuxカーネルと比較すると驚くほどシンプルですが、OSに必要な基本的な要素がすべて含まれています。
1991年9月17日にリリースされたLinux 0.01は、Torvaldsが約5ヶ月間かけて開発した最初の公開バージョンです。「まだ実用的ではないが、ソースコードを見てみたい人のために」という但し書きとともに公開されました。
ソースコードはkernel.orgのHistoricアーカイブから入手できます。
curl -LO https://mirrors.edge.kernel.org/pub/linux/kernel/Historic/linux-0.01.tar.gz
tar xzf linux-0.01.tar.gz
ディレクトリ構成
各ディレクトリは明確な役割を持っており、OSの機能が論理的に分離されています。この構造は、現代のLinuxカーネルにも引き継がれている優れた設計です。
linux/
├── boot/ # ブートコード(boot.s、head.sの2ファイルのみ)
├── fs/ # ファイルシステム実装
├── include/ # ヘッダファイル
├── init/ # 初期化コード(main.c)
├── kernel/ # カーネルコア機能+ドライバ
├── lib/ # ライブラリ関数
├── mm/ # メモリ管理
└── tools/ # ビルドツール(build.c)
コード規模の詳細
上記アーカイブの全ファイル(Makefile等を含む)をwc -lで実測した結果です。
| ディレクトリ | ファイル数 | 行数 | 主な内容 |
|---|---|---|---|
| boot/ | 2 | 504 | ブートローダー(boot.s)、32ビット開始コード(head.s) |
| fs/ | 19 | 2,866 | MINIX v11 ファイルシステム |
| kernel/ | 18 | 3,601 | プロセス管理、システムコール、コンソール/キーボード/HDドライバ |
| mm/ | 3 | 335 | メモリ管理、ページング2 |
| init/ | 1 | 147 | main()関数 |
| lib/ | 12 | 138 | ミニマムなlibc(_exit、open、write等) |
| include/ | 31 | 2,484 | ヘッダファイル |
| tools/ | 1 | 68 | イメージ結合ツール(build.c) |
| 合計 | 87 | 10,239 | - |
なお、本記事ではこのうちブートプロセス、プロセス管理、メモリ管理を中心に解説します。コード量として最大のfs/(ファイルシステム)と各種ドライバの詳細は扱いません。
主要な制限事項
Linux 0.01は最小限の実装のため、以下の制限があります。
| 項目 | 制限 | 現代のLinux |
|---|---|---|
| 対応CPU | Intel 80386のみ | x86, ARM, RISC-V等 |
| 最大プロセス数 | 64個(NR_TASKS) | 数万〜数十万 |
| 最大メモリ | 8MB(config.hのHIGH_MEMORY。4MB構成も選択可) | 数TB以上 |
| ハードディスク | ジオメトリをconfig.hに直書き(自動検出なし) | 自動検出 |
| ファイルシステム | MINIX v1のみ | ext4, Btrfs, XFS等 |
| ネットワーク | 未対応 | TCP/IP完全対応 |
| スワップ | 未対応 | 対応 |
| フロッピードライバ | 未対応(ブートのみBIOS経由) | (そもそもFDDが絶滅) |
ブートプロセスの詳細解析
コンピュータの電源ボタンを押してから、OSが使用可能になるまでの過程は、現代では意識することの少ない複雑なプロセスです。Linux 0.01のブートプロセスを追うことで、OSがどのようにハードウェアを初期化し、自身を起動するのかを理解できます。
特に興味深いのは、16ビットのリアルモードから32ビットの保護モード3への移行です。これは、Intel x86アーキテクチャの歴史的な制約と、それを克服するための巧妙な仕組みを学ぶ絶好の機会です。
注意点として、Linux 0.01のブートコードはboot.sとhead.sの2ファイルだけです。よく解説記事で見かけるbootsect.s/setup.s/head.sの3段構成は0.11以降のもので、0.01ではA20ゲートの有効化も保護モード移行も、すべてboot.sが1人でこなします。
1. BIOSによるブートセクタのロード
電源投入後、BIOS4はPOST5(自己診断)とデバイスの初期化を行い、ブートデバイスの先頭512バイト(ブートセクタ)をメモリアドレス0x7C00にロードして、そこにジャンプします。
2. boot.s - 最初のブートコード
BIOSがブートセクタをメモリにロードした後、最初に実行されるのがboot.sです。このわずか512バイトのコードが、OSの起動という壮大な旅の第一歩となります。セグメント6という概念を使用して、メモリアドレスを管理しています。
以下は実際のboot.sの冒頭です(as86構文。|以降はコメント)。
BOOTSEG = 0x07c0
INITSEG = 0x9000
SYSSEG = 0x1000 | system loaded at 0x10000 (65536).
ENDSEG = SYSSEG + SYSSIZE
entry start
start:
mov ax,#BOOTSEG
mov ds,ax
mov ax,#INITSEG
mov es,ax
mov cx,#256 | 512バイト = 256ワード
sub si,si
sub di,di
rep
movw | 自己再配置:0x7C00 → 0x90000
jmpi go,INITSEG | 0x9000:go にジャンプ
なぜ自己再配置が必要なのか?
BIOSは常に0x7C00にブートセクタをロードしますが、boot.sはこの後カーネル本体を0x10000にロードし、さらに0x0000へ移動させます。ブートローダー自身が上書きされないよう、邪魔にならない高位アドレス(0x90000)に退避するのです。
再配置後、boot.sはLoading system ...というメッセージを表示し(前述の起動メッセージの1行目です)、BIOSのディスクサービス(int 0x13)でカーネル本体を読み込みます。
メモリダンプで確認
理論的な説明だけでなく、実際のメモリの内容を確認することで、ブートローダーの動作を具体的に理解できます。QEMUのモニタ機能を使うと、任意のメモリアドレスの内容を確認できます。
# ブートセクタ(0x7C00)の内容
(qemu) xp/16xb 0x7c00
00007c00: 0xb8 0xc0 0x07 0x8e 0xd8 0xb8 0x00 0x90
00007c08: 0x8e 0xc0 0xb9 0x00 0x01 0x29 0xf6 0x29
先頭のb8 c0 07はmov ax,#0x07c0、続く8e d8はmov ds,ax……と、上に載せたboot.sの機械語がそのまま並んでいるのが分かります。再配置後は同じ内容が0x90000にも現れます。
3. A20ゲートの有効化と保護モード移行(boot.s後半)
カーネルの読み込みと0x0000への移動が終わると、boot.sは32ビットの世界への扉を開く準備をします。まず、A20ゲート7の有効化です。これは歴史的な理由により、デフォルトでは1MB以上のメモリにアクセスできないためです。
以下は実際のboot.sのコードです。
lidt idt_48 | load idt with 0,0
lgdt gdt_48 | load gdt with whatever appropriate
| that was painless, now we enable A20
call empty_8042
mov al,#0xD1 | command write
out #0x64,al
call empty_8042
mov al,#0xDF | A20 on
out #0x60,al
call empty_8042
A20ゲートとは?
Intel 8086との互換性のため、21本目のアドレスライン(ビット20、A20)がマスクされています。1MB以上のメモリにアクセスするには、キーボードコントローラ(8042)経由でこのA20ゲートを有効化する必要があります。「キーボードコントローラでメモリアクセス範囲を制御する」という奇妙な設計も、歴史的経緯の産物です。
4. 保護モードへの移行
CR0レジスタ8のPEビットをセットすることで、保護モードへ移行します。その後、セレクタ98(コードセグメント)へジャンプします。これも実際のboot.sのコードです。
mov ax,#0x0001 | protected mode (PE) bit
lmsw ax | This is it!
jmpi 0,8 | jmp offset 0 of segment 8 (cs)
This is it!(これだ!)という当時のTorvaldsのコメントがそのまま残っています。
実際のGDT内容
保護モードでは、セグメントの属性を定義するためにGDT10(Global Descriptor Table)を使用します。boot.sに定義されている実際のGDTは以下の通りです。
gdt:
.word 0,0,0,0 | dummy
.word 0x07FF | 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 | base address=0
.word 0x9A00 | code read/exec
.word 0x00C0 | granularity=4096, 386
.word 0x07FF | 8Mb - limit=2047 (2048*4096=8Mb)
.word 0x0000 | base address=0
.word 0x9200 | data read/write
.word 0x00C0 | granularity=4096, 386
NULLディスクリプタ、コードセグメント(セレクタ8)、データセグメント(セレクタ0x10)の3エントリだけの最小構成です。リミットが8MBになっている点にも注目してください。0.01が管理するメモリの上限(HIGH_MEMORY = 8MB)と対応しています。
この後、head.sがページングを有効化し、init/main.cのmain()に制御が渡ります。main()は各サブシステムを初期化(tty_init()、trap_init()、sched_init()、buffer_init()、hd_init())した後、ユーザーモードに移行してタスク0となり、fork()した子(initプロセス)が/bin/shを起動します。
プロセス管理とスケジューラ
OSの最も重要な機能の一つが、複数のプログラムを同時に実行しているように見せることです。実際にはCPUは一度に一つのプログラムしか実行できませんが、高速に切り替える(コンテキストスイッチ11)ことで並行実行を実現しています。
Linux 0.01のスケジューラは、動的優先度ベースの非常にシンプルな実装です。このシンプルさゆえに、スケジューリングの本質を理解するのに最適な教材となっています。
タスク構造体
プロセスを管理するためには、各プロセスの状態を記録しておく必要があります。Linux 0.01では、task_struct構造体がその役割を担います。以下はinclude/linux/sched.hの実際の定義です(コメントも原文のまま)。
struct task_struct {
/* these are hardcoded - don't touch */
long state; /* -1 unrunnable, 0 runnable, >0 stopped */
long counter;
long priority;
long signal;
fn_ptr sig_restorer;
fn_ptr sig_fn[32];
/* various fields */
int exit_code;
unsigned long end_code,end_data,brk,start_stack;
long pid,father,pgrp,session,leader;
unsigned short uid,euid,suid;
unsigned short gid,egid,sgid;
long alarm;
long utime,stime,cutime,cstime,start_time;
unsigned short used_math;
/* file system info */
int tty; /* -1 if no tty, so it must be signed */
unsigned short umask;
struct m_inode * pwd;
struct m_inode * root;
unsigned long close_on_exec;
struct file * filp[NR_OPEN];
/* ldt for this task 0 - zero 1 - cs 2 - ds&ss */
struct desc_struct ldt[3];
/* tss for this task */
struct tss_struct tss;
};
stateの-1 unrunnableは「実行不能(スリープ中)」の意味です。counterが動的優先度(タイムスライス12)、priorityが静的優先度です。末尾には2つの重要なx86固有構造があります。
- ldt[3] - プロセスごとのセグメント定義(後述するメモリ管理で、プロセス分離の要になります)
- tss - TSS13(Task State Segment)。CPU状態の保存・復元に使われます
なお、Linux 0.01はタスク切り替えにハードウェアタスクスイッチ(TSSセレクタへのljmp命令)を使用しています。Linuxがこれをやめてソフトウェアによるコンテキストスイッチに移行したのは、ずっと後のカーネル2.2(1999年)です。
スケジューリングアルゴリズム
スケジューラの実装は、OSの性能と応答性に直接影響する重要な部分です。Linux 0.01のスケジューラは、現代の複雑なアルゴリズムと比べると非常にシンプルですが、基本的な考え方は同じです。
このスケジューラの興味深い点は、動的優先度(counter)という概念です。各プロセスは実行時間に応じてcounterが減少し、実行可能なプロセスのcounterがすべて0になると、優先度に基づいて再計算されます。これにより、CPU時間の公平な分配と、優先度の高いプロセスへの配慮を両立しています(動的優先度スケジューリング14)。
以下はkernel/sched.cのschedule()の全文です。alarmシグナルの処理まで含めて、これだけです。
void schedule(void)
{
int i,next,c;
struct task_struct ** p;
/* check alarm, wake up any interruptible tasks that have got a signal */
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p) {
if ((*p)->alarm && (*p)->alarm < jiffies) {
(*p)->signal |= (1<<(SIGALRM-1));
(*p)->alarm = 0;
}
if ((*p)->signal && (*p)->state==TASK_INTERRUPTIBLE)
(*p)->state=TASK_RUNNING;
}
/* this is the scheduler proper: */
while (1) {
c = -1;
next = 0;
i = NR_TASKS;
p = &task[NR_TASKS];
while (--i) {
if (!*--p)
continue;
if ((*p)->state == TASK_RUNNING && (*p)->counter > c)
c = (*p)->counter, next = i;
}
if (c) break;
for(p = &LAST_TASK ; p > &FIRST_TASK ; --p)
if (*p)
(*p)->counter = ((*p)->counter >> 1) +
(*p)->priority;
}
switch_to(next);
}
スケジューリングの特徴
- 動的優先度(counter) - タイマー割り込みごとに減少し、counterが最大の実行可能タスクが選ばれる
-
タイマー割り込みは100Hz -
sched.cで8253タイマーにLATCH = 1193180/HZ(HZ=100、つまり11931カウント≒10ms間隔)を設定 -
優先度の再計算 - 全タスクのcounterが尽きたら
counter = counter/2 + priority。スリープ中のタスクはcounterが持ち越されるため、I/O待ちの多い対話的なプロセスが優遇される -
最大タスク数は64 -
NR_TASKS定数で固定 - switch_to(next) - ljmp命令でTSSセレクタにジャンプするハードウェアタスクスイッチ
メモリ管理
メモリ管理は、OSの中核機能の一つです。限られた物理メモリを効率的に利用し、各プロセスに独立したメモリ空間を提供することで、安定性とセキュリティを実現します。
Linux 0.01は4KBページングを使った仮想メモリ15を実装していますが、現代のOSとは大きく異なるユニークな設計です。
- ページディレクトリは全プロセスで共有 - 1つの線形アドレス空間を全員で使う
- プロセス分離はセグメンテーションで実現 - 各プロセスに線形アドレス空間の64MBスライスを割り当てる
- demand-paging(要求時ページング)なし - プログラムはロード時に読み込まれる
- スワップ機能なし - メモリ不足時の退避機構は未実装
64MBスライスという発想
kernel/fork.cに、この設計を象徴する1行があります。
new_data_base = new_code_base = nr * 0x4000000;
タスク番号nrのプロセスは、線形アドレスnr × 64MBから始まる領域を使います。task_structのldt[3]に、この位置をベースアドレスとするコード/データセグメントが設定され、プロセスは自分のスライスの外にはアクセスできません。ページテーブルを切り替える代わりにセグメントベースをずらすことで、「各プロセスが独立した空間を持っているように見せる」仕組みです。
現代のLinux(プロセスごとに独立したページテーブルを持つ)とは全く違うアプローチであり、x86のセグメンテーションを最大限に活用した、0.01ならではの設計と言えます。
物理メモリレイアウト
include/linux/config.hの定数から、物理メモリの使い方が分かります(既定のHIGH_MEMORY=8MB構成の場合)。
0x000000 - カーネル本体(先頭にページディレクトリ)
カーネル末尾 - 0x200000 バッファキャッシュ(BUFFER_END = 2MB)
0x200000 - 0x800000 ユーザープロセス用ページプール(LOW_MEM 〜 HIGH_MEMORY)
搭載メモリの自動検出はなく、管理範囲はコンパイル時に固定です。QEMUに-m 16Mを渡しても8MBまでしか使われません。
Copy-on-Write(COW)の実装
メモリは貴重なリソースです。fork()システムコールで新しいプロセスを作成する際、親プロセスのメモリをすべてコピーするのは非効率的です。Linux 0.01は、最初のバージョンにしてすでにCopy-on-Write16という巧妙な技術を実装しています。
COWの基本的なアイデアは「必要になるまでコピーしない」ことです。fork()時には、親子プロセスが同じ物理メモリページを共有し、どちらかが書き込みを行った時点で初めてコピーを作成します。書き込み保護されたページへの書き込みはページフォルト17を発生させ、そこで初めて実コピーが行われます(mm/memory.cのdo_wp_page())。
以下はmm/memory.cのcopy_page_tables()の実際のコードです。fork()時にページの実体ではなくページテーブルだけをコピーし、両者を書き込み不可にしているのが分かります。
int copy_page_tables(unsigned long from,unsigned long to,long size)
{
unsigned long * from_page_table;
unsigned long * to_page_table;
unsigned long this_page;
unsigned long * from_dir, * to_dir;
unsigned long nr;
if ((from&0x3fffff) || (to&0x3fffff))
panic("copy_page_tables called with wrong alignment");
from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
to_dir = (unsigned long *) ((to>>20) & 0xffc);
size = ((unsigned) (size+0x3fffff)) >> 22;
for( ; size-->0 ; from_dir++,to_dir++) {
if (1 & *to_dir)
panic("copy_page_tables: already exist");
if (!(1 & *from_dir))
continue;
from_page_table = (unsigned long *) (0xfffff000 & *from_dir);
if (!(to_page_table = (unsigned long *) get_free_page()))
return -1; /* Out of memory, see freeing */
*to_dir = ((unsigned long) to_page_table) | 7;
nr = (from==0)?0xA0:1024;
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
this_page = *from_page_table;
if (!(1 & this_page))
continue;
this_page &= ~2; /* 書き込み保護(COW) */
*to_page_table = this_page;
if (this_page > LOW_MEM) {
*from_page_table = this_page; /* 親も書き込み不可に */
this_page -= LOW_MEM;
this_page >>= 12;
mem_map[this_page]++; /* 参照カウント増加 */
}
}
}
invalidate();
return 0;
}
なお、0.01には高分解能の時刻取得手段がない(time()の分解能は1秒)ため、fork()の所要時間のようなマイクロベンチマークを0.01の上で正確に測ることはできません。本記事では効果の定量評価ではなく、実装の存在をソースコードで確認するにとどめます。
QEMUでの詳細解析
「実際に起動してみる」の節で動かした環境を使って、さらに深く観察してみましょう。QEMU18のモニタ機能とデバッガを使うことで、CPUの内部状態やメモリの内容をリアルタイムで観察できます。
デバッグモードでの実行
# GDBデバッグ用(-s: gdbserverをポート1234で待機、-S: 開始時に停止)
qemu-system-i386 <通常の起動オプション> -s -S -monitor stdio
# 別ターミナルでGDB接続
gdb
(gdb) target remote localhost:1234
(gdb) set architecture i8086 # リアルモード(ブート直後)の解析時
(gdb) break *0x7c00 # ブートセクタの先頭で停止
(gdb) continue
QEMUモニタでの解析
QEMUの最も強力な機能の一つが、実行中のシステムを詳細に調査できるモニタ機能です。これを使うことで、教科書的な理解から実践的な理解へと深めることができます。
# モニタコマンドの例
(qemu) info registers # レジスタ状態
(qemu) info mem # 仮想メモリマッピング
(qemu) xp/8xw 0x7c00 # メモリダンプ(物理アドレス)
(qemu) info tlb # TLBの内容(仮想→物理の変換状態)
(qemu) screendump x.ppm # VGA画面のスクリーンショット
info registersでは、実行中コードの特権レベルであるCPL19や、各セグメントのDPL20、保護モード移行後にCS/DSのセレクタやCR0のPEビットが変化する様子を確認できます。info tlbではTLB21(アドレス変換キャッシュ)の状態、つまりページングの動作を観察できます。前述のブートプロセスの説明と突き合わせながら観察すると理解が深まります。
トレース機能の活用
詳細な実行トレースを取得することで、カーネルの動作を深く理解できます。
# CPU実行トレース(大量に出るので注意)
qemu-system-i386 <起動オプション> -d cpu,exec -D trace.log
# 割り込みトレース(タイマー割り込みint 0x20やシステムコールint 0x80が観察できる)
qemu-system-i386 <起動オプション> -d int -D int.log
# MMU関連のトレース
qemu-system-i386 <起動オプション> -d mmu -D mmu.log
-d intのログでは、100Hzのタイマー割り込み(ベクタ0x20)に混じって、シェル操作時にint 0x80(システムコール)やページフォルト(ベクタ0x0E、COWの発動)が流れるのが観察できます。
実践演習:システムコールの追加
理論を学んだ後は、実際に手を動かしてみましょう。Linux 0.01に新しいシステムコールを追加することで、カーネルとユーザープログラムの境界がどのように機能するかを体験できます。
システムコール22は、ユーザープログラムがカーネルの機能を利用するための唯一の正式な方法です。Linux 0.01には67個のシステムコールがあり(kernel/system_call.sにnr_system_calls = 67と定義)、0番のsys_setupから66番のsys_setsidまで、include/linux/sys.hのsys_call_tableに並んでいます。
新しいシステムコールは、この続きの67番として追加します。
実装手順
1. システムコール番号の定義
/* include/unistd.h に追加 */
#define __NR_mysyscall 67 /* 既存の66番(setsid)の次 */
2. システムコール実装
/* kernel/sys.c に追加 */
int sys_mysyscall(void)
{
printk("My first system call! PID=%d\n\r", current->pid);
return current->pid * 2; /* PIDの2倍を返す */
}
3. システムコールテーブルへの登録
/* include/linux/sys.h の sys_call_table の末尾に追加 */
extern int sys_mysyscall();
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
/* ... 中略 ... */
sys_getpgrp, sys_setsid, sys_mysyscall }; /* ← 67番目として追加 */
4. システムコール数の上限を更新(忘れると必ず失敗!)
! kernel/system_call.s
nr_system_calls = 68 ! 67 → 68 に変更
system_call.sはint 0x80のエントリでeaxとこの値を比較し、範囲外なら-1を返します。ここを更新し忘れると、テーブルに登録しても永遠に呼び出せません(筆者は最初これで数十分悩みました)。
5. テストプログラム
/* test_syscall.c */
#define __NR_mysyscall 67
int mysyscall(void)
{
int result;
__asm__ volatile (
"int $0x80"
: "=a" (result)
: "a" (__NR_mysyscall)
);
return result;
}
int main(void)
{
int result = mysyscall();
printf("System call returned: %d\n", result);
return 0;
}
ビルドと実行について
重要な注意点:このテストプログラムはホストのgccではビルドできません。現代のgccが生成するのはELF形式(しかも既定ではx86-64)のバイナリで、0.01が実行できるのは当時のa.out形式だけだからです。ホスト上で実行しても、int 0x80は現代のカーネルのシステムコール表を引いてしまいます。
実際に試すには、カーネルの再ビルドとa.out形式のユーザーランドをビルドできる環境(前述のremake版が配布している当時互換ツールチェーン、またはlinux-0.01-still-runsのuserland/ビルド環境)を使い、生成したバイナリをMINIX v1のルートFSイメージに配置します。成功すれば、0.01のシェル上で次のような出力が得られるはずです。
My first system call! PID=8
System call returned: 16
1行目はカーネル内のprintk(コンソールへ直接出力)、2行目はユーザープログラムのprintfです。カーネル空間23とユーザー空間24の境界をint 0x80が越えていく様子を、わずか数十行で体験できます。
現代のLinuxとの比較
30年以上の時を経て、LinuxはどのようなOSに成長したのでしょうか。数値で見ると、その進化の規模に圧倒されます。しかし同時に、基本的なアーキテクチャは驚くほど一貫していることもわかります。
| 項目 | Linux 0.01 | Linux 6.x | 備考 |
|---|---|---|---|
| コード行数 | 10,239行(実測) | 3000万行以上 | 約3000倍 |
| システムコール数 | 67個 | 400個以上 | x86-64で約450個 |
| カーネルイメージ | 約100KB(実測102,017バイト※) | 数十MB+モジュール | ※remake版をGCC 13でビルドした値 |
| 対応アーキテクチャ | i386のみ | 20種類以上 | - |
| 管理可能メモリ | 8MB | 数TB以上 | - |
Linux 0.01の「シンプルさ」は欠点ではなく、学習と理解のための大きな利点です。
まとめ
本記事では、1991年に公開されたLinux 0.01のソースコード解析を行い、移植版を使ってQEMU上で実際に起動・操作できることを確認しました。約1万行のコードの中に、現代のOSにも通じる重要な概念が数多く実装されていることが確認できました。
技術的な成果
ブートプロセスでは、BIOSからの起動、16ビットから32ビット保護モードへの移行、ページングの有効化という、x86アーキテクチャの基本的な初期化手順を詳細に追いました。0.01ではこれらすべてをboot.sとhead.sのわずか2ファイルが担っており、A20ゲートの有効化やGDT10の設定など、現代では意識することの少ない低レベルの処理を最小構成で学ぶことができました。
プロセス管理では、動的優先度に基づくシンプルながら効果的なスケジューラの実装を確認しました。タイマー割り込み(100Hz)ごとに動作し、各プロセスのcounterを減算していく仕組みは、その後長くLinuxのスケジューラの基本形となりました(2.6系のCFS25を経て、現在のカーネルはEEVDFに移行しています)。また、タスク切り替えがTSSによるハードウェアタスクスイッチだった時代の実装を見られるのも0.01ならではです。
メモリ管理では、全プロセスがページディレクトリを共有し、セグメンテーションで各プロセスに線形空間の64MBスライスを割り当てるという、現代とは全く異なるプロセス分離の設計を確認しました。その一方で、Copy-on-Writeは最初のバージョンからすでに実装されており、copy_page_tables()とdo_wp_page()のコードでその仕組みを確認できました。
システムコールの仕組みは、int 0x80による割り込みを使用したシンプルな実装で、現代のLinuxでも基本的な考え方は変わっていません。67個のシステムコールに新しい1個を追加する演習を通じて、カーネル空間とユーザー空間の境界を越える仕組みを学びました。
そして動作検証を通じて、0.01が前提としていた環境(当時のツールチェーン、フロッピー+固定ジオメトリのHDD、ミリ秒単位で応答するディスク)と現代の環境のギャップも体感できました。「30年前のOSを動かす」こと自体が、優れたシステムプログラミングの演習になります。
歴史的な意義
Linux 0.01は「ただの趣味」として始まりましたが、以下の要因により歴史を変えるプロジェクトとなりました。
- 実用主義的アプローチ - 理論的な美しさよりも「動くこと」を重視
- オープンな開発モデル - 早期からソースコードを公開し、フィードバックを歓迎
- タイミングの良さ - 386プロセッサの普及と、フリーなUNIX系OSへの需要
- コミュニティの力 - 世界中の開発者からの貢献
Torvaldsの「Talk is cheap. Show me the code.」という言葉が示すように、実際に動くコードを示すことこそが、オープンソース開発の本質でした。技術的な成功だけでなく、協力的な開発文化の確立こそがLinuxの真の革新でした。
現代への教訓
Linux 0.01は、現代の複雑なOSと比べて非常にシンプルですが、OSの本質的な機能の多くがすでに実装されています。この小さなカーネルから学べることは、
- シンプルに始める - 完璧を求めず、まず動くものを作る
- フィードバックを歓迎する - 批判も含めて、外部の意見を取り入れる
- 実用性を重視する - 理論だけでなく、実際に使えるものを作る
- 楽しむこと - Torvaldsが「プログラミング自体を楽しんでいた」ように
ぜひ本記事の手順でQEMU上で実際に動かしながら、OSの仕組みと、オープンソース開発の原点を体験してみてください!
参考資料
- Linux Kernel Archives - Historic Linux(0.01のオリジナルソース)
- linux-0.01-still-runs(現代のQEMUで起動できる移植版。本記事の動作確認に使用)
- linux-0.01 remake(Wayback Machineアーカイブ)(GCC 4.x対応化パッチと当時のツールチェーン一式)
- Intel 80386 Programmer's Reference Manual
- QEMU Documentation
- The Tanenbaum-Torvalds Debate
- LINUX's History by Linus Torvalds
-
MINIX v1 - Andrew S. Tanenbaumが教育用に開発したUNIX互換OSのファイルシステム。Linux 0.01はMINIXのファイルシステムを採用(14文字ファイル名、最大64MB)。 ↩
-
ページング - 仮想メモリを固定サイズのページに分割して管理する方式。Linux 0.01では4KBページを使用。 ↩
-
保護モード - Intel 80286以降で導入されたCPUの動作モード。メモリ保護、特権レベル、32ビットアドレッシングなどの機能を提供。 ↩
-
BIOS (Basic Input/Output System) - コンピュータの電源投入時に最初に実行されるファームウェア。ハードウェアの初期化とOSの起動を担当する。 ↩
-
POST (Power-On Self Test) - BIOSが起動時に実行するハードウェアの自己診断テスト。メモリやデバイスの正常性を確認。 ↩
-
セグメント - x86アーキテクチャにおけるメモリ管理の単位。セグメントレジスタとオフセットの組み合わせでアドレスを計算。 ↩
-
A20ゲート - Intel 8086の20ビットアドレスバスとの互換性のため、21本目(ビット20)のアドレスラインがマスクされている。これを有効化しないと1MB以上のメモリにアクセスできない。 ↩
-
CR0レジスタ - x86プロセッサの制御レジスタ。保護モード有効化(PEビット)やページング有効化(PGビット)などの重要なフラグを含む。 ↩
-
セレクタ - 保護モードでセグメントディスクリプタを指定するための16ビット値。GDTまたはLDTのインデックスを含む。 ↩
-
GDT (Global Descriptor Table) - 保護モードでセグメントの属性を定義するテーブル。各セグメントのベースアドレス、リミット、アクセス権限などを格納。 ↩ ↩2
-
コンテキストスイッチ - CPUが実行するプロセスを切り替える処理。レジスタやスタックなどのCPU状態を保存・復元する。 ↩
-
タイムスライス - プロセスがCPUを占有できる時間の単位。Linux 0.01では10ms(100Hz)ごとにcounterが減算される。 ↩
-
TSS (Task State Segment) - x86プロセッサがタスク切り替え時にCPU状態を保存・復元するためのデータ構造。Linuxはカーネル2.2までTSSによるハードウェアタスクスイッチを使用していた。 ↩
-
動的優先度スケジューリング - プロセスの実行時間に応じて優先度が動的に変化するスケジューリング方式。長時間実行されたプロセスの優先度が下がることで、公平性を保つ。 ↩
-
仮想メモリ - 物理メモリを抽象化し、プロセスに独立したアドレス空間を提供する仕組み。Linux 0.01ではページング+セグメンテーションの併用で実現しており、ページテーブルは全プロセス共有、分離はセグメントで行うという現代とは異なる設計。 ↩
-
Copy-on-Write(COW) - fork()時にメモリページを即座にコピーせず、書き込みが発生した時点で初めてコピーを作成する最適化技術。メモリ使用量とfork()のコストを大幅に削減できる。 ↩
-
ページフォルト - プロセスがアクセスしようとしたページが物理メモリに存在しない、または書き込み禁止の場合に発生する例外。COWの実現に利用される。 ↩
-
QEMU - オープンソースのプロセッサエミュレータ。様々なCPUアーキテクチャをエミュレートでき、OSの開発やデバッグに広く使用される。 ↩
-
CPL (Current Privilege Level) - 現在実行中のコードの特権レベル。0(カーネルモード)から3(ユーザーモード)までの4段階。 ↩
-
DPL (Descriptor Privilege Level) - セグメントディスクリプタに設定されたアクセス権限レベル。CPLと比較してアクセス可否を判定。 ↩
-
TLB (Translation Lookaside Buffer) - 仮想アドレスから物理アドレスへの変換をキャッシュする高速メモリ。ページテーブル参照の高速化に使用。 ↩
-
システムコール - ユーザープログラムがカーネルの機能を利用するためのインターフェース。Linux 0.01ではint 0x80割り込みを使用。 ↩
-
カーネル空間 - カーネルが動作するメモリ領域。最高特権レベル(CPL=0)で実行され、すべてのハードウェアリソースにアクセス可能。 ↩
-
ユーザー空間 - 一般のアプリケーションが動作するメモリ領域。低い特権レベル(CPL=3)で実行され、システムコール経由でのみカーネル機能を利用。 ↩
-
CFS (Completely Fair Scheduler) - Linux 2.6.23以降で採用された公平性を重視したスケジューラ。赤黒木を使用して効率的にプロセスを管理。カーネル6.6(2023年)でEEVDFスケジューラに置き換えられた。 ↩
