В третьей статье из серии по IL2CPP мы обсудим некоторые полезные советы по отладке генерируемого кода C++: как расставлять точки останова, просматривать содержимое строк и пользовательских типов и определять места, где возникают исключения.
Учтите, что сама по себе отладка кода C++, генерируемого на основе кода .NET IL, – занятие не из приятных. Тем не менее представленные ниже советы помогут вам разобраться, как код проекта Unity выполняется на целевом устройстве (в конце статьи мы также немного поговорим об отладке управляемого кода).
Будьте готовы, что генерируемый код в вашем проекте может отличаться от того, который вы увидите здесь. В каждой новой версии Unity мы пытаемся оптимизировать генерируемый код и сделать его еще более компактным и производительным.
Подготовка к работе
В этой статье я буду использовать версию Unity 5.0.1p3 на OSX. В качестве примера возьму проект из предыдущей статьи, но в этот раз соберу его для целевой платформы iOS, используя скриптовый движок IL2CPP. Как и в предыдущей статье, я включил опцию Development Player, чтобы il2cpp.exe генерировала имена типов и методов в коде C++ на основе имен в коде IL.
Как только Unity сгенерировал проект, его можно открыть в Xcode (у меня установлена версия 6.3.1, но подойдет и другая актуальная версия), выбрать целевое устройство (iPad Mini 3 или любое устройство на iOS) и собрать.
Расстановка точек останова
Прежде чем запускать проект, я поставлю точку останова в начале метода Start в классе HelloWorld. Как вы могли видеть в предыдущей статье, в генерируемом коде C++ этот метод называется HelloWorld_Start_m3. Нажав Cmd+Shift+O, начинаем вводить имя метода, чтобы найти его в Xcode, и затем ставим в нем точку останова.
То же можно сделать, выбрав опцию Debug > Breakpoints > Create Symbolic Breakpoint (Отладка > Точки останова > Создать символьную точку останова).
Теперь, когда я запускаю проект Xcode, он прерывается в самом начале метода.
Аналогичным образом точки останова можно ставить на другие методы генерируемого кода, если вам известны их имена. Более того, Xcode позволяет ставить точки останова прямо на строках файлов. Все генерируемые файлы можно найти в проекте Xcode – для этого перейдите к директории Classes/Native в навигаторе проекта.
Просмотр строк
Просматривать представления строк IL2CPP в Xcode можно двумя способами: напрямую или с помощью утилиты из libil2cpp, которая преобразовывает строки в объекты std::string для отображения в Xcode. Давайте посмотрим на значение строки с именем _stringLiteral1.
С помощью встроенной утилиты Ctags (или команды Cmd+Ctrl+J в Xcode) мы можем перейти к определению _stringLiteral1 с типом Il2CppString_14:
Такое представление имеют практически все строки в IL2CPP. Определение Il2CppString находится в заголовочном файле object-internals.h. Сначала идет стандартный заголовок любого управляемого типа в IL2CPP – Il2CppObject (доступ к которому осуществляется при помощи объявления typedef Il2CppDataSegmentString), затем четырехбайтовая длина строки и массив двухбайтовых символов в формате UTF-16. Строки, определяемые при компиляции, такие как_stringLiteral1, получают массив символов фиксированной длины, а генерируемые во время выполнения – выделенный массив.
Добавим _stringLiteral1 в окно Watch и выберем опцию View Memory of “_stringLiteral1”, чтобы посмотреть на представление строки в памяти.
Вот что мы видим в Memory Viewer:
Заголовок строки занимает 16 байтов. За ним следуют 4 байта для размера строки со значением 0x000E (14). Следующий байт после них – это первый символ строки, 0x0048 (H). Теоретически один символ занимает 2 байта, но каждый символ в этой строке помещается в байт, поэтому в представлении справа они разграничены точками. Впрочем, всё предельно разборчиво. Такой метод, безусловно, работает, но не слишком хорошо подходит для длинных строк.
Просматривать содержимое строки также можно с помощью отладчика lldb в Xcode. Заголовок utils/StringUtils.h предоставляет нам интерфейс для полезных утилит из libil2cpp. Например, давайте вызовем метод Utf16ToUtf8 из командной строки lldb. Его интерфейс выглядит так:
Мы можем передать этому методу элемент chars структуры C++, и он возвратит его в виде объекта std::string в формате UTF-8. В таком случае можно будет распечатать содержимое строки с помощью команды p в приглашении lldb.
Просмотр пользовательских типов
Кроме того, мы можем просматривать содержимое пользовательских типов. В простом скрипте, который я написал для этого проекта, есть тип C# с именем Important и полем InstanceIdentifier. Если поставить точку останова сразу после создания второго экземпляра этого типа, можно увидеть, что значение поля InstanceIdentifier в генерируемом коде закономерно становится 1.
Таким образом, просматривать содержимое пользовательских типов в генерируемом коде можно точно так же, как и в коде C++ в Xcode.
Прерывание выполнения кода при исключении
Мне часто приходится отлаживать генерируемый код, чтобы отследить причину ошибки, и в большинстве случаев здесь замешаны управляемые исключения. Как я уже говорил, для реализации управляемых исключений в IL2CPP используются исключения C++. В Xcode есть несколько способов прервать выполнение кода в такой ситуации.
Самый простой – поставить точку останова на функции il2cpp_codegen_raise_exception, которую il2cpp.exe использует всякий раз, когда выбрасывается управляемое исключение.
Если после этого разрешить выполнение проекта, Xcode остановит его, когда код метода Start выдаст исключение InvalidOperationException. В этом случае может быть очень полезно просмотреть содержимое строки. Например, в аргументе ex можно заметить элемент ___message_2, который указывает на строку с сообщением об исключении.
С помощью вышеупомянутых действий можно вывести содержимое этой строки, чтобы узнать причину проблемы:
Обратите внимание, что эта строка очень похожа на ту, которую я показывал выше, но имена генерируемых полей немного отличаются. Поле chars называется ___start_char_1 и имеет тип uint16_t, а не uint16_t[]. Впрочем, это всё равно первый символ массива, и мы можем передать его адрес функции преобразования.
Тем не менее генерируемый код выдает не все управляемые исключения. Код среды выполнения libil2cpp делает это лишь в некоторых случаях, да и то без вызова функции il2cpp_codegen_raise_exception. Как же тогда их перехватывать?
Создав точку останова по исключению (Debug > Breakpoints > Create Exception Breakpoint), в ее параметрах можно выбрать исключения C++, чтобы выполнение кода прерывалось при выдаче исключения типа Il2CppExceptionWrapper. Таким образом, мы сможем перехватить любое управляемое исключение, так как все они заворачиваются в тип C++.
Чтобы проверить, как это работает, добавим 2 следующие строки в начало метода Start:
Вторая строка вызовет исключение NullReferenceException. Запустив этот код в Xcode (предварительно поставив точку останова по исключению), мы увидим, что его выполнение действительно прервется при выдаче исключения. Однако точка останова находится в коде libil2cpp, поэтому мы увидим только ассемблерный код. В стеке вызовов нам нужно подняться на несколько фреймов вверх к методу NullCheck, который внедряется il2cpp.exe.
На этом этапе мы можем вернуться на один фрейм назад и увидеть, что в экземпляре типа Important действительно стоит значение NULL.
Вывод
Надеюсь, эти советы помогли вам понять, как отслеживать проблемы, возникающие в коде C++, генерируемом IL2CPP. Чтобы еще лучше разобраться в такой отладке, я рекомендую покопаться в других типах, используемых в IL2CPP.
Вы можете спросить, что насчет отладчика управляемого кода. Действительно неплохо иметь возможность отлаживать управляемый код IL2CPP на устройстве.
И у нас уже есть альфа-версия встроенного отладчика для IL2CPP. Мы продолжаем работать над ним, так что следите за обновлениями.
В следующей статье из этой серии мы рассмотрим несколько способов реализации вызовов методов в IL2CPP и сравним их с точки зрения производительности.
Учтите, что сама по себе отладка кода C++, генерируемого на основе кода .NET IL, – занятие не из приятных. Тем не менее представленные ниже советы помогут вам разобраться, как код проекта Unity выполняется на целевом устройстве (в конце статьи мы также немного поговорим об отладке управляемого кода).
Будьте готовы, что генерируемый код в вашем проекте может отличаться от того, который вы увидите здесь. В каждой новой версии Unity мы пытаемся оптимизировать генерируемый код и сделать его еще более компактным и производительным.
Подготовка к работе
В этой статье я буду использовать версию Unity 5.0.1p3 на OSX. В качестве примера возьму проект из предыдущей статьи, но в этот раз соберу его для целевой платформы iOS, используя скриптовый движок IL2CPP. Как и в предыдущей статье, я включил опцию Development Player, чтобы il2cpp.exe генерировала имена типов и методов в коде C++ на основе имен в коде IL.
Как только Unity сгенерировал проект, его можно открыть в Xcode (у меня установлена версия 6.3.1, но подойдет и другая актуальная версия), выбрать целевое устройство (iPad Mini 3 или любое устройство на iOS) и собрать.
Расстановка точек останова
Прежде чем запускать проект, я поставлю точку останова в начале метода Start в классе HelloWorld. Как вы могли видеть в предыдущей статье, в генерируемом коде C++ этот метод называется HelloWorld_Start_m3. Нажав Cmd+Shift+O, начинаем вводить имя метода, чтобы найти его в Xcode, и затем ставим в нем точку останова.
То же можно сделать, выбрав опцию Debug > Breakpoints > Create Symbolic Breakpoint (Отладка > Точки останова > Создать символьную точку останова).
Теперь, когда я запускаю проект Xcode, он прерывается в самом начале метода.
Аналогичным образом точки останова можно ставить на другие методы генерируемого кода, если вам известны их имена. Более того, Xcode позволяет ставить точки останова прямо на строках файлов. Все генерируемые файлы можно найти в проекте Xcode – для этого перейдите к директории Classes/Native в навигаторе проекта.
Просмотр строк
Просматривать представления строк IL2CPP в Xcode можно двумя способами: напрямую или с помощью утилиты из libil2cpp, которая преобразовывает строки в объекты std::string для отображения в Xcode. Давайте посмотрим на значение строки с именем _stringLiteral1.
С помощью встроенной утилиты Ctags (или команды Cmd+Ctrl+J в Xcode) мы можем перейти к определению _stringLiteral1 с типом Il2CppString_14:
struct Il2CppString_14
{
Il2CppDataSegmentString header;
int32_t length;
uint16_t chars[15];
};
Такое представление имеют практически все строки в IL2CPP. Определение Il2CppString находится в заголовочном файле object-internals.h. Сначала идет стандартный заголовок любого управляемого типа в IL2CPP – Il2CppObject (доступ к которому осуществляется при помощи объявления typedef Il2CppDataSegmentString), затем четырехбайтовая длина строки и массив двухбайтовых символов в формате UTF-16. Строки, определяемые при компиляции, такие как_stringLiteral1, получают массив символов фиксированной длины, а генерируемые во время выполнения – выделенный массив.
Добавим _stringLiteral1 в окно Watch и выберем опцию View Memory of “_stringLiteral1”, чтобы посмотреть на представление строки в памяти.
Вот что мы видим в Memory Viewer:
Заголовок строки занимает 16 байтов. За ним следуют 4 байта для размера строки со значением 0x000E (14). Следующий байт после них – это первый символ строки, 0x0048 (H). Теоретически один символ занимает 2 байта, но каждый символ в этой строке помещается в байт, поэтому в представлении справа они разграничены точками. Впрочем, всё предельно разборчиво. Такой метод, безусловно, работает, но не слишком хорошо подходит для длинных строк.
Просматривать содержимое строки также можно с помощью отладчика lldb в Xcode. Заголовок utils/StringUtils.h предоставляет нам интерфейс для полезных утилит из libil2cpp. Например, давайте вызовем метод Utf16ToUtf8 из командной строки lldb. Его интерфейс выглядит так:
static std::string Utf16ToUtf8 (const uint16_t* utf16String);
Мы можем передать этому методу элемент chars структуры C++, и он возвратит его в виде объекта std::string в формате UTF-8. В таком случае можно будет распечатать содержимое строки с помощью команды p в приглашении lldb.
(lldb) p il2cpp::utils::StringUtils::Utf16ToUtf8(_stringLiteral1.chars)
(std::__1::string) $1 = "Hello, IL2CPP!"
Просмотр пользовательских типов
Кроме того, мы можем просматривать содержимое пользовательских типов. В простом скрипте, который я написал для этого проекта, есть тип C# с именем Important и полем InstanceIdentifier. Если поставить точку останова сразу после создания второго экземпляра этого типа, можно увидеть, что значение поля InstanceIdentifier в генерируемом коде закономерно становится 1.
Таким образом, просматривать содержимое пользовательских типов в генерируемом коде можно точно так же, как и в коде C++ в Xcode.
Прерывание выполнения кода при исключении
Мне часто приходится отлаживать генерируемый код, чтобы отследить причину ошибки, и в большинстве случаев здесь замешаны управляемые исключения. Как я уже говорил, для реализации управляемых исключений в IL2CPP используются исключения C++. В Xcode есть несколько способов прервать выполнение кода в такой ситуации.
Самый простой – поставить точку останова на функции il2cpp_codegen_raise_exception, которую il2cpp.exe использует всякий раз, когда выбрасывается управляемое исключение.
Если после этого разрешить выполнение проекта, Xcode остановит его, когда код метода Start выдаст исключение InvalidOperationException. В этом случае может быть очень полезно просмотреть содержимое строки. Например, в аргументе ex можно заметить элемент ___message_2, который указывает на строку с сообщением об исключении.
С помощью вышеупомянутых действий можно вывести содержимое этой строки, чтобы узнать причину проблемы:
(lldb) p il2cpp::utils::StringUtils::Utf16ToUtf8(&ex->___message_2->___start_char_1)
(std::__1::string) $88 = "Don't panic"
Обратите внимание, что эта строка очень похожа на ту, которую я показывал выше, но имена генерируемых полей немного отличаются. Поле chars называется ___start_char_1 и имеет тип uint16_t, а не uint16_t[]. Впрочем, это всё равно первый символ массива, и мы можем передать его адрес функции преобразования.
Тем не менее генерируемый код выдает не все управляемые исключения. Код среды выполнения libil2cpp делает это лишь в некоторых случаях, да и то без вызова функции il2cpp_codegen_raise_exception. Как же тогда их перехватывать?
Создав точку останова по исключению (Debug > Breakpoints > Create Exception Breakpoint), в ее параметрах можно выбрать исключения C++, чтобы выполнение кода прерывалось при выдаче исключения типа Il2CppExceptionWrapper. Таким образом, мы сможем перехватить любое управляемое исключение, так как все они заворачиваются в тип C++.
Чтобы проверить, как это работает, добавим 2 следующие строки в начало метода Start:
Important boom = null;
Debug.Log(boom.InstanceIdentifier);
Вторая строка вызовет исключение NullReferenceException. Запустив этот код в Xcode (предварительно поставив точку останова по исключению), мы увидим, что его выполнение действительно прервется при выдаче исключения. Однако точка останова находится в коде libil2cpp, поэтому мы увидим только ассемблерный код. В стеке вызовов нам нужно подняться на несколько фреймов вверх к методу NullCheck, который внедряется il2cpp.exe.
На этом этапе мы можем вернуться на один фрейм назад и увидеть, что в экземпляре типа Important действительно стоит значение NULL.
Вывод
Надеюсь, эти советы помогли вам понять, как отслеживать проблемы, возникающие в коде C++, генерируемом IL2CPP. Чтобы еще лучше разобраться в такой отладке, я рекомендую покопаться в других типах, используемых в IL2CPP.
Вы можете спросить, что насчет отладчика управляемого кода. Действительно неплохо иметь возможность отлаживать управляемый код IL2CPP на устройстве.
И у нас уже есть альфа-версия встроенного отладчика для IL2CPP. Мы продолжаем работать над ним, так что следите за обновлениями.
В следующей статье из этой серии мы рассмотрим несколько способов реализации вызовов методов в IL2CPP и сравним их с точки зрения производительности.