IL2CPP 内部:生成代码的调试提示

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
May 20, 2015|8 Min
IL2CPP 内部:生成代码的调试提示
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。

这是IL2CPP Internals系列的第三篇博文。在本篇文章中,我们将探讨一些技巧,让调试 IL2CPP 生成的 C++ 代码变得更容易一些。我们将了解如何设置断点、查看字符串和用户定义类型的内容以及确定异常发生的位置。

当我们开始调试时,请考虑我们正在调试由 .NET IL 代码生成的 C++ 代码。因此,调试它可能不会是最愉快的经历。不过,只要掌握了其中的一些技巧,就有可能深入了解 Unity 项目的代码在实际目标设备上的执行情况(我们将在本篇文章的最后谈谈托管代码的调试)。

此外,请做好准备,您的项目中生成的代码可能与此代码不同。每推出一个新版本的 Unity,我们都会想方设法使生成的代码更好、更快、更小。

设置

在这篇文章中,我使用的是 OSX 上的 Unity 5.0.1p3。我将使用与关于生成代码的帖子中相同的示例项目,但这次我将使用 IL2CPP 脚本后台为 iOS 目标机构建。正如我在上一篇文章中所做的,我将在构建时选择 "开发播放器 "选项,这样 il2cpp.exe 将根据 IL 代码中的名称生成带有类型和方法名称的 C++ 代码。

Unity 生成 Xcode 项目后,我可以在 Xcode 中打开它(我使用的是 6.3.1 版,但任何最新版本都可以),选择目标设备(iPad Mini 3,但任何 iOS 设备都可以),然后在 Xcode 中构建项目。

设置断点

在运行项目之前,我首先要在 HelloWorld 类中的 Start 方法顶部设置一个断点。正如我们在上一篇文章中所看到的,在生成的 C++ 代码中,该方法的名称是 HelloWorld_Start_m3。我们可以使用 Cmd+Shift+O 开始键入该方法的名称,以便在 Xcode 中找到它,然后在其中设置一个断点。

image05

我们还可以在 XCode 中选择 "调试 > 断点 > 创建符号断点",并将其设置为在此方法处断开。

image02

现在,当我运行 Xcode 项目时,我立即看到它在方法的开始处中断了。

如果我们知道方法的名称,就可以像这样在生成代码中的其他方法上设置断点。在 Xcode 中,我们还可以在生成的代码文件中的某一行设置断点。事实上,所有生成的文件都是 Xcode 项目的一部分。您可以在项目导航器的 Classes/Native 目录中找到它们。

image03

查看字符串

在 Xcode 中,有两种方法可以查看 IL2CPP 字符串的表示。我们可以直接查看字符串的内存,也可以调用 libil2cpp 中的字符串实用程序将字符串转换为 std::字符串,以便 Xcode 显示。让我们看看名为 _stringLiteral1的字符串的值(剧透:其内容为 "Hello, IL2CPP!")。

在内置Ctags的生成代码中(或在 Xcode 中使用 Cmd+Ctrl+J),我们可以跳转到 _stringLiteral1 的定义,看到其类型是 Il2CppString_14:

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

事实上,IL2CPP 中的所有字符串都是这样表示的。您可以在 object-internals.h 头文件中找到 Il2CppString 的定义。这些字符串包括 IL2CPP 中任何托管类型的标准头部分,即 Il2CppObject(通过 Il2CppDataSegmentString 类型定义访问),然后是一个四字节长度,接着是一个两字节字符数组。在编译时定义的字符串(如 _stringLiteral1)最终会使用一个固定长度的字符数组,而在运行时创建的字符串会使用一个已分配的数组。字符串中的字符以 UTF-16 编码。

如果我们将 _stringLiteral1 添加到 Xcode 中的观察窗口,就可以选择 "查看 _stringLiteral1 的内存 "选项来查看内存中的字符串布局。

image06

然后在内存查看器中,我们可以看到这个:

image00

字符串的首部是 16 个字节,因此跳过首部后,我们可以看到大小的 4 个字节的值是 0x000E(14)。长度之后的下一个字节是字符串的第一个字符 0x0048('H')。由于每个字符都有两个字节宽,但在这个字符串中,所有字符都只能容纳一个字节,因此 Xcode 会在右侧显示这些字符,并在每个字符之间打上圆点。不过,字符串的内容还是清晰可见。这种查看字符串的方法确实有效,但对于更复杂的字符串来说有点困难。

