楽しいハック講座(1) マルチメディアタイマー

 こんにちは。わび~です。

今日はマルチメディアタイマーを楽しくハックしていきます。(注:クラックではありません)

 

マルチメディアタイマーは、使ってみると思ったより精度が出ないと言われますが、それはなぜでしょうか。

今回は特に周期タイマーを取り上げて、その謎に迫ります。

紹介する内容は、Microsoft のデバッガ (windbg) と公開デバッグシンボルを使えば誰でも確認できます。

 

こちらが本日の実験用のプログラムです。

 

#include "stdafx.h"

#pragma comment(lib, "winmm.lib")

 

void CALLBACK TimerCallback(UINT wTimerID, UINT msg, DWORD dwUser, DWORD dw1, DWORD dw2)

{

std::cout << "timer" << std::endl;

return;

}

 

int _tmain(int argc, _TCHAR* argv[])

{

// タイマーを5個登録します。

timeSetEvent(1234, 0, TimerCallback, 0, TIME_PERIODIC);

timeSetEvent(1234, 0, TimerCallback, 0, TIME_PERIODIC);

timeSetEvent(1234, 0, TimerCallback, 0, TIME_PERIODIC);

timeSetEvent(1234, 0, TimerCallback, 0, TIME_PERIODIC);

timeSetEvent(1234, 0, TimerCallback, 0, TIME_PERIODIC);

 

// 無限に待ちます。

HANDLE event1 = CreateEvent(NULL,NULL,NULL,NULL);

WaitForSingleObject(event1,INFINITE);

return 0;

}

 

ビルドしたものを timer1.exe とします。windbg でこのプログラムを読み込んで、 _tmain にブレークポイントを設定しておき、先頭から実行します。

 

0:000> bp timer1!wmain

0:000> g

 

Breakpoint 0 hit

eax=00397268 ebx=7ffdf000 ecx=00395ec8 edx=00000001 esi=04a8f762 edi=04a8f6f2

eip=00414160 esp=0012ff6c ebp=0012ffb8 iopl=0         nv up ei pl nz na po nc

cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000202

timer1!wmain:

00414160 55              push    ebp

 

ブレークポイントで止まったらスレッド一覧を見ます。当然、今見ているスレッド1つしかありません。

 

0:000> ~

.  0  Id: 179c.1658 Suspend: 1 Teb: 7ffde000 Unfrozen

 

そのままステップ実行して timeSetEvent() を実行します。

 

0:000> p

eax=00000010 ebx=7ffdf000 ecx=0012fdf4 edx=7c94e4f4 esi=0012fe90 edi=0012ff68

eip=0041419d esp=0012fe90 ebp=0012ff68 iopl=0         nv up ei pl zr na pe nc

cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000246

timer1!wmain+0x3d:

0041419d 8bf4            mov     esi,esp

 

ここでスレッド一覧を見ると、スレッドが1つ増えています。

 

0:000> ~

.  0  Id: 179c.1658 Suspend: 1 Teb: 7ffde000 Unfrozen

   1  Id: 179c.320 Suspend: 1 Teb: 7ffdd000 Unfrozen

 

増えたスレッドのコールスタックを見ると、winmm 関連のスレッドであることと、何かの同期オブジェクトを待っていることが分かります。

 

0:000> ~1s

0:001> kbn

 # ChildEBP RetAddr  Args to Child             

00 00a7ff04 7c94df2c 76b0aee9 00000002 00a7ff6c ntdll!KiFastSystemCallRet

01 00a7ff08 76b0aee9 00000002 00a7ff6c 00000001 ntdll!NtWaitForMultipleObjects+0xc

02 00a7ffb4 7c80b713 00000000 00252678 00252370 WINMM!timeThread+0x3a

03 00a7ffec 00000000 76b0aeaf 00000000 00000000 kernel32!BaseThreadStart+0x37

 

一方、コールバック関数 timer1!TimerCallback にブレークポイントを張って実行すると、以下のように、コールバックはこの winmm スレッドからコールされることが分かります。

 

0:001> bp timer1!TimerCallback

0:001> g

Breakpoint 4 hit

eax=00000000 ebx=00000000 ecx=00a7fee8 edx=76b10200 esi=00000010 edi=00411267

eip=004114d0 esp=00a7feb8 ebp=00a7fedc iopl=0         nv up ei pl zr na pe nc

cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000246

timer1!TimerCallback:

004114d0 55              push    ebp

0:001> k

ChildEBP RetAddr 

00a7feb4 76af54e3 timer1!TimerCallback

00a7fedc 76b0adfe WINMM!DriverCallback+0x5c

00a7ff18 76b0af02 WINMM!TimerCompletion+0xf4

00a7ffb4 7c80b713 WINMM!timeThread+0x53

00a7ffec 00000000 kernel32!BaseThreadStart+0x37

 

そのまま5個目の timeSetEvent を実行し、winmm スレッドの様子を見てみます。WaitForMultipleObjects の第1引数は待つオブジェクトの個数なのですが、timeSetEvent を 1回呼んだ後は 2個、timeSetEvent を5回呼んだ後は 6個待っています。

 

timeSetEvent を1回呼んだ後:

0:001> kbn

 # ChildEBP RetAddr  Args to Child             

00 00a7ff04 7c94df2c 76b0aee9 00000002 00a7ff6c ntdll!KiFastSystemCallRet

01 00a7ff08 76b0aee9 00000002 00a7ff6c 00000001 ntdll!NtWaitForMultipleObjects+0xc

