C#으로 .NET 프로파일러 작성 - 2부 | Kevin Gosse

C#으로 .NET 프로파일러 작성 - 2부 | Kevin Gosse

·

6 min read

Kevin Gosse님의 Writing a .NET profiler in C# — Part 2을 번역하였습니다.

첫 번째 파트에서는 COM 객체의 레이아웃을 모방하고 이를 사용하여 IClassFactory의 가짜 인스턴스를 노출하는 방법을 살펴봤습니다. 이 방법은 잘 작동했지만 정적 메서드를 사용했기 때문에 여러 인스턴스가 예상될 때마다 객체의 상태를 추적하는 것이 편리하지 않았습니다. COM 개체를 .NET의 실제 개체 인스턴스에 매핑할 수 있다면 정말 좋을 것 같습니다.

이 시점에서 코드는 다음과 같습니다:

public class DllMain
{
    private static ClassFactory Instance;

    [UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
    public static unsafe int DllGetClassObject(void* rclsid, void* riid, nint* ppv)
    {
        Console.WriteLine("Hello from the profiling API");

        // Allocate the chunk of memory for the vtable pointer + the pointers to the 5 methods
        var chunk = (IntPtr*)NativeMemory.Alloc(1 + 5, (nuint)IntPtr.Size);

        // Pointer to the vtable
        *chunk = (IntPtr)(chunk + 1);

        // Pointers to each method of the interface
        *(chunk + 1) = (IntPtr)(delegate* unmanaged<IntPtr, Guid*, IntPtr*, int>)&QueryInterface;
        *(chunk + 2) = (IntPtr)(delegate* unmanaged<IntPtr, int>)&AddRef;
        *(chunk + 3) = (IntPtr)(delegate* unmanaged<IntPtr, int>)&Release;
        *(chunk + 4) = (IntPtr)(delegate* unmanaged<IntPtr, IntPtr, Guid*, IntPtr*, int>)&CreateInstance;
        *(chunk + 5) = (IntPtr)(delegate* unmanaged<IntPtr, bool, int>)&LockServer;

        *ppv = (IntPtr)chunk;

        return HResult.S_OK;
    }

    [UnmanagedCallersOnly]
    public static unsafe int QueryInterface(IntPtr self, Guid* guid, IntPtr* ptr)
    {
        Console.WriteLine("QueryInterface");
        *ptr = IntPtr.Zero;
        return 0;
    }

    [UnmanagedCallersOnly]
    public static int AddRef(IntPtr self)
    {
        Console.WriteLine("AddRef");
        return 1;
    }

    [UnmanagedCallersOnly]
    public static int Release(IntPtr self)
    {
        Console.WriteLine("Release");
        return 1;
    }

    [UnmanagedCallersOnly]
    public static unsafe int CreateInstance(IntPtr self, IntPtr outer, Guid* guid, IntPtr* instance)
    {
        Console.WriteLine("CreateInstance");
        *instance = IntPtr.Zero;
        return 0;
    }

    [UnmanagedCallersOnly]
    public static int LockServer(IntPtr self, bool @lock)
    {
        return 0;
    }
}

이상적으로는 다음과 같이 인스턴스 메서드가 있는 실제 객체를 원합니다:

public class ClassFactory
{
    public unsafe int QueryInterface(IntPtr self, Guid* guid, IntPtr* ptr)
    {
        Console.WriteLine("QueryInterface");
        *ptr = IntPtr.Zero;
        return 0;
    }

    public int AddRef(IntPtr self)
    {
        Console.WriteLine("AddRef");
        return 1;
    }

    public int Release(IntPtr self)
    {
        Console.WriteLine("Release");
        return 1;
    }

    public unsafe int CreateInstance(IntPtr self, IntPtr outer, Guid* guid, IntPtr* instance)
    {
        Console.WriteLine("CreateInstance");
        *instance = IntPtr.Zero;
        return 0;
    }

    public int LockServer(IntPtr self, bool @lock)
    {
        return 0;
    }
}

그러나 네이티브 측에서는 UnmanagedCallersOnly 속성으로 장식된 메서드만 호출할 수 있으며, 이 속성은 정적 메서드에만 적용될 수 있습니다. 따라서 정적 메서드 집합과 이러한 정적 메서드에서 객체의 인스턴스를 검색할 수 있는 방법이 필요합니다.

이를 달성하기 위한 핵심은 해당 메서드의 self 인자입니다. C++ 객체의 레이아웃을 모방하고 있기 때문에 네이티브 객체의 인스턴스 주소가 첫 번째 인자로 전달됩니다. 이를 사용하여 관리되는 객체를 검색하고 정적이지 않은 버전의 메서드를 호출할 수 있습니다. 예를 들어:

public unsafe class ClassFactory
{
    private static Dictionary<IntPtr, ClassFactory> _instances = new();

