创建 Windows 的汇编语言 DLL 模块





5.00/5 (18投票s)
在需要的情况下,一个完整的所有汇编语言 .DLL 模块可以比单独使用内联汇编或内部函数提供巨大的性能和速度提升。
本文解释了如何使用纯汇编语言为 Windows 创建功能齐全的 .DLL 和 .LIB 模块。尽管本文的许多讨论都围绕 Visual Studio 展开,但您创建的 .DLL 和 .LIB 模块可以集成到任何允许使用它们的语言中。生成的是标准 .DLL,最终产品与以任何其他方式创建的 .DLL 模块没有任何区别。
Visual Studio 仅在 32 位模式下允许内联汇编,甚至在 64 位模式下也几乎不允许。在后者中,您必须改用极其复杂和令人困惑的内部函数。无论哪种方式,您都只能获得汇编语言在有效处理处理器密集型任务方面所提供的强大功能的冰山一角。在一个成熟的汇编 .DLL 模块中可以完成的大部分工作都无法通过内联汇编或内部函数实现。
创建一个纯汇编 .DLL 模块远没有许多人想象的那么复杂——您甚至可以只用记事本完成(假设您有合适的汇编器和链接器)。它开启了汇编语言的全部强大功能——包括函数、宏和 Visual Studio(或任何其他允许您使用某种形式的汇编的环境)中不可用的许多其他好处。
在 Windows 中,可移植可执行文件格式——PE——普遍用于可执行文件、驱动程序和 .DLL。它们之间唯一的真正区别是加载器如何处理它们。文件中各种字段还有其他细微的变化,但这三种文件类型的总体格式是相同的。
入门 – 主模块
汇编语言注释使用 ;
字符。没有开/关注释对,尽管您可以使用
comment ^
This is comment text.
It can run on forever as many politicians do.
Vote for me and put the Purple Party in power!
^
^
字符可以是任何东西,但请记住,无论使用什么,只要汇编器第一次遇到它,就会关闭注释块。所以选择一个不属于注释块本身的东西。
除了注释,汇编语言源文件的第一行应该是
.data
一旦声明,您就可以包含包含数据声明的文件,或直接输入这些声明。当遇到 .code 时,.data 块固有地结束;它是 .data 块之后的下一行。
尽管它们可以放在任何地方,但我通常也将宏定义放入数据块中。做对您有用的事情,但宏当然必须在使用之前定义。
在您的数据声明之后是
.code
现在您在代码段中。
此时,您会想“那还用说!”,但这就是汇编语言应用程序的复杂程度,所以请习惯“简单”。
对于 .DLL 模块,传统的入口点是 DllMain
,您必须将其声明为一个函数
DllMain proc ; 64-bit function
… code goes here …
ret
DllMain endp ; End function
如果您使用的是 32 位代码,声明是
DllMain proc near hInstDll:dword, fdwReason:dword, lpReserved:dword
… code goes here …
ret 12 ; Return to caller
DllMain endp ; End function
在 32 位版本中,参数 hInstDll
、fdwReason
和 lpReserved
可以直接通过名称访问。在 64 位版本中,遵循 64 位调用约定,这意味着进入 DllMain
函数时
RCX = hInstDll
RDX = fdwReason
R8 = lpReserved
Windows 加载器设置要传递的参数,因此进入 DllMain
时,输入值将始终如上所述。加载器不知道或不关心用于创建 .DLL 模块的语言。如果格式正确,DllMain
将以传递的标准参数进入。
fdwReason
参数将包含四种可能的值之一:DLL_PROCESS_ATTACH (1)
、DLL_PROCESS_DETACH (0)
、DLL_THREAD_ATTACH (2)
和 DLL_THREAD_DETACH (3)
。这些值应在您的数据段中(如果您愿意,也可以在之前,因为等效项对它们所在的段不敏感)声明为常量,如下所示
DLL_PROCESS_ATTACH equ 1
DLL_PROCESS_DETACH equ 0
DLL_THREAD_ATTACH equ 2
DLL_THREAD_DETACH equ 3
这允许您使用它们的标准名称而不是硬编码整数来处理这些值。
在 DllMain 中处理 fdwReason
fdwReason
参数回答了“你为什么给我打电话?”这个问题。我采用了一种消息路由方法,我主要在窗口回调函数中使用它,但也将其应用于 DllMain
函数。我从人类文明的黎明开始就一直在这样做。此方法在查找表上查找传入消息(在此例中为 fdwReason
),并跳转到路由器表上的相同位置。这允许使用查找/路由器表对来代替 switch
语句。由于两个表中的所有值都在静态内存中,与使用暴力 switch
语句相比,节省了大量的代码,后者对传入消息与可能值列表进行单次比较,一次一个。除了在代码流中硬编码值之外,此方法在经验上也很慢且效率低下。在 DllMain
中使用查找方法的影响实际上可以忽略不计,考虑到该函数对于附加到它的每个进程或线程只调用两次,但我仍然使用它,仅仅因为它涉及的代码量少得多。
fdwReason
值的查找表如下所示——不要输入它,因为它很快就会被丢弃;这里仅供参考
dll_reasons qword ( dll_reasons_e – dll_reasons_s ) / 8 ; Count of values in the table
;--------------------------------------------------
dll_reasons_s qword DLL_PROCESS_DETACH ; DLL_PROCESS_DETACH
qword DLL_PROCESS_ATTACH ; DLL_PROCESS_ATTACH
qword DLL_THREAD_ATTACH ; DLL_THREAD_ATTACH
qword DLL_THREAD_DETACH ; DLL_THREAD_DETACH
;--------------------------------------------------
dll_reasons_e label qword ; Reference label
路由器表如下所示
dll_router qword DllMain_P_Detach ; DLL_PROCESS_DETACH = 0
qword DllMain_P_Attach ; DLL_PROCESS_ATTACH = 1
qword DllMain_T_Attach ; DLL_THREAD_ATTACH = 2
qword DllMain_T_Detach ; DLL_THREAD_DETACH = 3
要使用此过程,CPU 提供指令 repnz scasq
。它是 repeat while zero flag clear (或 repeat while not zero): scan string quadword 的缩写。该指令在 RDI 寄存器指向的位置搜索 64 位四字(qword),计数为 RCX,将 RAX 中的值与每个连续的 qword 进行比较。此寄存器用法是为此指令硬编码的,因此不能更改。设置 RAX、RCX 和 RDI 寄存器后,发出指令。然后 CPU 从内存位置 RDI 开始逐个扫描 qword(RDI 在每次扫描时自动前进),每次扫描递减 RCX。这会一直持续到找到匹配项或 RCX 中的计数达到零。由于必须完成匹配值的扫描才能确定它是匹配项,因此 RDI 将始终指向匹配值 之后 的位置。
此过程的编码如下所示
lea rdi, dll_reasons ; Set the scan start pointer
mov rcx, [rdi] ; Load the first qword – the entry count for the table
scasq ; Skip over the entry count
mov rsi, rdi ; Save the location of table entry 0
mov rax, fdwReason ; Set the scan target (the value to search for)
repnz scasq ; Execute the scan
jnz <no_match> ; Not found – do <whatever>
sub rdi, rsi ; Set byte count into table, remembering target was passed over
sub rdi, 4 ; Undo the scan overshot
lea rax, dll_router ; Get the router table offset
jmp qword ptr [rdi + rax] ; Jump to the target process
此代码可以处理任何大小的表。它消除了逐个检查传入消息(在本例中为 fdwReason
)与每个可能值的需要,并允许非常清晰的源文件,将所有可能的值列在一个地方。dll_reason
表的设置允许添加无限数量的新条目,而无需不断更新表的条目计数——它会自动更新。
现在我介绍了查找过程,我将放弃查找部分。在这种相对独特的情况下使用它没有意义。由于 fdwReason
只能有 0
、1
、2
或 3
的值,只需将该值乘以 8
(左移 3 位),然后直接将该值用作路由器表中的偏移量。在 DllMain
中处理 fdwReason
的几乎唯一情况下,使用查找表无法返回 fdwReason
中未包含的任何信息。
mov rax, fdwReason ; Get the incoming value
shl rax, 3 ; Scale to * 8 for qword size
jmp dll_router [rax] ; Jump to the target process
上述处理 fdwReason
值的路由完美无缺。
继续...
一旦您执行了传入 fdwReason
值的所需处理程序,您就可以简单地退出 DllMain
。最终返回值必须在从 DllMain 返回时存在于 RAX 中。1 表示成功,0
表示失败。如果 Windows 加载器从 DllMain
收到返回代码 0,它将卸载库并认为失败,因此正确的返回值至关重要。
外部函数
链接到 Windows 库需要将要调用的函数声明为外部函数。没有人喜欢名称修饰,尽管 64 位 Windows 库已经删除了参数名称末尾的所有 @24 类型的东西。但您仍然有 __imp_
前缀在每个函数名称之前,没有人希望每次调用函数时都使用它。
汇编的 文本等价 将开发人员从这个噩梦中解救出来。Windows 函数 LoadLibrary
的完整声明如下所示——请注意,当任何参数是 string
类型时,A 和 W 变体仍然必须指定。
首先,64 位版本
extrn __imp_LoadLibraryA:qword ; In 64 bit mode, externals are always declared as qwords
LoadLibary textequ <__imp_LoadLibraryA>
32 位版本看起来像这样
extrn _imp__LoadLibraryA@4:dword ; In 32 bit mode, externals are always declared as dwords
LoadLibrary textequ <_imp__LoadLibraryA@4>
仔细数下划线!它们在 32 位和 64 位声明之间不同。
对于不熟悉 32 位模式中讨厌的名称修饰的人来说,@
后面的数字总是传递给函数的参数数量乘以四。LoadLibrary
接受一个参数,因此它被声明为 @4
。
一旦外部函数被声明,您就可以简单地
call LoadLibary ; Load the target library
然后就可以开始了。
64 位参数传递
网上有很多关于 64 位参数传递的文章。在 64 位模式下,它是在寄存器中完成的,而不是在堆栈上。当涉及到 float
时,它可能会变得混乱,但下表应该能澄清
Parameter Number
1
2
3
4
| Non-Float
RCX
RDX
R8
R9
| Float
?MM0
?MM1
?MM2
?MM3
|
Float
值通过 XMM 寄存器传递单精度浮点数,YMM 传递双精度浮点数,ZMM 传递 128 位值。
第四个参数之后的所有参数都在堆栈上传递,因此寄存器使用无关紧要,但是 float
不能直接传递到第四个参数之外——它们必须通过引用传递(传递值的指针而不是实际值)。
如果函数 foo
以以下 C++ 代码调用
int hr = foo ( “Hello”, “world!”, 3.14f, 95 );
“Hello
”将由 RCX 指向,“world!
”将由 RDX 指向,3.14
将包含在 XMM2 中(它是第三个参数),整数值 95
将在 R9
中。
我已经深入撰写了关于 64 位调用约定的文章,许多其他人也一样。我关于这个主题的文章在 CodeProject 上,链接是 梦魇在榆树街:64 位调用约定。
对于 32 位代码,参数在堆栈上传递。被调用的函数返回时会清除它们,因此不需要清理。请记住以与显示相反的顺序传递参数。调用 foo
的 32 位代码,如上面 C++ 代码所示,可能如下所示(任何寄存器都可以工作,因为 foo
只查看堆栈以访问参数数据)
push 95
push pi ; "pi" is a 32-bit real4 variable initialized at 3.14
push offset world_string
push offset hello_string
call foo
结束 DllMain
函数编码完成后,除了关闭 DllMain
之外,几乎没有什么可做的了
ret ; Return from DllMain (for 32-bit code, use ret 12)
DllMain endp ; End procedure
主模块的最后一行对于 64 位代码只是 end
,对于 32 位代码是 end DllMain
。
编译项目
编译代码的批处理文件如下所示
@echo off
rem Set this value to the location of rc.exe under the VC directory; it contains the RC.EXE executable
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;
it contains the LINK.EXE executable
set link_directory="C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\bin
rem Set this directory to the INCLUDE location for ASM source
set asm_source_directory="C:\[your ASM directory]
rem Set this directory to the include path for Windows libraries.
Use c:\dir1;c:\dir2 format for multiple directories.
NOTE THAT THERE CAN BE NO TERMINATING \ IN THE STRING DEFINITION OR THE PATH WILL NOT BE FOUND.
set lib_directory="C:\Program Files (x86)\Windows Kits\10\Lib\10.0.10586.0\um\x64
%rc_directory%\rc.exe" %asm_source_directory%\resource.rc"
%ml_directory%\ml64.exe" /c /Cp /Cx /Fm /FR /W2 /Zd /Zf /Zi /Ta
%asm_source_directory%\your_dll.asm" /I%asm_source_directory% >
%asm_source_directory%\asm_errors.txt"
%link_directory%\link.exe" %asm_source_directory%\your_dll.obj"
/debug /def:%asm_source_directory%\your_dll.def" /entry:DllMain
/manifest:no /machine:x64 /map /dll /out:%asm_source_directory%\your_dll.dll" /pdb:
%asm_source_directory%\your_dll.pdb" /subsystem:windows,6.0 /LIBPATH:%lib_directory%"
user32.lib kernel32.lib
rem <-----> use /debug for debug symbols
<----------> use /machine:x86 for 32-bit code>
rem use /debug:none for
release version
rem copy *.dll [wherever you want the .DLL and .LIB files to copy to]"
type %asm_source_directory%\asm_errors.txt"
任何两个开发人员之间的目录设置都有很多差异,因此必须运用常识才能使批处理文件正常运行。其中的内容是直截了当的,不应该引起任何问题。
结论
本文旨在指导您完成为可能需要它的极端情况创建全汇编 .DLL 模块的过程。除了这里介绍的内容,其余的只是像您在 ASM 中通常那样创建函数。
在需求量大的情况下使用 ASM,与仅限于内联汇编或内部函数相比,可以为开发团队节省数千小时的累积工时——当然,这取决于具体情况。例如,在当今世界,编译本身只需要几秒钟就能完成,却需要几个小时被认为是正常的。创建 ASM 编码的 .DLL 模块开启了整个语言的强大功能。如果您有 Visual Studio,可以使用 ml64.exe 和 C++ 链接器;如果您使用其他语言,则可以使用其链接器和在线提供的任意数量的替代汇编器。当然,如果您使用 Microsoft 之外的汇编器,则必须根据该汇编器的具体情况调整此处显示的代码和数据片段。
只需按照本文的指导创建一个 .DLL 项目,就足以让任何开发人员熟悉该过程,从而说明这项任务实际上是多么相对简单。
在现实世界中,有些情况需要特殊处理。适当地掌握在需要时将 ASM 应用于给定任务的能力,可以为许多开发人员和最终用户节省大量时间。实事求是地评估情况,Windows 已经因机器特定依赖项、操作系统依赖项以及对依赖项的依赖项而变得迟缓,每天都有数不胜数的上述所有版本,因此在您的项目中添加汇编语言 .DLL 应该没有任何不妥。