65.9K
CodeProject 正在变化。 阅读更多。
Home

DirectX 和纯汇编语言:做不可能的事 -第四部分(b):完整的 DirectX 应用

starIconstarIconstarIconstarIconstarIcon

5.00/5 (14投票s)

2018 年 6 月 26 日

CPOL

17分钟阅读

viewsIcon

24950

downloadIcon

105

用纯汇编语言创建的完整 DirectX 示例应用程序

概述

在发布了 IV(a) 部分好几年之后,我终于有了动力来创建完整的 DirectX 应用——尽管从功能上看它可能很小(它旋转一个三角形)。对我来说,主要问题是创建这个示例的源代码需要手动重打涉及的每一个字节的源代码。由于这需要超过 40 个小时的工作量,动力资源非常有限。

我个人应用中的大部分内容都是为我使用的特定编辑器定制格式的,在任何其他编辑器中都无法正确显示。(我不会说出我使用的编辑器,因为我认为出版它的公司/人员很邪恶且令人讨厌,我也不想给他们任何免费宣传。)此外,如果我要发布一个完整的应用,我会做得尽善尽美——这包括检查每一个字节的源代码,并确保一切都准确无误。将所有内容都迁移到汇编来编写 DirectX 应用是一个巨大的进步,而且全世界几乎没有支持。读者最不需要的就是潦草地写出的源代码,它会带来比解决更多的问题。ASM 是大多数人避讳的话题,要开始走这条路,需要破除很多错误信息。引导开发人员走向这条路的资料必须是最高质量的。

编译应用

为项目创建一个新目录,并将项目的 .zip 文件解压到那里。会有两个子目录:**Triangle** 和 **DXSampleMath**。后者将在本文后面的“令人头疼的数学库”标题下介绍。

**Triangle** 子目录中的 **go.bat** 文件用于编译项目。它引用 ml64.exe 和 link.exe,假设它们位于默认的 VS17 C++ 目录中。如果您的系统上这些路径不同,您必须更改批处理文件顶部的字符串赋值。

下面显示了 **go.bat** 的未修改内容。请注意,故意使用了没有闭合的起始引号。

@echo off

rem Set this value to the location of rc.exe under the VC directory
set rc_directory="C:\Program Files (x86)\Windows Kits\10\bin\x86

rem Set this value to the location of ml64.exe under the VC directory
set ml_directory="C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin\x86_amd64

rem Set this value to the location of link.exe under the VC directory
set link_directory="C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin

%rc_directory%\rc.exe" resource.rc
%ml_directory%\ml64.exe" /c /Cp /Cx /Fm /FR /W2 /Zd /Zf /Zi /Ta DXSample.xasm > errors.txt
%link_directory%\link.exe" DXSample.obj resource.res /debug:none /opt:ref /opt:noicf /largeaddressaware:no /def:DXSample.def /entry:Startup /machine:x64 /map /out:DXSample.exe /PDB:DXSample.pdb /subsystem:windows,6.0 "C:\Program Files (x86)\Windows Kits\10\Lib\10.0.10586.0\um\x64\kernel32.lib" "C:\Program Files (x86)\Windows Kits\10\Lib\10.0.10586.0\um\x64\user32.lib" "C:\Program Files (x86)\Microsoft DirectX SDK (August 2009)\Lib\x64\d3d11.lib" "C:\Program Files (x86)\Microsoft DirectX SDK (August 2009)\Lib\x64\d3dx11.lib" DXSampleMath.lib
type errors.txt

一个小修复

如果您不使用 WinDbg 进行调试,可以注释掉 **DXSample.asm** 文件中的第一个调用;即调用 FixWinDbg。我在我写的每个应用中都使用此调用来应对 WinDbg 中一个超过十年的 bug,即每次执行时,窗口布局都会随之改变,每个窗口的边缘都会缩小几个像素。窗口会随着每次新的执行而变得越来越小,直到最终达到最小尺寸。FixWinDbg 函数在 WinDbg 中重置窗口的位置和大小,以应对此故障。微软已多次被联系到此问题。他们选择不解决,所以如果开发人员想要解决,就必须自己动手。