我们还可以通过 Xcode 中的 lldb 提示查看字符串的内容。utils/StringUtils.h 头文件为我们提供了 libil2cpp 中一些字符串实用程序的接口,我们可以使用它们。具体来说,让我们在 lldb 提示符下调用 Utf16ToUtf8 方法。它的界面是这样的

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

我们可以将 C++ 结构中的 chars 成员传递给该方法,它将返回一个 UTF-8 编码的 std::string 字符串。然后,在 lldb 提示符下,如果我们使用 p 命令,就可以打印出字符串的内容。

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器


查看用户定义的类型

我们还可以查看用户定义类型的内容。在本项目的简单脚本代码中,我们创建了一个名为 Important 的 C# 类型,其中有一个名为 InstanceIdentifier 的字段。如果我在脚本中创建重要类型的第二个实例后设置一个断点,我可以看到生成的代码已将 InstanceIdentifier 设置为 1,正如预期的那样。

image09

因此,在生成的代码中查看用户定义类型的内容与在 Xcode 中查看 C++ 代码的方法相同。

破解生成代码中的异常

我经常发现自己在调试生成的代码,试图找出错误的原因。在许多情况下,这些错误表现为托管异常。正如我们在上一篇文章中所讨论的,IL2CPP 使用 C++ 异常来实现托管异常,因此当托管异常在 Xcode 中发生时,我们可以通过几种方法来破解。

当托管异常被抛出时,最简单的方法是在 il2cpp_codegen_raise_exception 函数上设置断点,il2cpp.exe 会在任何明确抛出托管异常的地方使用该函数。

image08

如果我让项目运行,当 Start 中的代码抛出 InvalidOperationException 异常时,Xcode 就会崩溃。在这里,查看字符串内容非常有用。如果我仔细研究一下 ex 参数的成员,就会发现它有一个 ___message_2 成员,这是一个字符串,代表异常的信息。

image07

只要稍加处理,我们就可以打印出这个字符串的值,看看问题出在哪里:

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器


请注意,这里的字符串布局与上文相同,但生成字段的名称略有不同。字符字段的名称是 ___start_char_1 ,类型是 uint16_t,而不是 uint16_t[]。不过,它仍然是数组的第一个字符,因此我们可以将其地址传递给转换函数。

但并非所有托管异常都会被生成的代码明确抛出。libil2cpp 运行时代码在某些情况下会抛出托管异常,但不会为此调用 il2cpp_codegen_raise_exception。如何捕捉这些异常?

如果我们在 Xcode 中使用调试 > 断点 > 创建异常断点,然后编辑断点,就可以选择 C++ 异常,并在抛出 Il2CppExceptionWrapper 类型的异常时断点。由于该 C++ 类型用于封装所有托管异常,因此它允许我们捕获所有托管异常。

image10

让我们在脚本中的 "开始 "方法顶端添加以下两行代码来证明它的工作原理:

未知块类型 "codeBlock",请在 "serializers.type "道具中为其指定一个序列化器

这里的第二行将导致抛出 NullReferenceException。如果我们在 Xcode 中运行这段代码并设置异常断点,我们会发现当异常抛出时,Xcode 确实会断开。但是,断点位于 libil2cpp 中的代码中,因此我们看到的只是汇编代码。如果我们查看一下调用堆栈,就会发现我们需要向上移动几帧到 NullCheck 方法,该方法由 il2cpp.exe 注入到生成的代码中。

image01

从这里,我们可以再向上移动一格,看到我们的重要类型实例的值确实是 NULL。

image04

结论

在讨论了一些调试生成代码的技巧之后,我希望您能更好地了解如何跟踪 IL2CPP 生成的 C++ 代码可能存在的问题。我鼓励您研究 IL2CPP 使用的其他类型的布局,以便进一步了解如何调试生成的代码。

IL2CPP 托管代码调试器在哪里?难道我们不应该能够调试通过设备上的 IL2CPP 脚本后台运行的托管代码吗?事实上,这是可能的。现在,我们有一个内部的、阿尔法质量的 IL2CPP 托管代码调试器。它还没有准备好发布,但已在我们的路线图上,敬请期待。

本系列的下一篇文章将探讨 IL2CPP 脚本后端实现托管代码中各种类型方法调用的不同方式。我们将研究每种方法调用的运行时成本。