Why do we need marshaling?

Since IL2CPP is already generating C++ code, why do we need marshaling from C# to C++ code at all? Although the generated C++ code is native code, the representation of types in C# differs from C++ in a number of cases, so the IL2CPP runtime must be able to convert back and forth from representations on both sides. The il2cpp.exe utility does this both for types and methods.

In managed code, all types can be categorized as either blittable or non-blittable. Blittable types have the same representation in managed and native code (e.g. byte, int, float). Non-blittable types have a different representation in managed and native code (e.g. bool, string, array types). As such, blittable types can be passed to native code directly, but non-blittable types require some conversion before they can be passed to native code. Often this conversion involves new memory allocation.

In order to tell the managed code compiler that a given method is implemented in native code, the extern keyword is used in C#. This keyword, along with a DllImport attribute, allows the managed code runtime to find the native method definition and call it. The il2cpp.exe utility generates a wrapper C++ method for each extern method. This wrapper performIL2CPP_24.aspxs a few important tasks:

  • It defines a typedef for the native method which is used to invoke the method via a function pointer.
  • It resolves the native method by name, getting a function pointer to that method.
  • It converts the arguments from their managed representation to their native representation (if necessary).
  • It calls the native method.
  • It converts the return value of the method from its native representation to its managed representation (if necessary).
  • In converts any out or ref arguments from from their native representation to their managed representation (if necessary).

We’ll take a look at the generated wrapper methods for some extern method declarations next.

Marshaling a blittable type

The simplest kind of extern wrapper only deals with blittable types.

                [DllImport("__Internal")]
private extern static int Increment(int value);
 
            

In the Bulk_Assembly-CSharp_0.cpp file, search for the string “HelloWorld_Increment_m3”. The wrapper function for the Increment method looks like this:

                extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}
extern "C" int32_t HelloWorld_Increment_m3 (Object_t * __this /* static, unused */, int32_t ___value, const MethodInfo* method)
{
typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
static PInvokeFunc _il2cpp_pinvoke_func;
if (!_il2cpp_pinvoke_func)
{
_il2cpp_pinvoke_func = (PInvokeFunc)Increment;
if (_il2cpp_pinvoke_func == NULL)
{
il2cpp_codegen_raise_exception(il2cpp_codegen_get_not_supported_exception("Unable to find method for p/invoke: 'Increment'"));
}
}

int32_t _return_value = _il2cpp_pinvoke_func(___value);

return _return_value;
}

            

First, note the typedef for the native function signature:

                typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
            

Something similar will show up in each of the wrapper functions. This native function accepts a single int32_t and returns an int32_t.

Next, the wrapper finds the proper function pointer and stores it in a static variable:

                _il2cpp_pinvoke_func = (PInvokeFunc)Increment;
            

Here the Increment function actually comes from an extern statement (in the C++ code):

                extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}

            

On iOS, native methods are statically linked into a single binary (indicated by the “__Internal” string in the DllImport attribute), so the IL2CPP runtime does nothing to look up the function pointer. Instead, this extern statement informs the linker to find the proper function at link time. On other platforms, the IL2CPP runtime may perform a lookup (if necessary) using a platform-specific API method to obtain this function pointer.

Practically, this means thaton iOS, an incorrect p/invoke signature in managed code will show up as a linker error in the generated code. The error will not occur at runtime. So all p/invoke signatures need to be correct, even with they are not used at runtime.

Finally, the native method is called via the function pointer, and the return value is returned. Notice that the argument is passed to the native function by value, so any changes to its value in the native code will not be available in the managed code, as we would expect.