(Russian translation from English by Maxim Voloshin)

Сегодня мы от отправимся через все, что случается, когда Вы делаете вызов P/Invoke. Мы увидим как загружаются нативные библиотеки и как работает маршалинг. Мы коснемся соглашения о вызовах и кодировки символов. И, наконец , мы лучше поймем, что происходит когда мы делаем вызов в нативный код.

Начнем с примера из недавней статьи, давайте посмотрим на типичную функцию, которую C# вызывает в нативном (C в данном случае) коде. Это один из достаточно тяжелых вызовов, дающий SQLite команду выполнить запрос:

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 это extern функция, определенная следующим образом:

[DllImport(PLUGIN_NAME, CallingConvention = CallingConvention.Cdecl)]
public static extern ReturnCode sqlite3_exec(
    IntPtr pDb,
    string sql,
    IntPtr callback,
    IntPtr userdata,
    IntPtr errmsg);

Теперь выберем IL2CPP в качестве скриптового бекенда и соберем билд для macOS на Unity 2018.3.1f1. Посмотрите в папку MyProject_macOS_BackUpThisFolder_ButDontShipItWithYourGame, там находится подпапка il2cppOutput с большим количеством .cpp файлов. Один из них содержит наш код, это Bulk_Assembly-CSharp_0.cpp, давайте откроем его и прокрутим вниз до строки:

// Sqlite/ReturnCode Sqlite::Execute(Sqlite/Pointer`1<Sqlite/Database>,System.String)

Далее идет функция на C++, которую IL2CPP скомпилировал из кода на C#. Вот как она выглядит:

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), /*скрытый аргумент*/NULL);
        return L_3;
 
}

Здесь мы видим обычную инициализацию и последующий вызов PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974. В качестве аргументов передается Ptr поле структуры Pointer и SQL команда string. Пока что это абсолютно нормальный C++ код для C# функции, который вызывает другую функцию.

Следующий шаг – проследовать за вызовом extern функции PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974. Она может быть найдена в том же файле.

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);
 
 
 
    // Маршалинг параметров  '___sql1' в нативное представление
    char* ____sql1_marshaled = NULL;
    ____sql1_marshaled = il2cpp_codegen_marshal_string(___sql1);
 
    // Вызов нативной функции
    int32_t returnValue = il2cppPInvokeFunc(___pDb0, ____sql1_marshaled, ___callback2, ___userdata3, ___errmsg4);
 
    // Очистка параметров '___sql1'
    il2cpp_codegen_marshal_free(____sql1_marshaled);
    ____sql1_marshaled = NULL;
 
    return returnValue;
}

В самом начале мы видим static переменную PInvokeFunc. В C++, локальные переменные static сохраняется от вызова к вызову, а не находится в стеке. По существу, это глобальная переменная, которая может быть доступна только из функции. Предполагается, что при первом вызове эта переменная равна null и if блок выполняется чтобы установить ее значение. Это такой же подход, как и выполнение некоторого кода для инициализации данных перед первым вызовом функции.

Основная работа заключается в вызове il2cpp_codegen_resolve_pinvoke с различными параметрами для указания как должен быть извлечен указатель на нативную функцию. Первый параметр это имя нативной библиотеки: "sqlite3", в данном случае. Строка с именем обернута макросом IL2CPP_NATIVE_STRING. Чтобы увидеть его определение, откройте /path/to/Unity/installation/Contents/il2cpp/libil2cpp/il2cpp-api-types.h. Этот путь может немного отличаться если вы используете не macOS. Определение макроса:

#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

Так как мы не используем Windows (т.е. _MSC_VER не определен), этот макрос не имеет никакого эффекта.

Следующий параметр имя функции: "sqlite3_exec".

Третий, это тип “соглашения о вызовах”, эквивалентный в C# перечислению CallingConvention.Cdecl: IL2CPP_CALL_C.

Точно так же, четвертый параметр, это тип кодировки символов в строке CHARSET_NOT_SPECIFIED. Для него не существует эквивалента в C#, но по умолчанию это CharSet.Ansi.

