EternalWindows
プロファイル API / メソッドのフック

アプリケーションの動作確認とは、単にエラーが発生しているかどうかを調べるだけでなく、 パフォーマンスやメモリの使用状況なども考慮するべきといえます。 ある状況に決まってパフォーマンスが悪くなるのであれば、 そのときに実行されたメソッドに問題がありそうですし、 アプリケーションを実行し続ければ続けるほどメモリの使用量が増えるのであれば、 メモリの開放が適切に行われていないことになり、問題が発生しているといえるからです。 こうした問題を解決するには、開発者がコードを1つずつ調べていくことも可能ですが、 アプリケーションの規模が大きくなるにつれて、それは困難に感じることになるでしょう。 幸いにもマネージアプリケーションはCLRの管理下で動作していますから、 プロファイル APIと呼ばれるAPIでCLRと通信することで、 外部アプリケーション(正確にはDLL)からプロファイリングを行うことが可能です。 プロファイリングとは、パフォーマンスやメモリの使用状況の確認することですが、 そうした処理を専用のアプリケーションで行うことによって、 問題のある部分を特定しやすくなります。

プロファイリングは、プロファイラと呼ばれるDLLを調査対象のプロセスにロードさせることで成立します。 このDLLはCOMのインプロセスサーバーであり、オブジェクトがICorProfilerCallback2を実装していれば、 イベントが発生した場合に適切なメソッドがCLRによって呼ばれます。 また、プロファイラからCLRに対してデータを要求することもでき、この場合はICorProfilerInfo2を使用します。 たとえば、ICorProfilerCallback2::AssemblyLoadStartedでAssemblyIDを受け取ったプロファイラは、 このIDからアセンブリのデータを取得したいと思うはずですが、そうしたときにICorProfilerInfo2を使用します。 データを取得したプロファイラはそれをファイルに保存しても構いませんし、 プロセス間通信でGUIクライアントにデータを送信してもよいでしょう。 DLLをプロセスにロードさせる方法については、後の節で説明します。

CLRがプロファイラに対して最初に呼び出すメソッドは、ICorProfilerCallback2::Initializeです。

STDMETHODIMP CProfilerCallback::Initialize(IUnknown *pICorProfilerInfoUnk)
{
	pICorProfilerInfoUnk->QueryInterface(IID_PPV_ARGS(&m_pProfilerInfo2));

	m_pProfilerInfo2->SetEventMask(COR_PRF_MONITOR_ENTERLEAVE | COR_PRF_ENABLE_FUNCTION_RETVAL | COR_PRF_ENABLE_FUNCTION_ARGS);

	m_pProfilerInfo2->SetEnterLeaveFunctionHooks2(FunctionEnterNaked2, NULL, NULL);
	
	InitializeCriticalSection(&g_cs);

	WriteLogFile(L"Initialize");
	
	g_pProfilerCallback = this;

	return S_OK;
}

まず、pICorProfilerInfoUnkからICorProfilerInfo2を照会しておきましょう。 これがなければ、プロファイラとして必要な情報を取得したり設定したりできなくなります。 SetEventMaskには、通知を受け取りたいイベントを示す定数を指定します。 つまり、CLRはあらゆるイベントの発生時に関連するメソッドを呼び出すのではなく、 ここに指定したイベントに関連するメソッドを呼び出すことになります。 COR_PRF_MONITOR_ENTERLEAVEを指定するということは、 関数が呼ばれたタイミングや関数から抜けるタイミングを特定したいということであり、 その際に呼び出して欲しい関数をSetEnterLeaveFunctionHooks2で登録します。 このメソッドの第1引数に指定した関数は、何らかの関数が呼ばれる際に呼ばれます。 SetEventMaskにはCOR_PRF_ENABLE_FUNCTION_RETVALやCOR_PRF_ENABLE_FUNCTION_ARGSも指定していますが、 これはSetEnterLeaveFunctionHooksではなく、SetEnterLeaveFunctionHooks2を指定する場合は必須です。 SetEnterLeaveFunctionHooksでも関数が呼ばれるタイミングは特定できますが、 関数のパラメータを取得できないなどの欠点があります。 InitializeCriticalSectionを呼び出してクリティカルセクションを初期化しているのは、 ファイルへ関数のログを保存する際にスレッドが同時にアクセスしないようにするためです。 ログを記録するのはWriteLogFileであり、第1引数に指定した文字列が書き込まれます。 クラスのポインタをグローバル変数に保存しているのは、コールバック関数からクラスのメソッドを呼び出すためです。

実際にどのようなログが保存されるのかを確認してみましょう。 次のようなコードのマネージアプリケーションがあったとします。

