Marshaling a non-blittable type
Things get a little more exciting with a non-blittable type, like string. Recall from an earlier
post that strings
in IL2CPP are represented as an array of two-byte characters encoded via UTF-16, prefixed by a 4-byte length value.
This representation does not match either the char* or wchar_t* representations of strings in C on iOS, so we have
to do some conversion. If we look at the StringsMatch method (HelloWorld_StringsMatch_m4 in the generated code):
DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool StringsMatch([MarshalAs(UnmanagedType.LPStr)]string l, [MarshalAs(UnmanagedType.LPStr)]string r);
We can see that each string argument will be converted to a char* (due to the UnmangedType.LPStr directive).
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (char*, char*);
The conversion looks like this (for the first argument):
char* ____l_marshaled = { 0 };
____l_marshaled = il2cpp_codegen_marshal_string(___l);
A new char buffer of the proper length is allocated, and the contents of the string are copied into the new buffer. Of course, after the native method
is called we need to clean up those allocated buffers:
il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;
So marshaling a non-blittable type like string can be costly.
Marshaling a user-defined type
Simple types like int and string are nice, but what about a more complex, user defined type? Suppose we want to marshal the Vector structure above,
which contains three float values. It turns out that a user defined type is blittable if and only if all of its fields are blittable.
So we can call ComputeLength (HelloWorld_ComputeLength_m5 in the generated code) without any need to convert the argument:
typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );
// I’ve omitted the function pointer code.
float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;
Notice that the argument is passed by value, just as it was for the initial example when the argument type was int. If we want to modify the instance
of Vector and see those changes in managed code, we need to pass it by reference, as in the SetX method (HelloWorld_SetX_m6):
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;
Here the Vector argument is passed as a pointer to native code. The generated code goes through a bit of a rigmarole, but it is basically creating a
local variable of the same type, copying the value of the argument to the local, then calling the native method with a pointer to that local variable.
After the native function returns, the value in the local variable is copied back into the argument, and that value is available in the managed code then.