Пятый параметр представляет собой сумму размеров параметров, которые принимает функция в нативном коде. Обратите внимание, что после маршалинга используется размер C char*, вместо C# string.

И, наконец, шестой параметр уточняет стоит ли использовать искажение имени. Искажение выполняется компилятором для соблюдения условий уникальности имен функций C++. Это позволяет реализовать, например, перегрузки, когда все функции имеют одинаковое имя в C++, но по-прежнему уникальные имена для целей привязки к таким языкам как C#.

Теперь, понимая эти параметры, посмотрим на функцию il2cpp_codegen_resolve_pinvoke. Она располагается в файле /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));
}

Эта функция на самом деле оборачивает все параметры в PInvokeArguments и передает их в il2cpp::vm::PlatformInvoke::Resolve. При этом она создает экземпляры класса StringView для имен библиотеки и нативной функции. Этот класс может быть найден в /path/to/Unity/installation/Contents/il2cpp/libil2cpp/utils/StringView.h. По большому счету это обертка для указателя на первый символ строки и для переменной со значением ее длины. Кроме того, он содержит удобные функции для работы со строками. Вот выдержка из его начала:

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)
 
 
};

Шаблонный конструктор использует тот факт, что строка в C++ это массив символов и его длина известна во время компиляции, таким образом нет необходимости вызывать strlen или идти по всем символам пока не будет достигнут символ конца строки NUL.

Теперь давайте перейдем к функции il2cpp::vm::PlatformInvoke::Resolve. Ее можно найти в /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/PlatformInvoke.cpp и выглядит она вот так:

Il2CppMethodPointer PlatformInvoke::Resolve(const PInvokeArguments& pinvokeArgs)
{
    // Перед выполнением P/Invoke, проверьте есть ли он в хардкод-списке "известных P/Invoke", который отличается для каждой платформы.
    // Это решает несколько разных проблем при вызове P/Invoking в нативные системные библиотеки из mscorlib.dll.
    // Некоторые платформы, например, UWP, не позволят загрузить системные библиотеки динамически во время выполнения.
    // На других платформах (НЕ СТОИТ ИХ НАЗЫВАТЬ :O) функции, которые mscorlib.dll хочет вызвать через P/Invoke,
    // Существуют в  других системных библиотеках, нежели указано в DllImport атрибуте.
    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;
}

В самом начале вызывается os::LibraryLoader::GetHardcodedPInvokeDependencyFunctionPointer. В macOS она находится в /path/to/Unity/installation/Contents/il2cpp/libil2cpp/os/Posix/LibraryLoader.cpp и выглядит так:

Il2CppMethodPointer LibraryLoader::GetHardcodedPInvokeDependencyFunctionPointer(const il2cpp::utils::StringView<Il2CppNativeChar>& nativeDynamicLibrary, const il2cpp::utils::StringView<char>& entryPoint)
{
    return NULL;
}

Похоже, что для macOS нет захардкоженных функций, и этот вызов всегда возвращает null и, следовательно, условие if (function != NULL) никогда не выполнится.

Далее идет вызов utils::VmStringUtils::CaseSensitiveEquals для проверки равно ли moduleName (имя нативной библиотеки) значению "__InternalDynamic". Это просто сравнение строк и мы не будем рассматривать его детально. В данном случае, мы не использовали "__InternalDynamic", поэтому условие не выполнится и будет вызван LibraryLoader::LoadLibrary(pinvokeArgs.moduleName).

LibraryLoader::LoadLibrary также находится в /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/LibraryLoader.cpp:

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);
}

Первая строка проверяет равна ли нулю глобальная переменная
s_FindPluginCallback, которая расположена прямо перед функцией:

static Il2CppSetFindPlugInCallback s_FindPluginCallback = NULL;

