Akkuman 的博客

习技术,不积跬步无以至千里,不积小流无以成江海

win32 汇编学习 (2):消息框

这一次,我们将用汇编语言写一个 Windows 程序,程序运行时将弹出一个消息框并显示 "你好,我的第一个 Win32 汇编程序"。

理论知识

Windows 为编写应用程序提供了大量的资源。其中最重要的是 Windows API (Application Programming Interface)。 Windows API 是一大组功能强大的函数,它们本身驻扎在 Windows 中供人们随时调用。这些函数的大部分被包含在几个动态链接库 (DLL) 中,譬如:kernel32.dll、 user32.dll 和 gdi32.dll。 Kernel32.dll 中的函数主要处理内存管理和进程调度;user32.dll 中的函数主要控制用户界面;gdi32.dll 中的函数则负责图形方面的操作。除了上面主要的三个动态链接库,您还可以调用包含在其他动态链接库中的函数,当然您必须要有关于这些函数的足够的资料。

动态链接库,顾名思义,这些 API 的代码本身并不包含在 Windows 可执行文件中,而是当要使用时才被加载。为了让应用程序在运行时能找到这些函数,就必须事先把有关的重定位信息嵌入到应用程序的可执行文件中。这些信息存在于引入库中,由链接器把相关信息从引入库中找出插入到可执行文件中。您必须指定正确的引入库,因为只有正确的引入库才会有正确的重定位信息。

当应用程序被加载时 Windows 会检查这些信息,这些信息包括动态链接库的名字和其中被调用的函数的名字。若检查到这样的信息,Windows 就会加载相应的动态链接库,并且重定位调用的函数语句的入口地址,以便在调用函数时控制权能转移到函数内部。

如果从和字符集的相关性来分,API 共有两类:一类是处理 ANSI 字符集的,另一类是处理 UNICODE 字符集的。前一类函数名字的尾部带一个 "A" 字符,处理 UNICODE 的则带一个 "W" 字符 (宽字符)。我们比较熟悉的 ANSI 字符串是以 0 (NULL) 结尾的一串字符数组,每一个 ANSI 字符是一个 BYTE 宽。对于欧洲语言体系,ANSI 字符集已足够了,但对于有成千上万个唯一字符的几种象形语言体系来说就只有用 UNICODE 字符集了。每一个 UNICODE 字符占有两个 BYTE 宽,这样一来就可以在一个字符串中使用 65336 个不同字符了。

这也是为什么引进 UNICODE 的原因。在大多数情况下我们都可以包含一个头文件,在其中定义一个宏,然后在实际调用函数时,函数名后不需要加后缀 "A" 或 "W"。
如在头文件中定义函数foo()

#ifdef UNICODE
#define foo() fooW()
#else
#define foo() fooA()
#endif

例子

我先把程序框架放在下面,然后我们再向里面加东西。

.386
.model flat, stdcall
.data
.code
start:
end start

应用程序的执行是从 END 定义的标识符后的第一条语句开始的。在上面的框架程序中就是从 START 开始。程序逐条语句执行一直到遇到 JMP,JNE,JE,RET 等跳转指令。这些跳转指令将把执行权转移到其他语句上,若程序要退出 Windows,则必须调用函数 ExitProcess。

ExitProcess proto uExitCode:DWORD

上面一行是函数原型。函数原型会告诉编译器和链接器该函数的属性,这样在编译和链接时,编译器和链接器就会作相关的类型检查。 函数的原型定义如下:

FunctionName PROTO [ParameterName]:DataType,[ParameterName]:DataType,...

简言之,就是在函数名后加伪指令 PROTO,再跟一串由逗号相隔的数据类型链表。在前面的 ExitProcess 定义中,该函数有一个 DWORD 类型的参数。当您使用高层调用语句 INVOKE 时,使用函数原型定义特别有用,您可以简单地认为 INVOKE 是一个有参数类型检查的调用语句。譬如,假设您这样写:

call ExitProcess

若您事先没把一个 DWORD 类型参数压入堆栈,编译器和链接器都不会报错,但毫无疑问,在您的程序运行时将引起崩溃。但是,当您这样写:

