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

DirectX 和纯汇编语言:实现不可能 - 第三部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (19投票s)

2017年7月10日

CPOL

25分钟阅读

viewsIcon

15365

应用程序初始化和主窗口创建。

[对于本文中零星出现的格式错误的-代码片段深感抱歉。我已经仔细检查过这些片段,删除后重新输入,并重新进行了格式化,但都无济于事。据我所知,这似乎是 CodeProject HTML 中的一个错误——如果不是,我也不知道问题出在哪里。当我提交文章时,换行符并不存在。它们在之后才出现;片段中的水平滚动关闭,并且换行符凭空出现。]

本文的源代码可从http://www.starjourneygames.com/demo part 3.zip下载

开始主模块

与32位汇编不同,相关的指令(例如.686P)很少见。我还没有机会使用过。在这种情况下,主模块以以下内容开头

     include     constants.asm                           ;
     include     externals.asm                           ;
     include     macros.asm                              ;
     include     structuredefs.asm                       ;
     include     wincons.asm                             ;

     .data                                               ;

     include     lookups.asm                             ;
     include     riid.asm                                ;
     include     routers.asm                             ;
     include     strings.asm                             ;
     include     structures.asm                          ;
     include     variables.asm                           ;

     .code                                               ;

这样,主源文件的开头会进行调整,以便 typedef、结构声明和宏等实用文件位于 **.data** 指令之前。不包含代码或数据的文件(出现在 **.data** 指令之前的文件)为编译器定义宏、结构等。它们不生成实际输出,因此可以放置在 **.data** 指令之前,此时编译器尚不知道将生成的输出放置在哪里,因此会拒绝实际代码或数据。

链接器将把入口点(接下来讨论)设置在 **.code** 段的开头,因此代码和数据段的顺序无关紧要。此应用程序将数据放在最前面。

上面列出的每个include文件都对其内容进行了自解释。附带的源代码包含完整文件。

Windows 入口点

应用程序的 **WinMain** 函数并非真正是其入口点。当汇编语言应用程序声明其代码段时,使用

.code

**.code** 之后的第一个可执行指令成为应用程序真正的入口点。**WinMain** 完全是多余的,根本不需要。我不使用它;在汇编语言应用程序中,它与不创建应用程序相比没有任何优势。虽然有人可能会争辩说传递给 **WinMain** 的参数对于初始化应用程序可能至关重要,但对于汇编应用程序来说,这种争论是零和的,因为您,开发人员,如果打算使用 **WinMain**,需要设置该调用。如果您必须在调用它之前检索通常发送给 **WinMain** 的所有信息,那么为什么还要使用该函数呢?

话虽如此,如果你想包含 **WinMain**,肯定没有任何可衡量的损害。你可能有充分的理由想包含它,所以如果你觉得有必要,就添加它。但请注意,你自己的启动代码——它在 **.code** 语句之后立即开始执行——必须手动设置 **WinMain** 调用。

要调用 **WinMain**,nCmdShow 参数从 **STARTUPINFO** 结构的 **wShowWindow** 字段中检索,该结构传递给 **GetStartupInfo**。

**GetCommandLine** 返回 lpCmdLine 参数;将其值按原样传递给 **WinMain**。

根据 MSDN 关于 **WinMain** 的文档,hPrevInstance 始终为空。

通过调用 **GetModuleHandle (0)** 检索 hInstance

下面是调用 WinMain 的完整初始化源代码。**请注意,即使您不打算包含 WinMain,您可能仍需要传递给它的部分或全部参数。下面的讨论涵盖了在有或没有 WinMain 实现的情况下检索这些信息。**

structuredefs.asm(或您喜欢的任何位置)中声明 **STARTUPINFO** 结构

STARTUPINFO         struct
cb                  qword     sizeof ( STARTUPINFO )         
lpReserved          qword     ?         
lpDesktop           qword     ?         
lpTitle             qword     ?         
dwX                 dword     ?         
dwY                 dword     ?         
dwXSize             dword     ?         
dwYSize             dword     ?         
dwXCountChars       dword     ?         
dwYCountChars       dword     ?         
dwFillAttribute     dword     ?         
dwFlags             dword     ?         
wShowWindow         word      ?         
cbReserved2         word      3 dup ( ? )
lpReserved2         qword     ?         
hStdInput           qword     ?         
hStdOutput          qword     ?         
hStdError           qword     ?         
STARTUPINFO         ends

externals.asm 文件中,声明要用于初始化的函数

extrn              __imp_GetCommandLineA:qword
GetCommandLine     textequ     <__imp_GetCommandLineA>

extrn              __imp_GetModuleHandleA:qword
GetModuleHandle    textequ     <__imp_GetModuleHandleA>

extrn              __imp_GetStartupInfoA:qword
GetStartupInfo     textequ     <__imp_GetStartupInfoA>

如果您正在使用 Unicode,则应声明 **GetCommandLineW**、**GetModuleHandleW** 和 **GetStartupInfoW**,而不是上面显示的“A”函数。

现在编译器已经知道所需的函数,需要声明变量来保存传递给 **WinMain** 的参数——这些变量放在文件 variables.asm 中:

hInstance          qword     ?
lpCmdLine          qword     ?

structures.asm 文件中,声明 **STARTUPINFO** 结构

startup_info STARTUPINFO <> ; cbSize is already set in the structure declaration

然后可以按如下方式编写应用程序的入口点(**Startup** 可以重命名为您喜欢的任何名称——如果您打算将其命名为 **WinMain**,请注意它不固有地符合 **WinMain** 的文档)

