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

Direct X 和纯汇编语言:实现不可能 - 第二部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (9投票s)

2017年6月8日

CPOL

19分钟阅读

viewsIcon

18856

第二部分 - 为迁移到汇编奠定基础

本系列的第一部分可以在这里找到。

附加说明

在深入细节之前,本文附带一个重要的免责声明。

汇编语言的每一位初学者都能教会我一两件事。当我看到他们的作品时,通常都能有所收获。x64 架构中有我应该知道但仍然不知道的指令。作为我主要的编程语言,经过几十年的汇编编程,我倾向于固守我的方式,而有时这些方式会是限制性的。你应该假设本系列文章中提出的所有内容都可以被改进。而且毫无疑问会被改进。我不是任何事物的最终权威,我也不自称是。

归根结底,我作为一个开发者真正的价值是由我为之开发的最终用户来衡量的——而不是由其他开发者来衡量的。你将在你所承担的任何任务中融入你自己的风格和天赋;那些对你来说独特的小细节。它们加起来就构成了一个我们无法在其他地方精确复制的更宏大的整体。你们是世界所拥有的全部。你们所做的事情很重要,无论是在工作场所还是在自己的时间里。它非常重要。

本系列文章是一扇门,而不是一条严谨的道路。当你找到更好的做事方式时(而且你会的!),不要犹豫使用它。

扯皮!

在第一部分中,我曾说过第二部分将涵盖主窗口的创建和 DirectX 的初始化。这些内容已被推迟到第三部分和第四部分,因为参与这些任务之前极有必要的前言部分比原计划耗费了更长的时间。既然我在这里是为了写文章,而不是为了写一篇需要一次性读完的《战争与和平》,我必须调整我的努力。汇编语言是一个不同的世界,关于它有很多东西要说,仅仅是为了涵盖与“普通”高级语言的差异。所以,为了让每篇文章的长度保持在合理的范围内,我正在偏离我原来的计划,将其分解得比我最初认为有必要的分得更多。

工具

汇编器并不缺乏。我使用微软提供的标准 ML64.EXE;它随 Visual Studio 一起安装。市面上各种汇编器之间的差异相对较小,所以如果你选择使用 ML64.EXE 以外的其他东西,你将不得不对本系列提供的源代码进行任何符合该汇编器要求的调整。

链接器也是如此:我使用的是随 Visual Studio 一起安装的 LINK.EXE。

资源编译器也是如此,**rc.exe**。这三个文件都存在于你的 Visual Studio 安装路径的 VC\bin 目录(或其子目录)中。

我有一个批处理文件可以完成所有事情——构建 resource.rc 文件,汇编,然后链接。用于编译我的 **edit** 副项目的批处理文件的内容(稍后讨论)如下

"C:\Program Files (x86)\Windows Kits\10\bin\x86\rc.exe" resource.rc
"C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin\x86_amd64\ml64.exe" /c /Cp /Cx /Fm /FR /W2 /Zd /Zf /Zi /Ta edit.xasm
"C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin\link.exe" edit.obj resource.res /debug /opt:ref /opt:noicf /largeaddressaware:no /assemblydebug /def:edit.def /entry:Startup /machine:x64 /map /out:edit.exe /PDB:edit.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)\Windows Kits\10\Lib\10.0.10586.0\um\x64\gdi32.lib" "C:\Program Files (x86)\Windows Kits\10\Lib\10.0.10586.0\um\x64\ComCtl32.lib" "C:\Program Files (x86)\Windows Kits\10\Lib\10.0.10586.0\um\x64\msimg32.lib"

无所不能的文本编辑器

如果你搜索“汇编语言 IDE”,你会看到一列汇编语言的集成开发环境 (IDE)。我个人没有使用过它们,主要是因为我非常依赖我当前的文本编辑器。我不打算提它的名字,因为作者在几年前我尝试升级时非常粗鲁,而且更不专业(我付了全价;他只是不想提供产品)。这是一个我一直使用该编辑器,并且只是卡在那里的一种情况。我目前正在作为一个副项目开发我自己的编辑器;在此之前,我将继续使用我正在使用的。所以你需要找到一个你喜欢的文本编辑器,或者一个支持汇编语言的 IDE。

