int 2E/sysenter/syscall考察
WindowsNT/2000/XP/2003などにおいてカーネルモードへ移行するために
使われているint 2E/sysenter/syscallの3命令について、
考えてみます。
カーネルモードへ移行する方法
各種Windowsにおいてカーネルモードへ移行するために
使われている方法は以下のようになります。
【カーネルモードへ移行する方法】
WindowsNT/2000 int 2E(割込みゲート)
WindowsXP/2003(x86版) sysenter
WindowsXP/2003(x64版) syscall
Windows95/98/Me call(コールゲート)
int 2E(割込みゲート)とcall(コールゲート)は32ビット世代の最初のCPUである386から利用可能な方法です。(正確にはプロテクトモードが導入された286(16ビットCPU)からですが。)
これに対し、sysenterはインテルがPentiumIIで導入した命令、
syscallはAMDが(たしか)K6で導入した命令です。
なおsyscallはEM64Tでも利用可能です。
速度比較
これらの方法で速度にどの程度の差があるのかをテストしてみます。
以下のプログラムを使います。
【syscall.c】
// カーネルモード移行(int 2E/sysenter/syscall)の
// 時間比較を行うための簡易プログラム。(Win32/Win64共用)
// 時間計測はQueryPerformanceCounter()で行う。
// マルチCPU機で動作させる時は
// SetThreadAffinityMask()を忘れずに。
// Releaseビルドで実行するべし。
// コンパイラの最適化は念のために抑止。
#include <windows.h>
#include <stdio.h>
#define COUNTS 1000000
void main(void){
LPBYTE baseadr=0x0;
MEMORY_BASIC_INFORMATION mbi;
LARGE_INTEGER qpc1, qpc2, freq;
SIZE_T i;
QueryPerformanceFrequency(&freq);
QueryPerformanceCounter(&qpc1);
i=COUNTS;
while(i--){
// DebugBreak();
VirtualQuery(baseadr, &mbi, sizeof(MEMORY_BASIC_INFORMATION));
// DebugBreak();
FlushInstructionCache(GetCurrentProcess(), NULL, 0);
}
QueryPerformanceCounter(&qpc2);
printf("Elapsed time = %I64d msec.\n", (qpc2.QuadPart-qpc1.QuadPart)/(freq.QuadPart/1000));
} // END of main()
VirtualQuery()とFlushInstructionCache()の2つのAPIを100万回実行します。
どちらもカーネルモードへ移行して処理の行われるAPIです。
この2つのAPIを選んだのは、カーネルモード内で行われる
処理が簡単そうで、OSのバージョンが違っても処理内容(=速度)に違いが出にくいように思われたからです。
上記プログラムを実行した結果を示します。(20回実行した結果の平均です。)
【syscall.exeの実行結果】
■Athlon64 3000+(NewCastle, Socket754, 実クロック2.0GHz)
(1)Windows2000 SP4 478msec (int 2E)
(2)WindowsXP Professional SP2 439msec (sysenter)
(3)WindowsXP Professional(x64) - Win32(WOW64) 570msec (syscall)
(4)WindowsXP Professional(x64) - Win64 311msec (syscall)
■Pentium4 3.06GHz(Northwood, FSB533MHz, HT対応)
(5)WindowsXP Professional SP2 762msec (sysenter)
(1)と(2)の比較より、int 2Eよりはsysenterの方が速そうだとわかります。
(3)はWOW64の処理(Win32でのAPI呼び出しをWin64でのAPIに置き換える処理)が入るのでパスするとして、
(2)と(4)を比べると、かなり差が出てしまっています。
ただこれを見て「syscallはsysenterよりも優れている」と判断するのは早計です。
syscallとsysenterには、処理内容で優劣の差はありません。(詳細は後述。)
AMD64ではsysenterよりもsyscallの方がCPU内部での最適化が進んでいるといった程度の違いなのでは、
と推測されます。
(2)と(5)の違いには、正直びっくりしました。
Netburstアーキテクチャの弱点が露呈したものなのか、それともメモリアクセス性能の違い(Pentium4の方はFSB533MHz,
Athlon64の方はHyperTransport 800MHz(FSB換算で1600MHz相当))なのか、私にはわかりません。
以下ではint 2Eとsysenter/syscallの概要について調べた後、KD(カーネルデバッガ)を用いて
Windowsがこれらの命令をどのように使っているのかを調べてみます。
int 2Eの概要
ユーザーモードにおいてint 2Eを実行すると、
CPUは以下のような処理を行います。
【int 2E実行時の処理内容(概要)】
(1)IDTRレジスタの指すIDT(割込みディスクリプタテーブル)の0x2E番目のエントリーを探す。
(2)0x2E番目のエントリーは割込みゲートであった。
(3)上記割込みゲートディスクリプタ内に記述されたセグメントセレクタを
CSレジスタにロードする。
(4)割込みゲートディスクリプタに記述されたベースアドレスを
EIPレジスタにロードする。
(5)TRレジスタに入っているセレクタのディスクリプタを読み取る。
(6)これはTSSディスクリプタである。(TSS=タスク・ステート・セグメント)
(7)上記TSSディスクリプタ内に記述されたベースアドレスとセグメントリミットを元に
TSSを読み取り、その中にあるRING0用のSSセレクタとESPレジスタの値を読み取り、
SSレジスタとESPレジスタにロードする。
カーネルモードのルーチンを指すセレクタを作り
そのディスクリプタをGDT(グローバル・ディスクリプタテーブル)に入れておきます。
そのセレクタを指すゲートディスクリプタ(割込みゲート)を作成することで、
ソフトウェア割込みを使ってカーネルモードに移行できるわけです。
sysenter/syscallの概要
int 2Eを実行すると
割り込みゲート経由でカーネルモードに移行するわけですが、
この移行に際して以下のような処理が暗黙のうちに行われます。
【割込みゲート経由で移行する際に暗黙のうちに行われる処理(抜粋)】
(1)int 2E実行時のユーザーモードプロセス(RING3)では、
スタックにEFLAGS, SS, ESP, CS, EIPなどを積む。
(2)割込みゲートのゲートディスクリプタについては下記チェックをする。
・割込みゲートディスクリプタのDPLがCPL以上か
(値比較ではDPL<=CPL)どうかをチェックする。
(3)CSレジスタに新たにロードするセレクタについては下記チェックをする。
・NULLセレクタでないかどうかをチェックする。
・コードセグメントを指すセレクタであるかどうかをチェックする。
(4)SSレジスタに新たにロードするセレクタについては下記チェックをする。
・NULLセレクタでないかどうかをチェックする。
・書き込み可能なデータセグメントを指すセレクタであるかどうかをチェックする。
(5)スタック切り替え後、ユーザーモードプロセス(RING3)側の
スタックに積んだパラメータを新しいスタック(RING0用スタック)にコピーする。
これらの処理をするために相当のオーバーヘッドがかかるであろうことが想像できます。
特にシステムコールする毎にセレクタのチェックをするのは無駄と言えます。
なぜなら、OSのカーネルモード・ルーチンのエントリーポイントは
どのAPI呼び出しであろうとも変わらないからです。
(呼び出したいサービスの番号をレジスタに入れ、カーネルモードのディスパッチャを呼び出すわけですから。)
これらの問題を解決するために導入されたのがsysenter/syscall命令です。
この2つの命令はフラットメモリ・モデルの利用を前提に
高速なシステムコールを実現します。
sysenterはインテルがPentiumIIで導入した命令、
syscallはAMDが(たしか)K6で導入した命令です。
なおsysenterとsyscallとで、処理内容に優劣の差はありません。
以下に述べるように、参照しているレジスタ(MSR)が違う程度です。
似た内容の命令が2種類あるのは、(当方の勝手な推測では)
インテルとAMDとの間の政治的な理由(おたがいのメンツ)に起因するものと思われます。
sysenter/syscallでは、カーネルモードのルーチンを指すセレクタをあらかじめ
指定のレジスタ(MSR)に入れておき、実際にカーネルモードに移行する際(sysenter/syscall実行時)には
そのレジスタからノーチェックでCSレジスタにロードすることで、
オーバーヘッドの少ないカーネルモード移行を実現します。
【sysenter実行時の処理内容(概要)】
(1)CSレジスタにSYSENTER_CS_MSR(MSR-174H)の値をロードする。
(2)EIPレジスタにSYSENTER_EIP_MSR(MSR-176H)の値をロードする。
(3)SSレジスタにSYSENTER_CS_MSRの値に8を加算した値をロードする。
(4)ESPレジスタにSYSENTER_ESP_MSR(MSR-175H)の値をロードする。
(5)特権レベル0に切り替えて、カーネルモードルーチンの実行を開始する。
※注意※ sysenter実行時、リターンアドレスなどをスタックに積むことはしない。
たったこれだけのステップでカーネルモードに移行できてしまうのです。
セグメントレジスタへのセレクタのロードはノーチェックです。
なおCSレジスタ・SSレジスタにロードするセレクタの
属性は以下のように設定されます。
(この辺りが、フラットメモリ・モデルの利用を前提にしている所以です。)
【sysenter実行時のセレクタの属性(概要)】
(1)CSとSSのセレクタのセグメントベースは0。
(2)CSとSSのセレクタのセグメントリミットは4Gbytes。
(3)CSセグメントは実行可能・読み取り可能な32ビットコードセグメント。
(4)SSセグメントは読み書き可能な32ビットスタックセグメント。
syscallについても見てみましょう。
まずレガシーモード(386互換のモード)からです。
【syscall実行時の処理内容(レガシーモード, 概要)】
(1)syscallの次の命令を指すポインタ(EIP)をECXレジスタにセーブする。
(2)STAR MSR(MSR-C000_0081H)の値(bits 47-32)をCSレジスタにロードする。
(3)STAR MSR(MSR-C000_0081H)の値(bits 31-0)をEIPレジスタにロードする。
(4)STAR MSR(MSR-C000_0081H)の値(bits 47-32)に8を加算した値をSSレジスタにロードする。
(5)特権レベル0に切り替えて、カーネルモードルーチンの実行を開始する。
【syscall実行時のセレクタの属性(レガシーモード, 概要)】
(1)CSとSSのセレクタのセグメントベースは0。
(2)CSとSSのセレクタのセグメントリミットは4Gbytes。
(3)CSセグメントは実行可能・読み取り可能な32ビットコードセグメント。
(4)SSセグメントは読み書き可能な32ビットスタックセグメント。
次はロングモードです。
【syscall実行時の処理内容(ロングモード, 概要)】
(1)syscallの次の命令を指すポインタ(RIP)をRCXレジスタにセーブする。
(2)フラグ(RFLAGS)をR11レジスタにセーブする。
(3)(64-bit mode時のみ)
LSTAR MSR(MSR-C000_0082H)の値(bits 63-0)をRIPレジスタにロードする。
(3)(Compatibility mode時のみ)
CSTAR MSR(MSR-C000_0083H)の値(bits 63-0)をRIPレジスタにロードする。
(4)STAR MSR(MSR-C000_0081H)の値(bits 47-32)をCSレジスタにロードする。
(5)STAR MSR(MSR-C000_0081H)の値(bits 47-32)に8を加算した値をSSレジスタにロードする。
(6)フラグ(RFLAGS)とSYSCALL_FLAG_MASK(MSR-C000_0084H)の値(bits 31-0)とのANDをとり、
その結果をRFLAGSにロードする。
(7)特権レベル0に切り替えて、カーネルモードルーチンの実行を開始する。
【syscall実行時のセレクタの属性(ロングモード, 概要)】
(1)CSとSSのセレクタのセグメントベースは0。
(2)CSとSSのセレクタのセグメントリミットは4Gbytes。
(3)CSセグメントは実行可能・読み取り可能な64ビットコードセグメント。
(4)SSセグメントは読み書き可能な64ビットスタックセグメント。
レガシーモードとロングモードとで動作が
若干異なるものの、
処理内容はいずれもsysenterと概ね同じであることがわかります。
int 2Eの実際
int 2EがWindowsでどのように使われているのかを調べてみましょう。
ここからは、KD(カーネルデバッガ)を使います。
Windows2000(SP4)のインストールされたPC(ターゲットPC)とホストPCとを
シリアルケーブル(クロスタイプ)で接続してからターゲットPCをデバッグモードで起動します。
ホストPC側からブレークをかけて調査開始です。
まずIDTのベースアドレスを調べます。
【IDTのベースアドレス】
kd> R idtr
idtr=8003f400
kd> R idtl
idtl=000007ff
IDTのベースアドレスが0x8003f400で、リミットが0x7ffであることがわかります。
これより、0x2E番目のエントリーのアドレスは8003f400+8×2e=8003f570と求まります。
ダンプしてみます。
【IDTの0x2E番目のエントリー】
kd> Db 8003f570
8003f570 cd 55 08 00 00 ee 86 80-8f 8c 08 00 00 8e 86 80 .U..............
8003f580 10 4c 08 00 00 8e 86 80-1a 4c 08 00 00 8e 86 80 .L.......L......
8003f590 24 4c 08 00 00 8e 86 80-2e 4c 08 00 00 8e 86 80 $L.......L......
8003f5a0 38 4c 08 00 00 8e 86 80-42 4c 08 00 00 8e 86 80 8L......BL......
8003f5b0 4c 4c 08 00 00 8e 86 80-b8 60 08 00 00 8e 9a 80 LL.......`......
8003f5c0 60 4c 08 00 00 8e 86 80-6a 4c 08 00 00 8e 86 80 `L......jL......
8003f5d0 74 4c 08 00 00 8e 86 80-7e 4c 08 00 00 8e 86 80 tL......~L......
8003f5e0 88 4c 08 00 00 8e 86 80-54 72 08 00 00 8e 9a 80 .L......Tr......
朱書きした箇所がIDTの0x2E番目のゲートディスクリプタです。
+5バイト目が0xEE(2進数で1110_1110)となっているので、
このゲートディスクリプタがタスクゲートやトラップゲートではなく
割込みゲートであることがわかります。
わかりやすくまとめると以下のようになります。
【int 2Eに対応するゲートディスクリプタ】
ゲートの種類: 割込みゲート
ジャンプ先のセレクタ: 0x8
ジャンプ先のオフセット値: 0x808655cd
P(Segment Present): 1
DPL(Descriptor Privilege Level): 3
DPLが3なので、CPLが3(ユーザーモードのプロセス)から呼び出せる
ゲートディスクリプタであることがわかります。
ジャンプ先のセレクタ0x8を調べます。
【セレクタ0x8】
kd> DG 8
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- -------- -------- ---------- - -- -- -- -- --------
0008 00000000 ffffffff Code RE Ac 0 Bg Pg P Nl 00000c9b
ベースアドレスが0x00000000、リミットが0xffffffffなコードセグメントであることがわかります。
4GBの全仮想アドレス空間をアクセスできる(フラットメモリな)セレクタです。
Pl(=DPL)が0なので、カーネルモード用のコードセグメントであることもわかります。
さらにこのベースアドレス(0x00000000)にゲートディスクリプタのオフセット値(0x808655cd)を加算した値が
0x80000000以上である、すなわち4GBの仮想アドレス空間の上位2GBを指していることより、
システムが予約している領域内で実行されることもわかります。
次にTSSを調べます。
まずTRレジスタの内容(セレクタ)を見ます。
【TRレジスタ】
kd> R tr
tr=00000028
GDT内の0x28番目にTSSディスクリプタが入っていることがわかります。
さっそくセレクタのディスクリプタを見てみます。
【セレクタ0x28】
kd> DG 28
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- -------- -------- ---------- - -- -- -- -- --------
0028 80042000 000020ab TSS32 Busy 0 Nb By P Nl 0000008b
たしかにTSSディスクリプタです。
上記ベースアドレスを元にTSSそのものをダンプします。
【TSSのダンプ】
kd> Db 80042000
80042000 57 ff 75 08 00 3c 87 80-10 00 8b 45 10 89 85 78 W.u..<.....E...x
80042010 ff ff ff e8 a6 f7 ff ff-8b c8 33 f6 00 90 03 00 ..........3.....
80042020 74 ff ff ff 75 04 33 c0-eb 30 b8 00 01 00 00 39 t...u.3..0.....9
80042030 45 18 76 03 89 45 18 53-6a 40 5b 3b fb 8d 45 bc E.v..E.Sj@[;..E.
80042040 72 26 6a 04 56 50 ff 75-0c 51 e8 b2 fb ff ff 66 r&j.VP.u.Q.....f
80042050 81 7d bc ff ff 75 7e 33-c0 5b 8b 4d fc 5f 5e e8 .}...u~3.[.M._^.
80042060 00 00 00 00 00 00 ac 20-04 00 00 18 18 00 00 00 ....... ........
80042070 00 00 00 00 00 00 00 00-00 00 00 00 00 00 00 00 ................
朱書きした箇所より、CPUがカーネルモード(RING0)に切り替わるときに
SSレジスタにはセレクタ0x10、ESPレジスタには0x80873c00がロードされることがわかります。
セレクタ0x10について調べます。
【セレクタ0x10】
kd> DG 10
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- -------- -------- ---------- - -- -- -- -- --------
0010 00000000 ffffffff Data RW Ac 0 Bg Pg P Nl 00000c93
ベースアドレスが0x00000000、リミットが0xffffffffなデータセグメントであることがわかります。
4GBの全仮想アドレス空間をアクセスできる(フラットメモリな)セレクタです。
Pl(=DPL)が0なので、カーネルモード用のデータセグメントであることもわかります。
sysenterの実際
WindowsXP Professional SP2がインストールされたターゲットPCを
用いて、sysenterがWindowsでどのように使われているのかを
調べてみます。
まずMSRを読み取ります。
【sysenter関連MSRの読み取り】
kd> rdmsr 174
msr[174] = 00000000:00000008
kd> rdmsr 175
msr[175] = 00000000:f7a34000
kd> rdmsr 176
msr[176] = 00000000:80865710
SYSENTER_CS_MSR(MSR-174H)が0x8ですので、sysenter実行時に
CSレジスタには0x8がロードされ、SSレジスタには0x8+0x8=0x10がロードされることがわかります。
(セレクタの値がWindows2000の場合と同じになっています。)
SYSENTER_EIP_MSR(MSR-176H)に入っている0x80865710がEIPレジスタにロードされ、
SYSENTER_ESP_MSR(MSR-175H)に入っている0xf7a34000がESPレジスタにロードされることがわかります。
セレクタについて見てみます。
【セレクタ0x8, 0x10】
kd> DG 8
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- -------- -------- ---------- - -- -- -- -- --------
0008 00000000 ffffffff Code RE Ac 0 Bg Pg P Nl 00000c9b
kd> DG 10
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- -------- -------- ---------- - -- -- -- -- --------
0010 00000000 ffffffff Data RW Ac 0 Bg Pg P Nl 00000c93
CSレジスタにロードされるセレクタ0x8は、
ベースアドレスが0x00000000、リミットが0xffffffffなコードセグメントであることがわかります。
4GBの全空間をアクセスできる(フラットメモリな)セレクタです。
Pl(=DPL)が0なので、カーネルモード用のコードセグメントであることもわかります。
さらにこのベースアドレス(0x00000000)にEIPレジスタにロードされる値(0x80865710)を加算した値が
0x80000000以上である、すなわち4GBの仮想アドレス空間の上位2GBを指していることより、
システムが予約している領域内で実行されることもわかります。
SSレジスタにロードされるセレクタ0x10は、
ベースアドレスが0x00000000、リミットが0xffffffffなデータセグメントであることがわかります。
4GBの全仮想アドレス空間をアクセスできる(フラットメモリな)セレクタです。
Pl(=DPL)が0なので、カーネルモード用のデータセグメントであることもわかります。
syscallの実際
WindowsXP Professional x64 Edition(RC1)がインストールされたターゲットPCを
用いて、syscallがWindowsでどのように使われているのかを
調べてみます。
なおここで調べるのはロングモードでのsyscallについてです。
まずMSRを読み取ります。
【syscall関連MSRの読み取り】
kd> rdmsr c0000081
msr[c0000081] = 00230010:00000000
kd> rdmsr c0000082
msr[c0000082] = fffff800:01024040
kd> rdmsr c0000083
msr[c0000083] = fffff800:01023d80
kd> rdmsr c0000084
msr[c0000084] = 00000000:00014700
STAR MSR(MSR-C000_0081H)の値(bits 47-32)が0x10ですので、syscall実行時に
CSレジスタには0x10がロードされ、SSレジスタには0x10+0x8=0x18がロードされることがわかります。
64-bit modeではLSTAR MSR(MSR-C000_0082H)の値0xfffff80001024040がRIPレジスタにロードされ、
Compatibility modeではCSTAR MSR(MSR-C000_0083H)の値0xfffff80001023d80がRIPレジスタにロードされることがわかります。
(ただしこれらのポインタで実際に意味を持つのは下位48ビットのみです。
AMD64/EM64Tでは仮想アドレス空間が(現時点では)48ビットに制限されていることに注意してください。
この場合上位16ビット(bits 63-48)は、有効なビット(bits 47-0)の最上位ビット(bit 47, MSB(Most Significant Bit)と呼ぶ)の値で
埋めておきます。これをCanonical Address Formと呼びます。ポインタとして有効なのは下位48ビットのみです。)
さらにRFLAGSとはSYSCALL_FLAG_MASK(MSR-C000_0084H)の値(bits 31-0)である0x14700と
ANDをとることがわかります。
これはsyscall実行時のフラグのうちRF, NT, DF, IF, TFフラグがカーネルモード実行時も引き継がれることを意味します。
セレクタについて調べます。
【セレクタ0x10, 0x18】
kd> DG 10
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- ----------------- ----------------- ---------- - -- -- -- -- --------
0010 00000000`00000000 00000000`00000000 Code RE Ac 0 Nb By P Lo 0000029b
kd> DG 18
P Si Gr Pr Lo
Sel Base Limit Type l ze an es ng Flags
---- ----------------- ----------------- ---------- - -- -- -- -- --------
0018 00000000`00000000 00000000`ffffffff Data RW Ac 0 Bg Pg P Nl 00000c93
セレクタ0x10は
コードセグメントであることがわかります。
ベースアドレスやリミット値は64-bit modeでは無視されるので、
このセレクタで仮想アドレス空間の全領域をアクセスできることになります。
L bit(LONG)が立っているので、このコードセグメントが
ロングモード(64-bit mode もしくはcompatibility mode)で実行されることもわかります。
Pl(=DPL)がゼロなので、カーネルモードのコードセグメントであることもわかります。
セレクタ0x18は
データセグメントであることがわかります。
ベースアドレスやリミット値は64-bit modeでは無視されるので、
このセレクタで仮想アドレス空間の全領域をアクセスできることになります。
(ベースアドレス、リミット値、その他のフラグがセットされているのは
compatibility modeでも共有するセレクタだからなのでしょうか?)
関連リンク
Information for Windows NT and Windows 2000 - The Native API
How Do Windows NT System Calls REALLY Work
System Call Optimization with the SYSENTER Instruction
Windows Source Home Page
参考文献
- Advanced Micro Devices, Inc., AMD64 Architecture Programmer's Manual Volume 2: System Programming(Rev. 3.09), Advanced Micro Devices, Inc., September 2003
- Advanced Micro Devices, Inc., AMD64 Architecture Programmer's Manual Volume 3: General-Purpose and System Instructions(Rev. 3.09), Advanced Micro Devices, Inc., September 2003
- Intel Corporation, IA-32 Intel Architecture Software Developer's Manual Volume 2B: Instruction Set Reference N-Z, Intel Corporation, 2004
- Intel Corporation, IA-32 Intel Architecture Software Developer's Manual Volume 3: System Programming Guide, Intel Corporation, 2004
最終更新日:2005年1月9日(日)