.code

align          qword
Startup        proc                          ; Declare the startup function; this is declared as /entry in the linker command line

local          holder:qword                  ; Required for the WinCall macro

xor            rcx, rcx                      ; The first parameter (NULL) always goes into RCX
WinCall        GetModuleHandle, 1, rcx       ; 1 parameter is passed to this function
mov            hInstance, rax                ; RAX always holds the return value when calling Win32 functions

WinCall        GetCommandLine, 0             ; No parameters on this call
mov            lpCmdLine, rax                ; Save the command line string pointer

lea            rcx, startup_info             ; Set lpStartupInfo
WinCall        GetStartupInfo, 1, rcx        ; Get the startup info
xor            rax, rax                      ; Zero all bits of RAX
mov            ax, startup_info.wShowWindow  ; Get the incoming nCmdShow

; Since this is the last "setup" call, there is no reason to place nCmdShow into a memory variable then
; pull it right back out again to pass in a register.  Register-to-register moves are exponentially
; faster than memory access, so all that needs to be done is to move RAX into R9 for the call to WinMain.

mov            r9, rax                       ; Set nCmdShow
mov            r8, lpCmdLine                 ; Set lpCmdLine
xor            rdx, rdx                      ; Zero RDX for hPrevInst
mov            rcx, hInstance                ; Set hInstance
call           WinMain                       ; Execute call to WinMain

xor            rax, rax                      ; Zero final return – or use the return from WinMain
ret                                          ; Return to caller

Startup        endp                          ; End of startup procedure

**注意:** 如果您严格遵守 64 位调用约定,则启动代码对 **WinMain** 的调用应使用 **WinCall** 宏,而不是直接的 **call** 指令。我不会在我的应用程序中这样做,也不会在这个示例代码中这样做。栈使用意味着内存使用,我的第一条编程规则是避免内存命中。在调用期间访问 RCX、RDX、R8 和 R9 的栈上的影子区域时,必须使用间接寻址,这会进一步减慢应用程序。如前所述,对违反 64 位调用约定的担忧可能很强烈。然而,就必要性而言,它仍然是没有根据的。我只是看不到在我的应用程序的局部函数中使用该约定的任何好处——它需要相对大量的设置代码,这在整个应用程序中执行时间过长;它会消耗内存,并增加开发时间。我的应用程序中声明的任何函数(包括这个函数)都不直接使用 64 位调用约定——前四个参数仍然在 RCX、RDX、R8 和 R9 中传递,但栈上不保留用于影子它们的空间。对于附加参数,我只是使用其他寄存器。栈不用于任何参数数据。有些人会称之为鲁莽,但“鲁莽”只相对于所比较的标准而言。

此外,在我自己的应用程序中,对局部函数的调用使用单个指针(在 RCX 中),指向一个结构,该结构包含要传递给该函数的所有参数。以任何其他方式进行,大多数局部函数首先需要做的是将传入参数保存到局部变量中以便以后访问;从 CPU 的角度来看,这与使用 64 位约定创建时没有太大区别。我在这里没有这样做;各个寄存器将参数传递到局部函数中。这样做的原因是,从教程的角度来看,对于每个函数都使用指向参数数组的指针会更加令人困惑,其中许多指针又是指向其他东西的指针。什么时候才算足够呢?因此再次重申:根据您的偏好修改代码。

万能的RAX

RAX 寄存器(在 EAX 的基础上构建,而 EAX 又在 AX 的基础上构建)诞生于英特尔 CPU 设计的最早记录版本中,它总是保存函数的返回值。在任何 WinAPI 函数或方法中,无论其用途如何,我从未见过此规则的例外。即使驱动程序也普遍使用它。我编写的任何应用程序中的所有函数都这样做,因此它将在此处适用:无论您调用什么、何时调用以及从何处调用,RAX 都持有返回值。

注册窗口类

要创建应用程序的主窗口,必须注册其窗口类。这需要声明 **WNDCLASSEX** 结构,在该示例代码中,它将在一个名为 structuredefs.asm 的单独文件中完成。一如既往,您可以根据需要随意移动和重命名文件。但是,此时正在对主源文件的开头进行调整,以便 typedef、结构声明和宏等实用文件位于 **.data** 指令之前。

以下内容放在 structuredefs.asm 文件中

WNDCLASSEX        struct
cbSize            dword     ?
dwStyle           dword     ?                   
lpfnCallback      qword     ?                    
cbClsExtra        dword     ?
cbWndExtra        dword     ?
hInst             qword     ?
hIcon             qword     ?
hCursor           qword     ?
hbrBackground     qword     ?
lpszMenuName      qword     ?
lpszClassName     qword     ?
hIconSm           qword     ?
WNDCLASSEX        ends

当深入研究 DirectX 结构时,嵌套结构将被讨论——届时将有实际示例可供使用。

注意你的 D 和 Q —— 复制代码时,请仔细准确地输入 **dword** 和 **qword** 声明。一个打字错误可能会(而且很可能会)导致应用程序崩溃。

为了保持源代码的可读性,我使用一个常量来表示逻辑或运算后的长串标志。在此应用程序中,**WNDCLASSEX.dwStyle** 将等同于

classStyle equ CS_VREDRAW OR CS_HREDRAW OR CS_DBLCLKS OR CS_OWNDC OR CS_PARENTDC

(CS_xxx 常量来自 Windows 头文件;它们在此文章附带的源代码中的 wincons.asm 中声明。)

