
This is the sixth article
in the IL2CPP series . This time we will see how il2cpp.exe generates wrappers for methods and types that are necessary for the interaction of managed and native code. In particular, we will discuss the difference between non-convertible and convertible types, deal with the marshaling of strings and arrays and talk about the cost 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 ad in C # is still a daunting task for me, to put it mildly. Understanding how the runtime marshaling objects marshals is even more difficult. Since the IL2CPP technology does 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 set out to provide in this article general information about marshaling and native interaction, since this is too broad a topic even for a separate publication. The Unity documentation describes how
native plugins interact with Unity. But
Mono and
Microsoft provide enough comprehensive information about p / invoke as a whole.
')
As in the previous articles of this series, we will again work with the code that may change in the future and, most likely, will really change in the newer version of Unity. Anyway, the basic concepts of this will not change. Please consider all the material in this series as implementation details. We like to openly discuss such details when it is possible.
Preparation for work
I work in Unity version 5.0.2p4 on OSX and I 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.
Native code looks like this:
[cpp] #include <cstring> #include <cmath> 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(vx*vx + vy*vy + vz*vz); } 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 contained 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 the managed method for each method.
Why do I need to marshall?
If IL2CPP immediately generates C ++ code, then why marshall from C # to C ++? Despite the fact that the code generated in C ++ is native code, the representation of types in C # differs in some cases from the representation in C ++. The IL2CPP runtime must be able to convert type representations to both sides. 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 in both cases differently (for example, the types bool, string, array). Non-translatable types as such can be passed to the native code directly, but for translatable types, this requires a prior conversion. Often, such a conversion requires new memory allocation.
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 a native method that is used to invoke a method through a function pointer;
- resolves the native method by name, passing a function pointer to this method;
- converts the arguments from their managed representation to the native representation (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 declarations of extern methods.
Non-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 string “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 , 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 each wrapper function. This native function takes a single int32_t value and returns an int32_t.
The wrapper then finds the appropriate function pointer and stores it in a static variable:
[cpp] _il2cpp_pinvoke_func = (PInvokeFunc)Increment; [/cpp]
Here, the Increment function comes from the extern operator (in C ++ code):
[cpp] extern "C" {int32_t DEFAULT_CALL Increment(int32_t);} [/cpp]
In 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 a function pointer. Instead, this extern operator notifies the linker in order to find a suitable function at the time of linking. On other platforms, the IL2CPP runtime can search (if necessary) using the platform-dependent API method to find the 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 does not appear at run time. Therefore, all p / invoke signatures must be correct, even if they are not used at run time.
Finally, the native method is invoked through a function pointer, and the return value is returned. Note that the argument is passed to the native function by value, therefore, it is clear that any changes to this value in the native code will not be available in the managed code.
Marshaling type convertible
With a convertible type, such as string, this is a bit more interesting. As mentioned in
one of the previous publications, strings in IL2CPP are represented as an array of two-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 will 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 placed 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 expensive.
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, which contains three float values. It turns out that a
user type is non-convertible only when all its fields are non-convertible . Therefore, we can call ComputeLength (HelloWorld_ComputeLength_m5 in the generated code) without the need to convert the argument:
[cpp] typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );
Notice that the argument is passed by value - just 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 the 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 confused, but in essence it is the creation of 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 native function returns, the value in the local variable is copied back into the argument, and this value is then available in the managed code.
Marshaling convertible user type
Marshaling a convertible custom type, such as the Boss type defined above, is also possible, but this will require a little more work. To do this, you must 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 the 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 , 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. Please note that it is passed to a native function with a different type: Boss_t2_marshaled. If we proceed to the definition of this type, we will see that it coincides with the definition of the Boss structure in our static C ++ 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 custom type being converted, 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 all memory allocated during this marshaling process.
Array marshaling
Finally, consider marshaling of arrays of translatable and non-translatable types. The 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 array element type (int) is non-convertible, the marshaling cost will be very small:
[cpp] int32_t* ____elements_marshaled = { 0 }; ____elements_marshaled = il2cpp_codegen_marshal_array<int32_t>((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 translatable types requires much 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 perform the marshaling of each element separately:
[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<Boss_t2_marshaled>(____bosses_Length); } for (int i = 0; i < ____bosses_Length; i++) { Boss_t2 const& item = *reinterpret_cast<Boss_t2 *>(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
The IL2CPP scripting technology supports the same marshaling behavior as Mono scripting technology. Since IL2CPP produces generated wrappers for types and extern 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 cost of interaction can be very high. However, our review was rather superficial. You can spend more time exploring the generated code and see how it is marshaled for return values ​​and output parameters, native function pointers and managed delegates, as well as custom pointer types.
Next time we'll talk about integrating the IL2CPP with the garbage collector.