Внутреннее устройство IL2CPP: Советы по отладке сгенерированного кода

Это третья запись в блоге из серии IL2CPP Internals. В этом посте мы рассмотрим несколько советов, которые сделают отладку C++ кода, сгенерированного IL2CPP, немного проще. Мы увидим, как устанавливать точки останова, просматривать содержимое строк и типов, определяемых пользователем, и определять места возникновения исключений.
Чтобы разобраться в этом, учтите, что мы отлаживаем сгенерированный код C++, созданный из IL-кода .NET. Поэтому его отладка, скорее всего, будет не самым приятным занятием. Тем не менее, используя некоторые из этих советов, можно получить значимое представление о том, как код проекта Unity выполняется на реальном целевом устройстве (мы немного поговорим об отладке управляемого кода в конце статьи).
Также будьте готовы к тому, что сгенерированный код в вашем проекте будет отличаться от этого кода. С каждой новой версией Unity мы ищем способы сделать генерируемый код лучше, быстрее и меньше.
Установка
В этом посте я использую Unity 5.0.1p3 на OSX. Я использую тот же пример проекта, что и в посте о сгенерированном коде, но на этот раз я буду собирать для iOS с использованием бэкенда сценариев IL2CPP. Как и в предыдущем посте, я буду собирать с выбранной опцией "Development Player", так что il2cpp.exe сгенерирует код C++ с именами типов и методов, основанными на именах в IL-коде.
После того как Unity закончит генерировать проект в Xcode, я могу открыть его в Xcode (у меня версия 6.3.1, но любая последняя версия должна работать), выбрать целевое устройство (iPad Mini 3, но любое устройство iOS должно работать) и собрать проект в Xcode.
Установка точек останова
Перед запуском проекта я сначала установлю точку останова в верхней части метода Start в классе HelloWorld. Как мы видели в предыдущем посте, имя этого метода в сгенерированном коде C++ - HelloWorld_Start_m3. Мы можем использовать Cmd+Shift+O и начать вводить имя этого метода, чтобы найти его в Xcode, а затем установить в нем точку останова.

Мы также можем выбрать Debug > Breakpoints > Create Symbolic Breakpoint в XCode и установить прерывание на этом методе.

Теперь, когда я запускаю проект в Xcode, я сразу вижу, что он прерывается в начале метода.
Мы можем установить точки останова на другие методы в сгенерированном коде, если знаем имя метода. Мы также можем установить точки останова в Xcode на определенную строку в одном из сгенерированных файлов кода. Фактически, все сгенерированные файлы являются частью проекта Xcode. Вы найдете их в Навигаторе проекта в каталоге Classes/Native.

Просмотр строк
Существует два способа просмотра представления строки IL2CPP в Xcode. Мы можем просматривать память строки напрямую или вызвать одну из строковых утилит в libil2cpp, чтобы преобразовать строку в std::string, которую Xcode может отобразить. Давайте посмотрим на значение строки с именем _stringLiteral1 (предупреждение о спойлере: ее содержимое - "Hello, IL2CPP!").
В сгенерированном коде со встроенными Ctags (или с помощью Cmd+Ctrl+J в Xcode) мы можем перейти к определению _stringLiteral1 и увидеть, что его тип - Il2CppString_14:
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
На самом деле, все строки в IL2CPP представлены именно так. Определение Il2CppString можно найти в заголовочном файле object-internals.h. Эти строки включают стандартную заголовочную часть любого управляемого типа в IL2CPP, Il2CppObject (доступ к которой осуществляется через типизированное определение Il2CppDataSegmentString), затем длину в четыре байта, а затем массив из двух байтов символов. Строки, определенные во время компиляции, например _stringLiteral1, имеют массив символов фиксированной длины, в то время как строки, созданные во время выполнения, имеют выделенный массив. Символы в строке кодируются как UTF-16.
Если мы добавим _stringLiteral1 в окно наблюдения в Xcode, мы можем выбрать опцию View Memory of "_stringLiteral1", чтобы увидеть расположение строки в памяти.

Затем в окне просмотра памяти мы видим следующее:

Член заголовка строки составляет 16 байт, поэтому, пропустив его, мы увидим, что четыре байта для размера имеют значение 0x000E (14). Следующий байт после длины - первый символ строки, 0x0048 ('H'). Поскольку каждый символ имеет ширину два байта, а в данной строке все символы помещаются только в один байт, Xcode отображает их справа с точками между каждым символом. Тем не менее, содержимое строки хорошо видно. Этот способ просмотра строки действительно работает, но для более сложных строк он несколько затруднителен.
Мы также можем просмотреть содержимое строки из подсказки lldb в Xcode. Заголовок utils/StringUtils.h предоставляет нам интерфейс для некоторых строковых утилит в libil2cpp, которые мы можем использовать. В частности, давайте вызовем метод Utf16ToUtf8 из подсказки lldb. Его интерфейс выглядит следующим образом:
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
Мы можем передать этому методу член chars структуры C++, и он вернет строку std::string в кодировке UTF-8. Затем в приглашении lldb, используя команду p, мы можем распечатать содержимое строки.
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
Просмотр типов, заданных пользователем
Мы также можем просмотреть содержимое определенного пользователем типа. В коде простого сценария в этом проекте мы создали тип C# с именем Important и полем InstanceIdentifier. Если я установлю точку останова сразу после создания второго экземпляра типа Important в сценарии, я увижу, что сгенерированный код установил InstanceIdentifier в значение 1, как и ожидалось.

Поэтому просмотр содержимого пользовательских типов в сгенерированном коде осуществляется так же, как и в коде C++ в Xcode.
Разбор исключений в сгенерированном коде
Часто я сталкиваюсь с отладкой сгенерированного кода, пытаясь отследить причину ошибки. Во многих случаях эти ошибки проявляются в виде управляемых исключений. Как мы уже говорили в прошлом посте, IL2CPP использует исключения C++ для реализации управляемых исключений, поэтому мы можем нарушить ситуацию, когда управляемое исключение возникает в Xcode, несколькими способами.
Самый простой способ прерывания при возникновении управляемого исключения - установить точку останова на функции il2cpp_codegen_raise_exception, которая используется il2cpp.exe в любом месте, где явно возникает управляемое исключение.

Если затем запустить проект, Xcode сломается, когда код в Start выбросит исключение InvalidOperationException. Это место, где просмотр содержимого строки может быть очень полезен. Если я покопаюсь в членах аргумента ex, то увижу, что у него есть член ___message_2, который является строкой, представляющей сообщение об исключении.

Немного повозившись, мы можем вывести значение этой строки и понять, в чем проблема:
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
Обратите внимание, что строка здесь имеет ту же компоновку, что и выше, но имена генерируемых полей немного отличаются. Поле chars имеет имя ___start_char_1, а его тип - uint16_t, а не uint16_t[]. Однако это все еще первый символ массива, поэтому мы можем передать его адрес в функцию преобразования и обнаружить, что сообщение в этом исключении довольно утешительное.
Но не все управляемые исключения явно выбрасываются генерируемым кодом. Код времени выполнения libil2cpp будет бросать управляемые исключения в некоторых случаях, и он не будет вызывать il2cpp_codegen_raise_exception для этого. Как мы можем поймать эти исключения?
Если мы воспользуемся Debug > Breakpoints > Create Exception Breakpoint в Xcode, а затем отредактируем точку останова, мы можем выбрать исключения C++ и прерваться при возникновении исключения типа Il2CppExceptionWrapper. Поскольку этот тип C++ используется для обертывания всех управляемых исключений, он позволит нам перехватывать все управляемые исключения.

Давайте докажем, что это работает, добавив следующие две строки кода в верхнюю часть метода Start нашего сценария:
Неизвестный тип блока "codeBlock", укажите для него сериализатор в свойстве `serializers.types`.
Вторая строка здесь приведет к выбросу NullReferenceException. Если мы запустим этот код в Xcode с установленной точкой останова исключения, то увидим, что Xcode действительно прервется при возникновении исключения. Однако точка останова находится в коде в libil2cpp, поэтому все, что мы видим, - это ассемблерный код. Если мы посмотрим на стек вызовов, то увидим, что нам нужно переместиться на несколько кадров вверх к методу NullCheck, который внедряется il2cpp.exe в сгенерированный код.

Отсюда мы можем переместиться еще на один кадр вверх и увидеть, что наш экземпляр типа Important действительно имеет значение NULL.

Заключение
После обсуждения нескольких советов по отладке сгенерированного кода я надеюсь, что вы лучше понимаете, как отследить возможные проблемы с кодом на C++, сгенерированным IL2CPP. Я рекомендую вам изучить компоновку других типов, используемых IL2CPP, чтобы узнать больше о том, как отладить сгенерированный код.
А где же отладчик управляемого кода IL2CPP? Разве мы не должны иметь возможность отлаживать управляемый код, запущенный через бэкенд сценариев IL2CPP на устройстве? На самом деле, это возможно. Теперь у нас есть внутренний отладчик управляемого кода альфа-качества для IL2CPP. Он еще не готов к выпуску, но находится в нашей дорожной карте, так что следите за новостями.
В следующем посте этой серии мы рассмотрим различные способы, которыми скриптовый бэкенд IL2CPP реализует различные типы вызовов методов, присутствующих в управляемом коде. Мы рассмотрим стоимость выполнения каждого типа вызова метода.
