楽しいハック講座(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倍くらいは話が長くなりますので、またの機会に。今回はユーザーモードデバッグのみを行いましたが、カーネルモードデバッグを併用するともっと楽しくなります。


 


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


 

Comments (3)

  1. ちろ says:

    ありがとうございます。とっても助かりました。VB で 200ms の Timer を構成したは良いものの、その実時間間隔が 203ms になってしまい、悩んでおりました。15.6ms 間隔という事は 1/64=0.015625 なのでしょうから 13/64=0.203125 でぴったりです。なるほど、そういう事だったのですね。すっきりしました。

  2. お役に立てたようで何よりです。

    タイマーの最小発火間隔は CPU がマザーボードからシステムクロック割り込みをかけられる間隔と同じものです。実機で手軽に確認するには sysinternals の clockres というツールがあります。

    http://technet.microsoft.com/en-us/sysinternals/bb897568.aspx

    引き続きディープな内容を扱っていきますので、今後ともハック講座をよろしくお願いいたします。

Skip to main content