**OR** 指令被汇编器保留;它的功能与大多数其他语言中的 | 字符相同。我将上面一行添加到我的 constants.asm 文件中;您可以将其(如果使用的话)放置在您喜欢的任何位置。

完成此操作后,现在可以声明实际的 **WNDCLASSEX** 结构,它将用于创建主窗口。在此示例代码中,它是在 structures.asm 文件中完成的。所有可以固定放置的数据都直接放置在结构字段中。没有理由比所需更多地移动数据,因此在不需要即时初始化的地方进行初始化是完全没有意义的。对于 **WNDCLASSEX**,**hInst**、**hIcon**、**hbrBackground** 和 **hIconSm** 字段直到运行时才会被知晓,因此它们被初始化为零。

声明 **WNDCLASSEX** 结构(将命名为 **wcl**)的实际数据时有几个选项。可以使用最传统的格式

wcl WNDCLASSEX <sizeof (WNDCLASSEX), [. . .]>

使用上述格式,如果声明了任何字段,则必须考虑所有字段(但这将根据所使用的实际汇编器而有所不同)。

汇编语言中数据声明的方式为结构声明提供了一些灵活性,至少对我来说,这非常方便。将 **wcl** 声明为 **WNDCLASSEX** 类型的“标签”允许我单独列出每个字段,同时调试器仍然将该结构识别为 **WNDCLASSEX**。在源代码中,我更喜欢逐行访问各个字段;它使源代码更整洁,并且更易于阅读和更新。当然,这样做也会导致错误;如果逐字段声明与 **WNDCLASSEX** 定义不完全匹配,就会出现问题。因此,这种方法可能不适合所有人。如果您不喜欢它,只需使用上面显示的第一种形式。

wcl     label     WNDCLASSEX
        dword     sizeof ( WNDCLASSEX )  ; cbSize
        dword     classStyle             ; dwStyle
        qword     mainCallback           ; lpfnCallback
        dword     0                      ; cbClsExtra
        dword     0                      ; cbWndExtra
        qword     ?                      ; hInst
        qword     ?                      ; hIcon
        qword     ?                      ; hCursor
        qword     ?                      ; hbrBackground
        qword     mainName               ; lpszMenuName
        qword     mainClass              ; lpszClassName
        qword     ?                      ; hIconSm

函数 **mainCallback** 是窗口类的回调函数;接下来将讨论它。变量 **mainClass** 是类名字符串,我在 strings.asm 文件中定义它,以及主窗口名称(窗口文本),如下所示

mainClass     byte     ‘DemoMainClass’, 0  ; Main window class name
mainName      byte     ‘Demo Window’, 0    ; Main window title bar text

请注意,终止符 0 **必须** 在字符串末尾声明。汇编器不会自动放置它。

从现在开始,假设在 Win32 调用中使用的任何常量都必须在您的源代码中声明。我将 Win32 常量放在文件 wincons.asm 中,将我的应用程序特有的常量放在文件 constants.asm 中。

要填充 hCursor,调用 **LoadImage** 如下。在 constants.asm 中,声明

lr_cur equ LR_SHARED OR LR_VGACOLOR OR LR_DEFAULTSIZE

这是一个可选步骤。如果您更喜欢直接使用这些值,只需将下面的 **lr_cur** 替换为 **LR_SHARED OR LR_VGACOLOR OR LR_DEFAULTSIZE**。

     xor                 r11, r11                                ; Set cyDesired; uses default if zero: XOR R11 with itself zeroes the register
     xor                 r9, r9                                  ; Set cxDesired; uses default if zero: XOR R9 with itself zeroes the register
     mov                 r8, image_cursor                        ; Set uType
     mov                 rdx, ocr_normal                         ; Set lpszName
     xor                 rcx, rcx                                ; Set hInstance to 0 for a global Windows cursor
     WinCall         LoadImage, 6, rcx, rdx, r8, r9, r11, lr_cur ; Load the standard cursor
     mov                 wcl.hCursor, rax                        ; Set wcl.hCursor

**注意:** 许多需要运行时初始化的数据结构将是 **dword** 或更小的尺寸。您必须密切注意这一点,因为汇编器不会阻止您将 **qword** 写入 **dword** 位置。它只会覆盖 **dword** 之后的四个字节,而这并不是您想要做的。如果 **wcl.hCursor** 是 **dword**,那么将写入 32 位 EAX,而不是 64 位 RAX。如果它是 2 字节字,那么将写入 16 位 AX,如果它是一个 8 位字节,那么将写入 AL。

网上有无数关于英特尔架构寄存器的参考资料。按需使用它们。硬件设计者,作为一条主要规则,在命名事物方面非常一致,因此很快就能记住寄存器的工作原理。只需继续使用它们;信息就会记牢。

**hInstance** 在应用程序启动时分配;任何具有相当 WinAPI 经验的人都知道它经常被使用。在初始化 DirectX 等方面,它将是必需的。加载标准光标时不使用它,因为它是一个由 Windows 提供的库存对象。如果指定了 hInstance,Windows 将在调用应用程序中搜索资源。它找不到,并且调用将失败。

