Skip to main content
  1. Posts/

GNU Machのソースコードを、素人が興味本位で読んでみた話

Table of Contents

きっかけは本当に些細なことで、「GNU Hurdって結局動いてるの?」という Twitter(もうXって呼ぶべきなんでしょうか)のタイムラインの雑談を見かけた ことでした。Hurdというと「1990年代からずっと開発中と言われ続けているOS」 という、ある種のミームみたいな扱いをされがちですが、その基盤になっている マイクロカーネル gnumach の中身を実際に読んだことはなかったな、と 思い立って、素朴に git clone して読み始めました。

半分は職業病みたいなものかもしれません。「動いていると言われているもの」 の中身を実際に確認する、という行為自体にわりと快感を覚えるタイプの人間 です。とはいえ今回は仕事とは何の関係もない、純粋な趣味の話です。

以下、読んだ範囲で「へぇ」と思ったところを、初見の人にも伝わるように 整理してみます。実は25年ほど前にも一度Hurd周りのコードを読んでいた 時期があって、後半はその頃の記憶を辿る話にもなっています。

そもそも gnumach って何なのか
#

GNU Mach は、GNU Hurd の土台になっている マイクロカーネルです。ルーツを辿ると Carnegie Mellon 大学の Mach 3.0 に 行き着く、由緒正しい(?)1980年代の設計です。「マイクロカーネル」という 言葉の教科書的な説明——「カーネルはメカニズムだけ提供し、ポリシーは ユーザ空間に追い出す」——を、これほど愚直に実装しているコードベースも 珍しいんじゃないかと思います。

ディレクトリ構成を見るだけでも性格がわかります。

ディレクトリ中身規模
kern/スケジューラ・タスク/スレッド管理約31,500行
ipc/Mach IPC(ポート・メッセージング)約16,600行
vm/仮想メモリ管理約21,200行
device/デバイス抽象層約9,700行
linux/Linuxから移植したドライバ群約345,500行

見ての通り linux/ が圧倒的に大きいんですが、これは中身がMachというより 「LinuxのドライバをMachの作法でラップしたもの」がほとんどだからです。 IDE/SCSI/AHCIのディスクドライバやネットワークカードのドライバを、いちいち Machのために書き直すのは非現実的なので、既存資産を持ってきている。この あたりの「潔さ」は好きです。

肝心の「マイクロカーネルらしさ」が詰まっているのは kern/~・~ipc/~・~vm/ の3つで、合計しても7万行に満たない程度。今回はほぼこの3つだけを読みました。

一番おもしろかった:ポートは「番号」でしかない、という設計
#

Mach IPCの根幹は「すべてはポートへのメッセージ送信である」という一文に 尽きます。ファイルもプロセスもメモリオブジェクトも、カーネルの目からは 「ポート」という統一的な窓口を通してやり取りされる。

で、実際にコードを読んでいちばん「なるほど」と思ったのが、ユーザ空間から 見えている「ポート」の正体です。ipc/ipc_kmsg.cipc_kmsg_copyin_header という関数(送信メッセージのヘッダを処理する箇所)を読むと、こう書いて あります。

is_read_lock(space);
entry = ipc_entry_lookup(space, dest_name);      // 名前→エントリ
...
dest_port = (ipc_port_t) entry->ie_object;
ip_lock(dest_port);
is_read_unlock(space);
...
dest_port->ip_srights++;                          // 参照カウント増加

つまりユーザ空間が持っている「ポート」というのは、実体としてはただの 整数(mach_port_name_t)です。カーネルオブジェクトへのポインタなんて どこにも渡っていません。その整数を、必ず「呼び出したタスク自身のIPC 空間」というテーブルに引かせて、初めて実オブジェクトのポインタが手に 入る。

これがどういう意味を持つかというと、**他人のポート番号を知っていても、 それだけでは何もできない**ということです。番号が指すテーブルは常に 「自分の空間」なので、他タスクの空間を覗く経路がAPIレベルで存在しない。 UNIXのファイルディスクリプタと似ているようで、「送信権」「受信権」を はっきり区別した、もう一段厳格なケーパビリティモデルになっています。

しかも権利を人に渡すときのコストが、渡し方によってきっちり作り分けられて いるのも面白いところです。ipc/ipc_right.cipc_right_copyin を見ると:

  • MAKE_SEND(受信権から新しい送信権を複製する)は、単に参照カウントを 増やすだけ
  • MOVE_SEND(自分の送信権を他人に渡して、自分は手放す)は、内部の 「ユーザ参照カウント(urefs)」をちゃんとデクリメントし、0になったら エントリごと消す

「コピーする」と「明け渡す」で明確にコード上の扱いが違う、というのは、 1980年代の設計とは思えないほど几帳面だなと感心しました。

