システムコールの仕組みを調べた
概要
"システムコール" はご存知でしょうか?
ターミナルからなにかコマンドを実行したときに呼ばれているんでしょ?という認識が何となくある人が多いかと思います。
システムコールの仕組に関しては正確に分からないため調査した次第です。
当記事はシステムコールがどのように実行されるのかを把握し、システムコールの正体を探索した記録の記事です。
対象読者
- システムコールがOSの機能だと思っている人
- システムコールを使っているのがユーザーだと思っている人
- コマンドラインで何かしら命令を実行した事がある人
- Linuxカーネルに興味がある人
- ある程度根本まで知らないと気が済まないタイプの人
あるとよい環境
-
gdbでデバッグできる -
C言語がコンパイルできる(gccなどがインストールされている)
注意事項
64bit環境で説明しています。
異なる場合は適宜読み替えてくださいませ。
先に結論
"私たちが" Linux のシステムコールを直接利用しているというよりは、"Linux カーネルが" CPU の SYSCALL 命令を利用してシステムコール機能を提供しています
これはどういうことかと言うと、
- システムコールの実体は、OS ではなくCPU が提供する
SYSCALL命令です。 - Linux カーネルは、その
SYSCALL命令に反応する 「ハンドラ(処理プログラム)」 を登録しています。
つまりシステムコールという機能を提供する 「主体」は Linux カーネル ですが、その 「きっかけ」は CPU 命令 です。
この記事では、この仕組みについて探求していきます。
システムコールとは
教科書的な書籍で毎度説明されていることなのであえてここで紹介するほどでもないですが、プロセスがカーネルへ処理を依頼する際に呼び出されます。
readやwrite、open など普段目にしているコマンドの大概はカーネルへお願いして代わりに処理してもらっています。
カーネルくんにはいつも大変感謝ですね。
SYSCALL命令というもの
システムコールがファイルの書き込み/読み込み/作成/削除、プロセスの複製/削除、などの処理とLinuxの解説でペアで説明されることでまるでOSの機能のように誤解してしまう人がおおいのではないでしょうか?
私はこれに関して間違ってはいないが正しくはないと捉えています。
以降は以下のIntel@64 and IA-32 Architectures SoftwareDevelopper's Manual, Volumn4と併せて読んでいただくと該当箇所を確認しながら読み進めらます。
概要図
先に概要図を以下に示しておきます。
OS CPU
-------------------------|-------------------------
write(2) ─────<CPUへSYSCALL命令>─────> CALL SYCALL
│
│
entry_SYCALL_64 <──<ジャンプ>─────────────┘
│
<writeに必要な引数などをレジスタへセット>
│
sys_write
OSの機能ではなくCPUの機能である
システムコールという機能がOSに備わっているのではなく、CPU側のレジスタとしてSYSCALLという命令が定義されています。
これがシステムコールと呼ばれているものの正体です。
Intel x86 の開発者マニュアルを参考にして確認してほしいのですが、IA32_LSTARにジャンプしますよ(IA32_LSTARのアドレスがロードされますよ)と書いてあります。
SYSCALL invokes an OS system-call handler at privilege level 0. It does so by loading RIP from the IA32_LSTAR
MSR (after saving the address of the instruction following SYSCALL into RCX). (The WRMSR instruction ensures
that the IA32_LSTAR MSR always contain a canonical address.)
Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 2D, 3A, 3B, 3C, 3D, and 4 より
IA32_LSTARはアドレスが固定されたCPU側で定義された定数のようなものです。
カーネル側ではMSR_LSTARという名前で定義し、IA32_LSTAR のアドレスを割り当てています。
カーネル側ではシステムコールの受け取り方法を定義している
LinuxカーネルはCPU周りの初期化ロジック実行時にwrmsrl()関数を通してMSR_LSTARのアドレスとentry_SYCALL_64のアドレスの関連付けを行っています。
entry_SYCALL_64は次は次節で登場するので記憶しておいてください!
MSR(モデル・スペシフィック・レジスタ)の役割
MSRってなんだよと思った方多いかと思います。
MSRとはモデル・スペシフィック・レジスタと呼ばれるものです。
CPU が SYSCALL 命令を受け取ったときにジャンプする先のアドレスを、OS が起動時に設定しておくための特別な場所のことを指しています。
以下の用途に活用できるCPUの制御機能と紹介されています。
- パフォーマンスの監視
- プロセッサのステータスの確認
- ソフトウェアのデバッグやCPUの機能の切り替え(ユーザーモードからカーネルモードの切り替えのことなどを指しているのだろうか🤔)
Reading and Writing Model Specific Registers (MSRs) in Linux*
つまりLinuxのシステムコールはただのイベントハンドラ
システムコールの正体はズバリただのCPUからのイベント通知でした。
CPUの仕様として定義されたSYSCALL命令こそがシステムコールということでしょう。
SYCALL命令が発行されたらMSR_LSTAR(IA32_LSTAR)にジャンプするよ、それ以降はソフト側の自由にしてね~
というわけです。
普段私たちが利用しているカーネルはこれにhookしていますが、自作カーネルなどを作った場合は別にこれにhookしてもしなくてもいいということです。
システムコールが実行されるまで
システムコールの"本当の"トリガーがCPUのSYSCALL命令であることが分かりました。
それをトリガーにしてカーネルが"システムコールハンドラ"を実行するまではざっくり以下の表の通りです。
| 流れ | 関数 | 概要 |
|---|---|---|
| 1 | wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64); |
カーネルブート時にMSR_LSTARのアドレスにentry_SYCALL_64をセットする |
| 2 | CALL SYSCALL |
CPUにSYSCALL命令が発行される。 |
| 3 | entry_SYCALL_64 |
システムコールの唯一のエントリーポイント。ここがカーネル側のシステムコール処理の入口。SYSCALLのジャンプ先である。 |
| 4 | do_syscall_64 |
entry_SYCALL_64 から呼ばれるカーネルの関数。レジスタの値詰めたりしている。 |
| 5 | do_syscall_x64 |
x64_sys_call の薄いwrapper |
| 6 | x64_sys_call |
引数で指定されたシステムコール番号に対応した処理を判定して実行する。 |
それでは表の3番から話を進めていきます。
システムコールの入口へ
カーネル側の入口が以下のコードです。
This is the only entry point used for 64-bit system calls.
とコメントがありますのでここ以外にシステムコールの入口がないと言って問題ないでしょう。
またいくつかレジスタに格納すべき値の仕様が記載されていることが分かります。
今回の話で重要な値がRAXです。
| 引数 | 説明 |
|---|---|
| RAX | システムコールの番号 |
| RCX | 戻り値のアドレス |
| R11 | 保存済みの rflag |
| RDI | 1つ目の引数 |
| RSI | 2つ目の引数 |
| RDX | 3つ目の引数 |
| R10 | 4つ目の引数 |
| R8 | 5つ目の引数 |
| R9 | 6つ目の引数 |
下に向かって読み進めていくと CALL do_syscall_64 命令が記述されています。
こちらがカーネル(C言語)で実装されたdo_syscall_64()関数の呼び出しとなり、処理が開始します。
カーネルがどの処理を実行するかジャッジする
do_syscall_64() というカーネル側の関数が実行されます。
色々書いてありそうで何だか読みたくない気もしますが、pt_regs という構造体に先ほど紹介したレジスタの値を保存しているだけです。
次にdo_syscall_x64()という関数が呼び出されます。
x64_sys_call()関数のWrapperのような役割をしているようです。
処理が成功すればtrue、失敗すればfalseを返すようですね。
いかにもシステムコールを実行していそうなx64_sys_call()関数は、なにか実行の判定を行うような処理がありそうですね。
switch (nr)の内部で<asm/syscalls_64.h>includeしているようです。
カーネルが対応するシステムコール関数を実行する
こちらの.tblファイルをご覧ください。
多くのシステムコールが番号と紐づけられています。
この羅列はx64_sys_call()のswitch (nr)文で展開されるマクロであると捉えていただいて大丈夫かと思います。
//雑に書けば以下のような展開がされる
switch (nr) {
case 0: sys_read(...);
case 1: sys_write(...);
case 2: sys_open(...);
default: ...;
}
JavaScriptで言うところの...
イベントハンドラといえばJavaScriptですね。
長々と色々書きましたが簡単に表現すればカーネルは以下の内容を処理しています。
//カーネルはSYSCALLが呼ばれたら以下の内容を実行するようにあらかじめ設定している
cpu.addEventLister("syscall", (event) => {
//実行したいシステムコールハンドラがRAX レジスタにセットされている
const rax = event.target.value.rax;
//番号を検証し、対応するシステムコールハンドラを実行する
switch (rax) {
case 1:
//システムコールハンドラに応じた引数をセットする
const { rdi, rsi } = event.target.value;
handleHoge(rdi, rsi);
case 2:
//システムコールハンドラに応じた引数をセットする
const { rdi, rsi, rdx } = event.target.value;
handleFuga(rdi, rsi, rdx);
case 3:
//システムコールハンドラに応じた引数をセットする
const { rdi, rsi, rdx, r10 } = event.target.value;
handlePiyo(rdi, rsi, rdx, r10);
//case ...:
//...:
default:
throw Error("未定義のシステムコールだよ");
}
});
[実践] SYSCALL命令の発行を観察しよう
実際にSYSCALL命令が発行される様子を観察してここまでの流れを体験してみましょう。
まずはシステムコールを直接呼び出す関数を使って標準出力に任意の文字列を出力する関数を定義します。
基本のファイルを用意しよう
$ touch scall.c
#include <sys/syscall.h>;
#include <unistd.h>;
int main(void)
{
//writeシステムコールを呼び出す
long ret = syscall(SYS_Write, 1, "hello\n", 6);
return 0;
}
ファイルの編集が完了したら正常に動作するか確認します。
$ gcc -o scall scall.c
$ ./scall
#入力した文字列が出力される
# 例) hello
デバッグ可能な状態にしよう
内容が期待通りであれば、次はデバッグできるように-gフラグをつけてビルドします。
$ gcc -g -o scall scall.c
デバッグしてみよう
gdbを使ってデバッグ可能となったscall実行ファイルを起動してデバッグを開始します。
$ gdb scall
gdbの画面に入ったらまずはアセンブリを確認できるようにしましょう。
layout asm を入力して画面上部にアセンブリが表示されている状態にします。
(gdb) layout asm

