Linuxのシステムコールについて見ていきます。
それでは、順番に見ていきます。
システムコールの呼び出しの説明について
この説明は「Ext2ファイルシステムその2 ファイルシステムへのアクセス」と同じ説明となっています。
またここで64ビットという用語がでてきたら主にx86-64となります。
(IA-64とx86-64の混同にご注意ください)。
システムコール
システムコールは、カーネル世界に入ることができる唯一のゲートです。
システムコールを呼び出すことではじめてOSの機能やリソースを利用することができます。いつも使用している、lsコマンドやviコマンドも例外なくシステムコールを呼び出すことで、ファイルシステムにアクセスしています。
また、これらのコマンドに限らずファイルシステムなどOSの機能が必要となるプログラムは最終的にシステムコールを呼び出すことになります。これはシステムコールのことを知らないプログラマーでさえも呼び出していることがあります。
(そもそもプログラマーだからといって知っている必要は無いと思います。また扱っている言語のライブラリーがシステムコールをラッパーしていますので、存在していることを知らなくてもそもそもプログラミング可能です。)
システムコールはOSの機能呼び出しとなりますので、カーネルの機能であるファイルシステムを利用することができますが、システムコール以外の方法でのカーネルへアクセスは禁止されています(例外が発生してプロセスが停止します)。
システムコール呼び出し時のみカーネルの機能・リソースを使用することができます。
システムコールの呼び出し(C言語)
システムコールとして身近なものにwriteシステムコールがあります。
ここではwriteシステムコール呼び出しについて体験していきましょう。
writeシステムコールは次の例のように呼び出します。
#include <unistd.h>
int main( int argc, char *argv[ ] )
{
int err;
const char *text = “abc\n”;
write( STDOUT_FILENO, ( const void* )text, sizeof( text ) – 1 );
return( 0 );
}
上記プログラムを”my_write.c”に保存して次のようにコンパイルします。
$ gcc -o my_write my_write.c
そして、実行します。
$ ./my_write
abc
と表示されます。表示されない場合補足説明を参照してください。
ここでwriteシステムコールの第1引数は書き込むファイルのファイルディスクプリター、第2引数は書き込むデータのアドレス、第3引数は書き込むデータのサイズです。この例ではファイルディスクプリターにはプロセス起動時にオープンされる標準出力のファイルディスクプリター(STDOUT_FILENO)です。
(STDOUT_FILENOはunistd.hに定義されていて通常1です。)
この例ではwriteシステムコールを呼び出して直ぐに終了しています。writeシステムコールでは、カーネル内のバッファーに書き込みしたらすぐに処理が終了します。カーネル内のバッファーから標準出力に書き出されるまで時間がかかり、先にプロセスが終了してしまうかもしれません。
GCCなどのライブラリーではwriteシステムコールはラップされています。
実際には、ライブラリーのwrite関数の最終段で実際のwriteシステムコールが呼び出されます。
ライブラリーでのシステムコール呼び出しはラッパー関数で、実際にシステムコールを呼び出す前に引数のチェック(アライメントなどのチェック)を行うものもあります。効率良く実装されていますが、ここではもう少しだけ直接的に呼び出してみましょう。
syscall関数を使用します。
syscall関数では第一引数にsys/syscall.hに定義されているシステムコール番号を指定します。システムコール番号はプレフィクスSys_の後に呼び出したいシステムコール名を付けたものとなります。例えば、writeシステムコールの場合Sys_writeとなります。そのあとの引数は呼び出すシステムコールの引数と同じ引数ぶんだけ指定します。
syscall関数によるwriteシステムコール呼び出しの例は次のようになります。
#include <unistd.h>
#include <sys/syscall.h>
int main( int argc, char *argv[ ] )
{
long ret;
const char *text = “abc\n”;
ret = syscall( SYS_write, STDOUT_FILENO, text, sizeof( text ) – 1 );
return( 0 );
}
思ったより簡単に実装できたのではないでしょうか。このsyscall関数も実際のシステムコール呼び出しのラッパーとなります。しかし、ライブラリーよりもすごく簡素に実装されています。次にもっと直接的にアセンブラーでの呼び出しについて見ていきます。
システムコールの呼び出し(アセンブラー)
システムコールはソフトウェア割り込み(例外)を発生させる必要があるため、CPUの専用命令が必要となります。ですので、実際の呼び出し部分ははアセンブラーで記述することになります。パソコンでは32ビットCPUはSYSENTER命令を、64ビットCPUではSYSENTER命令/SYSCALL命令を使います。
歴史的にはシステムコールの呼び出しにはINT命令を使用していました。
(Linuxでは割り込み番号128を指定してINT 0x80を実行します。)
しかし、INT命令の呼び出しではIntel CPUの仕様上オーバーヘッドが大きくなるため、呼び出し処理に時間がかかっていました。システムコールはほぼすべてのプロセスで頻繁に呼び出されるので、改善を行いIntel P6ファミリープロセッサーでSYSENTER命令が設けられました。
LinuxではINT命令によるシステムコール呼び出しにも対応していますがSYSENTER命令を使用するようにします。SYSENTER命令に対応しているプロセッサーかどうかはCPUID命令で判断することができます。
Intel P6ファイミリープロセッサー以降でSYSENTER命令が導入されました。
SYSCALL命令はAMDの64ビットプロセッサーで仕様が決められました。先にAMD主導で開発されてたため、パソコンの64ビットCPUは事実上AMD仕様がデファクトスタンダードとなっています。64ビットOSではSYSENTER命令もSYSCALL命令にも対応しています。アプリケーションプログラムがどちらを使用するのかはコンパイラー次第となりますが、基本的に新しく用意されたSYSCACLL命令を使用します。
システムコールの呼び出した後はCPUは特権モードで動作します。
このときに、システムコールの引数をどう渡すのかが問題となってきます。例えば、システムコールの引数をスタックで渡す場合を考えてみます。スタックで引数を渡す場合、ユーザーアプリケーションで引数をスタックに積みあげて、システムコールを発行します。システムコールが発行されるとカーネルはスタックから引数を取り出すという手順が発生します。
このようにスタックで引数を渡す場合は、メモリーアクセスが必要となってきます。システム上で動作するアプリケーションはたくさんあり、そして各アプリケーションは頻繁にシステムコールを発行しています。このような状況でスタックで引数で渡すとそれだけでかなりのオーバーヘッドとなってしまいます。そこで、現在の汎用OSではスタックではなく、汎用レジスターでシステムコールの引数をカーネルに渡します。
レジスターはメモリーほど潤沢にあるわけではなく、数は限られていますし、CPUによってその数が異なります。ですので、パソコンでは32ビットのLinuxでは5個までのレジスター、64ビットのLinuxでは6個を使ってシステムコールの引数を渡します。使用するレジスターはカーネルの仕様(作り)で決まっています。また、コンパイラーはカーネルの仕様に合わせてシステムコールの呼び出しをライブラリーにあらかじめ定義しています。
引数に使用するレジスターは次のように決まっています。
(32ビットのLinuxと64ビットのLinuxで使用するレジスターは異なります。)
32ビットのLinuxに対しては次のレジスターを使用します。
32ビットLinuxのシステムコール呼び出しで使用するレジスター | |
---|---|
レジスター | 説明 |
EAX | システムコールの番号 |
EBX | システムコールの第1引数 |
ECX | システムコールの第2引数 |
EDX | システムコールの第3引数 |
ESI | システムコールの第4引数 |
EDI |
システムコールの第5引数 |
64ビットのLinuxに対しては次のレジスターを使用します。
64ビットLinuxのシステムコール呼び出しで使用するレジスター | |
---|---|
レジスター | 説明 |
EAX | システムコールの番号 |
RDI | システムコールの第1引数 |
RSI | システムコールの第2引数 |
RDX | システムコールの第3引数 |
R10 | システムコールの第4引数 |
R8 | システムコールの第5引数 |
R9 | システムコールの第6引数 |
それでは、実際にシステムコールを呼び出す手順について見ていきましょう。
- システムコールの呼び出しには対応するシステムコールの番号を汎用レジスターのEAXレジスターに格納してから呼び出します。システムコール番号はシステムコールが登録されているテーブルのインデックスとなります。
- そして、SYSENTER/SYSCALL/INT 0x80命令を実行すると、CPUは例外を検知してあらかじめ登録されているシステムコールハンドラーを呼び出そうとします。もう少し詳しく見ると、SYSCALL命令の場合MSR(Model Specific Register)のIA32_LSTARにハンドラーのアドレスが登録されています。SYSCALL命令による例外発生時、CPUはここからハンドラーのアドレスを取得しハンドラーを実行します。
-
ハンドラーはsystem_callがその実体となります。32ビットプロセッサーであればarch/x86/kernel/entry_32.Sに、64ビットプロセッサーであればarch/x86/kernel/entry_64.Sに定義されています。CPUはMSRまたはIDTからsystem_callのアドレスを取得して処理を開始します。
-
ハンドラーでは、EAXレジスターの値からシステムコールが登録されているテーブル(sys_call_table)のシステムコール番号目の関数が呼び出されて、対応するシステムコールの処理が始まります。例えば、64ビットLinuxのwriteは1番目のシステムコール番号となりますので、sys_call_tableの1番目に登録されているsys_writeの処理が始まります。
SYSENTER命令とSYSCALL命令とINT 0x80命令に対応しているかどうかは、プロセッサーの種類とOSによって異なります。どのプロセッサーとどのOSがどのシステムコールの命令に対応しているかを次の表にまとめました。現在のプロセッサーでは全部の命令に対応していますが、古いプロセッサーが載っているパソコンをお使いの場合注意してください。
システムコールの命令とプロセッサーの種類とOSの対応 | ||||
---|---|---|---|---|
64ビットプロセッサー (x86-64) |
32ビットプロセッサー (P6ファミリー以降) |
32ビットプロセッサー (P6ファミリーより前) |
||
64ビットLinux | 32ビットLinux | 32ビットLinux | 32ビットLinux | |
SYSCALL命令 | ○ | ○ | × | × |
SYSENTER命令 | ○ | ○ | ○ | × |
INT 0x80命令 | ○ | ○ | ○ | ○ |
SYSCALL命令によるwriteシステムコール呼び出し
それでは、自分自身でwriteシステムコールを呼び出す体験をしていきましょう。
まずは、SYSCALL命令での呼び出し体験です。64ビットカーネルでやってみましょう。
ここでは、インラインアセンブラーで実装してみました。ここでは、my_write.cというファイル名に次の内容を保存しているとして話を進めます。
[my_write.c]
#define STDOUT_FILENO 1
#define WRITE_SYS_NUM 1
int main( int argc, char *argv[ ] )
{
int err;
const char *text = “abc\n”;
/* write system call # = 1 */
asm volatile( “mov %0, %%rax” : : “i”( WRITE_SYS_NUM ) );
/* 1st argument (fd=stdout) */
asm volatile( “mov %0, %%rdi” : : “i”( STDOUT_FILENO ) );
/* 2nd argument ( *text ) */
asm volatile( “mov %0, %%rsi” : : “m”( text ) );
/* 3rd argument ( size ) */
asm volatile( “mov %0, %%rdx” : : “i”( sizeof( text ) – 1 ) );
asm volatile( “syscall” : “=a”( err ) );
return( 0 );
}
上記プログラムを”my_write.c”に保存して次のようにコンパイルします。
$ gcc -o my_write my_write.c
そして、実行します。
$ ./my_write
abc
SYSCALL命令によりwriteシステムコールが呼び出されて、標準出力に文字列が表示されました。単純なプログラムですが、やっていることはC言語によるwriteシステムコール呼び出しと同じことになります。(ただし、ライブラリーのwriteシステムコール呼び出しと実際には動作が異なります。ライブラリーではもう少し複雑な処理を行っています。)
アセンブラーが苦手な方のために説明します。インラインアセンブラーの詳細な記述方法については、ファイルシステムの本質から少し離れてしまいますし、他に説明されている文書がたくさんありまので、その都度ご参照ください。
アセンブラーについてわかっている方は読み飛ばしてください。
インラインアセンブラーについては”GCC インラインアセンブラー”や”GCC 拡張インラインアセンブラー”などで検索を行うとさまざまな説明サイトが出てくるかと思います。
最初の命令から見ていきましょう。
/* write system call # = 1 */
asm volatile( “mov %0, %%rax” : : “i”( WRITE_SYS_NUM ) );
/* 1st argument (fd=stdout) */
asm volatile( “mov %0, %%rdi” : : “i”( STDOUT_FILENO ) );
/* 2nd argument ( *text ) */
asm volatile( “mov %0, %%rsi” : : “m”( text ) );
/* 3rd argument ( size ) */
asm volatile( “mov %0, %%rdx” : : “i”( sizeof( text ) – 1 ) );
asm volatile( “syscall” : “=a”( err ) );
ここでは、writeシステムコールの番号としてWRITE_SYS_NUM=1を指定
して、RAXレジスター(64ビットのレジスターです。下位32ビットがEAXレジスターと同じ
になります)にmov命令で格納しています。拡張フォーマットのiは定数を
指定して%0に出力しています。次の命令を実行しているのと同じことになります。
mov $1, %rax
これで第1引数の準備ができました。次を見ていきましょう。
/* write system call # = 1 */
asm volatile( “mov %0, %%rax” : : “i”( WRITE_SYS_NUM ) );
/* 1st argument (fd=stdout) */
asm volatile( “mov %0, %%rdi” : : “i”( STDOUT_FILENO ) );
/* 2nd argument ( *text ) */
asm volatile( “mov %0, %%rsi” : : “m”( text ) );
/* 3rd argument ( size ) */
asm volatile( “mov %0, %%rdx” : : “i”( sizeof( text ) – 1 ) );
asm volatile( “syscall” : “=a”( err ) );
先ほどの同じような命令となります。writeシステムコールの第1引数にSTDOUT_FILENO=1を指定して、RDIレジスターに格納しています。次に行きます。
/* write system call # = 1 */
asm volatile( “mov %0, %%rax” : : “i”( WRITE_SYS_NUM ) );
/* 1st argument (fd=stdout) */
asm volatile( “mov %0, %%rdi” : : “i”( STDOUT_FILENO ) );
/* 2nd argument ( *text ) */
asm volatile( “mov %0, %%rsi” : : “m”( text ) );
/* 3rd argument ( size ) */
asm volatile( “mov %0, %%rdx” : : “i”( sizeof( text ) – 1 ) );
asm volatile( “syscall” : “=a”( err ) );
writeシステムコールの第2引数に”text”変数のアドレスを指定して、RSIレジスターに格納しています。拡張フォーマットのmは出力元がメモリーであると言うことを意味しています。ここではメモリー上にある”text”のアドレスを%0に出力しています。
/* write system call # = 1 */
asm volatile( “mov %0, %%rax” : : “i”( WRITE_SYS_NUM ) );
/* 1st argument (fd=stdout) */
asm volatile( “mov %0, %%rdi” : : “i”( STDOUT_FILENO ) );
/* 2nd argument ( *text ) */
asm volatile( “mov %0, %%rsi” : : “m”( text ) );
/* 3rd argument ( size ) */
asm volatile( “mov %0, %%rdx” : : “i”( sizeof( text ) – 1 ) );
asm volatile( “syscall” : “=a”( err ) );
writeシステムコールの第3引数に”text”の文字列長を指定してRDXレジスターに格納しています。拡張フォーマットのiは出力元が定数である”sizeof(text)-1″の計算結果を%0に出力しています。ここで”-1″しているのは、文字リテラルの最後にNULLが格納されているので、NULL以外の文字の長さを指定するためとなります。
/* write system call # = 1 */
asm volatile( “mov %0, %%rax” : : “i”( WRITE_SYS_NUM ) );
/* 1st argument (fd=stdout) */
asm volatile( “mov %0, %%rdi” : : “i”( STDOUT_FILENO ) );
/* 2nd argument ( *text ) */
asm volatile( “mov %0, %%rsi” : : “m”( text ) );
/* 3rd argument ( size ) */
asm volatile( “mov %0, %%rdx” : : “i”( sizeof( text ) – 1 ) );
asm volatile( “syscall” : “=a”( err ) );
最後にSYSCALL命令を実行しています。拡張フォーマットの=aは入力をRAX(EAX)レジスターに格納しています。”err”変数を指定しているのは、err変数としてRAXレジスターを使用することを指示するためとなります。
次にINT 0x80命令によるwriteシステムコール呼び出しについて見ていきましょう。
INT 0x80命令によるwriteシステムコール呼び出し
INT 0x80命令によるシステムコール呼び出しは基本的にSYSCALL命令による呼び出しと同じに
なります。ここでは、32ビットLinuxでの例となります。
#define STDOUT_FILENO 1
#define WRITE_SYS_NUM 4
int main( int argc, char *argv[ ] )
{
int err;
const char *text = “abc\n”;
/* write system call # = 1 */
asm volatile( “mov %0, %%eax” : : “i”( WRITE_SYS_NUM ) );
/* 1st argument (fd=stdout) */
asm volatile( “mov %0, %%ebx” : : “i”( STDOUT_FILENO ) );
/* 2nd argument ( *text ) */
asm volatile( “mov %0, %%ecx” : : “m”( text ) );
/* 3rd argument ( size ) */
asm volatile( “mov %0, %%edx” : : “i”( sizeof( text ) – 1 ) );
asm volatile( “int $0x80” : “=a”( err ) );
return( 0 );
}
レジスターが32ビットのレジスターになっているのと、最後にINT 0x80命令を実行している箇所が違うだけとなります。次にSYSENTER命令によるwriteシステムコール呼び出しについて見ていきましょう。
SYSENTER命令によるwriteシステムコール呼び出し
SYSENTER命令によるシステムコール呼び出しは命令の仕様から言って結構むずかしいです。
SYSENTER命令が呼び出された後カーネルはSYSEXIT命令で呼び出しもとに復帰します。
゛呼び出しもとに復帰”と書いていますが、実際にはそうではなく近い動作を想定してカーネルを作っています。SYSENTER命令では、INT 0x80やSYSCALL命令のように実行時に”呼び出しもと”のアドレスをスタックやレジスターに退避することはありません。
元に戻る場所をカーネルに知らせることなく処理が開始します。SYSEXIT命令からユーザープログラムに復帰するには、SYSEXIT命令を実行する前にカーネルで、EDXレジスターに戻り先のアドレスを、ECXレジスターにユーザープログラムのスタックポインターを設定する必要があります。つまり、カーネルは戻り先のユーザープログラムのアドレスを把握しおく必要があります。
これにはいくつか方法があります。
レジスターの1つを使ってカーネルに通知するなどが考えられますが、いずれにせよオーバーヘッドがかかってしまい、なんのためのSYSENTER命令かわからなくなってきます。そこで、Linuxでは事前に決められたアドレスに復帰する方針をとっています。つまり、ユーザー空間の固定アドレスに復帰するという手段をとります。
Linuxでは従来SYSENTER命令からの復帰するアドレスは固定アドレスでしたが、セキュリティーの要請からそうはいかないようになってきました。時代の要請からなるべくランダムなアドレスに復帰するようになっています(実際にはSYSENTER命令の呼び出しもとがランダムなアドレスになります)。これについては後述していきます。
このため、システムコールを呼び出すときにユーザープログラムのどのアドレスに帰ってくるのかを知っておく必要がありますが、これはカーネルとコンパイラーのライブラリーが知っていればよいだけの話となります。通常のプログラムをしていくうえでこのようなことを知っている必要はありませんが、ちょっと説明していきたいと思います。
カーネルではvDSO(Virtual Dynamic Shared Object)という仮想的な動的ライブラリーを用意しています。vDSOは全てのプロセス起動時にカーネルが共有ライブラリーとしてリンクを行います。vDSOは通常Cライブラリーから呼び出しされますので、意識することはありません。
vDSOには頻繁に呼び出されるシステムコールが実装されていてます。頻繁に呼び出されるシステムコールとしてgettimeofdayがあります。タイムスタンプの更新や、ウェイトの時間測定などの使用しますが、アプリケーションからもライブラリーからもよく呼び出されます。
このため、なるべくオーバーヘッドを少なくしたいためvDSOに実装されています。なるべく処理が速くなるように、システムコール自体の発行は行わず、カーネルがvDSO領域で更新する時刻情報を取得するだけの軽い処理になっています。