    public ClassFactory()
    {
        // Allocate the chunk of memory for the vtable pointer + the pointers to the 5 methods
        var chunk = (IntPtr*)NativeMemory.Alloc(1 + 5, (nuint)IntPtr.Size);

        // Pointer to the vtable
        *chunk = (IntPtr)(chunk + 1);

        // Pointers to each method of the interface
        *(chunk + 1) = (IntPtr)(delegate* unmanaged<IntPtr, Guid*, IntPtr*, int>)&QueryInterfaceNative;

        // [...] (stripped for brevity

        _instances.Add((IntPtr)chunk, this);
    }

    public int QueryInterface(Guid* guid, IntPtr* ptr)
    {
        Console.WriteLine("QueryInterface");
        *ptr = IntPtr.Zero;
        return 0;
    }

    // [...] (same for other instance methods of ClassFactory)

    [UnmanagedCallersOnly]
    public static int QueryInterfaceNative(IntPtr self, Guid* guid, IntPtr* ptr)
    {
        var instance = _instances[self];

        return instance.QueryInterface(guid, ptr);
    }

    // [...] (same for other static methods of ClassFactory)
}

생성자에서는 연결된 네이티브 객체의 주소와 함께 정적 딕셔너리에 ClassFactory의 인스턴스를 추가합니다. 정적 메서드인 QueryInterfaceNative에서는 정적 딕셔너리에서 해당 인스턴스를 검색하고 비정적 메서드인 QueryInterface를 호출합니다.

작동은 하지만 메서드를 호출할 때마다 사전을 조회해야 한다는 점이 아쉽습니다. 게다가 동시성을 처리해야 합니다(아마도 ConcurrentDictionary를 사용해서). 더 나은 해결책이 있을까요?

이미 네이티브 객체에 대한 포인터가 있으므로 해당 네이티브 객체가 관리 객체에 대한 포인터를 저장할 수 있다면 좋을 것입니다. 이런 식으로요:

public ClassFactory()
{
    // Allocate the chunk of memory for the vtable pointer + the address of the managed object + the pointers to the 5 methods
    var chunk = (IntPtr*)NativeMemory.Alloc(2 + 5, (nuint)IntPtr.Size);

    // Pointer to the vtable
    *chunk = (IntPtr)(chunk + 2);

    // Pointer to the managed object
    *(chunk + 1) = &this;

    // [...]
}

정적 메서드가 있다면 관리되는 객체에 대한 포인터를 가져오는 것만 하면 됩니다:

[UnmanagedCallersOnly]
public static unsafe int QueryInterfaceNative(IntPtr* self, Guid* guid, IntPtr* ptr)
{
    var instance = *(ClassFactory*)(self + 1);

    return instance.QueryInterface(guid, ptr);
}

하지만 &this은 이 컴파일*되지 않습니다. 그럴만한 이유가 있습니다: 관리되는 객체는 가비지 수집기에 의해 언제든지 이동할 수 있으므로 다음 가비지 수집 시 포인터가 유효하지 않게 될 수 있습니다.

*: 거짓말입니다. 최신 버전의 C#을 사용하는 경우 this 주소를 가져올 수 있습니다:

var classFactory = this;
\
(chunk + 1) = (nint)(nint*)&classFactory;*

하지만 앞서 언급한 이유로 안전하지 않으므로 자신이 무엇을 하고 있는지 잘 알지 못한다면 하지 마세요.

이 문제를 해결하기 위해 개체를 고정하고 싶을 수도 있지만, 다른 관리되는 개체에 대한 참조가 있는 개체는 고정할 수 없으므로 이 역시 좋지 않습니다.

우리에게 필요한 것은 관리되는 오브젝트에 대한 일종의 고정된 참조인데, 다행히도 GCHandle이 이를 정확히 제공합니다. 관리되는 객체를 가리키는 GCHandle을 할당하면 GCHandle.ToIntPtr을 사용해 해당 핸들에 연결된 고정 주소를 가져오고, GCHandle.FromIntPtr을 사용해 해당 주소에서 핸들을 검색할 수 있습니다. 따라서 우리가 할 수 있는 일은:

public ClassFactory()
{
    // Allocate the chunk of memory for the vtable pointer + the address of the managed object + the pointers to the 5 methods
    var chunk = (IntPtr*)NativeMemory.Alloc(2 + 5, (nuint)IntPtr.Size);

    // Pointer to the vtable
    *chunk = (IntPtr)(chunk + 2);

    // Pointer to the managed object
    var handle = GCHandle.Alloc(this);
    *(chunk + 1) = GCHandle.ToIntPtr(handle);

    // [...]
}

그런 다음 정적 메서드에서 핸들 및 관련 객체를 검색할 수 있습니다:

[UnmanagedCallersOnly]
public static unsafe int QueryInterfaceNative(IntPtr* self, Guid* guid, IntPtr* ptr)
{
    var handleAddress = *(self + 1);
    var handle = GCHandle.FromIntPtr(handleAddress);
    var instance = (ClassFactory)handle.Target;

    return instance.QueryInterface(guid, ptr);
}

모든 것을 종합하면 이제 ClassFactory의 모습은 다음과 같습니다:

public unsafe class ClassFactory
{
    public ClassFactory()
    {
        // Allocate the chunk of memory for the vtable pointer + the address of the managed object + the pointers to the 5 methods
        var chunk = (IntPtr*)NativeMemory.Alloc(2 + 5, (nuint)IntPtr.Size);

        // Pointer to the vtable
        *chunk = (IntPtr)(chunk + 2);

        // Pointer to the managed object
        var handle = GCHandle.Alloc(this);
        *(chunk + 1) = GCHandle.ToIntPtr(handle);

        *(chunk + 2) = (IntPtr)(delegate* unmanaged<IntPtr*, Guid*, IntPtr*, int>)&Exports.QueryInterface;
        *(chunk + 3) = (IntPtr)(delegate* unmanaged<IntPtr*, int>)&Exports.AddRef;
        *(chunk + 4) = (IntPtr)(delegate* unmanaged<IntPtr*, int>)&Exports.Release;
        *(chunk + 5) = (IntPtr)(delegate* unmanaged<IntPtr*, IntPtr, Guid*, IntPtr*, int>)&Exports.CreateInstance;
        *(chunk + 6) = (IntPtr)(delegate* unmanaged<IntPtr*, bool, int>)&Exports.LockServer;

        Object = (IntPtr)chunk;
    }