在上面的调用中,调用了 **WinCall** 宏。**LoadImage** 接受六个参数,因此指定了六个参数。64 位调用约定要求 RCX、RDX、R8 和 R9 保存前四个参数,这意味着这些参数不能更改——不能用其他寄存器替换它们。R11 是任意的;除了 RAX 或 R10(WinCall 宏内部使用)之外的任何开放寄存器都可以用来存储 **cyDesired** 参数,因为它不是前四个参数之一。**WinCall** 宏将为调用 **LoadImage** 正确设置堆栈,而不关心在进入宏时使用哪些寄存器来传递值。**请记住,R10 由宏本身用作“出租车服务”,因此不要使用它或 RAX 来将参数发送到 WinCall。**

要设置 **hIcon** 和 **hIconSm** 字段,**LoadImage** 的用法与上面所示相同;只需将 cxDesiredcyDesired 参数更改为图标的适当尺寸;uType 设置为 **image_icon**,当然 lpszName 设置为资源文件中声明的资源名称。**hInstance 必须设置为应用程序的 hInstance**,因为这些资源是应用程序的一部分——它们不是 Windows 全局的。资源文件由 **RC.EXE** 编译,因此其格式与任何 C++ 应用程序(或任何其他与 C++ 处理资源文件相同方式的语言)的格式没有变化。图标的资源文件行如下所示

LARGE_ICON   ICON   DISCARDABLE   “LargeIcon.ico”
SMALL_ICON   ICON   DISCARDABLE   “SmallIcon.ico”

在汇编源代码中,声明资源的名称(我将这些放在 strings.asm 文件中)

LargeIconResource     byte     ‘LARGE_ICON’, 0 ;
SmallIconResource     byte     ‘SMALL_ICON’, 0 ;

指向变量 LargeIconResourceSmallIconResource 的指针依次传递给加载图标的两个 **LoadImage** 调用。结果分别放置在 **wcl.hIcon** 和 **wcl.hIconSm** 中。这些是 qword 字段,因此从 **LoadImage** 返回时,RAX 被分配给每个字段。要实际加载指向字符串的指针,请使用汇编 **lea** 指令。这是“加载有效地址”;它旨在执行内存地址的即时计算,并且稍后将在应用程序中充分利用其潜力。目前,它只是加载指针而无需计算

lea rdx, LargeIconResource ;

之所以需要这样做,是因为 ML64.EXE 取消了曾经属于 Microsoft 汇编语言的 **offset** 指令。如果他们没有取消它,那行代码就可以简单地编码为“mov rdx, offset LargeIconResource”。我不知道他们为什么这样做;这是抽象退化永恒行进中的又一步倒退。

处理完以上所有内容后,现在可以声明窗口类了

lea     rcx, wcl                    ; Set lpWndClass
winCall RegisterWindowClass, 1, rcx ; Register the window class

此应用程序不使用返回值。

注册窗口类最终允许创建将用作 DirectX 渲染目标的窗口。Win32 是 Win32(64 位或其他),因此从逻辑上讲,窗口创建过程与 C++ 应用程序中的过程相同。

本文附带的源代码中包含完整的启动代码。

您的未来之窗

本节将主要关注主窗口的回调函数。此应用程序与普通 C++ 应用程序的主要区别在于,此处不使用 switch 语句。我一直不喜欢这个语句,觉得它笨拙、原始且效率低下(考虑到我从 CPU 实际执行的角度看待一切)。

代替 switch,使用了 CPU 的 scan instructions(扫描指令组)。这些是 CPU 级别的指令,它们扫描内存中给定的字节数组(、words、dwords、qwords),直到找到匹配项(或未找到)。要扫描的值总是包含在 RAX 中用于 qwords,EAX 用于 dwords,AX 用于 words,AL 用于 bytes。RCX 包含要扫描的 qwords 等的数量,RDI 指向内存中开始扫描的位置。

scan 指令的一个非常方便的用途是确定字符串的大小。以下代码执行此任务

mov rdi, <location to scan>    ; Set the location to begin scanning
mov rcx, -1                    ; Set the max unsigned value for the # of bytes to scan
xor al, al                     ; Zero AL as the value to scan for
repnz scasb                    ; Stop scanning when a match is encountered or the RCX counter reaches zero
not rcx                        ; Apply a logical NOT to the negative RCX count
dec rcx                        ; Adjust for overshot (the actual match of 0 is counted and needs to be undone)

无论内存中遇到什么停止扫描(如果使用 repnz,表示“重复直到不为零 [重复直到 CPU 的零标志清除]”),RDI 将指向该值之后的下一个字节、字、双字或四字。在上面的示例代码中,当扫描完成时,RDI 将指向字符串终止零之后的紧接着的字节。对于 Unicode 字符串,代码将如下所示

mov rdi, <location to scan>   ; Set the location to begin scanning
mov rcx, -1                   ; Set the max unsigned value for the # of bytes to scan
xor ax, ax                    ; Wide strings use a 16-bit word as a terminator
repnz scasw                   ; Scan words, not bytes
not rcx                       ; Apply a logical NOT to the negative RCX count
dec rcx                       ; Adjust for overshot

然后 RCX 存储字符串的长度。然而,所有美好的事物都有其弊端;如果您忘记在字符串(无论是宽字符串还是 ANSI 字符串)后面添加终止 0,那么将返回错误的长度,因为扫描将继续,直到 RCX 达到 0(这可能需要扫描大量的字节),或者在扫描起始位置之后的内存中遇到下一个 0 值。尽管如此,在这种情况下,无论使用哪种方法来确定字符串大小,忘记终止 0 都不会有好的结果。