invoke ExitProcess

连接器将报错提醒您忘记压入一个 DWORD 类型参数。所以我建议您用 INVOKE 指令而不是 CALL 去调用一个函数。INVOKE 的语法如下:

INVOKE expression [,arguments]

expression 既可以是一个函数名也可以是一个函数指针。参数由逗号隔开。大多数 API 函数的原型放在头文件中。 如果您用的是 MASM32,这些头文件在文件夹 MASM32/include 下, 这些头文件的扩展名为 INC,函数名和 DLL 中的函数名相同,譬如:KERNEL32.LIB 引出的函数 ExitProcess 的函数原形声明于 kernel.inc 中。您也可以自己声明函数原型。

好,我们现在回到 ExitProcess 函数,参数 uExitCode 是您希望当您的应用程序结束时传递 Windows 的。 您可以这样写:

invoke ExitProcess,0

把这一行放到start标识符下,这个应用程序就会立即退出 Windows,当然毫无疑问个应用程序本身是一个完整的 Windows 程序。

IDE 为 Visual MASM,masm32 安装在 c:\masm32

386
.model flat, stdcall
option casemap:none

include c:\masm32\include\windows.inc
include c:\masm32\include\kernel32.inc
includelib c:\masm32\lib\kernel32.lib

.data
.code
start:
invoke ExitProcess,0
end start

option casemap:none 一句的意思是告诉 MASM 要区分标号的大小写,譬如:start 和 START 是不同的。请注意新的伪指令 include,跟在其后的文件名所指定的文件在编译时将 “插” 在该处。在我们上面的程序段中,当 MASM 处理到语句 include c:\masm\include\windows.inc 时,它就会打开文件夹 c:\masm32\include 中的文件 windows.inc,这和您把整个文件都粘贴到您的源程序中的效果是一样的。 windows.inc 包含了 WIN32 编程所需要的常量和结构体的定义。 但是它不包含函数原型的定义。

您的应用程序除了从 windows.inc 中得到相关变量结构体的定义外,还需要从其他的头文件中得到函数原型的声明,这些头文件都放在 c:\masm32\include 文件夹中。 在我们上面的例子中调用了 kernel.dll 中的函数,所以需要包含有这个函数原型声明的头文件 kernel.inc。如果用文本编辑器打开该文件您会发现里面全是从 kernel.dll 中引出的函数的声明。如果您不包含 kernel.inc,您仍然可以调用(call)ExitProcess,但不能够调用(invoke)ExitProcess(这会无法通过编译器和连接器的参数合法性检查)。所以若用 invoke 去调用一个函数,您就必须事先声明包含头文件,您完全可以在调用该函数前在源代码的适当位置进行声名。包含头文件主要是为了节省时间(当然还有正确性)

接下来我们来看看 includelib 伪指令,和 include 不同,它仅仅是告诉编译器您的程序引用了哪个库。当编译器处理到该指令时会在生成的目标文件中插入链接命令告诉链接器链入什么库。当然您还可以通过在链接器的命令行指定引入库名称的方法来达到和用 includelib 指令相同的目的,但考虑到命令行仅能够传递 128 个字符而且要不厌其烦地在命令行敲字符,所以这种方法是非常不可取的。

命令行编译

好了,现在保存例子,取名为 msgbox.asm。把 ml.exe(C:\masm32\bin) 的路径放到 PATH 环境变量中,键入下面一行 进行编译:

ml /c /coff /Cp msgbox.asm
  • /c 是告诉 MASM 只编译不链接。这主要是考虑到在链接前您可能还有其他工作要做。
  • /coff 告诉 MASM 产生的目标文件用 coff 格式。MASM 的 coff 格式是 COFF(Common Object File Format:通用目标文件格式) 格式的一种变体。在 UNIX 下的 COFF 格式又有不同。
  • /Cp 告诉 MASM 不要更改用户定义的标识符的大小写。在. model 指令下加入 "option casemap:none" 语句,可达到同样的效果。
    当您成功的编译了 msgbox.asm 后,编译器会产生 msgbox.obj 目标文件,目标文件和可执行文件只一步之遥,目标文件中包含了以二进制形式存在的指令和数据,比可执行文件相差的只是链接器加入的重定位信息。