02 00a7ffb4 7c80b713 00000000 00252438 00252738 WINMM!timeThread+0x3a

03 00a7ffec 00000000 76b0aeaf 00000000 00000000 kernel32!BaseThreadStart+0x37

 

timeSetEvent を5回呼んだ後:

0:001> kbn

 # ChildEBP RetAddr  Args to Child             

00 00a7ff04 7c94df2c 76b0aee9 00000006 00a7ff6c ntdll!KiFastSystemCallRet

01 00a7ff08 76b0aee9 00000006 00a7ff6c 00000001 ntdll!NtWaitForMultipleObjects+0xc

02 00a7ffb4 7c80b713 00000000 00252438 00252738 WINMM!timeThread+0x3a

03 00a7ffec 00000000 76b0aeaf 00000000 00000000 kernel32!BaseThreadStart+0x37

 

そこでこの時点で待っている同期オブジェクトの種類を調べます。すると、イベント1個+タイマー5個であることが分かります。

 

0:001> dd 00a7ff6c

00a7ff6c  000007b4 000007a4 000007a0 0000079c

00a7ff7c 00000798 00000794 00000000 (……)

 

0:001> !handle 000007b4

Handle 7b4

  Type                 Event

0:001> !handle 000007a4

Handle 7a4

  Type                 Timer

0:001> !handle 000007a0

Handle 7a0

  Type                 Timer

0:001> !handle 0000079c

Handle 79c

  Type                 Timer

0:001> !handle 00000798

Handle 798

  Type                 Timer

0:001> !handle 00000794

Handle 794

  Type                 Timer

 

これは、タイマーハンドルの配列に変更があった場合にイベントを発火させて配列を更新して再度Waitするためにこのような実装になっていると推測できます。

 

ではタイマーオブジェクトを作成している部分はどこでしょうか。

winmm!timeSetEvent の中身を逆アセンブルしながら追跡していくと、内部の処理はかなり延々と続くのですが、最終的には ntdll!NtSetTimer という関数をコールしていることが分かります。

 

WINMM!timeSetTimerEvent+0x8c:

(……)

76b0ac34 8d45f8          lea     eax,[ebp-8]

76b0ac37 57              push    edi

76b0ac38 50              push    eax

76b0ac39 ff7618          push    dword ptr [esi+18h]

76b0ac3c ff152812af76    call    dword ptr [WINMM!_imp__NtSetTimer (76af1228)]

 

0:001> dps 76af1228

76af1228  7c94dd80 ntdll!NtSetTimer

 

NtSetTimer は非公開関数なので詳細は書けませんが、ここにブレークポイントを設定して実行すると、timeSetEvent の中からコールされることは当然として、それ以外に、タイマーの発火の度に繰り返しコールされることが分かります。

 

0:001> g

Breakpoint 1 hit

eax=00a7feec ebx=76b10200 ecx=ec1851e0 edx=00000005 esi=76b11760 edi=00000000

eip=7c94dd80 esp=00a7fec4 ebp=00a7fef4 iopl=0         nv up ei pl nz na po nc

cs=001b  ss=0023  ds=0023  es=0023  fs=003b  gs=0000             efl=00000202

ntdll!NtSetTimer:

7c94dd80 b8f4000000      mov     eax,0F4h

0:001> k

ChildEBP RetAddr 

00a7fec0 76b0ac42 ntdll!NtSetTimer

00a7fef4 76b0ae46 WINMM!timeSetTimerEvent+0xa0

00a7ff18 76b0af02 WINMM!TimerCompletion+0x13c

00a7ffb4 7c80b713 WINMM!timeThread+0x53

00a7ffec 00000000 kernel32!BaseThreadStart+0x37

 

ということは、これはワンショットタイマーであると推測できます。マルチメディアタイマーが提供する周期タイマーは、ワンショットタイマーを繰り返し登録することによって実装されているのだろう、ということです。既知の制限事項に、「ある周期タイマーのコールバックが周期以内に完了しないと次回のコールバックが遅れる」というのがありますが、これとも符合します。

 

まとめると、マルチメディアタイマーは1プロセスあたり16個しか登録できないという制約と、ワンショットタイマーで周期タイマーを実装しているという点の2点がイケてないと言えます。しかしそれ以外には、タイマー自体はWindows カーネルが提供するタイマーオブジェクトで実装されているので、ことさらに精度が悪くなるような原因は特に見当たりません。2点の壁がクリアされているのに精度が悪いとすれば、それはそもそもWindowsがリアルタイムOSではないことや、デフォルトのシステムクロック割り込み間隔が 15.6 msec でありマルチメディア処理にとっては少々長い、という点に起因する部分になります。

 

そのため、精度を上げる秘訣は timeBeginPeriod でシステムクロックの割り込み間隔を短く設定することと、自分のスレッド以上の優先度を持つスレッドを極力少なくすることに尽きます。(注:timeBeginPeriod は副作用がありますので Microsoft として推奨はしていません。)

 

さて、使用しているのが本当にワンショットタイマーかどうか、については各関数を逆アセンブルしたものをよ~く見比べると分かりますが、それを詳しく説明するとあと10倍くらいは話が長くなりますので、またの機会に。今回はユーザーモードデバッグのみを行いましたが、カーネルモードデバッグを併用するともっと楽しくなります。

 

私たちは業務として毎日こんなことをやっています。一緒にやってみたい方はぜひ採用担当まで!