**请注意,strings.asm 文件中的 win_ids 字符串是为 WinDbg 版本 10.0.17134.12 硬编码的。** 如果您要使用 FixWinDbg 并且您运行的是其他版本,您必须将此字符串中的版本号更改为与您运行的 WinDbg 版本匹配。如果未执行此操作,WinDbg 窗口将无法正确识别。

如果您不修改应用程序,除非您想跟踪代码执行,否则您不需要调试器。

应用入口点

WinMain 并不是应用程序的真正入口点。实际的入口点是在链接器命令行中声明的,可以是任何名称。此应用程序使用 Startup 作为主函数;它充当应用程序的入口点。如果需要,Startup 可以设置参数并调用 WinMain,但这样做没有明显的好处。应用程序最终只会调用必需的 WinAPI 函数来检索本应作为参数传递给 WinMain 的信息。

定义和声明结构

structuredefs.asm 文件包含应用程序使用的所有数据结构的定义。结构可以嵌套,但任何结构都必须在其被引用之前定义,即使是在另一个结构内。在 structuredefs.asm 文件中,数据结构通常按字母顺序定义,但是当一个结构(如 DXGI_SAMPLE_DESC)被另一个结构(如 D3D11_TEXTURE2D_DESC)引用时,DXGI_SAMPLE_DESC 结构必须在其被引用之前定义。因此,其定义出现在 D3D11_TEXTURE2D_DESC 定义之前的文件中。

有几种声明初始化数据结构的方法。第一种可能最常用,但不是我的最爱,因为它看起来很乱,而且很难立即将值与给定字段关联起来。**请注意,整个应用程序中唯一区分大小写的数值是外部定义文件中的。** 必须使用与从其驻留库中导出时完全相同的的大小写来声明外部函数。第一种声明结构的方法如下所示。

sampleDesc   dxgi_sample_desc   <1, 0>

应用程序采用的方法如下所示。

swapChainDesc    label   dxgi_swap_chain_desc                   ; Declare structure label
                ;------------------------------------------------
                 dword   ?                                      ; dxgi_swap_chain_desc.BufferDesc._Width
                 dword   ?                                      ; dxgi_swap_chain_desc.BufferDesc._Height
                 dword   60                                     ; dxgi_swap_chain_desc.BufferDesc.RefreshRate.numerator
                 dword   1                                      ; dxgi_swap_chain_desc.BufferDesc.RefreshRate.denominator
                 dword   DXGI_FORMAT_R8G8B8A8_UNORM             ; dxgi_swap_chain_desc.BufferDesc.Format
                 dword   DXGI_MODE_SCANLINE_ORDER_UNSPECIFIED   ; dxgi_swap_chain_desc.BufferDesc.ScanlineOrdering
                 dword   DXGI_MODE_SCALING_UNSPECIFIED          ; dxgi_swap_chain_desc.BufferDesc.Scaling
                 dword   1                                      ; dxgi_swap_chain_desc.SampleDesc.Count
                 dword   0                                      ; dxgi_swap_chain_desc.SampleDesc.Quality
                 dword   DXGI_USAGE_RENDER_TARGET_OUTPUT        ; dxgi_swap_chain_desc.BufferUsage
                 qword   1                                      ; dxgi_swap_chain_desc.BufferCount
                 qword   ?                                      ; dxgi_swap_chain_desc.OutputWindow
                 dword   1                                      ; dxgi_swap_chain_desc.Windowed
                 dword   DXGI_SWAP_EFFECT_DISCARD               ; dxgi_swap_chain_desc.SwapEffect
                 qword   0                                      ; dxgi_swap_chain_desc.Flags

这种方法可以轻松地在源代码中引用每个字段的值。编译后的输出在两种方法之间保持不变。

检查点

