前章までで、私たちは「メモリを自由に使えるプログラム」を書いてきた。malloc でヒープを確保し、ポインタで住所を辿り、printf で画面に文字を出した。だが、ここで根本的な問いが浮かぶ。
あなたのプログラムは、本当にハードウェアを直接触っているのだろうか?
答えは「否」だ。あなたのコードと、CPU・メモリ・ディスク・画面という物理の間には、カーネルという名の管理者が立っている。この章では、その管理者と対話する唯一の窓口——システムコール——を解体する。
なぜ「壁」が必要だったのか — 歴史の必然
初期のコンピュータには、OSと呼べるものがなかった。プログラムはハードウェアを直接叩き、メモリのどこでも書き換え、I/Oを直に制御した。一台のマシンで一つのプログラムだけを動かしていた時代は、それで良かった。
だが問題が起きる。複数のプログラムを同時に動かしたいという要求だ。もし全プログラムがメモリを好き勝手に触れたら、プログラムAがプログラムBのデータを踏み潰す。悪意あるコードがディスク全体を消す。一つのバグがマシン全体を巻き込んでクラッシュする。
そこで人類は、CPUに2つの動作モードを持たせることにした。
- カーネルモード(特権モード / Ring 0): すべての命令を実行でき、全メモリ・全ハードウェアにアクセスできる
- ユーザーモード(Ring 3): 制限された命令しか使えず、直接ハードウェアを触れない
あなたのアプリは常にユーザーモードで動く。ハードウェアを触りたければ、カーネルモードで動くカーネルに「お願い」するしかない。このお願いの仕組みが、システムコールだ。
厨房のアナロジー — 客は厨房に入れない
レストランを思い浮かべてほしい。客席がユーザー空間、厨房がカーネル空間だ。
客(アプリ)は、料理(データ)が欲しくても厨房に勝手に入ることはできない。ガスコンロ(CPU)や冷蔵庫(メモリ)、食材庫(ディスク)を客が直接触ったら、厨房は無法地帯になる。他の客の料理を勝手に食べる者、火を消してしまう者——店は崩壊する。
だから間にウェイター(システムコール)を置く。客は「ステーキを焼いてくれ」と伝票(システムコール番号と引数)を書いてウェイターに渡す。ウェイターはそれを厨房に通し、シェフ(カーネル)が調理して、料理を客席まで運ぶ。
私が飲食店で働いていた頃、この「客席と厨房の分離」は絶対のルールだった。客が厨房に入れないのは意地悪ではなく、全員の食事を守るためだ。OSの特権分離も、まったく同じ思想でできている。
┌─────────────────────┐ ┌─────────────────────┐
│ ユーザー空間 │ │ カーネル空間 │
│ (客席 / Ring 3) │ │ (厨房 / Ring 0) │
│ │ 伝票 │ │
│ あなたのアプリ ──── syscall ───▶ カーネル │
│ │ │ ├ プロセス管理 │
│ printf, open, ... │ ◀─────── ├ メモリ管理 │
│ │ 戻り値 │ ├ ファイルシステム │
└─────────────────────┘ │ └ デバイスドライバ │
└──────────┬──────────┘
│
物理ハードウェア
(CPU/メモリ/ディスク/NIC)
システムコールの正体 — 意図的な「割り込み」
では、ユーザーモードのアプリは、どうやってカーネルモードへ制御を移すのか。勝手にモードを切り替えられたら壁の意味がない。答えは、CPUに用意された専用の命令を使うことだ。
x86-64 Linux では、syscall という機械語命令がそれにあたる。この命令を実行すると、CPUは以下を自動的に行う:
- 現在のユーザーモードの状態を保存する
- カーネルモードに切り替える
- カーネルが事前に登録した入口(システムコールハンドラ)へジャンプする
これは、あらかじめ決められた入口からしかカーネルに入れないという意味で、「意図的で安全な割り込み」だ。どのサービスを呼ぶかは、レジスタに入れたシステムコール番号で指定する。
アプリ側の「伝票」の書き方(x86-64 Linux):
rax ← システムコール番号(例: write = 1)
rdi ← 第1引数(ファイルディスクリプタ: 1 = 標準出力)
rsi ← 第2引数(書き込むデータの先頭アドレス)
rdx ← 第3引数(バイト数)
syscall ← ここでカーネルモードへ!
rax → 戻り値(書き込めたバイト数、またはエラー)
「ファイルディスクリプタ(fd)」とは、開いているファイルやI/Oに割り当てられる整数の番号札だ。UNIXでは慣習的に、0 が標準入力、1 が標準出力、2 が標準エラー出力に割り当てられている。
アセンブリで直接システムコールを叩く
printf のような便利関数を一切使わず、write システムコールだけで “Hi\n” を出力してみよう。
/* syscall を直接呼ぶ。libc の printf を経由しない */
#include <unistd.h> /* write() のプロトタイプ */
#include <sys/syscall.h> /* SYS_write */
int main(void) {
const char msg[] = "Hi\n";
/* write(fd=1, buf=msg, count=3) を直接発行 */
syscall(SYS_write, 1, msg, 3);
return 0;
}
printf("Hi\n") と書いたときも、内部を辿れば最終的にこの write システムコールに行き着く。あなたが今まで使ってきた出力関数は、すべてこの壁越しのお願いの薄い包み紙だった。
strace で「お願い」を盗み見る
理屈だけでは腑に落ちない。実際にアプリがどんなシステムコールを発行しているか、strace コマンドで覗いてみよう(Linux)。
# たった "hello" を出すだけのプログラムでも…
$ strace ./hello
execve("./hello", ["./hello"], ...) = 0 # プログラムの起動そのものがsyscall
brk(NULL) = 0x... # ヒープ境界の問い合わせ
openat(..., "/etc/ld.so.cache", ...) = 3 # 共有ライブラリの読み込み
mmap(NULL, 8192, PROT_READ|PROT_WRITE, ...) = 0x... # メモリマッピング
write(1, "hello\n", 6) = 6 # ← これが本命の出力
exit_group(0) = ? # プロセス終了もsyscall
驚くべきことに、「hello と出すだけ」のプログラムですら、起動・ライブラリ読み込み・メモリ確保・出力・終了と、十数回もカーネルにお願いしている。第5章で見た「ヒープの確保」は、この brk や mmap というシステムコールの正体だったのだ。malloc は、これらを束ねて使いやすくした包み紙にすぎない。
モード遷移のコスト — なぜ「壁」は速くないのか
システムコールは無料ではない。ユーザーモードとカーネルモードの往復には、レジスタの退避・復帰、CPUキャッシュやTLB(次章で扱う)の乱れなど、無視できないコストがかかる。
一回あたり数百ナノ秒〜マイクロ秒のオーダーだ。単発なら誤差だが、1秒間に数百万回発行すればボトルネックになる。
だからこそ、高性能なプログラムは「システムコールの回数を減らす」ことに腐心する。
- バッファリング: 1バイトずつ
writeせず、まとめてから一度に書く(printfが内部でやっているのはこれだ) - バッチ化: 複数の要求を1回のシステムコールにまとめる
第10章で扱う epoll も、突き詰めれば「大量の接続を、少ないシステムコールで捌く」ための発明だ。この「壁を越えるコスト」という物理が、後のあらゆる高性能設計の動機になっている。
UNIXの思想 — 「すべてはファイル」
システムコールを語るうえで、UNIXの設計思想は避けて通れない。1970年代、ベル研究所のケン・トンプソンとデニス・リッチーが生んだUNIXは、一つの美しい抽象を打ち立てた。
Everything is a file.(すべてはファイルである)
ディスク上のデータも、キーボード入力も、画面出力も、ネットワーク接続も、プロセス間通信も——すべてを「ファイルディスクリプタ」という同じ番号札で扱う。だから、たった数個のシステムコールでほとんどのI/Oが表現できる。
| システムコール | 意味(厨房の比喩) |
|---|---|
open | 食材庫の扉を開ける(fdを得る) |
read | 中身を取り出す |
write | 中身を書き込む |
close | 扉を閉じる(fdを返す) |
#include <fcntl.h>
#include <unistd.h>
int main(void) {
/* ファイルもソケットも端末も、開けば同じ「fd」になる */
int fd = open("data.txt", O_RDONLY); /* 扉を開ける → 番号札を得る */
char buf[64];
ssize_t n = read(fd, buf, sizeof buf); /* 同じ read で中身を取る */
write(1, buf, n); /* 同じ write で標準出力へ */
close(fd); /* 扉を閉じる */
return 0;
}
この統一が、パイプ(ls | grep foo)のような組み合わせの自由を生んだ。あるプログラムの出力(fd 1)を、別のプログラムの入力(fd 0)に繋ぐだけでいい。「小さな道具を組み合わせる」というUNIX哲学は、この単純な抽象から生まれている。LinuxもmacOSも、その根はここにある。
まとめ — 「おまじない」の消し方
- CPUには2つのモードがある:ユーザーモード(客席)とカーネルモード(厨房)。アプリは常に客席側にいる
- システムコールは唯一の窓口:
syscall命令で安全にカーネルモードへ移り、ハードウェアを「お願い」する printfもmallocも包み紙:辿ればwrite・brk/mmapというシステムコールに行き着く- 壁を越えるにはコストがかかる:モード遷移は高価だから、バッファリングとバッチ化で回数を減らす
- すべてはファイル:UNIXは全I/Oをfdで統一し、道具の組み合わせの自由を生んだ
「なぜアプリはOSなしに動けないのか」「なぜ sudo(特権)が必要な操作があるのか」——その答えは、この特権分離の壁にあった。
だが、まだ大きな謎が残っている。複数のプログラムが同時に動くとき、なぜ互いのメモリを壊さずに済むのか。プログラムはみな「アドレス 0x1000」を使っているように見えるのに、なぜ衝突しないのか。次章では、カーネルが作り出す最大の幻想——仮想メモリ——の舞台裏へ降りていく。
「良い塀は良い隣人をつくる。カーネルという塀があるからこそ、無数のプログラムが同じマシンの上で、互いを信頼せずに共存できる。」