PR

【IT用語解説】システムコールとは?アプリがOSへ処理を依頼する仕組みを解説

システムコールとは?をコンピュータ基礎シリーズの統一デザインで表すアイキャッチ IT-Skills

システムコールとは、ユーザーモードのプログラムが、番号と引数をCPUの決められた場所へ置き、専用命令でカーネルの入口へ移って処理してもらい、結果を受け取るためのABI(機械レベルの約束)です。memo.txtへ「A」を保存するときは、ライブラリのラッパーが引数を整え、CPUが権限境界を越え、カーネルがポインタ・ファイル記述子・権限を検査して書き込み、処理結果を同じスレッドへ返します。ここで起きるモード切り替えは、別プロセスへ実行を渡すコンテキストスイッチとは別物であり、write()の成功も電源断後の永続化完了とは限りません。

「アプリがOSへお願いする窓口」は入口として正しい説明です。この記事では、その“お願い”が実際にはどのレジスタへ何を入れ、CPUのどの境界を越え、カーネルが何を確かめ、どんな値を返す往復なのかまで分解します。

このページで学べる内容
  • 普通の関数呼び出しとシステムコールの決定的な違い
  • API・ライブラリ関数・システムコール・ABIの4層
  • memo.txt保存時のラッパー、レジスタ、CPU命令、カーネル検証、戻り値
  • open()という関数名と実際のopenatシステムコールが一致しない理由
  • 部分書き込み、EINTRerrnoを正しく扱う考え方
  • fwritefflushwritefsyncが保証する地点の違い
スポンサーリンク

システムコールは「特権関数」ではなく、制御された往復である

Linux man-pagesのsyscalls(2)は、システムコールをアプリケーションとLinuxカーネルの基本インターフェースと説明しています。ただし、「カーネルの関数を普通に呼ぶ」と理解すると、本質を外します。ユーザープログラムはカーネル内の任意のアドレスへジャンプできず、OSがあらかじめ用意した入口、番号、引数形式、戻り値形式に従う必要があります。

普通の関数呼び出しと比べると、違いが見えます。

観点 普通の関数呼び出し システムコール
主な移動先 同じプロセスのユーザー空間にある関数 OSが定めたカーネルの入口
CPUの権限 通常はユーザーモードのまま 専用命令でカーネルモードへ制御移行する
呼び先の指定 関数アドレスや呼出規約 システムコール番号とシステムコールABI
引数 言語・コンパイラのABIに従いレジスタやスタックへ置く OS・CPUアーキテクチャ固有のレジスタ等へ置く
安全性 呼び先の関数が受け取った値を扱う カーネルがユーザーポインタ、長さ、識別子、権限を信頼せず検査する
戻り値 関数の規約どおりに返る カーネルの生の結果をラッパーがアプリ向け形式へ直す場合がある

この境界が必要なのは、一般アプリを不自由にするためではありません。1つのアプリのバグや悪意が、OS全体、他プロセス、任意の機器を直接壊さないようにするためです。ユーザーモードとカーネルモードを含むOSの全体像は、先にOSとはを読むとつながります。

API・ライブラリ・システムコール・ABIは同じものではない

「コードにwrite()と書いたから、そこがシステムコールそのもの」とは限りません。動作を正確に追うには、次の4層を分けます。

何を決める約束か memo.txtの例
高水準API アプリや言語から見える機能・型・エラーの約束 エディタの「保存」、言語ランタイムのファイル書き込み
ライブラリ関数/ラッパー APIを実装し、バッファリングや引数変換を行うユーザー空間のコード fwrite()、glibcのwrite()open()ラッパー
システムコールABI 番号、引数レジスタ、専用命令、戻り値レジスタの機械レベルの約束 番号とfdbufcountをレジスタへ置きsyscallを実行
カーネル実装 検査、ディスパッチ、VFS、メモリ管理など内部処理 ファイル記述子を検索し、バイト列をページキャッシュ等へ反映