在我的应用程序中,我通常在所有字符串前面加上一个大小 qword,这样我就可以简单地将该大小 qword lodsq 到 RAX 中,让 RSI 指向字符串开头。(所有 lods? 指令都将数据移动到 RAX、EAX、AX 或 AL 中。)如果一个字符串是静态的并且不会改变(例如窗口类名),那么在运行时根本没有必要对其进行大小调整,更不用说多次,因为在编译时就可以设置正确的大小。

您不会总是想使用这种方法。如果您的缓冲区大小有限,并且想确保不会扫描超出缓冲区,那么您必须将 RCX 设置为缓冲区大小。然而,这样做会迫使您通过从起始计数中减去 RCX 中的结束计数,或者在扫描后从起始指针中减去结束指针(加一)来计算字符串大小。

给我回电话,好吗?

关于窗口回调,一个查找表包含了回调处理的所有消息。该表总是以一个四字节(qword)开头,其中包含入口计数。然后计算进入表的偏移量,并且相应路由表中的相同偏移量包含处理该消息的代码的位置。

     mov                 rax, rdx                                ; Set the value to scan (incoming message)
     lea                 rdi, message_table                      ; Point RSI @ the table start (entry count qword)
     mov                 rcx, [ rdi ]                            ; Load the entry count
     scasq                                                       ; Skip over the entry count qword
     mov                 rsi, rdi                                ; Save pointer @ table start
     repnz               scasq                                   ; Scan until match found or counter reaches 0
     jnz                 call_default                            ; No match; use DefWindowProc
     sub                 rdi, rsi                                ; Get the offset from the first entry of the table (not including entry count qword)
     lea                 rax, message_router                     ; Point RAX at the base of the router table
     call                qword ptr [ rax + rdi - 8 ]             ; Call the handler

对于此应用程序,最初只处理四条消息:WM_ERASEBKGND、WM_PAINT、WM_CLOSE 和 WM_DESTROY。

WM_ERASEBKGND 的处理程序什么也不做,只在 RAX 中返回 TRUE (1)。为该消息返回 TRUE 告诉 Windows 消息的所有处理都已完成,无需再做任何事情。(相对于回调可能处理的所有 Windows 消息,您的应用程序必须返回 FALSE (0) 的情况要多得多,但绝不能想当然——始终检查每个处理消息的文档;有些消息根据您的处理方式需要非常不同且特定的返回值。这在对话框中尤其如此,特别是通过 WM_NOTIFY 发送的 NM_ 通知。)

在某些情况下,从事复杂绘图操作的应用程序可能希望知道背景何时被擦除,但即使这些异类也可能不再存在。WM_ERASEBKGND 消息本身就是 bygone 时代的一个古老遗物。在 Windows 的早期,仅仅绘制一个带框架的普通窗口就可能严重影响图形适配器。因此,必须采用一系列技巧和补偿措施,以便在可以实现的情况下加快这个过程。其中一项创新是确定窗口客户区中实际需要重绘的精确部分。这个“更新区域”通常不包括整个客户区,任何时候只要能节省几个 CPU 周期,都值得去做。

因此,“更新区域”或“更新区域”(“区域”是矩形的集合)的概念应运而生。在客户区内,一个区域被声明为“无效”,这意味着它必须被重绘。在现代,处理更新区域所需的代码可以说比简单地在屏幕外重绘整个客户区然后一次性绘制到窗口本身更慢更臃肿。DirectX 广泛使用这种“双缓冲”技术来避免闪烁,闪烁源于屏幕上的“擦除然后重绘”。然而,即使在 Windows 10 中,旧窗口绘制系统的残余仍然存在;WM_PAINT 消息的处理程序必须显式“验证”更新区域。如果它不这样做,WM_PAINT 消息将永远重复,快速而猛烈地流入窗口的回调函数,拖慢整个应用程序的性能。此示例代码的 WM_PAINT 处理程序仅将 Win32 **ValidateRect** 函数作为其唯一任务,因为 DirectX 接管了窗口客户区中的所有绘图。

一个易变的情况

请注意,根据 MSDN,以下寄存器被视为非易失性——任何被调用的函数都将在其整个执行过程中保留其值

R12, R13, R14, R15; RDI, RSI, RBX, RBP, RSP

如果在窗口回调(或任何其他)函数中修改了它们,则必须先保存再恢复它们。对于此应用程序,所有非易失性寄存器都将保存,无论处理何种消息——在查看传入消息之前就完成了保存。您可能希望或不希望在您自己的应用程序的内部函数中更改此行为,但是当您调用任何 Windows 函数时,您必须意识到易失性寄存器中的内容永远不能依赖于在调用之间持续存在。一个函数可能会在返回时保持易失性寄存器不变,但规范就是规范,并且该行为随时可能轻易改变,尤其是在 Windows 10 的随时更新策略下。

下面显示了窗口回调的完整函数,注意汇编器将处理每个声明函数的 RBP 和 RSP 的保存和恢复