一个能够节省你大量时间的文本编辑器的要求是它支持列块编辑。这就是你可以选中一行或多行文本并对它们进行操作——剪切、复制、粘贴、缩进等。除此之外,看看市面上有哪些,看看你喜欢什么。

基于你喜欢其他语言的编辑器来选择编辑器,当你处理汇编时,可能不会让你满意。多尝试,看看市面上有哪些。

如果你是汇编语言的新手,我强烈建议你经常回顾文本编辑器的选项,至少在早期是这样。随着你的知识增长,你将越来越清楚自己喜欢什么,不喜欢什么,并且能够做出更好的选择。

我个人的建议:不要吝啬。如果你真的喜欢一个编辑器,但它要价 99 美元或 79 美元之类,就付钱吧,别再纠结了。不值得为了省钱而偷工减料。

忘记你学过的

如果你和大多数人一样,你会做的第一件事就是竭尽全力让你的汇编语言代码看起来尽可能像高级语言代码。根深蒂固的开发社区的教条影响很深,我们都被条件化,认为违反一个被普遍认为是“正确”的技术或方法会让我们成为真正差劲的开发者。别被它迷惑。最终,“正确”和“错误”,正如既定社区所定义的,并不是真正的正确和错误。它不过是延续的教条,最终是个人偏好。

如果你不愿意承受其他开发者的拒绝和/或批评,那么你已经为使用汇编语言的初衷本身设置了严重的限制。忘记别人的评判。让他们评判、尖叫、抱怨、发牢骚、诋毁你。你的游戏能运行吗?如果能,你产出的作品就近乎完美了。你将工作在一个大多数人即使偶然也永远不会了解的环境中。你自己制定关于什么是恰当的做法,什么不是。

边学边做

从概念上讲,创建 all-assembly app for DirectX 需要涉及大量的材料。在不使用实际代码作为学习催化剂的情况下涵盖这些材料很可能是一种徒劳的尝试。因此,我将直接开始创建源代码,并逐步解释。

我的应用程序将从声明所需数据开始;实际代码将从创建窗口开始。这个窗口将作为渲染目标——显示绘图的地方。因此,应用程序处理 Windows 消息将是极简的,因为 DirectX 会处理大部分窗口管理。过度干预它可能会导致问题。

这个普遍规则有一个值得注意的例外:窗口框架。为了完整起见,我将在本项目源代码中自定义框架。据我所知,DirectX 不关心窗口框架,在那里进行的任何程度的自定义都是公平的。如果你打算使用汇编语言,那么你很有可能对你的项目充满热情,想要全力以赴。如果你想使用标准的 Windows 框架(许多游戏都这样做),那么只需使用 **DefWindowProc** 来处理所有与窗口框架相关的消息。

照做,数据先生

首先,需要创建主模块。在本项目中,我将其命名为“game.asm”。它以一个声明数据段的指令开始。

.data

声明数据段就是这么简单。在编译器遇到 **.code** 指令之前,源代码文件中遇到的所有内容都被视为数据声明。

汇编语言将数据和代码分为两个独立的区域。这实际上是符合 Windows PE(可移植可执行文件)文件格式的,任何语言的编译都会这样做——只是在大多数其他语言中你看不到,它们允许你按照自己的意愿混合数据和指令。

你的数据声明方式会对你的游戏的性能产生巨大影响,因此理解 CPU 在声明变量时发生的情况至关重要。

在汇编语言中,变量会保持在你放置它们的位置。变量——尤其是数据结构——应该始终对齐到能被其大小整除的内存地址。如果这样做,CPU 将执行两次内存访问(而不是一次)来完整地读取或写入该值。字节可以放置在任何地方;字(2 字节值)应始终放置在可被 2 整除的地址;双字 (dwords) 应放置在 4 字节边界上,四字 (qwords) 应放置在 8 字节边界上。**编译器不会为你处理此对齐**。虽然这条规则给开发者增加了额外的责任,但也增加了额外的控制。例如,如果你创建以下 C++ 数据结构