好,我们来链接目标文件:

link /SUBSYSTEM:WINDOWS /LIBPATH:c:\masm32\lib msgbox.obj
  • /SUBSYSTEM:WINDOWS 告诉链接器可执行文件的运行平台
  • /LIBPATH:〈path to import library〉 告诉链接器引入库的路径。
    链接器做的工作就是根据引入库往目标文件中加入重定位信息,最后产生可执行文件。 既然得到了可执行文件,我们来运行一下。好,一、二、三,GO!屏幕上什么都没有。哦,对了,我们除了调用了 ExitProcess 函数外,什么都还没做呢!但是别一点成就感都没有哦,因为我们用汇编所写的是一个真正 Windows 程序,不信的话,看看您磁盘上的 msgbox.exe 文件。

下面我们来做一点可以看的见摸的着的,我们在程序中加入一个对话框。该函数的原型如下:

MessageBox PROTO hwnd:DWORD, lpText:DWORD, lpCaption:DWORD, uType:DWORD
  • hWnd 是父窗口的句柄。句柄代表您引用的窗口的一个地址指针。它的值对您编 Windows 程序并不重要(译者注:如果您想成为高手则是必须的),您只要知道它代表一个窗口。当您要对窗口做任何操作时,必须要引用该窗口的指针。
  • lpText 是指向您要显示的文本的指针。指向文本串的指针事实上就是文本串的首地址。
  • lpCaption 是指向您要显示的对话框的标题文本串指针。
  • uType 是显示在对话框窗口上的小图标的类型。

下面是源程序

.386 
.model flat,stdcall 
option casemap:none 
include \masm32\include\windows.inc 
include \masm32\include\kernel32.inc 
includelib \masm32\lib\kernel32.lib 
include \masm32\include\user32.inc 
includelib \masm32\lib\user32.lib 

.data 
MsgBoxCaption db "可爱的标题",0
MsgBoxText    db "你好,我的第一个Win32汇编程序",0

.code 
start: 
invoke MessageBox, NULL, addr MsgBoxText, addr MsgBoxCaption, MB_OK 
invoke ExitProcess, NULL 
end start

编译、链接上面的程序段,得到可执行文件。运行,哈哈,窗口上弹出了一个对话框,上面有一行字:“你好,我的第一个 Win32 汇编程序”。

好,我们回过头来看看上面的源代码。我们在. DATA“分段” 定义了两个 NULL 结尾的字符串。我们用了两个常量:NULLMB_OK。这些常量在 windows.inc 文件中有定义,使用常量使得您的程序有较好的可读性。 addr 操作符用来把标号的地址传递给被调用的函数,它只能用在 invoke 语句中,譬如您不能用它来把标号的地址赋给寄存器或变量,如果想这样做则要用 offset 操作符。在 offsetaddr 之间有如下区别:

addr不可以处理向前引用,offset则能。所谓向前引用是指:标号的定义是在invoke 语句之后,譬如在如下的例子:

invoke MessageBox,NULL, addr MsgBoxText,addr MsgBoxCaption,MB_OK

...... 

MsgBoxCaption db "可爱的标题",0
MsgBoxText db "你好,我的第一个Win32汇编程序",0

如果您是用 addr 而不是 offset 的话,那 MASM 就会报错。

addr可以处理局部变量而 offset 则不能。局部变量只是在运行时在堆栈中分配内存空间。而 offset 则是在编译时由编译器解释,这显然不能用 offset 在运行时来分配内存空间。编译器对 addr 的处理是先检查处理的是全局还是局部变量,若是全局变量则把其地址放到目标文件中,这一点和 offset 相同,若是局部变量,就在执行 invoke 语句前产生如下指令序列:

lea eax, LocalVar
push eax

因为lea指令能够在运行时决定标号的有��地址,所以有了上述指令序列,就可以保证 invoke 的正确执行了。

更方便的编译选择:Visual MASM

新建一个 asm 后缀文件,用 Visual MASM 打开,把上面的代码复制进去,点击左上角的 Run 即可,如图所示。

留下你的脚步