システムコールの仕組みを調べた
概要
"システムコール" はご存知でしょうか?
ターミナルからなにかコマンドを実行したときに呼ばれているんでしょ?という認識が何となくある人が多いかと思います。
システムコールの仕組に関しては正確に分からないため調査した次第です。
当記事はシステムコールがどのように実行されるのかを把握し、システムコールの正体を探索した記録の記事です。
対象読者
- システムコールが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