open()がopenatを呼ぶ――名前は1対1ではない

LinuxのCプログラムでopen("memo.txt", ...)と書いても、現在のglibcでは、同名のカーネルシステムコールopenが実行されるとは限りません。open(2)によると、glibc 2.26以降のopen()ラッパーは、すべてのアーキテクチャで実際にはopenat()システムコールを使います。概念的には、基準ディレクトリをAT_FDCWDとしてopenat(AT_FDCWD, "memo.txt", ...)へ置き換えられます。

ラッパーは薄いこともあれば、OS・CPU・ライブラリの版に応じて別のシステムコールを選ぶこともあります。syscalls(2)も、ラッパー名とシステムコール名は「しばしば同じだが、常にではない」と説明しています。したがって、API名だけを見て、監視すべきシステムコールや番号を決めることはできません。

fwrite()は、その場でカーネルへ入らないこともある

Cの標準入出力のような高水準ライブラリは、ユーザー空間にバッファを持ちます。fwrite()で「A」を渡しても、まずライブラリのバッファへコピーされ、バッファが満杯になる、fflush()を呼ぶ、ストリームを閉じる、といった時点までwriteシステムコールが発生しない場合があります。つまりAPIを1回呼ぶ=システムコールを1回実行するではありません。

呼び出しをまとめる理由は、権限境界の往復と小さなデータ処理を何度も繰り返さずに済ませるためです。ユーザー空間・OS・装置にある一時領域の違いはバッファとはで詳しく確認できます。

memo.txt保存の一往復を図でつかむ

memo.txtへAを書き込む要求が高水準APIとlibcラッパーを通り、システムコール番号・ファイル記述子・ユーザーポインタ・長さをレジスタへ配置してCPUのsyscall命令で権限境界を越える。カーネルが入口で番号をディスパッチし、ファイル記述子・書き込み権限・ユーザーポインタ・長さを検証してVFSとページキャッシュへ処理し、書けたバイト数または負のエラー番号を返す。ユーザー側ラッパーがエラーを-1とerrnoへ変換し、同じスレッドのアプリへ戻す往復を示す図
ラッパーが番号と引数をABIどおりに並べ、CPUの専用命令でカーネルへ入り、検査と処理の結果を同じスレッドへ返す。モード切り替え自体はプロセス切り替えではなく、write完了は永続化完了でもない。

図の中心は、「アプリ → カーネル」という一方向の矢印ではなく、要求を符号化して渡し、検査された結果を復号して戻す往復です。以下では、Linux上の通常のバッファ付きファイル書き込みを例に、各段階の入力と出力を追います。実際のエディタは、直接上書きせず、一時ファイルへ書いてrenameするなど、より安全な保存方式を使うことがあります。

第1段階:ライブラリがシステムコール番号と引数を整える

説明用に、低水準APIで次の操作をするとします。これは保存の因果を示すPOSIX風の疑似コードであり、エラー処理を省いた完成プログラムではありません。

fd = open("memo.txt", O_WRONLY | O_CREAT, 0666)
write(fd, "A", 1)
fsync(fd)       // 電源断後にも残す保証が必要な場合
close(fd)

write(fd, buf, count)ラッパーが受け取る主な入力は、開いている対象を示すファイル記述子、ユーザー空間にあるバイト列の先頭アドレス、書きたいバイト数です。ラッパーは、対象CPUとOSのシステムコールABIに従い、システムコール番号と引数を所定のレジスタへ移します。

Linux syscall(2)が示す代表例は次のとおりです。これはLinuxの各ABIの例であり、他OSへそのまま持ち込めません。

Linux ABI 番号 第1〜第6引数 専用命令 主な戻り値
x86-64 eax rdi, rsi, rdx, r10, r8, r9 syscall rax
arm64 w8 x0, x1, x2, x3, x4, x5 svc #0 x0

関数呼出ABIとシステムコールABIも別です。 x86-64では通常のC関数の第4引数に使うレジスタと、システムコールの第4引数に使うレジスタが同じとは限りません。ラッパーがこの差を吸収するため、通常のアプリはレジスタ配置を直接書かずに済みます。

