楽しいハック講座(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
Anonymous
February 15, 2009
PingBack from http://www.clickandsolve.com/?p=8466Anonymous
January 20, 2010
ありがとうございます。とっても助かりました。VB で 200ms の Timer を構成したは良いものの、その実時間間隔が 203ms になってしまい、悩んでおりました。15.6ms 間隔という事は 1/64=0.015625 なのでしょうから 13/64=0.203125 でぴったりです。なるほど、そういう事だったのですね。すっきりしました。Anonymous
January 20, 2010
The comment has been removed