    public IntPtr Object { get; }

    public int QueryInterface(Guid* guid, IntPtr* ptr)
    {
        Console.WriteLine("QueryInterface");
        *ptr = IntPtr.Zero;
        return 0;
    }

    public int AddRef()
    {
        Console.WriteLine("AddRef");
        return 1;
    }

    public int Release()
    {
        Console.WriteLine("Release");
        return 1;
    }

    public int CreateInstance(IntPtr outer, Guid* guid, IntPtr* instance)
    {
        Console.WriteLine("CreateInstance");
        *instance = IntPtr.Zero;
        return 0;
    }

    public int LockServer(bool @lock)
    {
        Console.WriteLine("LockServer");
        return 0;
    }

    private class Exports
    {
        [UnmanagedCallersOnly]
        public static int QueryInterface(IntPtr* self, Guid* guid, IntPtr* ptr)
        {
            var handleAddress = *(self + 1);
            var handle = GCHandle.FromIntPtr(handleAddress);
            var obj = (ClassFactory)handle.Target;

            return obj.QueryInterface(guid, ptr);
        }


        [UnmanagedCallersOnly]
        public static int AddRef(IntPtr* self)
        {
            var handleAddress = *(self + 1);
            var handle = GCHandle.FromIntPtr(handleAddress);
            var obj = (ClassFactory)handle.Target;

            return obj.AddRef();
        }

        [UnmanagedCallersOnly]
        public static int Release(IntPtr* self)
        {
            var handleAddress = *(self + 1);
            var handle = GCHandle.FromIntPtr(handleAddress);
            var obj = (ClassFactory)handle.Target;

            return obj.Release();
        }

        [UnmanagedCallersOnly]
        public static unsafe int CreateInstance(IntPtr* self, IntPtr outer, Guid* guid, IntPtr* instance)
        {
            var handleAddress = *(self + 1);
            var handle = GCHandle.FromIntPtr(handleAddress);
            var obj = (ClassFactory)handle.Target;

            return obj.CreateInstance(outer, guid, instance);
        }

        [UnmanagedCallersOnly]
        public static int LockServer(IntPtr* self, bool @lock)
        {
            var handleAddress = *(self + 1);
            var handle = GCHandle.FromIntPtr(handleAddress);
            var obj = (ClassFactory)handle.Target;

            return obj.LockServer(@lock);
        }
    }
}

(이름 충돌을 피하기 위해 정적 메서드를 중첩 클래스로 옮겼습니다)

그리고 진입 지점부터 사용할 수 있습니다:

public class DllMain
{
    private static ClassFactory Instance;

    [UnmanagedCallersOnly(EntryPoint = "DllGetClassObject")]
    public static unsafe int DllGetClassObject(void* rclsid, void* riid, nint* ppv)
    {
        Instance = new ClassFactory();

        Console.WriteLine("Hello from the profiling API");

        *ppv = Instance.Object;

        return HResult.S_OK;
    }
}

남은 것은 ICorProfilerCallback과 약 70개의 메서드에 대해 이 작업을 수행하는 것입니다. 이 작업을 수작업으로 수행하지 않을 것이므로 다음 글에서는 프로세스를 자동화하는 소스 생성기를 작성하겠습니다.