using System;
using System.Windows.Forms;

class class1
{
    public static long l;
    public int[] a = new int[2];
}

struct struct1
{
    public sbyte s;
    public char c;
}

static class Program
{
    static void Main()
    {
        short n = 5;
        int r = 10;
        string str = "abc";
        ShowParam(n, ref r, str);
        
        class1 cl = new class1();
        class1.l = 30;
        cl.a[0] = 8;
        cl.a[1] = 70;
        ShowClass(cl, 8);

        struct1 st = new struct1();
        st.c = 'k';
        st.s = 8;
        ShowStruct(st);

        object[] objects = new object[2] {"def", 8};
        ShowObject(objects);
    }

    static void ShowParam(short n, ref int r, string str)
    {
        MessageBox.Show(n.ToString() + str);
    }

    static void ShowClass(class1 cl,int k)
    {
        MessageBox.Show(cl.a.ToString() + cl.a[0].ToString()+k.ToString());
    }

    static void ShowStruct(struct1 st)
    {
        MessageBox.Show(st.c.ToString() + st.s.ToString());
    }

    static void ShowObject(object[] objects)
    {
        MessageBox.Show(objects[0].ToString() + objects[1].ToString());
    }
}

ShowParamやShowClassといったメソッドを呼び出していますが、 これはプロファイラがメソッドをフックできるようにするためであり、 メソッドの実装内容については特に意味がありません。 このマネージアプリケーションに対してプロファイリングを行った場合、 次のような情報がファイルとして出力されます。

Initialize
--------Enter Program.Main--------
void Main()
--------Enter Program.ShowParam--------
void ShowParam(short, ref int, string)
short = 5
ref int = 10
string = abc
--------Enter Program.ShowClass--------
void ShowClass(class1, int)
class1.int[] a = {8, 70}
int = 8
--------Enter Program.ShowStruct--------
void ShowStruct(struct1)
struct1.sbyte s = 8
struct1.char c = k
--------Enter Program.ShowObject--------
void ShowObject(object[])
object[] = {def, 8}

1つのメソッドが呼ばれた場合、Entet + メソッドの名前が書き込まれ、 その次にメソッドのプロトタイプが書き込まれます。 そしいて、メソッドが引数を持つ場合は、個々の引数のパラメータも書き込まれます。 このパラメータは、先のアプリケーションで指定した値と同一になっていることが分かるはずです。

既に述べたように、何らかの関数が呼ばれた場合はSetEnterLeaveFunctionHooksの第1引数が呼ばれます。 この関数のプロトタイプはFunctionEnter2として定義されており、実装は次のようになります。

void _declspec(naked) FunctionEnterNaked2(FunctionID funcId, UINT_PTR clientData, COR_PRF_FRAME_INFO func, COR_PRF_FUNCTION_ARGUMENT_INFO *argumentInfo)
{
    __asm
    {
        push    ebp                 // Create a standard frame
        mov     ebp,esp
        pushad                      // Preserve all registers

        mov     eax,[ebp+0x14]      // argumentInfo
        push    eax
        mov     ecx,[ebp+0x10]      // func
        push    ecx
        mov     edx,[ebp+0x0C]      // clientData
        push    edx
        mov     eax,[ebp+0x08]      // funcId
        push    eax
        call    FunctionEnterGlobal

        popad                       // Restore all registers
        pop     ebp                 // Restore EBP
        ret     16
    }
}

この関数は_declspec(naked)という定義を使用する決まりがあり、関数の中身をインラインアセンブラで記述しなければなりません。 インラインアセンブラについてはよく分かりませんが、上記のようなコードを記述するのが一般的なようです。 重要なのはFunctionEnterGlobalという関数を呼び出している点であり、 この関数ならばいつも通りC言語のコードを記述できます。

void __stdcall FunctionEnterGlobal(FunctionID funcId, UINT_PTR clientData, COR_PRF_FRAME_INFO func, COR_PRF_FUNCTION_ARGUMENT_INFO *argumentInfo)
{
	if (g_pProfilerCallback != NULL) {
		WCHAR szName[256], szBuf[1024];
		
		if (!g_pProfilerCallback->FilterClass(funcId, L"Program"))
			return;

		g_pProfilerCallback->GetFullMethodName(funcId, szName);
		wsprintfW(szBuf, L"--------Enter %s--------", szName);
		WriteLogFile(szBuf);
		
		g_pProfilerCallback->WriteMethodInfo(funcId, argumentInfo);
	}
}