アセンブリが確認できる
表示されることが確認できたら breakコマンドでbreakpointを設定しましょう。
syscall@plt,syscallという呼び出しに対してbreakpointを貼ります。
(gdb) break syscall@plt
(gdb) break syscall
runコマンドでデバッグを開始しましょう。
(gdb) run

debugが開始!
syscall関数の内部に入ろう
開始後にcontinueコマンドを実行して<syscall>まで飛びましょう。
(gdb) continue

continue実行後の画面
スクショと同じようにな場所にたどり着きましたでしょうか?
ここまで来たらいよいよSYCALLとご対面です。
処理途中に小文字でsyscallと書いてある場所は見えたでしょうか?
それがCPUに対するSYSCALL命令を発行している箇所です。
レジスタの値を確認しよう
まずはsyscall命令までstepを実行しましょう。
因みに察しの良い方は気づいたかもしれませんが、entry_SYCALL_64のコメントで要求されている引数をMOV命令を通してセットしています。
syscall命令までstepを進めたら、つづけてinfo registersコマンドを実行してレジスタにセットされた値を確認してみましょう。
write システムコールの番号である 1 が RAX に、第一引数 (ファイルディスクリプタ 1(標準出力のことです)) が RDI に、第二引数 (バッファのアドレス) が RSI に、第三引数 (バイト数 6) が RDX にセットされているはずです。確認してみましょう
(gdb) info registers

