有状态的 CLR 委托如何编组为仅采用函数指针的本机 C 函数

How are stateful CLR delegates marshaled to native C functions which only take a function pointer

(替代标题:如何在 C 或 C++ 中实现与 CLR 委托等效的方法)

考虑这个 C 函数:

int Test(int(*fn1)(double a));

如果我要从 C 程序调用此函数,我将无法传递任意状态 object 以及我的函数指针 - 我只能有效地处理全局状态。这是一个常见问题,这就是为什么许多 C API 提供类似于

int Test(int(*fn1)(double a, void *state), void *state);

然而,令我惊讶的是,我注意到从 C# 程序调用函数的第一个版本时这不是问题。

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
delegate int CallbackType(double something);

[DllImport("TestLib.dll", CallingConvention = CallingConvention.Cdecl)]
extern static int Test(CallbackType fn);

当调用回调函数时(即在 C# 代码中),this 指针及其所有成员将被保留(这自动意味着可以轻松实现闭包和多播委托等附加功能).

我不明白封送拆收器如何将 2 个指针的信息压缩到 1 个中。我做了很多测试并在使用 TestCallback 的不同实例调用 C 函数时意识到(即不同的调用targets in C#),到达C的函数指针每次都是不同的地址。更准确地说,TestCallback 实例和唯一的 C 函数指针地址之间似乎存在直接的 1:1 映射,并且该地址似乎是持久的——但是我无法找到该地址的存储位置在 TestCallback 实例中。

我得出结论,在程序运行期间 TestCallback 实例化时,CLR 必须将可执行的本机代码块发送到 RAM 中。该代码块使用 hard-coded 状态 object 指针调用调度程序函数(状态 object 可能是发出代码 black 的特定 TestCallback 实例)。

然而,到目前为止,我没有发现任何可以证实或反驳这一点的东西 - 要么没有关于这个主题的任何 in-depth 信息,要么它被肤浅的教程所掩盖。

如果那是真的,那怎么可能在程序内存和数据内存严格分离的架构上工作,以至于 CPU 无法从数据内存加载运行时生成的代码?它如何在强制 ahead-of-time 编译的平台上工作?以及如何在 C 或 C++ 等较低级别的语言中实现类似的东西?


我用于测试的一些额外代码:

C header file ===================

extern __declspec(dllexport) int Test(
    int(*fn1)(double a), int *address1,
    int(*fn2)(double a), int *address2,
    int(*fn3)(double a), int *address3
);

C file ===================

int Test(
    int(*fn1)(double a), int *address1,
    int(*fn2)(double a), int *address2,
    int(*fn3)(double a), int *address3
)
{
    *address1 = (int)fn1;
    *address2 = (int)fn2;
    *address3 = (int)fn3;
    int result = fn1(5538867.0);
    result += 9;
    return result;
}

C# file ===================

class Program
{
    static void Main(string[] args)
    {
        var sc1 = new SomeClass(5538867);
        var sc2 = new SomeClass(-999999);
        var callback1 = new CallbackType(sc1.TheCallback);
        var callback2 = new CallbackType(sc2.TheCallback);
        int called_address1 = 0, called_address2 = 0, called_address3 = 0;

        var result = Test(
            callback1, ref called_address1,
            callback2, ref called_address2,
            callback2, ref called_address3
            );
        // should be 9 or 8
        Console.WriteLine(result);
        Console.WriteLine(called_address1.ToString("x8"));
        Console.WriteLine(called_address2.ToString("x8"));
        Console.WriteLine(called_address3.ToString("x8"));

        result = Test(
            callback1, ref called_address1,
            callback2, ref called_address2,
            callback2, ref called_address3
            );

        Console.WriteLine(result);
        Console.WriteLine(called_address1.ToString("x8"));
        Console.WriteLine(called_address2.ToString("x8"));
        Console.WriteLine(called_address3.ToString("x8"));

        GC.KeepAlive(callback1);
        GC.KeepAlive(callback2);

        Console.ReadKey();
    }

    class SomeClass
    {
        public SomeClass(int i)
        {
            this.i = i;
            this.something_else = "sjdklfjksdf";
        }

        private readonly int i;
        private readonly string something_else;

        public int TheCallback(double something)
        {
            return (int)something - this.i + this.something_else.Length - 11;
        }
    }

    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    delegate int CallbackType(double something);

    [DllImport("TestLib.dll", CallingConvention = CallingConvention.Cdecl)]
    extern static int Test(
        CallbackType callback1, ref int called_address1,
        CallbackType callback2, ref int called_address2,
        CallbackType callback3, ref int called_address3
        );
}

此后我找到了部分答案。

I conclude that upon instanciation of TestCallback, which is during the runtime of the program, the CLR must emit an executable native code block into RAM. That code block that calls a dispatcher function with a hard-coded state object pointer (the state object possibly being the specific TestCallback instance for which the code black was emitted).

这是正确的;实例化委托时,可能会生成 Thunk。它包含加载上下文数据的指令,就好像它们是常量一样,然后跳转到实际的目标函数。当目标函数 returns 时,它不会 return 到 thunk(因为它不需要 return 那里),而是直接到原始调用者。

how can that possibly work on an architecture where program memory and data memory are strictly separated, such that the CPU cannot load runtime generated code from the data memory?

如果目标平台没有既可写又可执行的内存,则不起作用。例如,这会影响指令指针只能指向 ROM 的非可编程控制器。

How does it work on platforms that mandate ahead-of-time compilation?

它可能不起作用,至少不是在所有平台上。例如,在 Mono 上,它需要 MonoPInvokeCallbackAttribute,并且只能使用静态方法作为回调,请参阅 this MSDN article on Xamarin on iOS

And how can something like that be implemented in a lower level langugate like C or C++?

它需要手动将所需的 X86(或任何目标平台)加载和跳转指令以字节形式写入内存。有库可以做到这一点,目标平台必须是已知的;没有编译器支持它。

(注意:我本可以发誓 Hans Passant 之前发布过这个问题的答案)