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.