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を比較する議論が日本語でもいっぱい見つかりましたが、細かいことはよくわかりません。