WriteMethodInfoというメソッドがファイルに対してメソッド情報を書き込みますが、 その前にFilterClassというメソッドを呼び出しています。 このメソッドはFunctionIDから、その関数が第2引数のクラスで実装されているかを調べ、 そうである場合のみTRUEを返します。 FunctionEnterGlobalは何らかの関数が呼ばれるために呼ばれるため、 特定のクラスのメソッドだけを記録することで、メソッドを書き込む量を減らしています。 FilterClassの実装は、次のようになっています。

BOOL CProfilerCallback::FilterClass(FunctionID functionId, LPWSTR lpszFilterClassName)
{
	IMetaDataImport *pMetaDataImport;
	mdMethodDef     methodDef;
	mdTypeDef       typeDef;
	WCHAR           szClassName[256];

	m_pProfilerInfo2->GetTokenAndMetaDataFromFunction(functionId, IID_IMetaDataImport, (IUnknown **)&pMetaDataImport, &methodDef);
	pMetaDataImport->GetMethodProps(methodDef, &typeDef, NULL, 0, NULL, NULL, NULL, NULL, NULL, NULL);
	pMetaDataImport->GetTypeDefProps(typeDef, szClassName, 256, NULL, NULL, NULL);
	pMetaDataImport->Release();

	return lstrcmpW(szClassName, lpszFilterClassName) == 0;
}

GetTokenAndMetaDataFromFunctionを呼び出せば、メソッドのIDからメタデータを表すIMetaDataImportを取得できます。 第4引数に返される値はメタデータ内のMethodDefトークンを識別しており、 これをGetMethodPropsに指定すれば、メソッドの情報をメタデータから取得できます。 GetMethodPropsの第2引数に返るのは、メソッドを実装するクラスを表すTypeDefトークンであり、 これをGetTypeDefPropsに指定すれば、クラスの名前を取得できます。 今回はクラスの名前がProgramでないメソッドは、フィルタするようにしています。

WriteMethodInfoの実装を見ていきます。 このメソッドは処理が多いため、まずはプロトタイプを出力するまでの部分を取り上げます。

m_pProfilerInfo2->GetTokenAndMetaDataFromFunction(functionId, IID_IMetaDataImport, (IUnknown **)&pMetaDataImport, &methodDef);
pMetaDataImport->GetMethodProps(methodDef, NULL, szName, 256, NULL, NULL, &pSig, NULL, NULL, NULL);

p = &pSig[2];
type = GetElementType(&p, &typeDef, &bRef, &bArray);
pParamStart = p;
GetElementTypeName(pMetaDataImport, type, typeDef, bRef, bArray, szType);

wsprintfW(szBuf, L"%s %s(", szType, szName);

for (i = 0; i < pArgInfo->numRanges; i++) {
	type = GetElementType(&p, &typeDef, &bRef, &bArray);
	GetElementTypeName(pMetaDataImport, type, typeDef, bRef, bArray, szType);
	lstrcatW(szBuf, szType);
	if (i + 1 != pArgInfo->numRanges)
		lstrcatW(szBuf, L", ");
}
lstrcatW(szBuf, L")");
WriteLogFile(szBuf);

今回のGetMethodPropsでは、メソッドの名前を取得するために第3引数へバッファを指定します。 また、第7引数からはメソッドのシグネチャを取得しています。 シグネチャには1つの要素ずつ意味のある値が格納され、pSig[0]には呼び出し方式が、pSig[1]には引数の数が、 pSig[2]には戻り値の型が格納されます。 この型というのは、void型ならELEMENT_TYPE_VOID、int型ならELEMENT_TYPE_I4という具合になりますが、 場合によってはpSig[2]だけに収まりきらないこともあります。 たとえば、戻り値の型がint[]である場合は、pSig[2]がELEMENT_TYPE_SZARRAYになり、 pSig[3]がELEMENT_TYPE_I4になります。 つまり、型を取得するためにtype = pSig[2]というように表せれないため、 GetElementTypeという専用の関数を通じて型を取得しています。 また、型から関連する文字列を取得するには、GetElementTypeNameを呼び出します。 ループ文では、メソッドの引数の型をszBufに連結していきます。

GetElementTypeの実装は、次のようになっています。