第2段階:専用命令で、CPUが決められたカーネル入口へ移る

番号と引数を準備した後、x86-64ならsyscall、Arm AArch64ならSVCのような命令を実行します。Intel 64 / IA-32 Software Developer’s ManualSYSCALL命令と保護機構を、ArmのSystem calls資料SVCが例外を発生させ、より高いException levelへ制御された入口を作ることを説明しています。

CPUとカーネルの入口コードは、ユーザーモードへ戻るために必要な命令位置や状態を保ち、OSが設定したカーネル入口へ制御を移します。ユーザープログラムが「カーネルモードになる場所」を自由に選ぶのではありません。CPUが認めた入口へしか入れず、その後もシステムコール番号で許された処理へ振り分けられます。

モード切り替えは、コンテキストスイッチではない

ここは最も混同されやすい点です。システムコール入口では、通常、呼び出したスレッド自身が、そのスレッドの依頼を処理するためにカーネルコードを実行します。ユーザー→カーネルというCPU権限の切り替えは起きますが、ただちに別プロセスや別スレッドへCPUが移るとは限りません。Linuxカーネル文書も、システムコールをprocess contextで実行される処理として扱っています。

ただし、システムコールがディスクやネットワークの完了を待ってブロックした場合、または途中でスケジューラが実行権を渡した場合には、別スレッドへのコンテキストスイッチが起こり得ます。関係は次のとおりです。

システムコール入口
  → モード切り替えは必要
  → コンテキストスイッチは必須ではない
  → 待ち・プリエンプト・スケジューリングがあれば起こり得る

MicrosoftのVTrace解説にも、ユーザーモードからカーネルの処理を呼ぶ際、カーネルトラップは必要でもコンテキストスイッチは不要な例が示されています。

第3段階:カーネルは番号を振り分け、引数を信頼せず検査する

カーネル入口は、レジスタからシステムコール番号を読み、対応する実装へ振り分けます。番号が存在しなければLinuxではENOSYSに相当するエラーです。しかし、正しい番号だっただけで処理が許可されるわけではありません。

write(fd, buf, count)なら、少なくとも次の異なる種類の値を扱います。

入力 何を確かめるか 失敗例
fd 呼出元プロセスのファイル記述子表に存在し、書き込み可能なopen file descriptionを指すか EBADF
buf 呼出元のユーザー空間で、指定範囲を安全に読めるか EFAULT
count 範囲・上限・対象の整列条件などを満たすか EINVALなど
ファイル状態 書き込みアクセス、seal、容量、quota、ファイルシステム状態を満たすか EPERMENOSPCEDQUOTなど

特にbufは、ユーザープロセスの仮想アドレスです。カーネルは、渡された数値をそのまま信頼して通常のポインタのように触れません。LinuxカーネルのUser Space Memory Access文書が示すaccess_ok()get_user()等の仕組みを使い、ユーザー領域へのアクセス失敗を-EFAULTとして扱える形で読み書きします。

「カーネルモードなら、渡されたポインタを安全に読める」わけではありません。 ユーザーページは未配置、アクセス不可、途中でfaultする可能性があります。特権が強いからこそ、境界での検査と安全なコピー手順が必要です。

第4段階:VFSがファイル記述子を、対象ファイルの書き込み処理へつなぐ

fdはファイル名そのものではなく、そのプロセスのファイル記述子表からカーネル内のopen file descriptionをたどるための小さな整数です。Linux VFS文書は、VFS(Virtual File System)がユーザー空間へ共通のファイルシステムインターフェースを提供し、ファイル記述子から対応するstruct fileとファイル操作へつなぐことを説明しています。