mainCallback       proc                                                        ;

                   local               holder:qword, hwnd:qword, message:qword, wParam:qword, lParam:qword

                   ; Save nonvolatile registers

                   push                rbx                                     ;
                   push                rsi                                     ;
                   push                rdi                                     ;
                   push                r12                                     ;
                   push                r13                                     ;
                   push                r14                                     ;
                   push                r15                                     ;

                   ; Save the incoming parameters

                   mov                 hwnd, rcx                               ;
                   mov                 message, rdx                            ;
                   mov                 wParam, r8                              ;
                   mov                 lParam, r9                              ;

                   ; Look up the incoming message

                   mov                 rax, rdx                                ; Set the value to scan (incoming message)
                   lea                 rdi, message_table                      ; Point RSI @ the table start (entry count qword)
                   mov                 rcx, [ rdi ]                            ; Load the entry count
                   scasq                                                       ; Skip over the entry count qword
                   mov                 rsi, rdi                                ; Save pointer @ table start
                   repnz               scasq                                   ; Scan until match found or counter reaches 0
                   jnz                 call_default                            ; No match; use DefWindowProc
                   sub                 rdi, rsi                                ; Get the offset from the first entry of the table (not including entry count qword)
                   lea                 rax, message_router                     ; Point RAX at the base of the router table
                   call                qword ptr [ rax + rdi - 8 ]             ; Call the handler
                   jmp                 callback_done                           ; Skip default handler

call_default:                                                                  ; The only changed register holding incoming parameters is RCX so only reset that
                   mov                 rcx, hWnd                               ; Set hWnd
                   WinCall             DefWindowProc, 4, rcx, rdx, r8, r9      ; Call the default handler

callback_done:     pop                 r15                                     ;
                   pop                 r14                                     ;
                   pop                 r13                                     ;
                   pop                 r12                                     ;
                   pop                 rdi                                     ;
                   pop                 rsi                                     ;
                   pop                 rbx                                     ;

                   ret                                                         ; Return to caller

mainCallback       ends                                                        ; End procedure declaration

弹出寄存器时,必须以与保存(压入)时完全相反的顺序弹出。英特尔架构使用 LIFO 栈模型——后进先出。每个 push 指令(假设压入一个 **qword**)将该 **qword** 存储在 RSP 指向的内存位置;然后 RSP 向后(向 0 移动)8 个字节。“最低”的栈地址是栈的“顶部”。进入此回调会将项目按压入顺序放置在栈上——最低(最接近 0)的地址保存 r15;最高地址保存 RBX(参考上面的代码)。

汇编语言应用程序中最常见的错误之一是忘记弹出已压入堆栈的项目。你不再是堪萨斯州的托托了,所以你必须手动完成这些事情。额外的能力意味着额外的责任。当这种情况发生时,函数的返回(ret 指令)本身会从堆栈顶部弹出一个 **qword**,然后跳转到它所持有的任何地址。因此,即使无意中在堆栈上留下一个 **qword**,也会完全破坏函数的返回,通常会导致应用程序坠毁。

以上代码代表了主窗口回调函数的全部内容(减去单独的消息处理程序)。对于那些能回忆起那么久远的人来说,每个处理程序都可以看作是原始 BASIC 语言中的“子例程”。每个消息的处理程序代码实际上是 **mainCallback** 函数的一部分——它存在于函数本身内部。由于所有处理程序都在 ret 指令之后编码,所以除非明确调用,否则它们永远不会执行。

在处理程序中,唯一的异常是使用 ret 指令从处理程序返回到函数的主代码。从汇编器的角度来看,你不能这样做。汇编器看到 ret,并假定你正在从函数本身返回。因此,它会插入尝试恢复 RBP 和 RSP 的代码,这会在你绝对不希望发生的位置进行。这就是我采用半非正统方法的地方:我直接将 ret 语句编码为

byte     0C3h     ; Return to caller

或者,您可以使用 textequ 语句将其更改为更易接受的形式,例如

HandlerReturn     textequ     <byte 0C3h> ;

文本等效在源代码级别之外会消失;汇编器将简单地将所有出现的 HandlerReturn 替换为 byte 0C3h。你只是不必在源代码中看到它。

在 CPU 层面,call 指令不会直接对 RBP 或 RSP 做任何操作。该指令本身只是 push call 之后的下一条指令的地址,然后跳转到你正在调用的地方。相应地,ret 除了 pop 栈顶等待的任何值并跳转到该值作为地址之外,不会做任何事情。(恢复 RBP、重置 RSP [从其用于局部变量设置的状态] 并返回的代码由汇编器生成。)CPU 没有函数是什么的概念;汇编器完全独立于 CPU 赋予所有这些意义。因此,将 0C3h 硬编码为 ret 语句可以防止编译器试图通过重置 RBP 和 RSP 来“好心地”使你的程序崩溃,以准备从函数返回。“但是等等,我还在上厕所!”将字节直接编码到代码流中只是汇编语言非常直接的数据模型(即“根本没有模型”)所提供的灵活性的另一个好处。

如果您不喜欢这种方法,则必须为实际处理的每条消息编码一个单独的函数。然后您可以根据需要简单地调用,但在此之前,您必须重新加载进入 **mainCallback** 函数的参数(每个处理程序需要多少信息)。然而,这会带来设置和拆除函数(保存寄存器等)的额外开销,以及将 **mainCallback** 的传入参数放回所需的寄存器中,以便传递给每个消息处理函数。所有这些加起来,只是为了遵守仪式,就增加了大量的额外开销。这几乎不值得。避免这种额外开销是使用“函数内处理程序”进行消息处理的原因。每个处理程序都可以直接访问 **mainCallback** 的局部变量(它们保存传入参数),因为从编译器的角度来看,每个处理程序仍然是 **mainCallback** 的一部分。

访问局部变量——特别是 **mainCallback** 的传入参数——完全没有问题。在每个处理程序中,您仍然处于 **mainCallback** 函数的“空间”中,因此其所有局部变量都完全完整且可访问。

查找表 message_table 如下所示

