WindowsNT/2000/XP/2003などにおいてカーネルモードへ移行するために使われているint 2E/sysenter/syscallの3命令について、考えてみます。
(本記事の初稿は2005年です。64ビットCPUが普及した現在でも有用な内容と思われるので、そのままの内容で公開します。)
■カーネルモードへ移行する方法
各種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でも利用可能です。
■速度比較
これらの方法で速度にどの程度の差があるのかをテストしてみます。以下のプログラムを使います。
// カーネルモード移行(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回実行した結果の平均です。)
■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は以下のような処理を行います。
(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レジスタにロードすることで、オーバーヘッドの少ないカーネルモード移行を実現します。
(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レジスタにロードするセレクタの属性は以下のように設定されます。(この辺りが、フラットメモリ・モデルの利用を前提にしている所以です。)
(1)CSとSSのセレクタのセグメントベースは0。 (2)CSとSSのセレクタのセグメントリミットは4Gbytes。 (3)CSセグメントは実行可能・読み取り可能な32ビットコードセグメント。 (4)SSセグメントは読み書き可能な32ビットスタックセグメント。
syscallについても見てみましょう。まずレガシーモード(386互換のモード)からです。
(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に切り替えて、カーネルモードルーチンの実行を開始する。
(1)CSとSSのセレクタのセグメントベースは0。 (2)CSとSSのセレクタのセグメントリミットは4Gbytes。 (3)CSセグメントは実行可能・読み取り可能な32ビットコードセグメント。 (4)SSセグメントは読み書き可能な32ビットスタックセグメント。
次はロングモードです。
(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に切り替えて、カーネルモードルーチンの実行を開始する。
(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のベースアドレスを調べます。
kd> R idtr idtr=8003f400 kd> R idtl idtl=000007ff
IDTのベースアドレスが0x8003f400で、リミットが0x7ffであることがわかります。これより、0x2E番目のエントリーのアドレスは8003f400+8×2e=8003f570と求まります。ダンプしてみます。
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......
ハイライトした箇所(cd 55~ee 86 80)がIDTの0x2E番目のゲートディスクリプタです。+5バイト目が0xEE(2進数で1110_1110)となっているので、このゲートディスクリプタがタスクゲートやトラップゲートではなく割込みゲートであることがわかります。わかりやすくまとめると以下のようになります。
ゲートの種類: 割込みゲート ジャンプ先のセレクタ: 0x8 ジャンプ先のオフセット値: 0x808655cd P(Segment Present): 1 DPL(Descriptor Privilege Level): 3
DPLが3なので、CPLが3(ユーザーモードのプロセス)から呼び出せるゲートディスクリプタであることがわかります。ジャンプ先のセレクタ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レジスタの内容(セレクタ)を見ます。
kd> R tr tr=00000028
GDT内の0x28番目にTSSディスクリプタが入っていることがわかります。さっそくセレクタのディスクリプタを見てみます。
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そのものをダンプします。
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 ................
ハイライトした箇所(00 3c~10 00)より、CPUがカーネルモード(RING0)に切り替わるときにSSレジスタにはセレクタ0x10、ESPレジスタには0x80873c00がロードされることがわかります。セレクタ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を読み取ります。
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レジスタにロードされることがわかります。
セレクタについて見てみます。
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を読み取ります。
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フラグがカーネルモード実行時も引き継がれることを意味します。
セレクタについて調べます。
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でも共有するセレクタだからなのでしょうか?)
起草日:2005年1月9日(日)(www.marbacka.net内の別のサイトで公開)
最終更新日:2017年2月19日(日)