Это указатель на функцию, которому может быть не присвоено значение. Похоже, что в macOS ему никогда ничего не присваивается. Но если он валиден, функция вызывается, чтобы получить измененную версию имени библиотеки. В любом случае в конце концов вызывается os::LibraryLoader::LoadDynamicLibrary. В macOS ее можно увидеть в файле /path/to/Unity/installation/Contents/il2cpp/libil2cpp/os/Posix/LibraryLoader.cpp:

void* LibraryLoader::LoadDynamicLibrary(const utils::StringView<Il2CppNativeChar>& nativeDynamicLibrary)
{
    return LoadDynamicLibrary(nativeDynamicLibrary, RTLD_LAZY);
}

Это только перегрузка той же функции с дополнительным параметром: RTLD_LAZY. Этот параметр означает, что функция с указанным именем ищется только в библиотеке, используемой в данный момент. Что касается перегрузки, она находится далее в том же файле:

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;
}

Условие if (nativeDynamicLibrary.IsEmpty()) никогда не выполнится т.к. мы никогда не передаем пустых строк. Далее вызывается LoadLibraryWithName, располагающаяся в том же самом файле:

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);
 
    // Возврат к использованию имени. Это может быть системная 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;
}

Так как мы не используем iOS, по сути эта функция вызывает dlopen, которая говорит macOS открыть нативную библиотеку. Предполагая, что открытие успешно, вызывающая сторона пропускает все проверки нулевого дескриптора, и мы просто выполним часть в конце функции, где происходит блокировка, и дескриптор передается в s_NativeHandlesOpen.

Теперь мы можем вернуться к PlatformInvoke::Resolve и обратить свое внимание на функцию os::LibraryLoader::GetFunctionPointer, которая вызывается с результатом открытия библиотеки. Находится там же:

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); // Индексация начинается с '1', потому что мы должны пропустить нижнее подчеркивание в случае искажения stdcall
    memcpy(&functionName[1], entryPoint, originalFuncNameLength);
    memset(&functionName[1] + originalFuncNameLength, 0, kBufferOverhead);
 
    // Если флаг 'dont mangle' не установлен, функция 'W' более приоритетна, по сравнению с оригинальным именем чем функция 'A' (да, серьезно)
    if (pinvokeArgs.charSet == CHARSET_UNICODE)
 
        functionName[originalFuncNameLength] = 'W';
        functionPtr = dlsym(dynamicLibrary, functionName.c_str() + 1);
        if (functionPtr != NULL)
            return reinterpret_cast<Il2CppMethodPointer>(functionPtr);
 
        // Если поиск функции с учетом кодировки символов неудачен, пробуем с оригинальным именем
        functionPtr = dlsym(dynamicLibrary, entryPoint);
 
    else
 
        functionPtr = dlsym(dynamicLibrary, entryPoint);
        if (functionPtr != NULL)
            return reinterpret_cast<Il2CppMethodPointer>(functionPtr);
 
        // Если поиск с оригинальным именем неудачен, пробуем исказить имя
        functionName[originalFuncNameLength] = 'A';
        functionPtr = dlsym(dynamicLibrary, functionName.c_str() + 1);
 
 
    return reinterpret_cast<Il2CppMethodPointer>(functionPtr);
}

Т.к. isNoMangle имеет значение false, ветвь if (pinvokeArgs.isNoMangle) не выполнится и нам нужно сделать немного больше работы. В частности, мы устанавливаем для functionName имя нативной функции, а затем, поскольку мы не используем CHARSET_UNICODE, мы игнорируем его и вместо этого вызываем dlsym с entryPoint. Если мы не получим указатель на нативную функцию мы меняем functionName и передаем его в dlsym. В конце мы приводим тип указателя на нативную функцию к желаемому типу.

Обратите внимание, что ни эта функция ни PlatformInvoke::Resolve никогда не использовали тип “соглашения о вызовах”. Указание разных типов соглашения, может привести к генерированию разных параметров (например, неиспользованный IL2CPP_CALL_DEFAULT), но, в конце концов, настройки игнорируются, если только не выполняется сборка для Windows.