short Foo1;
byte  Foo2;
int   Foo3;

那么 C++ 编译器将在 **Foo2** 之后放置最多 7 个字节的未使用填充,以便正确对齐四字 (int) 值 **Foo3**。开发者将永远看不到这些浪费的内存,但它已牢固地编码在最终的可执行文件中。使用汇编,如果你而是将结构编码为

align 8 ; (this directive is explained shortly)
qword Foo3
word  Foo1
byte  Foo2

你将没有任何对齐问题。**Foo1**,在 8 字节四字之后,将自然地落在 8 字节边界上,这本身就满足了正确对齐它的“两字节偶数倍”要求。**Foo2**,是一个字节,没有对齐要求——无论在何种情况下,一次内存访问都能检索到它。

在上面的代码中,**align** 指令导致编译器生成 0 到 7 字节的填充字节,以创建所需的对齐,具体取决于如果未使用 **align**,最终编译的数据将被放置在哪里。

**align** 指令接受任何高达 16 的对齐值,但必须始终是 2 的幂。

对于单个变量(不属于任何结构),一个优化编码的数据段自然会从 16 字节边界开始,所以所有四字声明将首先出现,然后是所有双字,接着是字,最后是字节。这种安排将消除对任何 **align** 指令的需求,从而消除数据段中所有浪费的填充——至少与单个变量有关。

归根结底,如果你追求性能,数据对齐至关重要,但由于使用 **align** 指令而在此处或那里浪费的几个字节内存不会毁掉你的应用程序。即使在最坏的情况下,与使用汇编节省的总内存量相比,这种浪费也将是九牛一毛。

由于你必须手动声明所有数据,你可以很容易地随时看到对齐情况。

关于数据结构,DirectX 在其结构声明中遵循所有对齐规则,所以当你使用这些定义创建结构时,你唯一需要关注的就是结构的初始对齐。这是 **align** 指令最有可能被使用的地方。

对于你自己自定义的数据结构(稍后将介绍),你对放置什么以及放在哪里拥有完全的控制权,因此遵循在这些结构中保持数据对齐的通用规则是确保最大性能的最佳实践。

请注意,任何要被 XMM 或 YMM 寄存器作为打包数据访问的值必须进行 16 字节对齐。这是一个 CPU 级别的规则;如果违反此规则,处理器将引发异常。

站出来,表明你的立场

**include** 指令是应用程序源代码中 **.data** 之后的下一行。它将一个源文件包含到另一个文件中,有效地将其嵌入到编译过程中。

.data
include variables.asm
include macros.asm
include structures.asm
...

虽然你当然可以将所有数据声明放在一个源文件中,但你会很快发现,对于任何具有一定规模的应用程序,你不会想这样做。在这里,你自己的偏好和创造力将决定你的数据声明如何在文件之间划分。我个人非常喜欢将数据声明分解到单独的文件中,因为它使查找变得非常容易。我使用一个数据文件用于查找列表(其中变量被查找或与可能值的列表进行比较),另一个用于结构,另一个用于变量,依此类推。

不上不下

现在是时候讨论局部变量和全局变量的问题了。即使(或者尤其是在)DirectX 游戏中,开发者似乎都有在运行时动态初始化数据结构的习惯。这只有在结构或值在函数内部被声明为局部变量时,或者对于运行时未知结构字段时才是必需的。这样做简直是性能自杀。为什么?

局部声明的变量驻留在堆栈上。编译器为每个局部值在堆栈空间中分配一个位置,但它不会在这些位置上分配一个初始值,除非在源代码中明确设置。初始值是最后被放入内存(堆栈空间)中变量位置的任何内容:残留的垃圾。