通常のバッファ付きファイル書き込みでは、VFSと対象ファイルシステムの処理が、ユーザーバッファからカーネル側のページキャッシュへデータを取り込み、該当ページを「変更あり(dirty)」として扱います。この時点で、ストレージ装置へ物理書き込みが完了している必要はありません。ファイル名・ディレクトリ・メタデータ・データブロックの対応はファイルシステムとは、装置固有の要求への変換はデバイスドライバとはに委ね、この記事では境界の往復に焦点を戻します。

第5段階:生の結果が戻り、ラッパーがerrnoへ翻訳する

カーネル処理が終わると、結果はABIで決められた戻り値レジスタへ置かれます。Linuxの多くのアーキテクチャでは、成功なら書けたバイト数などの非負値、失敗なら負のエラー番号がカーネルから返ります。

glibcのようなラッパーは、その生の結果をCプログラム向けに翻訳します。syscalls(2)が説明する一般形は次のとおりです。

カーネルの生の結果      libcラッパーがアプリへ見せる結果
書けたバイト数 n   →    n
-EACCES など       →    -1 を返し、errno = EACCES

したがって、errnoは「CPUが直接返す別の戻り値」ではありません。多くの場合、ユーザー空間のラッパーが、カーネルの負のエラー番号を正のエラー番号へ直して、スレッドごとのerrnoへ保存します。またerrnoを見るのは、関数が失敗を示したときだけです。成功後に残っている古いerrnoを読んでも、今回の結果は分かりません。

最後にCPUは保存していたユーザー側の状態へ戻り、ラッパーからアプリの次の命令へ進みます。入口から出口まで、別スレッドへ切り替わる事情がなければ、同じスレッドの実行文脈がユーザー→カーネル→ユーザーと往復したことになります。

write()は「全部書けた」か「全部失敗」の二択ではない

Linux write(2)POSIXのwrite仕様では、write()は要求したcountバイトまでを書こうとします。成功の戻り値は、実際に書けたバイト数です。返った数がcountより小さい部分書き込みも成功結果であり、残りは呼出側が位置を進めて再度書く必要があります。

シグナルで中断された場合も、いつ中断されたかで結果が変わります。

状況 代表的なLinuxの結果 呼出側の考え方
1バイトも書く前に中断 -1errno = EINTR 同じ残量を再試行できる
一部を書いた後に中断 書けた正のバイト数 ポインタと残量を進め、残りだけを再試行する
非ブロッキングで今は書けない EAGAINまたはEWOULDBLOCK 忙しいループにせず、書込み可能になる通知を待つ
容量不足・権限・I/O障害 ENOSPCEPERMEIO 無条件再試行せず、原因に応じて中止・通知・回復する

低水準APIで全バイトを書き切る考え方は次のようになります。実用コードでは、シグナル、非ブロッキングI/O、ファイル種別、キャンセル、タイムアウト、ログ方針まで設計します。

p = buffer
remaining = length

while (remaining > 0) {
    n = write(fd, p, remaining)
    if (n > 0) {
        p += n
        remaining -= n
        continue
    }
    if (n == -1 && errno == EINTR) {
        continue                 // まだ1バイトも進んでいない
    }
    if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
        wait_until_writable()
        continue
    }
    report_error_and_stop()
}

再試行は「同じ呼び出しを何でも繰り返す」ことではありません。 正の戻り値なら、その分はすでに進んでいます。残りだけを送ります。永続的な権限エラーや容量不足を無限に再試行しても解決しません。

fwrite・fflush・write・fsyncの成功地点を分ける

「保存に成功した」という言葉は、どの層まで成功したかを省略しています。memo.txtへ「A」を残すなら、少なくとも次の境界を分けます。

成功した操作 主に保証する地点 まだ保証しないこと
fwrite() ライブラリが指定バイトを受け付けた。ユーザー空間バッファだけの場合もある システムコール実行、カーネル受理、永続化
fflush() ユーザー空間の出力バッファを下位のwrite関数へ渡した ストレージへの永続化
write() 戻り値のバイト数をカーネルが受け付けた 要求全量の処理、装置への到達、電源断耐久
fsync() そのファイルの変更データと必要なメタデータを永続装置へ同期するよう要求し、装置完了まで待つ 新規ファイル名を含む親ディレクトリエントリの永続化、複数ファイル更新の原子性
親ディレクトリのfsync() 新規作成やrenameで変わったディレクトリエントリの同期 アプリ全体のトランザクション保証

