「保護」はどうやって実現されているのか

第1章では、OSの役割の1つとして「保護」を挙げました。あるプロセスが他のプロセスのメモリを書き換えたり、ディスクを勝手に初期化したりできないようにする仕組みです。 では、普通のプログラムとOSは何が違うのでしょうか。どちらもCPUが実行する機械語であることに変わりはありません。

違いを生んでいるのはCPUのハードウェア機能です。CPUは「今どの権限で命令を実行しているか」というモードを持ち、権限の低いモードでは危険な命令を実行できません。 本章では、このモードの境界と、境界を越えてOSに処理を頼む窓口であるシステムコールを見ていきます。

ユーザーモードとカーネルモード

OSの中核部分をカーネルと呼びます。カーネルはカーネルモードで動き、ハードウェアを直接操作する命令やページテーブル(第5章)の書き換えなど、すべての命令を実行できます。 一方、Node.jsやブラウザといった普通のプログラムはユーザーモードで動きます。 ユーザーモードでI/Oデバイスを直接触る命令などを実行しようとすると、CPUが例外を起こしてカーネルに制御を移します。

x86のリングとARMの例外レベル

x86は特権レベルをリング0〜3の4段階で表します。数字が小さいほど権限が強く、Linuxはリング0をカーネルに、リング3をユーザープログラムに使っています。リング1と2は使っていません。 Apple SiliconなどのARMでは同じ考え方を「例外レベル」と呼び、EL0がユーザー、EL1がカーネルに当たります。 呼び方は違っても、権限の強いモードと弱いモードを分けるという点は共通です。

メモリも同じように分けられます。各プロセスのアドレス空間には、プログラム自身が使うユーザー空間と、カーネルのコードやデータが置かれるカーネル空間があります。 カーネル空間のページにはカーネルモードでしかアクセスできない印が付いているので、ユーザーモードのコードが読もうとするとページフォールトが起きて止められます。

Limited Direct Execution — 速さと安全の両立

OSTEP第6章は、OSがプログラムを動かす方法をLimited Direct Execution(制限付き直接実行)と呼んで説明しています。 「直接実行」とは、プログラムの機械語をCPUにそのまま実行させることです。命令を1つずつOSが検査するより、ずっと速く動きます。 ただし、そのままではプログラムが何でもできてしまい、OSは制御を取り戻せません。そこで2つの「制限」を加えます。

制限1: 特権操作はトラップ経由でしか頼めない

プログラムがファイルを開きたいときは、特別な命令でトラップを起こします。 トラップが起きると、CPUはモードをカーネルモードに切り替え、あらかじめ決められたカーネルの入口へジャンプします。 入口の場所はOSが起動時にトラップテーブルとしてCPUに登録しておくので、ユーザープログラムが好きな場所に飛び込むことはできません。 処理が終わると、カーネルは「トラップからの復帰」命令でユーザーモードに戻り、プログラムの続きを実行させます。

制限2: タイマー割り込みで必ず制御を取り戻す

もう1つの問題は、プログラムが無限ループに入ってトラップを一切起こさない場合です。 そのままでは他のプログラムにCPUが回ってきません。そこでOSは起動時にタイマーを設定し、一定時間ごとにタイマー割り込みを発生させます。 割り込みが来るとCPUは強制的にカーネルへ制御を移すので、OSは実行中のプログラムを止めて別のプログラムに切り替えられます。 第4章で扱うスケジューラは、このタイマー割り込みを前提に動いています。

システムコールの流れ

トラップを使ってカーネルに処理を頼む仕組みがシステムコールです。 ファイルの読み書き(read / write)、ネットワーク(socket / connect)、プロセスの生成(fork / execve)など、OSの機能を使う操作はすべてシステムコールを通ります。 ふだんは直接呼ばず、C標準ライブラリ(libc)の関数が中で呼んでくれます。

sequenceDiagram
  participant App as アプリ<br/>(ユーザーモード)
  participant Libc as libc<br/>read()関数
  participant CPU as CPU
  participant K as カーネル<br/>(カーネルモード)
  App->>Libc: read(fd, buf, n)
  Libc->>Libc: rax=0(read) rdi=fd rsi=buf rdx=n
  Libc->>CPU: syscall命令
  CPU->>K: カーネルモードへ切り替え<br/>登録済みの入口へジャンプ
  K->>K: システムコール表から<br/>番号0の処理を呼ぶ
  K->>K: ファイルからbufへコピー
  K-->>CPU: 戻り値をraxへ
  CPU-->>Libc: ユーザーモードへ復帰
  Libc-->>App: 読んだバイト数<br/>失敗時は-1とerrno