在为局部变量分配特定位置之后,编译器将生成初始化数据的指令(如果代码中存在此类语句)。这本身就完全打破了通过使值局部化而不是全局化来节省任何内存的想法。还有其他原因使值成为局部变量;关键在于这样做并没有节省任何内存。除了初始化所需的多余字节代码外,即时数据(直接编码在指令中的数据)在可能的情况下被使用,这进一步增加了代码的臃肿。所有最终会成为局部变量的数据都必须在某处声明——通常在代码本身中——从而增加了代码初始化所需的字节数所带来的内存使用量。在太多情况下,除了遵守惯例之外,什么都没有获得,并且损失了大量的性能和内存(所有这些都是相对的)。在我看来,局部数据被过度使用了,长远来看,这种做法在内存和性能方面都会付出高昂的代价。

更糟糕的是,所有对局部变量的引用都需要间接寻址。这就是 CPU 需要实时计算每个变量的实际位置,通常每个访问都需要相对昂贵的加法。CPU 的 **RBP** 寄存器用作基址;从那里,必须添加(或减去)一定数量的字节才能找到变量的位置。局部变量的偏移量,相对于基址,在应用程序编译时总是已知的,但基址的值——**RBP** 寄存器——在应用程序执行之前是未知的。所以间接寻址是访问局部变量的唯一选择。当你试图为你的游戏维持一个良好的帧率时,这就像在跑沙子。你将通过将局部变量的使用量降至最低来获得易于测量的性能提升——这些提升不会转化为每秒几十帧,但肯定会产生影响。

当有令人信服的理由使用局部数据时(主要是当多个执行线程必须访问同一变量时),才使用它。否则,特别是对于游戏,即使你被训练成不这样做,也要使用全局变量。全局声明的变量、结构、字符串等驻留在数据段,使编译器能够利用硬编码地址来访问它们。无需额外的计算。

(操作系统将在应用程序加载时调整对变量位置的引用;这种修复处理由 OS 加载程序处理,此处无需讨论。)

OMG WinCall 宏

我使用一个核心宏,你可能想原封不动地复制到你的代码中。我称之为 **WinCall**,它用于调用我应用程序所做的每一个 Windows API 调用——包括 DirectX 方法。

它花了一些时间才使其正常工作,其主要缺点是在调用它时,你不能使用 RAX 或 R10 寄存器来保存参数。可能有十万个论点认为这是真正糟糕的代码,而且这些论点可能都是正确的。但这就是我所做的。R10 和 RAX 会在实际进行 Win32 调用之前被销毁;它们是实际设置调用的一个组成部分。所以尝试使用它们来保存参数值不会有好结果。RAX 和 R10 可以被保存,然后在宏中恢复,但这会转化为内存访问,而对于专注于性能的应用程序来说,内存访问是头号公敌。

与所有宏一样,**WinCall** 宏是合并的,不是调用的——每次调用它时,它的内容都会被添加到你的源代码中被调用时所在的位置。.  

关于 **RAX** 和 **R10**,那些比我更有道德的人可能会修改宏以消除这种可怕的业余无能的表现。

我关于 64 位调用约定的文章概述了在 64 位模式下参数如何传递给 Windows 函数,而这个约定也正是 **WinCall** 宏的必要之处。

WinCall parameter count, parm1, parm2, …

宏的实际内容如下

WinCall            macro     call_dest, parm_count, fnames:vararg

                   local     jump_1, jump_2, lpointer

                   mov       rax, parm_count
                   cmp       rax, 4
                   jge       jump_1
                   mov       rax, 4
jump_1:            shl       rax, 3
                   push      r10
                   mov       holder, rsp
                   sub       rsp, rax
                   test      rsp, 0Fh
                   jz        jump_2
                   and       rsp, 0FFFFFFFFFFFFFFF0h
jump_2:            .if parm_count > 3
                     lPointer  = 4
                     for       fname, <fnames>
                       mov       r10, fname
                       mov       qword ptr [rsp+lPointer], r10
                       lPointer  = lPointer + 8
                     endm
                   .endif

                   call      call_dest
                   mov       rsp, holder
                   pop       r10
                   endm

