A Journey Through a P/Invoke Call
Today we’ll go through everything that happens when you make a P/Invoke call. We’ll see how native libraries are loaded and how marshaling works. We’ll touch on calling conventions and character sets. In the end, we’ll have a better understanding of what goes on when we call into native code.
Update: A Russian translation of this article is available.
Starting from the usage example in last week’s article, let’s look at a representative function call from C# into native (C in this case) code. Here’s one that’s a sufficiently complex call to have SQLite execute a query:
public static ReturnCode Execute(Pointer<Database> database, string sql) { return PInvoke.sqlite3_exec( database.Ptr, sql, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero); }
PInvoke.sqlite3_exec
is the extern
function that was defined like so:
[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)] public static extern ReturnCode sqlite3_exec( IntPtr pDb, string sql, IntPtr callback, IntPtr userdata, IntPtr errmsg);
Now let’s set IL2CPP as the scripting backend and build for macOS with Unity 2018.3.1f1. Looking in the MyProject_macOS_BackUpThisFolder_ButDontShipItWithYourGame
directory, we see a il2cppOutput
sub-directory with many .cpp
files. The one containing our code is Bulk_Assembly-CSharp_0.cpp
, so let’s open that up and scroll down to this line:
// Sqlite/ReturnCode Sqlite::Execute(Sqlite/Pointer`1<Sqlite/Database>,System.String)
The following function is the C++ output from IL2CPP for that C# function. Here’s how it looks:
extern "C" IL2CPP_METHOD_ATTR int32_t Sqlite_Execute_mD47D78DB934E6BA9E8C537F1A6BC6C2F5B5F16D8 (Pointer_1_tA8033EA1406105F61E1B6D9BEB34FD0C2AC13CE3 ___database0, String_t* ___sql1, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (Sqlite_Execute_mD47D78DB934E6BA9E8C537F1A6BC6C2F5B5F16D8_MetadataUsageId); s_Il2CppMethodInitialized = true; } { Pointer_1_tA8033EA1406105F61E1B6D9BEB34FD0C2AC13CE3 L_0 = ___database0; intptr_t L_1 = L_0.get_Ptr_0(); String_t* L_2 = ___sql1; int32_t L_3 = PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974((intptr_t)L_1, L_2, (intptr_t)(0), (intptr_t)(0), (intptr_t)(0), /*hidden argument*/NULL); return L_3; } }
Here we see the usual method initialization overhead and then a function call to PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974
. The arguments passed are the Ptr
field of the Pointer<Database>
struct and the SQL command string
. So far it’s all completely normal C++ code output for a C# function that calls another function.
The next step is to follow the function call and see what the extern
function looks like in the form of PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974
. It can be found in the same file.
extern "C" IL2CPP_METHOD_ATTR int32_t PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974 (intptr_t ___pDb0, String_t* ___sql1, intptr_t ___callback2, intptr_t ___userdata3, intptr_t ___errmsg4, const RuntimeMethod* method) { typedef int32_t (DEFAULT_CALL *PInvokeFunc) (intptr_t, char*, intptr_t, intptr_t, intptr_t); static PInvokeFunc il2cppPInvokeFunc; if (il2cppPInvokeFunc == NULL) { int parameterSize = sizeof(intptr_t) + sizeof(char*) + sizeof(intptr_t) + sizeof(intptr_t) + sizeof(intptr_t); il2cppPInvokeFunc = il2cpp_codegen_resolve_pinvoke<PInvokeFunc>(IL2CPP_NATIVE_STRING("sqlite3"), "sqlite3_exec", IL2CPP_CALL_C, CHARSET_NOT_SPECIFIED, parameterSize, false); if (il2cppPInvokeFunc == NULL) { IL2CPP_RAISE_MANAGED_EXCEPTION(il2cpp_codegen_get_not_supported_exception("Unable to find method for p/invoke: 'sqlite3_exec'"), NULL, NULL); } } // Marshaling of parameter '___sql1' to native representation char* ____sql1_marshaled = NULL; ____sql1_marshaled = il2cpp_codegen_marshal_string(___sql1); // Native function invocation int32_t returnValue = il2cppPInvokeFunc(___pDb0, ____sql1_marshaled, ___callback2, ___userdata3, ___errmsg4); // Marshaling cleanup of parameter '___sql1' native representation il2cpp_codegen_marshal_free(____sql1_marshaled); ____sql1_marshaled = NULL; return returnValue; }
At the start we see a static
PInvokeFunc
variable. In C++, a static
local variable means it persists from function call to function call rather than being located on the stack. It’s essentially a global variable that’s only accessible within the function. On the first call, it’s presumed to be null and so the if
block will be executed to set it to non-null. This is the same technique that the method initialization overhead uses to execute some code only on the first call to the function.
The major work to do is in the call to il2cpp_codegen_resolve_pinvoke
with various parameters to indicate how the function pointer to the native function should be retrieved. The first parameter is the name of the native library: "sqlite3"
in this case. The string literal is wrapped in a IL2CPP_NATIVE_STRING
macro. To see the definition of it, open /path/to/Unity/installation/Contents/il2cpp/libil2cpp/il2cpp-api-types.h
. The path is slightly different when not using macOS. Here’s the definition of the macro:
#if _MSC_VER typedef wchar_t Il2CppNativeChar; #define IL2CPP_NATIVE_STRING(str) L##str #else typedef char Il2CppNativeChar; #define IL2CPP_NATIVE_STRING(str) str #endif
Since we’re not using Windows (i.e. _MSC_VER
is not defined), this is the identity macro and will have no effect.
The next parameter is the name of the function: "sqlite3_exec"
.
The third is the “calling convention,” which is equivalent to CallingConvention.Cdecl
: IL2CPP_CALL_C
.
Likewise, the fourth parameter is CHARSET_NOT_SPECIFIED
to specify the character set for strings. There’s no real equivalent in C# to that name, but the default is CharSet.Ansi
.
The fifth parameter is the sum of the parameter sizes the native function takes. Note that this is after marshalling, so the C# string
size is not used but rather the C char*
size.
Finally, the sixth parameter specifies whether name mangling should be used. Name mangling refers to the compiler transformation of function names in C++ to make them into unique strings. This allows for features such as overloading where all the functions have the same name in C++ but still unique string names for the purposes of binding to languages like C#.
With those parameters understood, let’s take a look at il2cpp_codegen_resolve_pinvoke
. This is located in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/il2cpp-codegen-il2cpp.h
.
template<typename FunctionPointerType, size_t dynamicLibraryLength, size_t entryPointLength> inline FunctionPointerType il2cpp_codegen_resolve_pinvoke(const Il2CppNativeChar(&nativeDynamicLibrary)[dynamicLibraryLength], const char(&entryPoint)[entryPointLength], Il2CppCallConvention callingConvention, Il2CppCharSet charSet, int parameterSize, bool isNoMangle) { const PInvokeArguments pinvokeArgs = { il2cpp::utils::StringView<Il2CppNativeChar>(nativeDynamicLibrary), il2cpp::utils::StringView<char>(entryPoint), callingConvention, charSet, parameterSize, isNoMangle }; return reinterpret_cast<FunctionPointerType>(il2cpp::vm::PlatformInvoke::Resolve(pinvokeArgs)); }
This function really just wraps up all the parameters into a PInvokeArguments
and passes them to il2cpp::vm::PlatformInvoke::Resolve
. In so doing, it creates a StringView
for the name of the library and the native function name. That class can be found in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/utils/StringView.h
. It’s really just a wrapper for a pointer to the first character of the string and its length. It provides handy functions for manipulating the string. Here’s an excerpt of its start:
template<typename CharType> class StringView { private: const CharType* m_String; size_t m_Length; // Intended to only be used by Empty() inline StringView() : m_String(NULL), m_Length(0) { } public: template<size_t Length> inline StringView(const CharType(&str)[Length]) : m_String(str), m_Length(Length - 1) { } };
The constructor template makes use the fact that string literals in C++ are an array of char
and their length is known at compile time so there’s no need to call strlen
or otherwise read every character until the NUL
terminator is found.
Now let’s proceed to il2cpp::vm::PlatformInvoke::Resolve
. That’s found in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/PlatformInvoke.cpp
and looks like this:
Il2CppMethodPointer PlatformInvoke::Resolve(const PInvokeArguments& pinvokeArgs) { // Before resolving a P/Invoke, check against a hardcoded list of "known P/Invokes" that is different for every platform. // This bit solves several different problems we have when P/Invoking into native system libraries from mscorlib.dll. // Some platforms, like UWP, just don't allow you to load to load system libraries at runtime dynamically. // On other platforms (THEY SHALL NOT BE NAMED :O), while the functions that mscorlib.dll wants to P/Invoke into exist, // They exist in different system libraries than it is said in the DllImport attribute. Il2CppMethodPointer function = os::LibraryLoader::GetHardcodedPInvokeDependencyFunctionPointer(pinvokeArgs.moduleName, pinvokeArgs.entryPoint); if (function != NULL) return function; void* dynamicLibrary = NULL; if (utils::VmStringUtils::CaseSensitiveEquals(il2cpp::utils::StringUtils::NativeStringToUtf8(pinvokeArgs.moduleName.Str()).c_str(), "__InternalDynamic")) dynamicLibrary = LibraryLoader::LoadLibrary(il2cpp::utils::StringView<Il2CppNativeChar>::Empty()); else dynamicLibrary = LibraryLoader::LoadLibrary(pinvokeArgs.moduleName); if (dynamicLibrary == NULL) { std::basic_string<Il2CppNativeChar> message; message += IL2CPP_NATIVE_STRING("Unable to load DLL '"); message += pinvokeArgs.moduleName.Str(); message += IL2CPP_NATIVE_STRING("': The specified module could not be found."); Exception::Raise(Exception::GetDllNotFoundException(il2cpp::utils::StringUtils::NativeStringToUtf8(message).c_str())); } function = os::LibraryLoader::GetFunctionPointer(dynamicLibrary, pinvokeArgs); if (function == NULL) { std::basic_string<Il2CppNativeChar> message; message += IL2CPP_NATIVE_STRING("Unable to find an entry point named '"); message += il2cpp::utils::StringUtils::Utf8ToNativeString(pinvokeArgs.entryPoint.Str()); message += IL2CPP_NATIVE_STRING("' in '"); message += pinvokeArgs.moduleName.Str(); message += IL2CPP_NATIVE_STRING("'."); Exception::Raise(Exception::GetEntryPointNotFoundException(il2cpp::utils::StringUtils::NativeStringToUtf8(message).c_str())); } return function; }
To begin with, it calls os::LibraryLoader::GetHardcodedPInvokeDependencyFunctionPointer
. For macOS, that’s found in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/os/Posix/LibraryLoader.cpp
and it looks like this:
Il2CppMethodPointer LibraryLoader::GetHardcodedPInvokeDependencyFunctionPointer(const il2cpp::utils::StringView<Il2CppNativeChar>& nativeDynamicLibrary, const il2cpp::utils::StringView<char>& entryPoint) { return NULL; }
There seem to be no hard-coded functions for macOS, so this call always returns null and the if (function != NULL)
check will consequently never be true.
Next, there’s a call to utils::VmStringUtils::CaseSensitiveEquals
to check if moduleName
(the native library name) equals "__InternalDynamic"
. We’ll skip looking at that since it is just a string comparison. In this case, we’re never using "__InternalDynamic"
so the check will always fail and LibraryLoader::LoadLibrary(pinvokeArgs.moduleName)
will be called instead.
LibraryLoader::LoadLibrary
is also found in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/LibraryLoader.cpp
and looks like this:
void* LibraryLoader::LoadLibrary(il2cpp::utils::StringView<Il2CppNativeChar> nativeDynamicLibrary) { if (s_FindPluginCallback) { StringViewAsNullTerminatedStringOf(Il2CppNativeChar, nativeDynamicLibrary, libraryName); const Il2CppNativeChar* modifiedLibraryName = s_FindPluginCallback(libraryName); if (modifiedLibraryName != libraryName) { utils::StringView<Il2CppNativeChar> modifiedDynamicLibrary(modifiedLibraryName, utils::StringUtils::StrLen(modifiedLibraryName)); return os::LibraryLoader::LoadDynamicLibrary(modifiedDynamicLibrary); } } return os::LibraryLoader::LoadDynamicLibrary(nativeDynamicLibrary); }
The first line checks if there’s a non-null s_FindPluginCallback
, which is a global variable located right before the function:
static Il2CppSetFindPlugInCallback s_FindPluginCallback = NULL;
This is a function pointer that may or may not be set. On macOS, it doesn’t seem to ever be set. If it is, it’s called to get a modified version of the library name. Either way, this function ultimately calls os::LibraryLoader::LoadDynamicLibrary
. That’s found in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/os/Posix/LibraryLoader.cpp
for macOS and looks like this:
void* LibraryLoader::LoadDynamicLibrary(const utils::StringView<Il2CppNativeChar>& nativeDynamicLibrary) { return LoadDynamicLibrary(nativeDynamicLibrary, RTLD_LAZY); }
This just calls an overload of the function with an extra parameter: RTLD_LAZY
. That parameter means that function names are only resolved in the library as they’re actually used. As for the overload, it’s right afterward in the file and looks like this:
void* LibraryLoader::LoadDynamicLibrary(const utils::StringView<Il2CppNativeChar>& nativeDynamicLibrary, int flags) { #ifdef VERBOSE_OUTPUT printf("Attempting to load dynamic library: %sn", nativeDynamicLibrary.Str()); #endif if (nativeDynamicLibrary.IsEmpty()) return LoadLibraryWithName(NULL, flags); StringViewAsNullTerminatedStringOf(char, nativeDynamicLibrary, libraryName); void* handle = LoadLibraryWithName(libraryName, flags); if (handle == NULL) handle = CheckLibraryVariations(libraryName, flags); if (handle == NULL) { size_t lengthWithoutDotDll = nativeDynamicLibrary.Length() - 4; if (strncmp(libraryName + lengthWithoutDotDll, ".dll", 4) == 0) { char* nativeDynamicLibraryWithoutExtension = static_cast<char*>(alloca((lengthWithoutDotDll + 1) * sizeof(char))); memcpy(nativeDynamicLibraryWithoutExtension, libraryName, lengthWithoutDotDll); nativeDynamicLibraryWithoutExtension[lengthWithoutDotDll] = 0; handle = CheckLibraryVariations(nativeDynamicLibraryWithoutExtension, flags); } } os::FastAutoLock lock(&s_NativeHandlesOpenMutex); if (handle != NULL) s_NativeHandlesOpen.insert(handle); return handle; }
The if (nativeDynamicLibrary.IsEmpty())
check at the start won’t be triggered since we’re never passing an empty string. Next up is a call to LoadLibraryWithName
which is also in the same file:
static void* LoadLibraryWithName(const char* name, int flags) { #ifdef VERBOSE_OUTPUT printf("Trying name: %sn", name); #endif void* handle = NULL; #if IL2CPP_TARGET_IOS std::string dirName; if (utils::Environment::GetNumMainArgs() > 0) { std::string main = utils::StringUtils::Utf16ToUtf8(utils::Environment::GetMainArgs()[0]); dirName = utils::PathUtils::DirectoryName(main); } std::string libPath = utils::StringUtils::Printf("%s/%s", dirName.c_str(), name); handle = dlopen(libPath.c_str(), flags); // Fallback to just using the name. This might be a system dylib. if (handle == NULL) handle = dlopen(name, flags); #else handle = dlopen(name, flags); #endif if (handle != NULL) return handle; #ifdef VERBOSE_OUTPUT printf("Error: %sn", dlerror()); #endif return NULL; }
Since we’re not using iOS, this function is essentially just a call to dlopen
which tells macOS to open a the native library. Assuming this succeeds, in the caller all the null handle checks will be skipped and we’ll just execute the part at the end of the function where a lock is taken and the handle is inserted into s_NativeHandlesOpen
.
Now we can go all the way back to PlatformInvoke::Resolve
and see that os::LibraryLoader::GetFunctionPointer
is called with the result of opening the library. That too is in the same file and looks like this:
Il2CppMethodPointer LibraryLoader::GetFunctionPointer(void* dynamicLibrary, const PInvokeArguments& pinvokeArgs) { StringViewAsNullTerminatedStringOf(char, pinvokeArgs.entryPoint, entryPoint); if (pinvokeArgs.isNoMangle) return reinterpret_cast<Il2CppMethodPointer>(dlsym(dynamicLibrary, entryPoint)); const size_t kBufferOverhead = 10; void* functionPtr = NULL; size_t originalFuncNameLength = strlen(entryPoint) + 1; std::string functionName; functionName.resize(originalFuncNameLength + kBufferOverhead + 1); // Let's index the string from '1', because we might have to prepend an underscore in case of stdcall mangling memcpy(&functionName[1], entryPoint, originalFuncNameLength); memset(&functionName[1] + originalFuncNameLength, 0, kBufferOverhead); // If there's no 'dont mangle' flag set, 'W' function takes priority over original name, but 'A' function does not (yes, really) if (pinvokeArgs.charSet == CHARSET_UNICODE) { functionName[originalFuncNameLength] = 'W'; functionPtr = dlsym(dynamicLibrary, functionName.c_str() + 1); if (functionPtr != NULL) return reinterpret_cast<Il2CppMethodPointer>(functionPtr); // If charset specific function lookup failed, try with original name functionPtr = dlsym(dynamicLibrary, entryPoint); } else { functionPtr = dlsym(dynamicLibrary, entryPoint); if (functionPtr != NULL) return reinterpret_cast<Il2CppMethodPointer>(functionPtr); // If original name function lookup failed, try with mangled name functionName[originalFuncNameLength] = 'A'; functionPtr = dlsym(dynamicLibrary, functionName.c_str() + 1); } return reinterpret_cast<Il2CppMethodPointer>(functionPtr); }
Because isNoMangle
was set to false
, the if (pinvokeArgs.isNoMangle)
branch won’t be executed and we’ll need to do some more work. Specifically, we set functionName
to have the name of the native function and then, because we’re not using CHARSET_UNICODE
, we ignore it and call dlsym
with entryPoint
instead. If that doesn’t return a pointer to the native function we mangle the functionName
and pass that to dlsym
instead. In the end, we cast the pointer to the native function to the desired function pointer type.
Notice that neither this function nor PlatformInvoke::Resolve
ever used the “calling convention” and they were the only ones exposed to it. So while a calling convention can be specified and does generate different parameters (e.g. IL2CPP_CALL_DEFAULT
when not specified), the setting ends up getting ignored when not building for Windows.
Now we can go all the way back to our extern
function: PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974
. Assuming everything worked, we can get into actually calling the function. To prepare, we need to marshal the C# string
to a C-style string: char*
which is a pointer to the first character of a character array. That’s done by il2cpp_codegen_marshal_string
, which is located in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/codegen/il2cpp-codegen-il2cpp.h
:
inline char* il2cpp_codegen_marshal_string(String_t* string) { return il2cpp::vm::PlatformInvoke::MarshalCSharpStringToCppString((RuntimeString*)string); }
This function just forwards to il2cpp::vm::PlatformInvoke::MarshalCSharpStringToCppString
, so let’s open that up in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/PlatformInvoke.cpp
:
char* PlatformInvoke::MarshalCSharpStringToCppString(Il2CppString* managedString) { if (managedString == NULL) return NULL; std::string utf8String = utils::StringUtils::Utf16ToUtf8(managedString->chars); char* nativeString = MarshalAllocateStringBuffer<char>(utf8String.size() + 1); strcpy(nativeString, utf8String.c_str()); return nativeString; }
Note that RuntimeString
is the same type as Il2CppString
, so the parameter does match.
This calls utils::StringUtils::Utf16ToUtf8
to convert the string from C#’s 16-bit characters to C’s 8-bit characters. It’s found in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/utils/StringUtils.cpp
and looks like this:
std::string StringUtils::Utf16ToUtf8(const Il2CppChar* utf16String) { return Utf16ToUtf8(utf16String, -1); } std::string StringUtils::Utf16ToUtf8(const Il2CppChar* utf16String, int maximumSize) { const Il2CppChar* ptr = utf16String; size_t length = 0; while (*ptr) { ptr++; length++; if (maximumSize != -1 && length == maximumSize) break; } std::string utf8String; utf8String.reserve(length); utf8::unchecked::utf16to8(utf16String, ptr, std::back_inserter(utf8String)); return utf8String; }
The first part gets the length of the string. The second part reserves that much space in the std::string
and then calls utf8::unchecked::utf16to8
to fill it. That function is in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/utils/utf8-cpp/source/utf8/unchecked.h
as part of a third-party library. We’ll skip the details of it since it’s a straightforward conversion from UTF-16 to UTF-8.
Back in PlatformInvoke::MarshalCSharpStringToCppString
, we continue by calling MarshalAllocateStringBuffer
. That’s in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/PlatformInvoke.h
and looks like this:
template<typename T> static T* MarshalAllocateStringBuffer(size_t numberOfCharacters) { return (T*)MarshalAlloc::Allocate(numberOfCharacters * sizeof(T)); }
Since this just calls MarshalAlloc::Allocate
, let’s follow it to its macOS definition in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/os/Posix/MarshalAlloc.cpp
:
void* MarshalAlloc::Allocate(size_t size) { return malloc(size); }
This just wraps the basic C memory allocation function malloc
.
Again, back in PlatformInvoke::MarshalCSharpStringToCppString
we see a copy of the string into the newly-allocated memory and it is finally returned all the way back to PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974
.
After all this, we finally arrive at the call to the native function via the function pointer we got earlier: il2cppPInvokeFunc
. After it returns, we call il2cpp_codegen_marshal_free
. That’s located alongside the allocation function in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/codegen/il2cpp-codegen-il2cpp.h
:
inline void il2cpp_codegen_marshal_free(void* ptr) { il2cpp::vm::PlatformInvoke::MarshalFree(ptr); }
This again forwards to another function. In this case, MarshalFree
is in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/PlatformInvoke.cpp
and looks like this:
void PlatformInvoke::MarshalFree(void* ptr) { if (ptr != NULL) MarshalAlloc::Free(ptr); }
As expected, MarshalAlloc::Free
is in /path/to/Unity/installation/Contents/il2cpp/libil2cpp/os/Posix/MarshalAlloc.cpp
and simply looks like this:
void MarshalAlloc::Free(void* ptr) { free(ptr); }
That essentially wraps up the process to make a call into a native function from C#. There’s a lot of one-time work to be done in loading the library (e.g. with dlopen
) and the function pointer from that library (e.g. with dlsym
), but that work is all skipped by a single if
to check a static
local variable on subsequent calls. Marshaling a string
is really just a process of allocating some memory and converting it from 16-bit to 8-bit then freeing that memory.
Overall, calls into native libraries are rather fast. They’re not as fast as a call to another C# function as they do go through a function pointer, involve an if
check, and may involve marshaling, but it should be fine to make quite a few such calls per frame without any noticeable performance loss.
#1 by Baptiste Dupy on March 4th, 2019 ·
Thanks for this detailled article, it’s really interesting to see in details what’s happening under the hood.
I’ve been wondering for a while if there is a more optimised way to perform those calls by bypassing PInvoke. If you know well your program and your data you don’t need all the safety provided by PInvoke.
I’m working on a prototype using a bit of interop C#/C++ but I’m restricting usage of interop to non-performance critical code sections because from experience even with plain data (no marshalling) the cost of external function call is quite prohibitive.
By any chance, do you know what mechanism Unity uses to perform communication between C# scripts and the core engine C++ ? All I could find is usage of the attribute
in decompiled code.
I suspect it’s maybe some kind of work on the VM side.
#2 by jackson on March 5th, 2019 ·
I’m glad you enjoyed the article. :)
The only more optimized way to call into native code than P/Invoke that I can think of is to modify the output of IL2CPP such that you directly call that native code from the C++ code generated for your C# functions. This is possible on platforms like iOS where Unity generates an Xcode project that you could modify before building it but much more difficult on platforms like macOS where there is no in-between step. You would also probably need to come up with a way to automate the changes to the IL2CPP output, which may be quite tricky. This probably also isn’t a supported way to use the engine.
Perhaps the more sensible approach is to cut down on the number of P/Invoke calls you’re making. There’s usually a way to bundle up multiple calls into a single call, often with the help of a little bit of wrapper code on the native side.
#3 by Baptiste Dupy on March 13th, 2019 ·
Thanks a lot for the tip, batching calls is pretty clever and makes total sense in my case. I wonder how I did not think of that as my work as a graphic programmer is essentially batching stuff…
Anyway it opens a new path that looks interesting !
Thanks again