到目前为止,应该已经很清楚,汇编在清理源代码和轻松定位需要查找的任何内容方面提供了极大的灵活性。与 C++ 相比,源代码本身缺乏管理开销是巨大的,并且开发人员可以享受更直接、更深入的对编译输出的控制。此外,在我看来,缺乏任何类型转换是最大的好处。任何时候,当你试图挽救开发者免于自身的无能时,一种语言很快就会变成一个巨大的微观管理巨兽。“拯救开发者免于愚蠢”的方法使得寻找真正做有用工作的代码变得极其困难,而如今,这些代码本身就很少见了。通过菜单和源代码语句,需要向编译器提供如此多的显式指令,以至于不如直接用汇编编写应用程序,从而节省大量时间。

更好的是,数据就是数据。对于你要如何使用它,没有任何预设,这完全消除了类型转换。例如,如果你想翻转浮点值的符号,不需要将该值加载到寄存器中,应用某些 SSE 指令,然后将其写回内存。相反,你可以简单地切换保存浮点值最高有效位的字节的第 7 位(使用 xor byte ptr [location], 80h)。

告别那些混乱的双下划线、双星号、双冒号等,这些让源代码看起来像意大利面条工厂爆炸一样。

从 Switch 切换

在 callbacks.asm 文件中,显着缺乏任何 switch 语句(ASM 中不存在)。相反,采用了一种更直接、更合理的处理传入 Windows 消息的方法:通过查找表索引传入的消息,并在路由表上跳转到相同的索引。CPU 提供了 repnz scasq 指令,这使得与逐个值进行暴力比较相比,此类查找速度极快。放在 RAX 寄存器中的值会相对于 RDI 位置的 RCX 字节重复扫描。当找到匹配项时,扫描停止。

内存位置经常直接用作跳转目标,就像寄存器的内容可以起到此作用一样。在附加的源代码中,查找表 Main_CB_Lookup(在 lookups.asm 文件中)与 Main_CB_Rte(在 routers.asm 文件中)配对。查找表上的条目 0 在路由表上的条目 0 指向的位置进行处理。

从调用返回

Main_CB 函数——主窗口的回调函数——包含一个对 ASM 中唯一真正问题的变通方法:RET 或 return 语句。这是编译器变得“乐于助人”的地方,当它遇到 RET 语句时,会接管它认为你的意思:它想重置 RBP 并销毁函数进入时创建的堆栈帧。在此应用程序中,会在函数框架内使用句柄。每个消息使用一个句柄。这些被调用,就像函数一样,所以它们必须被返回。但是,从这些句柄返回应该只是 POP 堆栈上的返回地址并跳转到那里。由于 ASM 中自动处理函数帧的构建,遇到的每个 RET 语句都被视为“从函数返回”,并且当不应该销毁函数堆栈帧时,它就会被销毁。

为了克服这个问题,只需将 0C3h 的字节值直接内联到源代码中声明,就像声明数据一样。这将像 RET 语句一样执行,而不会破坏函数的堆栈帧,一切都很好。

请注意,所有消息处理程序都放在函数操作的 RET 语句之后,这样它们就能保证除非被显式调用,否则不会执行。

WinCall 和 LocalCall

LocalCall 宏只是生成一个 CALL 指令。它仅用于明确调用目标在应用程序内部,而不是 Windows API 的一部分。相反,WinCall 处理 64 位调用约定的所有细节。调用目标的前四个参数应始终按顺序出现在 RCX、RDX、R8 和 R9 中。超过第四个参数将被 WinCall 宏放置到堆栈上的适当位置。常量、直接内存引用和通用寄存器可用于保存第四个参数之后的任何参数。由于内部使用 RAX 来在堆栈上传递这些值,因此任何可以放入 RAX 的值都可以作为参数传递给 WinCall,在 RCX、RDX、R8 和 R9 之后(这些寄存器直接使用)。

顺便说一句,我总是避免使用 R10 和 R11 进行参数传递。这些寄存器根据 64 位约定被定义为易失性的。因此,从调用返回时它们的值永远无法保证,我出于安全考虑避免使用它们。

COM 和 ASM

