09/12/22 | デバッグ系APIでアプリ動作を操作してみる編−2(COM・DLL編)[★★★★◎◎] |
前回はデバッガAPIを使ってプロセスのイベントを監視したり、ブレークポイントを張ったりしてみました。 今度は、dxdsでも使用したCreateDeviceのフックまでの流れを題材に、COM呼び出しやDLL呼び出しのトラップに関して一通り必要な処理を見ていきます。 1. 処理の流れ 2. DLL呼び出しへのブレークポイント生成 3. DLL呼び出し時の処理と返り値の取得 4. COM呼び出しのブレークポイント設定 5. CreateDevice呼び出し時の処理 6. NTDLL.DLLの注意点 7. まとめ 1. 処理の流れ 前回示したとおり、dxdsにおける最終目的はIDirect3Dインターフェースに対するCreateDevice呼び出しをトラップし、引数を書き換えることです。 まず、それに向けて何をすべきか簡単に考えて見ます。 とはいえ、それほど複雑なステップはありません。 CreateDeviceはDirect3D9オブジェクトに対するCOM呼び出しで実行されます。 また、そのDirect3D9オブジェクトはDLL呼び出しによりDirect3DCreate9関数で生成されます。 通常間に画面の設定などもありますが、それはCreateDeviceの引数の設定のためであり、CreateDevice呼び出しそのものに関わるのはこの2つだけですね。 gd3d = Direct3DCreate9(D3D_SDK_VERSION); gd3d->CreateDevice(***); さて、dxdsでは内部的に4ステップを踏んでCreateDevice呼び出しをトラップしています。
下3つはともかく、先頭は不要に思えるかも知れません。 「DirectX9オブジェクトが欲しいなら、最初からDirect3DCreate9の終了位置にブレークポイントを張れば?」と。 とはいえDirect3DCreate9の終了位置を最初から探し当てるのはなかなか難しいです。 ではなぜDirect3DCreate9自体にブレークポイントを張れば、終了位置がわかるか、については別途後述します。 それぞれ、ブレークポイント生成のために個別のテクニックが必要になります。 それでは1つずつ見ていきます。 2. DLL呼び出しへのブレークポイント生成 まず、Direct3DCreate9の呼び出しにブレークポイントを張ります。 Direct3DCreate9は単なるDLL呼び出しで行われるため、これが出来ればDLL呼び出し全般に応用可能です。 DLL呼び出しへのブレークポイント生成は、WaitForDebugEventがDLLロードイベントを返したときに行うのが良いでしょう。 DLLロードイベントが発生すると、DEBUG_EVENT構造体の中に共用体を用いて以下の情報が渡されます。 typedef struct _LOAD_DLL_DEBUG_INFO { HANDLE hFile; // DLLのファイルハンドル LPVOID lpBaseOfDll; // DLLの配置位置 DWORD dwDebugInfoFileOffset; // デバッグ情報の位置 DWORD nDebugInfoSize; // デバッグ情報のサイズ LPVOID lpImageName; // DLLファイル名 WORD fUnicode; // ファイル名がUnicodeかどうか }LOAD_DLL_DEBUG_INFO, *LPLOAD_DLL_DEBUG_INFO;これらを用いて、Direct3DCreate9の位置を特定します。 DLLファイル名の取得 まず、プログラムは動作を開始するとDLLが色々なものをロードするため、Direct3DCreate9と関係がないDLLのロードは無視する必要があります。 そこで、まずlpImageNameとfUnicodeを用いてファイル名を取得します。 dxds中の該当コードは以下の通り。これらはCodeZine プロセスデバッガを作ってみるを参考にしました。 static void ProcLoadDLL(HANDLE hDP, DEBUG_EVENT* pde) { char temporary[1024], *pt, *pt2; DWORD dwAccessByte; WCHAR wcBuf[MAX_PATH]; LONG_PTR lpData; //DLL名取得 ReadProcessMemory(hDP,pde->u.LoadDll.lpImageName,&lpData,sizeof(LONG_PTR),&dwAccessByte); if(!lpData) return; ReadProcessMemory(hDP,(void *)lpData,wcBuf,sizeof(WCHAR)*MAX_PATH,&dwAccessByte); if(pde->dwThreadId,pde->u.LoadDll.fUnicode) WideCharToMultiByte(CP_ACP,0,wcBuf,-1,temporary,255,NULL,NULL); else lstrcpy((LPWSTR)temporary,(LPCWSTR)wcBuf);lpImageNameはファイル名そのもののを示すのではなく、ファイル名の格納されたアドレスを格納します。 そこで、まずReadProcessMemoryでそのアドレスをlpDataに取得します。 そしてさらにlpDataのアドレスからパスの最大長であるMAX_PATH文字分だけ文字列をコピーします。 ここで、fUnicodeが0ではない場合、ファイル名がUnicodeであることを示すので、ここではWideCharToMultiByteでマルチバイト文字に直しています。 Direct3DCreate9はd3d9.dllが提供しているAPIなので、上記ファイル名がd3d9.dllと一致するかチェックして先へ進みます。 なお、上記処理で得られるパス名は絶対パスなので注意が必要です。 DLL関数位置の取得 LOAD_DLL_DEBUG_INFO構造体はDLLの配置された位置をlpBaseOfDllに格納して返しますが、DLL内の個々の関数の位置は返しません。 このままだとDirect3DCreate9の位置がわからないので、ここで少しトリッキーな技を使います。 と言っても中身は単純で、自分でd3d9.dllをロードしてDirect3DCreate9の位置を取得し、d3d9.dllのロード位置からの差分を得ようというものです。 以下は、DLL名と関数名を与えるとDLLのロード位置からの差分を取得する関数です。 dxdsで用いたものから、エラー処理などを抜いて簡略化しています。 a2uはANSIからUnicode化する自作の手抜き関数なのであまり気にしないでください。 //DLL内における関数の相対位置を取得 LONG_PTR GetFunctionRelAddr(LPCSTR dllname,LPCSTR funcname){ LONG_PTR func; HMODULE hDll; MEMORY_BASIC_INFORMATION mbi; hDll = LoadLibrary(a2u(dllname)); func = (LONG_PTR)GetProcAddress(hDll, funcname); VirtualQuery((LPVOID)func, &mbi, sizeof (mbi)); func -= (LONG_PTR)mbi.AllocationBase; FreeLibrary (hDll); return func; }上記コードでは、まずLoadLibrary(MSDN)でDLLを自分でロードし、さらにGetProcAddress(MSDN)で関数の位置を取得しています。 次に、このDLLの先頭位置を取得するためにVirtualQuery(MSDN)を用います。 これは余り使用する機会のないAPIなので機能を説明すると、対象アドレスに関するメモリの情報を取得できるというものです。 このAPIが返すMEMORY_BASIC_INFORMATION構造体(MSDN)のAllocationBaseメンバにDLLのロード位置が格納されます(正確にはこのDLL用に割り当てられたメモリ領域の先頭位置)。 よって、GetProcAddressで得た関数のアドレスと、このDLLのロード位置の差分を取ることが出来ます。 この関数で得た値を、DLLロードイベントで得られた情報のうち、lpBaseOfDllに加算すると、デバッギにおけるDLL内関数のアドレスが取得できます。 そこでこのアドレスに前回の要領でブレークポイントを設定し、実際に呼ばれるのを待ちましょう。 3. DLL呼び出し時の処理と返り値の取得 さて、上記処理で設定したDirect3DCreate9呼び出しのブレークポイントで無事ブレークしたとします。 次に行いたいことは、この関数の返り値であるIDirect3D9*の値の取得です。 ブレークした時点ではまだ関数は実行されていないので、次に実行後の位置にブレークポイントを張る必要があります。 とはいえ、Direct3DCreate9関数内のリターン処理にブレークポイントを張るのは無理でしょう。 この関数のサイズがわかるわけでもなく、この関数内のどこにリターン文があるかもわかりません。 ここでも小技を使って、関数の実行後にブレークポイントを張ります。 Direct3DCreate9の中身自体はわかりませんが、関数が終わって次に実行される処理はわかります。 すなわち、スタックに入っている関数のリターンアドレスです。 ここにブレークポイントを張れば、関数の実行直後にブレークします。 (このあたり、理解にはC言語における関数呼び出しの実装に関する知識が必要です。必要に応じて呼出規約やコールスタック(いずれもWikipedia)などを参照してください。) この処理は非常に単純です。以下にdxdsの該当部分を記載します。 //スタックからリターンアドレス取得 ReadProcessMemory(hDP,(LPVOID)(LONG_PTR)con.Esp,&ret_addr,sizeof(DWORD),NULL); //Direct3DCreate9の呼び出し終了時のブレークポイント設定 MakeBreakPoint(hDP,pde,&_bp,ret_addr); 前回説明したブレークポイントの処理において、GetThreadContextを使ってレジスタの値を取得する処理を紹介しました。 そこで、まずReadProcessMemoryを使ってESPレジスタの示す位置から4byte読み出すだけです。 そこにリターンアドレス(ret_addr)が格納されています。あとはそこにブレークポイントを張るだけです。 そしたら処理を再開して、Direct3DCreate9の処理が終わるのを待ちましょう。 4. COM呼び出しのブレークポイント設定 さて、先の処理で仕込んだDirect3DCreate9のリターンアドレスでブレークしたとします。 まず、IDirect3D9*の値ですが、Windowsの場合関数の返り値はEAXレジスタに格納されています。 よって、GetThreadContextでレジスタ値を得ればIDirect3D9*の値は取得できます。 ここで得られるIDirect3D9*の値から、CreateDeviceメンバ関数の位置を取得しましょう。 前回示したd3d->CreateDevice(***)の呼び出しの処理ですが、コンパイラの設定によっては以下の様にコンパイルされます。 (途中の引数処理省略) mov eax,dword ptr [d3d (647AF0h)] // eax = d3d mov ecx,dword ptr [eax] // ecx = *eax mov edx,dword ptr [d3d (647AF0h)] // edx = d3d push edx // C++のメンバ関数呼び出しのthis(=d3d)設定 mov eax,dword ptr [ecx+40h] // eax = *(ecx + 0x40) call eax // eaxをコールつまり、IDirect3D9*型の変数に対し、そのポインタの指すアドレスの値を取得し、0x40を足したアドレスに関数のアドレスが格納されているわけです。 まとめると*(*d3d + 0x40)ですね。 dxdsの中では以下のようなコードを使用しています。(エラー処理などを消し、コメントを一部追加しています) これで、CreateDeviceにブレークポイントを張れます。 //Direct3DCreate9の返り値であるIDirect3D9*の値をEAXレジスタから取得 d3d_addr = con.Eax; //この返り値が指すアドレスの中身をd3d_vtbl_addrに取得 ReadProcessMemory(hDP,(LPVOID)d3d_addr,&d3d_vtbl_addr,sizeof(DWORD),NULL); //上記値に0x40を足したアドレスを参照し、関数のアドレスをd3d_func_addrに取得 ReadProcessMemory(hDP,(LPVOID)(0x40 + d3d_vtbl_addr),&d3d_func_addr,sizeof(DWORD),NULL); //得られたアドレスにブレークポイントを張ります。 MakeBreakPoint(hDP,pde,&_bp,d3d_func_addr); COMオブジェクトのメンバ関数のアドレス取得 これで目的であるCreateDevice関数へのブレークポイントは張れるわけですが、IDirect3D9*からCreateDeviceのアドレスを求める過程が気持ち悪いですね。いきなり0x40なんてマジックナンバーが出てきますし。 そこで、一応ここでその理由を説明しておきます。 まず、DirectXに出てくる諸オブジェクトはCOMオブジェクトです。 COMオブジェクトとは何か?についてはDirectX9 SDKにCOM オブジェクトとは(MSDN)に説明されていますが、正直これだとよくわかりません。 ただ、このページの最後を見ると「メソッドを呼び出すときは、C++ のメソッドへのポインタを呼び出すときと同様な構文を使う」と書いてあります。 よって、IDirect3D9*のCreateDeviceの呼び出しは、COMとか気にせず単にC++の呼び出し規約に則っていると考えてOKです。 よって、この章の以降の内容はC++のメンバ関数呼び出しを理解している人は読む必要がありません。 まず、SDKからIDirect3D9の定義を見てみます。(関係のない部分は省略。) DECLARE_INTERFACE_(IDirect3D9, IUnknown) { STDMETHOD(QueryInterface)(THIS_ REFIID riid, void** ppvObj) PURE; STDMETHOD_(ULONG,AddRef)(THIS) PURE; STDMETHOD_(ULONG,Release)(THIS) PURE; STDMETHOD(RegisterSoftwareDevice)(THIS_ void* pInitializeFunction) PURE; STDMETHOD_(UINT, GetAdapterCount)(THIS) PURE; STDMETHOD(GetAdapterIdentifier)(THIS_ UINT Adapter,DWORD Flags,D3DADAPTER_IDENTIFIER9* pIdentifier) PURE; STDMETHOD_(UINT, GetAdapterModeCount)(THIS_ UINT Adapter,D3DFORMAT Format) PURE; STDMETHOD(EnumAdapterModes)(THIS_ UINT Adapter,D3DFORMAT Format,UINT Mode,D3DDISPLAYMODE* pMode) PURE; STDMETHOD(GetAdapterDisplayMode)(THIS_ UINT Adapter,D3DDISPLAYMODE* pMode) PURE; STDMETHOD(CheckDeviceType)(...) PURE; STDMETHOD(CheckDeviceFormat)(...) PURE; STDMETHOD(CheckDeviceMultiSampleType)(...) PURE; STDMETHOD(CheckDepthStencilMatch)(...) PURE; STDMETHOD(CheckDeviceFormatConversion)(...) PURE; STDMETHOD(GetDeviceCaps)(...) PURE; STDMETHOD_(HMONITOR, GetAdapterMonitor)(THIS_ UINT Adapter) PURE; STDMETHOD(CreateDevice)(...) PURE; };マクロを使っていますが、DECLARE_INTERFACE_は単にstruct、STDMETHODは仮想関数をマクロでラップしているだけです。 と言う訳で、このクラスは全メンバ純粋仮想関数なので、抽象クラスとなります。 このクラスはメンバ変数を持たないのですが、sizeof(IDirect3D9)を取ると4byteと返ります。 なぜ0じゃないのかについて、メモリ上の構造を元に説明します。 以下の図はVisualC++でIDirect3D9をコンパイルしたときのメモリ構造です。 IDirect3D*は当然IDirect3Dを指すとして、IDirect3Dの4byteはvptrというデータを持っています。 このvptrはコンパイラが内部で生成する(プログラム作成者に見えない)vtblというデータの位置を示します。 vtblには、このクラスが持つ仮想関数のアドレス一覧を格納しています。 このとき、上記IDirect3D9の定義を見ると、CreateDeviceは17個目のメンバ関数です。 なので、1関数あたり4byteで配置すると、17関数目のものは0x40に格納されます。 よって、IDirect3D9*の変数d3dに対し、*d3dでvptrの場所を取得し、*(vptr+0x40)で無事CreateDeviceの位置がわかりました。 5. CreateDevice呼び出し時の処理 これで、ようやくCreateDeviceの呼び出しをブレークすることが出来ました。 元々の目的はここの第1引数を書き換えることです。 ここまでの知識で引数の書き換えに必要なものは揃っていますので簡単に説明。 まず、第1引数がどこにあるのかを知る必要があります。 前回出したCreateDeviceの逆アセンブル結果を見てみます。 push offset d3ddev (644AB8h) # &d3ddev push edi # &PP push 40h # D3DCREATE_HARDWARE_VERTEXPROCESSING push esi # hwnd push 1 # D3DDEVTYPE_HAL push 0 # D3DADAPTER_DEFAULT。これがスクリーン指定。 push eax # this = d3d call edxWindows環境(32bitに限る)では、スタックに引数が逆順に積まれていることがわかります。 目的の第1引数はpush 0の部分で、その後C++のthisに相当するオブジェクトのポインタ自体がpushされ、callされています。 よって関数呼び出しの直後、ESPの示す場所にリターンアドレス、4バイト後ろにthis、もう4バイト後ろに第1引数があることになります。 以下はdxdsのコードです。esp+8バイトの位置の値を書き換えることで第1引数の書き換えを完了しました。 ReadProcessMemory(hDP,(LPCVOID)(esp+0x8) ,&TmpAdapter,sizeof(UINT),&len); //この間でTmpAdapterに格納された値をいじる WriteProcessMemory(hDP,(LPVOID)(esp+0x8) ,&TmpAdapter,sizeof(UINT),&len); これでようやく目的のCreateDevice第1引数書き換えが終了です。 何度もブレークを張りましたが、なんとかたどり着きましたね。 6. NTDLL.DLLの注意点 実際にここまでのコードでCreateDeviceの引数を書き換えようとすると、妙なことに気がつきます。 前回もちょろっと触れたように、ntdll.dllがなぜかブレークしてくることです。 調べたところ、ntdll.dll内のDbgBreakPointという関数がブレークを発生させます。 前回述べたように、見知らぬブレークポイントは無視してやればよいのですが、とはいえ全部無視というのもなんだか怖いです。 本当にプログラマのミスで変な位置にブレークポイントを仕込んでしまっているだけの可能性もありますしね。 そこで、DbgBreakPointからの呼び出しだけちゃんと見て無視することにしましょう。 これはここまでの知識で解決可能です。 まず、DbgBreakPointの位置は、d3d9.dllからDirect3DCreate9の位置を求めたのと同じように、ntdll.dllをロードしてDbgBreakPointをGetProcAddressしてやればよいです。 下記の通りntdll.dllを見ると、DbgBreakPointは最初のコードがint 03になります。よって、GetProcAddressで得たアドレス=int 3でブレークするアドレスです。 CC int 03 C3 ret これでこのアドレスだけ意図的に無視してやるようにすれば、ntdll.dllの余計なブレークを気にする必要がなくなります。 (なんでntdll.dllがDbgBreakPointでわざわざブレークするのかは不明です。ご存知の方教えてください…) 7. まとめ 前回はデバッグ系APIを使ってブレークポイントの処理を説明しました。 今回は、dxdsを題材に、DLL内関数でのブレークやCOMのメソッド呼び出しのブレークを行い、引数の書き換えを行う部分まで説明しました。 ここまでの知識を応用すれば、既存のアプリケーションの処理を色々いじれるようになりそうですね(^^; 参考資料 [1] プロセスデバッガを作ってみる(CodeZine) |
09/10/24 | デバッグ系APIでアプリ動作を操作してみる編−1(ブレークポイント編)[★★★★◎◎] |
VisualStudioなどの開発環境に含まれているデバッガを利用すると、他のプログラムの動作を途中で止めたりメモリや実行位置などの変更が出来ます。 WindowsのデバッガAPIを用いることで、自前のプログラムからでも似たような処理が行えます。 今回は、実際に自分のコードから他のプログラムにブレークポイントを張ってみたいと思います。 1. はじめに 2. デバッガの基本処理 3. ブレークポイントの仕込み 4. ブレークポイント到達時の処理 5. ブレークポイント処理の注意点 参考資料 1. はじめに 導入なのでこの章は飛ばしても問題ありません(^^; 先日、ミニソフトその61−DirectX9 強制セカンダリディスプレイ表示ソフト dxdsというソフトをアップロードしました。 元々このソフトを作成したのは、フルスクリーンがプライマリディスプレイに固定されるゲームをどうにかセカンダリディスプレイに表示させたいと思ったのがきっかけです。 DirectX9では、フルスクリーン時の表示先スクリーンはIDirect3D9オブジェクトのCreateDevice(MSDN)メソッドの第1引数の値で決まります。 開発側が特にマルチディスプレイ環境を意識せずにゲームを作った場合、この値は大抵デフォルトの0にしてしまいます。 この呼び出し部分を探し出して引数を書き換えられれば、表示先ディスプレイを変えることが出来ます。 この処理を、任意のゲームに対して行えないかと試みました。 まずは、ゲームのバイナリを逆アセンブルしてCreateDevice呼び出し時の引数を変えることにチャレンジしてみました。 以下の例は非常に単純です。push 0の部分をpush 1に書き換えればそれだけでOKです。 ↓C++のコード d3d->CreateDevice(D3DADAPTER_DEFAULT,D3DDEVTYPE_HAL, hwnd,D3DCREATE_HARDWARE_VERTEXPROCESSING,&PP,&d3ddev) ↓上記メソッド呼び出しを逆アセンブル push offset d3ddev (644AB8h) # &d3ddev push edi # &PP push 40h # D3DCREATE_HARDWARE_VERTEXPROCESSING push esi # hwnd push 1 # D3DDEVTYPE_HAL push 0 # D3DADAPTER_DEFAULT。これがスクリーン指定。 push eax # this = d3d call edx ただ、ゲームによってはそう単純に行かないこともありました。たとえばこんなパターンです。 xor eax, eax # eax=0になる (この間でeaxを色々使う) push eax # スクリーン指定 push ecx call edx このコードは、最初のxor文によって途中eaxが0であることを前提としており、xorの部分を書き換えてmov eax, 1などとした場合、コードの挙動が変わってゲームがうまく動きませんでした。 また、push eaxを書き換えてpush 1にしたくてもpush eaxのオペコードは1byte、push 1は2byteでうまく行きません。 このように、元のコードにより生成されるバイナリに癖があるため、逆アセンブルでどのゲームにも対応する方法を作るのは大変です。 そもそも、これらを自動化しようとした場合、コードを逆アセンブルしてCreateDevice呼び出し位置を調べること自体かなり面倒です。 そこで、「だったら動作中にCreateDevice呼び出しにブレークポイント張って、引数書き換えれば良いんじゃない?」というのが今回のテーマであるデバッグ系APIに取り組んだきっかけです。 ちなみに、最初ネット上の資料を探したところ、「デバッグ系APIを使うとこんなことが出来るよ」とか「ブレークポイントをトラップできるよ」という話はいくつか見つかりました。 ただ、実際ブレークポイントを張る方法や、ブレークポイント時に行うべき処理について説明されたページが(少なくとも日本語では)見つかりませんでした。 そんなわけで、自分でブレークポイントの処理方法を調べてみました。 今回は、デバッグ系APIの基本と、ブレークポイントの処理までを話題にします。 具体的にCreateDeviceをフックして引数を書き換える部分はまた次回。 2. デバッガの基本処理 Windowsにおける基本的なデバッガ動作の基本的な流れは以下の様になります。 Visual Studioでも同じようなものですね。 デバッグ対象のプログラム(デバッギ)を開始、または既存のプロセスをアタッチしてデバッギにする。 ↓ デバッグイベントを待つ ↓ イベントが発生したらプログラムは一時停止、デバッガでメモリを読み書きしたりブレークポイントをいじったりする ↓ デバッギのプログラムを再開 ↓ またイベントを待つ(以下ループ) 上記の処理を1つずつ見ていきます。 Creating a Basic Debugger(MSDN)も見ていくと良いかも知れません。 まず、デバッギを1から起動する場合ですが、これはCreateProcessでプロセスを立ち上げる際、第6引数にDEBUG_PROCESSを付加すればOKです。 既存のプロセスをデバッギにしたい場合は、DebugActiveProcess(プロセスID)を実行するとデバッグ可能になります。 次に、デバッグイベントが発生するのを待つためにWaitForDebugEventAPIを実行します。 この関数を実行すると、イベントが発生するまで待機し、いざイベント発生時にはイベント内容を構造体に詰めて返してくれます。 ちなみに、具体的なイベントの内容は以下の通りです。 大概のイベントはデバッガが何もしなくても発生します。デバッガが影響するのはブレークポイントやシングルステップ実行ぐらいでしょうか。
WaitForDebugEventはDEBUG_EVENT構造体(MSDN)にイベントの状況を格納して返すので、まずはこの情報を用いて色々な操作を行うことが出来ます。 これにはContinueDebugEventAPIを実行すればよいです。 特に何もする必要がないイベントが発生したときは、すぐにContinueDebugEventしてしまえばよいです。 この後は、またWaitForDebugEventでイベント発生を待ちます。 下記は、MSDNの内容からコメントなどを削除して簡潔にしたものです。みごとにループ処理ですね。 void EnterDebugLoop(const LPDEBUG_EVENT DebugEv){ DWORD dwContinueStatus = DBG_CONTINUE; // exception continuation for(;;) { WaitForDebugEvent(DebugEv, INFINITE); switch (DebugEv->dwDebugEventCode) { case EXCEPTION_DEBUG_EVENT: break; case CREATE_THREAD_DEBUG_EVENT: break; (イベントごとにcase文。中略) } ContinueDebugEvent(DebugEv->dwProcessId, DebugEv->dwThreadId, dwContinueStatus); } } ContinueDebugEventには、処理の継続方法を指定できます。 大概は、DBG_CONTINUEを指定しておけば問題ありません。 ただし、イベント内容が例外イベント(EXCEPTION_DEBUG_EVENT)であり、かつ自分で仕込んだ覚えのないイベントが発生した場合は、DBG_EXCEPTION_NOT_HANDLEDを指定する必要があります。(例えば、勝手にデバッギがアクセス保護違反した場合など。) その場合Windowsが適切な処理を行います。 自分でブレークポイントなどを仕込んで、それに対する処理を行った場合は、DBG_CONTINUEで大丈夫です。 なお、CreateProcessでデバッギを起動した場合、一番最初のWaitForDebugEventでは、まずプロセスの生成イベントが発生します。 なので、起動時にブレークポイント等を仕込みたい場合はこのタイミングでそれが出来ます。 既存のプロセスにアタッチする場合は、WaitForDebugEventを呼ぶ前に仕込めばよいでしょう。 3. ブレークポイントの仕込み ブレークポイントは、デバッガが自分で手を加えないと発生しません。 ここではブレークポイントの仕込み方を説明していきます。 IntelのCPUはハードウェア的にブレークポイントを設定する機能を持ちますが、こちらは利用しません。 x86の命令セットの中には、ブレークポイント用のInt 03呼び出し命令がありますので、こちらを利用します。 Int 03によるCPU例外が発生すると、WindowsがそれをトラップしてWaitForDebugEventでまつプロセスに制御が渡ります。 この命令をデバッギのプログラムに仕込めば、ブレークポイントに仕込みが完了です。 このInt 03呼び出しのオペコードは、0xCCの1バイトです。 そこで、デバッギのメモリを1バイト書き換えます。 この際、いくつかの注意点があります。自分の環境では3番目はやり忘れても問題なかったです(Intel系アーキテクチャなら問題ない?)が、一応やっておいたほうがいいでしょう。
BreakPoint構造体のaddrに対象のアドレス、protectに処理前のメモリ保護属性、codeにInt 03書き換え前のコードが入ります。 struct BreakPoint { LONG_PTR addr; DWORD protect; BYTE code; }; /* ブレークポイントの作成 */ void MakeBreakPoint(HANDLE hDP,DEBUG_EVENT* pde,BreakPoint *pbp,LONG_PTR addr){ BYTE int3 = 0xCC; //int3の命令 DeleteBreakPoint(hDP, pde, pbp); //念のため以前のブレークポイント設定を削除 pbp->addr = addr; /* 読み書き可能にしてから書き込む */ VirtualProtectEx(hDP, (LPVOID)addr, 1, PAGE_EXECUTE_READWRITE, &pbp->protect); /* 元の命令列を保存してからint3を書く */ ReadProcessMemory(hDP, (LPVOID)addr, &pbp->code, 1, NULL); WriteProcessMemory(hDP, (LPVOID)addr, &int3, 1, NULL); /* キャッシュのflush */ FlushInstructionCache(hDP, (LPVOID)pbp->addr ,1); }終わったらContinueDebugEvent後にWaitForDebugEventを実行し、ブレークポイントが引っかかるのを待ちましょう。 (2009/11補足) CPUの機能を用いたブレークポイントの設定を説明されているページがありましたので補足します。 x86の場合、CPUの機能によるブレークポイントは4箇所まで設定できるので、それ以下の数で十分であればこちらを使うとよいかも知れません。 http://d.hatena.ne.jp/Hossy/20071120 4. ブレークポイント到達時の処理 さて、いざブレークポイントに到達したときにどうするかを説明します。 大まかに、以下の作業を行います。
今回のdxdsの場合には、CreateDevice関数呼び出し時の引数の書き換えをやったりしています。(この詳細は次回。) 後ろ2つについて説明します。 CPUがint03による例外を実行すると、int03を実行したスレッドのEIPは、int03の次のバイトをさした状態になります。 このまま処理を継続すると、元々あった1バイト分のオペコードが実行されずに進んでしまうため不具合が発生します。 なので、3.でint03に書き換えた命令を書き戻し、かつ命令ポインタも1バイト戻すことで、正しくプログラムを再開することが出来ます。 dxdsの中では以下のような処理を行っています。 まずDeleteBreakPoint関数を呼び出していますが、これはブレークポイントを張ったときに保存しておいた命令1バイト及びメモリ保護属性を元に戻しています。 スレッドのレジスタの取得・書き換えには、スレッドハンドルに対しGetThreadContext/SetThreadContext APIを実行することで行うことが出来ます。 下記の例ではEIPをデクリメントしています。 /* ブレークポイント中の処理抜粋 */ DeleteBreakPoint(hDP,pde,&_bp); hThread = OpenThread(THREAD_GET_CONTEXT | THREAD_SET_CONTEXT, false, pde->dwThreadId); if(hThread) { con.ContextFlags = CONTEXT_FULL; GetThreadContext(hThread, &con); //EIPを1バイト巻き戻す con.ContextFlags = CONTEXT_FULL; con.Eip--; SetThreadContext(hThread, &con); CloseHandle(hThread); } /* ブレークポイントの削除 */ void DeleteBreakPoint(HANDLE hDP,DEBUG_EVENT* pde,BreakPoint *pbp){ DWORD tmp; if(!pbp->addr) return; /* 元のデータで書き戻す */ WriteProcessMemory(hDP,(LPVOID)pbp->addr,&pbp->code,1,NULL); VirtualProtectEx(hDP,(LPVOID)pbp->addr,1,pbp->protect,&tmp); /* キャッシュのflush */ FlushInstructionCache(hDP,(LPVOID)pbp->addr,1); pbp->addr = 0; } これらの処理が終わったら、ContinueDebugEventにDEBUG_CONTINUEを渡してデバッギを再開すればよいです。 5. ブレークポイント処理の注意点 WaitForDebugEventでブレークポイント到達を待っていると、デバッギ起動時に挿入した覚えのない位置でブレークします。 (すいません、環境依存かも知れませんが公式資料などでは確認できていません。WinXP SP3で発生しました。) どうもNTDLL.DLL内に元々ブレークポイントが設定されているらしく、DEBUG_PROCESSをつけてCreateProcessを実行するとここでブレークするようです。 この場合、EIPのデクリメントやオペコードの書き戻しは必要なく、そのままContinueDebugEventにDBG_CONTINUEを渡してデバッギを再開してしまって問題ありません。 「どうやってブレークポイントがNTDLL.DLL内か判定するんだ?」という問題がありますが、それはまた次回。 自分で入れた覚えのないブレークポイントはこのように無視してもとりあえず問題はないでしょう。 6. まとめ 今回は、デバッグ系APIを使ったデバッグの簡単な流れと、ブレークポイントの処理を説明しました。 次回はdxdsでやっているように、DLL呼び出しやCOM呼び出しにブレークポイントを張り、監視をしたり関数の引数を書き換えたりしてみます。 参考資料 [1] Creating a Basic Debugger(MSDN) [2] Ralf Brown's Interrupt List Int 03 |
08/11/20 | コンパイラ作ってみるかも編−6(構文解析前の下準備)[★★★◎◎◎] |
ようやくC++で本格的なコンパイラを作ります。 作成したものはプログラム実験部屋のその5−JBCompilerにおいてあります。 1. はじめに 2. 変数宣言を考える 3. 関数内部を考える 4. 文を考える 5. 式を考える 6. BNFまとめ 7. プログラムのデータ構造 8. まとめ 1. はじめに コンパイラ作ってみるかも編−その1で始めたコンパイラ作成ですが、その5でようやくflexを使って実際に字句解析を行いました。 次は構文解析…と行きたいところですが、ここでは一歩立ち止まってどんな言語にしたいか、また構文解析の結果をどうプログラムで処理するかを考えておきたいと思います。 さて、まずは構文解析を行う前に、どんな文法にするかを考えておきます。 既存の言語を参考にするなら、CっぽいのとかPerlっぽいのとか色々あります。 もちろんかつて無いトンデモ言語を作ってもいいでしょう。 ここでは、こんなポリシーで作成してみます。
なお、参考までにJBCompilerで使用したbison用ファイルをサンプルに説明していきます。 サンプルファイル(別ウインドウ) 詳細は今後解説しますが、BNFの雰囲気はわかるかも知れません。 まずプログラム全体は、シンプルに変数と関数の一覧からなる、とします。 ここで言う変数は関数の外側で定義しているので、当然グローバル変数です。VBもこんな感じですよね。 [プログラム]:[変数宣言一覧] [関数一覧] さて、ここから変数宣言部と関数部を見ていきます。 2. 変数宣言を考える さて、まずは変数宣言の構文を考えて行きます。 変数宣言の構文は、グローバル変数の定義部でもローカル変数の定義部でも利用できます。 以下では、一部正規表現を取り入れ、bison側の表記とちょっと違う表記をしています。 変数宣言とは1行分の変数宣言行が0個以上(*は正規表現で使われる記号です)からなる、ということを指しています。 [変数宣言一覧]:[変数宣言行]* 1行分の変数宣言は、以下のようにしました。 VBでは、1行で複数の型の変数を定義できますが、ここでは文法を単純にするため、1行で1種類までにしました。 VBではdim a,b,c as Integerと書くとcだけInteger型になり、a,bはVariant型になりましたが、ここではこのような記述でa,b,cいずれもIntegerになるようにします。 どちらかというとCやJavaに近いですね。 [変数宣言行]:DIM [変数一覧] AS [型] 改行 変数一覧は、上に書いたとおり変数名をカンマで区切るだけ。 ここの[変数ID]とは、変数名に配列を示すカッコがついたものです。 [変数一覧]:[変数ID] ( カンマ [変数ID] )* 若干難しいのが配列の扱い。今回作成するのは、VB風とC風両方の配列記述を受け付けるようにします。 [変数ID]:変数名 [配列一覧] [配列一覧]:[配列定義形式1] | [配列定義形式2] | 空文字 [配列定義形式1]:(左中カッコ 整数? 右中カッコ)+ [配列定義形式2]:左カッコ 整数? ( カンマ 整数? ) 右カッコ 配列形式1はC言語風です。整数に正規表現で言う?がついていますが、整数はあってもなくてもかまいません。 この場合この配列は動的配列になります。 結果、[変数ID]はこんな表記を受け付けるようになります。 a 配列なし b[10] C言語風 c[] C言語風動的配列 d(2,3,4) VB風3次元配列 e[2][][3][] C言語風2箇所動的配列を含む4次元配列 f(2,,3,) 上をVB風に表記 最後に、上で[型]が出てきますが、今回作成するのは前述のとおり非常に単純な言語なので、こんな感じで。 配列は[変数ID]側に含んでいるのでこちらには不要。 [型]:int | float | string 3. 関数内部を考える さて、ようやく関数内部のBNFを考えていきます。まず関数の全体の枠を決めてしまいましょう。 [プログラム]:[変数宣言一覧] [関数一覧] [関数一覧]:[関数]* [関数]:[返り値なし関数ヘッダ] [変数宣言一覧] [文一覧] [返り値なし関数フッタ] | [返り値あり関数ヘッダ] [変数宣言一覧] [文一覧] [返り値あり関数フッタ] 関数はヘッダとフッタで囲まれていて、変数宣言と文がずらっと並んでいます。 今回の言語では簡略化のために、変数宣言は関数の先頭に集めています。C++とは違いますね。 返り値あり/なしで分けていますが、これらはVBではキーワードSubとFunctionで区別されているため分けています。 先にこちらを片付けてしまいましょう。 返り値があるばあい、ヘッダの最後に"As 型名"がつきますが、それ以外は一緒です。 [返り値なし関数ヘッダ]:Sub 関数名 左カッコ [引数一覧] 右カッコ 改行 [返り値なし関数フッタ]:End Sub 改行 [返り値あり関数ヘッダ]:Function 関数名 左カッコ [引数一覧] 右カッコ As [型] [配列一覧] [返り値あり関数フッタ]:End Function 改行 引数一覧は、変数定義の部分と似ていますが1つ1つに型名がつくあたりが異なります。 [引数一覧]:[引数] ( カンマ [引数] )* [引数]:変数名 [配列一覧] As [型] 変数宣言はグローバル変数と同じなので省略。 さて、ようやく実際に処理を行うプログラム本体です。 4. 文を考える [文]というのがいわゆる一かたまりの処理に相当します。 文の種類はそれほど多くなく、if・for・do・exitと言った制御構造を示す文か、関数呼び出し・代入文しかありません。 [文一覧]:( [文] 改行 )* [文]:[関数呼び出し文] | [代入文] | [exit文] | [if文] | [for文] | [do文] 以下1つずつ見ていきましょう。 まずは関数呼び出しです。なお、今回作成する言語では動的配列のサイズを指定するRedim文もここに含んでいます。 ここで、上記の配列定義方式のように配列の参照方法を2種類用意しておきます。 形式1はC言語風、形式2はVB風です。 関数の引数は、VBの配列風の表記をするので、そちらのみ受け入れます。Redimはどちらも可能です。 [式]については後述ですが、要は数字や文字と言った値をとるものです。 [関数呼び出し文] :Call 関数名 [配列形式2]? | Redim 変数名 [配列形式] [配列形式] :[配列形式1] | [配列形式2] [配列形式1] : (左中カッコ [式] 右中カッコ)+ [配列形式2]:左カッコ [式] ( カンマ [式] )* 右カッコ 代入文はよくある「変数=値」の形式です。 ただ、欲をだしてC言語風の複合代入演算子も受け入れます。 代入文の左辺は配列も受け入れられるようにしておきます。 [代入文] :[変数] [代入演算子] [式] [変数] :変数名 | 変数名 [配列形式1] | 変数名 [配列形式2] [代入演算子] : = | += | -= | *= | /= | |= | &= | <<= | >>= ||| [exit文]は関数やループを抜ける文です。VB風の表記とC風の表記両対応。 微妙に違うのが、関数の返り値はexit functionのところで表記することです。 [exit文] :[ループ脱出文] | [関数脱出文] [ループ脱出文] :Exit for | Exit Do | Continue [関数脱出文] :Exit sub | Exit Function [式] | Return | Return [式] [if文]は若干ややこしいです。else ifやelseがあったりなかったりしますし。 [単文if文]というのは1行で書けるend ifを含まない形式です。こちらはelse節を取れないようにしています。 [複文if文]、任意の数のelseif節をつけることができ、また最後にelse節をつけることも出来ます。 [if文] :[単文if文] | [複文if文] [単文if文] : If [式] Then [文] [複文if文] : If [式] Then 改行 [文一覧] [elseif節]* [else節]? End if [elseif節] : Elseif [式] Then 改行 [文一覧] [else節] : Else 改行 [文一覧] [for文]は割と単純です。 forの行で始まって、最後はnextで終わり、間に文がいくつか入ります。stepはあってもなくても良いですね。 [for文] :For 変数名 = [式] To [式] ( Step [式] )? 改行 [文一覧] Next [do文]は先頭及び最後にwhileまたはuntilによる条件をつけることが出来ます。 [do文] :Do [while節] 改行 [文一覧] Loop [while節] [while節] : While [式] | Until [式] | 空文字 5. 式を考える さて、ここまで主に制御構造を記述する[文]の構成要素を扱いました。 次は数値や文字列と言った値を記述する[式]についてみていきます。 [式]の種類は以下に示すとおり、それほど多くありません。 [式] :[変数・関数] | [定数] | [カッコ式] | [演算] | [型変換] 変数・関数が一緒くたになっていますが、VBの文法だと関数呼び出しと配列の参照の構文は同じですよね。 関数か変数かの判断はまた後で行うとして、文法的には一緒にまとめておきます。 これらは、[文]で出てきた関数呼び出しとほぼ同じです。callがないだけ。 [変数・関数] : 変数・関数名 [配列形式]? [定数]はそのまま、数値や文字列です。 [定数] : 整数値 | 小数値 | 文字列 [カッコ式]は単に式をカッコで囲うだけのものです。 これらは演算の順序を指定するのに使いますよね。 [カッコ式] : 左カッコ [式] 右カッコ [演算]はいわゆる計算式です。優先順位の話はとりあえずここではおいておきます。 演算子の種類はいろいろありますが、大きく二項演算子と単項演算子があります。 なお、ビット演算子やブール演算子は"&&"と"and"の様にC言語風の記号列とVB風の文字列どちらも利用できます。 ただ、これらは前回のflexの段階で同一の演算子にまとめているので、下記ではまとめてC言語風表記のみ掲載。 [演算] :[二項演算] | [単項演算] [二項演算] : [式] [二項演算子] [式] [単項演算] : [単項演算子] [式] [二項演算子] : [四則演算子] | [シフト演算子] | [ビット演算子] | [ブール演算子] | [比較演算子] [四則演算子] : + | - | * | / | % [シフト演算子] : << | >> [ビット演算子] : "|" | & | ^ [ブール演算子] : "||" | && | ^^ [比較演算子] : == | != | < | <= | > | >= [単項演算子] : - | ! | ~ [型変換]はキャスト処理を行います。 変換先の[型]は既に変数定義のところで説明しました。 [型変換] : 左カッコ [型] 右括弧 [式]ここまでで文法はおしまい。 6. BNFまとめ 今回考えた構文を以下に列挙します。少し関係がわかりやすいようにインデントしています。 [プログラム]:[変数宣言一覧] [関数一覧] [変数宣言一覧]:[変数宣言行]* [変数宣言行]:DIM [変数一覧] AS [型] 改行 [変数一覧]:[変数ID] ( カンマ [変数ID] )* [変数ID]:変数名 [配列一覧] [配列一覧]:[配列定義形式1] | [配列定義形式2] | 空文字 [配列定義形式1]:(左中カッコ 整数? 右中カッコ)+ [配列定義形式2]:左カッコ 整数? ( カンマ 整数? ) 右カッコ [型]:int | float | string [関数一覧]:[関数]* [関数]:[返り値なし関数ヘッダ] [変数宣言一覧] [文一覧] [返り値なし関数フッタ] | [返り値あり関数ヘッダ] [変数宣言一覧] [文一覧] [返り値あり関数フッタ] [返り値なし関数ヘッダ]:Sub 関数名 左カッコ [引数一覧] 右カッコ 改行 [返り値なし関数フッタ]:End Sub 改行 [返り値あり関数ヘッダ]:Function 関数名 左カッコ [引数一覧] 右カッコ As [型] [配列一覧] [返り値あり関数フッタ]:End Function 改行 [引数一覧]:[引数] ( カンマ [引数] )* [引数]:変数名 [配列一覧] As [型] [文一覧]:( [文] 改行 )* [文]:[関数呼び出し文] | [代入文] | [exit文] | [if文] | [for文] | [do文] [関数呼び出し文] :Call 関数名 [配列形式2]? | Redim 変数名 [配列形式] [配列形式] :[配列形式1] | [配列形式2] [配列形式1] : (左中カッコ [式] 右中カッコ)+ [配列形式2]:左カッコ [式] ( カンマ [式] )* 右カッコ [代入文] :[変数] [代入演算子] [式] [変数] :変数名 | 変数名 [配列形式1] | 変数名 [配列形式2] [代入演算子] : = | += | -= | *= | /= | |= | &= | <<= | >>= ||| [exit文] :[ループ脱出文] | [関数脱出文] [ループ脱出文] :Exit for | Exit Do | Continue [関数脱出文] :Exit sub | Exit Function [式] | Return | Return [式] [if文] :[単文if文] | [複文if文] [単文if文] : If [式] Then [文] [複文if文] : If [式] Then 改行 [文一覧] [elseif節]* [else節]? End if [elseif節] : Elseif [式] Then 改行 [文一覧] [else節] : Else 改行 [文一覧] [for文] :For 変数名 = [式] To [式] ( Step [式] )? 改行 [文一覧] Next [do文] :Do [while節] 改行 [文一覧] Loop [while節] [while節] : While [式] | Until [式] | 空文字 [式] :[変数・関数] | [定数] | [カッコ式] | [演算] | [型変換] [変数・関数] : 変数・関数名 [配列形式]? [定数] : 整数値 | 小数値 | 文字列 [カッコ式] : 左カッコ [式] 右カッコ [演算] :[二項演算] | [単項演算] [二項演算] : [式] [二項演算子] [式] [二項演算子] : [四則演算子] | [シフト演算子] | [ビット演算子] | [ブール演算子] | [比較演算子] [四則演算子] : + | - | * | / | % [シフト演算子] : << | >> [ビット演算子] : "|" | & | ^ [ブール演算子] : "||" | && | ^^ [比較演算子] : == | != | < | <= | > | >= [単項演算] : [単項演算子] [式] [単項演算子] : - | ! | ~ [型変換] : 左カッコ [型] 右括弧 [式] ほんとにこれでプログラムの構文を網羅しているかが気になります。 実際に、今回作成するコンパイラでコンパイルできるプログラムについて、上記のBNFにマッチするか見てみます。 ↓このファイルは、フィボナッチ数列の先頭10個ほどを表示する簡単なプログラムです。 たかが14行のプログラムですが、構文解析の木構造はえらいことになっていますね。 とはいえ、ちゃんとBNFでプログラムを表現できていることがわかります。 構文解析例(別ウインドウ) 7. プログラムのデータ構造 さて、上記のとおりプログラムのBNFは定義できましたが、コンパイルするには一旦上記構造をコンパイラ内に格納する必要があります。 そのためには、当然上記構造を網羅できるデータ構造が必要です。 と言う訳で、ここではコンパイラで用いるデータ構造を記述します。 まずは、変数定義について。1つの変数につき、以下の構造体で表現します。 変数名、型、ローカル・グローバル判定とそれほど情報量は多くありません。 struct Variable { string name; //変数名 Type type; //変数の型 bool local; //ローカル変数かグローバル変数か? int lineno; //デバッグ用の行番号 //初期値 int ival; float fval; string sval; }; 上で型が出てきたので、型の表現もしておきます。 基本となる型は整数で管理します。 あとは配列の次元を管理するためのint型vectorです。 このvectorは各次元における要素数を格納します。動的配列は-1を入れておくことで表現します。 struct Type { int type; //T_INTかT_VOIDかT_FLOATかT_STRING vector 次に[式]です。 [式]は上記BNFで出たとおり何種類かの記述方法があります。 そこでその内容を整数でexprtypeに入れます。 補助情報をtypeに入れます。たとえばexprtypeには[二項演算]を示す値をいれ、typeには加算を示す値を入れるとか。 なお、関数呼び出しや演算では、BNFが示すとおり内部で複数の[式]を持ちます。 これらは、[式]のvectorであるelistに格納します。 なお、後で[式]の値や型を使うことがあるので、それらも保持できるようにしておきます。 typedef vector そして[文]です。 stmttypeとsubtypeに[文]の種類を入れます。 各[文]は、内部に複数の[文]や[式]を持ちます。そこでそれらを格納できるよう、[文]・[式]のvectorを準備します。 typedef vector そして[関数]です。 関数は関数名と返り値の型、引数、ローカル変数定義、そして文一覧からなります。 typedef vector ここまで揃えば最後です。 プログラムの構造は最初に言ったとおり、グローバル変数の定義と、関数の一覧からなります。 typedef vector これらの構造を使うことで、構文解析したソースコードを格納できるようになります。 少し補足。ここでは、文や式の種類を整数値で分類しています。 ただ、オブジェクト指向系の言語であれば、デザインパターンで言うInterpreterパターンを使うことも出来ます。 今回の言語では、そこまでしていませんが… 8. まとめ 今回は、構文解析に入る前に、文法の肝でもあるBNF及びコンパイラ内でのデータ構造を考えてみました。 bisonの仕事は、flexが返すデータをBNF表現に合うよう解析し、上記データ構造に変換することです。 具体的なbisonの記述についてはまた今度。 |