message_table            qword       (message_table_end – message_table_start ) / 8
message_table_start      qword       WM_ERASEBKGND
                         qword       WM_PAINT
                         qword       WM_CLOSE
                         qword       WM_DESTROY
message_table_end        label       byte ; Any size declaration will work; byte is used as the smallest choice

回调函数的路由器列表是

message_router     qword     main_wm_erasebkgnd
                   qword     main_wm_paint
                   qword     main_wm_close
                   qword     main_wm_destroy

WM_ERASEBKGND 处理程序如下所示

                   align               qword                                   ;
main_wm_erasebkgnd label               near                                    ;

                   mov                 rax, 1                                  ; Set TRUE return
                   byte                0C3h                                    ; Return to caller

WM_PAINT:由于 DirectX 处理主客户区的所有绘图,WM_PAINT 处理程序唯一需要做的事情就是验证无效客户区。

首先,确保在 externals.asm 文件中声明了 **ValidateRect** 函数

extrn            _imp__ValidateRect:qword
ValidateRect     textequ     <_imp__ValidateRect>

WM_PAINT 处理程序只包含一个对 **ValidateRect** 的调用

                   align               qword                                   ;
main_wm_paint      label               near                                    ;

                   xor                 rdx, rdx                                ; Zero LPRC for entire update area
                   mov                 rcx, hwnd                               ; Set window handle
                   WinCall             ValidateRect, 2, rcx, rdx               ; Validate the invalid area

                   xor                 rax, rax                                ; Set FALSE return
                   byte                0C3h                                    ; Return to caller

未能验证更新区域将导致大量 WM_PAINT 消息涌入窗口的回调函数;Windows 将永远持续发送它们,认为仍有一个更新区域需要重绘。

WM_CLOSE 处理程序销毁窗口。**DestroyWindow** 函数需要在 externals.asm 文件中声明,或者您保存外部函数的位置

extrn             __imp_DestroyWindow:qword
DestroyWindow     textequ     <__imp_DestroyWindow>

WM_CLOSE 处理程序如下

                   align               qword                                   ;
main_wm_close      label               near                                    ;

                   mov                 rcx, hwnd                               ; Set the window handle
                   WinCall             DestroyWindow, 1, rcx                   ; Destroy the main window

                   ; Here it's assumed that the call succeeded.  If it failed,
                   ; RAX will be 0 and GetLastError will need a call to figure
                   ; out what's blocking the window from being destroyed.

                   xor                 rax, rax                                ; DestroyWindow leaves RAX at TRUE if it succeeds so it needs to be zeroed here
                   byte                0C3h                                    ; Return to caller

WM_DESTROY 的处理程序是一个通知,在窗口从屏幕上移除后发送。这是 DirectX 关闭操作发生的地方。局部 **ShutdownDirectX** 函数将在第四部分介绍,其中讨论了 DirectX 的初始化和关闭。

                   align               qword                                   ;
main_wm_destroy    label               near                                    ;

                 ; The DirectX shutdown function is commented out below; it
                 ; has not been covered yet as of part III of the series.  It
                 ; will be uncommented and coded in the source for part IV.

                 ; call                ShutdownDirectX                         ; Calling a local function does not require the WinCall macro

                   xor                 rax, rax                                ; Return 0 from this message
                   byte                0C3h                                    ; Return to caller

回调函数用一行代码结束

main_callback endp

为了使到目前为止介绍的代码成为一个完整的应用程序,唯一剩下的任务是实际的启动代码。**WinMain** 未使用,因此启动代码直接进入创建主窗口,然后进入消息循环。下面显示了整个入口代码块

;*******************************************************************************
;
; DEMO - Stage 1 of DirectX assembly app: create main window
;
; Chris Malcheski 07/10/2017

                   include             constants.asm                           ;
                   include             externals.asm                           ;
                   include             macros.asm                              ;
                   include             structuredefs.asm                       ;
                   include             wincons.asm                             ;

                   .data                                                       ;

                   include             lookups.asm                             ;
                   include             riid.asm                                ;
                   include             routers.asm                             ;
                   include             strings.asm                             ;
                   include             structures.asm                          ;
                   include             variables.asm                           ;

                   .code                                                       ;

Startup            proc                                                        ; Declare the startup function; this is declared as /entry in the linker command line

                   local               holder:qword                            ; Required for the WinCall macro

                   xor                 rcx, rcx                                ; The first parameter (NULL) always goes into RCX
                   WinCall             GetModuleHandle, 1, rcx                 ; 1 parameter is passed to this function
                   mov                 hInstance, rax                          ; RAX always holds the return value when calling Win32 functions

                   WinCall             GetCommandLine, 0                       ; No parameters on this call
                   mov                 r8, rax                                 ; Save the command line string pointer

                   lea                 rcx, startup_info                       ; Set lpStartupInfo
                   WinCall             GetStartupInfo, 1, rcx                  ; Get the startup info
                   xor                 r9, r9                                  ; Zero all bits of RAX
                   mov                 r9w, startup_info.wShowWindow           ; Get the incoming nCmdShow
                   xor                 rdx, rdx                                ; Zero RDX for hPrevInst
                   mov                 rcx, hInstance                          ; Set hInstance

; RCX, RDX, R8, and R9 are now set exactly as they would be on entry to the WinMain function.  WinMain is not
; used, so the code after this point proceeds exactly as it would inside WinMain.