COM 接口指针实际上是指向内存中某个位置的指针。在该位置有一个指向接口 vTable 的指针。在所有 COM 调用中,RCX 寄存器必须包含接口指针。虽然这可以通过研究 C++ 头文件来弄清楚,但这是不得不硬着头皮去学的令人不快的事实。所有文档化的 DirectX 方法都应假定一个未文档化的参数 this 作为第一个参数:接口指针本身必须在 RCX 中。

在此应用程序中,所有 DirectX 调用都遵循下面概述的逻辑。

  1. 将接口指针移到 RCX 中。
  2. 将 RCX 指向的值移到 RBX。即 mov rbx, [rcx]。这会将 vTable 指针移到 RBX 寄存器中。在此应用程序中,所有 COM 方法都相对于 RBX 寄存器定义。这是一个任意的决定;RBX 实际上没有用于其他什么,所以选择了它。所有 vTable(在 vTables.asm 中定义)都引用相对于 RBX 的偏移量,即。
ID3D11DeviceContext_DrawIndexed textequ <qword ptr [rbx+96]>
  1. 使用上面的例子,调用 ID3D11DeviceContext_DrawIndexed,而不是本地变量 D3D11DevCon->DrawIndexed。-> 不会被解释,因为 ASM 本身不理解 COM。

令人头疼的数学库

在理想情况下,我会为这个应用程序使用的所有数学库函数进行自定义编码。我没有。在这种情况下,我选择改用包含的 C++ 解决方案 DXSampleMath。这是一个示例应用程序,旨在介绍在 ASM 中创建 DirectX 应用程序,而可投入的时间非常有限。

DXSampleMath 包含的函数又会调用它们有时是内联的对应函数。例如,DXSampleMath .DLL 模块导出 XMMatrixMultiplyProxy。该函数只有一条语句:它将其参数传递给 XMMatrixMultiply。包装器是必需的,因为许多数学库函数是在包装函数内作为内联代码生成的。

VS2017 C++ 中的整个 .DLL 项目包含在紧邻您为 .zip 文件创建的目录下的 DXSampleMath 目录中。

结论

本文仅对应用程序的构成进行了快速概述。如果您打算扩展此应用程序或编写自己的应用程序,您将无法避免需要仔细研究相关的源代码。

我将把最重要的建议放在首位:不要为使用 ASM 而辩护。您的目标是服务最终用户,而不是让您的同行开心、获得他们的认可或避免让他们感到渺小。根据您自己的怪癖和偏好,最大限度地利用 ASM。*您并不是通过竭尽全力使您的 ASM 代码尽可能地像高级语言那样来“正确地”做事*。您没有义务遵守公司工作环境中适用于高级语言的规则,试图这样做将迅速破坏您迁移到 ASM 以实现的一切。那里的“正确”不适用于这里。那里的“正确”在这里不正确。做对您有效的事情。释放您的能力。摘掉枷锁。如果您喜欢使用全局变量,请使用它们;如果您不使用它们,请注意您选择的代价:通过动态声明数据结构或字符串节省的每 20 字节,您可能要花费 60 字节以上的额外代码来管理它。这不是智能编程。了解您正在生成的内容。没有其他语言比 ASM 更容易让您密切关注实际执行的内容。

您的大部分数据都应初始化为硬编码的静态变量。用于管理动态分配结构的内存量几乎总是导致比静态值所需的内存净损失。当您硬编码静态值时,初始化已在编译时完成,通过避免在运行时初始化数据,可以节省更多代码字节和执行时间。更糟糕的是,对局部变量的所有访问都必须是间接的——CPU 必须*每次访问*执行一个极其缓慢的减法指令。例如,MOV RAX, [RBP-38h] 需要在运行时计算 RBP-38h。编码该指令比固定内存位置需要更多的额外代码字节,此外还大大减慢了其执行速度。这种减慢将适用于您应用程序中*所有动态分配和/或局部变量*,每次访问时都会发生。本文附带的应用程序使用一个局部变量:holder,它对于使用它的每个函数中的 WinCall 宏都是必需的。所有其他数据都是静态的,除了少数几个值外,都在编译时初始化。