x86-64 Linuxでread()を呼んだときの流れ。libcがレジスタに番号と引数を詰めてsyscall命令を実行し、カーネルがシステムコール表から処理を選ぶ

x86-64 Linuxのレジスタ規約

x86-64のLinuxでは、システムコールを呼ぶ側は次のようにレジスタを使います。

レジスタ役割read の例
raxシステムコール番号(戻り値もここに入る)0(read)
rdi第1引数ファイルディスクリプタ
rsi第2引数読み込み先バッファのアドレス
rdx第3引数読む最大バイト数
r10第4引数(使わない)
r8第5引数(使わない)
r9第6引数(使わない)

普通の関数呼び出しでは第4引数にrcxを使いますが、システムコールではr10を使います。syscall命令がrcxに戻り先アドレスを保存してしまうためです。 システムコール番号はアーキテクチャごとに決まっていて、x86-64ではread=0、write=1、close=3、openat=257です。 ARM64のLinuxでは命令がsvc、番号を入れるレジスタがx8になるなど、細部が変わります。 libcを直接通さずにシステムコールを呼ぶと、たとえば標準出力への書き込みは次のようになります。

; x86-64 Linux: write(1, msg, 14) を直接呼ぶ
mov rax, 1          ; システムコール番号 1 = write
mov rdi, 1          ; 第1引数: fd 1 = 標準出力
lea rsi, [rel msg]  ; 第2引数: 書き込むバイト列のアドレス
mov rdx, 14         ; 第3引数: バイト数
syscall             ; カーネルへトラップ。戻り値は rax

msg: db "Hello, kernel", 10

カーネルはエラーのとき負の値(-errnoに当たる数)を返し、libcがそれを「戻り値-1とerrno変数」の形に直します。 Node.jsで見る ENOENT や EMFILE は、このerrnoの名前がそのままエラーコードになったものです。

fs.readFileSync は最後に何を呼んでいるのか

Node.jsで次のコードを書いたとき、カーネルから見ると何が起きているのでしょうか。

import { readFileSync } from "node:fs";

// JavaScriptから見ると1回の関数呼び出し
const text = readFileSync("./config.json", "utf8");
console.log(text.length);

readFileSync はNode.jsのC++バインディングを通ってlibuvの関数を呼び、libuvがlibcを通してシステムコールを発行します。 骨格は「openat でファイルを開いてファイルディスクリプタを受け取る → read で中身をバッファへ読む(大きければ複数回)→ close で閉じる」の3段階です。 実際にはサイズを調べる呼び出しなどが混ざることもあります。 最後の console.log も、標準出力への write システムコールになります。

この流れは、straceというツールを使うと実際に1行ずつ観察できます。 第12章ではDockerのLinuxコンテナでstraceを動かし、同期版の readFileSync はメインスレッドから、非同期版の fs.readFile はlibuvのワーカースレッドから openat を呼んでいることを確かめます。

vDSO — カーネルに入らないシステムコール

システムコールの中には、非常に頻繁に呼ばれるのに中身は単純なものがあります。代表が現在時刻の取得です。 ログのタイムスタンプ、タイムアウト判定、計測など、Webサーバーは1秒間に何度も時刻を読みます。そのたびにカーネルモードへ切り替えるのは無駄です。

そこでLinuxはvDSO(virtual dynamic shared object)という仕組みを用意しています。 カーネルが小さな共有ライブラリを各プロセスのアドレス空間に自動でマップし、時刻データも読み取り専用で公開しておきます。 libcの clock_gettime や gettimeofday はこのvDSOの関数を呼ぶので、トラップを起こさずユーザーモードのまま時刻を読めます。 straceで観察すると、時刻取得がほとんど表示されないのはこのためです。

システムコールのコスト

直接コストと間接コスト

システムコールのコストは2種類に分けて考えます。直接コストは、モードを切り替えてカーネルの処理に入り、戻ってくるまでの時間です。 1990年代半ばのマシンでは1回あたり約4µsかかっていましたが、今のCPUではずっと小さくなっています。