; Load the cursor image

                   xor                 r11, r11                                ; Set cyDesired; uses default if zero: XOR R11 with itself zeroes the register
                   xor                 r9, r9                                  ; Set cxDesired; uses default if zero: XOR R9 with itself zeroes the register
                   mov                 r8, image_cursor                        ; Set uType
                   mov                 rdx, ocr_normal                         ; Set lpszName
                   xor                 rcx, rcx                                ; Set hInstance
                   WinCall         LoadImage, 6, rcx, rdx, r8, r9, r11, lr_cur ; Load the standard cursor
                   mov                 wcl.hCursor, rax                        ; Set wcl.hCursor

; Load the large icon

                   mov                 r11, 32                                 ; Set cyDesited
                   mov                 r9, 32                                  ; Set cxDesired
                   mov                 r8, image_icon                          ; Set uType
                   lea                 rdx, LargeIconResource                  ; Set lpszName
                   mov                 rcx, hInstance                          ; Set hInstance
                   WinCall         LoadImage, 6, rcx, rdx, r8, r9, r11, lr_cur ; Load the large icon
                   mov                 wcl.hIcon, rax                          ; Set wcl.hIcon

; Load the small icon

                   mov                 r11, 32                                 ; Set cyDesited
                   mov                 r9, 32                                  ; Set cxDesired
                   mov                 r8, image_icon                          ; Set uType
                   lea                 rdx, SmallIconResource                  ; Set lpszName
                   mov                 rcx, hInstance                          ; Set hInstance
                   WinCall         LoadImage, 6, rcx, rdx, r8, r9, r11, lr_cur ; Load the large icon
                   mov                 wcl.hIconSm, rax                        ; Set wcl.hIcon

; Register the window class

                   lea                 rcx, wcl                                ; Set lpWndClass
                   winCall             RegisterClassEx, 1, rcx                 ; Register the window class

; Create the main window

                   xor                 r15, r15                                ; Set hWndParent
                   mov                 r14, 450                                ; Set nHeight
                   mov                 r13, 800                                ; Set nWidth
                   mov                 r12, 100                                ; Set y
                   mov                 r11, 100                                ; Set x
                   mov                 r9, mw_style                            ; Set dwStyle
                   lea                 r8, mainName                            ; Set lpWindowName
                   lea                 rdx, mainClass                          ; Set lpClassName
                   xor                 rcx, rcx                                ; Set dwExStyle
                   WinCall             CreateWindowEx, 12, rcx, rdx, r8, r9, r11, r12, r13, r14, r15, 0, hInstance, 0
                   mov                 main_handle, rax                        ; Save the main window handle

; Ensure main window displayed and updated

                   mov                 rdx, sw_show                            ; Set nCmdShow
                   mov                 rcx, rax                                ; Set hWnd
                   WinCall             ShowWindow, 2, rcx, rdx                 ; Display the window

                   mov                 rcx, main_handle                        ; Set hWnd
                   WinCall             UpdateWindow, 1, rcx                    ; Ensure window updated

; Execute the message loop

wait_msg:          xor                 r9, r9                                  ; Set wMsgFilterMax
                   xor                 r8, r8                                  ; Set wMsgFilterMin
                   xor                 rdx, rdx                                ; Set hWnd
                   lea                 rcx, mmsg                               ; Set lpMessage
                   WinCall             PeekMessage, 4, rcx, rdx, r8, r9, pm_remove

                   test                rax, rax                                ; Anything waiting?
                   jnz                 proc_msg                                ; Yes -- process the message

                 ; call                RenderScene                             ; <--- Placeholder; will uncomment and implement in Part IV article

proc_msg:          lea                 rcx, mmsg                               ; Set lpMessage
                   WinCall             TranslateMessage, 1, rcx                ; Translate the message

                   lea                 rcx, mmsg                               ; Set lpMessage
                   WinCall             DispatchMessage, 1, rcx                 ; Dispatch the message

                   jmp                 wait_msg                                ; Reloop for next message

breakout:          xor                 rax, rax                                ; Zero final return – or use the return from WinMain

                   ret                                                         ; Return to caller

Startup            endp                                                        ; End of startup procedure

                   include             callbacks.asm                           ;

                   end                                                         ; Declare end of module

作为一个骨架程序,演示应用程序现在已完成。

正如 README.TXT 文件(在随附的 .ZIP 文件中)所述,本文的源代码将进行许多改进,因此不要花费太多时间修改此代码。还没。此代码旨在阐述概念并作为教程;因此,它的重点不在于效率。这将在第四部分中改变,其中将介绍 DirectX 的初始化。

此外,回调函数中将添加几个处理程序,以创建一个自定义窗口框架。

请注意,DirectX 在常量声明和嵌套结构方面简直是疯了。(使用的是 DirectX 11,因为 DirectX 12 仅在 Windows 10 上运行。)由于将这些声明和定义移植到汇编中需要大量的输入,因此在本文之后,后续文章中将不再详细介绍它们。假定您现在已经明白了要点,并理解了在汇编中声明结构、常量等的基础知识——除了嵌套结构,这将在下一篇文章中介绍。

一个汇编语言应用程序很像丝网印刷:大部分时间都花在初始设置上——声明外部变量,定义结构等。所有这些都是一次性工作,可以用于无限数量的应用程序,而无需重复。随着本系列的继续,附带的源代码将为您完成大部分工作。请随意复制和粘贴所需的棘手声明和定义,这样您就不必手动研究和输入它们。

第四部分中,节奏将加快,届时将初始化 DirectX,在主窗口销毁时关闭 DirectX,并且消息循环将只渲染一个没有顶点的空白场景。

© . All rights reserved.