レジスタにセットされた値を確認する
確認できたらqを送ってレジスタの確認モードを終了してデバッグを再開します。
(gdb) q
syscall を実行しよう
すでにsyscallまでstepを進めているのでもう一度stepを実行すればgdbのコンソールに引数で渡した文字列が出力されることを確認できるかと思います。
実際にwriteシステムコールハンドラが実行されたということでしょう。
(gdb) step
...
#例)hello

syscall通過後
まとめ
普段私たちがファイルを開くためにopen関数を呼び出したり、書き込みをするためにwrite関数を呼び出したときシステムコールをしていると思いがちです。
実際のところはglibcのようなラッパー関数を通してSYSCALL命令でカーネルに渡しているだけです。
先に結論で話した通り、システムコールという機能を提供する 「主体」は Linux カーネル ですが、その 「きっかけ」は CPU 命令 ということが分かりました。
色々と見慣れないコードなどが出てきましたが、私たちが普段何気なく実行している些細な命令も裏ではこんな風になっているんだなぁという気付きが得られたなら幸いです🤗
参考文献
- カーネルのミラーリポジトリ
- MSRの概要
- manualへのリンク集(Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes: 1, 2A, 2B, 2C, 2D, 3A, 3B, 3C, 3D, and 4)
- [試して理解]Linuxのしくみ ―実験と図解で学ぶOS、仮想マシン、コンテナの基礎知識【増補改訂版】
Discussion