ページフォールトの中身は、ほぼ「シャドウチェーンを遡る」だけ
#

もう一つ面白かったのが仮想メモリ側、vm/vm_fault.c のページフォールト 処理です。

Machのメモリオブジェクトは「シャドウチェーン」という親子関係を持って いて、あるページが自分のオブジェクトに無ければ親を見に行く、という 仕組みになっています。コードで言うとこの部分です。

if (m->absent) {
    offset += object->shadow_offset;
    next_object = object->shadow;
    if (next_object == VM_OBJECT_NULL) {
        /* シャドウチェーンの末端 = ゼロフィルページを生成 */
        real_m = vm_page_grab(VM_PAGE_HIGHMEM);
        ...
        vm_page_zero_fill(m);
    }
    /* else: 親オブジェクトを見に行く */
}

これ、要するに fork() したあとの匿名メモリ(ヒープやBSS)がどう実現 されているか、そのものです。「無ければ親を見る、それでも無ければゼロで 埋める」——たったこれだけのルールの積み重ねで、プロセスのメモリ空間が 成立している。

Copy-on-Writeの実装も同じ発想の延長で、書き込みが起きた瞬間に「元の ページの中身を、コピー先のオブジェクトに逃がしてから」元を書き換え 可能にする、という順序を守っているだけです。派手な仕掛けは何もなくて、 地味なルールの積み重ねだけでOSの根幹機能が成立しているのを見ると、 なんというか、静かに感動します。

カーネルがポリシーを持たない、を本当に徹底している話
#

個人的に一番「これは筋がいい設計だ」と思ったのが、VMのコピー戦略の 決め方です。

vm/vm_object.cvm_object_copy_strategically という関数は、メモリを コピーするときに「即座に全部コピーする」「遅延COWにする」「ページャに 丸投げする」の3通りをswitchで切り替えているんですが、この戦略を カーネル自身は決めていない んですね。

while (!src_object->pager_ready) {
    vm_object_wait(src_object, VM_OBJECT_EVENT_PAGER_READY, interruptible);
    ...
}

外部のページャ(ユーザ空間で動くサーバプロセス)が「私が管理するこの メモリはこの戦略でコピーしてください」と申告してくるのを、カーネルは 律儀に待つだけなんです。申告が届くまではコピー処理そのものをブロック する。

Hurd側の対応も追ってみたら、ファイルシステム翻訳者(ext2fsなど)が 使う libpager というライブラリの pager_create() 関数が、まさに この copy_strategy を引数として受け取っていました。実行ファイルや 共有ライブラリのマップに使われる「遅延COW」は、カーネルが勝手に判断 しているのではなく、ファイルシステム側のサーバが「これは遅延COWで いいですよ」と明示的に宣言しているから成立している、ということです。

「メカニズムはカーネル、ポリシーはユーザ空間」という設計哲学、教科書 では何度も見た文言ですが、実際にコードでここまで愚直に貫かれていると、 ちょっと感動してしまいます。

余談:25年前に読んでいたのはHurd側だった
#

ここで少し個人的な話をさせてください。今回gnumach(カーネル側)を読んで いて気づいたんですが、実は25年ぐらい前、一度Hurd/gnumachのソースを 読もうとしたことがあります。当時惹かれていたのは カーネル側ではなく、Hurd側の translator という仕組みでした。

translatorというのは平たく言うと「ファイルシステムのノード1つ1つに、 好きなユーザ空間サーバを貼り付けられる」機構です。`settrans`コマンド 一発で、あるディレクトリの正体を任意のプログラムにすり替えられる。 2005年前後にFUSEが登場したときに「これは便利だ」と持て囃されましたが、 Hurdはその発想をずっと早く、しかもマウントポイント単位の局所的な話 ではなく、ファイルシステム階層そのものの設計原理としてやっていました。

当時特に読んでいたのは pfinet です。ネットワークをファイルシステムの translatorとして実装するという、今思えばかなり大胆な発想でした。今回 gnumachを読み返したついでに、記憶を頼りに pfinet のソースも覗いて みたところ、READMEにこう書いてありました。

“The Hurd’s pfinet server is based on networking code taken from the Linux kernel sources, initially version 2.2.12 of Linux.”

つまり中身は文字通り、 Linux 2.2.12のTCP/IPスタックをほぼそのまま移植したもの です。 pfinet/linux-src/net/{core,ipv4,ethernet} という ディレクトリがまるごと存在していて、外側の socket-ops.c~・~io-ops.c がHurdのRPC作法(`io_read`/`io_write`、`socket_create`など)を実装し、 内側でそのままLinuxのソケットコードを叩く、という二層構造。前段で 書いた「translatorは`memory_object`の発想の応用である」という話で 言うと、pfinetは「ネットワークスタックという巨大な機能ブロックを丸ごと ユーザ空間サーバに閉じ込めた」かなり極端な例だったんだと思います。 カーネルはネットワークのことを一切知らない。

