DirectX 和纯汇编语言:实现不可能 - 第四部分(a)





5.00/5 (5投票s)
初始化 DirectX。
概述
本文将清理本系列前几篇文章中的低效率之处。第四部分应用程序(将在下一期,即第四部分(b)中附带下载)将初始化 DirectX,实现应用程序的消息循环,并将一个空白场景渲染到屏幕上。之所以分部分呈现,是因为内容非常多。与任何事物一样,您接触得越多,它就越会成为第二天性。最大的挑战在于从高级语言思维方式向在汇编语言的领域中进行心智工作的转变。
首先:调用
首要任务是清理本系列前三篇文章中极其低效的 `WinCall` 宏。在(希望)为实现对汇编语言/Windows 宇宙的基本理解打下了足够的基础之后,现在可以介绍全新改进的 `WinCall` 宏。
WinCall macro call_dest:req, parms:vararg ; local jump_1, lpointer, numArgs ; numArgs = 0 ; for parm, <parms> ; numArgs = numArgs + 1 ; endm ; if numArgs lt 4 ; numArgs = 4 ; endif ; mov holder, rsp ; sub rsp, numArgs * 8 ; and rsp, 0FFFFFFFFFFFFFFF0h ; lPointer = 0 ; for parm, <parms> ; if lPointer gt 24 ; mov rax, parm ; mov [ rsp + lPointer ], rax ; elseif lPointer eq 0 ; mov rcx, fname ; elseif lPointer eq 8 ; mov rdx, fname ; elseif lPointer eq 16 ; mov r8, fname ; elseif lPointer eq 24 ; mov r9, fname ; endif ; lPointer = lPointer + 8 ; endm ; call call_dest ; mov rsp, holder ; endm ;
请记住,`holder` qword 变量是一个局部变量,在使用 `WinCall` 宏的每个函数中都必须声明。如果未这样做,编译器将失败。
修改后的 `WinCall` 宏比其“仅供教育使用”的前身效率高得多。不再需要传递参数计数,并且大部分工作在编译时完成,而不是在运行时完成。
宏汇编器包含一个用于返回传递给宏的参数数量的运算符,但至少在我使用的 `ML64.EXE` 版本中,它似乎不合作。因此,计数必须在手动循环中计算,但这项工作仍在编译时完成。
在宏的第一行,`:req` 表示“必需”。必须传递该参数——它是要调用的目标函数。参数 `parms` 是一个可变参数计数(`:vararg`),传递零个参数与传递一千个参数一样可以接受:无论函数需要多少。 (如果您要调用的函数实际上需要一千个参数,请查阅文档,因为您可能对它的理解有些偏差。)
让宏将值放入必需的寄存器以及堆栈中。RAX 将加载每个传递的参数;如果需要,RAX 的内容随后将被放入堆栈上的相应位置。因此,将任何可以合法加载到通用寄存器中的引用作为参数传递给宏都可以接受。您可以使用变量、偏移量或任何可以直接放入寄存器的内容。第四部分示例代码中的函数调用将演示合法内容的完整范围。
由于 RSP 在调用目标函数时会减少,清除低 4 位可确保其为 16 字节对齐——这是将由 XMM 寄存器访问的浮点值放入堆栈的要求。在示例应用程序的过程中,这种情况将非常普遍。目标函数返回后 RSP 会重置,因此 RSP 的调整量(在合理范围内)实际上无关紧要。如果 RSP 的低四位已经清除,则它们不会改变。否则,它们将被清除。因此,任何对这些位是否设置的测试都是浪费时间,没有理由这样做。
DirectX 结构
如果本文列出了示例代码使用的每一个 DirectX 结构,您将需要花费大量时间来阅读。示例代码使用的结构都定义在源代码本身中——在 `structuredefs.asm` 文件里。
如前所述,DirectX 喜欢嵌套结构,而且毫不避讳这样做。就数据结构而言,汇编语言与其他语言的主要区别在于不允许使用未命名的、嵌套的联合体和结构。一切都需要命名。在这里和那里,这会影响对深度嵌套结构字段的引用,因此必须牢记这一点。
预打包值
这个概念之前已经讲过,但在此重申:尤其是对于性能密集型的 DirectX 应用程序,在运行时分配常量或已知的其他值是一个大忌。在其他语言中,这可能会使代码看起来更漂亮,但在汇编语言中,在声明中将其初始化为启动值的结构字段非常简单。以任何其他方式在运行时分配值,都将需要该值存在于源代码字节中,此外还需要所有用于移动它的代码字节。更糟糕的是,这些移动的目标——内存中的结构字段本身——已经作为预留空间存在,存储 0 与存储所需的初始值没有区别。
经验法则是,如果一个字段的值直到应用程序执行才能确定,那么它必须在运行时分配。否则,预设值。这将在第四部分(b)中演示。
汇编语言和 COM
DirectX 基于 COM(组件对象模型)。COM 的基本操作有一个指向函数的指针表,称为方法;该表称为**虚拟方法表**或**vTable**。接口指针指向另一个指针,该指针指向 vTable,如下图所示。
[引用的图片在 CodeProject 编辑器中显示正常,但在预览模式下,它显示为损坏的链接,并且在最终文章查看时,您甚至看不到它——它只是空白。没人知道为什么。所以图片的 URL 是 http://www.starjourneygames.com/com_vtable.jpg。]
万能的 RIID
`riid` 参数在 COM 操作中经常使用。`Riid` 是 **reference interface identifier**(引用接口标识符)的缩写;它本质上是 **universally unique identifier**(通用唯一标识符)、**UUID** 或 **Globally Unique Identifier**(全局唯一标识符)、**GUID** 的别名。
Riid:每个 COM 接口都有一个。它是一个唯一标识整个接口的单位的值。当您在汇编语言中使用任何 COM 子系统时,您必须找到该接口的 `riid` 的确切值。有时,它们可以通过在 Windows SDK Include 目录的所有头文件中全局搜索 `IID_<interface name>` 来找到。有时它们被声明为外部的,您需要实际在 Visual Studio 中使用该值才能知道它是什么。有时您可以在网上找到所需的值。无论如何,当需要 `riid` 值时,必须在某处找到它,然后在数据段中声明。
从 CPU 的角度来看,`riid` 以字节字符串的形式存储。为了在声明它们作为数据时尽可能地保持一致,我通常使用的格式是 dword、word、word,然后是 8 个字节。在 MSDN 的头文件中,`riid` 最常以这种格式列出。
db6f6ddb-ac77-4e88-8253-819df9bbf140
将此转换为所需的汇编语言声明,结果如下:
IID_ID3D11Device dword 0DB6F6DDBh word 0AC77h word 04E88h byte 082h byte 053h byte 081h byte 09Dh byte 0F9h byte 0BBh byte 0F1h byte 040h
偶尔,DirectX 头文件会随机地以以下格式存储 `riid`:
0xDB, 0x6D, 0x6F, 0xDB, 0x77, 0xAC, 0x88, 0x4E, 0x82, 0x53, 0x81, 0x9D, 0xF9, 0xBB, 0xF1, 0x40
在这种情况下,必须对 Intel 的小端内存格式进行调整——除非您将整个 `riid` 声明为字节字符串,在这种情况下,您可以按原样使用您正在处理的数据。如果您打算使用 dword/word/word/bytes 格式,那么前四个字节必须反转才能将它们声明为 dword。
0xDB, 0x6D, 0x6F, 0xDB is declared as a dword of value 0DB6F6DDBh.
接下来的两个字必须反转,将 0x77, 0xAC 和 0x88, 0x4E 声明为:
word 0AC77h word 04E88h
Riid 值属于那种您无法逃避在汇编语言应用程序中使用它们的痛苦的情况。我个人的政策是,一旦我查找了一个 `riid` 值,我就会将其——完全格式化为数据声明——保存在一个专门用于保存 `riid` 值的特殊文件中。这样,我只需要查找每个我使用的值一次。
示例代码中使用的 `riid` 值都定义在这些源文件中。
调用方法
文本等价项,或称为 *textequ* 声明,是您最接近引用汇编语言中 COM 方法的“原生”格式的。您不能使用双冒号 (::) 与文本等价项;我使用下划线代替。重要的是,可以通过这种方式按名称调用方法。
每个方法都由其接口 vTable 中的给定位置指向。在 64 位 Windows 中,vTable 中的每个条目当然是 8 字节长,包含指向方法代码的 64 位指针。
构建文本等价项列表是设置从汇编语言使用 COM 的另一个(也是最后一个)可能耗费大量工作的步骤。例如,`IUnknown` 接口,它几乎开始每个 COM vTable,定义如下:
vTable [ 0 ] -> IUnknown::QueryInterface vTable [ 8 ] -> IUnknown::AddRef vTable [ 16 ] -> IUnknown::Release
在示例代码中,`IUnknown` 将定义为:
IUnknown_QueryInterface textequ <qword ptr [rbx]> IUnknown_AddRef textequ <qword ptr [rbx+8]> IUnknown_Release textequ <qword ptr [rbx+16]>
永远记住 RBX 寄存器保存 vTable 指针,调用 COM 方法 `IUnknown::QueryInterface` 的示例调用将如下所示:
lea r8, INewInterface lea rdx, IID_INewInterface mov rcx, IUnknown mov rbx, [rcx] WinCall IUnknown_QueryInterface, rcx, rdx, r8
在上面的示例中,`INewInterface` 是一个虚构的接口指针。`QueryInterface` 将使用指向请求接口的指针设置它,该接口由 `IID_INewInterface` 指定。 **对于每个调用的 COM 方法,RCX 必须保存接口指针**。这 nowhere 被明确记录,因为所有其他使用 COM 的语言都设计成由编译器插入该代码,并且从不要求开发人员担心它。在汇编语言中,情况并非如此:**RCX 必须保存接口指针**。RCX 作为接口指针,指向 vTable 的指针。在上面的示例中,RBX 加载了 vTable 的指针。随后,`IUnknown_QueryInterface` 的文本等价项是 `
如果您在汇编语言中处理 COM,很快就会发现这一切都成为第二天性。在示例代码中,RCX(如必须的)始终保存接口指针,而 RBX 在整个应用程序中使用,指向所有正在调用的方法的 vTable。
如果您需要构建自己的文本等价项列表,**您必须按照 vTable 的顺序进行**。在其在线文档中,MSDN(几乎)总是按字母顺序列出接口的方法。这使得查找给定方法的文档变得容易得多,但为了构建表示 vTable 访问的文本等价项,必须记住,实际 vTable 中的方法几乎从不按字母顺序排列。同样,必须查阅头文件以获取正确的 vTable 顺序。Visual Studio 中的 Intellisense 也会按字母顺序列出方法。这对于构建您的文本等价项来说也无济于事。
通常,在头文件中,搜索文件中的 `IID_<interface name>` 会引导您找到类似以下的内容:
EXTERN_C const IID IID_ID3D11Device; #if defined(__cplusplus) && !defined(CINTERFACE) MIDL_INTERFACE("db6f6ddb-ac77-4e88-8253-819df9bbf140") ID3D11Device : public IUnknown { public: virtual HRESULT STDMETHODCALLTYPE CreateBuffer( . . .
紧随其后的是整个 vTable——或其扩展。在这种情况下,请注意这一行:
ID3D11Device : public IUnknown
这意味着整个 `IUnknown` 接口构成了 vTable 的开头。紧随这些声明之后,可能会让您进行一次非常漫长的搜索,查找接口 A 以接口 B 开头,等等。许多接口,例如,都以 `IDispatch` 开头,而 `IDispatch` 又以 `IUnknown` 开头。
为了避免这种搜索,当您找到上面显示的内容时,向下滚动到 vTable 声明的末尾。在那里,您几乎总会找到“C 风格接口”声明,它们省略了所有嵌套,只声明接口中的每个方法。这就是我构建列表的基础。
#else /* C style interface */ typedef struct ID3D11DeviceVtbl { BEGIN_INTERFACE HRESULT ( STDMETHODCALLTYPE *QueryInterface )( . . .
COM:类别已结束
总的来说,COM 是一个庞然大物。本文讨论的只是启动 DirectX 所需的基本信息。与微软所做的一切一样,其他 COM 接口可能会改变规则、弯曲规则或打破规则。因此,对于 DirectX 有效的方法,如果最终深入到其他领域,例如 `IWebBrowser`,则不具有普遍性。在我所有使用 DirectX 的工作中,我都未见过任何此类违规行为;COM 的使用与本文提供的信息 100% 一致。
就本系列文章而言,您无需进行任何此类研究即可构建所需的数据、声明和结构。它们都将包含在附带的源代码中。
下一步……
下一期,第四部分(b),将直接深入初始化 DirectX——从 **D3DCreateDeviceAndSwapChain** 函数调用开始。