IL2CPP: P / Invoke Wrappers for Types and Methods
- Transfer

This is the sixth article in a series on IL2CPP . This time we will see how il2cpp.exe generates the wrappers for the methods and types necessary for the interaction of managed and native code. In particular, we will discuss the difference between non-convertible and convertible types, deal with marshaling strings and arrays, and talk about the costs of marshaling.
At one time, I wrote a lot of code for the interaction of managed and native code, but creating the right p / invoke declarations in C # still remains for me, to put it mildly, a difficult task. And understanding how the runtime marshals objects is even more difficult. Since IL2CPP technology performs most of the marshaling in the generated C ++ code, we can view (and even debug) its behavior, providing ourselves with a better understanding of internal relationships for more efficient performance analysis and troubleshooting.
I did not aim to provide general information about marshaling and native interaction in this article, since this is too extensive a topic even for a separate publication. Unity documentation describes how native pluginsinteract with Unity. And Mono and Microsoft provide a fair amount of comprehensive information about p / invoke in general.
As in previous articles in this series, we will again work with code that can change in the future and, most likely, will really change in a newer version of Unity. One way or another, the basic concepts of this will not change. Please consider all the material in this series as implementation details. We love to discuss such details openly whenever possible.
Preparation for work
I work in Unity version 5.0.2p4 on OSX and will create an assembly for the iOS platform using the Architecture value for Universal. For this example, I created native code using Xcode 6.3.2 as a static library for ARMv7 and ARM64.
The native code is as follows:
[cpp]
#include
#include
extern "C" {
int Increment(int i) {
return i + 1;
}
bool StringsMatch(const char* l, const char* r) {
return strcmp(l, r) == 0;
}
struct Vector {
float x;
float y;
float z;
};
float ComputeLength(Vector v) {
return sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
}
void SetX(Vector* v, float value) {
v->x = value;
}
struct Boss {
char* name;
int health;
};
bool IsBossDead(Boss b) {
return b.health == 0;
}
int SumArrayElements(int* elements, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += elements[i];
}
return sum;
}
int SumBossHealth(Boss* bosses, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += bosses[i].health;
}
return sum;
}
}
[/cpp]
The script code in Unity is still in the HelloWorld.cs file. It looks like this:
[csharp]
void Start () {
Debug.Log (string.Format ("Using a blittable argument: {0}", Increment (42)));
Debug.Log (string.Format ("Marshaling strings: {0}", StringsMatch ("Hello", "Goodbye")));
var vector = new Vector (1.0f, 2.0f, 3.0f);
Debug.Log (string.Format ("Marshaling a blittable struct: {0}", ComputeLength (vector)));
SetX (ref vector, 42.0f);
Debug.Log (string.Format ("Marshaling a blittable struct by reference: {0}", vector.x));
Debug.Log (string.Format ("Marshaling a non-blittable struct: {0}", IsBossDead (new Boss("Final Boss", 100))));
int[] values = {1, 2, 3, 4};
Debug.Log(string.Format("Marshaling an array: {0}", SumArrayElements(values, values.Length)));
Boss[] bosses = {new Boss("First Boss", 25), new Boss("Second Boss", 45)};
Debug.Log(string.Format("Marshaling an array by reference: {0}", SumBossHealth(bosses, bosses.Length)));
}
[/csharp]
Each of the method calls in this code is executed on the above native code. Next, we look at the declaration of a managed method for each method.
Why is marshaling necessary?
If IL2CPP immediately generates C ++ code, then why marshaling from C # to C ++? Although the code generated in C ++ is native code, the representation of types in C # in some cases differs from the representation in C ++. The IL2CPP runtime should be able to convert type representations in both directions. The il2cpp.exe utility does this for both types and methods.
In managed code, all types can be divided into two categories: non-convertible and convertible. Non-convertible types have the same representation in both managed and native code (for example, byte, int, float). In turn, the converted types are represented differently in both cases (for example, the types bool, string, array). Non-convertible types as such can be passed directly to the native code, but convertible types require preliminary conversion. Often, such a conversion requires a new allocation of memory.
To tell the managed code compiler that this method is implemented in native code, the extern keyword is used in C #. This keyword, along with the DllImport attribute, allows the managed code runtime to find the definition of the native method and call it. The il2cpp.exe utility generates a wrapper for the C ++ method for each extern method. This wrapper performs several important tasks:
- Defines a typedef for the native method, which is used to call the method through a function pointer;
- resolves the native method by name, passing a function pointer to this method;
- converts arguments from their managed view to the native view (if necessary);
- calls the native method;
- converts the return value of a method from its native representation to a managed one (if necessary);
- converts any ref or out argument from their native representation to a managed one (if necessary).
Next, we look at the generated method wrappers for some extern method declarations.
Convertible Marshaling
The simplest type of extern wrapper deals only with non-convertible types.
[csharp]
[DllImport("__Internal")]
private extern static int Increment(int value);
[/csharp]
In the Bulk_Assembly-CSharp_0.cpp file, find the line “HelloWorld_Increment_m3”. The wrapper function for the Increment method looks like this:
[cpp]
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;
}
[/cpp]
First, specify a typedef for the native function signature:
[cpp]
typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
[/cpp]
Something similar will appear in every wrapper function. This native function takes a single int32_t value and returns int32_t.
Then the wrapper finds a suitable function pointer and stores it in a static variable:
[cpp]
_il2cpp_pinvoke_func = (PInvokeFunc)Increment;
[/cpp]
Here the Increment function comes from the extern statement (in C ++ code):
[cpp]
extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}
[/cpp]
On iOS, native methods are statically bound to a single binary representation (indicated by the string “__Internal” in the DllImport attribute), so the IL2CPP runtime does nothing to find the function pointer. Instead, this extern statement notifies the linker to find a suitable function during linking. On other platforms, the IL2CPP runtime can search (if necessary) using the platform-specific API method to find a pointer to this function.
In practice, this means that on iOS, the incorrect p / invoke signature in the managed code will appear as a linker error in the generated code . This error will not appear at runtime. Therefore, all p / invoke signatures must be valid, even if they are not used at run time.
Finally, the native method is called through the function pointer, and the return value is returned. Please note that the argument is passed to the native function by value, therefore, it is quite obvious that any changes to this value in the native code will not be available in the managed code.
Convert Type Marshaling
With a convertible type like string, things are a little more interesting. As stated in a previous post, strings in IL2CPP are represented as an array of double-byte characters encrypted with UTF-16 and prefixed with a four-byte value. This view does not match either the char * view or wchar_t * in C on iOS, so we have to do the conversion. Take a look at the StringsMatch method (HelloWorld_StringsMatch_m4 in the generated code):
[csharp]
DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool StringsMatch([MarshalAs(UnmanagedType.LPStr)]string l, [MarshalAs(UnmanagedType.LPStr)]string r);
[/csharp]
As you can see, each string argument will be converted to char * (due to the UnmangedType.LPStr directive).
[cpp]
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (char*, char*);
[/cpp]
The conversion looks like this (for the first argument):
[cpp]
char* ____l_marshaled = { 0 };
____l_marshaled = il2cpp_codegen_marshal_string(___l);
[/cpp]
A new char buffer of suitable length is allocated in memory, and the contents of the string are copied to the new buffer. Of course, after calling the native method, we will need to clear these allocated buffers:
[cpp]
il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;
[/cpp]
Therefore, marshaling such a convertible type as a string can be costly.
Custom Type Marshaling
With simple types like int and string, everything is clear, but what about more complex, custom types? Suppose we want to marshal the Vector structure from the example above containing three float values. It turns out that the user type is not convertible exclusively in cases where all its fields are not convertible . Therefore, we can call ComputeLength (HelloWorld_ComputeLength_m5 in the generated code) without having to convert the argument:
[cpp]
typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );
// I’ve omitted the function pointer code.
float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;
[/cpp]
Note that the argument is passed by value - exactly the same as it was in the original example when the argument type was int. If we want to change the Vector instance and see these values in managed code, we need to pass it by reference, as in the SetX method (HelloWorld_SetX_m6):
[cpp]
typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 *, float);
Vector_t1 * ____v_marshaled = { 0 };
Vector_t1 ____v_marshaled_dereferenced = { 0 };
____v_marshaled_dereferenced = *___v;
____v_marshaled = &____v_marshaled_dereferenced;
float _return_value = _il2cpp_pinvoke_func(____v_marshaled, ___value);
Vector_t1 ____v_result_dereferenced = { 0 };
Vector_t1 * ____v_result = &____v_result_dereferenced;
*____v_result = *____v_marshaled;
*___v = *____v_result;
return _return_value;
[/cpp]
Here the Vector argument is passed as a pointer to the native code. The generated code is a bit confusing, but in essence it is creating a local variable of the same type, copying the value of the argument locally, then calling the native method with a pointer to this local variable. After the return of the native function, the value in the local variable is copied back to the argument and this value then becomes available in the managed code.
Custom Type Convert Marshaling
Marshaling a convertible user type, such as the Boss type defined above, is also possible, but it will take a bit more work. For this, it is necessary to marshal each field of a given type to their native representation. In addition, the generated C ++ code must have a representation of a managed type that matches the representation in native code.
Consider the extern declaration of IsBossDead:
[csharp]
[DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool IsBossDead(Boss b);
[/csharp]
The wrapper for this method is called HelloWorld_IsBossDead_m7:
[cpp]
extern "C" bool HelloWorld_IsBossDead_m7 (Object_t * __this /* static, unused */, Boss_t2 ___b, const MethodInfo* method)
{
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (Boss_t2_marshaled);
Boss_t2_marshaled ____b_marshaled = { 0 };
Boss_t2_marshal(___b, ____b_marshaled);
uint8_t _return_value = _il2cpp_pinvoke_func(____b_marshaled);
Boss_t2_marshal_cleanup(____b_marshaled);
return _return_value;
}
[/cpp]
The argument is passed to the wrapper function as the type Boss_t2, which is the generated type for the Boss structure. Note that it is passed to the native function with a different type: Boss_t2_marshaled. If we move on to defining this type, we will see that it matches the definition of the Boss structure in our C ++ static library code:
[cpp]
struct Boss_t2_marshaled
{
char* ___name_0;
int32_t ___health_1;
};
[/cpp]
We again used the UnmanagedType.LPStr directive in C # to indicate that the string field should be marshaled as char *. If you are debugging a problem with a convertible user type, it will be very useful for you to look at this _marshaled structure in the generated code. If the field layout does not match the native side, then the marshaling directive in the managed code may be incorrect.
The Boss_t2_marshal function is a generated function that marshals each field, and Boss_t2_marshal_cleanup frees up all the memory allocated during this marshaling process.
Array marshaling
Finally, consider marshaling arrays of convertible and non-convertible types. An array of integers is passed to the SumArrayElements method:
[csharp]
[DllImport("__Internal")]
private extern static int SumArrayElements(int[] elements, int size);
[/csharp]
This is a marshalized array, but since the type of the array element (int) is not convertible, the cost of marshaling will be very small:
[cpp]
int32_t* ____elements_marshaled = { 0 };
____elements_marshaled = il2cpp_codegen_marshal_array((Il2CppCodeGenArray*)___elements);
[/cpp]
The il2cpp_codegen_marshal_array function simply returns a pointer to the memory of an existing managed array, that's all.
However, marshaling an array of convertible types requires a lot more resources. The SumBossHealth method passes an array of Boss instances:
[csharp]
[DllImport("__Internal")]
private extern static int SumBossHealth(Boss[] bosses, int size);
[/csharp]
Its wrapper should allocate memory for the new array, and then marshal each element individually:
[cpp]
Boss_t2_marshaled* ____bosses_marshaled = { 0 };
size_t ____bosses_Length = 0;
if (___bosses != NULL)
{
____bosses_Length = ((Il2CppCodeGenArray*)___bosses)->max_length;
____bosses_marshaled = il2cpp_codegen_marshal_allocate_array(____bosses_Length);
}
for (int i = 0; i < ____bosses_Length; i++)
{
Boss_t2 const& item = *reinterpret_cast(SZArrayLdElema((Il2CppCodeGenArray*)___bosses, i));
Boss_t2_marshal(item, (____bosses_marshaled)[i]);
}
[/cpp]
Of course, all allocated memory is cleared after the call to the native method completes.
Conclusion
IL2CPP scripting technology supports the same marshaling behavior as Mono scripting technology. Since IL2CPP produces generated wrappers for extern types and methods, we have the opportunity to see the cost of calls from managed code to native. As a rule, this cost is not very high for non-convertible types, but in the case of convertible types, the interaction price can be very high. However, our review was rather superficial. You can devote more time to studying the generated code and see how marshaling is performed for return values and output parameters, native function pointers and managed delegates, as well as for custom pointer types.
Next time we'll talk about integrating IL2CPP with the garbage collector.