GetTickCountとtimeGetTime
GetTickCount関数とtimeGetTime関数がどうやって実装されているか確認し、分解能の違いが実現できている理由を読み解きます。[NTKRNL]のタグがついている時点でアレなわけですが「どっちを使うべき」的な情報がほしい方は回れ右。特に明示しない場合、x86 XP SP3を対象に調査しています。Vista以降はHPET(High Precision Event Timer)がサポートされているので、少し違うかもしれません。
まとめ
- GetTickCount関数およびtimeGetTime関数はSharedUserData内のそれぞれ別のデータを利用する
- SharedUserDataのタイマー関係のデータはKeUpdateSystemTime関数で更新される
- KeUpdateSystemTime関数は割り込みハンドラHalpClockInterrupt関数から呼び出される
- timeBeginPeriod関数で分解能を上げるとHalpClockInterrupt関数が呼び出される頻度が上がる
- timeGetTime関数が使う値はKeUpdateSystemTime関数の呼び出しのたびに更新される
- GetTickCount関数が使う値はKeUpdateSystemTime関数の呼び出しの頻度とは無関係にKeMaximumIncrement変数の値を基準に更新される
- きれいな絵を入れるとなんかすごそうに見える
実装を確認
GetTickCount
まず、GetTickCount関数の実装を見てみます。
_GetTickCount@0 proc near mov edx, 7FFE0000h ; <--------------------------- mov eax, [edx] mul dword ptr [edx+4] shrd eax, edx, 18h retn _GetTickCount@0 endp
関数はSharedUserData(7FFE0000h)のオフセット +0 と +4 を利用しています。
SharedUserDataはKUSER_SHARED_DATA型の変数としてntddk.hに定義されています。ntddk.hから引用しても良いのですが、ここではオフセットが判るwindbgの出力から引用します。
kd> dt nt!_KUSER_SHARED_DATA -r +0x000 TickCountLow : Uint4B +0x004 TickCountMultiplier : Uint4B +0x008 InterruptTime : _KSYSTEM_TIME +0x000 LowPart : Uint4B +0x004 High1Time : Int4B +0x008 High2Time : Int4B +0x014 SystemTime : _KSYSTEM_TIME ...
上記出力から、TickCountLowやTickCountMultiplierを利用していることがわかります。
なお、SharedUserDataとは、ユーザー空間とカーネル空間の間で共有される(Shared)データ構造です。その性質上、何かと悪用されやすい存在でもあるのですが、ユーザー空間からカーネル空間に遷移することなくカーネル提供のデータ構造を参照できるため、TickCountのようなデータを扱うのにとても適しているといえます。
timeGetTime
次にtimeGetTimeの実装を見てみます。
.text:76AF4E4F _timeGetTime@0 proc near .text:76AF4E4F cmp dword_76B10014, 0 .text:76AF4E56 jnz loc_76AFC5F3 ; .text:76AFC5F3 jmp ds:__imp__GetTickCount@0 ; GetTickCount() .text:76AF4E5C call sub_76AF2B09 ; <--------------------------- .text:76AF4E61 sub eax, dword_76B10018 .text:76AF4E67 push 0 .text:76AF4E69 sbb edx, dword_76B1001C .text:76AF4E6F push 2710h .text:76AF4E74 push edx .text:76AF4E75 push eax .text:76AF4E76 call __alldiv .text:76AF4E7B add eax, dword_76B10020 .text:76AF4E81 retn .text:76AF4E81 _timeGetTime@0 endp
.text:76AF2B09 sub_76AF2B09 proc near .text:76AF2B09 mov edi, edi .text:76AF2B0B loc_76AF2B0B: .text:76AF2B0B mov edx, ds:7FFE000Ch ; <--------------------------- .text:76AF2B11 mov eax, ds:7FFE0008h ; <--------------------------- .text:76AF2B16 cmp edx, ds:7FFE0010h .text:76AF2B1C jnz short loc_76AF2B0B .text:76AF2B1E retn .text:76AF2B1E sub_76AF2B09 endp
サブルーチン sub_76AF2B09 で +0x008 InterruptTime 構造体を取得していることがわかります。GetTickCount関数とは利用する値が違います。2者の違いは直接はここから来ていることが判ります。
更新の原理
次に、この2つの変数がどのように更新されるか調べます。ユーザー空間から見えるSharedUserDataは読み取り専用なので、更新はカーネル空間で行われていることがわかります。タイマーの根源はハードウェアにあるはずなのでIDTを調べてみます。
kd>!idt -a ... d0: 80543170 nt!KiUnexpectedInterrupt160 d1: 806e6e54 hal!HalpClockInterrupt d2: 80543184 nt!KiUnexpectedInterrupt162 ...
それらしい関数が登録されています。HalpClockInterrupt関数を眺めていくと、inとかoutした後、nt!KeUpdateSystemTime関数を呼び出しています。
KeUpdateSystemTime関数を見ると真っ先に0FFDF0000hを利用しています。
_KeUpdateSystemTime@0 proc near mov ecx, 0FFDF0000h ; <-------------------------- mov edi, [ecx+8] mov esi, [ecx+0Ch] add edi, eax adc esi, 0 mov [ecx+10h], esi mov [ecx+8], edi mov [ecx+0Ch], esi ;...
これはカーネル空間内にマップされたSharedUserDataです。GetTickCount関数やtimeGetTime関数の実装で出てきたユーザー空間のSharedUserData(7FFE0000h)とまったく同じものが見えていますが、こちらは書き込み可能です。
timeGetTimeの更新と分解能
上のコードではtimeGetTime関数が利用するInterruptTime.LowPartをeaxの値で加算していることもわかります。eaxの起源はここからは読み取れませんが、HalpClockInterrupt関数で取得したhal.dll内の変数HalpCurrentClockRateIn100nsの値です。この値がシステムタイマーの現在の分解能で、単位は100nsです。
HalpCurrentClockRateIn100nsの値はNtQueryTimerResolution関数の第3引数ActualResolutionで確認することができます。
NTSTATUS __stdcall NtQueryTimerResolution( PULONG CoarsestResolution, // 最も荒い分解能 PULONG FinestResolution, // 最も細かい分解能 PULONG ActualResolution); // 現在の分解能
マルチメディアタイマーの分解能を高められることで有名な、timeBeginPeriod関数を使ってタイマーの分解能を高めた場合、ActualResolutionの値が小さくなります。そして、HalpClockInterrupt関数が呼び出される頻度が上がります。
timeBeginPeriod関数とActualResolutionの関係は、このコード(NtQueryTimerResolution.cpp)を使って確認することができます。timeBeginPeriod関数を呼び出すと、最終的にはHalpCurrentClockRateIn100nsが変更され、割り込みの頻度が変わるわけです。
以上で、timeGetTime関数はハードウェアによるタイマー割り込みが起こるたびに更新される値を利用していることがわかりました。
GetTickCount
一方、GetTickCount関数が利用する値は、同じくKeUpdateSystemTime関数の中で、カーネル変数KeMaximumIncrementを先ほどのHalpCurrentClockRateIn100nsの値で減算していき、0(以下)になったときに更新されます。
_KeUpdateSystemTime@0 proc near ;... .text:0040B601 29 05 14 30 48 00 sub ds:_KiTickOffset, eax ; 別途KiTickOffset = KeMaximumIncrementされている ;... .text:0040B60E 0F 8F 84 00 00 00 jg loc_40B698 ;...値の調整 .text:0040B670 03 C1 add eax, ecx .text:0040B672 A3 00 00 DF FF mov ds:0FFDF0000h, eax
このKeMaximumIncrementの値は(おそらく)一定で、分解能が高いときは小さなHalpCurrentClockRateIn100nsによって頻繁に減算され、分解能が低いときは大きなHalpCurrentClockRateIn100nsによって低頻度に減算されることになります。以上で、TickCountのほうは分解能とは関係なく一定の間隔で更新されることが判りました。
最後に
カーネルからユーザープロセスのGetTickCount関数の結果をチートするコードを書いていたときに「そういえばマルチメディアタイマーのデータソースは同じなのか、なぜ分解能の違いが出るのか」と思って調べてみたのがきっかけです。ちょろちょろ調べてみると、GetTickCountとtimeGetTimeを比較する議論が日本語でもいっぱい見つかりましたが、細かいことはよくわかりません。