当時は結局、自分でtranslatorを書くところまでは至らず、読むだけで 終わりました。`trivfs`まわりの作法(`trivfs_S_dir_lookup`のあたり)を 理解しようとしたところで力尽きた記憶があります。それでも、25年経って また同じコードの子孫を読み返すことになるとは思っていなかったので、 不思議な巡り合わせでした。

ちなみに、25年前の設計が令和のいまも現役だという証拠に、2026年Q2の Hurd開発ニュースにもちょうどpfinetの話が出ていました。mmap の戻り値 をチェックせずに使っていた古いバグが2つ見つかり、メモリが逼迫すると pfinetがクラッシュしうる、という修正が最近入ったばかりだったそうです。 1999年頃のコードが、2026年になっても現役でバグ報告と修正を受けている というのは、なかなか感慨深いものがあります。

aarch64移植、見た目と実態のギャップが面白かった
#

ここからは余談です。gnumachには aarch64/ というディレクトリがあって、 「お、ARM移植も進んでるのか」と覗いてみたら、中身は型定義とABIの ヘッダファイルだけで、.cファイルが1つも無い。ブートコードも例外処理 コードも見当たらない。

「あれ、これって看板倒れなのでは……」と思ってコミットログを辿ったら、 直近のコミットメッセージにこんな一文がありました。

“Bugaev’s wip-aarch64 worked around this by editing kern/lock.h itself…”

つまり、実際に動くARM移植は別ブランチ(github.com/bugaevc/gnumachwip-aarch64)に存在していて、mainリポジトリには型定義だけが先行して 着地している状態でした。少し検索してみたところ、開発者本人が

“my branch of gnumach compiles, boots in QEMU, and passes the testsuite!”

と、2024年の時点でQEMU上でブートしてテストスイートまで通していると 報告していて、fork()やシグナル配送(intr RPC → シグナルトランポリン → sigreturn)まで一通り動く状態になっているとのこと。2026年に入ってからは、 別の開発者がこの動くブランチを「共有ファイルを一切壊さずにmainへ 段階的に取り込む」という、地味だけど誠実な作業を進めているようです。

コミットログという「表の記録」だけを見ていると進捗を過小評価してしまう 典型例だったな、と反省しました。オープンソースのアーキ移植って、動く 実装があってもupstream化には別の労力がかかるんだな、というのを実感 した瞬間でした。

直近のバグ修正から見える、地味だけど大事な話
#

最後にもう一つ、コミット履歴を追っていて印象に残ったバグ修正を紹介 します。

ひとつは、SMP環境でスケジューラがNULLポインタを踏んでカーネルパニック していたバグ。コミットメッセージには「Hurdを自己ホストビルドしている 最中にSMP環境でクラッシュする」とあって、地味に実害の大きいバグだった んだろうなと想像がつきます。

もうひとつは、もう少し本質的な話でした。task_priority() という RPC——タスク自身が自分の優先度を変更するための呼び出しなんですが、特権 ポートを一切要求しない、つまり**どのプロセスでも呼べる**ものです。 これに、修正前は上限チェックが一切ありませんでした。

task_lock(task);
if (task->max_priority > priority) {      // ← 追加されたチェック
    task_unlock(task);
    return KERN_NO_ACCESS;
}
task->priority = priority;

Machの優先度は数値が小さいほど「偉い」扱いなので、これはつまり「誰でも 自分を最優先に格上げできてしまい、他のタスクを飢餓状態に追い込める」 状態だったということです。修正では max_priority という天井を導入し、 それを超える要求は拒否するようにしていました。

CVE番号が付くような華々しい脆弱性ではなく、開発者自身が地道なコード レビューで見つけて塞いだ類の話だと思いますが、「マイクロカーネルに おいて、タスクポートは無制限の自己管理権限ではない」という信頼境界を 守る、というのはまさにこのOSの根幹に関わる話だよなと感じました。

まとめ
#

読んでみて改めて思ったのは、gnumachというコードベースは全体としては 「枯れた設計」なんですが、枯れているからこそ設計思想がむき出しで見える、 という面白さがあるということでした。ポートは番号でしかない、コピー 戦略はユーザ空間が決める、カーネルは律儀に待つ——1980年代に確立された アイデアが、令和のいま2026年のコミットログの中でも変わらず一貫して 守られているのを見るのは、なかなか得難い体験でした。

気が向いたら、スケジューラ(sched_prim.c)のロードバランシング周りも 続けて読んでみようと思います。

Related