CorElementType CProfilerCallback::GetElementType(PCCOR_SIGNATURE *ppSig, mdTypeDef *pTypeDef, LPBOOL lpbRef, LPBOOL lpbArray)
{
	CorElementType type = (CorElementType)**ppSig;

	*ppSig += 1;

	if (type == ELEMENT_TYPE_SZARRAY) {
		type = (CorElementType)**ppSig;
		*ppSig += 1;
		*lpbArray = TRUE;
	}
	else
		*lpbArray = FALSE;
	
	if (type == ELEMENT_TYPE_BYREF) {
		type = (CorElementType)**ppSig;
		*ppSig += 1;
		*lpbRef = TRUE;
	}
	else
		*lpbRef = FALSE;
	
	if (type == ELEMENT_TYPE_VALUETYPE || type == ELEMENT_TYPE_CLASS)
		*ppSig += CorSigUncompressToken(*ppSig, pTypeDef);
	else
		*pTypeDef = mdTypeDefNil;

	return type;
}

PCCOR_SIGNATUREへのポインタを受け取っていることから、このメソッドでは呼び出し側の第1引数が指すアドレスを変更できます。 最初の**ppSigで型を取得したらポインタを1つ進め、取得した型がELEMENT_TYPE_SZARRAYでないかを調べます。 一致する場合は、次の要素に本当の型(たとえばint[]ならELEMENT_TYPE_I4)が格納されているはずですから、それをtypeに保存します。 typeにELEMENT_TYPE_I4を格納したらそれが配列であったかどうか判断できなくなるように思えますが、 そのためにlpbArrayをTRUEにしているので問題ありません。 2つの要素を使用する型は配列以外に、ref intなどの参照があります。 この場合は、 ELEMENT_TYPE_BYREFの次の要素に実際の型が格納されるため、先の要領と同じように取得します。 typeがELEMENT_TYPE_VALUETYPEやELEMENT_TYPE_CLASSである場合は、 型が構造体かクラスであることを意味します。 この場合は、CorSigUncompressTokenを呼び出して型を表すTypeDefトークンを取得すると共に、 ppSigを適切な位置まで進めます。

CorElementTypeから型を表す文字列を取得するには、GetElementTypeNameを呼び出します。

void CProfilerCallback::GetElementTypeName(IMetaDataImport *pMetaDataImport, CorElementType type, mdTypeDef typeDef, BOOL bRef, BOOL bArray, LPWSTR lpszBuf)
{
	WCHAR szType[256];

	if (bArray) {	
		GetElementTypeName(pMetaDataImport, type, typeDef, FALSE, FALSE, szType);
		wsprintfW(lpszBuf, L"%s[]", szType);
		return;
	}

	if (type == ELEMENT_TYPE_VALUETYPE || type == ELEMENT_TYPE_CLASS) {
		pMetaDataImport->GetTypeDefProps(typeDef, lpszBuf, 256, NULL, NULL, NULL);
		return;
	}
	
	if (type == ELEMENT_TYPE_VOID)
		lstrcpyW(szType, L"void");
	else if (type == ELEMENT_TYPE_BOOLEAN)
		lstrcpyW(szType, L"bool");
	else if (type == ELEMENT_TYPE_CHAR)
		lstrcpyW(szType, L"char");
	else if (type == ELEMENT_TYPE_I1)
		lstrcpyW(szType, L"sbyte");
	else if (type == ELEMENT_TYPE_U1)
		lstrcpyW(szType, L"byte");
	else if (type == ELEMENT_TYPE_I2)
		lstrcpyW(szType, L"short");
	else if (type == ELEMENT_TYPE_U2)
		lstrcpyW(szType, L"ushort");
	else if (type == ELEMENT_TYPE_I4)
		lstrcpyW(szType, L"int");
	else if (type == ELEMENT_TYPE_U4)
		lstrcpyW(szType, L"uint");
	else if (type == ELEMENT_TYPE_I8)
		lstrcpyW(szType, L"long");
	else if (type == ELEMENT_TYPE_U8)
		lstrcpyW(szType, L"ulong");
	else if (type == ELEMENT_TYPE_STRING)
		lstrcpyW(szType, L"string");
	else if (type == ELEMENT_TYPE_OBJECT)
		lstrcpyW(szType, L"object");
	else
		wsprintfW(szType, L"unknown-type %x", type);

	if (bRef)
		wsprintfW(lpszBuf, L"ref %s", szType);
	else
		lstrcpyW(lpszBuf, szType);
}

typeがELEMENT_TYPE_I4である場合はint型の変数と思いたいところですが、実際にはint型の配列である可能性があります。 よって、最初にbArrayがTRUEであるかを確認し、これが真である場合はbArrayをFALSEにしてGetElementTypeNameを呼び出します。 そして、その結果と[]を連結することで、配列の型を表す文字列を作成します。 typeがELEMENT_TYPE_VALUETYPEかELEMENT_TYPE_CLASSである場合は、 GetTypeDefPropsを呼び出して型の名前を取得できます。 それ以外の型については、明示的に文字列をコピーしています。


戻る