**WinCall** 宏为每个参数创建 8 字节的堆栈空间,最少创建 32 字节,无论参数数量多少。前四个参数分别通过 **RCX, RDX, R8** 和 **R9** 传递。任何超出第 4 个参数的参数都放在堆栈上。

**每一个调用 WINCALL 宏的函数都必须有一个被声明为 QWORD 的局部变量“持有者”。** 它必须是局部的,因为 Win32 调用可能会在返回前触发发送到窗口回调的消息;该回调可以而且最有可能通过 **WinCall** 执行进一步的 API 调用。所以 **holder** 必须是局部的,定义为 qword,并且它必须存在于你应用程序中调用 Win32 API 的每个函数中。在源代码的后面,每个函数都会包含它,这样你就可以看到它的格式了。

对于调用定义在我应用程序中的函数,我只使用汇编语言的 **call** 指令。当本系列源代码到达这一点时,将会展示这一点。

代替此宏,**你不能使用 ML64.EXE 的 INVOKE 指令。它在 64 位模式下无法编译。**

前向声明和 EXTRN 的调用

汇编语言中不存在前向声明(函数)。它们存在于任何语言中对我来说都很好奇;链接器早已能够处理多遍扫描,这本身应该可以消除对前向声明函数的任何需求。但情况就是这样,在某个时候肯定有原因。

无论如何,所有外部函数——包括,或者尤其是在 Windows .DLL 中(整个 Windows / DirectX API)——都必须声明为外部,这样对这些函数的调用才能有一个实际的地址可以调用。你需要使用 **extrn** 指令声明在 Windows API(或你链接到的任何其他库)中调用的每个函数。幸运的是,这在 64 位 Windows 中比在 32 位中更简单。你将为每个函数创建两部分声明:实际的 **extrn** 指令,和一个 **textequ**(文本等价)语句。

**extrn** 指令将一个函数声明为外部。幸运的是,在 64 位版本中,32 位 Windows 中普遍存在的名称修饰已被或多或少地消除。有了 64 位调用约定,就不存在 **_imp__Foo@4** 了,因为没有参数被放入堆栈,而 **@4**(或每个函数参数的数量)指定了这一点。例如,下面展示了将 **DefWindowProc** 函数声明为外部。

extrn __imp_DefWindowProc:qword
DefWindowProc textequ <__imp_DefWindowProc>

应用程序调用的所有 Windows 和 DirectX 函数都以这种方式声明。

**textequ** 指令不是必需的;它只是简化了事情,让你可以使用 **DefWindowProc** 而不是 **__imp_DefWindowProc**。请注意,外部声明的函数名,与汇编语言的大部分其他部分不同,是区分大小写的。如果你在一个字符上写错了大小写,链接器就会失败并报错。 几乎在汇编语言的其他任何地方,都没有大小写敏感性。

用于处理 ANSI (A) 和宽 (W) 字符字符串的“A”和“W”函数也使用 **extrn** 进行标准化。例如,**GetClassName** 函数声明如下(用于 Unicode)

extrn __imp_GetClassNameW:qword
GetClassName textequ <__imp_GetClassNameW>

**extrn** 语句中声明的函数必须与包含该函数的库导出的名称完全匹配,包括大小写。在大多数情况下,如果一个或多个参数是指向字符串的指针,该函数将在末尾有一个 A 或 W(选择你想使用的)。你可以在 **textequ** 语句的左侧去掉 A 或 W,这样在你的应用程序中就不需要指定它了。显然,为了保持理智,你会想全部使用 W 或全部使用 A 函数。如果你愿意,也可以混合使用,但你必须记住哪些函数是 A,哪些是 W,或者不断地在你的 **extrn** 指令中查找。

访问 DirectX、WinAPI 等函数无需做更多事情。将它们声明为外部,如果选择使用 **textequ**,就使用它——几乎任何人都应该会选择使用。

下一步?

第三部分将终于直接深入声明主窗口类和创建主窗口。这将包括编写主窗口的回调函数。对于有任何 Windows 开发经验的人来说,这是基本的东西,但关于将其全部迁移到汇编语言方面,有很多值得讨论的地方。

© . All rights reserved.