Теперь мы можем вернуться назад к нашей extern функции: PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974. Предполагая, что все работает, перейдем к, непосредственно, самому вызову функции. Предварительно, нам необходимо преобразовать C# string в строку в стиле C: char*, которая является указателем на первый символ массива символов. Это делает функция il2cpp_codegen_marshal_string, из файла /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);
}

Она просто передает параметры в il2cpp::vm::PlatformInvoke::MarshalCSharpStringToCppString, давайте откроем ее в /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;
}

RuntimeString это тот же самый тип, что и Il2CppString, поэтому параметр не меняется.

В свою очередь вызывается функция utils::StringUtils::Utf16ToUtf8 чтобы конвертировать 16 битную C# строку в 8 битную C строку. Ее можно увидеть в файле /path/to/Unity/installation/Contents/il2cpp/libil2cpp/utils/StringUtils.cpp:

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;
}

Первая часть получает длину строки. Вторая часть резервирует достаточно места в std::string, затем вызывает utf8::unchecked::utf16to8 чтобы ее заполнить. Эта функция лежит в файле /path/to/Unity/installation/Contents/il2cpp/libil2cpp/utils/utf8-cpp/source/utf8/unchecked.h как часть другой библиотеки. Мы не будем детально рассматривать ее работу т.к. это простое преобразование из UTF-16 в UTF-8.

Возвращаясь к PlatformInvoke::MarshalCSharpStringToCppString, мы продолжаем вызывая MarshalAllocateStringBuffer. Рассмотрим ее в /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/PlatformInvoke.h:

template<typename T>
static T* MarshalAllocateStringBuffer(size_t numberOfCharacters)
{
    return (T*)MarshalAlloc::Allocate(numberOfCharacters * sizeof(T));
}

Таким образом это всего лишь вызов MarshalAlloc::Allocate, давайте проследуем по пути до файла в macOS /path/to/Unity/installation/Contents/il2cpp/libil2cpp/os/Posix/MarshalAlloc.cpp:

void* MarshalAlloc::Allocate(size_t size)
{
    return malloc(size);
}

Это обертка над базовой функцией C для выделения памяти malloc.

Снова, возвращаясь к PlatformInvoke::MarshalCSharpStringToCppString мы видим копирование строки в свежевыделенную память и, снова переходим к PInvoke_sqlite3_exec_m41306BF2F0AF2643E884D0594432D388B61C4974.

После всего этого мы, наконец, переходим к вызову нативной функции через указатель, полученный ранее: il2cppPInvokeFunc. Далее, мы вызываем il2cpp_codegen_marshal_free. Она располагается рядом с функцией выделения памяти /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);
}

И снова, параметр передается в другую функцию.В данном случае, в MarshalFree из файла /path/to/Unity/installation/Contents/il2cpp/libil2cpp/vm/PlatformInvoke.cpp:

void PlatformInvoke::MarshalFree(void* ptr)
{
    if (ptr != NULL)
        MarshalAlloc::Free(ptr);
}

Ожидаемо, MarshalAlloc::Free находится в файле /path/to/Unity/installation/Contents/il2cpp/libil2cpp/os/Posix/MarshalAlloc.cpp и выглядит довольно просто:

void MarshalAlloc::Free(void* ptr)
{
    free(ptr);
}

По сути это обертка нужна для того, чтобы сделать вызов нативной функции из C#.

В конечном итоге выполняется очень много работы, для загрузки библиотеки (например, через dlopen) и получения из нее указателя на функцию (например, через dlsym), но эта работа будет пропущена, благодаря единственному if проверяющему static переменную при последующих вызовах. Маршалинг строки, это просто процесс выделения небольшого объема памяти и конвертация из 16 битного формата в 8 битный, с последующим освобождением.

В целом, вызовы в нативные библиотеки скорее быстрые, чем медленные. Они не так быстры, как вызовы функций в C#, потому что они получают указатель на функцию, включая необходимые проверки, и могут включать маршалинг. Но нормально делать довольно много таких вызовов за кадр без какой-либо заметной потери производительности.