fflush(3)は、ユーザー空間のバッファを下位のwrite関数へ渡す操作です。一方、write(2)は、成功してもデータがディスクへ確定した保証はないと明記しています。通常のバッファ付き書き込みでは、データはまずページキャッシュでdirtyになり、後でwritebackされ得ます。

fsync(2)は、変更されたファイルデータと関連メタデータを永続装置へ送って、装置が完了を報告するまで待ちます。ただし、ファイル自身のfsyncだけでは、そのファイル名を持つディレクトリエントリが装置へ到達したとは限りません。新規作成やrenameを障害後も確実に残す設計では、親ディレクトリも開いてfsyncする必要があります。

write()成功=永続化完了、close()fsync()ではありません。 さらにfsync()自体もEIOENOSPCで失敗し得るため、戻り値を確認します。同期は耐久性の境界を進めますが、複数手順を自動的に原子的な1操作へ変えるものでもありません。

システムコールのコストは、権限境界だけでは決まらない

システムコールには、番号と引数の準備、CPUの入口・出口、状態の保存復元、番号のディスパッチ、引数検証、安全なユーザーデータコピー、ロックやキャッシュ処理などの仕事があります。普通の小さなユーザー空間関数より仕事が増えやすいのは確かですが、「1回何ナノ秒」「必ず遅い」と固定値で覚えるのは不正確です。CPU、OS、セキュリティ緩和策、キャッシュ状態、引数サイズ、処理内容、競合で変わります。

さらに、システムコール入口のコストと、その後に依頼する仕事のコストを分けます。1バイトをページキャッシュへコピーするwriteと、ネットワーク応答を待つreadでは、支配的な時間が違います。後者がブロックしてスケジューラが別スレッドを動かせば、そこで初めてコンテキストスイッチや待ち時間も加わります。

小さなwriteを大量に呼ぶ代わりに、ユーザー空間バッファへまとめて大きなwriteにすると、入口・出口の回数を減らせます。ただし、まとめたデータがバッファへ滞留する時間、失敗時にどこまで処理済みか、必要なレイテンシ、メモリ使用量とのトレードオフがあります。「呼び出し回数が少ないほど常に正しい」のではなく、必要な単位と保証に合わせてまとめます。

LinuxとWindows、x86-64とArmで名前・番号・入口は変わる

システムコールの役割は、多くのOSで「低い権限のプログラムが、制御された境界からOS中核のサービスを使うこと」です。しかし、公開API、システムコール名、番号、レジスタ、命令、エラー表現は同じではありません。

  • Linux x86-64とLinux arm64だけでも、番号・引数レジスタ・専用命令が異なる。
  • 同じLinuxでも、ABIやアーキテクチャによって存在するシステムコールと番号が異なる。
  • glibcの版やアーキテクチャにより、同じラッパーが別のシステムコールを選ぶことがある。
  • Windowsの一般アプリは通常Win32等の公開APIを使い、その下でNative System Servicesへつながる。Linuxのwriteと同じ名前・番号を前提にしない。

MicrosoftのUser mode and kernel modeは、アプリがユーザーモード、OS中核がカーネルモードで動く保護境界を説明しています。ファイル書き込みに対応する低水準サービスの一例としてNtWriteFileがありますが、通常のアプリがOS内部のシステムコール番号を固定して直接呼ぶ設計は、公開Win32 APIを使う設計より移植性・互換性が低くなります。

システムコール番号は関数の普遍的なIDではありません。 ソースコードで使うAPI名、ライブラリが選ぶシステムコール、特定ABIの番号を分けて記録します。seccomp、監査、トレース、低水準アセンブリでは特に重要です。