見落とされやすいのが間接コストです。OSDI 2010で発表されたFlexSC(Soares & Stumm)の論文は、この点を測定しています。 syscall命令の往復だけなら約150サイクルで済みます。ところがカーネルのコードとデータが実行されると、CPUのキャッシュやTLB(アドレス変換のキャッシュ)からアプリのデータが追い出されます。 その結果、システムコールから戻った後のユーザーモードの処理が遅くなり、IPC(1サイクルあたりの命令数)が最大65%低下し、元の性能に戻るまで約14,000サイクルかかったと報告されています。 キャッシュやTLBの仕組みはCPUシリーズ第4章で見た通りです。

KPTI — Meltdown対策で増えたコスト

2018年に公開されたCPUの脆弱性Meltdownは、ユーザーモードからカーネル空間のデータを投機実行で読み出せるというものでした。 Linuxはこの対策としてKPTI(Kernel Page Table Isolation)を導入し、ユーザーモード中はカーネル空間のほとんどをページテーブルから外すようにしました。 その代わり、システムコールのたびにページテーブルを切り替える処理が加わり、オーバーヘッドが増えました。 Brendan Greggはこの影響を測定し、システムコールの多いワークロードほど影響が大きいことを示しています。

なぜ「まとめて頼む」と速くなるのか

ここまでの話をまとめると、システムコールは1回あたりのコストが見た目より高い操作です。 そのため、呼び出す回数を減らすと効果が出ます。1行ずつ fs.writeSync するより、ストリームでバッファにためてまとめて書くほうが速いのは、write の回数が減るからです。 第7章で扱うepollは「どのソケットが読めるか」を1回の呼び出しでまとめて返し、io_uringはカーネルと共有したリングバッファにI/O要求を積んで、システムコールの回数そのものを減らします。

モノリシックカーネルとマイクロカーネル

カーネルにどこまでの機能を入れるかについては、設計思想が2つに分かれています。 Linuxは、ファイルシステム、ネットワーク、デバイスドライバまで1つの大きなプログラムとしてカーネルモードで動かすモノリシックカーネルです。 これに対し、MINIXやMachは、カーネルにはプロセス間通信やスケジューリングなど最小限の機能だけを残し、ファイルシステムやドライバはユーザーモードのサーバープロセスとして動かすマイクロカーネルを採っています。

観点モノリシックカーネルマイクロカーネル
代表例Linux、従来のUnixMINIX、Mach、seL4
カーネルに入る機能ファイルシステム・ネットワーク・ドライバまで全部IPC・スケジューリング・メモリ管理など最小限
性能機能間の呼び出しが関数呼び出しで済み速い機能間のやり取りがメッセージ通信になり、オーバーヘッドが出やすい
障害の影響ドライバのバグがカーネル全体を落としうるドライバが落ちてもサーバープロセスを再起動すればよい
拡張の方法ローダブルカーネルモジュール、eBPFユーザーモードのサーバーを追加

Tanenbaum–Torvalds論争(1992年)

1992年1月29日、MINIXの作者であるAndrew Tanenbaumは、ニュースグループに「LINUX is obsolete(Linuxは時代遅れだ)」という投稿をしました。 モノリシックな設計は1970年代の発想であり、これからはマイクロカーネルの時代だ、という主張です。 ヘルシンキ大学の学生だったLinus Torvaldsは、実際に動いて速いことのほうが大事だと反論し、論争は長く続きました。

その後、Linuxはモノリシックのまま広く普及しました。一方でマイクロカーネルの考え方も消えたわけではありません。 macOSのカーネルXNUはMachとBSDを組み合わせたハイブリッド構成で、Windows NTも同じくハイブリッドと呼ばれる設計です。 Linux自身も、カーネルを再ビルドせずに機能を足せるモジュールや、安全な小さなプログラムをカーネル内で動かすeBPFを取り入れ、柔軟性を高めてきました。

まとめ

CPUはユーザーモードとカーネルモードを区別し、アプリはsyscall命令でトラップを起こしてカーネルに処理を頼みます。 OSはトラップテーブルで入口を固定し、タイマー割り込みで制御を取り戻すことで、直接実行の速さと保護を両立しています。 システムコールは直接コストよりキャッシュやTLBを汚す間接コストが大きく、呼び出し回数を減らすバッチ化が効きます。 時刻取得のように頻繁な処理はvDSOでカーネルに入らずに済ませています。

次の第3章では、システムコールを使ってプロセスがどう生まれ、どう終わるのかを見ます。fork・exec・wait、シグナル、そしてゾンビプロセスが登場します。

理解度チェック

問題 0 / 50%
Q1

Limited Direct Execution において、無限ループに入ったプログラムからOSが制御を取り戻す仕組みはどれですか?

キーボード: 1〜4 で選択、Enter で回答