structures.asm 文件中的 swapChainDesc 结构声明演示了此过程。未硬编码的变量是运行时才知道的变量:主窗口的句柄及其宽度/高度。

自 1984 年以来,我几乎专门从事汇编语言的全面开发。在此期间,我学到了一件事,它远远超出了所有其他教训:*你总是可以从每一个使用该语言的人那里学到有价值的东西——特别是初学者,他们是正在寻找、思想开放、尚未被灌输到管理开发社区的官僚机构的人*。我可以向您保证,每一个因为老师的要求而涉足 ASM 的大学生都能教我一些我不知道的关于使用该语言的知识。这种情况不太可能改变。

您最大的速度提升将来自重构数学库。它是一个庞然大物,但并不令人印象深刻。它在内部反映了软件世界中罕见的糟糕组织程度。它在 CPU 寄存器广泛可用的情况下随意且鲁莽地使用内存;仅此一项开关就能呈指数级提高执行速度。与所有高级语言一样,DirectX 数学集合喜欢调用,调用,调用,多层深。每次调用,每次返回,都会消耗本可以用于改进游戏(或任何 DirectX 任务)的执行时间。C++ 使用十到十五个 CPU 指令来管理一个执行两三个指令的函数调用是很常见的。此类情况可以被 ASM 开发人员轻松覆盖,只需将两三个指令内联到一个函数中,而不是在函数中调用它们。

由于您对抗性能障碍的最有力武器是数学库,因此可以推断重构它将是您最大的挑战。这并不容易。但是重构的每个函数只需要执行一次,您将受益于一次一个函数地解决任务。

重构数学库的真正症结在于决定 SSE 演进路径的截止点。今天的 AVX-512 指令主要用于矢量数学,提供巨大的性能优势。但乔治亚州游戏镇的 Gary Gamer 可能有一个不支持 AVX-512 的旧系统。那怎么办?您必须弄清楚。我会为沿进化链的每个截止点创建一个单独的库,并在应用程序启动时根据检测到的硬件加载相应的库。这样,我的应用程序就不会因为每次调用数学库时都冗余地检查 CPU 版本而拖累。这将是一项巨大的工程,但这是我的方式——做对您有效的事情。

通过本文及其配套应用程序,您拥有了一个非常轻量级的起点。如果您认真想开辟新天地,想在标准硬件上生产人们认为不可能的 3D 产品,您将不得不习惯于抑制不耐烦,有时会踏上漫长、枯燥的信息和开发曲折之路。如果您使用 ASM,世界对您并不友好。帮助您前进的资源非常少;您的大部分进步都必须靠自己完成。而所有这些努力的回报是,当其他人都在开福特嘉年华时,您将是您圈子里唯一拥有兰博基尼的人。

突破界限,回归基础,掌控您的创作。向世界展示您能发明什么。大多数人;大多数公司,不会。他们将继续扮演纯粹的经纪人,雇用 491 家外包公司,而这些公司又各自雇用 491 家外包公司,回避工作,宁愿进行交易。他们将回避工作作为他们人生的主要目标——这是不劳而获的钱。这是他们的特权;这使得用您自己的创作轻松地将他们远远甩在后面。如果他们的做法是您的方向,您可能根本就不会阅读本文。很可能,对您来说,必须突破界限,否则您将不会快乐。您想要更多。*您是推动行业发展的人——而不是经纪人。*应用此处提供的内容,学习它,与它一起工作,在此基础上扩展,您将从人群中脱颖而出,迈向尖端。去吧,繁殖……除以……加……减……*创造!*

以此处呈现的材料为跳板,您将拥有一个通往 3D 游戏中前所未有的新视野的广阔门户。您所要做的就是走进它。并且不要害怕*努力工作*。

您将创造什么?那是您的决定。您为什么不给我们看看呢?

游戏开始!

© . All rights reserved.