実際に観測すると、APIとシステムコールの差が見える

Linuxではstraceを使うと、プロセスが実際に発行したシステムコールと戻り値を観測できます。たとえば、説明用の小さなプログラムなら次のような形が見えます。番号やファイル記述子、フラグ、実際の保存方式は環境とプログラムで変わります。

openat(AT_FDCWD, "memo.txt", O_WRONLY|O_CREAT, 0666) = 3
write(3, "A", 1)                                      = 1
fsync(3)                                               = 0
close(3)                                               = 0

コードにはopen()と書いたのにトレースにはopenat()が出る、複数のfwrite()が1回のwrite()へまとまる、という観測があり得ます。逆に、実用エディタでは一時ファイルの作成、複数回のwritefsyncrename、ディレクトリ同期などが現れる場合があります。トレースは「自分の想像したAPI対応」ではなく、実際の境界通過を確かめる道具です。

6つの誤解を、正しい境界へ置き換える

ありがちな理解 正確な理解
システムコールはカーネル関数を普通に呼ぶこと 番号・引数・専用命令・戻り値を定めたABIで、OSが用意した入口を往復すること
ユーザー→カーネルのモード切り替えはコンテキストスイッチ 同じスレッドのまま権限モードだけを切り替えられる。待ちやスケジューリングがあると別途コンテキストスイッチが起こり得る
システムコールのたびに別のOSプロセスが処理する 通常は呼出スレッド自身がprocess contextでカーネルコードを実行する。別workerへ委譲する処理もあるが必須ではない
API関数1回は同名のシステムコール1回 ユーザーバッファだけで終わる、複数回をまとめる、1回のAPIが複数システムコールを使う、open()openatを使う場合がある
write()が成功したらSSDへ永続化済み カーネルが戻り値分を受理したことを示す。ページキャッシュや装置キャッシュを経ており、耐久性にはfsync等の別保証が必要
システムコール名・番号・レジスタはOSやCPUが違っても同じ OS、CPUアーキテクチャ、ABI、ライブラリ版で異なる。移植可能なアプリは公開APIとラッパーへ差を吸収させる

まとめ:関数呼び出しがカーネル処理へ変わる因果

  • 高水準APIは、まずユーザー空間のライブラリやバッファで処理され得る
  • ラッパーが実際に使うシステムコールを選び、番号と引数をABI所定のレジスタへ置く
  • CPUのsyscallSVCが、OS設定済みの入口へ制御を移して権限モードを切り替える
  • カーネルは番号を振り分け、ファイル記述子、ユーザーポインタ、長さ、アクセス権を信頼せず検査する
  • VFSとファイルシステムが書き込み処理へつなぎ、通常はページキャッシュ等へデータを反映する
  • カーネルのバイト数または負のエラー番号を、ラッパーがアプリ向けの戻り値とerrnoへ変換する
  • モード切り替えはコンテキストスイッチではなく、write成功は永続化完了でもない

memo.txtへ「A」を保存するとき、アプリの関数呼び出しは、そのままカーネル関数へ飛ぶのではありません。ライブラリが要求をシステムコール番号と引数へ変換し、CPUの専用命令が制御された権限境界を越え、カーネルが入力を検証してVFSの書き込み処理へつなぎ、結果を同じスレッドへ返します。その戻り値を確認し、部分書き込みを処理し、必要ならfsyncで耐久性の境界を進めるところまで含めて、初めて「OSへ正しく仕事を依頼した」と説明できます。

関連する基礎用語

  • OSとは:ユーザーモード、カーネル、保護・共有・抽象化の全体像を確認する
  • ファイルシステムとは:パス、ファイル記述子の先にあるファイル、メタデータ、データブロックを追う
  • バッファとはfwrite、ページキャッシュ、装置キャッシュ、flushを層ごとに分ける
  • デバイスドライバとは:カーネルの共通I/O要求が、機器固有のコマンドへ変換される先を追う
タイトルとURLをコピーしました